Blueforce-Linux/tests/check-offline-repo-static.sh
emirhanmamak-blueforce d6d22c6e56 fix(offline-repo): commit the bundle pool policy and the guard for it
Follow-up to fc4c87c, which committed the pool files themselves. This records
the .gitignore rule that stops excluding built/pool/ and the two assertions that
keep it that way: the suite now fails if either the bundle's pool/ or its index
becomes gitignored, because a cloning device receives nothing but what the
repository tracks.

Verified: check-offline-repo-static.sh 140/140 with the pool committed.
2026-09-16 22:14:27 +03:00

727 lines
46 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# Validate the offline APT repository contract without building a bundle:
# manifest pin semantics, SBOM schema shape, secret hygiene, and the coverage
# guarantees of build-offline-repo.sh --dry-run-plan.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OFFLINE_REPO="$REPO_ROOT/provisioning/offline-repo"
MANIFEST_DIR="$OFFLINE_REPO/manifests"
BUILD="$OFFLINE_REPO/build-offline-repo.sh"
INSTALLER="$REPO_ROOT/scripts/install/blueforce-install.sh"
REQUIREMENTS="$MANIFEST_DIR/requirements.tsv"
LOCK_SCHEMA="$MANIFEST_DIR/packages.lock.schema.tsv"
MANIFESTS=(base-packages.txt remote-access.txt docker.txt)
LOCK_HEADER="$(printf 'package\tversion\tarchitecture\tsha256\tsource')"
FAIL=0
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
fail() { printf 'FAIL: %s\n' "$*" >&2; FAIL=1; }
pass() { printf 'OK: %s\n' "$*"; }
require_file() { [[ -f "$1" ]] && pass "present: ${1#$REPO_ROOT/}" || fail "missing: ${1#$REPO_ROOT/}"; }
require_file "$BUILD"
require_file "$OFFLINE_REPO/README.md"
require_file "$REQUIREMENTS"
require_file "$LOCK_SCHEMA"
for manifest in "${MANIFESTS[@]}"; do require_file "$MANIFEST_DIR/$manifest"; done
# 1. The build script is strict-mode Bash and parses.
[[ "$(head -n 1 "$BUILD")" == '#!/usr/bin/env bash' ]] || fail 'build-offline-repo.sh requires a Bash shebang'
grep -q '^set -euo pipefail$' "$BUILD" || fail 'build-offline-repo.sh requires strict mode'
bash -n "$BUILD" && pass 'build-offline-repo.sh parses' || fail 'build-offline-repo.sh parse error'
grep -q -- '--dry-run-plan' "$BUILD" || fail 'build-offline-repo.sh must offer --dry-run-plan'
grep -q -- '--check' "$BUILD" || fail 'build-offline-repo.sh must keep --check'
grep -q 'apt-ftparchive' "$BUILD" || fail 'the real build must still use apt-ftparchive'
grep -q 'pin still awaits its LAB version' "$BUILD" || fail 'the build must refuse an UNPINNED pin'
# 2. The plan path must not need the build tooling, which lives only on the LAB host.
plan_body="$(awk '/^plan_report\(\) \{/,/^\}/' "$BUILD")"
[[ -n "$plan_body" ]] || fail 'plan_report function not found in build-offline-repo.sh'
if grep -qE 'dpkg-deb[[:space:]]+-' <<<"$plan_body"; then fail 'dry-run-plan must not invoke dpkg-deb'; else pass 'dry-run-plan never invokes dpkg-deb'; fi
if grep -qE 'apt-ftparchive[[:space:]]+(packages|release)' <<<"$plan_body"; then fail 'dry-run-plan must not invoke apt-ftparchive'; else pass 'dry-run-plan never invokes apt-ftparchive'; fi
plan_dispatch="$(grep -n 'PLAN_ONLY" -eq 1' "$BUILD" | head -n 1 | cut -d: -f1)"
tool_guard="$(grep -n "apt-ftparchive is not installed" "$BUILD" | head -n 1 | cut -d: -f1)"
[[ -n "$plan_dispatch" && -n "$tool_guard" && "$plan_dispatch" -lt "$tool_guard" ]] \
&& pass 'plan mode is dispatched before the build tool requirements' \
|| fail 'plan mode must be dispatched before the build tool requirements'
# 3. The contract tooling acquires nothing: it is a local-file-only builder.
if grep -nE '(^|[^A-Za-z])(curl|wget)[[:space:]]+(-|http)|apt(-get)?[[:space:]]+download|https?://' "$BUILD"; then
fail 'build-offline-repo.sh must not download anything'
else
pass 'build-offline-repo.sh has no download primitive'
fi
# 4. manifests/*.txt is the offline installer contract: exactly three pin files.
mapfile -t manifest_txt < <(find "$MANIFEST_DIR" -maxdepth 1 -type f -name '*.txt' -printf '%f\n' | sort)
[[ ${#manifest_txt[@]} -eq 3 ]] || fail "manifests/ must hold exactly 3 *.txt pin files, found ${#manifest_txt[@]}"
for manifest in "${MANIFESTS[@]}"; do
found=0
for actual in "${manifest_txt[@]}"; do [[ "$actual" == "$manifest" ]] && found=1; done
[[ "$found" -eq 1 ]] || fail "manifests/$manifest is missing from the installer contract"
done
[[ -z "$(grep -L 'package=version' "$MANIFEST_DIR"/*.txt || true)" ]] && pass 'every manifest documents the package=version pin format' || fail 'a manifest does not document the package=version pin format'
[[ -z "$(grep -L 'Ubuntu 26.04' "$MANIFEST_DIR"/*.txt || true)" ]] && pass 'every manifest names its target Ubuntu release' || fail 'a manifest does not name its target Ubuntu release'
[[ -z "$(grep -L 'UNPINNED' "$MANIFEST_DIR"/*.txt || true)" ]] && pass 'every manifest explains the UNPINNED sentinel' || fail 'a manifest does not explain the UNPINNED sentinel'
# 5. Pin semantics, independently of the build script.
awk '
/^[[:space:]]*#/ { next }
/^[[:space:]]*$/ { next }
{
if ($0 ~ /[[:space:]]/ || $0 !~ /^[a-z0-9][a-z0-9+.-]*=[^[:space:]]+$/) { printf "INVALID\t%s\t%d\t%s\n", FILENAME, FNR, $0; next }
split($0, kv, "=")
pkg = kv[1]; ver = substr($0, length(pkg) + 2)
if (seen[pkg] != "") printf "DUPLICATE\t%s\t%s\n", pkg, seen[pkg]
else seen[pkg] = FILENAME ":" FNR
pins++
if (ver == "UNPINNED") awaiting++
else filled++
}
END { printf "COUNT\t%d\t%d\t%d\n", pins, filled, awaiting }
' "$MANIFEST_DIR/base-packages.txt" "$MANIFEST_DIR/remote-access.txt" "$MANIFEST_DIR/docker.txt" > "$TMP/pins.txt"
invalid="$(grep -c '^INVALID' "$TMP/pins.txt" || true)"
duplicates="$(grep -c '^DUPLICATE' "$TMP/pins.txt" || true)"
read -r _ pin_count filled_count awaiting_count < <(grep '^COUNT' "$TMP/pins.txt" || printf 'COUNT 0 0 0\n')
[[ "$invalid" -eq 0 ]] && pass 'every manifest line is an exact package=version pin' || { fail "invalid pin lines: $invalid"; grep '^INVALID' "$TMP/pins.txt" >&2; }
[[ "$duplicates" -eq 0 ]] && pass 'no package is pinned twice' || { fail "duplicate pins: $duplicates"; grep '^DUPLICATE' "$TMP/pins.txt" >&2; }
[[ "$pin_count" -gt 0 ]] && pass "manifests declare $pin_count concrete pins" || fail 'manifests declare no pins'
# The audit table and the manifests must describe the same contract.
python_rc=0
python3 - "$MANIFEST_DIR" "$pin_count" <<'PY' || python_rc=$?
import os
import sys
manifest_dir, pin_count = sys.argv[1], int(sys.argv[2])
pins = {}
for name in ('base-packages.txt', 'remote-access.txt', 'docker.txt'):
with open(os.path.join(manifest_dir, name), encoding='utf-8') as handle:
for lineno, raw in enumerate(handle, 1):
line = raw.rstrip('\n')
if not line or line.startswith('#'):
continue
package, _, version = line.partition('=')
pins.setdefault(package, []).append(name)
assert version == 'UNPINNED' or version[0].isdigit() or ':' in version, line
rows = []
with open(os.path.join(manifest_dir, 'requirements.tsv'), encoding='utf-8') as handle:
for lineno, raw in enumerate(handle, 1):
line = raw.rstrip('\n')
if not line or line.startswith('#'):
continue
fields = line.split('\t')
if len(fields) != 4 or not all(fields):
raise SystemExit(f'requirements.tsv:{lineno}: expected four TAB-separated fields')
rows.append((fields[0], fields[1]))
missing = [f'{m}: {p}' for m, p in rows if p not in pins or m not in pins[p]]
rogue = sorted(f'{n}: {p}' for p, names in pins.items() for n in names if (n, p) not in rows)
if len(pins) != pin_count:
raise SystemExit(f'pin parsing disagreement: script={pin_count} python={len(pins)}')
if missing or rogue:
for item in missing:
print(f'MISSING PACKAGE (in requirements.tsv, not in its manifest): {item}')
for item in rogue:
print(f'ROGUE PIN (in a manifest, not in requirements.tsv): {item}')
raise SystemExit(1)
print(f'CHECK ok: {len(rows)} requirement rows, {len(pins)} pinned packages, coverage is complete')
PY
if [[ "$python_rc" -eq 0 ]]; then pass 'requirements.tsv and manifests/*.txt describe the same contract'; else fail 'requirements audit table drifted from the manifests'; fi
# 6. The SBOM schema keeps the exact header the offline installer validates.
schema_rows="$(grep -vcE '^[[:space:]]*(#|$)' "$LOCK_SCHEMA" || true)"
[[ "$schema_rows" -eq 1 ]] || fail "packages.lock.schema.tsv must carry only the header row ($schema_rows uncommented lines)"
[[ "$(grep -vE '^[[:space:]]*(#|$)' "$LOCK_SCHEMA")" == "$LOCK_HEADER" ]] && pass 'lock schema header matches the documented layout' || fail 'lock schema header is wrong'
grep -q 'package version architecture sha256 source' "$LOCK_SCHEMA" || fail 'lock schema header is not TAB-separated'
installer_header="$(grep -o 'package\\tversion\\tarchitecture\\tsha256\\tsource' "$INSTALLER" | head -n 1)"
[[ "${installer_header//\\t/$'\t'}" == "$LOCK_HEADER" ]] \
&& pass 'lock header matches the header blueforce-install.sh validates' \
|| fail 'lock header drifted from blueforce-install.sh'
grep -q 'blueforce-install.sh' "$LOCK_SCHEMA" && pass 'lock schema names its consumer' || fail 'lock schema must name its consumer'
for column in package version architecture sha256 source; do
grep -q "\\b$column\\b" "$LOCK_SCHEMA" || fail "lock schema does not document the $column column"
done
# 7. No secrets, keys, or device identities in the offline repository assets.
#
# The committed built/ index is excluded: it is byte-for-byte apt-ftparchive
# output over third-party packages, and upstream descriptions legitimately
# contain the trigger words as prose (polkit's description ends "...without
# being asked for their password:"), so scanning it reports the packages, not a
# leak. Nothing is hidden by this: any secret that reached a manifest or a
# README would still be caught below, and build-index.sh embeds no credentials
# of its own -- its records are package name/version/sha256/size/source.
if grep -RniE --exclude-dir=.git --exclude-dir=built \
'(BEGIN [A-Z ]*PRIVATE KEY|(password|passwd|token|api[_-]?key|secret)[[:space:]]*[:=]|AKIA[0-9A-Z]{16}|BF-[0-9]{8})' \
"$OFFLINE_REPO"; then
fail 'secret-like or device-specific value found in provisioning/offline-repo'
else
pass 'no secret-like or device-specific value in provisioning/offline-repo'
fi
if grep -RniE --exclude-dir=.git 'https?://[^[:space:]]*:[^[:space:]]*@' "$OFFLINE_REPO"; then
fail 'credential-bearing URL found in provisioning/offline-repo'
else
pass 'no credential-bearing URL in provisioning/offline-repo'
fi
# 8. The plan is read-only and its verdict matches the fill state of the manifests.
manifest_state_before="$(find "$MANIFEST_DIR" -type f -exec sha256sum {} + | sort)"
plan_rc=0
plan_out="$(bash "$BUILD" --dry-run-plan 2>&1)" || plan_rc=$?
manifest_state_after="$(find "$MANIFEST_DIR" -type f -exec sha256sum {} + | sort)"
[[ "$manifest_state_before" == "$manifest_state_after" ]] && pass 'dry-run-plan leaves the manifests untouched' || fail 'dry-run-plan modified the manifests'
grep -q 'apt-ftparchive is not installed' <<<"$plan_out" && fail 'dry-run-plan must not require apt-ftparchive' || pass 'dry-run-plan runs without the LAB build tooling'
grep -q "^SUMMARY: pins=$pin_count filled=$filled_count awaiting=$awaiting_count missing_packages=0 errors=0$" <<<"$plan_out" \
&& pass "dry-run-plan summary matches the manifests (pins=$pin_count awaiting=$awaiting_count)" \
|| { fail 'dry-run-plan summary disagrees with the manifests'; grep '^SUMMARY:' <<<"$plan_out" >&2; }
if [[ "$awaiting_count" -gt 0 ]]; then
[[ "$plan_rc" -eq 1 ]] && pass 'dry-run-plan reports NOT READY while pins are unfilled' || fail "expected exit 1 while $awaiting_count pins await a version, got $plan_rc"
grep -q '^PLAN NOT READY' <<<"$plan_out" && pass 'dry-run-plan states PLAN NOT READY' || fail 'dry-run-plan must state PLAN NOT READY'
grep -q '^MISSING VERSIONS' <<<"$plan_out" && pass 'dry-run-plan lists the missing versions' || fail 'dry-run-plan must list the missing versions'
listed="$(grep -c '=UNPINNED$' <<<"$plan_out" || true)"
[[ "$listed" -eq "$awaiting_count" ]] && pass "dry-run-plan lists all $awaiting_count unfilled pins" || fail "dry-run-plan listed $listed of $awaiting_count unfilled pins"
printf 'INFO: %d pin(s) still await a LAB version; the repo ships no .deb and no bundle.\n' "$awaiting_count"
else
[[ "$plan_rc" -eq 0 ]] && pass 'dry-run-plan reports READY when every pin is filled' || fail "expected exit 0 with all pins filled, got $plan_rc"
grep -q '^PLAN READY' <<<"$plan_out" || fail 'dry-run-plan must state PLAN READY'
fi
plan_rc=0
bash "$BUILD" --dry-run-plan --check >/dev/null 2>&1 || plan_rc=$?
[[ "$plan_rc" -eq 2 ]] && pass 'dry-run-plan refuses to combine with --check' || fail "dry-run-plan --check must exit 2, got $plan_rc"
# 9. Fixtures: the validator must catch each contract violation it advertises.
new_fixture() {
rm -rf "$TMP/m"
mkdir -p "$TMP/m"
for manifest in "${MANIFESTS[@]}"; do cp "$MANIFEST_DIR/$manifest" "$TMP/m/$manifest"; done
cp "$REQUIREMENTS" "$TMP/m/requirements.tsv"
cp "$LOCK_SCHEMA" "$TMP/m/packages.lock.schema.tsv"
}
# Synthetic fixture versions: obviously non-release values used only to exercise
# the ready path. They are never copied into the repository or into a bundle.
# The fixture pool is built from synthetic versions, so the fixture manifests
# must carry those same versions. Rewriting every pin (not only the UNPINNED
# sentinels) keeps the two halves consistent now that the reviewed manifests
# carry real release versions: a pool of 9.9.9-lab-fixture files can never
# satisfy a pin that reads 8.18.0-1ubuntu2.5.
normalise_fixture_versions() {
local manifest src dst line
for manifest in "${MANIFESTS[@]}"; do
src="$TMP/m/$manifest"
dst="$TMP/m/$manifest.filled"
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ^[a-z0-9][a-z0-9+.-]*= ]]; then
printf '%s=9.9.9-lab-fixture\n' "${line%%=*}"
else
printf '%s\n' "$line"
fi
done < "$src" > "$dst"
mv "$dst" "$src"
done
}
fill_fixture() {
local manifest src dst line
for manifest in "${MANIFESTS[@]}"; do
src="$TMP/m/$manifest"
dst="$TMP/m/$manifest.filled"
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" == *=UNPINNED ]]; then
printf '%s\n' "${line%=UNPINNED}=9.9.9-lab-fixture"
else
printf '%s\n' "$line"
fi
done < "$src" > "$dst"
mv "$dst" "$src"
done
}
drop_fixture_line() {
local file="$1" prefix="$2" tmp="$TMP/drop.tmp" line
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" == "$prefix"* ]]; then continue; fi
printf '%s\n' "$line"
done < "$file" > "$tmp"
mv "$tmp" "$file"
}
plan_fixture() {
PLAN_RC=0
PLAN_OUT="$(BF_MANIFEST_DIR="$TMP/m" bash "$BUILD" --dry-run-plan 2>&1)" || PLAN_RC=$?
}
expect_violation() {
local label="$1" pattern="$2"
if [[ "$PLAN_RC" -eq 0 ]]; then fail "$label: validator accepted an invalid contract"; return; fi
if grep -qE "$pattern" <<<"$PLAN_OUT"; then pass "$label: rejected ($(grep -oE "$pattern" <<<"$PLAN_OUT" | head -n 1))"; else
fail "$label: expected diagnostic /$pattern/ not reported"; printf '%s\n' "$PLAN_OUT" >&2
fi
}
new_fixture; normalise_fixture_versions
plan_fixture
if [[ "$PLAN_RC" -eq 0 ]] && grep -q '^PLAN READY' <<<"$PLAN_OUT"; then
pass 'fixture: a fully filled contract reports PLAN READY'
else
fail "fixture: a fully filled contract must report PLAN READY (exit $PLAN_RC)"
printf '%s\n' "$PLAN_OUT" >&2
fi
new_fixture; normalise_fixture_versions
printf 'curl=1.0-lab-fixture\n' >> "$TMP/m/base-packages.txt"
plan_fixture; expect_violation 'fixture: duplicate pin' 'duplicate pin for curl'
new_fixture; normalise_fixture_versions
printf 'openssh-server=1.0-lab-fixture inline note\n' >> "$TMP/m/base-packages.txt"
plan_fixture; expect_violation 'fixture: whitespace in a pin' 'whitespace in a pin'
new_fixture; normalise_fixture_versions
printf 'Curl=1.0-lab-fixture\n' >> "$TMP/m/base-packages.txt"
plan_fixture; expect_violation 'fixture: malformed pin' 'not an exact pin'
new_fixture; normalise_fixture_versions
printf 'wget=placeholder-1.0\n' >> "$TMP/m/base-packages.txt"
plan_fixture; expect_violation 'fixture: placeholder token' 'placeholder token left in pin'
new_fixture; normalise_fixture_versions
printf 'ncdu=1.0-lab-fixture\n' >> "$TMP/m/base-packages.txt"
plan_fixture; expect_violation 'fixture: rogue pin without an audit row' 'absent from requirements.tsv'
new_fixture; normalise_fixture_versions
drop_fixture_line "$TMP/m/remote-access.txt" 'rustdesk='
plan_fixture
expect_violation 'fixture: required package missing' 'MISSING PACKAGES'
grep -q 'rustdesk' <<<"$PLAN_OUT" || fail 'fixture: the missing package must be named in the report'
new_fixture; normalise_fixture_versions
printf 'ncdu=1.0-lab-fixture\n' > "$TMP/m/extra.txt"
plan_fixture; expect_violation 'fixture: unexpected manifests/*.txt' 'unexpected manifests'
new_fixture; normalise_fixture_versions
printf 'curl\t1.0-lab-fixture\tamd64\t%s\tcurl_1.0-lab-fixture_amd64.deb\n' "$(printf 'a%.0s' {1..64})" >> "$TMP/m/packages.lock.schema.tsv"
plan_fixture; expect_violation 'fixture: data row in the lock schema' 'must not contain data rows'
# 10. Real builds and --check stay fail-closed without a reviewed pool.
mkdir -p "$TMP/pool"
build_rc=0
build_out="$(bash "$BUILD" --packages "$TMP/pool" --output "$TMP/out" --check 2>&1)" || build_rc=$?
if [[ "$build_rc" -ne 0 ]]; then pass 'build --check fails closed without a reviewed pool'; else fail 'build --check must not pass without a reviewed pool'; fi
grep -qE 'apt-ftparchive is not installed|no local \.deb files supplied' <<<"$build_out" \
&& pass 'build --check reports why it refused' \
|| { fail 'build --check refusal message is unclear'; printf '%s\n' "$build_out" >&2; }
[[ ! -e "$TMP/out" ]] && pass 'a refused build creates no output directory' || fail 'a refused build must not create output'
build_rc=0
bash "$BUILD" --packages "$TMP/absent-pool" --output "$TMP/out" >/dev/null 2>&1 || build_rc=$?
[[ "$build_rc" -eq 2 ]] && pass 'a missing package directory is a usage error' || fail "missing package directory must exit 2, got $build_rc"
# ------------------------------------------------- repository owner tooling ------
# The packages are committed by the repository owner on an Ubuntu host, so the
# intake and index builders are part of the contract: they must stay free of any
# download primitive, keep the exact lock header, and never write a partial build.
ADD_PKGS="$OFFLINE_REPO/add-packages.sh"
BUILD_INDEX="$OFFLINE_REPO/build-index.sh"
REPO_MANIFEST="$OFFLINE_REPO/repo-manifest.yaml"
require_file "$ADD_PKGS"
require_file "$BUILD_INDEX"
require_file "$REPO_MANIFEST"
for script in "$ADD_PKGS" "$BUILD_INDEX"; do
label="$(basename "$script")"
[[ "$(head -n 1 "$script")" == '#!/usr/bin/env bash' ]] || fail "$label requires a Bash shebang"
grep -q '^set -euo pipefail$' "$script" || fail "$label requires strict mode"
bash -n "$script" && pass "$label parses" || fail "$label has a parse error"
if grep -nE '(^|[^A-Za-z])(curl|wget)[[:space:]]+(-|http|https)|apt(-get)?[[:space:]]+download|https?://' "$script"; then
fail "$label must contain no download primitive"
else
pass "$label has no download primitive"
fi
if grep -nE '(BEGIN [A-Z ]*PRIVATE KEY|(password|passwd|token|api[_-]?key|secret)[[:space:]]*[:=]|AKIA[0-9A-Z]{16})' "$script"; then
fail "$label contains a credential-like pattern"
else
pass "$label carries no credential-like pattern"
fi
done
# add-packages.sh: intake verdicts and the manifest hand-off are mandatory.
grep -q 'dpkg-deb' "$ADD_PKGS" && pass 'add-packages.sh validates with dpkg-deb' || fail 'add-packages.sh must validate with dpkg-deb'
grep -q 'UNPINNED' "$ADD_PKGS" && pass 'add-packages.sh knows the UNPINNED sentinel' || fail 'add-packages.sh must handle the UNPINNED sentinel'
grep -q 'MANIFEST GÜNCELLEMESİ' "$ADD_PKGS" && pass 'add-packages.sh hands the owner the pin lines to fill' || fail 'add-packages.sh must print the manifest lines to fill'
grep -q 'EKLENDİ' "$ADD_PKGS" && pass 'add-packages.sh reports each accepted package' || fail 'add-packages.sh must report what it accepted'
grep -q 'çakışma' "$ADD_PKGS" && pass 'add-packages.sh refuses a second version of a package' || fail 'add-packages.sh must refuse a version conflict'
grep -q 'mimari uyumsuz' "$ADD_PKGS" && pass 'add-packages.sh refuses a foreign architecture' || fail 'add-packages.sh must refuse a foreign architecture'
grep -q 'geçerli bir Debian paketi' "$ADD_PKGS" && pass 'add-packages.sh refuses a file that is not a Debian package' || fail 'add-packages.sh must refuse a non-Debian file'
grep -q -- '-name .\*.deb.' "$ADD_PKGS" && pass 'add-packages.sh only admits *.deb candidates' || fail 'add-packages.sh must filter on the .deb extension'
if grep -qE '^[^#]*manifests/\*\.txt.*>[[:space:]]*"' "$ADD_PKGS" || grep -qE '>[[:space:]]*"\$MANIFEST' "$ADD_PKGS"; then
fail 'add-packages.sh must never rewrite manifests/*.txt'
else
pass 'add-packages.sh never rewrites the manifests'
fi
# build-index.sh: the bundle contract, fail-closed behaviour, stable rebuilds.
grep -q 'apt-ftparchive' "$BUILD_INDEX" && pass 'build-index.sh builds the index with apt-ftparchive' || fail 'build-index.sh must use apt-ftparchive'
grep -q 'apt-utils' "$BUILD_INDEX" && pass 'build-index.sh names the apt-utils package that carries the tool' || fail 'build-index.sh must say how to install apt-ftparchive'
grep -q 'package\\tversion\\tarchitecture\\tsha256\\tsource' "$BUILD_INDEX" \
&& pass 'build-index.sh writes the documented lock header' \
|| fail 'build-index.sh must write the documented lock header'
grep -q -- "-name '\*.txt'" "$BUILD_INDEX" && pass 'build-index.sh reads every manifests/*.txt pin file' || fail 'build-index.sh must read every pin manifest'
grep -q 'ekş\|eksik' "$BUILD_INDEX" && pass 'build-index.sh reports the missing packages' || fail 'build-index.sh must report missing packages'
grep -q 'paketler eksik' "$BUILD_INDEX" && pass 'build-index.sh names the missing package list explicitly' || fail 'build-index.sh must print an explicit missing package list'
grep -q 'YAZILMADI' "$BUILD_INDEX" && pass 'build-index.sh writes no bundle while a pin is unsatisfied' || fail 'build-index.sh must refuse to write an incomplete bundle'
grep -q 'canonicalize_stanzas' "$BUILD_INDEX" && pass 'build-index.sh orders the index canonically' || fail 'build-index.sh must order the index canonically'
grep -q 'DEĞİŞİKLİK YOK' "$BUILD_INDEX" && pass 'build-index.sh detects an unchanged bundle' || fail 'build-index.sh must detect an unchanged bundle'
grep -q 'repo-manifest.yaml' "$BUILD_INDEX" && pass 'build-index.sh maintains repo-manifest.yaml' || fail 'build-index.sh must maintain repo-manifest.yaml'
grep -q 'bu depoya ait olmayan dosya' "$BUILD_INDEX" && pass 'build-index.sh refuses to delete a foreign output directory' || fail 'build-index.sh must not clobber a foreign output directory'
grep -q 'git add -f' "$BUILD_INDEX" && pass 'build-index.sh prints the git add -f step the .gitignore requires' || fail 'build-index.sh must print the commit command'
# The bundle's pool must be deliverable. blueforce-install.sh resolves every
# pinned package as "<bundle>/pool/<source>", so a device that clones the
# repository needs those .deb files on disk -- not just the index. An earlier
# .gitignore rule excluded built/pool/, which left the device with an index and
# no packages and stopped the install at "Kilitte yazılı paket depoda yok".
if [[ -d "$OFFLINE_REPO/built" ]]; then
if git -C "$REPO_ROOT" check-ignore -q -- "provisioning/offline-repo/built/pool" 2>/dev/null; then
fail 'provisioning/offline-repo/built/pool is gitignored; a clone would not carry the packages'
else
pass 'the bundle pool is not gitignored (a clone carries the packages)'
fi
if git -C "$REPO_ROOT" check-ignore -q -- "provisioning/offline-repo/built/Packages" 2>/dev/null; then
fail 'provisioning/offline-repo/built/Packages is gitignored; a clone would not carry the index'
else
pass 'the bundle index is not gitignored'
fi
fi
grep -q 'built' "$BUILD_INDEX" && pass 'build-index.sh documents the built/ bundle path' || fail 'build-index.sh must document the bundle path'
# repo-manifest.yaml is the repository record: shape, not data, at rest.
grep -q '^schema_version: 1$' "$REPO_MANIFEST" && pass 'repo-manifest.yaml carries a schema version' || fail 'repo-manifest.yaml must carry a schema version'
for key in ubuntu_release architecture generated_at package_count total_bytes index_sha256 packages; do
grep -q "^ *$key:" "$REPO_MANIFEST" || fail "repo-manifest.yaml does not declare $key"
done
grep -q "^ architecture: 'amd64'$" "$REPO_MANIFEST" && pass 'repo-manifest.yaml states the amd64 target' || fail 'repo-manifest.yaml must state the target architecture'
grep -q "^ state: 'UNBUILT'$" "$REPO_MANIFEST" && pass 'repo-manifest.yaml is shipped in the unbuilt state' || fail 'the shipped repo-manifest.yaml must be marked UNBUILT'
grep -q '^packages: \[\]$' "$REPO_MANIFEST" && pass 'repo-manifest.yaml ships no package rows' || fail 'the shipped repo-manifest.yaml must not carry package rows'
if python3 -c 'import yaml' >/dev/null 2>&1; then
python3 - "$REPO_MANIFEST" <<'PY' && pass 'repo-manifest.yaml is valid YAML' || fail 'repo-manifest.yaml is not valid YAML'
import sys, yaml
with open(sys.argv[1], encoding='utf-8') as handle:
data = yaml.safe_load(handle)
assert data['schema_version'] == 1
assert data['repository']['architecture'] == 'amd64'
assert data['repository']['package_count'] == 0
assert data['packages'] == []
PY
else
pass 'repo-manifest.yaml YAML parse skipped (PyYAML unavailable)'
fi
# ------------------------------------------------------------ README is Turkish --
README_FILE="$OFFLINE_REPO/README.md"
require_file "$README_FILE"
grep -q 'repo sorumlusu' "$README_FILE" && pass 'README explains the repository owner role' || fail 'README must explain who supplies the packages'
grep -q 'indirmez\|indirme yapmaz' "$README_FILE" && pass 'README states that the installer downloads nothing' || fail 'README must state that the offline installer downloads nothing'
grep -q 'OFFLINE BLOCKED' "$README_FILE" && pass 'README documents the OFFLINE BLOCKED failures' || fail 'README must document OFFLINE BLOCKED'
grep -q 'requirements.tsv' "$README_FILE" && pass 'README points at the audit table' || fail 'README must reference requirements.tsv'
grep -q 'add-packages.sh' "$README_FILE" && grep -q 'build-index.sh' "$README_FILE" \
&& pass 'README documents both owner tools' \
|| fail 'README must document add-packages.sh and build-index.sh'
grep -q 'built/' "$README_FILE" && pass 'README documents the bundle directory' || fail 'README must document the built/ layout'
grep -q 'offline-repo' "$README_FILE" && pass 'README names the install path of the bundle' || fail 'README must name the install path'
grep -q 'UNPINNED' "$README_FILE" && pass 'README explains the UNPINNED sentinel' || fail 'README must explain the UNPINNED sentinel'
grep -q 'git add -f' "$README_FILE" && pass 'README explains the git add -f requirement' || fail 'README must explain the git add -f requirement'
for package in curl openssh-server wireguard-tools smartmontools ufw prometheus-node-exporter xrdp rustdesk docker-ce containerd.io; do
grep -q "$package" "$README_FILE" || fail "README does not explain why $package is needed"
done
pass 'README lists the pinned packages with their reason'
# ------------------------------------------ stub tools (this machine has no apt) --
# apt-ftparchive and dpkg-deb exist on the Ubuntu build host only. These two stubs
# reproduce their observable interface so the owner tooling is exercised end to end
# here; they are created inside the temporary directory and never committed, and a
# passing run is not a substitute for one real build on the Ubuntu host.
STUB_BIN="$TMP/stub-bin"
mkdir -p "$STUB_BIN"
cat > "$STUB_BIN/dpkg-deb" <<'STUB'
#!/usr/bin/env bash
# Test stub: real dpkg-deb reads the control member; the identity is read from
# the <package>_<version>_<arch>.deb file name instead.
#
# The output shape mirrors the real tool exactly, which matters more than it
# looks: dpkg-deb prints a BARE VALUE when one field is requested and
# "<Field>: <value>" when several are, and callers exist in both styles. A stub
# that always emitted one shape let a parser break the other pass every test --
# the real pool came out empty while this suite stayed green.
set -euo pipefail
[[ "${1:-}" == '-f' ]] || exit 1
file="${2:-}"
shift 2
base="$(basename "$file")"
case "$base" in
*_*_*.deb) ;;
*) printf 'stub: cannot read a control file from %s\n' "$base" >&2; exit 1 ;;
esac
name="${base%.deb}"; arch="${name##*_}"; rest="${name%_*}"
version="${rest##*_}"; package="${rest%_*}"
[[ -n "$package" && -n "$version" && -n "$arch" ]] || exit 1
# dpkg-deb prints a BARE VALUE for one requested field and "<Field>: <value>"
# for several. Both shapes must be reproduced: a stub that always emitted one
# of them let a reader that only handled the other pass every run, and the real
# pool came out empty while this suite stayed green.
if [[ $# -eq 1 ]]; then
case "$1" in
Package) printf '%s\n' "$package" ;;
Version) printf '%s\n' "$version" ;;
Architecture) printf '%s\n' "$arch" ;;
*) printf '\n' ;;
esac
else
for field in "$@"; do
case "$field" in
Package) printf 'Package: %s\n' "$package" ;;
Version) printf 'Version: %s\n' "$version" ;;
Architecture) printf 'Architecture: %s\n' "$arch" ;;
*) printf '\n' ;;
esac
done
fi
STUB
cat > "$STUB_BIN/apt-ftparchive" <<'STUB'
#!/usr/bin/env bash
# Test stub: emits one Packages stanza per .deb, in reverse file order, so the
# builder's canonical ordering has to do real work.
set -euo pipefail
[[ "${1:-}" == 'packages' ]] || exit 1
dir="${2:-pool}"
first=1
while IFS= read -r deb; do
base="$(basename "$deb")"
name="${base%.deb}"; arch="${name##*_}"; rest="${name%_*}"
version="${rest##*_}"; package="${rest%_*}"
[[ "$first" -eq 1 ]] || printf '\n'
first=0
printf 'Package: %s\nVersion: %s\nArchitecture: %s\nFilename: %s/%s\nSize: %s\nSHA256: %s\n' \
"$package" "$version" "$arch" "$dir" "$base" "$(stat -c %s "$deb")" "$(sha256sum "$deb" | awk '{print $1}')"
done < <(find "$dir" -maxdepth 1 -type f -name '*.deb' | sort -r)
STUB
chmod +x "$STUB_BIN/dpkg-deb" "$STUB_BIN/apt-ftparchive"
export PATH="$STUB_BIN:$PATH"
new_fixture; normalise_fixture_versions
DEB_DIR="$TMP/debs"; BAD_DIR="$TMP/bad-debs"; PKG_OUT="$TMP/owner-packages"
mkdir -p "$DEB_DIR" "$BAD_DIR"
while IFS= read -r pin; do
[[ -z "$pin" || "$pin" == \#* ]] && continue
printf 'stub %s\n' "$pin" > "$DEB_DIR/${pin%%=*}_${pin#*=}_amd64.deb"
done < <(cat "$TMP/m/base-packages.txt" "$TMP/m/remote-access.txt" "$TMP/m/docker.txt")
printf 'stub arm64\n' > "$BAD_DIR/stub-arm_1.0-lab-fixture_arm64.deb"
printf 'this is not a debian package\n' > "$BAD_DIR/garbage.deb"
FIXTURE_PINS="$(grep -hvE '^[[:space:]]*(#|$)' "$TMP/m"/*.txt | wc -l)"
FIXTURE_PINS="$((FIXTURE_PINS))"
# --check must validate and mutate nothing.
[[ "$FIXTURE_PINS" -gt 0 ]] && pass "fixture declares $FIXTURE_PINS pins for the owner tooling"
rc=0; out="$(bash "$ADD_PKGS" --packages "$PKG_OUT" --from "$DEB_DIR" --check 2>&1)" || rc=$?
[[ "$rc" -eq 0 ]] && pass 'add-packages.sh --check accepts a matching pool' || { fail "add-packages.sh --check failed (exit $rc)"; printf '%s\n' "$out" >&2; }
[[ ! -e "$PKG_OUT" ]] && pass 'add-packages.sh --check copies nothing' || fail 'add-packages.sh --check must not copy'
grep -q 'EKLENECEK' <<<"$out" && pass 'add-packages.sh --check previews the accepted packages' || fail 'add-packages.sh --check must preview the packages'
# Real intake, twice: the second run must be a no-op.
rc=0; out="$(bash "$ADD_PKGS" --packages "$PKG_OUT" --from "$DEB_DIR" --manifest "$TMP/m/base-packages.txt" --include-all 2>&1)" || rc=$?
[[ "$rc" -eq 0 ]] && pass 'add-packages.sh accepts the reviewed pool' || { fail "add-packages.sh intake failed (exit $rc)"; printf '%s\n' "$out" >&2; }
copied="$(find "$PKG_OUT" -maxdepth 1 -type f -name '*.deb' | wc -l)"
[[ "$copied" -eq "$FIXTURE_PINS" ]] && pass "add-packages.sh copied all $copied packages into packages/" || fail "add-packages.sh copied $copied of $FIXTURE_PINS packages"
state_before="$(find "$PKG_OUT" -maxdepth 1 -type f -exec sha256sum {} + | sort)"
rc=0; out="$(bash "$ADD_PKGS" --packages "$PKG_OUT" --from "$DEB_DIR" 2>&1)" || rc=$?
state_after="$(find "$PKG_OUT" -maxdepth 1 -type f -exec sha256sum {} + | sort)"
[[ "$rc" -eq 0 && "$state_before" == "$state_after" ]] \
&& pass 're-running add-packages.sh over the same files changes nothing' \
|| fail 'add-packages.sh is not idempotent'
grep -q 'ATLANDI' <<<"$out" && pass 'add-packages.sh reports an already accepted file instead of copying it again' || fail 'add-packages.sh must report a duplicate instead of re-copying'
# A pin that is still UNPINNED: the owner gets the exact line to fill.
#
# The reviewed manifests are fully pinned in production, so the sentinel has to
# be set up here rather than borrowed from the real file -- reading it from the
# repository made this case pass only while the contract was still unfilled.
UNPINNED_MANIFEST_DIR="$TMP/unpinned-manifests"
mkdir -p "$UNPINNED_MANIFEST_DIR"
awk 'BEGIN{FS=OFS="="}
/^[a-z0-9][a-z0-9+.-]*=/ { if ($1 == "curl") { print $1, "UNPINNED"; next } }
{ print }' "$TMP/m/base-packages.txt" > "$UNPINNED_MANIFEST_DIR/base-packages.txt"
grep -q '^curl=UNPINNED$' "$UNPINNED_MANIFEST_DIR/base-packages.txt" \
|| fail 'the unpinned scenario could not be set up (no curl=UNPINNED written)'
rc=0; out="$(bash "$ADD_PKGS" --packages "$TMP/other-packages" --from "$DEB_DIR" --manifest "$UNPINNED_MANIFEST_DIR/base-packages.txt" --check 2>&1)" || rc=$?
if [[ "$rc" -eq 0 ]] && grep -q 'MANIFEST GÜNCELLEMESİ' <<<"$out" && grep -q 'base-packages.txt: curl=9.9.9-lab-fixture' <<<"$out"; then
pass 'add-packages.sh hands over the UNPINNED pin line with the real version'
else
fail "add-packages.sh must print the pin line to fill (exit $rc)"
printf '%s\n' "$out" >&2
fi
# Failing intakes must change nothing and must be explicit.
printf 'stub newer\n' > "$BAD_DIR/curl_0.0.1-lab-fixture_amd64.deb"
rc=0; out="$(bash "$ADD_PKGS" --packages "$PKG_OUT" --from "$BAD_DIR" --include-all 2>&1)" || rc=$?
[[ "$rc" -ne 0 ]] && pass 'add-packages.sh fails on an unusable batch' || fail 'add-packages.sh must fail on an unusable batch'
grep -q 'mimari uyumsuz' <<<"$out" && pass 'add-packages.sh names the architecture mismatch' || fail 'add-packages.sh must name the architecture mismatch'
grep -q 'geçerli bir Debian paketi' <<<"$out" && pass 'add-packages.sh names the unreadable file' || fail 'add-packages.sh must name the unreadable file'
grep -q 'çakışma' <<<"$out" && pass 'add-packages.sh refuses the second version of curl' || fail 'add-packages.sh must refuse a version conflict'
[[ ! -e "$PKG_OUT/curl_0.0.1-lab-fixture_amd64.deb" ]] && pass 'a refused batch copies no file' || fail 'add-packages.sh copied a refused file'
[[ ! -e "$PKG_OUT/garbage.deb" ]] && pass 'an unreadable file never reaches packages/' || fail 'add-packages.sh copied an unreadable file'
printf 'not a deb\n' > "$BAD_DIR/notes.txt"
rc=0; out="$(bash "$ADD_PKGS" --packages "$PKG_OUT" "$BAD_DIR/notes.txt" 2>&1)" || rc=$?
[[ "$rc" -ne 0 ]] && grep -q 'Debian paketi değil' <<<"$out" && pass 'add-packages.sh refuses a non-.deb argument' || fail 'add-packages.sh must refuse a non-.deb argument'
# build-index.sh --check: contract verified, nothing written.
BAD_OUT="$TMP/never-written"
rc=0; out="$(bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$BAD_OUT" --manifest-dir "$TMP/m" --manifest-out "$TMP/never.yaml" --check 2>&1)" || rc=$?
[[ "$rc" -eq 0 ]] && pass 'build-index.sh --check accepts a complete pool' || { fail "build-index.sh --check failed (exit $rc)"; printf '%s\n' "$out" >&2; }
[[ ! -e "$BAD_OUT" && ! -e "$TMP/never.yaml" ]] && pass 'build-index.sh --check writes nothing' || fail 'build-index.sh --check must not write'
# A refused output path must be refused before anything is removed.
FAKE_HOME="$TMP/fake-home"; mkdir -p "$FAKE_HOME"
rc=0; out="$(HOME="$FAKE_HOME" bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$FAKE_HOME" --manifest-dir "$TMP/m" --check 2>&1)" || rc=$?
[[ "$rc" -eq 2 && -d "$FAKE_HOME" ]] && pass 'build-index.sh refuses an unsafe --output path' || fail "an unsafe --output must be a usage error (exit $rc)"
FOREIGN_OUT="$TMP/foreign-out"; mkdir -p "$FOREIGN_OUT"; printf 'keep me\n' > "$FOREIGN_OUT/junk.txt"
rc=0; out="$(bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$FOREIGN_OUT" --manifest-dir "$TMP/m" 2>&1)" || rc=$?
[[ "$rc" -ne 0 ]] && grep -q 'bu depoya ait olmayan dosya' <<<"$out" && pass 'build-index.sh refuses a foreign output directory' || fail 'build-index.sh must refuse a foreign output directory'
[[ -f "$FOREIGN_OUT/junk.txt" ]] && pass 'a refused build deletes nothing' || fail 'a refused build must not delete anything'
# The real build.
OUT="$TMP/owner-built"
# Snapshot the committed bundle directory before this build. The assertion below
# is about THIS build not writing there -- not about the directory being empty:
# a maintainer's machine may hold an untracked/committed real build, and an
# absolute "must be clean" check reported that legitimate bundle as test spill.
built_state_before="$(git -C "$REPO_ROOT" status --porcelain -- "provisioning/offline-repo/built" 2>/dev/null || true)"
rc=0; out="$(bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$OUT" --manifest-dir "$TMP/m" --manifest-out "$TMP/repo-manifest.yaml" 2>&1)" || rc=$?
[[ "$rc" -eq 0 ]] && pass 'build-index.sh builds a bundle from the accepted pool' || { fail "build-index.sh build failed (exit $rc)"; printf '%s\n' "$out" >&2; }
for artifact in Packages Packages.gz packages.lock.tsv SHA256SUMS; do
[[ -s "$OUT/$artifact" ]] || fail "build-index.sh did not write $artifact"
done
pool_count="$(find "$OUT/pool" -maxdepth 1 -type f -name '*.deb' | wc -l)"
[[ "$pool_count" -eq "$FIXTURE_PINS" ]] && pass "the bundle holds the whole pool ($pool_count .deb)" || fail "the bundle holds $pool_count of $FIXTURE_PINS packages"
lock_rows="$(wc -l < "$OUT/packages.lock.tsv")"
[[ "$lock_rows" -eq $((FIXTURE_PINS + 1)) ]] && pass 'packages.lock.tsv holds the header plus one row per package' || fail "packages.lock.tsv holds $lock_rows lines, expected $((FIXTURE_PINS + 1))"
[[ "$(head -n 1 "$OUT/packages.lock.tsv")" == "$LOCK_HEADER" ]] && pass 'the bundle lock header is byte-identical to the documented one' || fail 'the bundle lock header is wrong'
awk -F '\t' 'NR > 1 { if (NF != 5 || $0 ~ /\r/ || $4 !~ /^[a-f0-9]{64}$/) { print "BADROW: " $0; bad=1 } } END { exit bad }' "$OUT/packages.lock.tsv" \
&& pass 'every lock row carries five TAB-separated fields and a sha256' \
|| fail 'a lock row is malformed'
( cd "$OUT" && sha256sum -c SHA256SUMS >/dev/null ) && pass 'the bundle verifies with sha256sum -c' || fail 'the bundle does not verify'
stanza_count="$(grep -c '^Package: ' "$OUT/Packages")"
[[ "$stanza_count" -eq "$FIXTURE_PINS" ]] && pass 'Packages indexes every package' || fail "Packages indexes $stanza_count of $FIXTURE_PINS packages"
gzip_flg="$(od -An -t u1 -j 3 -N 1 "$OUT/Packages.gz" | tr -d ' ')"
gzip_mtime="$(od -An -t u1 -j 4 -N 4 "$OUT/Packages.gz" | tr -d ' \n')"
[[ "$gzip_flg" == '0' && "$gzip_mtime" == '0000' ]] \
&& pass 'Packages.gz carries no name and no timestamp (reproducible)' \
|| fail "Packages.gz is not timestamp free (FLG=$gzip_flg MTIME=$gzip_mtime)"
cmp -s <(gzip -dc "$OUT/Packages.gz") "$OUT/Packages" && pass 'Packages.gz decompresses to Packages' || fail 'Packages.gz does not match Packages'
# The repository record.
if [[ -f "$TMP/repo-manifest.yaml" ]]; then
pass 'build-index.sh writes the repository record'
if python3 -c 'import yaml' >/dev/null 2>&1; then
python3 - "$TMP/repo-manifest.yaml" "$FIXTURE_PINS" "$OUT" <<'PY' && pass 'the repository record matches the bundle' || fail 'the repository record disagrees with the bundle'
import hashlib, os, sys, yaml
record_path, expected, out = sys.argv[1], int(sys.argv[2]), sys.argv[3]
with open(record_path, encoding='utf-8') as handle:
rec = yaml.safe_load(handle)
repo = rec['repository']
assert rec['schema_version'] == 1, rec['schema_version']
assert repo['state'] == 'BUILT', repo['state']
assert repo['architecture'] == 'amd64', repo['architecture']
assert repo['ubuntu_release'] == '26.04', repo['ubuntu_release']
assert repo['package_count'] == expected, repo['package_count']
assert repo['pinned_packages'] == expected, repo['pinned_packages']
assert repo['total_bytes'] > 0
assert len(rec['packages']) == expected, len(rec['packages'])
for row in rec['packages']:
assert len(row['sha256']) == 64, row
assert row['pinned_in'], row
assert os.path.isfile(os.path.join(out, 'pool', row['source'])), row
for name in ('Packages', 'Packages.gz', 'packages.lock.tsv'):
with open(os.path.join(out, name), 'rb') as handle:
digest = hashlib.sha256(handle.read()).hexdigest()
assert repo['index_sha256'][name] == digest, (name, digest)
PY
else
pass 'repository record parse skipped (PyYAML unavailable)'
fi
grep -q "^ package_count: $FIXTURE_PINS$" "$TMP/repo-manifest.yaml" && pass 'the record counts every package' || fail 'the record has the wrong package_count'
else
fail 'build-index.sh must write the repository record'
fi
# An unchanged rebuild must be a no-op, byte for byte.
bundle_before="$(find "$OUT" -type f -exec sha256sum {} + | sort)"
record_before="$(sha256sum "$TMP/repo-manifest.yaml" | awk '{print $1}')"
rc=0; out="$(bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$OUT" --manifest-dir "$TMP/m" --manifest-out "$TMP/repo-manifest.yaml" 2>&1)" || rc=$?
bundle_after="$(find "$OUT" -type f -exec sha256sum {} + | sort)"
record_after="$(sha256sum "$TMP/repo-manifest.yaml" | awk '{print $1}')"
[[ "$rc" -eq 0 && "$bundle_before" == "$bundle_after" ]] && pass 'rebuilding an unchanged pool leaves the bundle byte-identical' || fail 'rebuilding an unchanged pool changed the bundle'
grep -q 'DEĞİŞİKLİK YOK' <<<"$out" && pass 'build-index.sh reports the unchanged bundle instead of rewriting it' || fail 'build-index.sh must report an unchanged bundle'
# The guard must accept this script's own output as its own. In production the
# record lives INSIDE the output directory (--manifest-out built/repo-manifest.yaml),
# and the earlier fixtures wrote it outside, so a guard that rejected
# repo-manifest.yaml as a foreign file passed every run while a real rebuild
# stopped with "bu depoya ait olmayan dosya". Mirror the real layout.
REBUILD_OUT="$TMP/rebuild-out"
mkdir -p "$REBUILD_OUT"
bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$REBUILD_OUT" --manifest-dir "$TMP/m" >/dev/null 2>&1
[[ -f "$REBUILD_OUT/repo-manifest.yaml" ]] || fail 'the record was not written inside the output directory'
rc_again=0; out_again="$(bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$REBUILD_OUT" --manifest-dir "$TMP/m" 2>&1)" || rc_again=$?
if [[ "$rc_again" -eq 0 ]] && ! grep -q 'bu depoya ait olmayan dosya' <<<"$out_again"; then
pass 'rebuilding the same output directory is accepted (its own record is not foreign)'
else
fail "a second build over its own output failed (exit $rc_again)"
printf '%s\n' "$out_again" >&2
fi
[[ "$record_before" == "$record_after" ]] && pass 'an unchanged rebuild leaves the repository record untouched' || fail 'an unchanged rebuild must not restamp the repository record'
git_status_note="$(git -C "$REPO_ROOT" status --porcelain -- "provisioning/offline-repo/built" 2>/dev/null || true)"
[[ "$git_status_note" == "$built_state_before" ]] \
&& pass 'the test build never touches the committed bundle directory' \
|| fail "the test build changed provisioning/offline-repo/built: before=[$built_state_before] after=[$git_status_note]"
# A missing pin: report, then stop, without touching the previous bundle.
rm -f "$PKG_OUT/rustdesk_9.9.9-lab-fixture_amd64.deb"
rc=0; out="$(bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$OUT" --manifest-dir "$TMP/m" 2>&1)" || rc=$?
[[ "$rc" -eq 1 ]] && pass 'build-index.sh fails when a manifest pin is absent from the pool' || fail "a missing pinned package must exit 1, got $rc"
grep -q 'paketler eksik' <<<"$out" && pass 'build-index.sh prints the missing package list' || fail 'build-index.sh must print the missing package list'
grep -q 'rustdesk' <<<"$out" && pass 'the missing package is named' || fail 'the missing package must be named'
grep -q 'YAZILMADI' <<<"$out" && grep -q 'OFFLINE BLOCKED' <<<"$out" \
&& pass 'build-index.sh explains that the device would report OFFLINE BLOCKED' \
|| fail 'build-index.sh must explain the offline consequence'
[[ "$bundle_before" == "$(find "$OUT" -type f -exec sha256sum {} + | sort)" ]] && pass 'a refused build leaves the previous bundle untouched' || fail 'a refused build must not modify the previous bundle'
[[ -z "$(find "$(dirname "$OUT")" -maxdepth 1 -name '.build-index.*' -print -quit)" ]] && pass 'a refused build leaves no staging directory behind' || fail 'a refused build left a staging directory'
# An unfilled manifest pin: no bundle either.
#
# Same reasoning as the add-packages case above: the unfilled contract is set up
# explicitly, because the reviewed manifests are fully pinned in production and
# borrowing their sentinel made this pass only while the contract was open.
rc=0; out="$(bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$TMP/never-2" --manifest-dir "$UNPINNED_MANIFEST_DIR" 2>&1)" || rc=$?
[[ "$rc" -eq 1 ]] && pass 'build-index.sh refuses while a pin still reads UNPINNED' || fail "an UNPINNED pin must exit 1, got $rc"
grep -q 'UNPINNED' <<<"$out" && grep -q 'eksik' <<<"$out" && pass 'the unfilled pins are listed as missing packages' || fail 'build-index.sh must list the unfilled pins'
[[ ! -e "$TMP/never-2" ]] && pass 'nothing is written while a pin is unfilled' || fail 'build-index.sh wrote a bundle for an unfilled contract'
# The canonical order is deterministic and stable: prove it on the sorter alone.
SORTED_A="$TMP/sorted-a"; SORTED_B="$TMP/sorted-b"; SORTER="$TMP/sorter.sh"
awk '/^canonicalize_stanzas\(\) \{/,/^\}/' "$BUILD_INDEX" > "$SORTER"
[[ -s "$SORTER" ]] || fail 'the canonical sorter function could not be extracted from build-index.sh'
bash -c 'set -euo pipefail
source "$1"
canonicalize_stanzas < "$2" > "$3"
canonicalize_stanzas < "$2" > "$4"
cmp -s "$3" "$4" || exit 1
canonicalize_stanzas < "$3" > "$3.recanon"
cmp -s "$3" "$3.recanon"' _ "$SORTER" "$OUT/Packages" "$SORTED_A" "$SORTED_B" \
&& pass 'the canonical index order is reproducible and self-stable' \
|| fail 'the canonical index sorter is not deterministic'
indexed_names="$(grep '^Package: ' "$OUT/Packages" | sed 's/^Package: //')"
[[ "$indexed_names" == "$(printf '%s\n' "$indexed_names" | LC_ALL=C sort)" ]] \
&& pass 'the index is ordered by package name, not by the raw pool order' \
|| fail 'the index must be ordered by package name'
if [[ "$FAIL" -ne 0 ]]; then
printf 'check-offline-repo-static: FAILED\n' >&2
exit 1
fi
printf 'check-offline-repo-static: all passed\n'