release: restore ZUPT and harden source-only 5.2.2

This commit is contained in:
Cristian Cezar Moisés 2026-08-31 14:14:36 -03:00
commit ff99770bd0
205 changed files with 19627 additions and 13215 deletions

View file

@ -0,0 +1,413 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -Eeuo pipefail
bin=${1:-./zupt}
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
case "$bin" in
/*) ;;
*) bin="$(pwd -P)/${bin#./}" ;;
esac
surgery="$repo_root/tests/archive_surgery.py"
tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-atomic-output.XXXXXX")
trap 'rm -rf "$tmp"' EXIT
fail() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
command -v python3 >/dev/null 2>&1 || fail 'python3 is required'
assert_no_temps() {
if find "$tmp" -name '.zupt-archive-*' -print -quit | grep -q .; then
fail 'private archive temporary was not removed'
fi
}
printf 'archive payload\n' > "$tmp/input.txt"
printf 'victim must remain unchanged\n' > "$tmp/victim.txt"
cp "$tmp/victim.txt" "$tmp/victim.expected"
# Writers must never create an archive that their own extraction policy would
# reject. A parent component in the user-supplied input name fails before any
# output is published.
mkdir "$tmp/parent-input-work"
printf 'parent input\n' > "$tmp/parent-input.txt"
if (cd "$tmp/parent-input-work" &&
MSYS2_ARG_CONV_EXCL='../parent-input.txt' \
"$bin" compress -s parent-path.zupt ../parent-input.txt \
>/dev/null 2>&1); then
fail 'compression accepted an unsafe parent-component archive name'
fi
test ! -e "$tmp/parent-input-work/parent-path.zupt" ||
fail 'unsafe parent-component input published an archive'
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) ;;
*)
mkdir -p "$tmp/collision/in/foo"
printf 'literal backslash\n' > "$tmp/collision/in/foo\\bar"
printf 'nested separator\n' > "$tmp/collision/in/foo/bar"
if "$bin" compress -s "$tmp/collision.zupt" \
"$tmp/collision/in" >/dev/null 2>&1; then
fail 'compression accepted colliding slash/backslash destinations'
fi
test ! -e "$tmp/collision.zupt" ||
fail 'colliding archive paths published an archive'
mkdir "$tmp/case-collision"
printf 'upper\n' > "$tmp/case-collision/Name.txt"
printf 'lower\n' > "$tmp/case-collision/name.txt"
if [[ $(find "$tmp/case-collision" -type f | wc -l) -eq 2 ]]; then
if "$bin" compress -s "$tmp/case-collision.zupt" \
"$tmp/case-collision" >/dev/null 2>&1; then
fail 'compression accepted ASCII case-colliding destinations'
fi
test ! -e "$tmp/case-collision.zupt" ||
fail 'case-colliding archive paths published an archive'
fi
;;
esac
# Normal compression must not publish an archive over any spelling or link
# alias of an input file. --force does not bypass this data-loss boundary.
mkdir "$tmp/self-input"
printf 'self input must survive\n' > "$tmp/self-input/self.zupt"
cp "$tmp/self-input/self.zupt" "$tmp/self-input.expected"
if "$bin" compress -s "$tmp/self-input/./self.zupt" \
"$tmp/self-input/self.zupt" >/dev/null 2>&1; then
fail 'compression accepted an alternate spelling of its input as output'
fi
if "$bin" compress --solid -s "$tmp/self-input/./self.zupt" \
"$tmp/self-input/self.zupt" >/dev/null 2>&1; then
fail 'solid compression accepted an alternate spelling of its input as output'
fi
cmp "$tmp/self-input.expected" "$tmp/self-input/self.zupt" ||
fail 'alternate-spelling self compression changed its input'
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*)
# Native Windows publication uses handle-relative APIs and rejects
# reparse-point ancestors. Exercise the portable guarantees here;
# POSIX symlink, hardlink, ulimit and raw-device cases are reported as
# skipped instead of imposing contradictory MSYS semantics on the PE.
printf 'existing Windows output\n' > "$tmp/windows-output.zupt"
"$bin" compress -s "$tmp/windows-output.zupt" "$tmp/input.txt" \
>/dev/null 2>&1 || fail 'Windows archive replacement failed'
"$bin" test "$tmp/windows-output.zupt" >/dev/null 2>&1 ||
fail 'Windows atomically published archive is invalid'
mkdir "$tmp/windows-directory.zupt"
if "$bin" compress -s "$tmp/windows-directory.zupt" \
"$tmp/input.txt" >/dev/null 2>&1; then
fail 'Windows directory destination was replaced'
fi
dd if=/dev/urandom of="$tmp/windows-disk.img" bs=65536 count=2 \
2>/dev/null
"$bin" disk backup -s -b 65536 "$tmp/windows-disk.zupt" \
"$tmp/windows-disk.img" >/dev/null 2>&1 ||
fail 'Windows disk backup failed'
"$bin" test "$tmp/windows-disk.zupt" >/dev/null 2>&1 ||
fail 'Windows disk archive is invalid'
mkdir "$tmp/windows-disk-extracted"
"$bin" extract -o "$tmp/windows-disk-extracted" \
"$tmp/windows-disk.zupt" >/dev/null 2>&1 ||
fail 'Windows disk archive generic extraction failed'
cmp "$tmp/windows-disk.img" \
"$tmp/windows-disk-extracted/windows-disk.img" ||
fail 'Windows disk archive generic extraction mismatch'
"$bin" disk restore "$tmp/windows-disk.zupt" \
"$tmp/windows-restored.img" >/dev/null 2>&1 ||
fail 'Windows disk restore failed'
cmp "$tmp/windows-disk.img" "$tmp/windows-restored.img" ||
fail 'Windows disk restore mismatch'
python3 "$surgery" flip-payload "$tmp/windows-disk.zupt" \
"$tmp/windows-disk-corrupt.zupt" --kind data ||
fail 'could not corrupt Windows disk archive fixture'
printf 'Windows restore sentinel\n' > "$tmp/windows-restore-target"
cp "$tmp/windows-restore-target" "$tmp/windows-restore.expected"
if "$bin" disk restore "$tmp/windows-disk-corrupt.zupt" \
"$tmp/windows-restore-target" >/dev/null 2>&1; then
fail 'Windows disk restore accepted corrupt DATA'
fi
cmp "$tmp/windows-restore.expected" "$tmp/windows-restore-target" ||
fail 'Windows corrupt disk restore changed its target'
assert_no_temps
printf 'SKIP: POSIX symlink, hardlink, ulimit and raw-device atomic cases\n'
printf 'atomic archive output Windows subset: PASS\n'
exit 0
;;
esac
printf 'hardlinked input must survive\n' > "$tmp/self-hard-input"
cp "$tmp/self-hard-input" "$tmp/self-hard.expected"
ln "$tmp/self-hard-input" "$tmp/self-hard-output.zupt"
if "$bin" compress -y -s "$tmp/self-hard-output.zupt" \
"$tmp/self-hard-input" >/dev/null 2>&1; then
fail 'compression accepted a hardlink alias of its input as output'
fi
if "$bin" compress --solid -y -s "$tmp/self-hard-output.zupt" \
"$tmp/self-hard-input" >/dev/null 2>&1; then
fail 'solid compression accepted a hardlink alias of its input as output'
fi
test "$tmp/self-hard-input" -ef "$tmp/self-hard-output.zupt" ||
fail 'rejected compression hardlink alias was replaced'
cmp "$tmp/self-hard.expected" "$tmp/self-hard-input" ||
fail 'hardlink-alias compression changed its input'
printf 'symlinked input must survive\n' > "$tmp/self-symlink-input"
cp "$tmp/self-symlink-input" "$tmp/self-symlink.expected"
ln -s self-symlink-input "$tmp/self-symlink-output.zupt"
if "$bin" compress -s "$tmp/self-symlink-output.zupt" \
"$tmp/self-symlink-input" >/dev/null 2>&1; then
fail 'compression accepted a symlink alias of its input as output'
fi
if "$bin" compress --solid -s "$tmp/self-symlink-output.zupt" \
"$tmp/self-symlink-input" >/dev/null 2>&1; then
fail 'solid compression accepted a symlink alias of its input as output'
fi
test -L "$tmp/self-symlink-output.zupt" ||
fail 'rejected compression symlink alias was replaced'
cmp "$tmp/self-symlink.expected" "$tmp/self-symlink-input" ||
fail 'symlink-alias compression changed its input'
# Replacing the output entry must not open or truncate its symlink target.
ln -s victim.txt "$tmp/symlink.zupt"
"$bin" compress -s "$tmp/symlink.zupt" "$tmp/input.txt" >/dev/null 2>&1
cmp "$tmp/victim.expected" "$tmp/victim.txt" || fail 'symlink target changed'
test ! -L "$tmp/symlink.zupt" || fail 'archive remained a symlink'
"$bin" test "$tmp/symlink.zupt" >/dev/null 2>&1 || fail 'published archive is invalid'
assert_no_temps
# The same directory-entry replacement rule protects another name linked to
# the old inode. The victim keeps its bytes while the output gets a new inode.
printf 'hardlink victim\n' > "$tmp/hard-victim"
cp "$tmp/hard-victim" "$tmp/hard.expected"
ln "$tmp/hard-victim" "$tmp/hardlink.zupt"
"$bin" compress --solid -s "$tmp/hardlink.zupt" "$tmp/input.txt" >/dev/null 2>&1
cmp "$tmp/hard.expected" "$tmp/hard-victim" || fail 'hardlink peer changed'
if test "$tmp/hard-victim" -ef "$tmp/hardlink.zupt"; then
fail 'archive reused victim inode'
fi
"$bin" test "$tmp/hardlink.zupt" >/dev/null 2>&1 || fail 'solid archive is invalid'
assert_no_temps
# A symlink explicitly present in the user-selected POSIX parent is resolved
# once, then the physical directory is pinned for the entire publication.
mkdir "$tmp/real-parent"
ln -s real-parent "$tmp/parent-link"
"$bin" compress -s "$tmp/parent-link/through-link.zupt" \
"$tmp/input.txt" >/dev/null 2>&1 || fail 'symlinked parent was unusable'
"$bin" test "$tmp/real-parent/through-link.zupt" >/dev/null 2>&1 ||
fail 'archive through resolved parent is invalid'
assert_no_temps
# A directory at the final name cannot be replaced. The publication failure
# must remove the private temporary and leave the old directory untouched.
mkdir "$tmp/final-is-directory.zupt"
printf 'directory sentinel\n' > "$tmp/final-is-directory.zupt/sentinel"
if "$bin" compress -s "$tmp/final-is-directory.zupt" \
"$tmp/input.txt" >/dev/null 2>&1; then
fail 'directory destination was replaced'
fi
grep -qx 'directory sentinel' "$tmp/final-is-directory.zupt/sentinel" ||
fail 'directory destination changed after failed publication'
assert_no_temps
# Force a write/fsync failure after the temporary has been opened. A prior
# destination must survive byte-for-byte and no partial archive may appear.
head -c 16384 /dev/urandom > "$tmp/large-input.bin"
printf 'previous archive sentinel\n' > "$tmp/write-failure.zupt"
cp "$tmp/write-failure.zupt" "$tmp/write-failure.expected"
if (trap '' XFSZ; ulimit -f 1; "$bin" compress -s \
"$tmp/write-failure.zupt" "$tmp/large-input.bin" \
>/dev/null 2>&1); then
fail 'forced write failure unexpectedly succeeded'
fi
cmp "$tmp/write-failure.expected" "$tmp/write-failure.zupt" ||
fail 'prior archive changed after write failure'
assert_no_temps
# Two publishers may race for the same directory entry. Each builds a private
# complete archive; whichever rename wins must leave a valid final archive.
"$bin" compress -s "$tmp/concurrent.zupt" "$tmp/input.txt" \
>/dev/null 2>&1 &
first_pid=$!
"$bin" compress --solid -s "$tmp/concurrent.zupt" "$tmp/input.txt" \
>/dev/null 2>&1 &
second_pid=$!
wait "$first_pid" || fail 'first concurrent publisher failed'
wait "$second_pid" || fail 'second concurrent publisher failed'
"$bin" test "$tmp/concurrent.zupt" >/dev/null 2>&1 ||
fail 'concurrent final archive is invalid'
assert_no_temps
# Disk-image backup uses the same atomic publisher.
printf 'disk image bytes\n' > "$tmp/disk.img"
# A disk backup must never replace its only source name with the archive. The
# identity check covers direct spelling, hardlink aliases, and symlink aliases.
cp "$tmp/disk.img" "$tmp/disk-same.img"
cp "$tmp/disk-same.img" "$tmp/disk-same.expected"
if "$bin" disk backup -s "$tmp/disk-same.img" "$tmp/disk-same.img" \
>/dev/null 2>&1; then
fail 'disk backup accepted the same source and output path'
fi
cmp "$tmp/disk-same.expected" "$tmp/disk-same.img" ||
fail 'same-path disk backup changed its source'
cp "$tmp/disk.img" "$tmp/disk-hardlink-source"
cp "$tmp/disk-hardlink-source" "$tmp/disk-hardlink.expected"
ln "$tmp/disk-hardlink-source" "$tmp/disk-hardlink-output.zupt"
if "$bin" disk backup -s "$tmp/disk-hardlink-output.zupt" \
"$tmp/disk-hardlink-source" >/dev/null 2>&1; then
fail 'disk backup accepted a hardlink alias of its source'
fi
test "$tmp/disk-hardlink-source" -ef "$tmp/disk-hardlink-output.zupt" ||
fail 'rejected disk hardlink alias was replaced'
cmp "$tmp/disk-hardlink.expected" "$tmp/disk-hardlink-source" ||
fail 'hardlink-alias disk backup changed its source'
cp "$tmp/disk.img" "$tmp/disk-symlink-source"
cp "$tmp/disk-symlink-source" "$tmp/disk-symlink.expected"
ln -s disk-symlink-source "$tmp/disk-symlink-output.zupt"
if "$bin" disk backup -s "$tmp/disk-symlink-output.zupt" \
"$tmp/disk-symlink-source" >/dev/null 2>&1; then
fail 'disk backup accepted a symlink alias of its source'
fi
test -L "$tmp/disk-symlink-output.zupt" ||
fail 'rejected disk symlink alias was replaced'
cmp "$tmp/disk-symlink.expected" "$tmp/disk-symlink-source" ||
fail 'symlink-alias disk backup changed its source'
assert_no_temps
printf 'disk victim\n' > "$tmp/disk-victim"
cp "$tmp/disk-victim" "$tmp/disk.expected"
ln -s disk-victim "$tmp/disk.zupt"
"$bin" disk backup -s "$tmp/disk.zupt" "$tmp/disk.img" >/dev/null 2>&1
cmp "$tmp/disk.expected" "$tmp/disk-victim" || fail 'disk backup followed symlink'
test ! -L "$tmp/disk.zupt" || fail 'disk archive remained a symlink'
"$bin" disk restore "$tmp/disk.zupt" "$tmp/disk-restored.img" \
>/dev/null 2>&1 || fail 'disk archive could not be restored'
cmp "$tmp/disk.img" "$tmp/disk-restored.img" || fail 'disk restore mismatch'
"$bin" test "$tmp/disk.zupt" >/dev/null 2>&1 || fail 'disk archive test failed'
"$bin" list "$tmp/disk.zupt" >/dev/null 2>&1 || fail 'disk archive list failed'
mkdir "$tmp/disk-extracted"
"$bin" extract -o "$tmp/disk-extracted" "$tmp/disk.zupt" \
>/dev/null 2>&1 || fail 'absolute-source disk archive generic extraction failed'
cmp "$tmp/disk.img" "$tmp/disk-extracted/disk.img" ||
fail 'absolute-source disk archive generic extraction mismatch'
assert_no_temps
# Restore must fail closed if it cannot create its private source snapshot;
# it may not fall back to validating and consuming a mutable pathname.
printf 'not a directory\n' > "$tmp/not-a-snapshot-directory"
printf 'snapshot failure target\n' > "$tmp/snapshot-failure-target"
cp "$tmp/snapshot-failure-target" "$tmp/snapshot-failure.expected"
if ZUPT_TMPDIR="$tmp/not-a-snapshot-directory" \
"$bin" disk restore "$tmp/disk.zupt" \
"$tmp/snapshot-failure-target" >/dev/null 2>&1; then
fail 'disk restore continued without a private archive snapshot'
fi
cmp "$tmp/snapshot-failure.expected" "$tmp/snapshot-failure-target" ||
fail 'snapshot creation failure changed the restore target'
assert_no_temps
# Restore targets are destructive by nature. A final-component symlink must
# be rejected without following it or replacing it, and its external target
# must remain byte-for-byte unchanged.
printf 'external restore target\n' > "$tmp/restore-symlink-victim"
cp "$tmp/restore-symlink-victim" "$tmp/restore-symlink.expected"
ln -s restore-symlink-victim "$tmp/restore-symlink-target"
if "$bin" disk restore "$tmp/disk.zupt" "$tmp/restore-symlink-target" \
>/dev/null 2>&1; then
fail 'disk restore accepted a symlink target'
fi
test -L "$tmp/restore-symlink-target" ||
fail 'disk restore replaced the rejected symlink'
cmp "$tmp/restore-symlink.expected" "$tmp/restore-symlink-victim" ||
fail 'disk restore changed the symlink target'
# A regular target with st_nlink > 1 must also be rejected. Both directory
# entries must still name the original inode and retain its original bytes.
printf 'multiply linked restore target\n' > "$tmp/restore-hardlink-peer"
cp "$tmp/restore-hardlink-peer" "$tmp/restore-hardlink.expected"
ln "$tmp/restore-hardlink-peer" "$tmp/restore-hardlink-target"
if "$bin" disk restore "$tmp/disk.zupt" "$tmp/restore-hardlink-target" \
>/dev/null 2>&1; then
fail 'disk restore accepted a multiply-linked target'
fi
test "$tmp/restore-hardlink-peer" -ef "$tmp/restore-hardlink-target" ||
fail 'disk restore replaced the rejected hardlink entry'
cmp "$tmp/restore-hardlink.expected" "$tmp/restore-hardlink-peer" ||
fail 'disk restore changed the hardlink peer'
cmp "$tmp/restore-hardlink.expected" "$tmp/restore-hardlink-target" ||
fail 'disk restore changed the multiply-linked target'
# A target that is another hardlink to the archive itself is rejected before
# opening either inode for writing. The archive must remain readable.
cp "$tmp/disk.zupt" "$tmp/same-inode.zupt"
ln "$tmp/same-inode.zupt" "$tmp/same-inode-target"
cp "$tmp/same-inode.zupt" "$tmp/same-inode.expected"
if "$bin" disk restore "$tmp/same-inode.zupt" "$tmp/same-inode-target" \
>/dev/null 2>&1; then
fail 'disk restore accepted its own archive inode as the target'
fi
cmp "$tmp/same-inode.expected" "$tmp/same-inode.zupt" ||
fail 'same-inode restore attempt changed the archive'
test "$tmp/same-inode.zupt" -ef "$tmp/same-inode-target" ||
fail 'same-inode restore attempt replaced one hardlink'
"$bin" test "$tmp/same-inode.zupt" >/dev/null 2>&1 ||
fail 'same-inode restore attempt corrupted the archive'
# Removing the trailing archive-integrity field creates the structurally valid
# legacy framing used by the downgrade attack. Disk restore must reject it by
# default and leave a preexisting regular target untouched.
python3 "$surgery" strip-ait "$tmp/disk.zupt" \
"$tmp/disk-without-ait.zupt" || fail 'could not remove disk archive AIT'
printf 'existing no-AIT restore target\n' > "$tmp/no-ait-restore-target"
cp "$tmp/no-ait-restore-target" "$tmp/no-ait-restore.expected"
if "$bin" disk restore "$tmp/disk-without-ait.zupt" \
"$tmp/no-ait-restore-target" >/dev/null 2>&1; then
fail 'disk restore accepted a no-AIT archive by default'
fi
cmp "$tmp/no-ait-restore.expected" "$tmp/no-ait-restore-target" ||
fail 'no-AIT disk archive changed the existing restore target'
# Late DATA corruption must be discovered before publishing over an existing
# regular target. This specifically guards against open(O_TRUNC)-then-verify
# behavior and partial output left behind after a checksum/authentication
# failure.
python3 "$surgery" flip-payload "$tmp/disk.zupt" \
"$tmp/corrupt-disk.zupt" --kind data ||
fail 'could not construct corrupt disk archive'
printf 'existing regular restore target\n' > "$tmp/restore-existing"
cp "$tmp/restore-existing" "$tmp/restore-existing.expected"
if "$bin" disk restore "$tmp/corrupt-disk.zupt" "$tmp/restore-existing" \
>/dev/null 2>&1; then
fail 'disk restore accepted a corrupt DATA block'
fi
cmp "$tmp/restore-existing.expected" "$tmp/restore-existing" ||
fail 'corrupt archive changed the existing restore target'
assert_no_temps
# Encrypted dedup references carry the original DATA frame AAD sequence and
# authenticate their own logical position; restore must reproduce the bytes.
dd if=/dev/urandom of="$tmp/repeated-block" bs=65536 count=1 2>/dev/null
cp "$tmp/repeated-block" "$tmp/dedup-disk.img"
dd if="$tmp/repeated-block" of="$tmp/dedup-disk.img" bs=65536 seek=1 \
conv=notrunc 2>/dev/null
printf 'atomic-disk-test-password\n' > "$tmp/disk-password"
chmod 600 "$tmp/disk-password"
"$bin" disk backup --dedup -b 65536 --pass-file "$tmp/disk-password" -s \
"$tmp/dedup-encrypted.zupt" "$tmp/dedup-disk.img" >/dev/null 2>&1 ||
fail 'encrypted dedup disk backup failed'
"$bin" test --pass-file "$tmp/disk-password" "$tmp/dedup-encrypted.zupt" \
>/dev/null 2>&1 || fail 'encrypted dedup disk archive test failed'
"$bin" disk restore --pass-file "$tmp/disk-password" \
"$tmp/dedup-encrypted.zupt" "$tmp/dedup-restored.img" >/dev/null 2>&1 ||
fail 'encrypted dedup disk restore failed'
cmp "$tmp/dedup-disk.img" "$tmp/dedup-restored.img" ||
fail 'encrypted dedup disk restore mismatch'
assert_no_temps
printf 'atomic archive output: PASS\n'