Artifact 6fe3bc5d3e61268929182e61e9008576459405542977191dbe08ebf00adaa03f:
- Executable file
scripts/archive-fossil-diff.sh
— part of check-in
[2420f6fb88]
at
2026-08-17 19:28:02
on branch trunk
— archive differential: unpack zips without an unzip binary (#95)
The runners have no unzip, so the harness exited 2 for a missing tool and the step failed on its own coverage guard instead. Adding a third tool to the battery for one step is the worse trade: python3 is already required here, and ZipFile.extractall drops modes and symlinks -- the exact defect this differential exists to catch -- so the external attributes are applied explicitly rather than trusted to an extractor. (user: dmestas, size: 11194) [annotate] [blame] [check-ins using]
#!/bin/sh # `fossil tarball`/`zip` vs `zit tarball`/`zip`, 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/archive-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 "ARCHIVE 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 archives, in both formats ------------------------------------- # zip is compared too, and separately: the two writers share the member set and # the selection but nothing of the container, so a defect in one says nothing # about the other. zip is where the mode hides one field deeper -- in the # central directory's external attributes rather than a typeflag byte. step "writing tarballs and zips with both tools" 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)" fossil zip -R "$ORIGIN" --name "$TOP" "$VERSION" "$WORK/fossil.zip" \ > "$WORK/fossil-zip.log" 2>&1 \ || fail "fossil zip failed (see $WORK/fossil-zip.log)" "$ZIT" zip -R "$WORK/zit" "$VERSION" "$WORK/zit.zip" --name "$TOP" \ > "$WORK/zit-zip.log" 2>&1 \ || fail "zit zip failed (see $WORK/zit-zip.log)" echo " tar: fossil $(wc -c < "$WORK/fossil.tar.gz" | tr -d ' ')," \ "zit $(wc -c < "$WORK/zit.tar.gz" | tr -d ' ') bytes" echo " zip: fossil $(wc -c < "$WORK/fossil.zip" | tr -d ' ')," \ "zit $(wc -c < "$WORK/zit.zip" | tr -d ' ') bytes" # --- 4. unpack and describe ------------------------------------------------ step "unpacking all four and describing every member" for side in fossil zit; do rm -rf "$WORK/x-$side" "$WORK/z-$side" mkdir -p "$WORK/x-$side" "$WORK/z-$side" tar -xzf "$WORK/$side.tar.gz" -C "$WORK/x-$side" \ || fail "could not unpack the $side tarball" # Unpacked with python3 rather than `unzip`, which is not present on every # runner -- and adding a third tool dependency to the battery for one step # is a worse trade than doing the extraction here. CPython's `zipfile` # parses the container independently of the writer under test, which is the # property that matters; what it does NOT do is restore modes or recreate # symlinks, so the external attributes are applied explicitly below. python3 "$ROOT/scripts/unzip-preserving-modes.py" "$WORK/$side.zip" "$WORK/z-$side" \ > "$WORK/unzip-$side.log" 2>&1 \ || fail "could not unpack the $side zip (see $WORK/unzip-$side.log)" [ -d "$WORK/x-$side/$TOP" ] \ || fail "the $side tarball has no top-level directory '$TOP'" [ -d "$WORK/z-$side/$TOP" ] \ || fail "the $side zip 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" describe "$WORK/z-fossil/$TOP" > "$WORK/fossil.zip.members" describe "$WORK/z-zit/$TOP" > "$WORK/zit.zip.members" echo " tar: fossil $(wc -l < "$WORK/fossil.members" | tr -d ' ')," \ "zit $(wc -l < "$WORK/zit.members" | tr -d ' ') members" echo " zip: fossil $(wc -l < "$WORK/fossil.zip.members" | tr -d ' ')," \ "zit $(wc -l < "$WORK/zit.zip.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. status=0 step "tar: comparing member sets, modes, and contents" python3 "$ROOT/scripts/archive-compare.py" "$WORK/fossil.members" "$WORK/zit.members" >&2 || status=1 step "zip: comparing member sets, modes, and contents" python3 "$ROOT/scripts/archive-compare.py" "$WORK/fossil.zip.members" "$WORK/zit.zip.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)" "$ZIT" zip -R "$WORK/zit" "$VERSION" "$WORK/zit2.zip" --name "$TOP" \ > "$WORK/zit-zip2.log" 2>&1 \ || fail "the second zit zip failed (see $WORK/zit-zip2.log)" for fmt in tar.gz zip; do if cmp -s "$WORK/zit.$fmt" "$WORK/zit2.$fmt"; then echo " $fmt: two runs are byte-identical" else echo " $fmt DIFFER: two runs of one check-in produced different bytes" >&2 status=1 fi done echo if [ "$status" -eq 0 ]; then echo "ARCHIVE 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"