Artifact 8c6f66283b1eed0c155fd9cbce71b25f47990e7671862514cac013c4355e4c23:
- Executable file
scripts/tarball-fossil-diff.sh
— part of check-in
[96a6d46d19]
at
2026-08-17 15:32:05
on branch trunk
— tarball: check zit's archives against fossil's, modes included (#95)
Comparing zit to itself cannot catch a wrong permission bit -- writer and reader agree in the same direction. Fossil is an independent witness that already gets it right, so the harness unpacks both archives and compares member set, modes, and contents. It found one: -R was dropped at dispatch, which every test missed by running inside a checkout. Fossil versions symlinks where zit add still refuses them, so an imported corpus is also what finally exercises the l-perm path. (user: dmestas, size: 8956) [annotate] [blame] [check-ins using]
#!/bin/sh # `fossil tarball` vs `zit tarball`, as a differential harness (#95). # # The target invariant: for one check-in, the two tools produce archives that # UNPACK TO THE SAME TREE — same member set, same contents, and same modes. # # Modes are the reason this exists rather than a self-comparison. An archive # whose members are all 0644 looks perfectly correct — right files, right # bytes, right hashes — until someone unpacks a release and `./configure` will # not run. Nothing zit can check against itself catches that, because the # writer and the reader would agree in the same wrong direction; fossil is an # independent witness that already gets it right. # # What it compares, in the order a failure is worth reading: # 1. member sets -- a file present in one archive and not the other # 2. modes -- regular / executable / symlink, per member # 3. contents -- sha256 per member, so a truncated blob is named # # The archives are NOT compared byte for byte, deliberately: fossil and zit # make different, equally legal tar choices (fossil emits directory members, # the header flavours differ), and demanding identical bytes would fail on # differences no consumer can observe. Unpacking is what a consumer does. # # Usage: scripts/tarball-fossil-diff.sh ORIGIN.fossil [VERSION] [WORKDIR] # # VERSION defaults to the origin's trunk tip, named by its full artifact id so # both tools resolve the identical check-in. WORKDIR defaults to a fresh # mktemp directory, left behind on failure; removed on success unless KEEP=1. # # Requires: a built ./zig-out/bin/zit, and `fossil` and `python3` on PATH. set -eu usage() { echo "usage: $0 ORIGIN.fossil [VERSION] [WORKDIR]" >&2 exit 2 } [ $# -ge 1 ] || usage ORIGIN=$1 [ -f "$ORIGIN" ] || { echo "$0: no such repository: $ORIGIN" >&2; exit 2; } ORIGIN=$(cd "$(dirname "$ORIGIN")" && pwd)/$(basename "$ORIGIN") VERSION=${2:-} ROOT=$(cd "$(dirname "$0")/.." && pwd) ZIT=$ROOT/zig-out/bin/zit [ -x "$ZIT" ] || { echo "$0: build zit first (zig build): $ZIT" >&2; exit 2; } command -v fossil >/dev/null || { echo "$0: fossil is not on PATH" >&2; exit 2; } command -v python3 >/dev/null || { echo "$0: python3 is not on PATH" >&2; exit 2; } WORK=${3:-$(mktemp -d)} mkdir -p "$WORK" WORK=$(cd "$WORK" && pwd) # The same isolation the round-trip harness needs, for the same reason: this # must not register throwaway clones in the operator's real ~/.fossil, and a # leaked server from an earlier run must not lock this one out. FOSSIL_HOME=$WORK export FOSSIL_HOME port_free() { python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()' } SERVER_PID= cleanup() { [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null return 0 } trap cleanup EXIT INT TERM fail() { echo >&2 echo "TARBALL DIFF FAILED: $1" >&2 echo " work directory kept for examination: $WORK" >&2 exit 1 } step() { echo "== $*"; } # The top-level directory both tools are told to use. Fixed, so a difference # in the two default-derivation rules cannot masquerade as a member-set # difference — that rule is zit's own choice and is not what this compares. TOP=diff-top # --- 1. pick the check-in -------------------------------------------------- step "reading the origin" if [ -z "$VERSION" ]; then # The trunk tip by FULL artifact id: a symbolic name could in principle # resolve differently in the two tools, and then a real difference and a # naming difference would be indistinguishable. VERSION=$(fossil sqlite3 --readonly -R "$ORIGIN" \ "select blob.uuid from event join blob on blob.rid=event.objid where event.type='ci' order by event.mtime desc limit 1;") fi [ -n "$VERSION" ] || fail "the origin has no check-in to archive" echo " check-in $VERSION" # --- 2. ORIGIN -> zit ------------------------------------------------------ step "serving the origin with stock fossil, cloning it into zit" PORT=$(port_free) fossil server "$ORIGIN" --port "$PORT" > "$WORK/fossil-serve.log" 2>&1 & SERVER_PID=$! sleep 2 rm -rf "$WORK/zit" "$ZIT" clone "http://127.0.0.1:$PORT/" "$WORK/zit" > "$WORK/zit-clone.log" 2>&1 \ || fail "zit clone of the origin failed (see $WORK/zit-clone.log)" # Reap it: an unwaited server keeps the global config database locked, and the # next fossil command dies with "database is locked" while nothing was wrong. kill "$SERVER_PID" 2>/dev/null; wait "$SERVER_PID" 2>/dev/null || :; SERVER_PID= # --- 3. both tarballs ------------------------------------------------------ step "writing both tarballs" fossil tarball -R "$ORIGIN" --name "$TOP" "$VERSION" "$WORK/fossil.tar.gz" \ > "$WORK/fossil-tarball.log" 2>&1 \ || fail "fossil tarball failed (see $WORK/fossil-tarball.log)" "$ZIT" tarball -R "$WORK/zit" "$VERSION" "$WORK/zit.tar.gz" --name "$TOP" \ > "$WORK/zit-tarball.log" 2>&1 \ || fail "zit tarball failed (see $WORK/zit-tarball.log)" echo " fossil $(wc -c < "$WORK/fossil.tar.gz" | tr -d ' ') bytes," \ "zit $(wc -c < "$WORK/zit.tar.gz" | tr -d ' ') bytes" # --- 4. unpack and describe ------------------------------------------------ step "unpacking both and describing every member" for side in fossil zit; do rm -rf "$WORK/x-$side" mkdir -p "$WORK/x-$side" tar -xzf "$WORK/$side.tar.gz" -C "$WORK/x-$side" \ || fail "could not unpack the $side archive" [ -d "$WORK/x-$side/$TOP" ] \ || fail "the $side archive has no top-level directory '$TOP'" done # One description per member: path, kind, executable bit, content hash. # Directories are skipped rather than compared: fossil emits directory members # and zit does not, which is a legal difference in what the archive SAYS and no # difference at all in the tree it produces. describe() { python3 - "$1" <<'PY' import hashlib, os, stat, sys root = sys.argv[1] rows = [] for dirpath, dirnames, filenames in os.walk(root): dirnames.sort() for name in sorted(filenames): full = os.path.join(dirpath, name) rel = os.path.relpath(full, root) st = os.lstat(full) if stat.S_ISLNK(st.st_mode): payload = os.readlink(full).encode() kind = "symlink" else: with open(full, "rb") as fh: payload = fh.read() kind = "exec" if st.st_mode & 0o111 else "file" rows.append("%s\t%s\t%s" % (rel, kind, hashlib.sha256(payload).hexdigest())) # os.walk does not descend into symlinked directories, and a symlink to a # directory lands in dirnames rather than filenames -- catch those too. for dirpath, dirnames, filenames in os.walk(root): for name in sorted(dirnames): full = os.path.join(dirpath, name) if os.path.islink(full): rel = os.path.relpath(full, root) payload = os.readlink(full).encode() rows.append("%s\tsymlink\t%s" % (rel, hashlib.sha256(payload).hexdigest())) for row in sorted(rows): print(row) PY } describe "$WORK/x-fossil/$TOP" > "$WORK/fossil.members" describe "$WORK/x-zit/$TOP" > "$WORK/zit.members" echo " fossil $(wc -l < "$WORK/fossil.members" | tr -d ' ') members," \ "zit $(wc -l < "$WORK/zit.members" | tr -d ' ') members" # --- 5. compare ------------------------------------------------------------ # One comparator rather than a pipeline of join and awk. The three categories # are reported separately on purpose: "same bytes, wrong bit" is a different # bug from "wrong bytes", and rolling them together hides which one happened. # # The shell-native spelling of this needs process substitution, which is a # bashism -- this script is #!/bin/sh, and `<(...)` fails at PARSE time, so a # harness written that way dies before it compares anything. step "comparing member sets, modes, and contents" status=0 python3 "$ROOT/scripts/archive-compare.py" "$WORK/fossil.members" "$WORK/zit.members" >&2 || status=1 # --- 6. zit's own extra promise ------------------------------------------- # Reproducibility is zit's, not fossil's -- fossil stamps the archive with the # current time and its tarballs differ run to run. Checked here anyway because # this harness already has a real corpus and a built binary, which is exactly # what the property needs and what a unit test cannot supply. step "zit reproducibility" "$ZIT" tarball -R "$WORK/zit" "$VERSION" "$WORK/zit2.tar.gz" --name "$TOP" \ > "$WORK/zit-tarball2.log" 2>&1 \ || fail "the second zit tarball failed (see $WORK/zit-tarball2.log)" if cmp -s "$WORK/zit.tar.gz" "$WORK/zit2.tar.gz"; then echo " two runs are byte-identical" else echo " DIFFER: two runs of one check-in produced different bytes" >&2 status=1 fi echo if [ "$status" -eq 0 ]; then echo "TARBALL DIFF CLEAN" if [ "${KEEP:-0}" = "1" ]; then echo " work directory kept: $WORK" else rm -rf "$WORK" fi exit 0 fi fail "see the differences above"