diff --git a/deploy/content/README.md b/deploy/content/README.md index b8816b7271..5e01d69e3c 100644 --- a/deploy/content/README.md +++ b/deploy/content/README.md @@ -51,7 +51,8 @@ production defaults. - every source file, the manifest, and config are tracked regular blobs in `HEAD`; - every working file is byte-for-byte equal to its `HEAD` blob; - the repository is clean, non-shallow, on a branch, and has no graft or - replacement refs; + replacement refs, object alternates, partial-clone metadata, or promisor + remotes, conversion-filter commands, or Git submodules; - raw `HEAD` equals the branch tip returned by an actual `git ls-remote --exit-code` query against explicit command-line trust anchors; and @@ -66,15 +67,24 @@ absolute, canonical bare repository path. Raw Git commands run with replacement-object processing, environment-provided repository overrides, global/system config, remote helpers, and the file -protocol disabled for SSH provenance. The publisher queries the branch, fetches -it into a fresh bare repository, enumerates the complete source subtree, and -materializes source, manifest, and config directly from that fetched commit. -Only regular executable or non-executable blobs are accepted. The complete -remote subtree must exactly match the manifest and the local working input, so -sparse checkout or `skip-worktree` cannot hide an extra remote file. The deploy -archive is built from the authoritative materialized source rather than the -working tree, eliminating a source-tree packaging race. The remote branch tip -is queried again under the deployment lock immediately before activation. +protocol disabled for SSH provenance. The publisher queries the explicit +remote branch tip, requires the local `HEAD` content-addressed commit to equal +that exact tip, then fetches the remote commit and tree graph with a blobless +filter. It fetches the remote config and manifest blobs on demand, fully checks +the fetched commit/tree connectivity, and copies each working source file once +into an isolated authoritative tree. It hashes each copy in the fetched +repository's Git object format and requires that object ID to match the remote +tree. The resulting tree must also match the SHA-256 hashes from the remotely +fetched manifest. This avoids downloading +unrelated private history and large source blobs during every release while +preserving the remote pushed-commit, tree, and manifest trust anchors. Only +regular executable or non-executable blobs are accepted. The complete remote +subtree must exactly match the manifest and the local working input, so sparse +checkout or `skip-worktree` cannot hide an extra file. The deploy archive is +built from the authoritative materialized source rather than the working tree, +eliminating a source-tree packaging race. The remote branch tip is queried +again after materialization and under the deployment lock immediately before +activation. SSH transport always requires this protection. Local transport may disable it only for disposable fixture testing. `ALLOW_INITIAL_INSTALL=false` prevents an diff --git a/deploy/content/publish-overlay.sh b/deploy/content/publish-overlay.sh index 2f0fa516ca..1d81cd1ed2 100755 --- a/deploy/content/publish-overlay.sh +++ b/deploy/content/publish-overlay.sh @@ -47,9 +47,11 @@ git_sanitized() { -u GIT_SSH -u GIT_SSH_COMMAND -u GIT_SSH_VARIANT -u GIT_PROXY_COMMAND \ GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null \ GIT_NO_REPLACE_OBJECTS=1 GIT_LITERAL_PATHSPECS=1 GIT_OPTIONAL_LOCKS=0 \ - GIT_TERMINAL_PROMPT=0 \ - GIT_SSH_COMMAND='ssh -F /dev/null -o BatchMode=yes' \ - GIT_PROTOCOL_FROM_USER=0 git "$@" + GIT_NO_LAZY_FETCH="${GIT_NO_LAZY_FETCH:-0}" GIT_TERMINAL_PROMPT=0 \ + GIT_SSH_COMMAND='ssh -F /dev/null -o BatchMode=yes -o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=3' \ + GIT_PROTOCOL_FROM_USER=0 git \ + -c protocol.ext.allow=never -c protocol.file.allow=never \ + -c core.fsmonitor=false -c core.hooksPath=/dev/null "$@" } validate_inode_types() { @@ -312,23 +314,50 @@ SOURCE_REVISION=unversioned if [ "$REQUIRE_CLEAN_SOURCE" = true ]; then [ -z "${GIT_REPLACE_REF_BASE:-}" ] && [ -z "${GIT_GRAFT_FILE:-}" ] \ || die "replace/graft environment overrides are forbidden" - SOURCE_REPO=$(git_sanitized -C "$SOURCE" rev-parse --show-toplevel 2>/dev/null) || die "source is not inside a git repository" - MANIFEST_REPO=$(git_sanitized -C "$MANIFEST_DIR" rev-parse --show-toplevel 2>/dev/null) || die "manifest is not inside a git repository" - CONFIG_REPO=$(git_sanitized -C "$CONFIG_DIR" rev-parse --show-toplevel 2>/dev/null) || die "config is not inside a git repository" + SOURCE_REPO=$(GIT_NO_LAZY_FETCH=1 git_sanitized -C "$SOURCE" rev-parse --show-toplevel 2>/dev/null) || die "source is not inside a git repository" + MANIFEST_REPO=$(GIT_NO_LAZY_FETCH=1 git_sanitized -C "$MANIFEST_DIR" rev-parse --show-toplevel 2>/dev/null) || die "manifest is not inside a git repository" + CONFIG_REPO=$(GIT_NO_LAZY_FETCH=1 git_sanitized -C "$CONFIG_DIR" rev-parse --show-toplevel 2>/dev/null) || die "config is not inside a git repository" SOURCE_REPO=$(cd "$SOURCE_REPO" && pwd -P) MANIFEST_REPO=$(cd "$MANIFEST_REPO" && pwd -P) CONFIG_REPO=$(cd "$CONFIG_REPO" && pwd -P) [ "$SOURCE_REPO" = "$MANIFEST_REPO" ] && [ "$SOURCE_REPO" = "$CONFIG_REPO" ] \ || die "source, manifest, and config must belong to the same git repository" - COMMON_DIR=$(git_sanitized -C "$SOURCE_REPO" rev-parse --git-common-dir) + + git_source() { + GIT_NO_LAZY_FETCH=1 git_sanitized -C "$SOURCE_REPO" "$@" + } + + COMMON_DIR=$(git_source rev-parse --git-common-dir) case "$COMMON_DIR" in /*) ;; *) COMMON_DIR="$SOURCE_REPO/$COMMON_DIR" ;; esac COMMON_DIR=$(cd "$COMMON_DIR" && pwd -P) + SOURCE_GIT_DIR=$(git_source rev-parse --absolute-git-dir) + SOURCE_GIT_DIR=$(cd "$SOURCE_GIT_DIR" && pwd -P) [ ! -e "$COMMON_DIR/info/grafts" ] && [ ! -L "$COMMON_DIR/info/grafts" ] \ || die "legacy Git grafts are forbidden" - [ -z "$(git_sanitized -C "$SOURCE_REPO" for-each-ref --format='%(refname)' refs/replace)" ] || die "Git replace refs are forbidden" - [ "$(git_sanitized -C "$SOURCE_REPO" rev-parse --is-shallow-repository)" = false ] || die "shallow source repositories are forbidden" - [ -z "$(git_sanitized -C "$SOURCE_REPO" status --porcelain --untracked-files=all)" ] || die "source repository is not clean" - BRANCH=$(git_sanitized -C "$SOURCE_REPO" symbolic-ref -q --short HEAD) || die "source repository must be on a branch" + [ ! -e "$COMMON_DIR/objects/info/alternates" ] \ + && [ ! -L "$COMMON_DIR/objects/info/alternates" ] \ + || die "Git object alternates are forbidden for provenance" + if [ "$SOURCE_GIT_DIR" != "$COMMON_DIR" ]; then + [ ! -e "$SOURCE_GIT_DIR/objects/info/alternates" ] \ + && [ ! -L "$SOURCE_GIT_DIR/objects/info/alternates" ] \ + || die "Git worktree object alternates are forbidden for provenance" + fi + [ -z "$(git_source for-each-ref --format='%(refname)' refs/replace)" ] || die "Git replace refs are forbidden" + [ "$(git_source rev-parse --is-shallow-repository)" = false ] || die "shallow source repositories are forbidden" + SOURCE_UNTRUSTED_CONFIG=$(git_source config --show-origin --get-regexp \ + '^(extensions\.partialclone|remote\..*\.(promisor|partialclonefilter)|filter\..*\.(clean|smudge|process))$' \ + 2>/dev/null || true) + [ -z "$SOURCE_UNTRUSTED_CONFIG" ] \ + || die "partial-clone, promisor, or conversion-filter config is forbidden for provenance" + SOURCE_REVISION=$(git_source rev-parse --verify HEAD) + SOURCE_GITLINK=$(git_source ls-files --stage | awk '$1 == "160000" {print "present"; exit}') + [ -z "$SOURCE_GITLINK" ] || die "Git submodules are forbidden for provenance" + git_source diff-index --cached --quiet --no-ext-diff --no-textconv \ + --ignore-submodules=all "$SOURCE_REVISION" -- \ + || die "source repository index differs from HEAD" + git_source ls-files --others --exclude-standard -z > "$WORK/source-untracked" + [ ! -s "$WORK/source-untracked" ] || die "source repository contains untracked files" + BRANCH=$(git_source symbolic-ref -q --short HEAD) || die "source repository must be on a branch" [[ "$PROVENANCE_REF" =~ ^refs/heads/[A-Za-z0-9._/-]+$ ]] \ && git_sanitized check-ref-format "$PROVENANCE_REF" >/dev/null 2>&1 \ @@ -371,52 +400,132 @@ if [ "$REQUIRE_CLEAN_SOURCE" = true ]; then } REMOTE_TIP=$(query_provenance_tip) - SOURCE_REVISION=$(git_sanitized -C "$SOURCE_REPO" rev-parse --verify 'HEAD^{commit}') [ "$SOURCE_REVISION" = "$REMOTE_TIP" ] \ || die "source HEAD is not equal to the provenance branch tip" + case "$SOURCE" in "$SOURCE_REPO"/*) SOURCE_REL=${SOURCE#"$SOURCE_REPO"/} ;; *) die "source is outside its repository" ;; esac + case "$MANIFEST" in "$SOURCE_REPO"/*) MANIFEST_REL=${MANIFEST#"$SOURCE_REPO"/} ;; *) die "manifest is outside its repository" ;; esac + case "$CONFIG" in "$SOURCE_REPO"/*) CONFIG_REL=${CONFIG#"$SOURCE_REPO"/} ;; *) die "config is outside its repository" ;; esac + + # Fetch the remote commit and tree graph without the potentially large blob + # history. The remote config and manifest blobs are fetched on demand; local + # source blobs are accepted only when both their remote Git object IDs and + # the SHA-256 hashes from that remote manifest agree. TRUST_REPO="$WORK/provenance.git" EMPTY_TEMPLATE="$WORK/empty-git-template" mkdir "$EMPTY_TEMPLATE" git_sanitized -C "$PROVENANCE_CWD" init -q --bare --template="$EMPTY_TEMPLATE" "$TRUST_REPO" + git_provenance --git-dir "$TRUST_REPO" remote add provenance "$PROVENANCE_REMOTE" + git_sanitized --git-dir "$TRUST_REPO" config extensions.partialClone provenance + git_sanitized --git-dir "$TRUST_REPO" config remote.provenance.promisor true + git_sanitized --git-dir "$TRUST_REPO" config remote.provenance.partialclonefilter blob:none git_provenance --git-dir "$TRUST_REPO" fetch -q --no-tags --force \ - "$PROVENANCE_REMOTE" "+$PROVENANCE_REF:refs/provenance/source" + --depth=1 --filter=blob:none provenance \ + "+$PROVENANCE_REF:refs/provenance/source" FETCHED_REVISION=$(git_sanitized --git-dir "$TRUST_REPO" rev-parse --verify 'refs/provenance/source^{commit}') [ "$FETCHED_REVISION" = "$SOURCE_REVISION" ] \ || die "provenance branch changed while the authoritative tree was fetched" - [ "$(git_sanitized --git-dir "$TRUST_REPO" rev-parse --is-shallow-repository)" = false ] \ - || die "authoritative provenance fetch unexpectedly produced a shallow repository" - [ ! -s "$TRUST_REPO/info/grafts" ] || die "authoritative provenance repository contains grafts" [ -z "$(git_sanitized --git-dir "$TRUST_REPO" for-each-ref --format='%(refname)' refs/replace)" ] \ || die "authoritative provenance repository contains replacement refs" - git_sanitized --git-dir "$TRUST_REPO" fsck --full --strict --no-reflogs "$FETCHED_REVISION" >/dev/null + GIT_NO_LAZY_FETCH=1 git_sanitized --git-dir "$TRUST_REPO" fsck --connectivity-only --strict \ + --no-reflogs "$FETCHED_REVISION" >/dev/null + + verify_source_worktree() { + local entry metadata mode oid stage path actual_oid link_target + git_source diff-index --cached --quiet --no-ext-diff --no-textconv \ + --ignore-submodules=all "$SOURCE_REVISION" -- \ + || die "source repository index differs from HEAD" + git_source ls-files --others --exclude-standard -z > "$WORK/source-untracked" + [ ! -s "$WORK/source-untracked" ] \ + || die "source repository contains untracked files" + git_source ls-files --stage -z > "$WORK/source-index" + while IFS= read -r -d '' entry; do + metadata=${entry%%$'\t'*} + path=${entry#*$'\t'} + read -r mode oid stage <<< "$metadata" + [ "$stage" = 0 ] || die "source repository index contains unresolved entries" + case "$mode" in + 100644|100755) + [ -f "$SOURCE_REPO/$path" ] && [ ! -L "$SOURCE_REPO/$path" ] \ + || die "tracked working file is missing or not regular: $path" + actual_oid=$(GIT_NO_LAZY_FETCH=1 git_sanitized --git-dir "$TRUST_REPO" \ + hash-object --stdin < "$SOURCE_REPO/$path") + ;; + 120000) + [ -L "$SOURCE_REPO/$path" ] || die "tracked working symlink is missing: $path" + link_target=$(readlink "$SOURCE_REPO/$path") + actual_oid=$(printf '%s' "$link_target" \ + | GIT_NO_LAZY_FETCH=1 git_sanitized --git-dir "$TRUST_REPO" hash-object --stdin) + ;; + 160000) die "Git submodules are forbidden for provenance" ;; + *) die "source repository index contains an unsupported mode: $path" ;; + esac + [ "$actual_oid" = "$oid" ] || die "tracked working file differs from the index: $path" + done < "$WORK/source-index" + } + + verify_source_worktree AUTHORITATIVE_ROOT="$WORK/authoritative" AUTHORITATIVE_SOURCE="$AUTHORITATIVE_ROOT/source" mkdir -p "$AUTHORITATIVE_SOURCE" + remote_blob_oid() { + local repository_path=$1 entry metadata mode type oid returned_path + entry=$(git_sanitized --git-dir "$TRUST_REPO" ls-tree "$SOURCE_REVISION" -- "$repository_path") + metadata=${entry%%$'\t'*} + returned_path=${entry#*$'\t'} + read -r mode type oid <<< "$metadata" + [ "$returned_path" = "$repository_path" ] \ + || die "provenance commit lacks a regular file: $repository_path" + case "$mode:$type" in 100644:blob|100755:blob) ;; *) die "provenance commit lacks a regular file: $repository_path" ;; esac + printf '%s\n' "$oid" + } + + require_promised_blob() { + local oid=$1 repository_path=$2 + if GIT_NO_LAZY_FETCH=1 git_sanitized --git-dir "$TRUST_REPO" \ + cat-file -e "$oid^{blob}" 2>/dev/null; then + die "provenance server did not honor blobless filtering: $repository_path" + fi + } + materialize_authoritative_file() { - local working_file=$1 repository_path=$2 output=$3 mode - mode=$(git_sanitized --git-dir "$TRUST_REPO" ls-tree "$SOURCE_REVISION" -- "$repository_path" \ - | awk 'NR == 1 {print $1}') - case "$mode" in 100644|100755) ;; *) die "provenance commit lacks a regular file: $repository_path" ;; esac + local working_file=$1 oid=$2 repository_path=$3 output=$4 mkdir -p "$(dirname "$output")" - git_sanitized --git-dir "$TRUST_REPO" cat-file blob "$SOURCE_REVISION:$repository_path" > "$output" + GIT_NO_LAZY_FETCH=0 git_provenance --git-dir "$TRUST_REPO" cat-file blob "$oid" > "$output" cmp -s "$output" "$working_file" || die "working file differs from provenance commit: $repository_path" } - case "$SOURCE" in "$SOURCE_REPO"/*) SOURCE_REL=${SOURCE#"$SOURCE_REPO"/} ;; *) die "source is outside its repository" ;; esac - case "$MANIFEST" in "$SOURCE_REPO"/*) MANIFEST_REL=${MANIFEST#"$SOURCE_REPO"/} ;; *) die "manifest is outside its repository" ;; esac - case "$CONFIG" in "$SOURCE_REPO"/*) CONFIG_REL=${CONFIG#"$SOURCE_REPO"/} ;; *) die "config is outside its repository" ;; esac - materialize_authoritative_file "$CONFIG_SNAPSHOT" "$CONFIG_REL" "$AUTHORITATIVE_ROOT/config" - materialize_authoritative_file "$MANIFEST_SNAPSHOT" "$MANIFEST_REL" "$AUTHORITATIVE_ROOT/manifest" + AUTHORITATIVE_TREE="$WORK/authoritative-tree" + git_sanitized --git-dir "$TRUST_REPO" ls-tree -r -z "$SOURCE_REVISION" -- "$SOURCE_REL" \ + > "$AUTHORITATIVE_TREE" + [ -s "$AUTHORITATIVE_TREE" ] || die "provenance source subtree is empty" + + CONFIG_OID=$(remote_blob_oid "$CONFIG_REL") + MANIFEST_OID=$(remote_blob_oid "$MANIFEST_REL") + require_promised_blob "$CONFIG_OID" "$CONFIG_REL" + require_promised_blob "$MANIFEST_OID" "$MANIFEST_REL" + while IFS= read -r -d '' tree_entry; do + tree_metadata=${tree_entry%%$'\t'*} + repository_path=${tree_entry#*$'\t'} + read -r tree_mode tree_type tree_oid <<< "$tree_metadata" + case "$tree_mode:$tree_type" in + 100644:blob|100755:blob) ;; + *) die "provenance source contains a non-regular Git entry: $repository_path" ;; + esac + require_promised_blob "$tree_oid" "$repository_path" + done < "$AUTHORITATIVE_TREE" + + materialize_authoritative_file "$CONFIG_SNAPSHOT" "$CONFIG_OID" \ + "$CONFIG_REL" "$AUTHORITATIVE_ROOT/config" + materialize_authoritative_file "$MANIFEST_SNAPSHOT" "$MANIFEST_OID" \ + "$MANIFEST_REL" "$AUTHORITATIVE_ROOT/manifest" [ "$(sha256_file "$AUTHORITATIVE_ROOT/config")" = "$CONFIG_READ_HASH" ] \ || die "config changed while it was being read" [ "$(sha256_file "$AUTHORITATIVE_ROOT/manifest")" = "$MANIFEST_READ_HASH" ] \ || die "manifest changed while it was being read" - AUTHORITATIVE_TREE="$WORK/authoritative-tree" - git_sanitized --git-dir "$TRUST_REPO" ls-tree -r -z "$SOURCE_REVISION" -- "$SOURCE_REL" \ - > "$AUTHORITATIVE_TREE" + : > "$WORK/authoritative-source-paths" while IFS= read -r -d '' tree_entry; do tree_metadata=${tree_entry%%$'\t'*} @@ -434,9 +543,14 @@ if [ "$REQUIRE_CLEAN_SOURCE" = true ]; then [ -f "$SOURCE/$path" ] && [ ! -L "$SOURCE/$path" ] \ || die "working source lacks a provenance file: $repository_path" mkdir -p "$(dirname "$AUTHORITATIVE_SOURCE/$path")" - git_sanitized --git-dir "$TRUST_REPO" cat-file blob "$tree_oid" \ - > "$AUTHORITATIVE_SOURCE/$path" - cmp -s "$AUTHORITATIVE_SOURCE/$path" "$SOURCE/$path" \ + cp "$SOURCE/$path" "$AUTHORITATIVE_SOURCE/$path" + case "$tree_mode" in + 100644) chmod 0644 "$AUTHORITATIVE_SOURCE/$path" ;; + 100755) chmod 0755 "$AUTHORITATIVE_SOURCE/$path" ;; + esac + COPIED_OID=$(GIT_NO_LAZY_FETCH=1 git_sanitized --git-dir "$TRUST_REPO" \ + hash-object --stdin < "$AUTHORITATIVE_SOURCE/$path") + [ "$COPIED_OID" = "$tree_oid" ] \ || die "working source differs from provenance commit: $repository_path" printf '%s\n' "$path" >> "$WORK/authoritative-source-paths" done < "$AUTHORITATIVE_TREE" @@ -445,6 +559,12 @@ if [ "$REQUIRE_CLEAN_SOURCE" = true ]; then mkdir "$VERIFY_AUTHORITATIVE" verify_tree "$AUTHORITATIVE_SOURCE" "$WORK/normalized.SHA256SUMS" "$ENTRYPOINT" "$VERIFY_AUTHORITATIVE" SOURCE=$AUTHORITATIVE_SOURCE + + verify_source_worktree + + VERIFIED_REMOTE_TIP=$(query_provenance_tip) + [ "$VERIFIED_REMOTE_TIP" = "$SOURCE_REVISION" ] \ + || die "provenance branch changed while the authoritative tree was materialized" else if git -C "$SOURCE" rev-parse HEAD >/dev/null 2>&1; then SOURCE_REVISION=$(git -C "$SOURCE" rev-parse HEAD) diff --git a/deploy/content/tests/run.sh b/deploy/content/tests/run.sh index 3141a662e3..8a9d050399 100755 --- a/deploy/content/tests/run.sh +++ b/deploy/content/tests/run.sh @@ -94,6 +94,7 @@ init_pushed_repo() { git -C "$repo" config user.name 'Content Test' git -C "$repo" config user.email content-test@example.invalid git init -q --bare "$bare" + git --git-dir "$bare" config uploadpack.allowFilter true git -C "$repo" remote add origin "$bare" } @@ -118,7 +119,7 @@ make_source() { printf '%s\n' "$label-image" > "$source_dir/assets/banner 中文 name.webp" } -echo '1..24' +echo '1..31' # 1. Default invocation is a read-only plan. CASE1="$TMP/case1" @@ -548,7 +549,7 @@ rm "$REPO23/overlays/landing/remote-extra.txt" expect_failure bash "$PUBLISH" --config "$CONFIG23" --mount landing \ --provenance-remote "$BARE23" --provenance-ref refs/heads/main \ --source "$REPO23/overlays/landing" --manifest "$REPO23/overlays/SHA256SUMS" -grep -q 'working source lacks a provenance file' "$TMP/expected-failure.out" \ +grep -q 'tracked working file is missing or not regular' "$TMP/expected-failure.out" \ || fail 'authoritative extra-file fixture failed for the wrong reason' ok 'authoritative remote subtree defeats sparse or skip-worktree omissions' @@ -582,4 +583,129 @@ assert_file_text "$VICTIM24" unchanged bash "$REMOTE_HELPER" upload-clean "$UPLOAD_SYMLINK24" ok 'private upload directory rejects symlink pre-placement before lock acquisition' +# 25. A persistent object-alternate file can redirect local object reads away +# from the verified source repository and must fail before provenance use. +CASE25="$TMP/case25" +REPO25="$CASE25/private" +BARE25="$CASE25/origin.git" +mkdir -p "$CASE25" +init_pushed_repo "$REPO25" "$BARE25" +make_source "$REPO25/overlays/landing" provenance-storage +bash "$GENERATE" "$REPO25/overlays/landing" "$REPO25/overlays/SHA256SUMS" >/dev/null +REMOTE25="$CASE25/remote" +mkdir -p "$REMOTE25/custom/public/assets/landing" "$REPO25/deploy" +printf 'stable\n' > "$REMOTE25/custom/public/assets/landing/index.html" +CONFIG25="$REPO25/deploy/production.env" +write_config "$CONFIG25" "$REMOTE25" "$CASE25/backups" false true +commit_and_push "$REPO25" 'strict provenance storage fixture' +mkdir -p "$REPO25/.git/objects/info" +: > "$REPO25/.git/objects/info/alternates" +expect_failure bash "$PUBLISH" --config "$CONFIG25" --mount landing \ + --provenance-remote "$BARE25" --provenance-ref refs/heads/main \ + --source "$REPO25/overlays/landing" --manifest "$REPO25/overlays/SHA256SUMS" +grep -q 'Git object alternates are forbidden' "$TMP/expected-failure.out" \ + || fail 'object-alternate fixture failed for the wrong reason' +rm "$REPO25/.git/objects/info/alternates" +ok 'strict provenance rejects persistent Git object alternates' + +# 26. Linked-worktree config is part of the repository config scope. A hidden +# promisor there must not bypass the fail-closed source checkout policy. +git -C "$REPO25" config extensions.worktreeConfig true +git -C "$REPO25" config --worktree remote.hidden.promisor true +expect_failure bash "$PUBLISH" --config "$CONFIG25" --mount landing \ + --provenance-remote "$BARE25" --provenance-ref refs/heads/main \ + --source "$REPO25/overlays/landing" --manifest "$REPO25/overlays/SHA256SUMS" +grep -q 'promisor, or conversion-filter config is forbidden' "$TMP/expected-failure.out" \ + || fail 'worktree-promisor fixture failed for the wrong reason' +git -C "$REPO25" config --worktree --unset remote.hidden.promisor +ok 'strict provenance rejects linked-worktree promisor config' + +# 27. A provenance server that ignores blobless filtering would reintroduce a +# full private-history download. Refuse it rather than silently degrading. +git --git-dir "$BARE25" config uploadpack.allowFilter false +expect_failure bash "$PUBLISH" --config "$CONFIG25" --mount landing \ + --provenance-remote "$BARE25" --provenance-ref refs/heads/main \ + --source "$REPO25/overlays/landing" --manifest "$REPO25/overlays/SHA256SUMS" +grep -q 'did not honor blobless filtering' "$TMP/expected-failure.out" \ + || fail 'filter-refusal fixture failed for the wrong reason' +ok 'strict provenance rejects servers that ignore blobless filtering' + +# 28. Repository-local fsmonitor config is not part of the remote provenance +# anchor and must never execute during clean-source validation. +git --git-dir "$BARE25" config uploadpack.allowFilter true +FSMONITOR28="$CASE25/untrusted-fsmonitor.sh" +FSMONITOR_MARKER28="$CASE25/fsmonitor-executed" +printf '#!/bin/sh\n: > "%s"\nexit 1\n' "$FSMONITOR_MARKER28" > "$FSMONITOR28" +chmod +x "$FSMONITOR28" +git -C "$REPO25" config core.fsmonitor "$FSMONITOR28" +bash "$PUBLISH" --config "$CONFIG25" --mount landing \ + --provenance-remote "$BARE25" --provenance-ref refs/heads/main \ + --source "$REPO25/overlays/landing" --manifest "$REPO25/overlays/SHA256SUMS" \ + >/dev/null +[ ! -e "$FSMONITOR_MARKER28" ] || fail 'repository-local fsmonitor executable ran' +git -C "$REPO25" config --unset core.fsmonitor +ok 'strict provenance disables repository-local fsmonitor execution' + +# 29. Git blob hashes do not cover executable mode. Normalize the staged tree +# from the remote tree mode even when core.fileMode hides a local inversion. +git -C "$REPO25" config core.fileMode false +chmod 0755 "$REPO25/overlays/landing/assets/site.css" +[ -z "$(git -C "$REPO25" status --porcelain --untracked-files=all)" ] \ + || fail 'mode-inversion fixture repository is unexpectedly dirty' +bash "$PUBLISH" --config "$CONFIG25" --mount landing \ + --provenance-remote "$BARE25" --provenance-ref refs/heads/main \ + --source "$REPO25/overlays/landing" --manifest "$REPO25/overlays/SHA256SUMS" \ + --apply >/dev/null +[ "$(file_mode "$REMOTE25/custom/public/assets/landing/assets/site.css")" = 644 ] \ + || fail 'published file mode differs from remote tree mode' +ok 'authoritative staging normalizes modes from the remote tree' + +# 30. Clean/process conversion filters from untracked local config are outside +# the remote trust anchor and must be rejected before checkout verification. +printf '*.css filter=pwn\n' > "$REPO25/.gitattributes" +commit_and_push "$REPO25" 'tracked conversion-filter attribute fixture' +FILTER30="$CASE25/untrusted-filter.sh" +FILTER_MARKER30="$CASE25/filter-executed" +printf '#!/bin/sh\n: > "%s"\ncat\n' "$FILTER_MARKER30" > "$FILTER30" +chmod +x "$FILTER30" +git -C "$REPO25" config filter.pwn.clean "$FILTER30" +touch -t 200001010000 "$REPO25/overlays/landing/assets/site.css" +expect_failure bash "$PUBLISH" --config "$CONFIG25" --mount landing \ + --provenance-remote "$BARE25" --provenance-ref refs/heads/main \ + --source "$REPO25/overlays/landing" --manifest "$REPO25/overlays/SHA256SUMS" +grep -q 'conversion-filter config is forbidden' "$TMP/expected-failure.out" \ + || fail 'conversion-filter fixture failed for the wrong reason' +[ ! -e "$FILTER_MARKER30" ] || fail 'repository-local conversion filter ran' +git -C "$REPO25" config --unset filter.pwn.clean +ok 'strict provenance rejects repository-local conversion filters before verification' + +# 31. Superproject status can recurse into a submodule and execute commands +# from that submodule's separate config. Reject gitlinks before status and +# force the status probe itself to ignore submodules. +SUBREPO31="$CASE25/submodule-source" +git init -q "$SUBREPO31" +git -C "$SUBREPO31" checkout -q -b main +git -C "$SUBREPO31" config user.name 'Content Test' +git -C "$SUBREPO31" config user.email content-test@example.invalid +printf '*.txt filter=pwn\n' > "$SUBREPO31/.gitattributes" +printf 'tracked\n' > "$SUBREPO31/tracked.txt" +git -C "$SUBREPO31" add .gitattributes tracked.txt +git -C "$SUBREPO31" commit -q -m 'submodule fixture' +git -C "$REPO25" -c protocol.file.allow=always submodule add -q \ + "$SUBREPO31" vendor/untrusted +commit_and_push "$REPO25" 'tracked submodule fixture' +SUB_FILTER31="$CASE25/untrusted-submodule-filter.sh" +SUB_FILTER_MARKER31="$CASE25/submodule-filter-executed" +printf '#!/bin/sh\n: > "%s"\ncat\n' "$SUB_FILTER_MARKER31" > "$SUB_FILTER31" +chmod +x "$SUB_FILTER31" +git -C "$REPO25/vendor/untrusted" config filter.pwn.clean "$SUB_FILTER31" +touch -t 200001010000 "$REPO25/vendor/untrusted/tracked.txt" +expect_failure bash "$PUBLISH" --config "$CONFIG25" --mount landing \ + --provenance-remote "$BARE25" --provenance-ref refs/heads/main \ + --source "$REPO25/overlays/landing" --manifest "$REPO25/overlays/SHA256SUMS" +grep -q 'Git submodules are forbidden' "$TMP/expected-failure.out" \ + || fail 'submodule fixture failed for the wrong reason' +[ ! -e "$SUB_FILTER_MARKER31" ] || fail 'submodule conversion filter ran' +ok 'strict provenance rejects submodules before cleanliness evaluation' + echo "All $PASS content publisher tests passed."