Rubyで重複ファイルを検出する

SHA-256を使った重複ファイル検出スクリプトと、削除前に確認すべき実装上の注意点

17-04-2016

このRubyスクリプトは、ディレクトリ内のファイルを読み込み、SHA-256ダイジェストを比較して内容が同じファイルを探します。-fを指定しない場合は削除前に確認し、指定すると確認なしで各グループの2件目以降を削除します。

ただし、これはそのまま実行するための安全なツールではなく、古い試作コードです。ディレクトリや空ファイルなど、ハッシュを計算しないパスもnilというキーに登録されるため、誤ったグループ化や削除エラーにつながる可能性があります。さらに--forceは実際にファイルを削除します。実データに使う前にこの問題を修正し、削除対象の一覧を確認するドライランを追加し、バックアップ上でテストしてください。

require 'find'
require 'digest/sha2'
require 'optparse'
require 'set'

# optparse
args = { :dir => nil , :del => false }
arguments = OptionParser.new do |arg|

  arg.banner = "Usage: #{__FILE__} [options]"

  arg.on("-d", "--dir DIRECTORY", "directory to find duplicates") do |dir|
    args[:dir] = dir
  end

  arg.on("-f", "--force", "remove without prompting.") do
    args[:del] = true
  end

  arg.on("-h", "--help", "print help message") do
    puts arguments
    exit 0
  end
end

arguments.parse!
exit 1 if args[:dir] == nil

# digests
hashes = Hash.new
puts "Building digests ..."

Find.find(args[:dir]) do |path|
  STDOUT.flush
  hash = nil

  if (not FileTest.directory?(path) and not FileTest.symlink?(path)\
      and not FileTest.blockdev?(path) and not FileTest.chardev?(path)\
      and not FileTest.socket?(path) and not FileTest.pipe?(path)\
      and not FileTest.zero?(path)) then

    begin
      digest = Digest::SHA2.new
      File.open(path, 'r') do |fd|
        while buffer = fd.read(9216)
          digest.update(buffer)
        end
      end
      hash = digest.hexdigest

    rescue NoMemoryError
      puts "NoMemoryError on #{path}"
      GC.start
    rescue IOError
      puts "IOError on #{path}"
      GC.start
    rescue Errno::ENOSYS; next
    rescue Errno::EACCES; next
    rescue Errno::EINVAL; next
    rescue => e
      puts "Error on #{path}, exception #{e}"
      exit 255
    end
  end

  if (hashes[hash]) then
    hashes[hash].add(path)
  else
    hashes[hash] = Set.new.add(path)
  end
end

# results
hashes.each_pair do |hash, paths|
  files = paths.to_a
  choice = nil

  if (files.size > 1) then
    puts "Duplicate digest <#{hash}> for files:\n  #{files.join("\n  ")}"

    if not args[:del] then
      puts "  Delete? [y/N] "
      choice = gets.chomp == 'y' ? true : false
    end

    if args[:del] or choice then
      for i in 1...files.size
        File.delete files[i]
      end
    end

  end
end