#! /usr/local/bin/ruby
#
#  pdumpfs 0.6 - a daily backup system similar to Plan9's dumpfs.
#
#  DESCRIPTION:
#
#    pdumpfs is a simple daily backup system similar to
#    Plan9's dumpfs which preserves every daily snapshot.
#    You can access the past snapshots at any time for
#    retrieving a certain day's file.  Let's backup your home
#    directory with pdumpfs!
#
#    pdumpfs constructs the snapshot YYYY/MM/DD in the
#    destination directory. All source files are copied to
#    the snapshot directory for the first time. On and after
#    the second time, pdumpfs copies only updated or newly
#    created files and stores unchanged files as hard links
#    to the files of the previous day's snapshot for saving a
#    disk space.
#
#  USAGE:
#
#    % pdumpfs <source directory> <destination directory>
#             [<destination basename>]
#
#  SAMPLE CRONTAB ENTRY:
# 
#    00 05 * * * pdumpfs /home/USER /backup >/dev/null 2>&1
#
#  BUGS: 
#
#    pdumpfs can handle only normal files, directories, and
#    symbolic links.
#
# 
# $Id: pdumpfs,v 1.37 2002/08/06 08:51:06 satoru Exp $
#
# Copyright (C) 2001 Satoru Takabayashi <satoru@namazu.org> 
#     All rights reserved.
#     This is free software with ABSOLUTELY NO WARRANTY.
#
# You can redistribute it and/or modify it under the terms of 
# the GNU General Public License version 2.
#

require 'find'
require 'date'
require 'ftools'

def usage
  puts "Usage: pdumpfs <source directory> <destination directory>"+
       " [destination basename]"
  exit 1
end

def nodir(dir)
  puts "No directory: " + dir
  exit 1
end

def same_file? (f1, f2)
  File.symlink?(f1) == false && File.symlink?(f2) == false && 
    File.file?(f1) && File.file?(f2) && 
    File.size(f1) == File.size(f2) && File.mtime(f1) == File.mtime(f2)
end

def parse_options
  usage if ARGV[0] == nil || ARGV[1] == nil
  nodir ARGV[0] if File.directory?(ARGV[0]) == false
  nodir ARGV[1] if File.directory?(ARGV[1]) == false
  return ARGV
end

def datedir(date)
  sprintf "%d/%02d/%02d", date.year, date.month, date.day
end

def latest_snapshot(src, dest, base)
  for i in 1 .. 31  # allow at most 31 days absence
    x = File.join dest, datedir(Date.today - i), base
    return x if File.directory?(x)
  end
  nil
end

# incomplete substitute for cp -p 
def copy(src, dest)
  stat = File.stat(src)
  File.copy src, dest
  File.utime(stat.atime, stat.mtime, dest)
  File.chmod(stat.mode, dest) # not necessary. just to make sure
end

def update_file(s, l, t)
  type = "unsupported"
  if File.symlink?(s) == false && File.directory?(s)
    type = "directory"
    File.mkpath t
  else
    if File.symlink?(l) == false && File.file?(l)
      if same_file?(s, l)
	type = "unchanged"
	File.link l, t
      else
	type = "updated"
	copy s, t
      end
    else
      case File.ftype(s)
      when "file"
	type = "new file"
	copy s, t
      when "link"
	type = "symlink"
	File.symlink(File.readlink(s), t)
      end
    end
  end 
  if Process.uid == 0 && type != "unsupported"
    if type == "symlink"
      if File.respond_to? 'lchown'
        stat = File.lstat(s)
        File.lchown(stat.uid, stat.gid, t)
      end
    else
      stat = File.stat(s)
      File.chown(stat.uid, stat.gid, t)
    end
  end
  printf "%-10s %s\n", type, s
end

def restore_dir_attributes(dirs)
  dirs.each {|dir, stat|
    File.utime(stat.atime, stat.mtime, dir)
    File.chmod(stat.mode, dir)
  }
end

def update_snapshot(src, latest, today)
  dirs = {};
  Find.find(src) do |s|      # path of the source file
    r = s.sub "^#{Regexp.quote src}/?", ""  # relative path
    l = File.join latest, r  # path of the latest  snapshot
    t = File.join today, r   # path of the today's snapshot

    begin
      update_file(s, l, t)
    rescue Errno::ENOENT => error
      STDERR.puts error.message
      next
    rescue => error
      STDERR.puts error.message
    end

    if File.ftype(s) == "directory"
      dirs[t] = File.stat(s)
    end
  end

  restore_dir_attributes(dirs)
end

# incomplete substitute for cp -rp
def recursive_copy(src, dest)
  dirs = {};
  Find.find(src) do |s|
    r = s.sub "^#{Regexp.quote src}/?", ""
    t = File.join dest, r

    begin
      case File.ftype(s)
      when "directory"
	File.mkpath t
      when "file"
	copy s, t
      when "link"
	File.symlink(File.readlink(s), t)
      end
      if Process.uid == 0
        if File.ftype(s) == "link"
          if File.respond_to? 'lchown'
            stat = File.lstat(s)
            File.lchown(stat.uid, stat.gid, t)
          end
        else
          stat = File.stat(s)
          File.chown(stat.uid, stat.gid, t)
        end
      end
    rescue Errno::ENOENT => error
      STDERR.puts error.message
      next
    rescue => error
      STDERR.puts error.message
    end

    if File.ftype(s) == "directory"
      dirs[t] = File.stat(s)
    end
  end
  restore_dir_attributes(dirs)
end

def main
  src, dest, base = parse_options
  base = File.basename(src) unless base

  latest = latest_snapshot(src, dest, base)
  today  = File.join(dest, datedir(Date.today), base)

  File.umask(0077)
  File.mkpath(today)
  if latest
    update_snapshot(src, latest, today)
  else
    recursive_copy(src, today)
  end
end

main if __FILE__ == $0
