サイズの大きいファイルをコピーした後で、ハッシュ値を比較したら、コピー元とコピー先が一致しなかったとする。ファイル全体をコピーし直すと、時間がかかる。壊れている範囲が一部だけなら、cmpで差分のあるバイト範囲を特定して、ddでその範囲だけを書き直せる。

$ cmp src.bin dst.bin
src.bin dst.bin differ: char 5242891, line 20492
$ dd if=src.bin of=dst.bin bs=1048576 skip=5 seek=5 count=1 conv=notrunc

1つ目のコマンドで、最初に異なるバイトの位置を確認する。2つ目のコマンドで、1MiB単位のブロックを指定して、コピー元からコピー先へ書き込む。bs=1048576は1MiBである。実行例は、20MiBのファイルの2か所を壊したもの。macOS 27.0.1で実行した。

参考: 【shasum】コピー前後のファイルをSHA-256で比較して検証する (-a 256)

cmp -lで差分のある位置をすべて表示する

cmpは、オプションなしでは、最初に異なる位置だけを表示して終了する。-lを付けると、異なるバイトをすべて表示する。

$ cmp -l src.bin dst.bin | head -4
5242891 152 130
5242892 121 131
5242893 273 132
13631489 305   0

各行は、バイトの位置(1から数える10進数)と、コピー元とコピー先のバイトの値(8進数)である。壊れた範囲が広いと、行数も多くなる。2MiBを書き換えたファイルでは、約200万行になった。

$ cmp -l src.bin dst.bin | wc -l
2088974

ファイルに保存せず、awkにパイプして、範囲に集約する。

差分のあるブロックの範囲を求める

バイトの位置を1MiB(1048576バイト)で割ると、ブロックの番号が得られる。awkで、連続するブロックを1つの範囲にまとめる。

$ cmp -l src.bin dst.bin | awk -v bs=1048576 '
  { b = int(($1 - 1) / bs)
    if (NR == 1) { s = b; e = b; next }
    if (b <= e + 1) { e = b } else { print s, e; s = b; e = b } }
  END { if (NR > 0) print s, e }'
5 5
13 14

ブロック5と、ブロック13から14に差分がある。位置5242891は、5242890 ÷ 1048576の商が5なので、ブロック5に含まれる。

ddで範囲だけを書き直す

ddのskipは、入力の読み込みを始めるブロック番号で、seekは、出力の書き込みを始めるブロック番号である。同じ番号を指定して、同じ位置に書き込む。countは、ブロックの数である。

$ dd if=src.bin of=dst.bin bs=1048576 skip=5 seek=5 count=1 conv=notrunc
$ dd if=src.bin of=dst.bin bs=1048576 skip=13 seek=13 count=2 conv=notrunc

書き直した後で、cmpで比較する。差分がなければ、何も出力されず、終了コードは0である。

$ cmp src.bin dst.bin && echo SAME
SAME

conv=notruncを付ける

conv=notruncは、書き込み前に出力ファイルを切り詰めない指定である。付けないと、ddは出力ファイルを書き込んだ位置で切り詰める。

$ cp src.bin t.bin
$ dd if=src.bin of=t.bin bs=1048576 skip=5 seek=5 count=1 2>/dev/null
$ wc -c < t.bin
6291456
$ cp src.bin t.bin
$ dd if=src.bin of=t.bin bs=1048576 skip=5 seek=5 count=1 conv=notrunc 2>/dev/null
$ wc -c < t.bin
20971520

conv=notruncなしでは、ファイルのサイズが6291456バイト(6MiB)に縮んだ。conv=notruncありでは、元のサイズの20971520バイトが保たれた。コピー先のファイルを書き直すときは、必ず付ける。

ブロックサイズは数値で指定する

ブロックサイズにbs=1mと書くと、macOSの/bin/ddでは動作するが、GNU coreutilsのddではinvalid numberで失敗する。

$ dd if=src.bin of=/dev/null bs=1m count=1
dd: invalid number: '1m'
$ dd if=src.bin of=/dev/null bs=1M count=1
1+0 records in
1+0 records out

bs=1Mは、どちらでも動作する。macOSのHomebrewでcoreutilsをインストールして、gnubinをPATHに追加している場合は、ddがGNU版になる。type -a ddで、どのddが実行されるか確認できる。

$ type -a dd
dd is /opt/homebrew/opt/coreutils/libexec/gnubin/dd
dd is /bin/dd

bs=1048576のように数値で書けば、どちらのddでも動作する。

スクリプトにまとめる

範囲の特定から書き直しまでを、1つのスクリプトにまとめる。

#!/bin/bash
# usage: rewrite-diff-ranges.sh <src> <dst>
# rewrites only the 1 MiB blocks of <dst> that differ from <src> (same size files only)
src=$1
dst=$2
bs=1048576

cmp -l "$src" "$dst" |
  awk -v bs="$bs" '
    { b = int(($1 - 1) / bs)
      if (NR == 1) { s = b; e = b; next }
      if (b <= e + 1) { e = b } else { print s, e; s = b; e = b } }
    END { if (NR > 0) print s, e }' |
  while read -r s e; do
    echo "rewrite blocks $s-$e"
    dd if="$src" of="$dst" bs="$bs" skip="$s" seek="$s" count=$((e - s + 1)) conv=notrunc 2>&1 | tail -1
  done

cmp "$src" "$dst" && echo SAME
$ ./rewrite-diff-ranges.sh src.bin dst.bin
rewrite blocks 5-5
1048576 bytes (1.0 MB, 1.0 MiB) copied, 0.000385 s, 2.7 GB/s
rewrite blocks 13-14
2097152 bytes (2.1 MB, 2.0 MiB) copied, 0.000497 s, 4.2 GB/s
SAME

差分のないファイルに実行すると、書き込みはされずにSAMEだけが表示される。実行例はmacOSで確認した。Ubuntu 24.04でも、同じ結果になった。

注意点

  • コピー元とコピー先のサイズが同じファイルに限る。サイズが異なるファイルは、cmpがEOF on ...を出力する。cpなどで全体をコピーし直す
  • cmp -lは、差分の有無にかかわらず、2つのファイルを最後まで読み込む。ネットワーク越しのファイルでは、読み込みに時間がかかる
  • 書き直した後は、ハッシュ値の比較で、ファイル全体が一致したことを確認する