Splux Linux

source-based Linux using SPS

SPS · log · files · refs · tags · releases · people · languages · GitHub · commit

8aa1c97


tests: cover repository, recipe contract, and definition digests

Date
Commit8aa1c97667abb75d72b8d02d887dc4cb47618a41
GitHubmirror

Parents

Stat

 tests/test_common.sh            |   1 +
 tests/test_definition_digest.sh | 104 ++++++++++++++++++
 tests/test_dependencies.sh      |  82 +++++++++++---
 tests/test_git_repository.sh    | 215 +++++++++++++++++++++++++++++++++++++
 tests/test_recipe_contract.sh   | 229 ++++++++++++++++++++++++++++++++++++++++
 tests/test_repository.sh        | 203 +++++++++++++++++++++++------------
 6 files changed, 750 insertions(+), 84 deletions(-)

Diff

diff --git a/tests/test_common.sh b/tests/test_common.sh
index 7e4de33..b7bb46b 100755
--- a/tests/test_common.sh
+++ b/tests/test_common.sh
@@ -33,6 +33,7 @@ sps_load_config
 [ "$SPS_DB" = "$test_root/state/db" ]
 [ "$SPS_CACHE" = "$test_root/state/cache" ]
 [ "$SPS_BUILD" = "$test_root/state/build" ]
+[ "$SPS_REPO_ROOT" = "$test_root/usr/src/sps" ]
 [ "$SPS_MAKEJOBS" = 3 ]
 [ "$SPS_ARCH" = test-arch ]
 [ "$SPS_PRESERVE" = etc,usr/local/etc ]
