Skip to content
zit Artifact [919462f5c2]
Login

Artifact [919462f5c2]

Artifact 919462f5c2432098a295fe53c7ff112cd6c6320cf04441609379790ff039f268:


#!/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
    # Print the named log, do not just point at it. This harness runs on CI
    # machines nobody can log into, and a failure that says "see $WORK/x.log"
    # is unactionable there -- which is exactly how a fossil-version
    # difference cost a full round trip to diagnose.
    if [ $# -ge 2 ] && [ -s "$2" ]; then
        echo "  --- $2 ---" >&2
        sed 's/^/  /' "$2" | head -40 >&2
        echo "  --- end ---" >&2
    fi
    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" "$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 "$VERSION" "$WORK/fossil.tar.gz" --name "$TOP" -R "$ORIGIN" \
    > "$WORK/fossil-tarball.log" 2>&1 \
    || fail "fossil tarball failed" "$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" "$WORK/zit-tarball.log"
fossil zip "$VERSION" "$WORK/fossil.zip" --name "$TOP" -R "$ORIGIN" \
    > "$WORK/fossil-zip.log" 2>&1 \
    || fail "fossil zip failed" "$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" "$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" "$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" "$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" "$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"