ディレクトリ階層だけをコピーするときは、既存rootへmkdir -pを繰り返してはいけません。新規stageへsourceの相対directory名だけをNUL-safeに作成し、regular fileが0件であることを検証してから新規targetへatomicに採用します。
rootの不存在を開始条件にする
sourceとtargetは別rootに固定し、targetが空に見えても存在すれば停止します。directory名は改行を含められるため、一覧はNUL区切りで比較します。4ブロックは同じBashセッションで実行します。
set -Eeuo pipefail
source=/srv/lab/source
target=/srv/lab/structure-copy
parent=$(dirname -- "$target")
test -d "$source" && test ! -L "$source"
test -d "$parent" && test ! -L "$parent"
test ! -e "$target" || { printf 'STOP: structure-copy root exists\n' >&2; exit 20; }
command -v python3 >/dev/null
directory_manifest() {
( cd -- "$1"; find . -xdev -type d -printf '%P\0' | sort -z ) > "$2"
}
run-owned stageへ一度だけ階層を作る
targetの親にmktempで作った空stageだけへmkdirします。mkdir -pはstage内部で親を補う目的に限定され、既存destinationへのsilent mergeには使いません。
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-117.$run_id.XXXXXXXX")
printf '%s\n' "$run_id" > "$work/.owner"
stage="$work/stage"
mkdir -- "$stage"
directory_manifest "$source" "$work/source.directories"
while IFS= read -r -d '' relative; do
test -z "$relative" && continue
mkdir -p -- "$stage/$relative"
done < "$work/source.directories"
directory_manifest "$stage" "$work/stage.directories"
cmp --silent "$work/source.directories" "$work/stage.directories"
test "$(find "$stage" -xdev -type f -printf x | wc -c)" -eq 0
test "$(find "$stage" -xdev ! -type d -printf x | wc -c)" -eq 0
directory一覧とfile 0件を検証して採用する
sourceとstageのdirectory listがbyte一致し、stageに非directoryが0件であることを確認します。採用はRENAME_NOREPLACEで行い、同名targetが途中で出現しても上書きしません。成功後はreceiptを残し、owner tokenが一致するworkだけを同一filesystemのretention pathへno-clobberで移します。破棄は保存期間を確認する別手順です。
atomic_noreplace "$stage" "$target"
test ! -e "$stage" && test -d "$target"
directory_manifest "$target" "$work/target.directories"
cmp --silent "$work/source.directories" "$work/target.directories"
test "$(find "$target" -xdev -type f -printf x | wc -c)" -eq 0
test "$(find "$target" -xdev ! -type d -printf x | wc -c)" -eq 0
directory_sha=$(sha256sum -- "$work/target.directories"); directory_sha=${directory_sha%% *}; directory_sha=${directory_sha#\\}
receipt="$parent/.ittrip-117-$run_id.receipt"
( umask 077; set -o noclobber; printf 'target=%s\ndirectory_sha=%s\nrun_id=%s\n' "$target" "$directory_sha" "$run_id" > "$receipt" )
test "$(cat -- "$work/.owner")" = "$run_id"
retained_work="$work.completed-$run_id"
test ! -e "$retained_work"
atomic_noreplace "$work" "$retained_work"
printf 'run-owned evidence retained=%s; disposal requires a separate approved retention procedure\n' "$retained_work"
printf 'accepted directories_sha=%s regular_files=0 non_directories=0\n' "$directory_sha"
run-owned rootだけを復旧する
復旧時はreceiptのtarget・run_id・directory hashと、現在も非directory 0件であることを再確認します。第三の状態なら停止し、完全一致する今回のrootだけをquarantineへ移します。
# このrunが作った空階層だけをquarantineし、開始前のtarget不存在へ戻す。
test -f "$receipt" && test ! -L "$receipt"
grep -Fx "target=$target" "$receipt" >/dev/null
grep -Fx "run_id=$run_id" "$receipt" >/dev/null
rollback_manifest=$(mktemp -p "$parent" ".ittrip-117-rollback.$run_id.XXXXXXXX")
directory_manifest "$target" "$rollback_manifest"
rollback_sha=$(sha256sum -- "$rollback_manifest"); rollback_sha=${rollback_sha%% *}; rollback_sha=${rollback_sha#\\}
rm -- "$rollback_manifest"
grep -Fx "directory_sha=$rollback_sha" "$receipt" >/dev/null
test "$(find "$target" -xdev ! -type d -printf x | wc -c)" -eq 0
quarantine="$target.review-$run_id"
test ! -e "$quarantine"
atomic_noreplace "$target" "$quarantine"
test ! -e "$target" && test -d "$quarantine"
rm -- "$receipt"
受入条件
既存target sentinelでは変更前に終了し、sentinel hashが不変であることを確認します。clean runではsourceとtargetのNUL-safe directory listが一致し、regular file 0、symlink 0、その他の非directory 0です。復旧後はtarget不存在、quarantine側の同じdirectory hashを確認します。
metadataを別要件にする
この例は階層名だけを複製します。mode、owner、ACL、xattrも必要なら別の承認fieldとしてmanifestへ追加してください。targetをsource配下へ置くと探索中に自己増殖するため、必ず別rootを使います。
公式情報・参考資料
ディレクトリ階層だけのコピーの構文と制約は、本文末の一次資料と対象環境のlocal helpで照合します。ディレクトリ階層だけのコピーの記事確認日は2026年7月17日で、版が異なる場合はoption、default、終了statusの差を先に確認してください。

コメント