diff --git a/tests/test_definition_digest.sh b/tests/test_definition_digest.sh
new file mode 100644
index 0000000..726fea2
--- /dev/null
+++ b/tests/test_definition_digest.sh
@@ -0,0 +1,104 @@
+#!/bin/sh
+set -eu
+
+case $0 in */*) test_dir=${0%/*} ;; *) test_dir=. ;; esac
+project_dir=$(CDPATH= cd "$test_dir/.." 2>/dev/null && pwd) || exit 1
+. "$project_dir/lib/common.sh"
+sps_program=test_definition_digest
+
+fail()
+{
+    printf 'test_definition_digest: %s\n' "$*" >&2
+    exit 1
+}
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-definition-digest.XXXXXX") ||
+    fail 'cannot create temporary directory'
+trap 'rm -rf "$tmp"' 0 1 2 3 15
+
+make_definition()
+{
+    definition_dir=$1
+    mkdir -p "$definition_dir/files/nested" "$definition_dir/patches" \
+        "$definition_dir/hooks"
+    printf '%s\n' 'name digest-test' 'version 1.0' 'release 1' \
+        'install true' >"$definition_dir/recipe"
+    printf '%s\n' 'setting=true' >"$definition_dir/files/nested/config"
+    printf '%s\n' 'patch contents' >"$definition_dir/patches/fix.patch"
+    printf '%s\n' 'exit 0' >"$definition_dir/hooks/post-install"
+    chmod 0750 "$definition_dir/files/nested"
+    chmod 0640 "$definition_dir/files/nested/config"
+}
+
+reset_copy()
+{
+    rm -rf "$tmp/copy"
+    cp -R "$tmp/original" "$tmp/copy"
+}
+
+expect_invalid_definition()
+{
+    invalid_label=$1
+    if sps_definition_sha256 "$tmp/copy/recipe" \
+        >"$tmp/$invalid_label.out" 2>"$tmp/$invalid_label.err"; then
+        fail "$invalid_label package definition unexpectedly hashed"
+    fi
+}
+
+make_definition "$tmp/original"
+reset_copy
+original_digest=$(sps_definition_sha256 "$tmp/original/recipe") ||
+    fail 'could not hash original package definition'
+copy_digest=$(sps_definition_sha256 "$tmp/copy/recipe") ||
+    fail 'could not hash copied package definition'
+[ "$original_digest" = "$copy_digest" ] ||
+    fail 'digest embeds or otherwise depends on the absolute package path'
+case $original_digest in
+    [0123456789abcdef][0123456789abcdef][0123456789abcdef][0123456789abcdef]*) ;;
+    *) fail 'definition digest is not lowercase hexadecimal' ;;
+esac
+[ "${#original_digest}" -eq 64 ] || fail 'definition digest is not SHA-256'
+
+printf '%s\n' changed >>"$tmp/copy/files/nested/config"
+[ "$(sps_definition_sha256 "$tmp/copy/recipe")" != "$original_digest" ] ||
+    fail 'support file content did not affect the digest'
+
+reset_copy
+mv "$tmp/copy/patches/fix.patch" "$tmp/copy/patches/renamed.patch"
+[ "$(sps_definition_sha256 "$tmp/copy/recipe")" != "$original_digest" ] ||
+    fail 'support file relative path did not affect the digest'
+
+reset_copy
+chmod 0600 "$tmp/copy/files/nested/config"
+[ "$(sps_definition_sha256 "$tmp/copy/recipe")" != "$original_digest" ] ||
+    fail 'copied support file mode did not affect the digest'
+
+reset_copy
+ln -s ../recipe "$tmp/copy/files/recipe-link"
+expect_invalid_definition symlink
+grep -F 'symlink or special file' "$tmp/symlink.err" >/dev/null ||
+    fail 'symlink rejection was not explained'
+
+reset_copy
+mkfifo "$tmp/copy/patches/input.fifo"
+expect_invalid_definition special
+grep -F 'symlink or special file' "$tmp/special.err" >/dev/null ||
+    fail 'special-file rejection was not explained'
+
+reset_copy
+tab=$(printf '\tX')
+tab=${tab%X}
+printf '%s\n' bad >"$tmp/copy/files/bad${tab}name"
+expect_invalid_definition tab
+grep -F 'tab or newline' "$tmp/tab.err" >/dev/null ||
+    fail 'tab-containing path rejection was not explained'
+
+reset_copy
+newline=$(printf '\nX')
+newline=${newline%X}
+printf '%s\n' bad >"$tmp/copy/files/bad${newline}name"
+expect_invalid_definition newline
+grep -F 'tab or newline' "$tmp/newline.err" >/dev/null ||
+    fail 'newline-containing path rejection was not explained'
+
+printf '%s\n' 'package definition digest tests passed'
diff --git a/tests/test_dependencies.sh b/tests/test_dependencies.sh
index b0ec233..b973ac5 100755
--- a/tests/test_dependencies.sh
+++ b/tests/test_dependencies.sh
@@ -27,7 +27,7 @@ trap 'rm -rf "$tmp"' 0 1 2 3 15
 
 mkdir -p "$tmp/root" "$tmp/cache" "$tmp/db/installed" "$tmp/build" \
     "$tmp/repo/base" "$tmp/repo/lib" "$tmp/repo/tool" "$tmp/repo/app" \
-    "$tmp/repo/broken" "$tmp/repo/cycle-a" "$tmp/repo/cycle-b" \
+    "$tmp/repo/left" "$tmp/repo/right" "$tmp/repo/diamond" \
     "$tmp/fakebin"
 : > "$tmp/sps.conf"
 printf 'repo test %s 10\n' "$tmp/repo" > "$tmp/repos.conf"
@@ -55,9 +55,14 @@ make_recipe "$tmp/repo/base" base 1.0
 make_recipe "$tmp/repo/lib" lib 2.1 'depend base>=1.0'
 make_recipe "$tmp/repo/tool" tool 3.0
 make_recipe "$tmp/repo/app" app 4.0 'depend lib>=2.0' 'builddep tool'
-make_recipe "$tmp/repo/broken" broken 1.0 'depend unavailable'
-make_recipe "$tmp/repo/cycle-a" cycle-a 1.0 'depend cycle-b'
-make_recipe "$tmp/repo/cycle-b" cycle-b 1.0 'depend cycle-a'
+make_recipe "$tmp/repo/left" left 1.0 'depend base'
+make_recipe "$tmp/repo/right" right 1.0 'depend base'
+make_recipe "$tmp/repo/diamond" diamond 1.0 'depend right left'
+mkdir -p "$tmp/repo/app/files" "$tmp/repo/app/patches" \
+    "$tmp/repo/app/hooks"
+printf '%s\n' 'default=true' >"$tmp/repo/app/files/app.conf"
+printf '%s\n' 'initial patch input' >"$tmp/repo/app/patches/build.patch"
+printf '%s\n' 'exit 0' >"$tmp/repo/app/hooks/post-install"
 
 SPS_ROOT=$tmp/root
 SPS_DB=$tmp/db
@@ -82,6 +87,10 @@ esac
 tree_with_build=$("$sget" depends app --build)
 contains "$tree_with_build" 'tool'
 
+why_path=$("$sget" why diamond base)
+[ "$why_path" = "$(printf 'diamond\nright\nbase')" ] ||
+    fail "dependency path did not follow deterministic declaration order: $why_path"
+
 plan=$("$sget" install --plan app)
 plan_names=$(printf '%s\n' "$plan" |
     awk '/^  / { print $2 }')
@@ -95,15 +104,30 @@ nodeps_names=$(printf '%s\n' "$nodeps_plan" |
 [ "$nodeps_names" = app ] ||
     fail '--nodeps did not restrict the plan to the requested package'
 
-if "$sget" install --plan broken > "$tmp/broken.out" 2> "$tmp/broken.err"; then
-    fail 'missing dependency unexpectedly resolved'
+# src validates the complete selected runtime/build graph before replacing the
+# usable index. These fixtures exercise the same resolver used by sget.
+index=$tmp/cache/indexes/packages.index
+old_index=$(cksum "$index")
+mkdir "$tmp/repo/broken"
+make_recipe "$tmp/repo/broken" broken 1.0 'depend unavailable'
+if "$src" update > "$tmp/broken.out" 2> "$tmp/broken.err"; then
+    fail 'repository with a missing dependency unexpectedly indexed'
 fi
 contains "$(sed -n '1,20p' "$tmp/broken.err")" "dependency 'unavailable'"
+[ "$(cksum "$index")" = "$old_index" ] ||
+    fail 'missing dependency replaced the previous repository index'
+rm -rf "$tmp/repo/broken"
 
-if "$sget" install --plan cycle-a > "$tmp/cycle.out" 2> "$tmp/cycle.err"; then
-    fail 'dependency cycle unexpectedly resolved'
+mkdir "$tmp/repo/cycle-a" "$tmp/repo/cycle-b"
+make_recipe "$tmp/repo/cycle-a" cycle-a 1.0 'depend cycle-b'
+make_recipe "$tmp/repo/cycle-b" cycle-b 1.0 'depend cycle-a'
+if "$src" update > "$tmp/cycle.out" 2> "$tmp/cycle.err"; then
+    fail 'repository dependency cycle unexpectedly indexed'
 fi
 contains "$(sed -n '1,20p' "$tmp/cycle.err")" 'dependency cycle:'
+[ "$(cksum "$index")" = "$old_index" ] ||
+    fail 'dependency cycle replaced the previous repository index'
+rm -rf "$tmp/repo/cycle-a" "$tmp/repo/cycle-b"
 
 [ "$(awk -v version_action=compare -v version_a=1.10 -v version_b=1.9 \
     -f "$project_dir/lib/version.awk" /dev/null)" = 1 ] ||
@@ -182,14 +206,42 @@ PATH="$tmp/fakebin:$PATH" "$sget" install app > "$tmp/install.out"
                   END { print count + 0 }' "$TEST_LOG")" = 1 ] ||
     fail 'requested package was not passed to pkin with explicit reason'
 
+mkdir -p "$tmp/cache/packages"
+app_arch=$(uname -m)
+app_cache=$tmp/cache/packages/app-4.0-1-$app_arch.pkg.tar
+cp "$tmp/repo/app/app-4.0-1-$app_arch.pkg.tar" "$app_cache" ||
+    fail 'could not prepare cached app artifact for stale-input tests'
+
 old_log_lines=$(wc -l < "$TEST_LOG" | awk '{ print $1 }')
+expect_stale_definition()
+{
+    stale_label=$1
+    if PATH="$tmp/fakebin:$PATH" "$sget" install --nodeps app \
+        > "$tmp/stale-$stale_label.out" 2> "$tmp/stale-$stale_label.err"; then
+        fail "sget reused or built an artifact after a stale $stale_label change"
+    fi
+    contains "$(sed -n '1,20p' "$tmp/stale-$stale_label.err")" \
+        'changed since indexing'
+    [ "$(wc -l < "$TEST_LOG" | awk '{ print $1 }')" = "$old_log_lines" ] ||
+        fail "mkpkg or pkin ran after stale $stale_label detection"
+    case $(cat "$tmp/stale-$stale_label.out") in
+        *'using cached'*) fail "cached artifact was considered before stale $stale_label detection" ;;
+    esac
+}
+
+printf '%s\n' 'local override=true' >>"$tmp/repo/app/files/app.conf"
+expect_stale_definition file
+"$src" update >/dev/null
+
+printf '%s\n' 'changed patch input' >>"$tmp/repo/app/patches/build.patch"
+expect_stale_definition patch
+"$src" update >/dev/null
+
+printf '%s\n' 'changed hook input' >>"$tmp/repo/app/hooks/post-install"
+expect_stale_definition hook
+"$src" update >/dev/null
+
 printf '%s\n' '# changed after indexing' >> "$tmp/repo/app/recipe"
-if PATH="$tmp/fakebin:$PATH" "$sget" install --nodeps app \
-    > "$tmp/stale.out" 2> "$tmp/stale.err"; then
-    fail 'sget built a recipe that changed after indexing'
-fi
-contains "$(sed -n '1,20p' "$tmp/stale.err")" 'changed since indexing'
-[ "$(wc -l < "$TEST_LOG" | awk '{ print $1 }')" = "$old_log_lines" ] ||
-    fail 'mkpkg or pkin ran after stale recipe detection'
+expect_stale_definition recipe
 
 printf '%s\n' 'dependency resolution and install orchestration tests passed'
diff --git a/tests/test_git_repository.sh b/tests/test_git_repository.sh
new file mode 100755
index 0000000..3fb9b6b
--- /dev/null
+++ b/tests/test_git_repository.sh
@@ -0,0 +1,215 @@
+#!/bin/sh
+set -eu
+
+case $0 in */*) test_dir=${0%/*} ;; *) test_dir=. ;; esac
+project_dir=$(CDPATH= cd "$test_dir/.." 2>/dev/null && pwd)
+
+if ! command -v git >/dev/null 2>&1; then
+    printf '%s\n' 'git repository tests skipped: git is unavailable'
+    exit 0
+fi
+
+fail()
+{
+    printf 'test_git_repository: %s\n' "$*" >&2
+    exit 1
+}
+
+contains()
+{
+    case $1 in
+        *"$2"*) return 0 ;;
+        *) fail "expected output to contain: $2" ;;
+    esac
+}
+
+expect_update_failure()
+{
+    expected=$1
+    if "$src" update >"$tmp/failure.out" 2>"$tmp/failure.err"; then
+        fail "Git update unexpectedly succeeded: $expected"
+    fi
+    contains "$(sed -n '1,40p' "$tmp/failure.err")" "$expected"
+}
+
+write_seed_recipe()
+{
+    seed_version=$1
+    mkdir -p "$seed/base/alpha"
+    {
+        printf '%s\n' 'name alpha'
+        printf 'version %s\n' "$seed_version"
+        printf '%s\n' 'release 1'
+        printf 'description Git repository alpha %s\n' "$seed_version"
+        printf '%s\n' 'install true'
+    } >"$seed/base/alpha/recipe"
+}
+
+push_seed_version()
+{
+    pushed_version=$1
+    write_seed_recipe "$pushed_version"
+    git -C "$seed" add base/alpha/recipe
+    git -C "$seed" commit -m "alpha $pushed_version" >/dev/null
+    git -C "$seed" push origin main >/dev/null 2>&1
+}
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-git-repository.XXXXXX")
+trap 'rm -rf "$tmp"' 0 1 2 3 15
+
+origin=$tmp/core.git
+seed=$tmp/seed
+checkout=$tmp/checkouts/core
+mkdir -p "$tmp/root" "$tmp/cache" "$tmp/db" "$tmp/build"
+: >"$tmp/sps.conf"
+
+git init --bare "$origin" >/dev/null 2>&1
+git init "$seed" >/dev/null 2>&1
+git -C "$seed" config user.name 'SPS Test'
+git -C "$seed" config user.email 'sps-test@example.invalid'
+git -C "$seed" config commit.gpgsign false
+write_seed_recipe 1.0
+git -C "$seed" add base/alpha/recipe
+git -C "$seed" commit -m 'initial alpha' >/dev/null
+git -C "$seed" branch -M main
+git -C "$seed" remote add origin "$origin"
+git -C "$seed" push -u origin main >/dev/null 2>&1
+git -C "$origin" symbolic-ref HEAD refs/heads/main
+
+printf 'git core %s 100\n' "$origin" >"$tmp/repos.conf"
+SPS_ROOT=$tmp/root
+SPS_DB=$tmp/db
+SPS_CACHE=$tmp/cache
+SPS_BUILD=$tmp/build
+SPS_REPO_ROOT=$tmp/checkouts
+SPS_CONFIG=$tmp/sps.conf
+SPS_REPOS_CONFIG=$tmp/repos.conf
+export SPS_ROOT SPS_DB SPS_CACHE SPS_BUILD SPS_REPO_ROOT SPS_CONFIG
+export SPS_REPOS_CONFIG
+
+src=$project_dir/bin/src
+first_update=$("$src" update 2>"$tmp/clone.err")
+contains "$first_update" 'updated 1 repositories, 1 package records'
+contains "$(cat "$tmp/clone.err")" "cloned repository 'core'"
+[ -d "$checkout/.git" ] || fail 'Git checkout was not created below SPS_REPO_ROOT'
+[ "$("$src" show alpha --raw | awk -F '\t' '{ print $2 }')" = 1.0 ] ||
+    fail 'initial Git package was not indexed'
+normalized=$("$src" list --raw)
+printf '%s\n' "$normalized" | awk -F '\t' \
+    -v source="$origin" -v checkout="$checkout" '
+    NF == 6 && $1 == "git" && $2 == "core" && $3 == source &&
+    $4 == checkout && $5 == 100 && $6 == 1 { found = 1 }
+    END { exit !found }
+' || fail 'Git repository did not produce the canonical normalized record'
+contains "$("$src" status)" "$checkout"
+
+push_seed_version 2.0
+remote_v2=$(git -C "$seed" rev-parse HEAD)
+"$src" update >/dev/null 2>"$tmp/ff.err"
+[ "$(git -C "$checkout" rev-parse HEAD)" = "$remote_v2" ] ||
+    fail 'clean Git checkout was not fast-forwarded'
+[ "$("$src" show alpha --raw | awk -F '\t' '{ print $2 }')" = 2.0 ] ||
+    fail 'fast-forwarded recipe was not indexed'
+
+# Dirty worktrees fail before fetch, leaving HEAD, remote-tracking refs, content,
+# and the previous package index untouched.
+printf '%s\n' '# local tracked change' >>"$checkout/base/alpha/recipe"
+push_seed_version 3.0
+dirty_head=$(git -C "$checkout" rev-parse HEAD)
+dirty_remote=$(git -C "$checkout" rev-parse refs/remotes/origin/main)
+index=$tmp/cache/indexes/packages.index
+dirty_index=$(cksum "$index")
+expect_update_failure 'has local modifications'
+[ "$(git -C "$checkout" rev-parse HEAD)" = "$dirty_head" ] ||
+    fail 'dirty update moved HEAD'
+[ "$(git -C "$checkout" rev-parse refs/remotes/origin/main)" = "$dirty_remote" ] ||
+    fail 'dirty update fetched before refusing the worktree'
+grep -q 'local tracked change' "$checkout/base/alpha/recipe" ||
+    fail 'dirty update discarded tracked content'
+[ "$(cksum "$index")" = "$dirty_index" ] ||
+    fail 'dirty update replaced the previous index'
+
+git -C "$checkout" checkout -- base/alpha/recipe
+"$src" update >/dev/null 2>"$tmp/ff3.err"
+[ "$("$src" show alpha --raw | awk -F '\t' '{ print $2 }')" = 3.0 ] ||
+    fail 'checkout did not recover after tracked dirt was removed'
+
+printf '%s\n' untracked >"$checkout/untracked"
+untracked_index=$(cksum "$index")
+expect_update_failure 'has local modifications'
+[ -f "$checkout/untracked" ] || fail 'dirty update discarded an untracked file'
+[ "$(cksum "$index")" = "$untracked_index" ] ||
+    fail 'untracked update replaced the previous index'
+rm -f "$checkout/untracked"
+
+wrong_origin=https://reader:originsecret456@example.invalid/private.git
+git -C "$checkout" remote set-url origin "$wrong_origin"
+origin_head=$(git -C "$checkout" rev-parse HEAD)
+expect_update_failure 'origin does not match its configured source'
+if grep -F 'originsecret456' "$tmp/failure.err" >/dev/null ||
+   grep -F "$origin" "$tmp/failure.err" >/dev/null; then
+    fail 'origin mismatch error repeated a configured or actual origin'
+fi
+[ "$(git -C "$checkout" rev-parse HEAD)" = "$origin_head" ] ||
+    fail 'origin mismatch moved HEAD'
+git -C "$checkout" remote set-url origin "$origin"
+
+# Detached HEAD is a commit pin: fetch updates remote refs but does not move it.
+git -C "$checkout" checkout --detach HEAD >/dev/null 2>&1
+detached_head=$(git -C "$checkout" rev-parse HEAD)
+push_seed_version 4.0
+remote_v4=$(git -C "$seed" rev-parse HEAD)
+"$src" update >/dev/null 2>"$tmp/detached.err"
+contains "$(cat "$tmp/detached.err")" 'is detached and remains pinned'
+[ "$(git -C "$checkout" rev-parse HEAD)" = "$detached_head" ] ||
+    fail 'detached Git pin moved'
+[ "$(git -C "$checkout" rev-parse refs/remotes/origin/main)" = "$remote_v4" ] ||
+    fail 'detached Git pin did not fetch origin'
+[ "$("$src" show alpha --raw | awk -F '\t' '{ print $2 }')" = 3.0 ] ||
+    fail 'detached checkout did not index its pinned recipe'
+
+git -C "$checkout" checkout main >/dev/null 2>&1
+"$src" update >/dev/null 2>"$tmp/ff4.err"
+[ "$(git -C "$checkout" rev-parse HEAD)" = "$remote_v4" ] ||
+    fail 'main did not fast-forward after leaving detached state'
+
+# A branch without an upstream is also an explicit pin.
+git -C "$checkout" checkout -b pinned >/dev/null 2>&1
+git -C "$checkout" branch --unset-upstream >/dev/null 2>&1 || :
+no_upstream_head=$(git -C "$checkout" rev-parse HEAD)
+push_seed_version 5.0
+"$src" update >/dev/null 2>"$tmp/no-upstream.err"
+contains "$(cat "$tmp/no-upstream.err")" 'has no upstream and remains pinned'
+[ "$(git -C "$checkout" rev-parse HEAD)" = "$no_upstream_head" ] ||
+    fail 'branch without an upstream moved'
+[ "$("$src" show alpha --raw | awk -F '\t' '{ print $2 }')" = 4.0 ] ||
+    fail 'no-upstream branch did not index its pinned recipe'
+
+git -C "$checkout" checkout main >/dev/null 2>&1
+"$src" update >/dev/null 2>"$tmp/ff5.err"
+[ "$("$src" show alpha --raw | awk -F '\t' '{ print $2 }')" = 5.0 ] ||
+    fail 'main did not fast-forward after leaving no-upstream branch'
+
+# Clean local commits are preserved. Once origin independently advances, the
+# histories diverge and src must refuse rather than reset either side.
+printf '%s\n' '# local committed policy' >>"$checkout/base/alpha/recipe"
+git -C "$checkout" add base/alpha/recipe
+git -C "$checkout" -c user.name='SPS Test' \
+    -c user.email='sps-test@example.invalid' commit -m 'local policy' >/dev/null
+local_ahead_head=$(git -C "$checkout" rev-parse HEAD)
+"$src" update >/dev/null 2>"$tmp/ahead.err"
+contains "$(cat "$tmp/ahead.err")" 'is ahead of origin/main and was left unchanged'
+[ "$(git -C "$checkout" rev-parse HEAD)" = "$local_ahead_head" ] ||
+    fail 'locally ahead branch moved'
+
+push_seed_version 6.0
+diverged_index=$(cksum "$index")
+expect_update_failure 'has diverged from origin/main'
+[ "$(git -C "$checkout" rev-parse HEAD)" = "$local_ahead_head" ] ||
+    fail 'diverged update moved local HEAD'
+grep -q 'local committed policy' "$checkout/base/alpha/recipe" ||
+    fail 'diverged update discarded local commit content'
+[ "$(cksum "$index")" = "$diverged_index" ] ||
+    fail 'diverged update replaced the previous index'
+
+printf '%s\n' 'Git repository synchronization tests passed'
diff --git a/tests/test_recipe_contract.sh b/tests/test_recipe_contract.sh
new file mode 100644
index 0000000..dd61eaa
--- /dev/null
+++ b/tests/test_recipe_contract.sh
@@ -0,0 +1,229 @@
+#!/bin/sh
+set -eu
+
+case $0 in */*) test_dir=${0%/*} ;; *) test_dir=. ;; esac
+project_dir=$(CDPATH= cd "$test_dir/.." 2>/dev/null && pwd) || exit 1
+mkpkg=$project_dir/bin/mkpkg
+recipe_parser=$project_dir/lib/recipe.awk
+
+fail()
+{
+    printf 'test_recipe_contract: %s\n' "$*" >&2
+    exit 1
+}
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-recipe-contract.XXXXXX") ||
+    fail 'cannot create temporary directory'
+trap 'rm -rf "$tmp"' 0 1 2 3 15
+
+recipe_digest=0000000000000000000000000000000000000000000000000000000000000000
+
+parse_recipe()
+{
+    parse_path=$1
+    awk -v recipe_repo=test -v recipe_priority=10 \
+        -v recipe_path="$parse_path" -v recipe_sha256="$recipe_digest" \
+        -v recipe_default_arch=testarch -f "$recipe_parser" "$parse_path"
+}
+
+expect_parse_failure()
+{
+    parse_label=$1
+    parse_expected=$2
+    parse_path=$3
+    set +e
+    parse_recipe "$parse_path" >"$tmp/$parse_label.out" 2>"$tmp/$parse_label.err"
+    parse_status=$?
+    set -e
+    [ "$parse_status" -eq 2 ] ||
+        fail "$parse_label returned $parse_status instead of 2"
+    grep -F "$parse_expected" "$tmp/$parse_label.err" >/dev/null ||
+        fail "$parse_label did not report '$parse_expected'"
+}
+
+run_builder()
+{
+    build_label=$1
+    build_recipe=$2
+    mkdir -p "$tmp/out-$build_label"
+    SPS_ROOT=$tmp/root \
+    SPS_DB=$tmp/db \
+    SPS_CACHE=$tmp/cache \
+    SPS_BUILD=$tmp/build \
+    SPS_CONFIG=/dev/null \
+    SPS_REPOS_CONFIG=/dev/null \
+    SPS_COMPRESSION=none \
+    SPS_ARCH=testarch \
+        "$mkpkg" --no-download --output "$tmp/out-$build_label" "$build_recipe"
+}
+
+expect_builder_failure()
+{
+    build_label=$1
+    build_expected=$2
+    build_recipe=$3
+    set +e
+    run_builder "$build_label" "$build_recipe" \
+        >"$tmp/$build_label.out" 2>"$tmp/$build_label.err"
+    build_status=$?
+    set -e
+    [ "$build_status" -eq 4 ] ||
+        fail "$build_label returned $build_status instead of 4"
+    grep -F "$build_expected" "$tmp/$build_label.err" >/dev/null ||
+        fail "$build_label did not report '$build_expected'"
+}
+
+mkdir -p "$tmp/valid"
+cat >"$tmp/valid/recipe" <<'EOF_VALID'
+name contract-valid
+version 1.0
+release 1
+arch any
+source source.tar
+hash sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+install true
+EOF_VALID
+valid_record=$(parse_recipe "$tmp/valid/recipe") ||
+    fail 'complete recipe was rejected by the index parser'
+printf '%s\n' "$valid_record" | awk -F '\t' '
+    NF == 12 && $1 == "contract-valid" && $2 == "1.0" &&
+    $3 == "1" && $4 == "any" { valid=1 }
+    END { exit !valid }
+' || fail 'complete recipe produced the wrong index identity'
+
+mkdir -p "$tmp/missing-install"
+cat >"$tmp/missing-install/recipe" <<'EOF_MISSING_INSTALL'
+name missing-install
+version 1.0
+release 1
+arch any
+EOF_MISSING_INSTALL
+expect_parse_failure missing-install "missing required 'install' command" \
+    "$tmp/missing-install/recipe"
+
+mkdir -p "$tmp/missing-hash"
+cat >"$tmp/missing-hash/recipe" <<'EOF_MISSING_HASH'
+name missing-hash
+version 1.0
+release 1
+arch any
+source source.tar
+install true
+EOF_MISSING_HASH
+expect_parse_failure missing-hash 'each source must have one corresponding hash' \
+    "$tmp/missing-hash/recipe"
+
+mkdir -p "$tmp/skip-hash"
+cat >"$tmp/skip-hash/recipe" <<'EOF_SKIP_HASH'
+name skip-hash
+version 1.0
+release 1
+arch any
+source source.tar
+hash skip
+install true
+EOF_SKIP_HASH
+expect_parse_failure skip-hash 'expected sha256:<64 hex digits>' \
+    "$tmp/skip-hash/recipe"
+expect_builder_failure skip-hash-builder 'unsupported source hash' \
+    "$tmp/skip-hash/recipe"
+
+mkdir -p "$tmp/duplicate-name"
+cat >"$tmp/duplicate-name/recipe" <<'EOF_DUPLICATE_NAME'
+name duplicate-name
+name duplicate-name-again
+version 1.0
+release 1
+arch any
+install true
+EOF_DUPLICATE_NAME
+expect_parse_failure duplicate-name "duplicate 'name' field" \
+    "$tmp/duplicate-name/recipe"
+
+mkdir -p "$tmp/bad-arch"
+cat >"$tmp/bad-arch/recipe" <<'EOF_BAD_ARCH'
+name bad-arch
+version 1.0
+release 1
+arch bad~arch
+install mkdir -p "$PKG/usr/share"
+install printf '%s\n' bad > "$PKG/usr/share/bad-arch"
+EOF_BAD_ARCH
+expect_parse_failure bad-arch "invalid architecture 'bad~arch'" \
+    "$tmp/bad-arch/recipe"
+expect_builder_failure bad-arch-builder "invalid arch 'bad~arch'" \
+    "$tmp/bad-arch/recipe"
+
+# Keep metadata substitution behavior identical between indexing and building,
+# including a value which introduces an earlier substitution token.
+mkdir -p "$tmp/expanded"
+cat >"$tmp/expanded/recipe" <<'EOF_EXPANDED'
+name expanded
+version ${arch}
+release 1
+arch ${name}
+install mkdir -p "$PKG/usr/share"
+install printf '%s\n' expanded > "$PKG/usr/share/expanded"
+EOF_EXPANDED
+expanded_record=$(parse_recipe "$tmp/expanded/recipe") ||
+    fail 'expanded identity was rejected by the index parser'
+printf '%s\n' "$expanded_record" | awk -F '\t' '
+    $1 == "expanded" && $2 == "expanded" && $3 == "1" &&
+    $4 == "expanded" { valid=1 }
+    END { exit !valid }
+' || fail 'index-time metadata expansion is incorrect'
+expanded_artifact=$(run_builder expanded "$tmp/expanded/recipe") ||
+    fail 'builder disagreed with the index parser about expanded identity'
+[ "$expanded_artifact" = "$tmp/out-expanded/expanded-expanded-1-expanded.pkg.tar" ] ||
+    fail "builder produced an unexpected expanded identity: $expanded_artifact"
+
+# `any` is canonical for official recipes, while `noarch` remains an accepted
+# compatibility spelling in both parsers.
+mkdir -p "$tmp/noarch"
+cat >"$tmp/noarch/recipe" <<'EOF_NOARCH'
+name noarch-compatible
+version 1.0
+release 1
+arch noarch
+install mkdir -p "$PKG/usr/share"
+install printf '%s\n' noarch > "$PKG/usr/share/noarch-compatible"
+EOF_NOARCH
+noarch_record=$(parse_recipe "$tmp/noarch/recipe") ||
+    fail 'index parser rejected the noarch compatibility spelling'
+[ "$(printf '%s\n' "$noarch_record" | awk -F '\t' '{ print $4 }')" = noarch ] ||
+    fail 'index parser changed the noarch compatibility spelling'
+noarch_artifact=$(run_builder noarch "$tmp/noarch/recipe") ||
+    fail 'builder rejected the noarch compatibility spelling'
+[ "$noarch_artifact" = \
+  "$tmp/out-noarch/noarch-compatible-1.0-1-noarch.pkg.tar" ] ||
+    fail "builder produced an unexpected noarch identity: $noarch_artifact"
+
+# Support trees and hook directories are visible package inputs. Refuse unsafe
+# top-level forms rather than following or silently ignoring them.
+mkdir -p "$tmp/support-target" "$tmp/support-link"
+cat >"$tmp/support-link/recipe" <<'EOF_SUPPORT_LINK'
+name support-link
+version 1.0
+release 1
+arch any
+install mkdir -p "$PKG/usr/share"
+install printf '%s\n' support > "$PKG/usr/share/support-link"
+EOF_SUPPORT_LINK
+ln -s "$tmp/support-target" "$tmp/support-link/files"
+expect_builder_failure support-link 'recipe files must be a real directory' \
+    "$tmp/support-link/recipe"
+
+mkdir -p "$tmp/bad-hook/hooks"
+cat >"$tmp/bad-hook/recipe" <<'EOF_BAD_HOOK'
+name bad-hook
+version 1.0
+release 1
+arch any
+install mkdir -p "$PKG/usr/share"
+install printf '%s\n' hook > "$PKG/usr/share/bad-hook"
+EOF_BAD_HOOK
+printf '%s\n' true >"$tmp/bad-hook/hooks/not-a-hook"
+expect_builder_failure bad-hook "unsupported recipe hook 'not-a-hook'" \
+    "$tmp/bad-hook/recipe"
+
+printf '%s\n' 'test_recipe_contract: ok'
diff --git a/tests/test_repository.sh b/tests/test_repository.sh
index 9b1ad1a..1b9b730 100755
--- a/tests/test_repository.sh
+++ b/tests/test_repository.sh
@@ -3,10 +3,6 @@ set -eu
 
 case $0 in */*) test_dir=${0%/*} ;; *) test_dir=. ;; esac
 project_dir=$(CDPATH= cd "$test_dir/.." 2>/dev/null && pwd)
-if [ ! -r "$project_dir/lib/common.sh" ]; then
-    printf '%s\n' 'repository tests skipped: lib/common.sh is not integrated'
-    exit 0
-fi
 
 fail()
 {
@@ -22,96 +18,99 @@ contains()
     esac
 }
 
+expect_update_failure()
+{
+    expected=$1
+    if "$src" update >"$tmp/failure.out" 2>"$tmp/failure.err"; then
+        fail "repository update unexpectedly succeeded: $expected"
+    fi
+    contains "$(sed -n '1,30p' "$tmp/failure.err")" "$expected"
+}
+
+write_recipe()
+{
+    recipe_path=$1
+    recipe_name=$2
+    recipe_version=$3
+    shift 3
+    mkdir -p "${recipe_path%/*}"
+    {
+        printf 'name %s\n' "$recipe_name"
+        printf 'version %s\n' "$recipe_version"
+        printf '%s\n' 'release 1'
+        printf 'description Repository test package %s\n' "$recipe_name"
+        for recipe_field
+        do
+            printf '%s\n' "$recipe_field"
+        done
+        printf '%s\n' 'install true'
+    } >"$recipe_path"
+}
+
 tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-repository.XXXXXX")
 trap 'rm -rf "$tmp"' 0 1 2 3 15
 
 mkdir -p "$tmp/root" "$tmp/cache" "$tmp/db" "$tmp/build" \
-    "$tmp/core/base/alpha" "$tmp/core/base/tie" "$tmp/core/z/tie" \
-    "$tmp/extra/apps/alpha" "$tmp/later/apps/tie"
-: > "$tmp/sps.conf"
+    "$tmp/core/base" "$tmp/extra/apps" "$tmp/later/apps"
+: >"$tmp/sps.conf"
+
+write_recipe "$tmp/core/base/alpha/recipe" alpha 1.0 'depend tie'
+write_recipe "$tmp/core/base/tie/recipe" tie 1.0
+write_recipe "$tmp/extra/apps/alpha/recipe" alpha 2.0 \
+    'optional unavailable-optional'
+write_recipe "$tmp/later/apps/tie/recipe" tie 9.0
+
+# Support trees may legitimately contain a file named recipe; they are data,
+# not additional package definitions.
+for support in files patches hooks .git; do
+    mkdir -p "$tmp/core/base/alpha/$support/nested"
+    printf '%s\n' 'this is not package metadata' \
+        >"$tmp/core/base/alpha/$support/nested/recipe"
+done
 
 {
-    printf '%s\n' 'name alpha'
-    printf '%s\n' 'version 1.0'
-    printf '%s\n' 'release 1'
-    printf '%s\n' 'description Core alpha'
-    printf '%s\n' 'depend tie'
-    printf '%s\n' 'install true'
-} > "$tmp/core/base/alpha/recipe"
-{
-    printf '%s\n' 'name tie'
-    printf '%s\n' 'version 1.0'
-    printf '%s\n' 'release 1'
-    printf '%s\n' 'description First configured tie'
-    printf '%s\n' 'install true'
-} > "$tmp/core/base/tie/recipe"
-{
-    printf '%s\n' 'name tie'
-    printf '%s\n' 'version 8.0'
-    printf '%s\n' 'release 1'
-    printf '%s\n' 'description Later path in the same repository'
-    printf '%s\n' 'install true'
-} > "$tmp/core/z/tie/recipe"
-{
-    printf '%s\n' 'name alpha'
-    printf '%s\n' 'version 2.0'
-    printf '%s\n' 'release 3'
-    printf '%s\n' 'description Alpha override \'
-    printf '%s\n' '  from the local repository'
-    printf '%s\n' 'install true'
-} > "$tmp/extra/apps/alpha/recipe"
-{
-    printf '%s\n' 'name tie'
-    printf '%s\n' 'version 9.0'
-    printf '%s\n' 'release 1'
-    printf '%s\n' 'description Later configured tie'
-    printf '%s\n' 'install true'
-} > "$tmp/later/apps/tie/recipe"
-
-{
-    printf 'repo core %s 10\n' "$tmp/core"
-    printf 'repo extra %s 20\n' "$tmp/extra"
+    printf 'dir core %s 10\n' "$tmp/core"
+    printf 'dir extra %s 20\n' "$tmp/extra"
     printf 'repo later %s 10\n' "$tmp/later"
-} > "$tmp/repos.conf"
+} >"$tmp/repos.conf"
 
 SPS_ROOT=$tmp/root
 SPS_DB=$tmp/db
 SPS_CACHE=$tmp/cache
 SPS_BUILD=$tmp/build
+SPS_REPO_ROOT=$tmp/checkouts
 SPS_CONFIG=$tmp/sps.conf
 SPS_REPOS_CONFIG=$tmp/repos.conf
-export SPS_ROOT SPS_DB SPS_CACHE SPS_BUILD SPS_CONFIG SPS_REPOS_CONFIG
+export SPS_ROOT SPS_DB SPS_CACHE SPS_BUILD SPS_REPO_ROOT SPS_CONFIG
+export SPS_REPOS_CONFIG
 
 src=$project_dir/bin/src
 update_output=$("$src" update)
-contains "$update_output" 'updated 3 repositories, 5 package records'
+contains "$update_output" 'updated 3 repositories, 4 package records'
 
 index=$tmp/cache/indexes/packages.index
 [ -r "$index" ] || fail 'aggregate index was not created'
-awk -F '	' 'NF != 12 { bad = 1 }
-             END { exit bad || NR != 5 }' "$index" ||
-    fail 'aggregate index does not contain five valid 12-field records'
+awk -F '\t' 'NF != 12 { bad = 1 }
+             END { exit bad || NR != 4 }' "$index" ||
+    fail 'aggregate index does not contain four valid records'
 
 alpha_raw=$("$src" show alpha --raw)
-[ "$(printf '%s\n' "$alpha_raw" | awk -F '	' '{ print $8 }')" = extra ] ||
+[ "$(printf '%s\n' "$alpha_raw" | awk -F '\t' '{ print $8 }')" = extra ] ||
     fail 'higher-priority repository did not win'
-[ "$(printf '%s\n' "$alpha_raw" | awk -F '	' '{ print $2 "-" $3 }')" = 2.0-3 ] ||
+[ "$(printf '%s\n' "$alpha_raw" | awk -F '\t' '{ print $2 "-" $3 }')" = 2.0-1 ] ||
     fail 'selected package version is wrong'
 
 tie_raw=$("$src" show tie --raw)
-[ "$(printf '%s\n' "$tie_raw" | awk -F '	' '{ print $8 }')" = core ] ||
-    fail 'repository configuration order did not break an equal-priority tie'
-[ "$(printf '%s\n' "$tie_raw" | awk -F '	' '{ print $10 }')" = \
-   "$tmp/core/base/tie/recipe" ] ||
-    fail 'recipe path order did not break an equal-priority intra-repository tie'
+[ "$(printf '%s\n' "$tie_raw" | awk -F '\t' '{ print $8 }')" = core ] ||
+    fail 'configuration order did not break an equal-priority cross-repository tie'
 
-search_output=$("$src" search 'LOCAL REPOSITORY')
+search_output=$("$src" search 'REPOSITORY TEST')
 contains "$search_output" 'alpha'
-contains "$search_output" 'extra'
+contains "$search_output" 'tie'
 
 show_output=$("$src" show alpha)
-contains "$show_output" 'description: Alpha override'
 contains "$show_output" 'repository: extra'
+contains "$show_output" 'optional dependencies: unavailable-optional'
 
 which_output=$("$src" which alpha)
 contains "$which_output" 'selected: extra'
@@ -120,18 +119,84 @@ contains "$which_output" 'core'
 
 list_output=$("$src" list)
 contains "$list_output" 'core'
+contains "$list_output" 'dir'
 contains "$list_output" 'priority 20'
+list_raw=$("$src" list --raw)
+[ "$(printf '%s\n' "$list_raw" | awk -F '\t' 'NR == 3 { print $1 }')" = dir ] ||
+    fail 'repo compatibility alias was not normalized to dir'
 status_output=$("$src" status)
-contains "$status_output" 'core: 3 packages'
+contains "$status_output" 'core: 2 packages'
 contains "$status_output" 'extra: 1 packages'
 
 old_index=$(cksum "$index")
-printf 'repo remote https://example.invalid/sps 100\n' >> "$tmp/repos.conf"
-if "$src" update > "$tmp/remote.out" 2> "$tmp/remote.err"; then
-    fail 'remote repository unexpectedly succeeded in SPS 1.0'
+cp "$tmp/repos.conf" "$tmp/repos.good"
+
+printf '%s\n' 'dir malformed' >>"$tmp/repos.conf"
+expect_update_failure "expected 'git NAME SOURCE"
+[ "$(cksum "$index")" = "$old_index" ] ||
+    fail 'malformed configuration replaced the previous index'
+cp "$tmp/repos.good" "$tmp/repos.conf"
+
+printf 'dir too-many %s 1 trailing-field\n' "$tmp/core" >>"$tmp/repos.conf"
+expect_update_failure "expected 'git NAME SOURCE"
+cp "$tmp/repos.good" "$tmp/repos.conf"
+
+printf '%s\n' 'dir relative relative/path 1' >>"$tmp/repos.conf"
+expect_update_failure 'requires an absolute path'
+cp "$tmp/repos.good" "$tmp/repos.conf"
+
+printf 'dir bad-priority %s high\n' "$tmp/core" >>"$tmp/repos.conf"
+expect_update_failure "invalid priority 'high'"
+cp "$tmp/repos.good" "$tmp/repos.conf"
+
+printf 'dir huge %s 2147483648\n' "$tmp/core" >>"$tmp/repos.conf"
+expect_update_failure 'priority out of range'
+cp "$tmp/repos.good" "$tmp/repos.conf"
+
+printf 'dir core %s 30\n' "$tmp/core" >>"$tmp/repos.conf"
+expect_update_failure "duplicate repository 'core'"
+cp "$tmp/repos.good" "$tmp/repos.conf"
+
+printf 'bogus bad %s 1\n' "$tmp/core" >>"$tmp/repos.conf"
+expect_update_failure "expected 'git NAME SOURCE"
+cp "$tmp/repos.good" "$tmp/repos.conf"
+
+printf '%s\n' \
+    'git credential https://reader:supersecret123@example.invalid/packages.git 1' \
+    >>"$tmp/repos.conf"
+expect_update_failure 'credential-bearing HTTP(S) Git source'
+if grep -F 'supersecret123' "$tmp/failure.err" >/dev/null; then
+    fail 'credential-bearing Git source was repeated in an error'
 fi
-contains "$(sed -n '1,20p' "$tmp/remote.err")" 'unsupported remote location'
+cp "$tmp/repos.good" "$tmp/repos.conf"
+
+write_recipe "$tmp/core/other/tie/recipe" tie 3.0
+expect_update_failure "duplicate package 'tie' in repository 'core'"
 [ "$(cksum "$index")" = "$old_index" ] ||
-    fail 'a failed update replaced the previous aggregate index'
+    fail 'duplicate package replaced the previous index'
+rm -rf "$tmp/core/other"
+
+write_recipe "$tmp/core/base/broken-runtime/recipe" broken-runtime 1.0 \
+    'depend unavailable-runtime'
+expect_update_failure "dependency 'unavailable-runtime'"
+[ "$(cksum "$index")" = "$old_index" ] ||
+    fail 'missing dependency replaced the previous index'
+rm -rf "$tmp/core/base/broken-runtime"
+
+write_recipe "$tmp/core/base/broken-build/recipe" broken-build 1.0 \
+    'builddep unavailable-build'
+expect_update_failure "dependency 'unavailable-build'"
+rm -rf "$tmp/core/base/broken-build"
+
+write_recipe "$tmp/core/base/cycle-a/recipe" cycle-a 1.0 'depend cycle-b'
+write_recipe "$tmp/core/base/cycle-b/recipe" cycle-b 1.0 'depend cycle-a'
+expect_update_failure 'dependency cycle:'
+rm -rf "$tmp/core/base/cycle-a" "$tmp/core/base/cycle-b"
+
+mkdir "$tmp/cache/.src-update"
+printf '%s\n' $$ >"$tmp/cache/.src-update/pid"
+expect_update_failure 'another repository update is active'
+rm -rf "$tmp/cache/.src-update"
 
+"$src" update >/dev/null || fail 'repository did not recover after failed updates'
 printf '%s\n' 'repository and query tests passed'