ディレクトリ内容の安全なコピーでは、既存の宛先へ重ねず、コピー元の境界と属性を固定してから新規宛先を一度だけ採用します。source/. を明示して最上位のdotfileも含め、targetが存在する場合は内容が空でも停止します。隠し項目、通常ファイル、ディレクトリ、シンボリックリンクをNUL区切りのinventoryとSHA-256で比較し、開始前が「宛先なし」だったことまで復旧条件に含めます。
実行前の境界と停止条件
GNU cp・find・sha256sum・Python 3と、Linuxのrenameat2(RENAME_NOREPLACE)を使用します。宛先が空に見えても既に存在する場合は停止します。これにより既存sentinel、既存dotfile、別処理の作業中ディレクトリへmergeしません。以下の4ブロックは同じ管理用Bashセッションで順に実行します。
set -Eeuo pipefail
source=/srv/lab/source
target=/srv/lab/target
parent=$(dirname -- "$target")
test -d "$source" && test ! -L "$source"
test -d "$parent" && test ! -L "$parent"
test ! -e "$target" || { printf 'STOP: destination already exists: %s\n' "$target" >&2; exit 20; }
command -v python3 >/dev/null
if find "$source" -xdev -mindepth 1 ! -type f ! -type d ! -type l -print -quit | grep -q .; then
printf 'STOP: special file found; design a separate copy policy.\n' >&2
exit 21
fi
tree_manifest() {
local tree=$1 output=$2 marker_name=${3-}
(
cd -- "$tree"
find . -xdev -mindepth 1 -type d ! -name "$marker_name" -printf 'd\t%m\t%U\t%G\t%P\0' | sort -z
find . -xdev -mindepth 1 -type l ! -name "$marker_name" -printf 'l\t%m\t%U\t%G\t%P\t%l\0' | sort -z
find . -xdev -mindepth 1 -type f ! -name "$marker_name" -print0 | sort -z | xargs -0 -r sha256sum --zero --
) > "$output"
}
同一ファイルシステムのステージへコピーする
宛先の親にmktempで作ったrun-owned stageだけを変更します。source/.を使うため、コピー元ディレクトリそのものではなく内容を複製します。inventoryが一致しない限りatomic adoptへ進みません。
atomic_noreplace() {
python3 - "$1" "$2" <<'PY'
import ctypes, os, sys
src, dst = map(os.fsencode, sys.argv[1:])
libc = ctypes.CDLL(None, use_errno=True)
try:
renameat2 = libc.renameat2
except AttributeError:
raise SystemExit("renameat2 is unavailable; no non-atomic fallback is allowed")
renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
if renameat2(-100, src, -100, dst, 1) != 0: # RENAME_NOREPLACE
number = ctypes.get_errno()
raise OSError(number, os.strerror(number), os.fsdecode(dst))
PY
}
run_id=$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM
work=$(mktemp -d -p "$parent" ".ittrip-108.$run_id.XXXXXXXX")
printf '%s\n' "$run_id" > "$work/.owner"
stage="$work/stage"
marker=".ittrip-owner-$run_id"
mkdir -- "$stage"
printf '%s\n' "$run_id" > "$stage/$marker"
tree_manifest "$source" "$work/source.manifest" "$marker"
cp --archive -- "$source/." "$stage/"
tree_manifest "$stage" "$work/stage.manifest" "$marker"
cmp --silent "$work/source.manifest" "$work/stage.manifest"
atomic_noreplace "$stage" "$target"
test ! -e "$stage" && test -f "$target/$marker"
採用後の完全一致を検証する
採用はRENAME_NOREPLACEなので、承認後に同名宛先が出現しても上書きしません。所有markerを除外した比較に合格後、markerを削除し、最終manifest hashとrun receiptを保存します。
tree_manifest "$target" "$work/target.manifest" "$marker"
cmp --silent "$work/source.manifest" "$work/target.manifest"
rm -- "$target/$marker"
tree_manifest "$target" "$work/final.manifest" ''
cmp --silent "$work/source.manifest" "$work/final.manifest"
manifest_sha=$(sha256sum -- "$work/final.manifest"); manifest_sha=${manifest_sha%% *}; manifest_sha=${manifest_sha#\\}
receipt="$parent/.ittrip-108-$run_id.receipt"
( umask 077; set -o noclobber; printf 'target=%s\nmanifest_sha=%s\nrun_id=%s\n' "$target" "$manifest_sha" "$run_id" > "$receipt" )
printf 'accepted target=%s manifest_sha=%s receipt=%s\n' "$target" "$manifest_sha" "$receipt"
今回作成した宛先だけを復旧する
復旧前にreceiptのtarget・run_id・manifest hashを再照合します。第三の状態や後続変更があれば停止し、開始前から存在したパスは決して移動・削除しません。合格時はrun-owned targetをquarantineへatomicに移し、開始前の「宛先なし」を復元します。
# 同じBashセッションで、今回作成したtargetを取り消す場合だけ実行する。
test -f "$receipt" && test ! -L "$receipt"
grep -Fx "target=$target" "$receipt" >/dev/null
grep -Fx "run_id=$run_id" "$receipt" >/dev/null
tree_manifest "$target" "$work/rollback-current.manifest" ''
current_sha=$(sha256sum -- "$work/rollback-current.manifest"); current_sha=${current_sha%% *}; current_sha=${current_sha#\\}
grep -Fx "manifest_sha=$current_sha" "$receipt" >/dev/null
quarantine="$target.failed-$run_id"
test ! -e "$quarantine"
atomic_noreplace "$target" "$quarantine"
test ! -e "$target" && test -d "$quarantine"
rm -- "$receipt"
printf 'restored pre-state: destination is absent; run-owned copy is quarantined at %s\n' "$quarantine"
受入条件
既存宛先のテストでは終了コード20となりsentinelのbytes/hashが不変であること、clean runではdotfileを含むsource・stage・targetのmanifestが一致すること、復旧後はtargetが存在せずquarantineのmanifestが同じであることを確認します。コピー中にsourceが更新される環境は停止し、snapshotまたはアプリ停止を先に設計します。
よくある誤り
cp -r source/* はdotfileを落とします。既存宛先へのcp -aはmergeやoverwriteになるため、この手順では許可しません。別ファイルシステムへstageを作るとrenameがatomicにならないため、必ずtargetの親でmktempします。
公式情報・参考資料
ディレクトリ内容コピーの構文と制約は、本文末の一次資料と対象環境のlocal helpで照合します。ディレクトリ内容コピーの記事確認日は2026年7月17日で、版が異なる場合はoption、default、終了statusの差を先に確認してください。

コメント