Splux Linux

source-based Linux using SPS

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

0137cde


first commit

Date
Commit0137cde66340bff6d5ec07fb1cdaf7aa5b2fae0c
GitHubmirror

Parents

Stat

 .gitignore                 |   7 +
 DESIGN.md                  | 246 ++++++++++++++
 LICENSE                    |  15 +
 MANIFESTO.md               | 269 ++++++++++++++++
 Makefile                   |  63 ++++
 README.md                  | 185 +++++++++++
 VERSION                    |   1 +
 bin/mkpkg                  | 783 +++++++++++++++++++++++++++++++++++++++++++++
 bin/pkcheck                | 330 +++++++++++++++++++
 bin/pkdel                  | 331 +++++++++++++++++++
 bin/pkin                   | 763 +++++++++++++++++++++++++++++++++++++++++++
 bin/pkmark                 |  90 ++++++
 bin/pkstat                 | 247 ++++++++++++++
 bin/sget                   | 616 +++++++++++++++++++++++++++++++++++
 bin/src                    | 277 ++++++++++++++++
 examples/README            |  18 ++
 examples/hello/hello.sh    |   3 +
 examples/hello/recipe      |  14 +
 examples/repos.conf        |   5 +
 examples/sps.conf          |  10 +
 lib/common.awk             |  37 +++
 lib/common.sh              | 352 ++++++++++++++++++++
 lib/config.awk             |  27 ++
 lib/db.awk                 |  71 ++++
 lib/deps.awk               | 222 +++++++++++++
 lib/index.awk              | 184 +++++++++++
 lib/package.awk            | 179 +++++++++++
 lib/recipe.awk             | 215 +++++++++++++
 lib/relations.awk          |  95 ++++++
 lib/repository.awk         | 130 ++++++++
 lib/version.awk            | 146 +++++++++
 man/mkpkg.1                |  82 +++++
 man/pkcheck.1              |  39 +++
 man/pkdel.1                |  35 ++
 man/pkin.1                 |  59 ++++
 man/pkmark.1               |  29 ++
 man/pkstat.1               |  42 +++
 man/sget.1                 | 100 ++++++
 man/sps.conf.5             |  98 ++++++
 man/src.1                  |  61 ++++
 tests/run                  |  30 ++
 tests/test_build.sh        | 159 +++++++++
 tests/test_common.sh       |  49 +++
 tests/test_compression.sh  |  81 +++++
 tests/test_dependencies.sh | 195 +++++++++++
 tests/test_highlevel.sh    | 122 +++++++
 tests/test_install.sh      |  69 ++++
 tests/test_packages.sh     | 163 ++++++++++
 tests/test_release.sh      | 152 +++++++++
 tests/test_repository.sh   | 137 ++++++++
 tests/test_security.sh     | 226 +++++++++++++
 51 files changed, 7859 insertions(+)

Diff

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..eea5120
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+*.pkg.tar
+*.pkg.tar.gz
+*.pkg.tar.xz
+*.pkg.tar.zst
+*.tmp
+.sps-*
+
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..31ed3fa
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,246 @@
+# SPS 1.0 formats and behavior
+
+This file documents the interfaces that packages and distro tooling can depend
+on in SPS Package metadata,
+indexes, and installed records are meant to be inspectable and scriptable.
+
+The current package format is `format 1`. If a later SPS release needs an
+incompatible package layout, it must use a new format number.
+
+## Recipe format
+
+A recipe is a line-oriented file named `recipe`. Each logical line is a key,
+whitespace, then a value. A trailing backslash continues the value on the next
+physical line. Blank lines and lines beginning with `#` are ignored.
+
+Required fields:
+
+```text
+name
+version
+release
+```
+
+`arch` is optional and defaults to the configured architecture.
+
+Repeatable metadata fields:
+
+```text
+source
+hash
+depend
+builddep
+optional
+```
+
+`description` is a single-value field.
+
+Build phases are repeatable command fields:
+
+```text
+prepare
+configure
+build
+install
+```
+
+Reading recipe metadata never executes those commands.
+
+Hashes correspond to sources in declaration order and use this form:
+
+```text
+sha256:HEX
+```
+
+`skip` is accepted only when `mkpkg` is run with its explicit insecure checksum
+override.
+
+A recipe directory may also contain `files/`, `patches/`, and `hooks/`. The
+only hook names accepted by 1.0 are:
+
+```text
+pre-install
+post-install
+pre-remove
+post-remove
+```
+
+Hook entries must be regular files, not symlinks.
+
+## Repository index
+
+Indexes live under `$SPS_CACHE/indexes`. The first line identifies the schema.
+Each package record after that is tab-separated and contains:
+
+```text
+name version release arch depends builddeps optional repo priority recipe_path description recipe_sha256
+```
+
+Dependency lists are comma-separated. Field values may not contain tabs or
+newlines.
+
+When more than one configured repository supplies the same package, SPS picks
+the highest numeric priority. Configuration order breaks equal priorities,
+then lexical recipe path breaks any remaining tie. The result is deterministic.
+
+`src update` indexes local directories. Moving repository data onto the machine
+is outside the SPS package format and is left to the distribution.
+
+## Binary package format
+
+An SPS package is a tar archive, optionally compressed with gzip, xz, or zstd.
+Archive member names are relative to the target root.
+
+Control data lives in `.SPS/`:
+
+```text
+.SPS/meta
+.SPS/files
+.SPS/hashes
+.SPS/hooks/       optional
+usr/...
+etc/...
+```
+
+`.SPS/meta` contains at least:
+
+```text
+format 1
+name VALUE
+version VALUE
+release VALUE
+arch VALUE
+```
+
+`.SPS/files` is a sorted list of normalized payload paths. Directory entries
+end in `/`.
+
+`.SPS/hashes` contains one record for each regular file:
+
+```text
+sha256<TAB>digest<TAB>path
+```
+
+Before committing installed metadata, `pkin` rejects malformed control files,
+absolute or traversing paths, unsupported control members, unsafe symlink
+parents, manifest/archive mismatches, and bad payload hashes.
+
+Packages marked `any` or `noarch` can be installed on any architecture. Other
+package architectures must match `SPS_ARCH`.
+
+## Lifecycle hooks
+
+Hooks are stored in the package and run with `/bin/sh -eu`.
+
+The hook environment includes normal SPS configuration plus:
+
+```text
+SPS_ACTION=install|upgrade|remove
+SPS_PACKAGE=name
+SPS_VERSION=version
+SPS_RELEASE=release
+SPS_PACKAGE_ARCH=arch
+```
+
+`SPS_RELEASE` and `SPS_PACKAGE_ARCH` apply to install and upgrade hooks.
+
+A failing pre-hook stops the requested payload change. A failing post-hook
+returns status 10 after the package database operation has already committed.
+The history entry still records the operation that actually happened.
+
+## Installed database
+
+Installed package records live at:
+
+```text
+$SPS_DB/installed/NAME/
+```
+
+A record contains `meta`, `files`, `hashes`, and optional `hooks/`.
+
+Other database files are:
+
+```text
+$SPS_DB/owners    path<TAB>package for managed non-directory paths
+$SPS_DB/world     one explicit package name per line
+$SPS_DB/history   append-only human-readable operation log
+```
+
+Writers create temporary siblings and rename them into place. Low-level package
+writers use `$SPS_DB/.lock`. State-changing `sget` operations also use
+`$SPS_DB/.transaction` so a dependency operation cannot interleave with
+another high-level dependency operation.
+
+Read-only queries and plans do not take the high-level transaction lock.
+
+A lock is considered stale only when it contains a numeric PID that can be
+verified as no longer running. SPS does not remove an unreadable or malformed
+lock on a guess.
+
+Database-file replacement is atomic. Installation of an entire package payload
+is not, and SPS does not claim whole-root filesystem atomicity.
+
+## Explicit packages and dependencies
+
+`world` is the set of explicit roots.
+
+`pkin --explicit` and `pkmark explicit` add a package to it. `pkin --dependency`
+and `pkmark dependency` leave or remove a package from it without uninstalling
+the package.
+
+`pkstat --orphans` walks installed runtime dependencies starting from `world`.
+Any dependency-installed package that cannot be reached from an explicit root
+is reported as an orphan.
+
+## File ownership and conflicts
+
+A managed non-directory path has one SPS owner. `pkin` checks both the owner
+index and the target filesystem before writing a payload entry.
+
+An existing unowned file is a conflict. SPS does not silently claim it.
+
+Directories are shared and are not stored in the exclusive owner index.
+Existing shared directories are not re-owned or chmodded just because another
+package contains the same directory entry.
+
+When installation runs as uid 0, newly installed regular payload files and new
+package directories are normalized to uid/gid 0 while retaining their packaged
+permission modes.
+
+## Protected files
+
+`SPS_PRESERVE` is a comma-separated list of package-relative path prefixes. The
+default is `etc`. Set it to `none` to disable this behavior.
+
+For a protected regular file that differs from the hash recorded by the
+installed package:
+
+- removal leaves the local file in place and drops SPS ownership;
+- upgrade leaves the local file in place;
+- if the new package also changed that file, its copy is written beside the
+  local file as `.sps-new`, or as `.sps-new.VERSION-RELEASE` if needed to avoid
+  a collision.
+
+SPS reports the situation and leaves merging to the administrator.
+
+## Binary package cache
+
+`sget` stores completed package artifacts under `$SPS_CACHE/packages`.
+
+An exact name/version/release/architecture package may be reused instead of
+being rebuilt. The cached archive's embedded package identity is checked before
+it is trusted.
+
+If a distribution changes build inputs or package behavior without changing
+the upstream version, it must bump the package release.
+
+## Dependency resolution
+
+Runtime and build dependencies form a directed graph. `sget` computes a stable
+topological order and rejects missing dependencies and cycles.
+
+`sget upgrade` starts with the explicit `world` set and resolves its selected
+dependency closure. `sget dependees` shows reverse dependencies. `sget why`
+shows one dependency path between two selected packages.
+
+`pkin` and `pkdel` do no dependency solving.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..409cde7
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 SPS contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/MANIFESTO.md b/MANIFESTO.md
new file mode 100644
index 0000000..78777e3
--- /dev/null
+++ b/MANIFESTO.md
@@ -0,0 +1,269 @@
+# SPS Source Package System
+
+## What SPS is
+
+SPS is a source package system for Unix-like operating systems. It is meant for
+small independent distributions and for administrators who want to understand
+how packages get from a recipe into the filesystem.
+
+It is built from a few separate tools, plain text metadata, POSIX shell, POSIX
+AWK, and standard Unix utilities. There is no package daemon, SQL database,
+plugin framework, or hidden update service.
+
+CRUX is an obvious influence. The useful part to borrow is not its command
+names or file formats, but the idea that package management can stay small,
+readable, and close to the filesystem. SPS keeps that idea and builds its own
+system around it.
+
+The point is not to make the smallest package manager possible. The point is to
+keep the machinery understandable while still having the things a real distro
+needs: dependency ordering, package ownership, upgrades, repository indexes,
+checksums, binary package reuse, configuration-file protection, and recovery
+from bad package state.
+
+## The tool split
+
+SPS is not one command with a large subcommand tree.
+
+```text
+src       source repository index and queries
+sget      dependency-aware package coordination
+mkpkg     recipe to binary package
+pkin      binary package installation and upgrade
+pkdel     low-level package removal
+pkstat    installed package queries
+pkcheck   installed-state verification
+pkmark    explicit/dependency package marking
+```
+
+Each tool has a boundary.
+
+`src` reads source repositories and writes the local index. It never installs a
+package.
+
+`mkpkg` reads one recipe, builds it in a staging area, and writes one package
+archive. It does not copy package files into the live root.
+
+`pkin` installs one already-built package. It knows about package format,
+filesystem conflicts, ownership, protected files, and the local package
+database. It does not search repositories or resolve dependencies.
+
+`pkdel` removes one package from the installed system. Dependency policy belongs
+to `sget`, not `pkdel`.
+
+`pkstat` and `pkcheck` are readers. They should stay useful even if the source
+repositories are offline or missing.
+
+`sget` is the convenient layer. It reads the repository index, resolves the
+package graph, calls `mkpkg` when it needs an artifact, and hands finished
+packages to `pkin` or names to `pkdel`.
+
+This split is useful beyond aesthetics. During rescue work, installation, or
+package development, the lower-level tools can be used directly without
+bringing the whole dependency layer along.
+
+## What a package is
+
+An SPS binary package is a tar archive with a small `.SPS/` control directory.
+The payload is laid out the same way it will appear below the target root.
+
+A package records its name, version, release, architecture, file list, regular
+file hashes, dependency metadata, and optional lifecycle hooks. Nothing is kept
+in an opaque package database format.
+
+The archive can be inspected with normal tools. The installed record can be
+read with normal tools. If SPS itself is unavailable during recovery, the state
+is still understandable.
+
+The exact package format is documented in [DESIGN.md](DESIGN.md).
+
+## Recipes
+
+Recipes are line-oriented text files. Metadata can be read without executing
+the build commands. This matters for repository indexing and for auditing a
+source tree.
+
+A normal recipe contains:
+
+```text
+name
+version
+release
+arch
+source
+hash
+depend
+builddep
+optional
+description
+prepare
+configure
+build
+install
+```
+
+Only the phase fields are executed as build commands. Name, version,
+dependencies, sources, and hashes remain data.
+
+SPS does not try to invent a replacement for shell or for existing build
+systems. A package recipe can call `make`, `cmake`, `ninja`, `meson`, or a
+project-specific script as needed.
+
+Builds happen away from the target root. The install phase writes below `$PKG`,
+which becomes the package payload. A recipe that writes directly into the live
+system is broken.
+
+## Repositories
+
+An SPS source repository is a directory tree containing recipes. It can live in
+Git, be mirrored with rsync, arrive as a tarball, or just exist on disk.
+
+SPS only indexes that tree. It does not own the transport mechanism.
+
+That keeps `src update` simple and also means a distribution is free to choose
+how its package trees are published. A local administrator repository is not a
+special case. It is just another repository, usually with a higher priority.
+
+Repository indexes are cached TSV records. Queries do not walk thousands of
+recipe files every time somebody runs `sget search`.
+
+## Dependencies
+
+Dependencies form a graph. SPS topologically orders that graph for builds and
+installs, rejects missing dependencies, and reports cycles rather than trying
+to guess around them.
+
+The installed `world` file contains packages that were explicitly requested.
+Packages pulled in only as dependencies are tracked separately through that
+root set. `pkstat --orphans` can therefore report dependency subgraphs that are
+no longer reachable from any explicit package.
+
+SPS 1.0 does not have an SAT solver, virtual providers, or a large policy
+language. A small source distribution normally controls its own repository and
+can keep dependency choices deterministic. If a future distribution runs into
+a real problem that needs more machinery, that feature can be added then.
+
+## Installed state
+
+The installed database lives under `/var/lib/sps` by default. Each installed
+package gets its own record containing metadata, its manifest, hashes, and any
+hooks. A separate owner index maps managed non-directory paths to packages.
+
+The database is plain text because administrators sometimes have to repair
+systems by hand. That should be possible without reverse engineering a private
+serialization format.
+
+SPS uses locks around package database writers and a higher-level transaction
+lock around state-changing `sget` operations. Database files are replaced by
+writing a temporary file and renaming it into place.
+
+This does not make the whole root filesystem transactional. SPS does not claim
+that it does. Package installation still consists of filesystem writes.
+
+## File ownership
+
+Managed files have one package owner. Before installation, `pkin` checks both
+the owner index and the target filesystem.
+
+If an unowned file already exists where a package wants to install one, that is
+a conflict. SPS does not silently take it over.
+
+Directories are shared. They are kept out of the exclusive owner index, and an
+existing shared directory is not re-owned or chmodded just because a new
+package lists it.
+
+When `pkin` runs as root, newly installed payload files and new package
+directories are normalized to uid/gid 0 while keeping their packaged modes.
+This prevents a package built by an ordinary user from leaking the builder's
+numeric UID or GID into the installed system.
+
+## Configuration files
+
+The default protected prefix is `etc`.
+
+If an installed protected regular file has been edited locally, an upgrade
+keeps the local copy. When the packaged default changed too, SPS writes the new
+copy beside it as `.sps-new`. Removal also leaves a locally modified protected
+file alone and drops package ownership of it.
+
+There is no automatic merge. The administrator gets both versions and decides
+what to do.
+
+The protected-prefix list is configurable, and it can be disabled.
+
+## Hooks
+
+SPS supports four package hooks:
+
+```text
+pre-install
+post-install
+pre-remove
+post-remove
+```
+
+That is enough for the cases where installing files is not sufficient, such as
+refreshing a loader cache or another system-maintained index.
+
+Hooks are regular files stored in the package. They are not downloaded later
+and are not hidden behind a trigger database. Package maintainers should avoid
+them when ordinary files or build-time work can solve the same problem.
+
+There is no generic plugin interface in 1.0.
+
+## Binary package cache
+
+SPS is source-oriented, but there is no reason to rebuild an identical package
+on every reinstall. `sget` keeps completed binary artifacts under the SPS cache
+and reuses an exact name/version/release/architecture match.
+
+The recipe is still authoritative. If a distribution changes how a package is
+built without changing the upstream version, the package release must be
+bumped. This is normal package-maintainer responsibility, not something the
+cache should try to infer.
+
+## Alternate roots
+
+The whole system can be pointed at another root with `SPS_ROOT`. This is useful
+for installers, rescue systems, chroots, and image construction.
+
+A package tool should not need a separate special mode just because the target
+filesystem is mounted at `/mnt/root` instead of `/`.
+
+## Why AWK
+
+Most package-manager state is text: recipe fields, indexes, manifests,
+dependency lists, package records, and ownership tables. AWK is very good at
+that work and is available on small systems.
+
+Using it also keeps the project honest. SPS does not need a language runtime,
+module registry, or dependency tree of its own just to manage the operating
+system's packages.
+
+AWK is not used for jobs it is bad at. SPS calls existing tools for archive
+handling, compression, hashing, downloads, patching, and builds. Reimplementing
+those in AWK would make the system larger and worse for no practical benefit.
+
+The target is POSIX AWK, not a GNU awk application that happens to use an AWK
+file extension.
+
+## KISS in SPS
+
+KISS does not mean refusing every feature. It means a feature has to pay for
+the complexity it adds.
+
+A few rules keep SPS on track:
+
+1. A simple command should have a simple path through the code.
+2. Read-only commands should not contact the network or modify state.
+3. Low-level tools should not grow policy that belongs in `sget`.
+4. Metadata should stay inspectable with normal Unix tools.
+5. New abstractions need a real use case, not just a cleaner diagram.
+6. Existing Unix programs should be used when they already solve the job well.
+7. Hidden background behavior is out of scope.
+8. Errors should say what failed and leave enough state to diagnose it.
+9. A distro maintainer should be able to read the implementation without
+   learning a framework first.
+10. When a feature cannot be implemented cleanly yet, it can wait.
+
+# And uh yeah thats about it
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..64182f9
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,63 @@
+POSIX_SHELL = /bin/sh
+PREFIX = /usr
+BINDIR = $(PREFIX)/bin
+LIBDIR = $(PREFIX)/lib/sps
+MANDIR = $(PREFIX)/share/man
+SYSCONFDIR = /etc/sps
+DESTDIR =
+
+PROGRAMS = src sget mkpkg pkin pkdel pkstat pkcheck pkmark
+AWK_LIBS = common.awk config.awk db.awk deps.awk index.awk package.awk recipe.awk repository.awk relations.awk version.awk
+
+.PHONY: all check lint install install-config clean
+
+all:
+	@printf '%s\n' "SPS is interpreted; run 'make check' or 'make install'."
+
+check: lint
+	$(POSIX_SHELL) tests/run
+
+lint:
+	@set -e; for program in $(PROGRAMS); do \
+		$(POSIX_SHELL) -n bin/$$program; \
+	done
+	@set -e; for library in lib/*.sh; do \
+		$(POSIX_SHELL) -n $$library; \
+	done
+	@# Pure library modules can be parsed without an action or fixture.  The
+	@# executable AWK modules validate their input in END rules, so the test
+	@# suite below is their syntax + behavior check rather than invoking them
+	@# against an intentionally invalid empty stream.
+	@awk -f lib/common.awk </dev/null >/dev/null
+	@awk -f lib/common.awk -f lib/config.awk </dev/null >/dev/null
+	@awk -f lib/version.awk </dev/null >/dev/null
+
+install:
+	install -d "$(DESTDIR)$(BINDIR)" "$(DESTDIR)$(LIBDIR)" \
+		"$(DESTDIR)$(MANDIR)/man1" "$(DESTDIR)$(MANDIR)/man5"
+	@set -e; for program in $(PROGRAMS); do \
+		install -m 0755 bin/$$program "$(DESTDIR)$(BINDIR)/$$program"; \
+	done
+	@set -e; for library in lib/*.awk lib/*.sh; do \
+		install -m 0644 $$library "$(DESTDIR)$(LIBDIR)/$${library##*/}"; \
+	done
+	@set -e; for page in man/*.1; do \
+		install -m 0644 $$page "$(DESTDIR)$(MANDIR)/man1/$${page##*/}"; \
+	done
+	@set -e; for page in man/*.5; do \
+		install -m 0644 $$page "$(DESTDIR)$(MANDIR)/man5/$${page##*/}"; \
+	done
+
+# Configuration is separate so installation never overwrites administrator
+# policy. Existing files are deliberately left untouched.
+install-config:
+	install -d "$(DESTDIR)$(SYSCONFDIR)"
+	@test -e "$(DESTDIR)$(SYSCONFDIR)/sps.conf" || \
+		install -m 0644 examples/sps.conf "$(DESTDIR)$(SYSCONFDIR)/sps.conf"
+	@test -e "$(DESTDIR)$(SYSCONFDIR)/repos.conf" || \
+		install -m 0644 examples/repos.conf "$(DESTDIR)$(SYSCONFDIR)/repos.conf"
+
+clean:
+	@find . -type f \( -name '*.pkg.tar' -o -name '*.pkg.tar.gz' -o \
+		-name '*.pkg.tar.xz' -o -name '*.pkg.tar.zst' \) -exec rm -f {} \;
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..82421d7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,185 @@
+# SPS Source Package System
+
+SPS is a source package system for small, administrator-run Unix-like systems.
+It is made from separate command-line tools, plain text state, POSIX shell,
+POSIX AWK, and normal tar archives.
+
+The layout is influenced by CRUX and older Unix package tools, but SPS is not a
+CRUX compatibility project. It has its own recipes, package metadata, repository
+index, dependency handling, commands, and upgrade rules.
+
+If you REALLY want the project rationale and the boundaries of the design, read
+[MANIFESTO.md](MANIFESTO.md). The exact 1.0 file formats and behavior are in
+[DESIGN.md](DESIGN.md).
+
+## Tools
+
+SPS is a set of tools rather than one large front end:
+
+- `src` indexes and queries source repositories.
+- `sget` handles dependency-aware installs, upgrades, removals, and queries.
+- `mkpkg` builds a binary package from a recipe.
+- `pkin` installs or upgrades one binary package.
+- `pkdel` removes one installed package.
+- `pkstat` reads the installed package database.
+- `pkcheck` checks installed files and database consistency.
+- `pkmark` changes whether an installed package is explicit or dependency-only.
+
+The split matters. `pkin` does not search repositories. `pkdel` does not solve
+dependencies. `src` does not install anything. `sget` ties the lower-level tools
+together when you want the usual package-manager workflow.
+
+## Requirements
+
+SPS does not compile into a binary. The core needs a POSIX `sh`, a POSIX `awk`,
+and a fairly normal Unix userland:
+
+```text
+tar find sort sed grep comm cmp cp mv rm mkdir ls mktemp
+```
+
+A SHA-256 tool is also required. `sha256sum`, `sha256`, `shasum`, and `openssl`
+are supported where available.
+
+Compression support depends on the package or source being used. Install
+`gzip`, `xz`, or `zstd` if you want those formats. Remote HTTP or HTTPS sources
+need `curl` or `wget` when building.
+
+## Test it
+
+```sh
+make check
+```
+
+The tests use temporary roots. They cover repository selection, dependency
+ordering, package creation, install and removal, upgrades, file ownership,
+protected configuration files, hooks, package compression, the installed
+layout, locks, architecture checks, and hostile archive/database cases.
+
+## Install it
+
+For packaging into a staging root:
+
+```sh
+make DESTDIR="$pkgdir" PREFIX=/usr install
+make DESTDIR="$pkgdir" install-config
+```
+
+For a direct system install:
+
+```sh
+sudo make PREFIX=/usr install
+sudo make install-config
+```
+
+`install-config` leaves existing `sps.conf` and `repos.conf` alone. A
+distribution can skip it and ship its own configuration instead.
+
+## Set up a repository
+
+SPS repositories are local directory trees. Put this in `/etc/sps/repos.conf`:
+
+```text
+repo core /usr/src/core 100
+```
+
+Then build the local index:
+
+```sh
+src update
+src search shell
+sget info bash
+```
+
+SPS does not decide how `/usr/src/core` gets updated. Use `git`, `rsync`, a
+release archive, or whatever fits the distribution. Repository transport and
+repository indexing are separate jobs.
+
+## Normal use
+
+The high-level interface is `sget`:
+
+```sh
+sget install neovim
+sget upgrade --plan
+sget upgrade
+sget depends firefox
+sget dependees openssl
+sget why firefox libxml2
+sget remove neovim
+```
+
+The lower-level tools are always available:
+
+```sh
+mkpkg /usr/src/local/foo/recipe
+pkin foo-1.0-1-x86_64.pkg.tar.zst
+pkstat foo
+pkcheck foo
+pkdel foo
+```
+
+This is useful for recovery work, distro installers, package development, and
+cases where the administrator wants to bypass dependency policy on purpose.
+
+## Alternate roots
+
+`SPS_ROOT` points SPS at another filesystem tree. Database, cache, and build
+paths are rooted below it unless you override those paths separately.
+
+For example:
+
+```sh
+SPS_ROOT=/mnt/root pkin base-1.0-1-x86_64.pkg.tar.zst
+```
+
+That makes SPS usable from an installer or rescue system without needing a
+special package-install mode.
+
+## Upgrades and configuration files
+
+By default, regular files below `etc` are protected when an administrator has
+changed them locally. During an upgrade SPS keeps the local file. If the new
+package also changed its copy, the new one is written beside it as `.sps-new`
+(or a versioned `.sps-new` name if necessary).
+
+SPS never attempts to merge the two files automatically.
+
+`pkin` is still a direct installer. `sget upgrade` starts from the explicit
+`world` set, resolves that dependency closure, and upgrades it in dependency
+order. A locally newer version is kept unless `--downgrade` is requested.
+
+## Hooks
+
+A recipe may ship these files in a `hooks/` directory:
+
+```text
+pre-install
+post-install
+pre-remove
+post-remove
+```
+
+They are copied into the package and can be inspected before installation.
+Hooks run with `/bin/sh -eu`. There is no generic plugin loader or background
+trigger service.
+
+Use hooks sparingly. Most packages should just install files.
+
+## Files and state
+
+The defaults are:
+
+```text
+/etc/sps/       configuration
+/var/lib/sps/   installed records, ownership, world, history
+/var/cache/sps/ sources, repository indexes, built packages
+/var/tmp/sps/   build and staging directories
+```
+
+Package state is text. Binary packages are tar archives, optionally compressed
+with gzip, xz, or zstd.
+
+Nothing in SPS needs a daemon or a database server, and no command refreshes a
+repository in the background. If you do not run a state-changing command, SPS
+does not change the system. Its intentionally minimal ;)
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..3eefcb9
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1.0.0
diff --git a/bin/mkpkg b/bin/mkpkg
new file mode 100755
index 0000000..a06beae
--- /dev/null
+++ b/bin/mkpkg
@@ -0,0 +1,783 @@
+#!/bin/sh
+# Build one SPS package from a recipe.
+
+set -eu
+
+prog=${0##*/}
+keep_build=0
+no_download=0
+compression=
+compression_set=0
+output_dir=.
+recipe=
+build_dir=
+stage_dir=
+
+usage()
+{
+	cat <<EOF
+usage: $prog [options] [recipe]
+
+Build the given recipe (default: ./recipe) into an SPS package archive.
+
+options:
+  -o, --output DIR          write the package to DIR
+  -C, --compression TYPE   auto, none, gzip, xz, or zstd
+      --no-download        use only local sources and cached downloads
+      --keep-build         retain build and staging directories
+  -h, --help               show this help
+EOF
+}
+
+say()
+{
+	printf '%s: %s\n' "$prog" "$*" >&2
+}
+
+die()
+{
+	say "$*"
+	exit 1
+}
+
+die_status()
+{
+	die_code=$1
+	shift
+	say "$*"
+	exit "$die_code"
+}
+
+cleanup()
+{
+	cleanup_status=$?
+	trap - 0 HUP INT TERM
+	if [ "$keep_build" -eq 1 ]; then
+		[ -z "$build_dir" ] || say "build directory retained at $build_dir"
+		[ -z "$stage_dir" ] || say "stage directory retained at $stage_dir"
+	else
+		[ -z "$build_dir" ] || rm -rf "$build_dir"
+		[ -z "$stage_dir" ] || rm -rf "$stage_dir"
+	fi
+	exit "$cleanup_status"
+}
+
+trap cleanup 0
+trap 'exit 129' HUP
+trap 'exit 130' INT
+trap 'exit 143' TERM
+
+while [ "$#" -gt 0 ]; do
+	case $1 in
+		-o|--output)
+			[ "$#" -ge 2 ] || die_status 2 "$1 requires a directory"
+			output_dir=$2
+			shift 2
+			;;
+		--output=*)
+			output_dir=${1#*=}
+			[ -n "$output_dir" ] || die_status 2 "--output requires a directory"
+			shift
+			;;
+		-C|--compression)
+			[ "$#" -ge 2 ] || die_status 2 "$1 requires a type"
+			compression=$2
+			compression_set=1
+			shift 2
+			;;
+		--compression=*)
+			compression=${1#*=}
+			compression_set=1
+			[ -n "$compression" ] || die_status 2 "--compression requires a type"
+			shift
+			;;
+		--no-download)
+			no_download=1
+			shift
+			;;
+		--keep-build)
+			keep_build=1
+			shift
+			;;
+		-h|--help)
+			usage
+			exit 0
+			;;
+		--)
+			shift
+			break
+			;;
+		-*)
+			die_status 2 "unknown option: $1"
+			;;
+		*)
+			[ -z "$recipe" ] || die_status 2 "only one recipe may be specified"
+			recipe=$1
+			shift
+			;;
+	esac
+done
+
+if [ "$#" -gt 0 ]; then
+	[ -z "$recipe" ] || die_status 2 "only one recipe may be specified"
+	[ "$#" -eq 1 ] || die_status 2 "only one recipe may be specified"
+	recipe=$1
+fi
+
+[ -n "$recipe" ] || recipe=./recipe
+if [ -d "$recipe" ]; then
+	recipe=$recipe/recipe
+fi
+[ -f "$recipe" ] || die_status 3 "recipe not found: $recipe"
+[ -r "$recipe" ] || die_status 8 "recipe is not readable: $recipe"
+
+script_dir=$(CDPATH= cd "$(dirname "$0")" 2>/dev/null && pwd) ||
+	die "cannot determine program directory"
+common_file=
+if [ -n "${SPS_LIBDIR:-}" ] && [ -r "$SPS_LIBDIR/common.sh" ]; then
+	common_file=$SPS_LIBDIR/common.sh
+elif [ -r "$script_dir/../lib/common.sh" ]; then
+	# Running from the source tree.
+	common_file=$script_dir/../lib/common.sh
+elif [ -r "$script_dir/../lib/sps/common.sh" ]; then
+	# Normal installed layout.
+	common_file=$script_dir/../lib/sps/common.sh
+elif [ -r /usr/lib/sps/common.sh ]; then
+	common_file=/usr/lib/sps/common.sh
+elif [ -r /usr/local/lib/sps/common.sh ]; then
+	common_file=/usr/local/lib/sps/common.sh
+fi
+[ -n "$common_file" ] || die "cannot locate lib/common.sh (set SPS_LIBDIR)"
+
+# shellcheck source=../lib/common.sh
+. "$common_file"
+command -v sps_load_config >/dev/null 2>&1 ||
+	die "lib/common.sh does not provide sps_load_config"
+sps_load_config
+
+if [ "$compression_set" -eq 0 ]; then
+	compression=${SPS_COMPRESSION:-auto}
+fi
+case $compression in
+	zst) compression=zstd ;;
+esac
+case $compression in
+	auto|none|gzip|xz|zstd) ;;
+	*) die_status 2 "unsupported compression type '$compression'" ;;
+esac
+
+: "${SPS_ROOT:=/}"
+: "${SPS_DB:=$SPS_ROOT/var/lib/sps}"
+: "${SPS_CACHE:=$SPS_ROOT/var/cache/sps}"
+: "${SPS_BUILD:=${TMPDIR:-/tmp}/sps}"
+
+makejobs=${MAKEJOBS:-${SPS_MAKEJOBS:-1}}
+case $makejobs in
+	''|*[!0-9]*|0) die_status 2 "MAKEJOBS must be a positive integer" ;;
+esac
+
+command -v awk >/dev/null 2>&1 || die "awk is required"
+command -v tar >/dev/null 2>&1 || die "tar is required"
+command -v mktemp >/dev/null 2>&1 || die "mktemp is required"
+
+recipe_dir=$(CDPATH= cd "$(dirname "$recipe")" 2>/dev/null && pwd) ||
+	die "cannot enter recipe directory"
+recipe_file=$recipe_dir/$(basename "$recipe")
+default_arch=${SPS_ARCH:-}
+if [ -z "$default_arch" ]; then
+    default_arch=$(uname -m 2>/dev/null) || die "cannot determine build architecture"
+fi
+
+mkdir -p "$SPS_BUILD/build" "$SPS_BUILD/stage" "$SPS_CACHE/sources" ||
+	die_status 8 "cannot create SPS build directories"
+build_dir=$(mktemp -d "$SPS_BUILD/build/mkpkg.XXXXXX") ||
+	die_status 8 "cannot create a secure build directory"
+stage_dir=$(mktemp -d "$SPS_BUILD/stage/mkpkg.XXXXXX") ||
+	die_status 8 "cannot create a secure stage directory"
+
+records=$build_dir/recipe.records
+parse_errors=$build_dir/recipe.errors
+
+# Fold continued recipe lines into key/value records. Metadata stays data here;
+# build commands are handled later.
+if ! awk -v err="$parse_errors" '
+function report(n, message) {
+	print FILENAME ":" n ": " message > err
+	bad = 1
+}
+function allowed(key) {
+	return key == "name" || key == "version" || key == "release" ||
+	       key == "arch" || key == "description" || key == "source" ||
+	       key == "hash" || key == "depend" || key == "builddep" ||
+	       key == "optional" || key == "prepare" || key == "configure" ||
+	       key == "build" || key == "install"
+}
+function emit(text, n, key, value, at) {
+	sub(/^[[:space:]]+/, "", text)
+	if (text == "" || text ~ /^#/) return
+	at = match(text, /[[:space:]]/)
+	if (!at) {
+		report(n, "expected a key followed by a value")
+		return
+	}
+	key = substr(text, 1, at - 1)
+	value = substr(text, at + 1)
+	sub(/^[[:space:]]+/, "", value)
+	sub(/[[:space:]]+$/, "", value)
+	if (!allowed(key)) {
+		report(n, "unknown recipe key \047" key "\047")
+		return
+	}
+	if (value == "") {
+		report(n, "empty value for \047" key "\047")
+		return
+	}
+	if (value ~ /\t/) {
+		report(n, "tab characters are not permitted in values")
+		return
+	}
+	print key "\t" value
+}
+{
+	line = $0
+	sub(/\r$/, "", line)
+	if (continued) {
+		sub(/^[[:space:]]+/, "", line)
+		logical = logical line
+	} else {
+		logical = line
+		start = NR
+	}
+	if (logical ~ /\\[[:space:]]*$/) {
+		sub(/\\[[:space:]]*$/, "", logical)
+		logical = logical " "
+		continued = 1
+		next
+	}
+	emit(logical, start)
+	logical = ""
+	continued = 0
+}
+END {
+	if (continued) report(start, "unterminated continuation")
+	if (bad) exit 1
+}
+' "$recipe_file" >"$records"; then
+	[ ! -s "$parse_errors" ] || cat "$parse_errors" >&2
+	die_status 4 "invalid recipe"
+fi
+
+# Read name/version/release/arch without sourcing the recipe.
+expanded=$build_dir/recipe.expanded
+validation_errors=$build_dir/validation.errors
+if ! awk -F '\t' -v err="$validation_errors" -v default_arch="$default_arch" '
+function report(message) {
+	print FILENAME ": " message > err
+	bad = 1
+}
+function replace(text, token, value, at, out) {
+	out = ""
+	while ((at = index(text, token)) != 0) {
+		out = out substr(text, 1, at - 1) value
+		text = substr(text, at + length(token))
+	}
+	return out text
+}
+NR == FNR {
+	key = $1
+	value = substr($0, index($0, "\t") + 1)
+	if (key == "name" || key == "version" || key == "release" ||
+	    key == "arch" || key == "description") {
+		count[key]++
+		if (count[key] > 1) report("duplicate \047" key "\047 record")
+		value_for[key] = value
+	}
+	if (key == "source") source_count++
+	if (key == "hash") hash_count++
+	if (key == "install") install_count++
+	next
+}
+{
+	if (!second_file) {
+		second_file = 1
+		if (!("arch" in value_for)) {
+			value_for["arch"] = default_arch
+			print "arch\t" default_arch
+		}
+	}
+	key = $1
+	value = substr($0, index($0, "\t") + 1)
+	value = replace(value, "${name}", value_for["name"])
+	value = replace(value, "${version}", value_for["version"])
+	value = replace(value, "${release}", value_for["release"])
+	value = replace(value, "${arch}", value_for["arch"])
+	print key "\t" value
+}
+END {
+	if (!("name" in value_for)) report("missing required \047name\047 record")
+	if (!("version" in value_for)) report("missing required \047version\047 record")
+	if (!("release" in value_for)) report("missing required \047release\047 record")
+	if (source_count != hash_count)
+		report("each source must have one corresponding hash")
+	if (!install_count) report("missing required \047install\047 command")
+	if (bad) exit 1
+}
+' "$records" "$records" >"$expanded"; then
+	[ ! -s "$validation_errors" ] || cat "$validation_errors" >&2
+	die_status 4 "invalid recipe"
+fi
+
+record_values()
+{
+	awk -F '\t' -v wanted="$1" '
+	$1 == wanted { print substr($0, index($0, "\t") + 1) }
+	' "$expanded"
+}
+
+name=$(record_values name)
+version=$(record_values version)
+release=$(record_values release)
+arch=$(record_values arch)
+
+for field in name version release arch; do
+	case $field in
+		name) value=$name ;;
+		version) value=$version ;;
+		release) value=$release ;;
+		arch) value=$arch ;;
+	esac
+	if [ "$field" = name ]; then
+		case $value in
+			''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._+-]*)
+				die_status 4 "invalid $field '$value'"
+				;;
+		esac
+	else
+		case $value in
+			''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._+~-]*)
+				die_status 4 "invalid $field '$value'"
+				;;
+		esac
+	fi
+	case $value in
+		[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789]*) ;;
+		*) die_status 4 "$field must begin with an alphanumeric character" ;;
+	esac
+done
+
+sources=$build_dir/sources.list
+hashes=$build_dir/source-hashes.list
+record_values source >"$sources"
+record_values hash >"$hashes"
+
+if ! awk -v err="$validation_errors" '
+{
+	value = $0
+	if (value !~ /^sha256:/) {
+		print "unsupported source hash \047" value "\047 (expected sha256:<hex>)" > err
+		bad = 1
+		next
+	}
+	hex = substr(value, 8)
+	if (length(hex) != 64 || hex !~ /^[0123456789abcdefABCDEF]+$/) {
+		print "invalid sha256 hash \047" value "\047" > err
+		bad = 1
+	}
+}
+END { if (bad) exit 1 }
+' "$hashes"; then
+	cat "$validation_errors" >&2
+	die_status 4 "invalid recipe"
+fi
+
+sha256_file()
+{
+    sps_sha256 "$1"
+}
+
+verify_source()
+{
+	verify_file=$1
+	verify_record=$2
+	verify_expected=${verify_record#sha256:}
+	verify_expected=$(printf '%s\n' "$verify_expected" | tr 'ABCDEF' 'abcdef')
+	verify_actual=$(sha256_file "$verify_file") ||
+		die_status 10 "cannot hash source $verify_file"
+	verify_actual=$(printf '%s\n' "$verify_actual" | tr 'ABCDEF' 'abcdef')
+	[ "$verify_actual" = "$verify_expected" ] ||
+		die_status 6 "checksum failure for ${verify_file##*/}: expected $verify_expected, got $verify_actual"
+}
+
+source_name()
+{
+	source_value=$1
+	case $source_value in
+		http://*|https://*)
+			source_leaf=${source_value%%\?*}
+			source_leaf=${source_leaf%%\#*}
+			;;
+		file://*) source_leaf=${source_value#file://} ;;
+		*) source_leaf=$source_value ;;
+	esac
+	source_leaf=${source_leaf%/}
+	source_leaf=${source_leaf##*/}
+	case $source_leaf in
+		''|.|..|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._+~-]*)
+			die_status 4 "source has an unsafe or unusable file name: $source_value"
+			;;
+	esac
+	printf '%s\n' "$source_leaf"
+}
+
+download_http()
+{
+	download_url=$1
+	download_dest=$2
+	if command -v curl >/dev/null 2>&1; then
+		curl -fL -o "$download_dest" "$download_url"
+	elif command -v wget >/dev/null 2>&1; then
+		wget -O "$download_dest" "$download_url"
+	else
+		die_status 9 "cannot download $download_url: curl or wget is required"
+	fi
+}
+
+source_dir=$build_dir/sources
+unpack_dir=$build_dir/work
+mkdir -p "$source_dir" "$unpack_dir"
+source_pairs=$build_dir/source-pairs
+awk 'NR == FNR { source[NR] = $0; next }
+     { print source[FNR] "\t" $0 }' "$sources" "$hashes" >"$source_pairs"
+acquired=$build_dir/acquired.list
+: >"$acquired"
+
+while IFS='	' read -r source hash_record; do
+	[ -n "$source" ] || continue
+	leaf=$(source_name "$source")
+	destination=$source_dir/$leaf
+	if [ -e "$destination" ] || [ -L "$destination" ]; then
+		die_status 4 "duplicate source file name '$leaf'"
+	fi
+
+	case $source in
+		http://*|https://*)
+			expected=${hash_record#sha256:}
+			cache_file=$SPS_CACHE/sources/$expected-$leaf
+			if [ -f "$cache_file" ]; then
+				verify_source "$cache_file" "$hash_record"
+				cp "$cache_file" "$destination" || die "cannot copy cached source $leaf"
+			else
+				[ "$no_download" -eq 0 ] ||
+					die_status 9 "source is not cached and downloads are disabled: $source"
+				download_tmp=$(mktemp "$SPS_CACHE/sources/.download.XXXXXX") ||
+					die_status 8 "cannot create source download file"
+				if ! download_http "$source" "$download_tmp"; then
+					rm -f "$download_tmp"
+					die_status 9 "download failed: $source"
+				fi
+				if ! verify_source "$download_tmp" "$hash_record"; then
+					rm -f "$download_tmp"
+					exit 1
+				fi
+				chmod 644 "$download_tmp"
+				if ! mv "$download_tmp" "$cache_file"; then
+					rm -f "$download_tmp"
+					die "cannot commit source to cache: $cache_file"
+				fi
+				cp "$cache_file" "$destination" || die "cannot copy downloaded source $leaf"
+			fi
+			;;
+		file://*)
+			local_source=${source#file://}
+			[ -f "$local_source" ] || die_status 3 "local source not found: $source"
+			cp "$local_source" "$destination" || die "cannot copy source $source"
+			verify_source "$destination" "$hash_record"
+			;;
+		*)
+			case $source in
+				/*) local_source=$source ;;
+				*) local_source=$recipe_dir/$source ;;
+			esac
+			[ -f "$local_source" ] || die_status 3 "local source not found: $source"
+			cp "$local_source" "$destination" || die "cannot copy source $source"
+			verify_source "$destination" "$hash_record"
+			;;
+	esac
+	printf '%s\n' "$destination" >>"$acquired"
+done <"$source_pairs"
+
+for support in files patches; do
+	if [ -d "$recipe_dir/$support" ] || [ -L "$recipe_dir/$support" ]; then
+		[ -d "$recipe_dir/$support" ] || die "$recipe_dir/$support is not a directory"
+		if [ -e "$source_dir/$support" ] || [ -L "$source_dir/$support" ]; then
+			die_status 4 "source conflicts with recipe $support directory"
+		fi
+		cp -R "$recipe_dir/$support" "$source_dir/$support" ||
+			die "cannot copy recipe $support directory"
+	fi
+done
+
+archive_list()
+{
+	archive=$1
+	case $archive in
+		*.tar) tar -tf "$archive" ;;
+		*.tar.gz|*.tgz) gzip -dc "$archive" | tar -tf - ;;
+		*.tar.xz|*.txz) xz -dc "$archive" | tar -tf - ;;
+		*.tar.zst|*.tzst) zstd -q -dc "$archive" | tar -tf - ;;
+		*.tar.bz2|*.tbz2) bzip2 -dc "$archive" | tar -tf - ;;
+		*) return 2 ;;
+	esac
+}
+
+archive_extract()
+{
+	archive=$1
+	case $archive in
+		*.tar) tar -xf "$archive" -C "$unpack_dir" ;;
+		*.tar.gz|*.tgz) gzip -dc "$archive" | tar -xf - -C "$unpack_dir" ;;
+		*.tar.xz|*.txz) xz -dc "$archive" | tar -xf - -C "$unpack_dir" ;;
+		*.tar.zst|*.tzst) zstd -q -dc "$archive" | tar -xf - -C "$unpack_dir" ;;
+		*.tar.bz2|*.tbz2) bzip2 -dc "$archive" | tar -xf - -C "$unpack_dir" ;;
+		*) return 2 ;;
+	esac
+}
+
+while IFS= read -r archive; do
+	case $archive in
+		*.tar|*.tar.gz|*.tgz|*.tar.xz|*.txz|*.tar.zst|*.tzst|*.tar.bz2|*.tbz2)
+			members=$build_dir/archive.members
+			if ! archive_list "$archive" >"$members"; then
+				die "cannot list source archive ${archive##*/}"
+			fi
+			if ! awk '
+			{
+				path = $0
+				while (sub(/^\.\//, "", path)) { }
+				if (path == "" || path == ".") next
+				if (path ~ /^\// || path ~ /(^|\/)\.\.($|\/)/) {
+					print "unsafe archive member: " $0
+					bad = 1
+				}
+			}
+			END { if (bad) exit 1 }
+			' "$members" >"$build_dir/archive.errors"; then
+				cat "$build_dir/archive.errors" >&2
+				die_status 4 "refusing unsafe source archive ${archive##*/}"
+			fi
+			archive_extract "$archive" || die "cannot extract source archive ${archive##*/}"
+			;;
+	esac
+done <"$acquired"
+
+top_entries=$build_dir/top.entries
+(CDPATH= cd "$unpack_dir" && find . ! -name . -prune -print) |
+	LC_ALL=C sort >"$top_entries"
+top_count=$(awk 'END { print NR + 0 }' "$top_entries")
+if [ "$top_count" -eq 1 ]; then
+	top_entry=$(sed -n '1p' "$top_entries")
+	top_entry=${top_entry#./}
+	if [ -d "$unpack_dir/$top_entry" ]; then
+		work_dir=$unpack_dir/$top_entry
+	else
+		work_dir=$unpack_dir
+	fi
+else
+	work_dir=$unpack_dir
+fi
+
+# Write each build phase to a temporary script. These are the recipe fields we
+# actually execute.
+for phase in prepare configure build install; do
+	record_values "$phase" >"$build_dir/$phase.sh"
+done
+
+PKG=$stage_dir
+SRC=$source_dir
+WORK=$work_dir
+MAKEJOBS=$makejobs
+export PKG SRC WORK MAKEJOBS
+
+run_phase()
+{
+	phase_name=$1
+	phase_file=$build_dir/$phase_name.sh
+	[ -s "$phase_file" ] || return 0
+	say "running $phase_name phase"
+	if ! (CDPATH= cd "$WORK" && sh -eu "$phase_file"); then
+		die_status 10 "$phase_name phase failed"
+	fi
+}
+
+run_phase prepare
+run_phase configure
+run_phase build
+run_phase install
+
+if [ -e "$stage_dir/.SPS" ] || [ -L "$stage_dir/.SPS" ]; then
+	die_status 10 "install phase attempted to provide reserved .SPS metadata"
+fi
+
+# Hooks live beside the recipe. Only the four documented hook names are valid.
+if [ -e "$recipe_dir/hooks" ] || [ -L "$recipe_dir/hooks" ]; then
+    [ -d "$recipe_dir/hooks" ] && [ ! -L "$recipe_dir/hooks" ] ||
+        die_status 4 "recipe hooks must be a real directory"
+    hook_list=$build_dir/hooks.list
+    (CDPATH= cd "$recipe_dir/hooks" && find . ! -name . -prune -print) >"$hook_list"
+    while IFS= read -r hook_entry || [ -n "$hook_entry" ]; do
+        hook_name=${hook_entry#./}
+        case $hook_name in
+            pre-install|post-install|pre-remove|post-remove) ;;
+            *) die_status 4 "unsupported recipe hook '$hook_name'" ;;
+        esac
+        [ -f "$recipe_dir/hooks/$hook_name" ] && [ ! -L "$recipe_dir/hooks/$hook_name" ] ||
+            die_status 4 "hook '$hook_name' must be a regular file"
+    done <"$hook_list"
+fi
+
+special_files=$build_dir/special-files
+(CDPATH= cd "$stage_dir" &&
+	find . ! -type d ! -type f ! -type l -print) >"$special_files"
+if [ -s "$special_files" ]; then
+	sed 's#^./##; s#^#unsupported staged file type: #' "$special_files" >&2
+	die_status 10 "package contains unsupported filesystem objects"
+fi
+
+payload_count=$(CDPATH= cd "$stage_dir" && find . ! -name . -prune -print |
+	awk 'END { print NR + 0 }')
+[ "$payload_count" -gt 0 ] || die_status 10 "install phase produced an empty package"
+
+mkdir "$stage_dir/.SPS" || die "cannot create package metadata directory"
+if [ -d "$recipe_dir/hooks" ]; then
+    mkdir "$stage_dir/.SPS/hooks" || die "cannot create hook metadata directory"
+    for hook_name in pre-install post-install pre-remove post-remove; do
+        [ -f "$recipe_dir/hooks/$hook_name" ] || continue
+        cp "$recipe_dir/hooks/$hook_name" "$stage_dir/.SPS/hooks/$hook_name" ||
+            die "cannot copy hook '$hook_name'"
+        chmod 0644 "$stage_dir/.SPS/hooks/$hook_name" ||
+            die "cannot set hook mode '$hook_name'"
+    done
+fi
+meta=$stage_dir/.SPS/meta
+files=$stage_dir/.SPS/files
+file_hashes=$stage_dir/.SPS/hashes
+
+awk -F '\t' '
+BEGIN { print "format\t1" }
+$1 == "name" || $1 == "version" || $1 == "release" || $1 == "arch" ||
+$1 == "description" {
+	scalar[$1] = $0
+	next
+}
+$1 == "source" || $1 == "hash" || $1 == "depend" ||
+$1 == "builddep" || $1 == "optional" {
+	repeated[++count] = $0
+}
+END {
+	key[1] = "name"
+	key[2] = "version"
+	key[3] = "release"
+	key[4] = "arch"
+	key[5] = "description"
+	for (i = 1; i <= 5; i++)
+		if (key[i] in scalar) print scalar[key[i]]
+	for (i = 1; i <= count; i++) print repeated[i]
+}
+' "$expanded" >"$meta"
+
+(
+	CDPATH= cd "$stage_dir"
+	find . -type d -print | awk '
+	$0 != "." && $0 !~ /^\.\/\.SPS($|\/)/ {
+		sub(/^\.\//, "")
+		print $0 "/"
+	}'
+	find . ! -type d -print | awk '
+	$0 !~ /^\.\/\.SPS($|\/)/ {
+		sub(/^\.\//, "")
+		print
+	}'
+) | LC_ALL=C sort >"$files"
+
+: >"$file_hashes"
+while IFS= read -r payload; do
+	case $payload in
+		*/) continue ;;
+	esac
+	payload_file=$stage_dir/$payload
+	if [ -f "$payload_file" ] && [ ! -L "$payload_file" ]; then
+		payload_hash=$(sha256_file "$payload_file") ||
+			die_status 10 "cannot hash staged file $payload"
+		printf 'sha256\t%s\t%s\n' "$payload_hash" "$payload" >>"$file_hashes"
+	fi
+done <"$files"
+
+if [ "$compression" = auto ]; then
+	if command -v zstd >/dev/null 2>&1; then
+		compression=zstd
+	elif command -v xz >/dev/null 2>&1; then
+		compression=xz
+	elif command -v gzip >/dev/null 2>&1; then
+		compression=gzip
+	else
+		compression=none
+	fi
+fi
+
+case $compression in
+	zstd) command -v zstd >/dev/null 2>&1 || die_status 10 "zstd compression requested but zstd is unavailable" ;;
+	xz) command -v xz >/dev/null 2>&1 || die_status 10 "xz compression requested but xz is unavailable" ;;
+	gzip) command -v gzip >/dev/null 2>&1 || die_status 10 "gzip compression requested but gzip is unavailable" ;;
+esac
+
+mkdir -p "$output_dir" || die "cannot create output directory: $output_dir"
+output_dir=$(CDPATH= cd "$output_dir" 2>/dev/null && pwd) ||
+	die "cannot enter output directory"
+base=$name-$version-$release-$arch.pkg.tar
+case $compression in
+	none) suffix= ;;
+	gzip) suffix=.gz ;;
+	xz) suffix=.xz ;;
+	zstd) suffix=.zst ;;
+esac
+artifact=$output_dir/$base$suffix
+[ ! -e "$artifact" ] || die_status 7 "output package already exists: $artifact"
+
+plain_archive=$build_dir/$base
+if ! tar -cf "$plain_archive" -C "$stage_dir" .; then
+	die_status 10 "cannot create package archive"
+fi
+
+output_tmp=$(mktemp "$output_dir/.mkpkg.XXXXXX") ||
+	die_status 8 "cannot create temporary output file"
+case $compression in
+	none)
+		if ! cp "$plain_archive" "$output_tmp"; then
+			rm -f "$output_tmp"
+			die_status 10 "cannot write package archive"
+		fi
+		;;
+	gzip)
+		if ! gzip -n -c "$plain_archive" >"$output_tmp"; then
+			rm -f "$output_tmp"
+			die_status 10 "gzip compression failed"
+		fi
+		;;
+	xz)
+		if ! xz -c "$plain_archive" >"$output_tmp"; then
+			rm -f "$output_tmp"
+			die_status 10 "xz compression failed"
+		fi
+		;;
+	zstd)
+		if ! zstd -q -c "$plain_archive" >"$output_tmp"; then
+			rm -f "$output_tmp"
+			die_status 10 "zstd compression failed"
+		fi
+		;;
+esac
+chmod 644 "$output_tmp"
+mv "$output_tmp" "$artifact" || die "cannot commit package archive: $artifact"
+
+# Keep stdout clean for sget: one artifact path.
+printf '%s\n' "$artifact"
diff --git a/bin/pkcheck b/bin/pkcheck
new file mode 100755
index 0000000..429603b
--- /dev/null
+++ b/bin/pkcheck
@@ -0,0 +1,330 @@
+#!/bin/sh
+
+sps_prog=pkcheck
+sps_bin_dir=$(CDPATH= cd -P "$(dirname "$0")" 2>/dev/null && pwd) || exit 1
+if [ -z "${SPS_LIBDIR+x}" ]; then
+    if [ -r "$sps_bin_dir/../lib/common.sh" ]; then
+        SPS_LIBDIR=$sps_bin_dir/../lib
+    else
+        SPS_LIBDIR=$sps_bin_dir/../lib/sps
+    fi
+fi
+if ! . "$SPS_LIBDIR/common.sh"; then
+    printf '%s\n' "pkcheck: cannot load $SPS_LIBDIR/common.sh" >&2
+    exit 1
+fi
+sps_load_config || exit $?
+
+sps_usage()
+{
+    cat <<'EOF'
+usage: pkcheck [PACKAGE]
+       pkcheck --all
+       pkcheck --missing [PACKAGE]
+       pkcheck --modified [PACKAGE]
+       pkcheck --database
+EOF
+}
+
+sps_fail()
+{
+    sps_code=$1
+    shift
+    printf '%s: %s\n' "$sps_prog" "$*" >&2
+    exit "$sps_code"
+}
+
+sps_exists()
+{
+    [ -f "$1" ] || [ -d "$1" ] || [ -L "$1" ]
+}
+
+sps_target_path()
+{
+    case $SPS_ROOT in
+        /) sps_target=/$1 ;;
+        */) sps_target=$SPS_ROOT$1 ;;
+        *) sps_target=$SPS_ROOT/$1 ;;
+    esac
+}
+
+sps_db_issue()
+{
+    printf 'database: %s\n' "$*"
+    sps_db_bad=1
+}
+
+sps_work=$(sps_mktemp_dir "${TMPDIR:-/tmp}" check) ||
+    sps_fail 8 "cannot create a secure verification workspace"
+sps_cleanup()
+{
+    sps_status=$?
+    trap - 0 1 2 3 15
+    [ -n "$sps_work" ] && [ -d "$sps_work" ] && rm -rf "$sps_work"
+    exit "$sps_status"
+}
+trap sps_cleanup 0
+trap 'exit 130' 1 2 3 15
+
+sps_tab=$(printf '\tX')
+sps_tab=${sps_tab%X}
+sps_installed=$SPS_DB/installed
+sps_db_bad=0
+sps_fs_bad=0
+
+sps_validate_record()
+{
+    sps_check_name=$1
+    sps_check_record=$sps_installed/$sps_check_name
+    if [ ! -d "$sps_check_record" ] || [ -L "$sps_check_record" ]; then
+        sps_db_issue "missing or unsafe installed record for '$sps_check_name'"
+        return 1
+    fi
+    for sps_control in meta files hashes; do
+        if [ ! -f "$sps_check_record/$sps_control" ] ||
+           [ -L "$sps_check_record/$sps_control" ]; then
+            sps_db_issue "$sps_check_name: missing or unsafe $sps_control"
+            return 1
+        fi
+    done
+    if [ -e "$sps_check_record/hooks" ] || [ -L "$sps_check_record/hooks" ]; then
+        if [ ! -d "$sps_check_record/hooks" ] || [ -L "$sps_check_record/hooks" ]; then
+            sps_db_issue "$sps_check_name: unsafe hooks directory"
+            return 1
+        fi
+        for sps_hook_path in "$sps_check_record/hooks"/*; do
+            [ -e "$sps_hook_path" ] || [ -L "$sps_hook_path" ] || continue
+            sps_hook_name=${sps_hook_path##*/}
+            case $sps_hook_name in
+                pre-install|post-install|pre-remove|post-remove) ;;
+                *) sps_db_issue "$sps_check_name: unknown hook '$sps_hook_name'"; return 1 ;;
+            esac
+            if [ ! -f "$sps_hook_path" ] || [ -L "$sps_hook_path" ]; then
+                sps_db_issue "$sps_check_name: unsafe hook '$sps_hook_name'"
+                return 1
+            fi
+        done
+    fi
+    if ! awk -v sps_action=validate-meta -v sps_program=pkcheck \
+        -f "$SPS_LIBDIR/package.awk" "$sps_check_record/meta"; then
+        sps_db_issue "$sps_check_name: malformed metadata"
+        return 1
+    fi
+    if ! awk -v sps_action=validate-paths -v sps_program=pkcheck \
+        -f "$SPS_LIBDIR/package.awk" "$sps_check_record/files"; then
+        sps_db_issue "$sps_check_name: malformed manifest"
+        return 1
+    fi
+    if ! awk -v sps_action=validate-hashes -v sps_program=pkcheck \
+        -f "$SPS_LIBDIR/package.awk" "$sps_check_record/hashes"; then
+        sps_db_issue "$sps_check_name: malformed hashes"
+        return 1
+    fi
+    sps_meta_name=$(awk -v sps_action=meta-get -v sps_key_name=name \
+        -v sps_program=pkcheck -f "$SPS_LIBDIR/package.awk" "$sps_check_record/meta") || {
+        sps_db_issue "$sps_check_name: cannot read metadata name"
+        return 1
+    }
+    if [ "$sps_meta_name" != "$sps_check_name" ]; then
+        sps_db_issue "$sps_check_name: metadata names package '$sps_meta_name'"
+        return 1
+    fi
+
+    # Hash records must point at non-directory entries in the manifest.
+    if ! awk -F "$sps_tab" -v manifest="$sps_check_record/files" '
+        BEGIN {
+            while ((getline path < manifest) > 0) {
+                if (path !~ /\/$/) payload[path] = 1
+            }
+            close(manifest)
+        }
+        !($3 in payload) { bad=1 }
+        END { exit bad }
+    ' "$sps_check_record/hashes"; then
+        sps_db_issue "$sps_check_name: hash path is absent from its manifest"
+        return 1
+    fi
+    return 0
+}
+
+sps_check_database()
+{
+    if [ ! -d "$sps_installed" ] || [ -L "$sps_installed" ]; then
+        sps_db_issue "installed database is missing or is not a real directory"
+        return
+    fi
+    for sps_db_control in owners world history; do
+        if [ ! -f "$SPS_DB/$sps_db_control" ] || [ -L "$SPS_DB/$sps_db_control" ]; then
+            sps_db_issue "$sps_db_control is missing or is not a regular file"
+        fi
+    done
+    [ "$sps_db_bad" -eq 0 ] || return
+    if ! awk -v sps_action=owners-validate -v sps_program=pkcheck \
+        -f "$SPS_LIBDIR/db.awk" "$SPS_DB/owners"; then
+        sps_db_issue "owner index is malformed"
+    fi
+    if ! awk -v sps_action=world-validate -v sps_program=pkcheck \
+        -f "$SPS_LIBDIR/db.awk" "$SPS_DB/world"; then
+        sps_db_issue "world file is malformed"
+    fi
+
+    : >"$sps_work/expected.owners"
+    : >"$sps_work/packages"
+    for sps_record in "$sps_installed"/* "$sps_installed"/.[!.]* "$sps_installed"/..?*; do
+        sps_exists "$sps_record" || continue
+        sps_name=${sps_record##*/}
+        case $sps_name in .|..) continue ;; esac
+        if [ ! -d "$sps_record" ] || [ -L "$sps_record" ]; then
+            sps_db_issue "unexpected entry installed/$sps_name"
+            continue
+        fi
+        if ! sps_validate_package_name "$sps_name"; then
+            sps_db_issue "invalid installed record name '$sps_name'"
+            continue
+        fi
+        printf '%s\n' "$sps_name" >>"$sps_work/packages"
+        if sps_validate_record "$sps_name"; then
+            while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
+                case $sps_entry in
+                    */) ;;
+                    *) printf '%s\t%s\n' "$sps_entry" "$sps_name" >>"$sps_work/expected.owners" ;;
+                esac
+            done <"$sps_record/files"
+        fi
+    done
+    LC_ALL=C sort "$sps_work/packages" -o "$sps_work/packages"
+    LC_ALL=C sort "$sps_work/expected.owners" -o "$sps_work/expected.owners"
+    LC_ALL=C sort "$SPS_DB/owners" >"$sps_work/actual.owners"
+    if ! cmp -s "$sps_work/expected.owners" "$sps_work/actual.owners"; then
+        sps_db_issue "owner index does not match installed manifests"
+    fi
+    if ! cmp -s "$SPS_DB/owners" "$sps_work/actual.owners"; then
+        sps_db_issue "owner index is not in deterministic sorted order"
+    fi
+    while IFS= read -r sps_name || [ -n "$sps_name" ]; do
+        [ -n "$sps_name" ] || continue
+        if ! awk -v wanted="$sps_name" '$0 == wanted { found=1 } END { exit !found }' \
+            "$sps_work/packages"; then
+            sps_db_issue "world names uninstalled package '$sps_name'"
+        fi
+    done <"$SPS_DB/world"
+}
+
+sps_check_files()
+{
+    sps_name=$1
+    sps_file_mode=$2
+    sps_record=$sps_installed/$sps_name
+    if ! sps_validate_record "$sps_name"; then
+        return
+    fi
+    case $sps_file_mode in
+        both|missing)
+            while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
+                sps_path=${sps_entry%/}
+                sps_target_path "$sps_path"
+                case $sps_entry in
+                    */)
+                        if [ ! -d "$sps_target" ] || [ -L "$sps_target" ]; then
+                            printf 'missing /%s %s\n' "$sps_path" "$sps_name"
+                            sps_fs_bad=1
+                        fi
+                        ;;
+                    *)
+                        if ! sps_exists "$sps_target"; then
+                            printf 'missing /%s %s\n' "$sps_path" "$sps_name"
+                            sps_fs_bad=1
+                        elif [ -d "$sps_target" ] && [ ! -L "$sps_target" ]; then
+                            printf 'modified /%s %s (wrong type)\n' "$sps_path" "$sps_name"
+                            sps_fs_bad=1
+                        fi
+                        ;;
+                esac
+            done <"$sps_record/files"
+            ;;
+    esac
+    case $sps_file_mode in
+        both|modified)
+            while IFS="$sps_tab" read -r sps_algorithm sps_expected sps_path; do
+                [ -n "$sps_algorithm" ] || continue
+                sps_target_path "$sps_path"
+                if ! sps_exists "$sps_target"; then
+                    continue
+                fi
+                if [ ! -f "$sps_target" ] || [ -L "$sps_target" ]; then
+                    printf 'modified /%s %s (wrong type)\n' "$sps_path" "$sps_name"
+                    sps_fs_bad=1
+                    continue
+                fi
+                sps_actual=$(sps_sha256 "$sps_target") || {
+                    printf 'modified /%s %s (unreadable)\n' "$sps_path" "$sps_name"
+                    sps_fs_bad=1
+                    continue
+                }
+                set -- $sps_actual
+                sps_actual=$1
+                if [ "$sps_actual" != "$sps_expected" ]; then
+                    printf 'modified /%s %s\n' "$sps_path" "$sps_name"
+                    sps_fs_bad=1
+                fi
+            done <"$sps_record/hashes"
+            ;;
+    esac
+}
+
+sps_mode=both
+sps_scope=one
+sps_name=
+case ${1-} in
+    -h|--help) sps_usage; exit 0 ;;
+    --database)
+        [ "$#" -eq 1 ] || { sps_usage >&2; exit 2; }
+        sps_mode=database
+        ;;
+    --all)
+        [ "$#" -eq 1 ] || { sps_usage >&2; exit 2; }
+        sps_mode=both
+        sps_scope=all
+        ;;
+    --missing|--modified)
+        sps_mode=${1#--}
+        shift
+        case $# in
+            0) sps_scope=all ;;
+            1) sps_scope=one; sps_name=$1 ;;
+            *) sps_usage >&2; exit 2 ;;
+        esac
+        ;;
+    --*) sps_usage >&2; exit 2 ;;
+    '') sps_usage >&2; exit 2 ;;
+    *)
+        [ "$#" -eq 1 ] || { sps_usage >&2; exit 2; }
+        sps_name=$1
+        ;;
+esac
+
+if [ "$sps_mode" = database ]; then
+    sps_check_database
+    [ "$sps_db_bad" -eq 0 ] || exit 11
+    printf '%s\n' 'database: ok'
+    exit 0
+fi
+
+if [ "$sps_scope" = all ]; then
+    sps_check_database
+    [ "$sps_db_bad" -eq 0 ] || exit 11
+    while IFS= read -r sps_name || [ -n "$sps_name" ]; do
+        [ -n "$sps_name" ] || continue
+        sps_check_files "$sps_name" "$sps_mode"
+    done <"$sps_work/packages"
+else
+    sps_validate_package_name "$sps_name" || sps_fail 2 "invalid package name '$sps_name'"
+    [ -d "$sps_installed" ] && [ ! -L "$sps_installed" ] ||
+        sps_fail 11 "installed database is missing or unsafe"
+    [ -d "$sps_installed/$sps_name" ] && [ ! -L "$sps_installed/$sps_name" ] ||
+        sps_fail 3 "package '$sps_name' is not installed"
+    sps_check_files "$sps_name" "$sps_mode"
+    [ "$sps_db_bad" -eq 0 ] || exit 11
+fi
+[ "$sps_fs_bad" -eq 0 ] || exit 1
+exit 0
diff --git a/bin/pkdel b/bin/pkdel
new file mode 100755
index 0000000..43a02ec
--- /dev/null
+++ b/bin/pkdel
@@ -0,0 +1,331 @@
+#!/bin/sh
+
+sps_prog=pkdel
+sps_bin_dir=$(CDPATH= cd -P "$(dirname "$0")" 2>/dev/null && pwd) || exit 1
+if [ -z "${SPS_LIBDIR+x}" ]; then
+    if [ -r "$sps_bin_dir/../lib/common.sh" ]; then
+        SPS_LIBDIR=$sps_bin_dir/../lib
+    else
+        SPS_LIBDIR=$sps_bin_dir/../lib/sps
+    fi
+fi
+if ! . "$SPS_LIBDIR/common.sh"; then
+    printf '%s\n' "pkdel: cannot load $SPS_LIBDIR/common.sh" >&2
+    exit 1
+fi
+sps_load_config || exit $?
+
+sps_usage()
+{
+    printf '%s\n' 'usage: pkdel [--plan] PACKAGE'
+}
+
+sps_fail()
+{
+    sps_code=$1
+    shift
+    printf '%s: %s\n' "$sps_prog" "$*" >&2
+    exit "$sps_code"
+}
+
+sps_exists()
+{
+    [ -f "$1" ] || [ -d "$1" ] || [ -L "$1" ]
+}
+
+sps_target_path()
+{
+    case $SPS_ROOT in
+        /) sps_target=/$1 ;;
+        */) sps_target=$SPS_ROOT$1 ;;
+        *) sps_target=$SPS_ROOT/$1 ;;
+    esac
+}
+
+sps_safe_parents()
+{
+    sps_parent_rel=${1%/*}
+    [ "$sps_parent_rel" != "$1" ] || return 0
+    sps_remainder=$sps_parent_rel
+    sps_prefix=
+    while [ -n "$sps_remainder" ]; do
+        case $sps_remainder in
+            */*) sps_component=${sps_remainder%%/*}; sps_remainder=${sps_remainder#*/} ;;
+            *) sps_component=$sps_remainder; sps_remainder= ;;
+        esac
+        if [ -n "$sps_prefix" ]; then
+            sps_prefix=$sps_prefix/$sps_component
+        else
+            sps_prefix=$sps_component
+        fi
+        sps_target_path "$sps_prefix"
+        if [ -L "$sps_target" ] ||
+           { sps_exists "$sps_target" && [ ! -d "$sps_target" ]; }; then
+            printf '%s: refusing unsafe target path /%s\n' "$sps_prog" "$sps_prefix" >&2
+            return 1
+        fi
+    done
+    return 0
+}
+
+sps_work=
+sps_backup_dir=
+sps_owners_new=
+sps_world_new=
+sps_lock=
+sps_lock_owned=0
+sps_commit_complete=0
+sps_database_commit_started=0
+sps_cleanup()
+{
+    sps_status=$?
+    trap - 0 1 2 3 15
+    if [ "$sps_commit_complete" -eq 0 ] && [ "$sps_database_commit_started" -eq 1 ] &&
+       [ -n "$sps_backup_dir" ] && [ -d "$sps_backup_dir" ]; then
+        if [ -n "${sps_record-}" ]; then
+            rm -rf "$sps_record"
+            mv "$sps_backup_dir/record" "$sps_record" 2>/dev/null || :
+        fi
+        cp "$sps_backup_dir/owners" "$SPS_DB/.owners-restore.$$" 2>/dev/null &&
+            mv -f "$SPS_DB/.owners-restore.$$" "$SPS_DB/owners" 2>/dev/null || :
+        cp "$sps_backup_dir/world" "$SPS_DB/.world-restore.$$" 2>/dev/null &&
+            mv -f "$SPS_DB/.world-restore.$$" "$SPS_DB/world" 2>/dev/null || :
+        sps_database_commit_started=0
+    fi
+    [ -n "$sps_owners_new" ] && rm -f "$sps_owners_new"
+    [ -n "$sps_world_new" ] && rm -f "$sps_world_new"
+    if [ -n "$sps_backup_dir" ] && [ -d "$sps_backup_dir" ]; then
+        rm -rf "$sps_backup_dir"
+    fi
+    if [ -n "$sps_work" ] && [ -d "$sps_work" ]; then
+        rm -rf "$sps_work"
+    fi
+    sps_lock_release
+    exit "$sps_status"
+}
+trap sps_cleanup 0
+trap 'exit 130' 1 2 3 15
+
+sps_plan=0
+case ${1-} in
+    --plan) sps_plan=1; shift ;;
+    -h|--help) sps_usage; exit 0 ;;
+esac
+[ "$#" -eq 1 ] || { sps_usage >&2; exit 2; }
+sps_name=$1
+if ! sps_validate_package_name "$sps_name"; then
+    sps_fail 2 "invalid package name '$sps_name'"
+fi
+sps_installed=$SPS_DB/installed
+[ -d "$sps_installed" ] && [ ! -L "$sps_installed" ] ||
+    sps_fail 11 "installed database is missing or is not a real directory"
+sps_record=$SPS_DB/installed/$sps_name
+if [ -L "$sps_record" ]; then
+    sps_fail 11 "installed record for '$sps_name' is not a real directory"
+fi
+[ -d "$sps_record" ] || sps_fail 3 "package '$sps_name' is not installed"
+if [ "$sps_plan" -eq 0 ]; then
+    if ! sps_lock_acquire "$SPS_DB"; then
+        sps_fail 11 "package database is locked or unavailable: '$SPS_DB/.lock'"
+    fi
+    [ -d "$sps_installed" ] && [ ! -L "$sps_installed" ] &&
+        [ -d "$sps_record" ] && [ ! -L "$sps_record" ] ||
+        sps_fail 11 "installed database changed while acquiring the lock"
+fi
+for sps_file in meta files hashes; do
+    [ -f "$sps_record/$sps_file" ] && [ ! -L "$sps_record/$sps_file" ] ||
+        sps_fail 11 "installed record for '$sps_name' is missing $sps_file"
+done
+if [ -e "$sps_record/hooks" ] || [ -L "$sps_record/hooks" ]; then
+    [ -d "$sps_record/hooks" ] && [ ! -L "$sps_record/hooks" ] ||
+        sps_fail 11 "installed hooks for '$sps_name' are unsafe"
+    for sps_hook in pre-install post-install pre-remove post-remove; do
+        [ ! -e "$sps_record/hooks/$sps_hook" ] && [ ! -L "$sps_record/hooks/$sps_hook" ] && continue
+        [ -f "$sps_record/hooks/$sps_hook" ] && [ ! -L "$sps_record/hooks/$sps_hook" ] ||
+            sps_fail 11 "installed hook '$sps_hook' for '$sps_name' is unsafe"
+    done
+fi
+if ! awk -v sps_action=validate-meta -v sps_program=pkdel \
+    -f "$SPS_LIBDIR/package.awk" "$sps_record/meta"; then
+    sps_fail 11 "installed metadata for '$sps_name' is malformed"
+fi
+if ! awk -v sps_action=validate-paths -v sps_program=pkdel \
+    -f "$SPS_LIBDIR/package.awk" "$sps_record/files"; then
+    sps_fail 11 "installed manifest for '$sps_name' is malformed"
+fi
+if ! awk -v sps_action=validate-hashes -v sps_program=pkdel \
+    -f "$SPS_LIBDIR/package.awk" "$sps_record/hashes"; then
+    sps_fail 11 "installed hashes for '$sps_name' are malformed"
+fi
+sps_record_name=$(awk -v sps_action=meta-get -v sps_key_name=name \
+    -v sps_program=pkdel -f "$SPS_LIBDIR/package.awk" "$sps_record/meta") ||
+    sps_fail 11 "cannot read installed package name"
+[ "$sps_record_name" = "$sps_name" ] ||
+    sps_fail 11 "record name '$sps_record_name' does not match '$sps_name'"
+sps_version=$(awk -v sps_action=meta-get -v sps_key_name=version \
+    -v sps_program=pkdel -f "$SPS_LIBDIR/package.awk" "$sps_record/meta") ||
+    sps_fail 11 "cannot read installed package version"
+
+for sps_db_control in owners world history; do
+    [ -f "$SPS_DB/$sps_db_control" ] && [ ! -L "$SPS_DB/$sps_db_control" ] ||
+        sps_fail 11 "database control '$sps_db_control' is missing or unsafe"
+done
+if ! awk -v sps_action=owners-validate -v sps_program=pkdel \
+    -f "$SPS_LIBDIR/db.awk" "$SPS_DB/owners"; then
+    sps_fail 11 "installed ownership database is malformed"
+fi
+if ! awk -v sps_action=world-validate -v sps_program=pkdel \
+    -f "$SPS_LIBDIR/db.awk" "$SPS_DB/world"; then
+    sps_fail 11 "installed world database is malformed"
+fi
+sps_tab=$(printf '\tX')
+sps_tab=${sps_tab%X}
+
+sps_record_file_modified()
+{
+    sps_preserve_path=$1
+    sps_preserve_target=$2
+    sps_is_preserved_path "$sps_preserve_path" || return 1
+    [ -f "$sps_preserve_target" ] && [ ! -L "$sps_preserve_target" ] || return 1
+    sps_preserve_expected=$(awk -F "$sps_tab" -v path="$sps_preserve_path"         '$3 == path && $1 == "sha256" { print $2; exit }' "$sps_record/hashes")
+    [ -n "$sps_preserve_expected" ] || return 1
+    sps_preserve_actual=$(sps_sha256 "$sps_preserve_target") || return 0
+    [ "$sps_preserve_actual" != "$sps_preserve_expected" ]
+}
+
+if [ "$sps_plan" -eq 1 ]; then
+    printf 'Remove plan\n\n'
+    printf 'package: %s %s\n' "$sps_name" "$sps_version"
+    while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
+        case $sps_entry in */) continue ;; esac
+        sps_owner=$(awk -F "$sps_tab" -v path="$sps_entry" '$1 == path { print $2 }' "$SPS_DB/owners")
+        if [ "$sps_owner" = "$sps_name" ]; then
+            sps_target_path "$sps_entry"
+            if sps_record_file_modified "$sps_entry" "$sps_target"; then
+                printf 'keep:   /%s (locally modified protected file)\n' "$sps_entry"
+            else
+                printf 'remove: /%s\n' "$sps_entry"
+            fi
+        elif [ -n "$sps_owner" ]; then
+            printf 'keep:   /%s (owned by %s)\n' "$sps_entry" "$sps_owner"
+        else
+            printf 'keep:   /%s (not in owner index)\n' "$sps_entry"
+        fi
+    done <"$sps_record/files"
+    exit 0
+fi
+
+mkdir -p "$SPS_BUILD" || sps_fail 8 "cannot create work directory '$SPS_BUILD'"
+sps_work=$(sps_mktemp_dir "$SPS_BUILD" remove) ||
+    sps_fail 8 "cannot create a secure removal workspace"
+if [ -d "$sps_record/hooks" ]; then
+    mkdir "$sps_work/hooks" || sps_fail 8 "cannot stage removal hooks"
+    cp "$sps_record/hooks"/* "$sps_work/hooks/" 2>/dev/null || :
+fi
+
+# Join the manifest and owner index once instead of looking up every path
+# separately.
+awk -F "$sps_tab" 'NR == FNR { owner[$1]=$2; next }
+    $0 !~ /\/$/ { print $0 FS owner[$0] }' \
+    "$SPS_DB/owners" "$sps_record/files" >"$sps_work/manifest.owners" ||
+    sps_fail 11 "cannot map package ownership"
+
+sps_run_remove_hook()
+{
+    sps_hook_name=$1
+    sps_hook_file=$sps_work/hooks/$sps_hook_name
+    [ -f "$sps_hook_file" ] || return 0
+    printf '%s: running %s hook for %s\n' "$sps_prog" "$sps_hook_name" "$sps_name" >&2
+    SPS_ACTION=remove
+    SPS_PACKAGE=$sps_name
+    SPS_VERSION=$sps_version
+    export SPS_ACTION SPS_PACKAGE SPS_VERSION
+    sh -eu "$sps_hook_file"
+}
+
+sps_run_remove_hook pre-remove ||
+    sps_fail 10 "pre-remove hook failed for '$sps_name'"
+
+# Remove a file only if the owner index still says it belongs to this package.
+while IFS="$sps_tab" read -r sps_entry sps_owner || [ -n "$sps_entry" ]; do
+    if [ "$sps_owner" != "$sps_name" ]; then
+        if [ -n "$sps_owner" ]; then
+            sps_warn "/$sps_entry is now owned by $sps_owner; keeping it"
+        else
+            sps_warn "/$sps_entry has no owner record; keeping it"
+        fi
+        continue
+    fi
+    sps_safe_parents "$sps_entry" || sps_fail 7 "unsafe target path /$sps_entry"
+    sps_target_path "$sps_entry"
+    if [ -d "$sps_target" ] && [ ! -L "$sps_target" ]; then
+        sps_warn "/$sps_entry is now a directory; keeping it"
+    elif sps_record_file_modified "$sps_entry" "$sps_target"; then
+        sps_warn "/$sps_entry is locally modified; keeping it unowned"
+    elif sps_exists "$sps_target"; then
+        rm -f "$sps_target" || sps_fail 8 "cannot remove /$sps_entry"
+    fi
+done <"$sps_work/manifest.owners"
+
+# Reverse sort puts children before parents; rmdir leaves shared/non-empty
+# directories alone.
+LC_ALL=C sort -r "$sps_record/files" >"$sps_work/files.reverse"
+while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
+    case $sps_entry in
+        */)
+            sps_path=${sps_entry%/}
+            sps_safe_parents "$sps_path" || continue
+            sps_target_path "$sps_path"
+            if [ -d "$sps_target" ] && [ ! -L "$sps_target" ]; then
+                rmdir "$sps_target" 2>/dev/null || :
+            fi
+            ;;
+    esac
+done <"$sps_work/files.reverse"
+
+sps_owners_new=$(mktemp "$SPS_DB/.owners-new.XXXXXX") || sps_fail 11 "cannot stage owner index"
+sps_world_new=$(mktemp "$SPS_DB/.world-new.XXXXXX") || sps_fail 11 "cannot stage world database"
+if ! awk -v sps_action=owners-remove -v sps_package="$sps_name" -v sps_program=pkdel \
+    -f "$SPS_LIBDIR/db.awk" "$SPS_DB/owners" >"$sps_owners_new"; then
+    sps_fail 11 "installed ownership database is malformed"
+fi
+if ! awk -v sps_action=world-remove -v sps_package="$sps_name" -v sps_program=pkdel \
+    -f "$SPS_LIBDIR/db.awk" "$SPS_DB/world" >"$sps_world_new"; then
+    sps_fail 11 "installed world database is malformed"
+fi
+
+sps_backup_dir=$(sps_mktemp_dir "$SPS_DB/installed" backup) ||
+    sps_fail 11 "cannot create database rollback area"
+cp -p "$SPS_DB/owners" "$sps_backup_dir/owners" &&
+cp -p "$SPS_DB/world" "$sps_backup_dir/world" ||
+    sps_fail 11 "cannot preserve database state"
+mv "$sps_record" "$sps_backup_dir/record" || sps_fail 11 "cannot preserve package record"
+sps_database_commit_started=1
+
+sps_commit_failed=0
+mv -f "$sps_owners_new" "$SPS_DB/owners" || sps_commit_failed=1
+if [ "$sps_commit_failed" -eq 0 ]; then
+    mv -f "$sps_world_new" "$SPS_DB/world" || sps_commit_failed=1
+fi
+if [ "$sps_commit_failed" -ne 0 ]; then
+    mv "$sps_backup_dir/record" "$sps_record" 2>/dev/null || :
+    cp "$sps_backup_dir/owners" "$SPS_DB/.owners-restore.$$" 2>/dev/null &&
+        mv -f "$SPS_DB/.owners-restore.$$" "$SPS_DB/owners" 2>/dev/null || :
+    cp "$sps_backup_dir/world" "$SPS_DB/.world-restore.$$" 2>/dev/null &&
+        mv -f "$SPS_DB/.world-restore.$$" "$SPS_DB/world" 2>/dev/null || :
+    sps_database_commit_started=0
+    sps_fail 11 "database commit failed; previous metadata was restored"
+fi
+sps_commit_complete=1
+
+sps_date=$(date '+%Y-%m-%d' 2>/dev/null || printf '%s' unknown)
+printf '%s remove %s %s\n' "$sps_date" "$sps_name" "$sps_version" >>"$SPS_DB/history" ||
+    sps_warn "could not append package history"
+
+if ! sps_run_remove_hook post-remove; then
+    sps_warn "package removal completed, but post-remove hook failed for '$sps_name'"
+    exit 10
+fi
+
+printf '%s: removed %s %s\n' "$sps_prog" "$sps_name" "$sps_version"
+exit 0
diff --git a/bin/pkin b/bin/pkin
new file mode 100755
index 0000000..88d84e0
--- /dev/null
+++ b/bin/pkin
@@ -0,0 +1,763 @@
+#!/bin/sh
+
+# Install one already-built SPS package. Repository lookup and dependency
+# policy belong to sget.
+
+sps_prog=pkin
+sps_bin_dir=$(CDPATH= cd -P "$(dirname "$0")" 2>/dev/null && pwd) || exit 1
+if [ -z "${SPS_LIBDIR+x}" ]; then
+    if [ -r "$sps_bin_dir/../lib/common.sh" ]; then
+        SPS_LIBDIR=$sps_bin_dir/../lib
+    else
+        SPS_LIBDIR=$sps_bin_dir/../lib/sps
+    fi
+fi
+if ! . "$SPS_LIBDIR/common.sh"; then
+    printf '%s\n' "pkin: cannot load $SPS_LIBDIR/common.sh" >&2
+    exit 1
+fi
+sps_load_config || exit $?
+
+sps_usage()
+{
+    printf '%s\n' 'usage: pkin [--explicit|--dependency] PACKAGE'
+}
+
+sps_fail()
+{
+    sps_code=$1
+    shift
+    printf '%s: %s\n' "$sps_prog" "$*" >&2
+    exit "$sps_code"
+}
+
+sps_exists()
+{
+    [ -f "$1" ] || [ -d "$1" ] || [ -L "$1" ]
+}
+
+sps_target_path()
+{
+    case $SPS_ROOT in
+        /) sps_target=/$1 ;;
+        */) sps_target=$SPS_ROOT$1 ;;
+        *) sps_target=$SPS_ROOT/$1 ;;
+    esac
+}
+
+# Do not walk through a symlink or non-directory while resolving a payload path.
+# The same rule applies to alternate roots.
+sps_safe_parents()
+{
+    sps_parent_rel=${1%/*}
+    [ "$sps_parent_rel" != "$1" ] || return 0
+    sps_remainder=$sps_parent_rel
+    sps_prefix=
+    while [ -n "$sps_remainder" ]; do
+        case $sps_remainder in
+            */*) sps_component=${sps_remainder%%/*}; sps_remainder=${sps_remainder#*/} ;;
+            *) sps_component=$sps_remainder; sps_remainder= ;;
+        esac
+        if [ -n "$sps_prefix" ]; then
+            sps_prefix=$sps_prefix/$sps_component
+        else
+            sps_prefix=$sps_component
+        fi
+        sps_target_path "$sps_prefix"
+        if [ -L "$sps_target" ]; then
+            printf '%s: unsafe symlink in target path: /%s\n' "$sps_prog" "$sps_prefix" >&2
+            return 1
+        fi
+        if sps_exists "$sps_target" && [ ! -d "$sps_target" ]; then
+            printf '%s: non-directory in target path: /%s\n' "$sps_prog" "$sps_prefix" >&2
+            return 1
+        fi
+    done
+    return 0
+}
+
+# Check the staging tree too. Packages may contain symlinks, but another payload
+# entry cannot live underneath one.
+sps_safe_stage_parents()
+{
+    sps_stage_parent=${1%/*}
+    [ "$sps_stage_parent" != "$1" ] || return 0
+    sps_stage_rest=$sps_stage_parent
+    sps_stage_prefix=
+    while [ -n "$sps_stage_rest" ]; do
+        case $sps_stage_rest in
+            */*) sps_stage_component=${sps_stage_rest%%/*}; sps_stage_rest=${sps_stage_rest#*/} ;;
+            *) sps_stage_component=$sps_stage_rest; sps_stage_rest= ;;
+        esac
+        if [ -n "$sps_stage_prefix" ]; then
+            sps_stage_prefix=$sps_stage_prefix/$sps_stage_component
+        else
+            sps_stage_prefix=$sps_stage_component
+        fi
+        sps_stage_ancestor=$sps_stage/$sps_stage_prefix
+        if [ -L "$sps_stage_ancestor" ] || [ ! -d "$sps_stage_ancestor" ]; then
+            printf '%s: unsafe extracted parent for /%s\n' "$sps_prog" "$1" >&2
+            return 1
+        fi
+    done
+    return 0
+}
+
+sps_work=
+sps_record_tmp=
+sps_backup_dir=
+sps_owners_base=
+sps_owners_new=
+sps_world_base=
+sps_world_new=
+sps_lock=
+sps_lock_owned=0
+sps_commit_complete=0
+sps_record_backed_up=0
+sps_database_commit_started=0
+
+sps_cleanup()
+{
+    sps_status=$?
+    trap - 0 1 2 3 15
+    if [ "$sps_commit_complete" -eq 0 ] && [ "$sps_database_commit_started" -eq 1 ] &&
+       [ -n "$sps_backup_dir" ] && [ -d "$sps_backup_dir" ]; then
+        if [ -n "${sps_old_record-}" ]; then
+            rm -rf "$sps_old_record"
+            if [ "$sps_record_backed_up" -eq 1 ]; then
+                mv "$sps_backup_dir/record" "$sps_old_record" 2>/dev/null || :
+            fi
+        fi
+        if [ -f "$sps_backup_dir/owners" ]; then
+            cp "$sps_backup_dir/owners" "$SPS_DB/.owners-restore.$$" 2>/dev/null &&
+                mv -f "$SPS_DB/.owners-restore.$$" "$SPS_DB/owners" 2>/dev/null || :
+        fi
+        if [ -f "$sps_backup_dir/world" ]; then
+            cp "$sps_backup_dir/world" "$SPS_DB/.world-restore.$$" 2>/dev/null &&
+                mv -f "$SPS_DB/.world-restore.$$" "$SPS_DB/world" 2>/dev/null || :
+        fi
+        sps_record_backed_up=0
+        sps_database_commit_started=0
+    fi
+    if [ -n "$sps_record_tmp" ] && [ -d "$sps_record_tmp" ]; then
+        rm -rf "$sps_record_tmp"
+    fi
+    for sps_temp_file in "$sps_owners_base" "$sps_owners_new" \
+        "$sps_world_base" "$sps_world_new"; do
+        [ -n "$sps_temp_file" ] && rm -f "$sps_temp_file"
+    done
+    if [ -n "$sps_backup_dir" ] && [ -d "$sps_backup_dir" ]; then
+        rm -rf "$sps_backup_dir"
+    fi
+    if [ -n "$sps_work" ] && [ -d "$sps_work" ]; then
+        rm -rf "$sps_work"
+    fi
+    sps_lock_release
+    exit "$sps_status"
+}
+trap sps_cleanup 0
+trap 'exit 130' 1 2 3 15
+
+sps_reason=${SPS_INSTALL_REASON:-explicit}
+case ${1-} in
+    --explicit) sps_reason=explicit; shift ;;
+    --dependency) sps_reason=dependency; shift ;;
+    -h|--help) sps_usage; exit 0 ;;
+esac
+case $sps_reason in
+    explicit|dependency) ;;
+    *) sps_fail 2 "invalid install reason '$sps_reason'" ;;
+esac
+[ "$#" -eq 1 ] || { sps_usage >&2; exit 2; }
+sps_archive=$1
+[ -f "$sps_archive" ] && [ -r "$sps_archive" ] ||
+    sps_fail 3 "cannot read package archive '$sps_archive'"
+
+case $sps_archive in
+    /*) ;;
+    *)
+        sps_archive_dir=$(CDPATH= cd -P "$(dirname "$sps_archive")" 2>/dev/null && pwd) ||
+            sps_fail 3 "cannot resolve package archive '$sps_archive'"
+        sps_archive=$sps_archive_dir/$(basename "$sps_archive")
+        ;;
+esac
+
+sps_archive_list()
+{
+    case $sps_archive in
+        *.tar.gz|*.tgz)
+            command -v gzip >/dev/null 2>&1 ||
+                sps_fail 4 "gzip is required to read '$sps_archive'"
+            gzip -dc "$sps_archive" | tar -tf -
+            ;;
+        *.tar.xz|*.txz)
+            command -v xz >/dev/null 2>&1 ||
+                sps_fail 4 "xz is required to read '$sps_archive'"
+            xz -dc "$sps_archive" | tar -tf -
+            ;;
+        *.tar.zst|*.tzst)
+            command -v zstd >/dev/null 2>&1 ||
+                sps_fail 4 "zstd is required to read '$sps_archive'"
+            zstd -q -dc "$sps_archive" | tar -tf -
+            ;;
+        *)
+            tar -tf "$sps_archive"
+            ;;
+    esac
+}
+
+sps_archive_extract()
+{
+    case $sps_archive in
+        *.tar.gz|*.tgz) gzip -dc "$sps_archive" | tar -xf - -C "$sps_stage" ;;
+        *.tar.xz|*.txz) xz -dc "$sps_archive" | tar -xf - -C "$sps_stage" ;;
+        *.tar.zst|*.tzst) zstd -q -dc "$sps_archive" | tar -xf - -C "$sps_stage" ;;
+        *) tar -xf "$sps_archive" -C "$sps_stage" ;;
+    esac
+}
+
+mkdir -p "$SPS_BUILD" || sps_fail 8 "cannot create build directory '$SPS_BUILD'"
+sps_work=$(sps_mktemp_dir "$SPS_BUILD" install) ||
+    sps_fail 8 "cannot create a secure installation workspace"
+sps_stage=$sps_work/stage
+mkdir "$sps_stage" || sps_fail 8 "cannot create package staging directory"
+
+if ! sps_archive_list >"$sps_work/members.raw" 2>"$sps_work/tar.error"; then
+    sed 's/^/pkin: tar: /' "$sps_work/tar.error" >&2
+    sps_fail 4 "invalid package archive '$sps_archive'"
+fi
+if ! awk -v sps_action=normalize-members -v sps_program=pkin \
+    -f "$SPS_LIBDIR/package.awk" "$sps_work/members.raw" >"$sps_work/members"; then
+    sps_fail 4 "unsafe package archive '$sps_archive'"
+fi
+for sps_control in .SPS/meta .SPS/files .SPS/hashes; do
+    if ! awk -v wanted="$sps_control" '
+        { value=$0; sub(/\/$/, "", value); if (value == wanted) found=1 }
+        END { exit !found }
+    ' "$sps_work/members"; then
+        sps_fail 4 "package archive is missing $sps_control"
+    fi
+done
+
+# Extract only after member names pass validation, and always into a fresh tree.
+if ! sps_archive_extract 2>"$sps_work/tar.error"; then
+    sed 's/^/pkin: tar: /' "$sps_work/tar.error" >&2
+    sps_fail 4 "cannot extract package archive '$sps_archive'"
+fi
+for sps_control in meta files hashes; do
+    [ -f "$sps_stage/.SPS/$sps_control" ] &&
+        [ ! -L "$sps_stage/.SPS/$sps_control" ] ||
+        sps_fail 4 "package control file .SPS/$sps_control is not a regular file"
+done
+if [ -e "$sps_stage/.SPS/hooks" ] || [ -L "$sps_stage/.SPS/hooks" ]; then
+    [ -d "$sps_stage/.SPS/hooks" ] && [ ! -L "$sps_stage/.SPS/hooks" ] ||
+        sps_fail 4 "package hook control is not a real directory"
+    for sps_hook in pre-install post-install pre-remove post-remove; do
+        [ ! -e "$sps_stage/.SPS/hooks/$sps_hook" ] &&
+            [ ! -L "$sps_stage/.SPS/hooks/$sps_hook" ] && continue
+        [ -f "$sps_stage/.SPS/hooks/$sps_hook" ] &&
+            [ ! -L "$sps_stage/.SPS/hooks/$sps_hook" ] ||
+            sps_fail 4 "package hook '$sps_hook' is not a regular file"
+    done
+fi
+
+if ! awk -v sps_action=validate-meta -v sps_program=pkin \
+    -f "$SPS_LIBDIR/package.awk" "$sps_stage/.SPS/meta"; then
+    sps_fail 4 "invalid package metadata"
+fi
+if ! awk -v sps_action=validate-paths -v sps_program=pkin \
+    -f "$SPS_LIBDIR/package.awk" "$sps_stage/.SPS/files"; then
+    sps_fail 4 "invalid package manifest"
+fi
+if ! awk -v sps_action=validate-hashes -v sps_program=pkin \
+    -f "$SPS_LIBDIR/package.awk" "$sps_stage/.SPS/hashes"; then
+    sps_fail 4 "invalid package hashes"
+fi
+
+sps_id=$(awk -v sps_action=meta-id -v sps_program=pkin \
+    -f "$SPS_LIBDIR/package.awk" "$sps_stage/.SPS/meta") ||
+    sps_fail 4 "cannot read package identity"
+sps_tab=$(printf '\tX')
+sps_tab=${sps_tab%X}
+sps_saved_ifs=$IFS
+IFS=$sps_tab
+set -- $sps_id
+IFS=$sps_saved_ifs
+[ "$#" -eq 4 ] || sps_fail 4 "malformed package identity"
+sps_name=$1
+sps_version=$2
+sps_release=$3
+sps_arch=$4
+case $sps_arch in
+    any|noarch|"$SPS_ARCH") ;;
+    *) sps_fail 4 "package architecture '$sps_arch' does not match target '$SPS_ARCH'" ;;
+esac
+
+# The manifest is the package file list. Reject extra payload and missing
+# manifest entries before installation.
+awk '
+    {
+        value=$0
+        sub(/\/$/, "", value)
+        if (value == ".SPS" || value ~ /^\.SPS\//) next
+        print value
+    }
+' "$sps_work/members" | LC_ALL=C sort -u >"$sps_work/archive.payload"
+sed 's:/$::' "$sps_stage/.SPS/files" | LC_ALL=C sort -u >"$sps_work/manifest.payload"
+if ! cmp -s "$sps_work/archive.payload" "$sps_work/manifest.payload"; then
+    sps_fail 4 "archive payload does not match .SPS/files"
+fi
+
+: >"$sps_work/regulars"
+: >"$sps_work/owned"
+while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
+    sps_path=${sps_entry%/}
+    sps_source=$sps_stage/$sps_path
+    sps_safe_stage_parents "$sps_path" ||
+        sps_fail 4 "unsafe extracted payload path /$sps_path"
+    case $sps_entry in
+        */)
+            [ -d "$sps_source" ] && [ ! -L "$sps_source" ] ||
+                sps_fail 4 "manifest directory has the wrong type: /$sps_path"
+            ;;
+        *)
+            if [ -f "$sps_source" ] && [ ! -L "$sps_source" ]; then
+                printf '%s\n' "$sps_path" >>"$sps_work/regulars"
+            elif [ -L "$sps_source" ]; then
+                :
+            else
+                sps_fail 4 "unsupported or missing payload entry: /$sps_path"
+            fi
+            printf '%s\n' "$sps_path" >>"$sps_work/owned"
+            ;;
+    esac
+done <"$sps_stage/.SPS/files"
+LC_ALL=C sort -u "$sps_work/regulars" -o "$sps_work/regulars"
+LC_ALL=C sort -u "$sps_work/owned" -o "$sps_work/owned"
+awk -v sps_action=hash-paths -v sps_program=pkin \
+    -f "$SPS_LIBDIR/package.awk" "$sps_stage/.SPS/hashes" |
+    LC_ALL=C sort -u >"$sps_work/hash.paths" ||
+    sps_fail 4 "invalid package hashes"
+if ! cmp -s "$sps_work/regulars" "$sps_work/hash.paths"; then
+    sps_fail 4 "hash records do not match the regular-file payload"
+fi
+
+while IFS="$sps_tab" read -r sps_algorithm sps_expected sps_path; do
+    [ -n "$sps_algorithm" ] || continue
+    sps_actual=$(sps_sha256 "$sps_stage/$sps_path") ||
+        sps_fail 6 "cannot hash payload file /$sps_path"
+    set -- $sps_actual
+    sps_actual=$1
+    [ "$sps_actual" = "$sps_expected" ] ||
+        sps_fail 6 "checksum mismatch for /$sps_path"
+done <"$sps_stage/.SPS/hashes"
+
+mkdir -p "$SPS_DB" || sps_fail 8 "cannot create package database '$SPS_DB'"
+if sps_exists "$SPS_DB/installed"; then
+    [ -d "$SPS_DB/installed" ] && [ ! -L "$SPS_DB/installed" ] ||
+        sps_fail 11 "installed database is not a real directory"
+else
+    mkdir "$SPS_DB/installed" || sps_fail 8 "cannot create installed database"
+fi
+for sps_db_file in owners world history; do
+    if sps_exists "$SPS_DB/$sps_db_file"; then
+        [ -f "$SPS_DB/$sps_db_file" ] && [ ! -L "$SPS_DB/$sps_db_file" ] ||
+            sps_fail 11 "database control '$sps_db_file' is not a regular file"
+    else
+        : >"$SPS_DB/$sps_db_file" || sps_fail 8 "cannot create database file '$SPS_DB/$sps_db_file'"
+    fi
+done
+if ! sps_lock_acquire "$SPS_DB"; then
+    sps_fail 11 "package database is locked: '$SPS_DB/.lock'"
+fi
+
+if ! awk -v sps_action=owners-validate -v sps_program=pkin \
+    -f "$SPS_LIBDIR/db.awk" "$SPS_DB/owners"; then
+    sps_fail 11 "installed ownership database is malformed"
+fi
+if ! awk -v sps_action=world-validate -v sps_program=pkin \
+    -f "$SPS_LIBDIR/db.awk" "$SPS_DB/world"; then
+    sps_fail 11 "installed world database is malformed"
+fi
+
+# Validate the old installed record before using it for upgrade cleanup. A bad
+# old manifest must never turn into a removal path.
+sps_old_record=$SPS_DB/installed/$sps_name
+sps_old_version=
+: >"$sps_work/old-owned"
+if sps_exists "$sps_old_record" &&
+   { [ ! -d "$sps_old_record" ] || [ -L "$sps_old_record" ]; }; then
+    sps_fail 11 "installed record for '$sps_name' is not a real directory"
+fi
+if [ -d "$sps_old_record" ]; then
+    for sps_old_control in meta files hashes; do
+        [ -f "$sps_old_record/$sps_old_control" ] &&
+            [ ! -L "$sps_old_record/$sps_old_control" ] ||
+            sps_fail 11 "installed record for '$sps_name' is missing $sps_old_control"
+    done
+    awk -v sps_action=validate-meta -v sps_program=pkin \
+        -f "$SPS_LIBDIR/package.awk" "$sps_old_record/meta" ||
+        sps_fail 11 "installed metadata for '$sps_name' is malformed"
+    awk -v sps_action=validate-paths -v sps_program=pkin \
+        -f "$SPS_LIBDIR/package.awk" "$sps_old_record/files" ||
+        sps_fail 11 "installed manifest for '$sps_name' is malformed"
+    awk -v sps_action=validate-hashes -v sps_program=pkin \
+        -f "$SPS_LIBDIR/package.awk" "$sps_old_record/hashes" ||
+        sps_fail 11 "installed hashes for '$sps_name' are malformed"
+    sps_old_name=$(awk -v sps_action=meta-get -v sps_key_name=name \
+        -v sps_program=pkin -f "$SPS_LIBDIR/package.awk" "$sps_old_record/meta") ||
+        sps_fail 11 "cannot read installed package identity"
+    [ "$sps_old_name" = "$sps_name" ] ||
+        sps_fail 11 "installed record name '$sps_old_name' does not match '$sps_name'"
+    sps_old_version=$(awk -v sps_action=meta-get -v sps_key_name=version \
+        -v sps_program=pkin -f "$SPS_LIBDIR/package.awk" "$sps_old_record/meta") ||
+        sps_fail 11 "installed metadata for '$sps_name' is malformed"
+    while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
+        case $sps_entry in */) ;; *) printf '%s\n' "$sps_entry" >>"$sps_work/old-owned" ;; esac
+    done <"$sps_old_record/files"
+    LC_ALL=C sort -u "$sps_work/old-owned" -o "$sps_work/old-owned"
+fi
+
+sps_run_hook()
+{
+    sps_hook_name=$1
+    sps_hook_file=$sps_stage/.SPS/hooks/$sps_hook_name
+    [ -f "$sps_hook_file" ] || return 0
+    printf '%s: running %s hook for %s\n' "$sps_prog" "$sps_hook_name" "$sps_name" >&2
+    SPS_ACTION=$sps_install_action
+    SPS_PACKAGE=$sps_name
+    SPS_VERSION=$sps_version
+    SPS_RELEASE=$sps_release
+    SPS_PACKAGE_ARCH=$sps_arch
+    export SPS_ACTION SPS_PACKAGE SPS_VERSION SPS_RELEASE SPS_PACKAGE_ARCH
+    sh -eu "$sps_hook_file"
+}
+
+# True when a protected regular file was changed since it was installed.
+sps_old_file_modified()
+{
+    sps_preserve_path=$1
+    sps_preserve_target=$2
+    [ -d "$sps_old_record" ] || return 1
+    sps_is_preserved_path "$sps_preserve_path" || return 1
+    [ -f "$sps_preserve_target" ] && [ ! -L "$sps_preserve_target" ] || return 1
+    sps_preserve_expected=$(awk -F "$sps_tab" -v path="$sps_preserve_path"         '$3 == path && $1 == "sha256" { print $2; exit }' "$sps_old_record/hashes")
+    [ -n "$sps_preserve_expected" ] || return 1
+    sps_preserve_actual=$(sps_sha256 "$sps_preserve_target") || return 0
+    [ "$sps_preserve_actual" != "$sps_preserve_expected" ]
+}
+
+if [ -d "$sps_old_record" ]; then
+    sps_install_action=upgrade
+else
+    sps_install_action=install
+fi
+sps_run_hook pre-install ||
+    sps_fail 10 "$sps_install_action pre-install hook failed for '$sps_name'"
+
+: >"$sps_work/self-owned"
+: >"$sps_work/conflicts"
+awk -F "$sps_tab" -v package="$sps_name" \
+    -v paths="$sps_work/owned" -v self="$sps_work/self-owned" \
+    -v conflicts="$sps_work/conflicts" '
+    BEGIN {
+        while ((getline path < paths) > 0) wanted[path] = 1
+        close(paths)
+    }
+    ($1 in wanted) {
+        if ($2 == package) print $1 > self
+        else print $1 "\t" $2 > conflicts
+    }
+' "$SPS_DB/owners"
+if [ -s "$sps_work/conflicts" ]; then
+    while IFS="$sps_tab" read -r sps_path sps_owner; do
+        printf '%s: /%s is owned by %s\n' "$sps_prog" "$sps_path" "$sps_owner" >&2
+    done <"$sps_work/conflicts"
+    sps_fail 7 "cannot install $sps_name-$sps_version-$sps_release"
+fi
+LC_ALL=C sort -u "$sps_work/self-owned" -o "$sps_work/self-owned"
+comm -23 "$sps_work/owned" "$sps_work/self-owned" >"$sps_work/unowned"
+
+while IFS= read -r sps_path || [ -n "$sps_path" ]; do
+    sps_target_path "$sps_path"
+    if sps_exists "$sps_target"; then
+        sps_fail 7 "/$sps_path exists and is not owned by an installed package"
+    fi
+done <"$sps_work/unowned"
+
+while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
+    sps_path=${sps_entry%/}
+    sps_safe_parents "$sps_path" || sps_fail 7 "unsafe target path /$sps_path"
+    sps_target_path "$sps_path"
+    case $sps_entry in
+        */)
+            if sps_exists "$sps_target" &&
+               { [ ! -d "$sps_target" ] || [ -L "$sps_target" ]; }; then
+                sps_fail 7 "/$sps_path exists and is not a directory"
+            fi
+            ;;
+        *)
+            if [ -d "$sps_target" ] && [ ! -L "$sps_target" ]; then
+                sps_fail 7 "/$sps_path is a directory"
+            fi
+            ;;
+    esac
+done <"$sps_stage/.SPS/files"
+
+mkdir -p "$SPS_ROOT" || sps_fail 8 "cannot create target root '$SPS_ROOT'"
+
+# Create directories first. Existing directories are shared, not exclusively
+# owned by a package.
+while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
+    case $sps_entry in
+        */)
+            sps_path=${sps_entry%/}
+            sps_safe_parents "$sps_path" || sps_fail 7 "unsafe target path /$sps_path"
+            sps_target_path "$sps_path"
+            if [ ! -d "$sps_target" ]; then
+                mkdir -p "$sps_target" || sps_fail 8 "cannot create /$sps_path"
+                sps_source_dir=$sps_stage/$sps_path
+                sps_dir_mode=$(sps_mode_of "$sps_source_dir") ||
+                    sps_fail 4 "cannot determine directory mode for /$sps_path"
+                sps_normalize_owner "$sps_target" ||
+                    sps_fail 8 "cannot normalize owner of /$sps_path"
+                chmod "$sps_dir_mode" "$sps_target" ||
+                    sps_fail 8 "cannot set mode on /$sps_path"
+            fi
+            ;;
+    esac
+done <"$sps_stage/.SPS/files"
+
+while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
+    case $sps_entry in */) continue ;; esac
+    sps_path=$sps_entry
+    sps_source=$sps_stage/$sps_path
+    sps_safe_stage_parents "$sps_path" || sps_fail 4 "unsafe extracted payload path /$sps_path"
+    sps_safe_parents "$sps_path" || sps_fail 7 "unsafe target path /$sps_path"
+    sps_target_path "$sps_path"
+    sps_parent=${sps_target%/*}
+    [ -n "$sps_parent" ] || sps_parent=/
+    mkdir -p "$sps_parent" || sps_fail 8 "cannot create parent of /$sps_path"
+    if [ -L "$sps_source" ]; then
+        sps_link_tmp=$(sps_mktemp_dir "$sps_parent" link) ||
+            sps_fail 8 "cannot stage symlink /$sps_path"
+        if ! cp -P "$sps_source" "$sps_link_tmp/item"; then
+            rm -rf "$sps_link_tmp"
+            sps_fail 8 "cannot copy symlink /$sps_path"
+        fi
+        if ! mv -f "$sps_link_tmp/item" "$sps_target"; then
+            rm -rf "$sps_link_tmp"
+            sps_fail 8 "cannot install symlink /$sps_path"
+        fi
+        rmdir "$sps_link_tmp" 2>/dev/null || :
+    else
+        # Keep the local file and leave the new packaged copy beside it as .sps-new.
+        if sps_old_file_modified "$sps_path" "$sps_target"; then
+            sps_old_expected=$(awk -F "$sps_tab" -v path="$sps_path"                 '$3 == path && $1 == "sha256" { print $2; exit }' "$sps_old_record/hashes")
+            sps_new_digest=$(sps_sha256 "$sps_source") ||
+                sps_fail 6 "cannot hash new protected file /$sps_path"
+            if [ "$sps_new_digest" = "$sps_old_expected" ]; then
+                sps_warn "/$sps_path is locally modified; packaged default is unchanged, keeping local file"
+                continue
+            fi
+            sps_new_target=$sps_target.sps-new
+            if [ -e "$sps_new_target" ] || [ -L "$sps_new_target" ]; then
+                if [ -f "$sps_new_target" ] && [ ! -L "$sps_new_target" ] &&
+                   [ "$(sps_sha256 "$sps_new_target" 2>/dev/null || :)" = "$sps_new_digest" ]; then
+                    sps_warn "/$sps_path is locally modified; matching .sps-new already exists"
+                    continue
+                fi
+                sps_new_target=$sps_target.sps-new.$sps_version-$sps_release
+            fi
+            sps_new_parent=${sps_new_target%/*}
+            sps_file_tmp=$(mktemp "$sps_new_parent/.sps-file.XXXXXX") ||
+                sps_fail 8 "cannot stage preserved update for /$sps_path"
+            if ! cp -p "$sps_source" "$sps_file_tmp"; then
+                rm -f "$sps_file_tmp"
+                sps_fail 8 "cannot stage preserved update for /$sps_path"
+            fi
+            if sps_is_root; then
+                # chown can clear setuid/setgid, so remember the mode only for those files.
+                sps_file_mode=
+                if [ -u "$sps_source" ] || [ -g "$sps_source" ]; then
+                    sps_file_mode=$(sps_mode_of "$sps_source") ||
+                        sps_fail 4 "cannot determine file mode for /$sps_path"
+                fi
+                sps_normalize_owner "$sps_file_tmp" ||
+                    sps_fail 8 "cannot normalize owner of /$sps_path"
+                if [ -n "$sps_file_mode" ]; then
+                    chmod "$sps_file_mode" "$sps_file_tmp" ||
+                        sps_fail 8 "cannot restore mode on /$sps_path"
+                fi
+            fi
+            mv -f "$sps_file_tmp" "$sps_new_target" || {
+                rm -f "$sps_file_tmp"
+                sps_fail 8 "cannot write preserved update for /$sps_path"
+            }
+            sps_warn "/$sps_path is locally modified; kept it and wrote ${sps_new_target#${SPS_ROOT%/}}"
+            continue
+        fi
+
+        sps_file_tmp=$(mktemp "$sps_parent/.sps-file.XXXXXX") ||
+            sps_fail 8 "cannot stage file /$sps_path"
+        if ! cp -p "$sps_source" "$sps_file_tmp"; then
+            rm -f "$sps_file_tmp"
+            sps_fail 8 "cannot install file /$sps_path"
+        fi
+        if sps_is_root; then
+            # chown can clear setuid/setgid, so remember the mode only for those files.
+            sps_file_mode=
+            if [ -u "$sps_source" ] || [ -g "$sps_source" ]; then
+                sps_file_mode=$(sps_mode_of "$sps_source") ||
+                    sps_fail 4 "cannot determine file mode for /$sps_path"
+            fi
+            sps_normalize_owner "$sps_file_tmp" ||
+                sps_fail 8 "cannot normalize owner of /$sps_path"
+            if [ -n "$sps_file_mode" ]; then
+                chmod "$sps_file_mode" "$sps_file_tmp" ||
+                    sps_fail 8 "cannot restore mode on /$sps_path"
+            fi
+        fi
+        if ! mv -f "$sps_file_tmp" "$sps_target"; then
+            rm -f "$sps_file_tmp"
+            sps_fail 8 "cannot install file /$sps_path"
+        fi
+    fi
+done <"$sps_stage/.SPS/files"
+
+comm -23 "$sps_work/old-owned" "$sps_work/owned" >"$sps_work/obsolete.candidates"
+# Read this package's owner entries once before cleaning obsolete files.
+awk -F "$sps_tab" -v package="$sps_name" '$2 == package { print $1 }' \
+    "$SPS_DB/owners" | LC_ALL=C sort -u >"$sps_work/current-owned" ||
+    sps_fail 11 "cannot inspect current owner index"
+comm -12 "$sps_work/obsolete.candidates" "$sps_work/current-owned" >"$sps_work/obsolete"
+while IFS= read -r sps_path || [ -n "$sps_path" ]; do
+    sps_safe_parents "$sps_path" || sps_fail 7 "unsafe target path /$sps_path"
+    sps_target_path "$sps_path"
+    if [ -f "$sps_target" ] && [ ! -L "$sps_target" ] &&
+       sps_old_file_modified "$sps_path" "$sps_target"; then
+        sps_warn "/$sps_path is locally modified and obsolete; keeping it unowned"
+        continue
+    fi
+    if [ -L "$sps_target" ] || [ -f "$sps_target" ]; then
+        rm -f "$sps_target" || sps_fail 8 "cannot remove obsolete file /$sps_path"
+    fi
+done <"$sps_work/obsolete"
+if [ -d "$sps_old_record" ]; then
+    LC_ALL=C sort -r "$sps_old_record/files" >"$sps_work/old-files.reverse"
+    while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
+        case $sps_entry in
+            */)
+                sps_path=${sps_entry%/}
+                if ! awk -v wanted="$sps_path" '
+                    { value=$0; sub(/\/$/, "", value); if (value == wanted) found=1 }
+                    END { exit !found }
+                ' "$sps_stage/.SPS/files"; then
+                    sps_safe_parents "$sps_path" || continue
+                    sps_target_path "$sps_path"
+                    rmdir "$sps_target" 2>/dev/null || :
+                fi
+                ;;
+        esac
+    done <"$sps_work/old-files.reverse"
+fi
+
+sps_record_tmp=$(sps_mktemp_dir "$SPS_DB/installed" record) ||
+    sps_fail 11 "cannot stage installed package record"
+cp "$sps_stage/.SPS/meta" "$sps_record_tmp/meta" &&
+cp "$sps_stage/.SPS/files" "$sps_record_tmp/files" &&
+cp "$sps_stage/.SPS/hashes" "$sps_record_tmp/hashes" ||
+    sps_fail 11 "cannot stage installed package metadata"
+if [ -d "$sps_stage/.SPS/hooks" ]; then
+    mkdir "$sps_record_tmp/hooks" || sps_fail 11 "cannot stage package hooks"
+    for sps_hook in pre-install post-install pre-remove post-remove; do
+        [ -f "$sps_stage/.SPS/hooks/$sps_hook" ] || continue
+        cp "$sps_stage/.SPS/hooks/$sps_hook" "$sps_record_tmp/hooks/$sps_hook" ||
+            sps_fail 11 "cannot stage package hook '$sps_hook'"
+    done
+fi
+
+sps_owners_base=$(mktemp "$SPS_DB/.owners-base.XXXXXX") || sps_fail 11 "cannot stage owner index"
+sps_owners_new=$(mktemp "$SPS_DB/.owners-new.XXXXXX") || sps_fail 11 "cannot stage owner index"
+if ! awk -v sps_action=owners-remove -v sps_package="$sps_name" -v sps_program=pkin \
+    -f "$SPS_LIBDIR/db.awk" "$SPS_DB/owners" >"$sps_owners_base"; then
+    sps_fail 11 "installed ownership database is malformed"
+fi
+while IFS= read -r sps_path || [ -n "$sps_path" ]; do
+    printf '%s\t%s\n' "$sps_path" "$sps_name" >>"$sps_owners_base"
+done <"$sps_work/owned"
+LC_ALL=C sort "$sps_owners_base" -o "$sps_owners_new" || sps_fail 11 "cannot stage owner index"
+
+sps_world_base=$(mktemp "$SPS_DB/.world-base.XXXXXX") || sps_fail 11 "cannot stage world database"
+sps_world_new=$(mktemp "$SPS_DB/.world-new.XXXXXX") || sps_fail 11 "cannot stage world database"
+sps_was_explicit=0
+if awk -v package="$sps_name" '$0 == package { found=1 } END { exit !found }' "$SPS_DB/world"; then
+    sps_was_explicit=1
+fi
+if ! awk -v sps_action=world-remove -v sps_package="$sps_name" -v sps_program=pkin \
+    -f "$SPS_LIBDIR/db.awk" "$SPS_DB/world" >"$sps_world_base"; then
+    sps_fail 11 "installed world database is malformed"
+fi
+if [ "$sps_reason" = explicit ] || [ "$sps_was_explicit" -eq 1 ]; then
+    printf '%s\n' "$sps_name" >>"$sps_world_base"
+fi
+LC_ALL=C sort -u "$sps_world_base" -o "$sps_world_new" || sps_fail 11 "cannot stage world database"
+
+sps_backup_dir=$(sps_mktemp_dir "$SPS_DB/installed" backup) ||
+    sps_fail 11 "cannot create database rollback area"
+cp -p "$SPS_DB/owners" "$sps_backup_dir/owners" &&
+cp -p "$SPS_DB/world" "$sps_backup_dir/world" ||
+    sps_fail 11 "cannot preserve database state"
+sps_had_old=0
+if [ -d "$sps_old_record" ]; then
+    mv "$sps_old_record" "$sps_backup_dir/record" || sps_fail 11 "cannot preserve old package record"
+    sps_had_old=1
+    sps_record_backed_up=1
+fi
+
+sps_database_commit_started=1
+sps_commit_failed=0
+mv "$sps_record_tmp" "$sps_old_record" || sps_commit_failed=1
+if [ "$sps_commit_failed" -eq 0 ]; then sps_record_tmp=; fi
+if [ "$sps_commit_failed" -eq 0 ]; then
+    mv -f "$sps_owners_new" "$SPS_DB/owners" || sps_commit_failed=1
+fi
+if [ "$sps_commit_failed" -eq 0 ]; then
+    mv -f "$sps_world_new" "$SPS_DB/world" || sps_commit_failed=1
+fi
+if [ "$sps_commit_failed" -ne 0 ]; then
+    rm -rf "$sps_old_record"
+    if [ "$sps_had_old" -eq 1 ]; then
+        mv "$sps_backup_dir/record" "$sps_old_record" 2>/dev/null || :
+    fi
+    cp "$sps_backup_dir/owners" "$SPS_DB/.owners-restore.$$" 2>/dev/null &&
+        mv -f "$SPS_DB/.owners-restore.$$" "$SPS_DB/owners" 2>/dev/null || :
+    cp "$sps_backup_dir/world" "$SPS_DB/.world-restore.$$" 2>/dev/null &&
+        mv -f "$SPS_DB/.world-restore.$$" "$SPS_DB/world" 2>/dev/null || :
+    sps_record_backed_up=0
+    sps_database_commit_started=0
+    sps_fail 11 "database commit failed; previous metadata was restored"
+fi
+sps_commit_complete=1
+
+sps_date=$(date '+%Y-%m-%d' 2>/dev/null || printf '%s' unknown)
+if [ -n "$sps_old_version" ]; then
+    printf '%s upgrade %s %s %s\n' "$sps_date" "$sps_name" "$sps_old_version" "$sps_version" >>"$SPS_DB/history" ||
+        sps_warn "could not append package history"
+else
+    printf '%s install %s %s-%s\n' "$sps_date" "$sps_name" "$sps_version" "$sps_release" >>"$SPS_DB/history" ||
+        sps_warn "could not append package history"
+fi
+
+if ! sps_run_hook post-install; then
+    sps_warn "$sps_install_action completed, but post-install hook failed for '$sps_name'"
+    exit 10
+fi
+
+if [ -n "$sps_old_version" ]; then
+    printf '%s: upgraded %s %s -> %s-%s\n' "$sps_prog" "$sps_name" "$sps_old_version" "$sps_version" "$sps_release"
+else
+    printf '%s: installed %s %s-%s\n' "$sps_prog" "$sps_name" "$sps_version" "$sps_release"
+fi
+
+exit 0
diff --git a/bin/pkmark b/bin/pkmark
new file mode 100755
index 0000000..4f75e15
--- /dev/null
+++ b/bin/pkmark
@@ -0,0 +1,90 @@
+#!/bin/sh
+# Change whether installed packages are explicit roots.
+
+sps_prog=pkmark
+sps_bin_dir=$(CDPATH= cd -P "$(dirname "$0")" 2>/dev/null && pwd) || exit 1
+if [ -z "${SPS_LIBDIR+x}" ]; then
+    if [ -r "$sps_bin_dir/../lib/common.sh" ]; then
+        SPS_LIBDIR=$sps_bin_dir/../lib
+    else
+        SPS_LIBDIR=$sps_bin_dir/../lib/sps
+    fi
+fi
+if ! . "$SPS_LIBDIR/common.sh"; then
+    printf '%s\n' "pkmark: cannot load $SPS_LIBDIR/common.sh" >&2
+    exit 1
+fi
+sps_load_config || exit $?
+
+usage()
+{
+    printf '%s\n' 'usage: pkmark explicit PACKAGE...'
+    printf '%s\n' '       pkmark dependency PACKAGE...'
+}
+
+case ${1-} in
+    explicit|dependency) mode=$1; shift ;;
+    -h|--help) usage; exit 0 ;;
+    *) usage >&2; exit "$SPS_EX_USAGE" ;;
+esac
+[ "$#" -gt 0 ] || { usage >&2; exit "$SPS_EX_USAGE"; }
+
+[ -d "$SPS_DB/installed" ] && [ ! -L "$SPS_DB/installed" ] ||
+    sps_die "$SPS_EX_DATABASE" "installed database is missing or unsafe"
+[ -f "$SPS_DB/world" ] && [ ! -L "$SPS_DB/world" ] ||
+    sps_die "$SPS_EX_DATABASE" "world database is missing or unsafe"
+awk -v sps_action=world-validate -v sps_program=pkmark \
+    -f "$SPS_LIBDIR/db.awk" "$SPS_DB/world" ||
+    sps_die "$SPS_EX_DATABASE" "world database is malformed"
+
+requested=
+for package
+do
+    sps_validate_package_name "$package" ||
+        sps_die "$SPS_EX_USAGE" "invalid package name '$package'"
+    [ -d "$SPS_DB/installed/$package" ] && [ ! -L "$SPS_DB/installed/$package" ] ||
+        sps_die "$SPS_EX_NOTFOUND" "package '$package' is not installed"
+    case " $requested " in
+        *" $package "*) sps_die "$SPS_EX_USAGE" "package '$package' was specified more than once" ;;
+    esac
+    requested="$requested $package"
+done
+
+sps_lock_owned=0
+trap 'status=$?; sps_lock_release; [ -n "${tmp-}" ] && rm -f "$tmp"; exit "$status"' 0
+trap 'exit 130' 1 2 3 15
+sps_lock_acquire "$SPS_DB" ||
+    sps_die "$SPS_EX_DATABASE" "package database is locked: '$SPS_DB/.lock'"
+
+tmp=$(mktemp "$SPS_DB/.world-new.XXXXXX") ||
+    sps_die "$SPS_EX_DATABASE" "cannot stage world database"
+cp "$SPS_DB/world" "$tmp" || sps_die "$SPS_EX_DATABASE" "cannot stage world database"
+
+for package
+do
+    if [ "$mode" = explicit ]; then
+        if ! grep -F -x -e "$package" "$tmp" >/dev/null 2>&1; then
+            printf '%s\n' "$package" >>"$tmp" ||
+                sps_die "$SPS_EX_DATABASE" "cannot update world database"
+        fi
+    else
+        next=$(mktemp "$SPS_DB/.world-filter.XXXXXX") ||
+            sps_die "$SPS_EX_DATABASE" "cannot stage world database"
+        awk -v p="$package" '$0 != p { print }' "$tmp" >"$next" || {
+            rm -f "$next"
+            sps_die "$SPS_EX_DATABASE" "cannot update world database"
+        }
+        mv -f "$next" "$tmp" || sps_die "$SPS_EX_DATABASE" "cannot update world database"
+    fi
+done
+LC_ALL=C sort -u "$tmp" -o "$tmp" || sps_die "$SPS_EX_DATABASE" "cannot sort world database"
+mv -f "$tmp" "$SPS_DB/world" || sps_die "$SPS_EX_DATABASE" "cannot commit world database"
+tmp=
+
+sps_date=$(date '+%Y-%m-%d' 2>/dev/null || printf '%s' unknown)
+for package
+do
+    printf '%s mark %s %s\n' "$sps_date" "$package" "$mode" >>"$SPS_DB/history" 2>/dev/null || :
+    printf '%s: marked %s as %s\n' "$sps_prog" "$package" "$mode"
+done
+exit 0
diff --git a/bin/pkstat b/bin/pkstat
new file mode 100755
index 0000000..5f74a1e
--- /dev/null
+++ b/bin/pkstat
@@ -0,0 +1,247 @@
+#!/bin/sh
+
+sps_prog=pkstat
+sps_bin_dir=$(CDPATH= cd -P "$(dirname "$0")" 2>/dev/null && pwd) || exit 1
+if [ -z "${SPS_LIBDIR+x}" ]; then
+    if [ -r "$sps_bin_dir/../lib/common.sh" ]; then
+        SPS_LIBDIR=$sps_bin_dir/../lib
+    else
+        SPS_LIBDIR=$sps_bin_dir/../lib/sps
+    fi
+fi
+if ! . "$SPS_LIBDIR/common.sh"; then
+    printf '%s\n' "pkstat: cannot load $SPS_LIBDIR/common.sh" >&2
+    exit 1
+fi
+sps_load_config || exit $?
+
+sps_usage()
+{
+    cat <<'EOF'
+usage: pkstat [PACKAGE]
+       pkstat --files PACKAGE
+       pkstat --owns PATH
+       pkstat --explicit
+       pkstat --orphans
+       pkstat --size PACKAGE
+EOF
+}
+
+sps_fail()
+{
+    sps_code=$1
+    shift
+    printf '%s: %s\n' "$sps_prog" "$*" >&2
+    exit "$sps_code"
+}
+
+sps_target_path()
+{
+    case $SPS_ROOT in
+        /) sps_target=/$1 ;;
+        */) sps_target=$SPS_ROOT$1 ;;
+        *) sps_target=$SPS_ROOT/$1 ;;
+    esac
+}
+
+sps_require_installed()
+{
+    [ -d "$SPS_DB/installed" ] && [ ! -L "$SPS_DB/installed" ] ||
+        sps_fail 11 "installed database is missing or is not a real directory"
+}
+
+sps_record_for()
+{
+    sps_lookup_name=$1
+    sps_validate_package_name "$sps_lookup_name" ||
+        sps_fail 2 "invalid package name '$sps_lookup_name'"
+    sps_require_installed
+    sps_record=$SPS_DB/installed/$sps_lookup_name
+    if [ -L "$sps_record" ]; then
+        sps_fail 11 "installed record for '$sps_lookup_name' is unsafe"
+    fi
+    [ -d "$sps_record" ] || sps_fail 3 "package '$sps_lookup_name' is not installed"
+    [ -f "$sps_record/meta" ] && [ ! -L "$sps_record/meta" ] ||
+        sps_fail 11 "installed metadata for '$sps_lookup_name' is missing"
+}
+
+sps_summary()
+{
+    sps_summary_name=$1
+    sps_summary_record=$SPS_DB/installed/$sps_summary_name
+    sps_summary_id=$(awk -v sps_action=meta-id -v sps_program=pkstat \
+        -f "$SPS_LIBDIR/package.awk" "$sps_summary_record/meta") ||
+        sps_fail 11 "installed metadata for '$sps_summary_name' is malformed"
+    sps_tab=$(printf '\tX')
+    sps_tab=${sps_tab%X}
+    sps_saved_ifs=$IFS
+    IFS=$sps_tab
+    set -- $sps_summary_id
+    IFS=$sps_saved_ifs
+    [ "$#" -eq 4 ] || sps_fail 11 "installed identity for '$sps_summary_name' is malformed"
+    printf '%s %s-%s %s\n' "$1" "$2" "$3" "$4"
+}
+
+sps_installed=$SPS_DB/installed
+sps_command=${1-}
+case $sps_command in
+    -h|--help)
+        sps_usage
+        exit 0
+        ;;
+    --files)
+        [ "$#" -eq 2 ] || { sps_usage >&2; exit 2; }
+        sps_record_for "$2"
+        [ -f "$sps_record/files" ] && [ ! -L "$sps_record/files" ] ||
+            sps_fail 11 "installed manifest for '$2' is missing"
+        awk -v sps_action=validate-paths -v sps_program=pkstat \
+            -f "$SPS_LIBDIR/package.awk" "$sps_record/files" ||
+            sps_fail 11 "installed manifest for '$2' is malformed"
+        awk '{ print "/" $0 }' "$sps_record/files"
+        ;;
+    --owns)
+        [ "$#" -eq 2 ] || { sps_usage >&2; exit 2; }
+        sps_query=$2
+        case $SPS_ROOT in
+            /) case $sps_query in /*) sps_path=${sps_query#/} ;; *) sps_path=$sps_query ;; esac ;;
+            *)
+                case $sps_query in
+                    "$SPS_ROOT"/*) sps_path=${sps_query#"$SPS_ROOT"/} ;;
+                    /*) sps_path=${sps_query#/} ;;
+                    *) sps_path=$sps_query ;;
+                esac
+                ;;
+        esac
+        sps_path=${sps_path%/}
+        [ -n "$sps_path" ] || sps_fail 3 "no package owns the filesystem root"
+        if ! printf '%s\n' "$sps_path" | awk -v sps_action=validate-paths \
+            -v sps_program=pkstat -f "$SPS_LIBDIR/package.awk"; then
+            sps_fail 2 "invalid ownership path '$sps_query'"
+        fi
+        [ -f "$SPS_DB/owners" ] && [ ! -L "$SPS_DB/owners" ] ||
+            sps_fail 11 "ownership database is missing or unsafe"
+        sps_owner=$(awk -v sps_action=owners-query -v sps_path="$sps_path" \
+            -v sps_program=pkstat -f "$SPS_LIBDIR/db.awk" "$SPS_DB/owners") ||
+            sps_fail 11 "ownership database is malformed"
+        [ -n "$sps_owner" ] || sps_fail 3 "no installed package owns /$sps_path"
+        sps_summary "$sps_owner"
+        ;;
+    --explicit)
+        [ "$#" -eq 1 ] || { sps_usage >&2; exit 2; }
+        sps_require_installed
+        [ -f "$SPS_DB/world" ] && [ ! -L "$SPS_DB/world" ] ||
+            sps_fail 11 "world database is missing or unsafe"
+        awk -v sps_action=world-validate -v sps_program=pkstat \
+            -f "$SPS_LIBDIR/db.awk" "$SPS_DB/world" ||
+            sps_fail 11 "world database is malformed"
+        while IFS= read -r sps_name || [ -n "$sps_name" ]; do
+            [ -n "$sps_name" ] || continue
+            [ -d "$sps_installed/$sps_name" ] ||
+                sps_fail 11 "explicit package '$sps_name' has no installed record"
+            sps_summary "$sps_name"
+        done <"$SPS_DB/world"
+        ;;
+    --orphans)
+        [ "$#" -eq 1 ] || { sps_usage >&2; exit 2; }
+        sps_require_installed
+        [ -f "$SPS_DB/world" ] && [ ! -L "$SPS_DB/world" ] ||
+            sps_fail 11 "world database is missing or unsafe"
+        awk -v sps_action=world-validate -v sps_program=pkstat \
+            -f "$SPS_LIBDIR/db.awk" "$SPS_DB/world" ||
+            sps_fail 11 "world database is malformed"
+        set -- "$sps_installed"/*/meta
+        [ -f "$1" ] || exit 0
+        awk -v world="$SPS_DB/world" '
+            function depname(spec,    p) {
+                p=match(spec, />=|<=|=|>|</)
+                if (p) spec=substr(spec,1,RSTART-1)
+                return spec
+            }
+            function visit(pkg,    k,a) {
+                if (reachable[pkg]++) return
+                for (k in edge) {
+                    split(k,a,SUBSEP)
+                    if (a[1] == pkg && (a[2] in installed)) visit(a[2])
+                }
+            }
+            BEGIN {
+                while ((getline name < world) > 0) if (name != "") root[name]=1
+                close(world)
+            }
+            FNR == 1 {
+                package=FILENAME
+                sub(/\/meta$/, "", package)
+                sub(/^.*\//, "", package)
+                installed[package]=1
+            }
+            {
+                pos=match($0, /[[:space:]]/)
+                if (!pos) next
+                key=substr($0,1,pos-1)
+                if (key != "depend") next
+                value=substr($0,pos)
+                sub(/^[[:space:]]+/, "", value)
+                gsub(/,/, " ", value)
+                n=split(value, part, /[[:space:]]+/)
+                for (i=1;i<=n;i++) {
+                    d=depname(part[i])
+                    if (d != "") edge[package SUBSEP d]=1
+                }
+            }
+            END {
+                for (package in root) if (package in installed) visit(package)
+                for (package in installed) if (!(package in reachable)) print package
+            }
+        ' "$@" | LC_ALL=C sort
+        ;;
+    --size)
+        [ "$#" -eq 2 ] || { sps_usage >&2; exit 2; }
+        sps_record_for "$2"
+        [ -f "$sps_record/files" ] && [ ! -L "$sps_record/files" ] ||
+            sps_fail 11 "installed manifest for '$2' is missing"
+        awk -v sps_action=validate-paths -v sps_program=pkstat \
+            -f "$SPS_LIBDIR/package.awk" "$sps_record/files" ||
+            sps_fail 11 "installed manifest for '$2' is malformed"
+        sps_size=0
+        while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
+            case $sps_entry in */) continue ;; esac
+            sps_target_path "$sps_entry"
+            if [ -f "$sps_target" ] && [ ! -L "$sps_target" ]; then
+                sps_bytes=$(wc -c <"$sps_target") || sps_fail 8 "cannot read /$sps_entry"
+                sps_size=$((sps_size + sps_bytes))
+            fi
+        done <"$sps_record/files"
+        printf '%s %s\n' "$2" "$sps_size"
+        ;;
+    --*)
+        sps_usage >&2
+        exit 2
+        ;;
+    '')
+        sps_require_installed
+        for sps_record in "$sps_installed"/*; do
+            [ -d "$sps_record" ] || continue
+            [ ! -L "$sps_record" ] || sps_fail 11 "unsafe installed record '${sps_record##*/}'"
+            sps_name=${sps_record##*/}
+            sps_summary "$sps_name"
+        done
+        ;;
+    *)
+        [ "$#" -eq 1 ] || { sps_usage >&2; exit 2; }
+        sps_record_for "$1"
+        awk -v sps_action=validate-meta -v sps_program=pkstat \
+            -f "$SPS_LIBDIR/package.awk" "$sps_record/meta" ||
+            sps_fail 11 "installed metadata for '$1' is malformed"
+        awk '
+            {
+                pos = match($0, /[[:space:]]/)
+                if (!pos) { print; next }
+                key = substr($0, 1, pos - 1)
+                value = substr($0, pos)
+                sub(/^[[:space:]]+/, "", value)
+                printf "%-12s %s\n", key, value
+            }
+        ' "$sps_record/meta"
+        ;;
+esac
+exit 0
diff --git a/bin/sget b/bin/sget
new file mode 100755
index 0000000..b0c735b
--- /dev/null
+++ b/bin/sget
@@ -0,0 +1,616 @@
+#!/bin/sh
+# High-level package and dependency coordinator.
+
+sps_prog=sget
+sps_bin_dir=$(CDPATH= cd -P "$(dirname "$0")" 2>/dev/null && pwd) || exit 1
+if [ -z "${SPS_LIBDIR+x}" ]; then
+    if [ -r "$sps_bin_dir/../lib/common.sh" ]; then
+        SPS_LIBDIR=$sps_bin_dir/../lib
+    else
+        SPS_LIBDIR=$sps_bin_dir/../lib/sps
+    fi
+fi
+if ! . "$SPS_LIBDIR/common.sh"; then
+    printf '%s\n' "sget: cannot load $SPS_LIBDIR/common.sh" >&2
+    exit 1
+fi
+sps_load_config || exit $?
+
+sps_index=$SPS_CACHE/indexes/packages.index
+sps_tab=$(printf '\tX')
+sps_tab=${sps_tab%X}
+
+sget_begin_transaction()
+{
+    if [ -e "$SPS_DB" ] || [ -L "$SPS_DB" ]; then
+        [ -d "$SPS_DB" ] && [ ! -L "$SPS_DB" ] ||
+            sps_die "$SPS_EX_DATABASE" "package database is not a real directory: $SPS_DB"
+    else
+        mkdir -p "$SPS_DB" ||
+            sps_die "$SPS_EX_PERMISSION" "cannot create package database '$SPS_DB'"
+        [ -d "$SPS_DB" ] && [ ! -L "$SPS_DB" ] ||
+            sps_die "$SPS_EX_DATABASE" "package database is not a real directory: $SPS_DB"
+    fi
+    sps_lock_acquire "$SPS_DB" .transaction ||
+        sps_die "$SPS_EX_DATABASE" "another high-level SPS transaction is active: '$SPS_DB/.transaction'"
+}
+
+sget_usage()
+{
+    cat <<'EOF_USAGE'
+usage: sget search PATTERN [--raw]
+       sget info PACKAGE [--raw]
+       sget depends PACKAGE [--raw] [--build]
+       sget dependees PACKAGE [--build]
+       sget why PACKAGE DEPENDENCY [--build]
+       sget install [--plan] [--nodeps] [--reinstall] PACKAGE...
+       sget upgrade [--plan] [--downgrade]
+       sget remove [--plan] [--nodeps] PACKAGE...
+EOF_USAGE
+}
+
+sget_need_index()
+{
+    [ -r "$sps_index" ] ||
+        sps_die "$SPS_EX_NOTFOUND" "repository index not found; run 'src update'"
+}
+
+sget_query()
+{
+    sget_action=$1
+    shift
+    sps_need_arg "$sget_action" "${1-}"
+    sget_value=$1
+    shift
+    sget_raw=0
+    case ${1-} in
+        --raw) sget_raw=1; shift ;;
+    esac
+    [ "$#" -eq 0 ] || { sget_usage >&2; exit "$SPS_EX_USAGE"; }
+    sget_need_index
+
+    case $sget_action in
+        search)
+            sps_tab_safe "$sget_value" ||
+                sps_die "$SPS_EX_USAGE" "search pattern contains a tab or newline"
+            [ "$sget_raw" -eq 0 ] || sget_action=search_raw
+            ;;
+        info)
+            sps_validate_package_name "$sget_value" ||
+                sps_die "$SPS_EX_USAGE" "invalid package name '$sget_value'"
+            [ "$sget_raw" -eq 0 ] && sget_action=show || sget_action=exact
+            ;;
+    esac
+
+    awk -v action="$sget_action" -v query="$sget_value" \
+        -v program=sget -f "$SPS_LIBDIR/index.awk" "$sps_index"
+}
+
+sget_depends()
+{
+    sps_need_arg depends "${1-}"
+    sget_package=$1
+    shift
+    sps_validate_package_name "$sget_package" ||
+        sps_die "$SPS_EX_USAGE" "invalid package name '$sget_package'"
+    sget_dep_action=tree
+    sget_include_build=0
+    while [ "$#" -gt 0 ]; do
+        case $1 in
+            --raw) sget_dep_action=order ;;
+            --build) sget_include_build=1 ;;
+            *) sget_usage >&2; exit "$SPS_EX_USAGE" ;;
+        esac
+        shift
+    done
+    sget_need_index
+    awk -v action="$sget_dep_action" -v roots="$sget_package" \
+        -v include_build="$sget_include_build" -v program=sget \
+        -f "$SPS_LIBDIR/version.awk" -f "$SPS_LIBDIR/deps.awk" \
+        "$sps_index"
+}
+
+sget_dependees()
+{
+    sps_need_arg dependees "${1-}"
+    sget_package=$1
+    shift
+    sps_validate_package_name "$sget_package" ||
+        sps_die "$SPS_EX_USAGE" "invalid package name '$sget_package'"
+    sget_include_build=0
+    case ${1-} in --build) sget_include_build=1; shift ;; esac
+    [ "$#" -eq 0 ] || { sget_usage >&2; exit "$SPS_EX_USAGE"; }
+    sget_need_index
+    awk -v action=dependees -v query="$sget_package" \
+        -v include_build="$sget_include_build" -v program=sget \
+        -f "$SPS_LIBDIR/relations.awk" "$sps_index"
+}
+
+sget_why()
+{
+    [ "$#" -ge 2 ] || { sget_usage >&2; exit "$SPS_EX_USAGE"; }
+    sget_root=$1
+    sget_dependency=$2
+    shift 2
+    sps_validate_package_name "$sget_root" ||
+        sps_die "$SPS_EX_USAGE" "invalid package name '$sget_root'"
+    sps_validate_package_name "$sget_dependency" ||
+        sps_die "$SPS_EX_USAGE" "invalid package name '$sget_dependency'"
+    sget_include_build=0
+    case ${1-} in --build) sget_include_build=1; shift ;; esac
+    [ "$#" -eq 0 ] || { sget_usage >&2; exit "$SPS_EX_USAGE"; }
+    sget_need_index
+    awk -v action=why -v root="$sget_root" -v query="$sget_dependency" \
+        -v include_build="$sget_include_build" -v program=sget \
+        -f "$SPS_LIBDIR/relations.awk" "$sps_index"
+}
+
+sget_read_installed()
+{
+    sget_installed_name=$1
+    sget_installed_record=$SPS_DB/installed/$sget_installed_name
+    if [ -L "$sget_installed_record" ] ||
+       { [ -f "$sget_installed_record" ] && [ ! -d "$sget_installed_record" ]; }; then
+        sps_die "$SPS_EX_DATABASE" "installed record for '$sget_installed_name' is unsafe"
+    fi
+    [ -d "$sget_installed_record" ] || return 1
+    [ -f "$sget_installed_record/meta" ] && [ ! -L "$sget_installed_record/meta" ] ||
+        sps_die "$SPS_EX_DATABASE" "installed metadata for '$sget_installed_name' is missing or unsafe"
+    sget_installed_id=$(awk -v sps_action=meta-id -v sps_program=sget \
+        -f "$SPS_LIBDIR/package.awk" "$sget_installed_record/meta") ||
+        sps_die "$SPS_EX_DATABASE" "installed metadata for '$sget_installed_name' is malformed"
+    sget_saved_ifs=$IFS
+    IFS=$sps_tab
+    set -- $sget_installed_id
+    IFS=$sget_saved_ifs
+    [ "$#" -eq 4 ] ||
+        sps_die "$SPS_EX_DATABASE" "installed identity for '$sget_installed_name' is malformed"
+    [ "$1" = "$sget_installed_name" ] ||
+        sps_die "$SPS_EX_DATABASE" "installed record '$sget_installed_name' identifies package '$1'"
+    sget_installed_version=$2
+    sget_installed_release=$3
+    sget_installed_arch=$4
+    return 0
+}
+
+# Compare the selected version/release with what is installed. Prints -1, 0, or 1.
+sget_compare_selected()
+{
+    sget_cmp=$(awk -v version_action=compare -v version_a="$1" -v version_b="$3" \
+        -f "$SPS_LIBDIR/version.awk" /dev/null) || return 1
+    if [ "$sget_cmp" -eq 0 ]; then
+        sget_cmp=$(awk -v version_action=compare -v version_a="$2" -v version_b="$4" \
+            -f "$SPS_LIBDIR/version.awk" /dev/null) || return 1
+    fi
+    printf '%s\n' "$sget_cmp"
+}
+
+sget_cleanup_install()
+{
+    if [ -n "${sget_work:-}" ]; then
+        rm -rf "$sget_work"
+        sget_work=
+    fi
+    sps_lock_release
+}
+
+sget_verify_artifact_identity()
+{
+    sget_verify_archive=$1
+    sget_verify_name=$2
+    sget_verify_version=$3
+    sget_verify_release=$4
+    sget_verify_arch=$5
+
+    case $sget_verify_archive in
+        *.tar.gz|*.tgz)
+            sps_require_cmd gzip
+            sget_verify_meta=$(gzip -dc "$sget_verify_archive" 2>/dev/null |
+                tar -xOf - ./.SPS/meta 2>/dev/null) || return 1
+            ;;
+        *.tar.xz|*.txz)
+            sps_require_cmd xz
+            sget_verify_meta=$(xz -dc "$sget_verify_archive" 2>/dev/null |
+                tar -xOf - ./.SPS/meta 2>/dev/null) || return 1
+            ;;
+        *.tar.zst|*.tzst)
+            sps_require_cmd zstd
+            sget_verify_meta=$(zstd -q -dc "$sget_verify_archive" 2>/dev/null |
+                tar -xOf - ./.SPS/meta 2>/dev/null) || return 1
+            ;;
+        *)
+            sget_verify_meta=$(tar -xOf "$sget_verify_archive" ./.SPS/meta 2>/dev/null) || return 1
+            ;;
+    esac
+    sget_verify_id=$(printf '%s\n' "$sget_verify_meta" |
+        awk -v sps_action=meta-id -v sps_program=sget \
+            -f "$SPS_LIBDIR/package.awk") || return 1
+    sget_verify_saved_ifs=$IFS
+    IFS=$sps_tab
+    set -- $sget_verify_id
+    IFS=$sget_verify_saved_ifs
+    [ "$#" -eq 4 ] || return 1
+    [ "$1" = "$sget_verify_name" ] &&
+    [ "$2" = "$sget_verify_version" ] &&
+    [ "$3" = "$sget_verify_release" ] &&
+    [ "$4" = "$sget_verify_arch" ]
+}
+
+# Set by the upgrade path.
+sget_upgrade_mode=${sget_upgrade_mode:-0}
+
+sget_install()
+{
+    sget_plan=0
+    sget_nodeps=0
+    sget_reinstall=0
+    sget_allow_downgrade=0
+    while [ "$#" -gt 0 ]; do
+        case $1 in
+            --plan) sget_plan=1; shift ;;
+            --nodeps) sget_nodeps=1; shift ;;
+            --reinstall) sget_reinstall=1; shift ;;
+            --downgrade) sget_allow_downgrade=1; shift ;;
+            --) shift; break ;;
+            -*) sps_die "$SPS_EX_USAGE" "unknown option '$1'" ;;
+            *) break ;;
+        esac
+    done
+    [ "$#" -gt 0 ] || { sget_usage >&2; exit "$SPS_EX_USAGE"; }
+    sget_need_index
+
+    sget_roots=
+    for sget_package
+    do
+        sps_validate_package_name "$sget_package" ||
+            sps_die "$SPS_EX_USAGE" "invalid package name '$sget_package'"
+        case ,$sget_roots, in
+            *,"$sget_package",*)
+                sps_die "$SPS_EX_USAGE" "package '$sget_package' was requested more than once" ;;
+        esac
+        [ -n "$sget_roots" ] && sget_roots=$sget_roots,$sget_package || sget_roots=$sget_package
+    done
+
+    sget_work=$(sps_mktemp_dir "${TMPDIR:-/tmp}" sget)
+    trap sget_cleanup_install 0
+    trap 'exit 129' 1
+    trap 'exit 130' 2
+    trap 'exit 131' 3
+    trap 'exit 143' 15
+    : >"$sget_work/resolution"
+    : >"$sget_work/todo"
+    : >"$sget_work/mark-explicit"
+    sps_mkdir "$SPS_CACHE/packages"
+
+    if [ "$sget_nodeps" -eq 1 ]; then
+        for sget_package
+        do
+            awk -v action=install_record -v query="$sget_package" \
+                -v program=sget -f "$SPS_LIBDIR/index.awk" "$sps_index" \
+                >>"$sget_work/resolution" || exit $?
+        done
+    else
+        if ! awk -v action=plan -v roots="$sget_roots" \
+            -v include_build=1 -v program=sget \
+            -f "$SPS_LIBDIR/version.awk" -f "$SPS_LIBDIR/deps.awk" \
+            "$sps_index" >"$sget_work/resolution"; then
+            sps_die "$SPS_EX_DEPENDENCY" "cannot resolve requested packages"
+        fi
+    fi
+
+    sget_count=0
+    while IFS="$sps_tab" read -r sget_package sget_recipe sget_recipe_hash \
+          sget_version sget_release sget_arch || [ -n "$sget_package" ]
+    do
+        [ -n "$sget_package" ] || continue
+        case $sget_arch in
+            any|noarch|"$SPS_ARCH") ;;
+            *) sps_die "$SPS_EX_PACKAGE" "'$sget_package' targets '$sget_arch', not '$SPS_ARCH'" ;;
+        esac
+
+        sget_action=install
+        if sget_read_installed "$sget_package"; then
+            sget_cmp=$(sget_compare_selected "$sget_version" "$sget_release" \
+                       "$sget_installed_version" "$sget_installed_release") ||
+                sps_die "$SPS_EX_PACKAGE" "cannot compare package versions"
+            if [ "$sget_cmp" -eq 0 ] && [ "$sget_installed_arch" = "$sget_arch" ]; then
+                case ,$sget_roots, in
+                    *,"$sget_package",*)
+                        if [ ! -r "$SPS_DB/world" ] ||
+                           ! grep -F -x -e "$sget_package" "$SPS_DB/world" >/dev/null 2>&1; then
+                            printf '%s\n' "$sget_package" >>"$sget_work/mark-explicit"
+                        fi
+                        ;;
+                esac
+                if [ "$sget_reinstall" -eq 1 ] && [ "$sget_upgrade_mode" -eq 0 ]; then
+                    sget_action=reinstall
+                else
+                    continue
+                fi
+            elif [ "$sget_cmp" -gt 0 ]; then
+                sget_action=upgrade
+            else
+                sget_action=downgrade
+                if [ "$sget_upgrade_mode" -eq 1 ] && [ "$sget_allow_downgrade" -eq 0 ]; then
+                    printf "%s: keeping newer installed %s %s-%s (repository has %s-%s)\n" \
+                        "$sps_prog" "$sget_package" "$sget_installed_version" \
+                        "$sget_installed_release" "$sget_version" "$sget_release" >&2
+                    continue
+                fi
+            fi
+        fi
+        printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
+            "$sget_action" "$sget_package" "$sget_recipe" "$sget_recipe_hash" \
+            "$sget_version" "$sget_release" "$sget_arch" >>"$sget_work/todo"
+        sget_count=$((sget_count + 1))
+    done <"$sget_work/resolution"
+
+    sget_mark_count=$(awk 'END { print NR + 0 }' "$sget_work/mark-explicit")
+    if [ "$sget_plan" -eq 1 ]; then
+        if [ "$sget_upgrade_mode" -eq 1 ]; then
+            printf 'Upgrade plan\n\n'
+        else
+            printf 'Install plan\n\n'
+        fi
+        if [ "$sget_count" -eq 0 ] && [ "$sget_mark_count" -eq 0 ]; then
+            printf '%s\n' 'No changes are needed.'
+        else
+            while IFS="$sps_tab" read -r sget_action sget_package sget_recipe \
+                  sget_recipe_hash sget_version sget_release sget_arch || [ -n "$sget_action" ]
+            do
+                [ -n "$sget_action" ] && printf '  %-10s %s %s-%s\n' \
+                    "$sget_action" "$sget_package" "$sget_version" "$sget_release"
+            done <"$sget_work/todo"
+            while IFS= read -r sget_package || [ -n "$sget_package" ]; do
+                [ -n "$sget_package" ] && printf '  %-10s %s\n' explicit "$sget_package"
+            done <"$sget_work/mark-explicit"
+            printf '\n%d package builds, %d reason changes\n' "$sget_count" "$sget_mark_count"
+        fi
+        sget_cleanup_install
+        trap - 0 1 2 3 15
+        return 0
+    fi
+
+    if [ "$sget_count" -gt 0 ]; then
+        sps_require_cmd mkpkg
+        sps_require_cmd pkin
+        while IFS="$sps_tab" read -r sget_action sget_package sget_recipe \
+              sget_recipe_hash sget_version sget_release sget_arch || [ -n "$sget_action" ]
+        do
+            [ -n "$sget_action" ] || continue
+            [ -f "$sget_recipe" ] ||
+                sps_die "$SPS_EX_NOTFOUND" "recipe for '$sget_package' is missing: $sget_recipe"
+            sget_current_hash=$(sps_sha256 "$sget_recipe") ||
+                sps_die "$SPS_EX_CHECKSUM" "cannot hash recipe for '$sget_package'"
+            [ "$sget_current_hash" = "$sget_recipe_hash" ] ||
+                sps_die "$SPS_EX_CHECKSUM" "recipe for '$sget_package' changed since indexing; run 'src update'"
+            sget_recipe_dir=${sget_recipe%/*}
+            sget_recipe_name=${sget_recipe##*/}
+
+            printf '%s %s\n' "$sget_action" "$sget_package"
+            sget_cache_base=$SPS_CACHE/packages/$sget_package-$sget_version-$sget_release-$sget_arch.pkg.tar
+            sget_artifact=
+            for sget_candidate in "$sget_cache_base.zst" "$sget_cache_base.xz" \
+                                  "$sget_cache_base.gz" "$sget_cache_base"; do
+                if [ -f "$sget_candidate" ] && [ ! -L "$sget_candidate" ]; then
+                    sget_artifact=$sget_candidate
+                    break
+                fi
+            done
+            if [ -n "$sget_artifact" ]; then
+                sget_verify_artifact_identity "$sget_artifact" "$sget_package" \
+                    "$sget_version" "$sget_release" "$sget_arch" ||
+                    sps_die "$SPS_EX_PACKAGE" "cached artifact identity is invalid for '$sget_package': $sget_artifact"
+                printf 'using cached %s\n' "${sget_artifact##*/}"
+            else
+                sget_mkpkg_output=$(cd "$sget_recipe_dir" &&
+                                    mkpkg --output "$SPS_CACHE/packages" "./$sget_recipe_name")
+                sget_mkpkg_status=$?
+                [ "$sget_mkpkg_status" -eq 0 ] ||
+                    sps_die "$SPS_EX_BUILD" "build failed for '$sget_package'"
+                sget_artifact=$(printf '%s\n' "$sget_mkpkg_output" |
+                               awk 'NF { result = $0 } END { print result }')
+                [ -n "$sget_artifact" ] ||
+                    sps_die "$SPS_EX_BUILD" "mkpkg did not report an artifact for '$sget_package'"
+                case $sget_artifact in /*) ;; *) sget_artifact=$sget_recipe_dir/$sget_artifact ;; esac
+                [ -f "$sget_artifact" ] ||
+                    sps_die "$SPS_EX_BUILD" "mkpkg artifact for '$sget_package' is missing: $sget_artifact"
+                sget_verify_artifact_identity "$sget_artifact" "$sget_package" \
+                    "$sget_version" "$sget_release" "$sget_arch" ||
+                    sps_die "$SPS_EX_PACKAGE" "mkpkg produced the wrong package identity for '$sget_package'"
+            fi
+
+            case ,$sget_roots, in *,"$sget_package",*) sget_reason=explicit ;; *) sget_reason=dependency ;; esac
+            SPS_INSTALL_REASON=$sget_reason pkin "$sget_artifact" ||
+                sps_die $? "installation failed for '$sget_package'"
+        done <"$sget_work/todo"
+    fi
+
+    if [ "$sget_mark_count" -gt 0 ]; then
+        sps_require_cmd pkmark
+        # Package names cannot contain spaces, so splitting this list is safe.
+        set -- $(cat "$sget_work/mark-explicit")
+        [ "$#" -eq 0 ] || pkmark explicit "$@" || exit $?
+    fi
+
+    if [ "$sget_count" -eq 0 ] && [ "$sget_mark_count" -eq 0 ]; then
+        [ "$sget_upgrade_mode" -eq 1 ] && printf '%s\n' 'System packages are up to date.' ||
+            printf '%s\n' 'All requested packages are already installed.'
+    fi
+    sget_cleanup_install
+    trap - 0 1 2 3 15
+}
+
+sget_upgrade()
+{
+    sget_upgrade_plan=0
+    sget_upgrade_downgrade=0
+    while [ "$#" -gt 0 ]; do
+        case $1 in
+            --plan) sget_upgrade_plan=1 ;;
+            --downgrade) sget_upgrade_downgrade=1 ;;
+            *) sget_usage >&2; exit "$SPS_EX_USAGE" ;;
+        esac
+        shift
+    done
+    [ -f "$SPS_DB/world" ] && [ ! -L "$SPS_DB/world" ] ||
+        sps_die "$SPS_EX_DATABASE" "world database is missing or unsafe"
+    awk -v sps_action=world-validate -v sps_program=sget \
+        -f "$SPS_LIBDIR/db.awk" "$SPS_DB/world" ||
+        sps_die "$SPS_EX_DATABASE" "world database is malformed"
+    set -- $(cat "$SPS_DB/world")
+    if [ "$#" -eq 0 ]; then
+        printf '%s\n' 'No explicit packages are installed.'
+        return 0
+    fi
+    sget_upgrade_mode=1
+    if [ "$sget_upgrade_plan" -eq 1 ]; then set -- --plan "$@"; fi
+    if [ "$sget_upgrade_downgrade" -eq 1 ]; then set -- --downgrade "$@"; fi
+    sget_install "$@"
+}
+
+sget_remove()
+{
+    sget_remove_plan=0
+    sget_remove_nodeps=0
+    while [ "$#" -gt 0 ]; do
+        case $1 in
+            --plan) sget_remove_plan=1; shift ;;
+            --nodeps) sget_remove_nodeps=1; shift ;;
+            --) shift; break ;;
+            -*) sps_die "$SPS_EX_USAGE" "unknown option '$1'" ;;
+            *) break ;;
+        esac
+    done
+    [ "$#" -gt 0 ] || { sget_usage >&2; exit "$SPS_EX_USAGE"; }
+    [ -d "$SPS_DB/installed" ] && [ ! -L "$SPS_DB/installed" ] ||
+        sps_die "$SPS_EX_DATABASE" "installed database is missing or unsafe"
+
+    sget_remove_work=$(sps_mktemp_dir "${TMPDIR:-/tmp}" remove-plan)
+    trap 'status=$?; rm -rf "$sget_remove_work"; sps_lock_release; exit "$status"' 0
+    trap 'exit 130' 1 2 3 15
+    : >"$sget_remove_work/requested"
+    for sget_package
+    do
+        sps_validate_package_name "$sget_package" ||
+            sps_die "$SPS_EX_USAGE" "invalid package name '$sget_package'"
+        [ -d "$SPS_DB/installed/$sget_package" ] && [ ! -L "$SPS_DB/installed/$sget_package" ] ||
+            sps_die "$SPS_EX_NOTFOUND" "package '$sget_package' is not installed"
+        if grep -F -x -e "$sget_package" "$sget_remove_work/requested" >/dev/null 2>&1; then
+            sps_die "$SPS_EX_USAGE" "package '$sget_package' was requested more than once"
+        fi
+        printf '%s\n' "$sget_package" >>"$sget_remove_work/requested"
+    done
+
+    : >"$sget_remove_work/edges"
+    set -- "$SPS_DB"/installed/*/meta
+    if [ -f "$1" ]; then
+        awk -v out="$sget_remove_work/edges" '
+        FNR == 1 {
+            pkg=FILENAME; sub(/\/meta$/, "", pkg); sub(/^.*\//, "", pkg)
+        }
+        {
+            p=match($0, /[[:space:]]/); if (!p) next
+            key=substr($0,1,p-1); if (key != "depend") next
+            value=substr($0,p); sub(/^[[:space:]]+/,"",value)
+            gsub(/,/," ",value); n=split(value,a,/[[:space:]]+/)
+            for (i=1;i<=n;i++) {
+                d=a[i]; sub(/(>=|<=|=|>|<).*/,"",d)
+                if (d != "") print pkg "\t" d > out
+            }
+        }' "$@"
+    fi
+
+    if [ "$sget_remove_nodeps" -eq 0 ]; then
+        if ! awk -F '\t' '
+            NR==FNR { req[$1]=1; next }
+            !($1 in req) && ($2 in req) {
+                print $1 " requires " $2 > "/dev/stderr"; bad=1
+            }
+            END { exit bad }
+        ' "$sget_remove_work/requested" "$sget_remove_work/edges"; then
+            sps_die "$SPS_EX_DEPENDENCY" "removal would break installed dependencies (use --nodeps to override)"
+        fi
+    fi
+
+    # Remove requested dependents before their dependencies. Cycles fall back to
+    # lexical order.
+    awk -F '\t' '
+        NR==FNR { req[$1]=1; names[++n]=$1; next }
+        ($1 in req) && ($2 in req) { dep[$1 SUBSEP $2]=1 }
+        END {
+            left=n
+            while (left>0) {
+                pick=""
+                for (i=1;i<=n;i++) if (!(names[i] in done)) {
+                    p=names[i]; incoming=0
+                    for (j=1;j<=n;j++) if (!(names[j] in done) && dep[names[j] SUBSEP p]) incoming=1
+                    if (!incoming && (pick=="" || p<pick)) pick=p
+                }
+                if (pick=="") for (i=1;i<=n;i++) if (!(names[i] in done) && (pick=="" || names[i]<pick)) pick=names[i]
+                print pick; done[pick]=1; left--
+            }
+        }
+    ' "$sget_remove_work/requested" "$sget_remove_work/edges" >"$sget_remove_work/order"
+
+    if [ "$sget_remove_plan" -eq 1 ]; then
+        printf 'Remove plan\n\n'
+        while IFS= read -r sget_package || [ -n "$sget_package" ]; do
+            [ -n "$sget_package" ] && printf '  remove     %s\n' "$sget_package"
+        done <"$sget_remove_work/order"
+        printf '\n%s packages\n' "$(awk 'END { print NR + 0 }' "$sget_remove_work/order")"
+        rm -rf "$sget_remove_work"
+        sps_lock_release
+        trap - 0 1 2 3 15
+        return 0
+    fi
+
+    sps_require_cmd pkdel
+    while IFS= read -r sget_package || [ -n "$sget_package" ]; do
+        [ -n "$sget_package" ] || continue
+        pkdel "$sget_package" || exit $?
+    done <"$sget_remove_work/order"
+    rm -rf "$sget_remove_work"
+    sps_lock_release
+    trap - 0 1 2 3 15
+}
+
+# Keep a whole sget install/upgrade/remove from interleaving with another one.
+# Low-level tools still use their shorter database lock. Queries and plans do not.
+sget_top_command=${1-}
+case $sget_top_command in
+    install|upgrade|remove)
+        sget_top_plan=0
+        shift_index=0
+        for sget_top_arg in "$@"; do
+            [ "$shift_index" -eq 0 ] && { shift_index=1; continue; }
+            [ "$sget_top_arg" = --plan ] && sget_top_plan=1
+        done
+        if [ "$sget_top_plan" -eq 0 ]; then
+            sget_begin_transaction
+            trap 'status=$?; sps_lock_release; exit "$status"' 0
+            trap 'exit 130' 1 2 3 15
+        fi
+        ;;
+esac
+
+case ${1-} in
+    search|info)
+        sget_command=$1; shift; sget_query "$sget_command" "$@" ;;
+    depends)
+        shift; sget_depends "$@" ;;
+    dependees)
+        shift; sget_dependees "$@" ;;
+    why)
+        shift; sget_why "$@" ;;
+    install)
+        shift; sget_upgrade_mode=0; sget_install "$@" ;;
+    upgrade)
+        shift; sget_upgrade "$@" ;;
+    remove)
+        shift; sget_remove "$@" ;;
+    -h|--help)
+        sget_usage ;;
+    *)
+        sget_usage >&2; exit "$SPS_EX_USAGE" ;;
+esac
diff --git a/bin/src b/bin/src
new file mode 100755
index 0000000..3cac1aa
--- /dev/null
+++ b/bin/src
@@ -0,0 +1,277 @@
+#!/bin/sh
+
+sps_prog=src
+sps_bin_dir=$(CDPATH= cd -P "$(dirname "$0")" 2>/dev/null && pwd) ||
+    exit 1
+if [ -z "${SPS_LIBDIR+x}" ]; then
+    if [ -r "$sps_bin_dir/../lib/common.sh" ]; then
+        SPS_LIBDIR=$sps_bin_dir/../lib
+    else
+        SPS_LIBDIR=$sps_bin_dir/../lib/sps
+    fi
+fi
+if ! . "$SPS_LIBDIR/common.sh"; then
+    printf '%s\n' "src: cannot load $SPS_LIBDIR/common.sh" >&2
+    exit 1
+fi
+sps_load_config || exit $?
+
+sps_index_dir=$SPS_CACHE/indexes
+sps_index=$sps_index_dir/packages.index
+sps_tab=$(printf '\tX')
+sps_tab=${sps_tab%X}
+
+src_usage()
+{
+    printf '%s\n' 'usage: src update'
+    printf '%s\n' '       src search PATTERN [--raw]'
+    printf '%s\n' '       src show PACKAGE [--raw]'
+    printf '%s\n' '       src which PACKAGE'
+    printf '%s\n' '       src list [--raw]'
+    printf '%s\n' '       src status [--raw]'
+}
+
+src_need_index()
+{
+    [ -r "$sps_index" ] ||
+        sps_die "$SPS_EX_NOTFOUND" "repository index not found; run 'src update'"
+}
+
+src_cleanup_update()
+{
+    [ -n "${src_work:-}" ] || return
+    rm -f "$src_work/repos.raw" "$src_work/repos" \
+          "$src_work/recipes.unsorted" "$src_work/recipes" \
+          "$src_work/packages.index"
+    rmdir "$src_work" 2>/dev/null || :
+    src_work=
+}
+
+src_update()
+{
+    [ "$#" -eq 0 ] || {
+        src_usage >&2
+        exit "$SPS_EX_USAGE"
+    }
+
+    sps_mkdir "$sps_index_dir"
+    src_work=$(sps_mktemp_dir "$sps_index_dir" update)
+    trap src_cleanup_update 0
+    trap 'exit 129' 1
+    trap 'exit 130' 2
+    trap 'exit 131' 3
+    trap 'exit 143' 15
+
+    if ! sps_repo_lines > "$src_work/repos.raw"; then
+        sps_die "$SPS_EX_USAGE" "cannot read repository configuration"
+    fi
+    if ! awk -v action=normalize -v program=src \
+        -f "$SPS_LIBDIR/repository.awk" "$src_work/repos.raw" \
+        > "$src_work/repos"; then
+        sps_die "$SPS_EX_USAGE" "invalid repository configuration"
+    fi
+    : > "$src_work/packages.index" ||
+        sps_die "$SPS_EX_PERMISSION" "cannot create repository index"
+
+    src_default_arch=$SPS_ARCH
+    src_repo_count=0
+    src_package_count=0
+    src_failed=0
+
+    while IFS="$sps_tab" read -r src_repo_name src_repo_path \
+          src_repo_priority src_repo_order
+    do
+        [ -n "$src_repo_name" ] || continue
+        case $src_repo_path in
+            *://*|git@*:*)
+                printf "%s: repository '%s' uses an unsupported remote location: %s\n" \
+                    "$sps_prog" "$src_repo_name" "$src_repo_path" >&2
+                src_failed=1
+                break
+                ;;
+        esac
+        if [ ! -d "$src_repo_path" ]; then
+            printf "%s: repository '%s' is not a local directory: %s\n" \
+                "$sps_prog" "$src_repo_name" "$src_repo_path" >&2
+            src_failed=1
+            break
+        fi
+        src_repo_absolute=$(CDPATH= cd -P "$src_repo_path" 2>/dev/null &&
+                            pwd -P) || {
+            printf "%s: cannot enter repository '%s': %s\n" \
+                "$sps_prog" "$src_repo_name" "$src_repo_path" >&2
+            src_failed=1
+            break
+        }
+
+        if ! find "$src_repo_absolute" -type f -name recipe -print \
+            > "$src_work/recipes.unsorted"; then
+            printf "%s: cannot scan repository '%s': %s\n" \
+                "$sps_prog" "$src_repo_name" "$src_repo_absolute" >&2
+            src_failed=1
+            break
+        fi
+        if ! LC_ALL=C sort "$src_work/recipes.unsorted" \
+            > "$src_work/recipes"; then
+            printf "%s: cannot sort recipes for repository '%s'\n" \
+                "$sps_prog" "$src_repo_name" >&2
+            src_failed=1
+            break
+        fi
+
+        while IFS= read -r src_recipe || [ -n "$src_recipe" ]
+        do
+            [ -n "$src_recipe" ] || continue
+            if ! sps_tab_safe "$src_recipe"; then
+                printf "%s: recipe path contains a tab or newline: %s\n" \
+                    "$sps_prog" "$src_recipe" >&2
+                src_failed=1
+                break
+            fi
+            src_digest=$(sps_sha256 "$src_recipe") || {
+                printf "%s: cannot hash recipe: %s\n" \
+                    "$sps_prog" "$src_recipe" >&2
+                src_failed=1
+                break
+            }
+            case $src_digest in
+                ''|*[!0123456789abcdefABCDEF]*)
+                    printf "%s: invalid SHA-256 result for recipe: %s\n" \
+                        "$sps_prog" "$src_recipe" >&2
+                    src_failed=1
+                    break
+                    ;;
+            esac
+            if [ "${#src_digest}" -ne 64 ]; then
+                printf "%s: invalid SHA-256 result for recipe: %s\n" \
+                    "$sps_prog" "$src_recipe" >&2
+                src_failed=1
+                break
+            fi
+
+            if ! awk -v recipe_repo="$src_repo_name" \
+                -v recipe_priority="$src_repo_priority" \
+                -v recipe_path="$src_recipe" \
+                -v recipe_sha256="$src_digest" \
+                -v recipe_default_arch="$src_default_arch" \
+                -f "$SPS_LIBDIR/recipe.awk" "$src_recipe" \
+                >> "$src_work/packages.index"; then
+                src_failed=1
+                break
+            fi
+            src_package_count=$((src_package_count + 1))
+        done < "$src_work/recipes"
+
+        [ "$src_failed" -eq 0 ] || break
+        src_repo_count=$((src_repo_count + 1))
+    done < "$src_work/repos"
+
+    if [ "$src_failed" -ne 0 ]; then
+        sps_die "$SPS_EX_PACKAGE" \
+            "repository update failed; previous index was preserved"
+    fi
+    if ! awk -v action=validate -v program=src \
+        -f "$SPS_LIBDIR/index.awk" "$src_work/packages.index"; then
+        sps_die "$SPS_EX_PACKAGE" \
+            "generated repository index is malformed"
+    fi
+    if ! mv "$src_work/packages.index" "$sps_index"; then
+        sps_die "$SPS_EX_PERMISSION" "cannot commit repository index"
+    fi
+
+    printf 'updated %d repositories, %d package records\n' \
+        "$src_repo_count" "$src_package_count"
+    src_cleanup_update
+    trap - 0 1 2 3 15
+}
+
+src_repositories()
+{
+    src_repo_action=$1
+    shift
+    src_raw=0
+    case ${1-} in
+        --raw) src_raw=1; shift ;;
+    esac
+    [ "$#" -eq 0 ] || {
+        src_usage >&2
+        exit "$SPS_EX_USAGE"
+    }
+    if [ "$src_repo_action" = list ]; then
+        [ "$src_raw" -eq 0 ] || src_repo_action=raw
+    else
+        [ "$src_raw" -eq 0 ] || src_repo_action=status_raw
+    fi
+    sps_repo_lines |
+        awk -v action="$src_repo_action" -v program=src \
+            -v index_file="$sps_index" \
+            -f "$SPS_LIBDIR/repository.awk"
+}
+
+src_query()
+{
+    src_action=$1
+    shift
+    sps_need_arg "$src_action" "${1-}"
+    src_query_value=$1
+    shift
+    src_raw=0
+    case ${1-} in
+        --raw) src_raw=1; shift ;;
+    esac
+    [ "$#" -eq 0 ] || {
+        src_usage >&2
+        exit "$SPS_EX_USAGE"
+    }
+    src_need_index
+
+    case $src_action in
+        search)
+            sps_tab_safe "$src_query_value" ||
+                sps_die "$SPS_EX_USAGE" "search pattern contains a tab or newline"
+            [ "$src_raw" -eq 0 ] ||
+                src_action=search_raw
+            ;;
+        show)
+            sps_validate_package_name "$src_query_value" ||
+                sps_die "$SPS_EX_USAGE" \
+                    "invalid package name '$src_query_value'"
+            [ "$src_raw" -eq 0 ] ||
+                src_action=exact
+            ;;
+        which)
+            sps_validate_package_name "$src_query_value" ||
+                sps_die "$SPS_EX_USAGE" \
+                    "invalid package name '$src_query_value'"
+            [ "$src_raw" -eq 0 ] ||
+                sps_die "$SPS_EX_USAGE" "'src which' does not accept --raw"
+            ;;
+    esac
+
+    awk -v action="$src_action" -v query="$src_query_value" \
+        -v program=src -f "$SPS_LIBDIR/index.awk" "$sps_index"
+}
+
+case ${1-} in
+    update)
+        shift
+        src_update "$@"
+        ;;
+    search|show|which)
+        src_command=$1
+        shift
+        src_query "$src_command" "$@"
+        ;;
+    list|status)
+        src_command=$1
+        shift
+        src_repositories "$src_command" "$@"
+        ;;
+    -h|--help)
+        src_usage
+        ;;
+    *)
+        src_usage >&2
+        exit "$SPS_EX_USAGE"
+        ;;
+esac
diff --git a/examples/README b/examples/README
new file mode 100644
index 0000000..91365a4
--- /dev/null
+++ b/examples/README
@@ -0,0 +1,18 @@
+SPS recipe examples
+===================
+
+hello/ is a small offline package used by the test suite and as a starting
+point for new recipes.
+
+    cd examples/hello
+    mkpkg --no-download --compression none
+
+Local source paths are resolved from the recipe directory. Downloaded or copied
+sources are available below $SRC. Tar sources are unpacked below $WORK; when an
+archive contains one top-level directory, that directory becomes $WORK.
+
+The install phase writes into $PKG. It must not install directly into the live
+root.
+
+If files/ or patches/ exist beside the recipe, mkpkg copies them to $SRC/files
+and $SRC/patches.
diff --git a/examples/hello/hello.sh b/examples/hello/hello.sh
new file mode 100755
index 0000000..b0428ef
--- /dev/null
+++ b/examples/hello/hello.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+printf '%s\n' 'hello from SPS'
diff --git a/examples/hello/recipe b/examples/hello/recipe
new file mode 100644
index 0000000..2b67962
--- /dev/null
+++ b/examples/hello/recipe
@@ -0,0 +1,14 @@
+# Tiny offline package used by the examples and tests.
+name        hello-sps
+version     1.0
+release     1
+arch        any
+description Minimal hello-world package used by the SPS examples
+
+source      hello.sh
+hash        sha256:25576de85c3f0b4dc3e7847956daf896c450318a00bb1b818869accf8fdc7268
+
+install     mkdir -p \
+            "$PKG/usr/bin"
+install     cp "$SRC/hello.sh" "$PKG/usr/bin/hello-sps"
+install     chmod 755 "$PKG/usr/bin/hello-sps"
diff --git a/examples/repos.conf b/examples/repos.conf
new file mode 100644
index 0000000..b5e9260
--- /dev/null
+++ b/examples/repos.conf
@@ -0,0 +1,5 @@
+# Format: repo NAME PATH [PRIORITY]
+#
+# Example:
+# repo core /usr/src/core 100
+# repo local /usr/src/local 200
diff --git a/examples/sps.conf b/examples/sps.conf
new file mode 100644
index 0000000..24f1dde
--- /dev/null
+++ b/examples/sps.conf
@@ -0,0 +1,10 @@
+# Basic SPS configuration. Paths below are relative to SPS_ROOT unless an
+# environment variable overrides them.
+db /var/lib/sps
+cache /var/cache/sps
+build /var/tmp/sps
+makejobs 1
+compression auto
+preserve etc
+
+# Leave arch unset to use `uname -m`.
diff --git a/lib/common.awk b/lib/common.awk
new file mode 100644
index 0000000..3b7286b
--- /dev/null
+++ b/lib/common.awk
@@ -0,0 +1,37 @@
+# Common POSIX AWK helpers. Keep BEGIN/END out of this file so callers can load
+# it alongside the other modules in any order.
+
+function sps_ltrim(s) { sub(/^[[:space:]]+/, "", s); return s }
+function sps_rtrim(s) { sub(/[[:space:]]+$/, "", s); return s }
+function sps_trim(s)  { return sps_rtrim(sps_ltrim(s)) }
+
+function sps_error(message, status) {
+	if (status == "") status = 1
+	print (SPS_PROGRAM == "" ? "sps" : SPS_PROGRAM) ": " message > "/dev/stderr"
+	exit status
+}
+
+function sps_valid_name(name) {
+	return name ~ /^[A-Za-z0-9_+][A-Za-z0-9_+.-]*$/ && name !~ /\.\./
+}
+
+function sps_join(list, value, separator) {
+	if (separator == "") separator = ","
+	return list == "" ? value : list separator value
+}
+
+function sps_split_csv(value, output,    count, i, item, result) {
+	for (i in output) delete output[i]
+	if (value == "") return 0
+	count = split(value, result, /,/)
+	for (i = 1; i <= count; i++) {
+		item = sps_trim(result[i])
+		if (item != "") output[++output[0]] = item
+	}
+	return output[0]
+}
+
+function sps_tsv_safe(value) {
+	return value !~ /[\t\r\n]/
+}
+
diff --git a/lib/common.sh b/lib/common.sh
new file mode 100644
index 0000000..0206ac4
--- /dev/null
+++ b/lib/common.sh
@@ -0,0 +1,352 @@
+#!/bin/sh
+# Shared shell helpers used by the SPS commands.
+
+SPS_EX_USAGE=2
+SPS_EX_NOTFOUND=3
+SPS_EX_PACKAGE=4
+SPS_EX_DEPENDENCY=5
+SPS_EX_CHECKSUM=6
+SPS_EX_CONFLICT=7
+SPS_EX_PERMISSION=8
+SPS_EX_NETWORK=9
+SPS_EX_BUILD=10
+SPS_EX_DATABASE=11
+
+sps_basename()
+{
+	case ${1-} in
+		*/*) printf '%s\n' "${1##*/}" ;;
+		*) printf '%s\n' "${1-}" ;;
+	esac
+}
+
+sps_program=${sps_program:-${sps_prog:-$(sps_basename "$0")}}
+[ -z "${SPS_LIBDIR+x}" ] || export SPS_LIBDIR
+
+sps_die()
+{
+	case ${1-} in
+		''|*[!0-9]*) sps_status=1 ;;
+		*) sps_status=$1; shift ;;
+	esac
+	printf '%s: %s\n' "$sps_program" "$*" >&2
+	exit "$sps_status"
+}
+
+sps_warn()
+{
+	printf '%s: %s\n' "$sps_program" "$*" >&2
+}
+
+sps_usage()
+{
+	printf 'usage: %s\n' "$*" >&2
+	exit "$SPS_EX_USAGE"
+}
+
+sps_need_arg()
+{
+	[ "$#" -ge 2 ] && [ -n "${2-}" ] ||
+		sps_die "$SPS_EX_USAGE" "option '$1' requires an argument"
+}
+
+sps_mkdir()
+{
+	[ -d "$1" ] || mkdir -p "$1" ||
+		sps_die "$SPS_EX_PERMISSION" "cannot create directory: $1"
+}
+
+sps_root_path()
+{
+	sps_path=$1
+	case $sps_path in
+		/*)
+			if [ "${SPS_ROOT:-/}" = / ]; then
+				printf '%s\n' "$sps_path"
+			else
+				printf '%s%s\n' "${SPS_ROOT%/}" "$sps_path"
+			fi
+			;;
+		*) printf '%s/%s\n' "${SPS_ROOT:-/}" "$sps_path" ;;
+	esac
+}
+
+sps_mktemp_dir()
+{
+	sps_tmp_parent=$1
+	sps_tmp_tag=${2:-work}
+	sps_mkdir "$sps_tmp_parent"
+	sps_tmp=$(mktemp -d "$sps_tmp_parent/.sps-$sps_tmp_tag.XXXXXX") ||
+		sps_die "$SPS_EX_PERMISSION" \
+			"cannot create temporary directory in $sps_tmp_parent"
+	printf '%s\n' "$sps_tmp"
+}
+
+sps_require_cmd()
+{
+	command -v "$1" >/dev/null 2>&1 ||
+		sps_die "$SPS_EX_PACKAGE" "required command not found: $1"
+}
+
+# Print only the digest. Avoid an extra awk process here.
+sps_sha256()
+{
+	if command -v sha256sum >/dev/null 2>&1; then
+		sps_hash_line=$(sha256sum "$1") || return $?
+		printf '%s\n' "${sps_hash_line%% *}"
+	elif command -v sha256 >/dev/null 2>&1; then
+		sha256 -q "$1"
+	elif command -v shasum >/dev/null 2>&1; then
+		sps_hash_line=$(shasum -a 256 "$1") || return $?
+		printf '%s\n' "${sps_hash_line%% *}"
+	elif command -v openssl >/dev/null 2>&1; then
+		sps_hash_line=$(openssl dgst -sha256 "$1") || return $?
+		printf '%s\n' "${sps_hash_line##* }"
+	else
+		sps_die "$SPS_EX_CHECKSUM" \
+			"no SHA-256 utility found (tried sha256sum, sha256, shasum, openssl)"
+	fi
+}
+
+sps_validate_package_name()
+{
+	case ${1-} in
+		''|[!A-Za-z0-9]*|*/*|*[!A-Za-z0-9_+.-]*) return 1 ;;
+		*) return 0 ;;
+	esac
+}
+
+sps_tab_safe()
+{
+	case ${1-} in
+		*"	"*|*"
+"*) return 1 ;;
+		*) return 0 ;;
+	esac
+}
+
+# SPS_PRESERVE is a comma-separated list of package-relative prefixes. "etc"
+# covers etc and everything below it; "none" turns the check off.
+sps_is_preserved_path()
+{
+	sps_preserve_path=${1#/}
+	[ "${SPS_PRESERVE:-etc}" != none ] || return 1
+	sps_preserve_rest=${SPS_PRESERVE:-etc}
+	while :; do
+		case $sps_preserve_rest in
+			*,*) sps_preserve_prefix=${sps_preserve_rest%%,*}; sps_preserve_rest=${sps_preserve_rest#*,} ;;
+			*) sps_preserve_prefix=$sps_preserve_rest; sps_preserve_rest= ;;
+		esac
+		sps_preserve_prefix=${sps_preserve_prefix#/}
+		sps_preserve_prefix=${sps_preserve_prefix%/}
+		if [ -n "$sps_preserve_prefix" ]; then
+			case $sps_preserve_path in
+				"$sps_preserve_prefix"|"$sps_preserve_prefix"/*) return 0 ;;
+			esac
+		fi
+		[ -n "$sps_preserve_rest" ] || break
+	done
+	return 1
+}
+
+# Cache the uid check. pkin calls this for many payload entries.
+sps_is_root()
+{
+	if [ "${sps_uid_is_root+x}" != x ]; then
+		sps_uid=$(id -u 2>/dev/null || printf '%s\n' 1)
+		case $sps_uid in
+			0) sps_uid_is_root=1 ;;
+			*) sps_uid_is_root=0 ;;
+		esac
+	fi
+	[ "$sps_uid_is_root" -eq 1 ]
+}
+
+# Turn the POSIX ls permission string into an octal mode. Keep the parsing in
+# shell so mode lookup needs only the ls process.
+sps_mode_of()
+{
+	sps_mode_line=$(LC_ALL=C ls -ld "$1" 2>/dev/null) || return 1
+	sps_mode_text=${sps_mode_line%% *}
+	# Some ls implementations append an ACL/xattr marker; ignore it.
+	sps_mode_tail=${sps_mode_text#??????????}
+	sps_mode_text=${sps_mode_text%"$sps_mode_tail"}
+	case $sps_mode_text in ??????????) ;; *) return 1 ;; esac
+	sps_mode_bits=${sps_mode_text#?}
+	sps_mode_user=${sps_mode_bits%??????}
+	sps_mode_rest=${sps_mode_bits#???}
+	sps_mode_group=${sps_mode_rest%???}
+	sps_mode_other=${sps_mode_rest#???}
+
+	sps_mode_u=0; sps_mode_g=0; sps_mode_o=0; sps_mode_s=0
+	case $sps_mode_user in r??) sps_mode_u=$((sps_mode_u + 4)) ;; esac
+	case $sps_mode_user in ?w?) sps_mode_u=$((sps_mode_u + 2)) ;; esac
+	case $sps_mode_user in ??[xst]) sps_mode_u=$((sps_mode_u + 1)) ;; esac
+	case $sps_mode_group in r??) sps_mode_g=$((sps_mode_g + 4)) ;; esac
+	case $sps_mode_group in ?w?) sps_mode_g=$((sps_mode_g + 2)) ;; esac
+	case $sps_mode_group in ??[xst]) sps_mode_g=$((sps_mode_g + 1)) ;; esac
+	case $sps_mode_other in r??) sps_mode_o=$((sps_mode_o + 4)) ;; esac
+	case $sps_mode_other in ?w?) sps_mode_o=$((sps_mode_o + 2)) ;; esac
+	case $sps_mode_other in ??[xst]) sps_mode_o=$((sps_mode_o + 1)) ;; esac
+	case $sps_mode_user in ??[sS]) sps_mode_s=$((sps_mode_s + 4)) ;; esac
+	case $sps_mode_group in ??[sS]) sps_mode_s=$((sps_mode_s + 2)) ;; esac
+	case $sps_mode_other in ??[tT]) sps_mode_s=$((sps_mode_s + 1)) ;; esac
+	printf '%d%d%d%d\n' "$sps_mode_s" "$sps_mode_u" "$sps_mode_g" "$sps_mode_o"
+}
+
+# Root installs normalize new payload ownership to root:root. Do not copy the
+# builder's numeric uid/gid into the target system.
+sps_normalize_owner()
+{
+	sps_is_root || return 0
+	chown 0:0 "$1"
+}
+
+# mkdir gives us the lock atomically. Only remove a stale lock when its recorded
+# numeric PID can be checked safely.
+sps_lock_acquire()
+{
+	sps_lock_base=$1
+	sps_lock_name=${2:-.lock}
+	case $sps_lock_name in ''|*/*|.|..) return 1 ;; esac
+	sps_lock=$sps_lock_base/$sps_lock_name
+	sps_lock_owned=0
+	if mkdir "$sps_lock" 2>/dev/null; then
+		printf '%s\n' "$$" >"$sps_lock/pid" || {
+			rmdir "$sps_lock" 2>/dev/null || :
+			return 1
+		}
+		sps_lock_owned=1
+		return 0
+	fi
+
+	[ -d "$sps_lock" ] && [ ! -L "$sps_lock" ] || return 1
+	[ -f "$sps_lock/pid" ] && [ ! -L "$sps_lock/pid" ] || return 1
+	sps_lock_pid=$(sed -n '1p' "$sps_lock/pid" 2>/dev/null) || return 1
+	case $sps_lock_pid in ''|*[!0-9]*) return 1 ;; esac
+	if kill -0 "$sps_lock_pid" 2>/dev/null; then
+		return 1
+	fi
+
+	sps_warn "removing stale package database lock from pid $sps_lock_pid"
+	rm -f "$sps_lock/pid" 2>/dev/null || return 1
+	rmdir "$sps_lock" 2>/dev/null || return 1
+	if mkdir "$sps_lock" 2>/dev/null; then
+		printf '%s\n' "$$" >"$sps_lock/pid" || {
+			rmdir "$sps_lock" 2>/dev/null || :
+			return 1
+		}
+		sps_lock_owned=1
+		return 0
+	fi
+	return 1
+}
+
+sps_lock_release()
+{
+	[ "${sps_lock_owned:-0}" -eq 1 ] || return 0
+	rm -f "$sps_lock/pid" 2>/dev/null || :
+	rmdir "$sps_lock" 2>/dev/null || :
+	sps_lock_owned=0
+}
+
+# Print repo lines from the main config and repos.conf.
+sps_repo_lines()
+{
+	for sps_repo_file in "$SPS_CONFIG" "$SPS_REPOS_CONFIG"; do
+		[ -r "$sps_repo_file" ] || continue
+		awk '
+			/^[[:space:]]*#/ || /^[[:space:]]*$/ { next }
+			$1 == "repo" && NF >= 3 {
+				printf "repo %s %s", $2, $3
+				if (NF >= 4) printf " %s", $4
+				printf "\n"
+			}
+		' "$sps_repo_file"
+	done
+}
+
+sps_load_config()
+{
+	[ "${SPS_ROOT+x}" = x ] && sps_env_root=1 || sps_env_root=0
+	[ "${SPS_DB+x}" = x ] && sps_env_db=1 || sps_env_db=0
+	[ "${SPS_CACHE+x}" = x ] && sps_env_cache=1 || sps_env_cache=0
+	[ "${SPS_BUILD+x}" = x ] && sps_env_build=1 || sps_env_build=0
+	[ "${SPS_MAKEJOBS+x}" = x ] && sps_env_makejobs=1 || sps_env_makejobs=0
+	[ "${SPS_COMPRESSION+x}" = x ] && sps_env_compression=1 || sps_env_compression=0
+	[ "${SPS_ARCH+x}" = x ] && sps_env_arch=1 || sps_env_arch=0
+	[ "${SPS_PRESERVE+x}" = x ] && sps_env_preserve=1 || sps_env_preserve=0
+
+	SPS_ROOT=${SPS_ROOT:-/}
+	case $SPS_ROOT in
+		/*) ;;
+		*) sps_die "$SPS_EX_USAGE" "SPS_ROOT must be an absolute path: $SPS_ROOT" ;;
+	esac
+	[ "$SPS_ROOT" = / ] || SPS_ROOT=${SPS_ROOT%/}
+
+	if [ "${SPS_CONFIG+x}" != x ]; then
+		SPS_CONFIG=$(sps_root_path /etc/sps/sps.conf)
+	fi
+	if [ "${SPS_REPOS_CONFIG+x}" != x ]; then
+		SPS_REPOS_CONFIG=$(sps_root_path /etc/sps/repos.conf)
+	fi
+
+	sps_cfg_root=$SPS_ROOT
+	sps_cfg_db=/var/lib/sps
+	sps_cfg_cache=/var/cache/sps
+	sps_cfg_build=/var/tmp/sps
+	sps_cfg_makejobs=1
+	sps_cfg_compression=auto
+	sps_cfg_arch=$(uname -m 2>/dev/null || printf '%s' unknown)
+	sps_cfg_preserve=etc
+
+	if [ -r "$SPS_CONFIG" ]; then
+		while IFS=' 	' read -r sps_key sps_value sps_extra; do
+			case $sps_key in
+				''|'#'*) continue ;;
+				root) [ "$sps_env_root" -eq 1 ] || sps_cfg_root=$sps_value ;;
+				db) sps_cfg_db=$sps_value ;;
+				cache) sps_cfg_cache=$sps_value ;;
+				build) sps_cfg_build=$sps_value ;;
+				makejobs) sps_cfg_makejobs=$sps_value ;;
+				compression) sps_cfg_compression=$sps_value ;;
+				arch) sps_cfg_arch=$sps_value ;;
+				preserve) sps_cfg_preserve=$sps_value ;;
+				repo) : ;;
+				*) sps_warn "ignoring unknown configuration key '$sps_key' in $SPS_CONFIG" ;;
+			esac
+		done < "$SPS_CONFIG"
+	fi
+
+	if [ "$sps_env_root" -eq 0 ]; then
+		SPS_ROOT=$sps_cfg_root
+		case $SPS_ROOT in /*) ;; *) sps_die "$SPS_EX_USAGE" "configured root must be absolute" ;; esac
+		[ "$SPS_ROOT" = / ] || SPS_ROOT=${SPS_ROOT%/}
+	fi
+	[ "$sps_env_db" -eq 1 ] || SPS_DB=$(sps_root_path "$sps_cfg_db")
+	[ "$sps_env_cache" -eq 1 ] || SPS_CACHE=$(sps_root_path "$sps_cfg_cache")
+	[ "$sps_env_build" -eq 1 ] || SPS_BUILD=$(sps_root_path "$sps_cfg_build")
+	[ "$sps_env_makejobs" -eq 1 ] || SPS_MAKEJOBS=$sps_cfg_makejobs
+	[ "$sps_env_compression" -eq 1 ] || SPS_COMPRESSION=$sps_cfg_compression
+	[ "$sps_env_arch" -eq 1 ] || SPS_ARCH=$sps_cfg_arch
+	[ "$sps_env_preserve" -eq 1 ] || SPS_PRESERVE=$sps_cfg_preserve
+	[ "$SPS_COMPRESSION" = zst ] && SPS_COMPRESSION=zstd
+
+	case $SPS_MAKEJOBS in ''|*[!0-9]*|0) sps_die "$SPS_EX_USAGE" "makejobs must be a positive integer" ;; esac
+	case $SPS_COMPRESSION in
+		auto|none|gzip|xz|zstd) ;;
+		*) sps_die "$SPS_EX_USAGE" "unsupported compression '$SPS_COMPRESSION'" ;;
+	esac
+	case $SPS_ARCH in
+		''|*[!A-Za-z0-9._+-]*|[!A-Za-z0-9]*)
+			sps_die "$SPS_EX_USAGE" "invalid architecture '$SPS_ARCH'" ;;
+	esac
+	case $SPS_PRESERVE in
+		'') sps_die "$SPS_EX_USAGE" "preserve may not be empty" ;;
+		*" "*|*"	"*|*"
+"*) sps_die "$SPS_EX_USAGE" "preserve must be a comma-separated path-prefix list" ;;
+	esac
+
+	export SPS_ROOT SPS_DB SPS_CACHE SPS_BUILD SPS_CONFIG SPS_REPOS_CONFIG
+	export SPS_MAKEJOBS SPS_COMPRESSION SPS_ARCH SPS_PRESERVE
+}
diff --git a/lib/config.awk b/lib/config.awk
new file mode 100644
index 0000000..7f0918f
--- /dev/null
+++ b/lib/config.awk
@@ -0,0 +1,27 @@
+# Read SPS config into caller-provided arrays. Repo lines are kept separately
+# because there can be more than one.
+
+function config_read(path, values, repo_name, repo_path, repo_priority,
+		line, fields, n, key, rest, count) {
+	count = 0
+	while ((getline line < path) > 0) {
+		line = sps_trim(line)
+		if (line == "" || line ~ /^#/) continue
+		n = split(line, fields, /[[:space:]]+/)
+		key = fields[1]
+		if (key == "repo") {
+			if (n < 3 || n > 4) sps_error(path ": malformed repo declaration")
+			count++
+			repo_name[count] = fields[2]
+			repo_path[count] = fields[3]
+			repo_priority[count] = n == 4 ? fields[4] : 0
+			continue
+		}
+		rest = line
+		sub(/^[^[:space:]]+[[:space:]]+/, "", rest)
+		values[key] = rest
+	}
+	close(path)
+	return count
+}
+
diff --git a/lib/db.awk b/lib/db.awk
new file mode 100644
index 0000000..5e32a7d
--- /dev/null
+++ b/lib/db.awk
@@ -0,0 +1,71 @@
+# Checks and small transformations for the SPS text database.
+
+BEGIN {
+    FS = sprintf("%c", 9)
+    OFS = FS
+    sps_errors = 0
+    sps_stderr = "cat >&2"
+}
+
+function sps_diag(message) {
+    print (sps_program != "" ? sps_program ": " : "") message | sps_stderr
+    sps_errors++
+}
+
+function sps_valid_name(value) {
+    return value ~ /^[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789][ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+_.-]*$/
+}
+
+function sps_valid_path(path,    count, pieces, i) {
+    if (path == "" || path ~ /[[:cntrl:]]/ || path ~ /^\// ||
+        path ~ /^\.\// || path ~ /\/\// || path ~ /\/$/)
+        return 0
+    count = split(path, pieces, "/")
+    for (i = 1; i <= count; i++)
+        if (pieces[i] == "" || pieces[i] == "." || pieces[i] == "..")
+            return 0
+    return 1
+}
+
+sps_action == "owners-remove" || sps_action == "owners-validate" ||
+sps_action == "owners-query" {
+    if (NF != 2 || !sps_valid_path($1) || !sps_valid_name($2)) {
+        sps_diag("malformed owner record at line " FNR)
+        next
+    }
+    if (sps_owner_seen[$1]++) {
+        sps_diag("duplicate owner record for '" $1 "'")
+        next
+    }
+    if (sps_action == "owners-remove" && $2 != sps_package)
+        print $1, $2
+    else if (sps_action == "owners-query" && $1 == sps_path)
+        print $2
+    next
+}
+
+sps_action == "world-remove" || sps_action == "world-validate" {
+    if (NF != 1 || !sps_valid_name($1)) {
+        sps_diag("malformed world record at line " FNR)
+        next
+    }
+    if (sps_world_seen[$1]++) {
+        sps_diag("duplicate world record for '" $1 "'")
+        next
+    }
+    if (sps_action == "world-remove" && $1 != sps_package)
+        print $1
+    next
+}
+
+END {
+    if (sps_action != "" && sps_action != "owners-remove" &&
+             sps_action != "owners-validate" &&
+             sps_action != "owners-query" &&
+             sps_action != "world-remove" &&
+             sps_action != "world-validate")
+        sps_diag("unknown database action '" sps_action "'")
+    close(sps_stderr)
+    if (sps_errors)
+        exit 1
+}
diff --git a/lib/deps.awk b/lib/deps.awk
new file mode 100644
index 0000000..275c7a4
--- /dev/null
+++ b/lib/deps.awk
@@ -0,0 +1,222 @@
+# Dependency graph handling for selected repository records. Load version.awk
+# first when constraints need version comparison.
+
+function deps_trim(value) {
+    sub(/^[ \t]+/, "", value)
+    sub(/[ \t]+$/, "", value)
+    return value
+}
+
+function deps_stderr(message,    command) {
+    command = "cat >&2"
+    print message | command
+    close(command)
+}
+
+function deps_error(message) {
+    if (!deps_failed)
+        deps_stderr((program == "" ? "sget" : program) ": " message)
+    deps_failed = 1
+}
+
+function deps_dependency_name(specification, parent,    position, operator,
+                              name, wanted) {
+    specification = deps_trim(specification)
+    position = match(specification, />=|<=|=|>|</)
+    if (position) {
+        operator = substr(specification, RSTART, RLENGTH)
+        name = substr(specification, 1, RSTART - 1)
+        wanted = substr(specification, RSTART + RLENGTH)
+    } else {
+        operator = ""
+        name = specification
+        wanted = ""
+    }
+
+    if (name !~ /^[A-Za-z0-9][A-Za-z0-9+_.-]*$/ ||
+        (operator != "" &&
+         wanted !~ /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/)) {
+        deps_error("invalid dependency '" specification "' required by '" \
+                   parent "'")
+        return ""
+    }
+    if (!(name in deps_selected_priority)) {
+        deps_error("dependency '" specification "' required by '" parent \
+                   "' is unavailable")
+        return ""
+    }
+    if (operator != "" &&
+        !version_satisfies(deps_version[name], operator, wanted)) {
+        deps_error("dependency '" specification "' required by '" parent \
+                   "' is unavailable (selected " name "-" deps_version[name] \
+                   ")")
+        return ""
+    }
+    return name
+}
+
+function deps_list(package, include_package_build) {
+    deps_combined = deps_runtime[package]
+    if (include_package_build && deps_build[package] != "") {
+        if (deps_combined != "")
+            deps_combined = deps_combined ","
+        deps_combined = deps_combined deps_build[package]
+    }
+    return deps_combined
+}
+
+function deps_cycle(package,    first, i, message) {
+    first = 1
+    for (i = 1; i <= deps_stack_depth; i++)
+        if (deps_stack[i] == package) {
+            first = i
+            break
+        }
+    message = ""
+    for (i = first; i <= deps_stack_depth; i++)
+        message = message (message == "" ? "" : " -> ") deps_stack[i]
+    message = message " -> " package
+    deps_error("dependency cycle: " message)
+}
+
+function deps_visit(package, parent,    list, count, items, i, child) {
+    if (deps_failed)
+        return
+    if (!(package in deps_selected_priority)) {
+        if (parent == "")
+            deps_error("package '" package "' not found")
+        else
+            deps_error("dependency '" package "' required by '" parent \
+                       "' is unavailable")
+        return
+    }
+    if (deps_state[package] == 1) {
+        deps_cycle(package)
+        return
+    }
+    if (deps_state[package] == 2)
+        return
+
+    deps_state[package] = 1
+    deps_stack_depth++
+    deps_stack[deps_stack_depth] = package
+
+    list = deps_list(package, include_build + 0)
+    if (list != "") {
+        count = split(list, items, ",")
+        for (i = 1; i <= count; i++) {
+            child = deps_dependency_name(items[i], package)
+            if (child == "")
+                break
+            deps_visit(child, package)
+            if (deps_failed)
+                break
+        }
+    }
+
+    delete deps_stack[deps_stack_depth]
+    deps_stack_depth--
+    deps_state[package] = 2
+    if (!deps_failed) {
+        deps_order_count++
+        deps_order[deps_order_count] = package
+    }
+}
+
+function deps_tree(package, prefix,    list, count, items, i, child) {
+    if (deps_tree_seen[package]) {
+        print prefix package " (already shown)"
+        return
+    }
+    print prefix package
+    deps_tree_seen[package] = 1
+
+    list = deps_list(package, include_build + 0)
+    if (list == "")
+        return
+    count = split(list, items, ",")
+    for (i = 1; i <= count; i++) {
+        child = deps_dependency_name(items[i], package)
+        if (child != "")
+            deps_tree(child, prefix "  ")
+    }
+}
+
+BEGIN {
+    FS = OFS = "\t"
+}
+
+/^$/ {
+    next
+}
+
+{
+    if (NF != 12) {
+        deps_error(FILENAME ":" FNR \
+                   ": malformed index record (expected 12 fields)")
+        next
+    }
+    if ($1 !~ /^[A-Za-z0-9][A-Za-z0-9+_.-]*$/ ||
+        $2 !~ /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/ ||
+        $9 !~ /^-?[[:digit:]]+$/ || $10 == "" ||
+        length($12) != 64 || $12 !~ /^[[:xdigit:]]+$/) {
+        deps_error(FILENAME ":" FNR ": invalid index record for '" $1 "'")
+        next
+    }
+
+    if (!($1 in deps_selected_priority) ||
+        ($9 + 0) > deps_selected_priority[$1]) {
+        deps_selected_priority[$1] = $9 + 0
+        deps_version[$1] = $2
+        deps_release[$1] = $3
+        deps_arch[$1] = $4
+        deps_runtime[$1] = $5
+        deps_build[$1] = $6
+        deps_recipe[$1] = $10
+        deps_recipe_sha256[$1] = $12
+    }
+}
+
+END {
+    if (deps_failed)
+        exit 5
+    if (roots == "") {
+        deps_error("no package specified")
+        exit 2
+    }
+
+    deps_root_count = split(roots, deps_roots, ",")
+    for (deps_i = 1; deps_i <= deps_root_count; deps_i++) {
+        if (deps_roots[deps_i] !~ \
+            /^[A-Za-z0-9][A-Za-z0-9+_.-]*$/) {
+            deps_error("invalid package name '" deps_roots[deps_i] "'")
+            break
+        }
+        deps_visit(deps_roots[deps_i], "")
+        if (deps_failed)
+            break
+    }
+    if (deps_failed)
+        exit 5
+
+    if (action == "order" || action == "plan") {
+        for (deps_i = 1; deps_i <= deps_order_count; deps_i++) {
+            deps_package = deps_order[deps_i]
+            if (action == "plan")
+                print deps_package, deps_recipe[deps_package],
+                      deps_recipe_sha256[deps_package], deps_version[deps_package],
+                      deps_release[deps_package], deps_arch[deps_package]
+            else
+                print deps_package
+        }
+    } else if (action == "tree") {
+        for (deps_i = 1; deps_i <= deps_root_count; deps_i++)
+            deps_tree(deps_roots[deps_i], "")
+    } else {
+        deps_stderr((program == "" ? "sget" : program) \
+                    ": unknown dependency action '" action "'")
+        exit 2
+    }
+    if (deps_failed)
+        exit 5
+}
diff --git a/lib/index.awk b/lib/index.awk
new file mode 100644
index 0000000..36ecd92
--- /dev/null
+++ b/lib/index.awk
@@ -0,0 +1,184 @@
+# Queries over the aggregate package index. For equal priorities, the first
+# record wins; src writes records in config order and then recipe-path order.
+
+function index_stderr(message,    command) {
+    command = "cat >&2"
+    print message | command
+    close(command)
+}
+
+function index_error(message) {
+    index_stderr((program == "" ? "src" : program) ": " message)
+    index_failed = 1
+}
+
+function index_sort_names(values, count,    gap, i, j, saved) {
+    gap = int(count / 2)
+    while (gap > 0) {
+        for (i = gap + 1; i <= count; i++) {
+            saved = values[i]
+            j = i
+            while (j > gap && values[j - gap] > saved) {
+                values[j] = values[j - gap]
+                j -= gap
+            }
+            values[j] = saved
+        }
+        gap = int(gap / 2)
+    }
+}
+
+function index_selected_fields(name) {
+    return split(index_selected_line[name], index_output_fields, FS)
+}
+
+function index_print_show(name) {
+    index_selected_fields(name)
+    print "name: " index_output_fields[1]
+    print "version: " index_output_fields[2]
+    print "release: " index_output_fields[3]
+    print "architecture: " index_output_fields[4]
+    print "repository: " index_output_fields[8]
+    print "priority: " index_output_fields[9]
+    print "recipe: " index_output_fields[10]
+    print "description: " index_output_fields[11]
+    print "dependencies: " (index_output_fields[5] == "" \
+                            ? "-" : index_output_fields[5])
+    print "build dependencies: " (index_output_fields[6] == "" \
+                                  ? "-" : index_output_fields[6])
+    print "optional dependencies: " (index_output_fields[7] == "" \
+                                     ? "-" : index_output_fields[7])
+    print "recipe sha256: " index_output_fields[12]
+}
+
+BEGIN {
+    FS = OFS = "\t"
+}
+
+/^$/ {
+    next
+}
+
+{
+    if (NF != 12) {
+        index_error(FILENAME ":" FNR ": malformed index record (expected 12 fields)")
+        next
+    }
+    if ($1 !~ /^[A-Za-z0-9][A-Za-z0-9+_.-]*$/ ||
+        $2 !~ /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/ ||
+        $3 !~ /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/ ||
+        $4 !~ /^[A-Za-z0-9][A-Za-z0-9._+-]*$/ ||
+        $8 !~ /^[[:alnum:]][[:alnum:].+_-]*$/ ||
+        $9 !~ /^-?[[:digit:]]+$/ || $10 == "" ||
+        length($12) != 64 || $12 !~ /^[[:xdigit:]]+$/) {
+        index_error(FILENAME ":" FNR ": invalid index record for '" $1 "'")
+        next
+    }
+
+    index_records++
+    index_record_line[index_records] = $0
+    index_record_name[index_records] = $1
+
+    if (!($1 in index_selected_line)) {
+        index_name_count++
+        index_names[index_name_count] = $1
+        index_selected_line[$1] = $0
+        index_selected_priority[$1] = $9 + 0
+        index_selected_record[$1] = index_records
+    } else if (($9 + 0) > index_selected_priority[$1]) {
+        index_selected_line[$1] = $0
+        index_selected_priority[$1] = $9 + 0
+        index_selected_record[$1] = index_records
+    }
+}
+
+END {
+    if (index_failed)
+        exit 2
+
+    index_sort_names(index_names, index_name_count)
+
+    if (action == "validate") {
+        exit 0
+    } else if (action == "all" || action == "search" ||
+               action == "search_raw") {
+        index_needle = tolower(query)
+        for (index_i = 1; index_i <= index_name_count; index_i++) {
+            index_name = index_names[index_i]
+            index_selected_fields(index_name)
+            index_haystack = tolower(index_output_fields[1] "\t" \
+                                     index_output_fields[11])
+            if (action != "all" &&
+                index(index_haystack, index_needle) == 0)
+                continue
+            if (action == "all" || action == "search_raw")
+                print index_selected_line[index_name]
+            else
+                printf "%s\t%s-%s\t%s\t%s\n",
+                       index_output_fields[1], index_output_fields[2],
+                       index_output_fields[3], index_output_fields[8],
+                       index_output_fields[11]
+        }
+    } else if (action == "show" || action == "exact" ||
+               action == "field" || action == "install_record") {
+        if (!(query in index_selected_line)) {
+            index_stderr((program == "" ? "src" : program) \
+                         ": package '" query "' not found")
+            exit 3
+        }
+        if (action == "show")
+            index_print_show(query)
+        else if (action == "exact")
+            print index_selected_line[query]
+        else if (action == "install_record") {
+            index_selected_fields(query)
+            print index_output_fields[1], index_output_fields[10],
+                  index_output_fields[12], index_output_fields[2],
+                  index_output_fields[3], index_output_fields[4]
+        } else {
+            if (field !~ /^[[:digit:]]+$/ || field < 1 || field > 12) {
+                index_stderr((program == "" ? "src" : program) \
+                             ": invalid index field '" field "'")
+                exit 2
+            }
+            index_selected_fields(query)
+            print index_output_fields[field]
+        }
+    } else if (action == "which") {
+        if (!(query in index_selected_line)) {
+            index_stderr((program == "" ? "src" : program) \
+                         ": package '" query "' not found")
+            exit 3
+        }
+
+        index_selected_fields(query)
+        print "selected: " index_output_fields[8] " " index_output_fields[10]
+        print "version: " index_output_fields[2] "-" index_output_fields[3]
+        print "priority: " index_output_fields[9]
+
+        index_alternatives = 0
+        for (index_i = 1; index_i <= index_records; index_i++)
+            if (index_record_name[index_i] == query &&
+                index_i != index_selected_record[query])
+                index_alternatives++
+
+        if (index_alternatives) {
+            print ""
+            print "also available:"
+            for (index_i = 1; index_i <= index_records; index_i++) {
+                if (index_record_name[index_i] != query ||
+                    index_i == index_selected_record[query])
+                    continue
+                split(index_record_line[index_i], index_output_fields, FS)
+                printf "%s %s %s-%s priority %s\n",
+                       index_output_fields[8], index_output_fields[10],
+                       index_output_fields[2], index_output_fields[3],
+                       index_output_fields[9]
+            }
+        }
+    } else {
+        index_stderr((program == "" ? "src" : program) \
+                     ": unknown index action '" action "'")
+        exit 2
+    }
+}
diff --git a/lib/package.awk b/lib/package.awk
new file mode 100644
index 0000000..f2fa1bd
--- /dev/null
+++ b/lib/package.awk
@@ -0,0 +1,179 @@
+# Package metadata validation. This module is run with -v sps_action=ACTION and
+# returns non-zero for malformed input.
+
+BEGIN {
+    sps_errors = 0
+    sps_stderr = "cat >&2"
+    sps_tab = sprintf("%c", 9)
+}
+
+function sps_diag(message) {
+    print (sps_program != "" ? sps_program ": " : "") message | sps_stderr
+    sps_errors++
+}
+
+function sps_valid_name(value) {
+    return value ~ /^[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789][ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+_.-]*$/
+}
+
+function sps_valid_path(path, directories,    value, count, part, pieces, i) {
+    value = path
+    if (value == "" || value ~ /[[:cntrl:]]/ || value ~ /^\// ||
+        value ~ /^\.\// || value ~ /\/\//)
+        return 0
+
+    if (directories && value ~ /\/$/)
+        sub(/\/$/, "", value)
+    if (value == "" || value ~ /\/$/)
+        return 0
+
+    count = split(value, pieces, "/")
+    for (i = 1; i <= count; i++) {
+        part = pieces[i]
+        if (part == "" || part == "." || part == "..")
+            return 0
+    }
+    return 1
+}
+
+function sps_meta_line(line,    pos, key, value, controls) {
+    if (line ~ /^[[:space:]]*$/)
+        return 1
+    controls = line
+    if (line ~ /^[[:space:]]/ || line ~ /[\r\n]/)
+        return 0
+
+    pos = match(line, /[[:space:]]/)
+    if (!pos)
+        return 0
+    key = substr(line, 1, pos - 1)
+    value = substr(line, pos)
+    sub(/^[[:space:]]+/, "", value)
+    sub(/[[:space:]]+$/, "", value)
+    if (key !~ /^[abcdefghijklmnopqrstuvwxyz][abcdefghijklmnopqrstuvwxyz0123456789_-]*$/ ||
+        value == "" || value ~ /[[:cntrl:]]/)
+        return 0
+
+    sps_meta_count[key]++
+    if (!(key in sps_meta_first))
+        sps_meta_first[key] = value
+    if ((key == "format" || key == "name" || key == "version" ||
+         key == "release" || key == "arch") && sps_meta_count[key] > 1) {
+        sps_diag("duplicate metadata key '" key "'")
+        return 1
+    }
+    if (key == "format" && value != "1")
+        sps_diag("unsupported package format '" value "'")
+    if (key == "name" && !sps_valid_name(value))
+        sps_diag("invalid package name '" value "'")
+    if ((key == "version" || key == "release") &&
+        value !~ /^[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789][ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._+~-]*$/)
+        sps_diag("invalid " key " value '" value "'")
+    if (key == "arch" &&
+        value !~ /^[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789][ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._+-]*$/)
+        sps_diag("invalid arch value '" value "'")
+    return 1
+}
+
+sps_action == "validate-meta" || sps_action == "meta-get" || sps_action == "meta-id" {
+    if (!sps_meta_line($0))
+        sps_diag("malformed metadata at line " FNR)
+    next
+}
+
+sps_action == "validate-paths" {
+    if (!sps_valid_path($0, 1)) {
+        sps_diag("unsafe manifest path at line " FNR ": " $0)
+        next
+    }
+    sps_path = $0
+    sub(/\/$/, "", sps_path)
+    if (sps_path == ".SPS" || sps_path ~ /^\.SPS\//) {
+        sps_diag("reserved manifest path at line " FNR ": " $0)
+        next
+    }
+    if (sps_seen_path[sps_path]++)
+        sps_diag("duplicate manifest path '" sps_path "'")
+    next
+}
+
+sps_action == "validate-hashes" || sps_action == "hash-paths" {
+    if (split($0, sps_hash_field, sps_tab) != 3 ||
+        sps_hash_field[1] != "sha256" ||
+        sps_hash_field[2] !~ /^[0123456789abcdef]+$/ ||
+        length(sps_hash_field[2]) != 64 ||
+        !sps_valid_path(sps_hash_field[3], 0)) {
+        sps_diag("malformed hash record at line " FNR)
+        next
+    }
+    if (sps_hash_seen[sps_hash_field[3]]++)
+        sps_diag("duplicate hash path '" sps_hash_field[3] "'")
+    if (sps_action == "hash-paths")
+        print sps_hash_field[3]
+    next
+}
+
+sps_action == "normalize-members" {
+    sps_member = $0
+    while (sps_member ~ /^\.\//)
+        sub(/^\.\//, "", sps_member)
+    if (sps_member == "" || sps_member == ".")
+        next
+    if (!sps_valid_path(sps_member, 1)) {
+        sps_diag("unsafe archive member at line " FNR ": " $0)
+        next
+    }
+    sps_member_key = sps_member
+    sub(/\/$/, "", sps_member_key)
+    if (sps_seen_member[sps_member_key]++) {
+        sps_diag("duplicate archive member '" sps_member_key "'")
+        next
+    }
+    if (sps_member_key ~ /^\.SPS\// &&
+        sps_member_key != ".SPS/meta" &&
+        sps_member_key != ".SPS/files" &&
+        sps_member_key != ".SPS/hashes" &&
+        sps_member_key != ".SPS/hooks" &&
+        sps_member_key != ".SPS/hooks/pre-install" &&
+        sps_member_key != ".SPS/hooks/post-install" &&
+        sps_member_key != ".SPS/hooks/pre-remove" &&
+        sps_member_key != ".SPS/hooks/post-remove") {
+        sps_diag("unsupported package control member '" sps_member_key "'")
+        next
+    }
+    print sps_member
+    next
+}
+
+END {
+    if (sps_action == "validate-meta" || sps_action == "meta-get" ||
+        sps_action == "meta-id") {
+        sps_required[1] = "format"
+        sps_required[2] = "name"
+        sps_required[3] = "version"
+        sps_required[4] = "release"
+        sps_required[5] = "arch"
+        for (sps_i = 1; sps_i <= 5; sps_i++) {
+            sps_key = sps_required[sps_i]
+            if (sps_meta_count[sps_key] != 1)
+                sps_diag("missing required metadata key '" sps_key "'")
+        }
+        if (!sps_errors && sps_action == "meta-get") {
+            if (!(sps_key_name in sps_meta_first))
+                sps_diag("metadata key '" sps_key_name "' is not present")
+            else
+                print sps_meta_first[sps_key_name]
+        }
+        if (!sps_errors && sps_action == "meta-id")
+            print sps_meta_first["name"] "\t" sps_meta_first["version"] "\t" \
+                  sps_meta_first["release"] "\t" sps_meta_first["arch"]
+    }
+    if (sps_action != "" && sps_action != "validate-meta" &&
+        sps_action != "meta-get" && sps_action != "meta-id" &&
+        sps_action != "validate-paths" && sps_action != "validate-hashes" &&
+        sps_action != "hash-paths" && sps_action != "normalize-members")
+        sps_diag("unknown package action '" sps_action "'")
+    close(sps_stderr)
+    if (sps_errors)
+        exit 1
+}
diff --git a/lib/recipe.awk b/lib/recipe.awk
new file mode 100644
index 0000000..75c2b79
--- /dev/null
+++ b/lib/recipe.awk
@@ -0,0 +1,215 @@
+# Parse one recipe into a repository index record. Build command fields are
+# recognized here, but never executed.
+
+function recipe_trim(value) {
+    sub(/^[ \t]+/, "", value)
+    sub(/[ \t]+$/, "", value)
+    return value
+}
+
+function recipe_stderr(message,    command) {
+    command = "cat >&2"
+    print message | command
+    close(command)
+}
+
+function recipe_error(message) {
+    recipe_stderr("recipe: " FILENAME ":" FNR ": " message)
+    recipe_failed = 1
+}
+
+function recipe_set_once(key, value) {
+    if (recipe_seen[key]) {
+        recipe_error("duplicate '" key "' field")
+        return
+    }
+    recipe_seen[key] = 1
+    recipe_value[key] = value
+}
+
+function recipe_append_dependency(key, value,    count, fields, i, item) {
+    value = recipe_trim(value)
+    gsub(/[ \t,]+/, " ", value)
+    count = split(value, fields, /[ ]+/)
+    for (i = 1; i <= count; i++) {
+        item = recipe_trim(fields[i])
+        if (item == "")
+            continue
+        if (recipe_value[key] != "")
+            recipe_value[key] = recipe_value[key] ","
+        recipe_value[key] = recipe_value[key] item
+    }
+}
+
+function recipe_replace_all(value, needle, replacement,    result, position) {
+    result = ""
+    while ((position = index(value, needle)) != 0) {
+        result = result substr(value, 1, position - 1) replacement
+        value = substr(value, position + length(needle))
+    }
+    return result value
+}
+
+function recipe_expand(value,    pass, previous) {
+    for (pass = 1; pass <= 8; pass++) {
+        previous = value
+        value = recipe_replace_all(value, "${name}", recipe_value["name"])
+        value = recipe_replace_all(value, "${version}", recipe_value["version"])
+        value = recipe_replace_all(value, "${release}", recipe_value["release"])
+        value = recipe_replace_all(value, "${arch}", recipe_value["arch"])
+        if (value == previous)
+            break
+    }
+    return value
+}
+
+function recipe_safe_field(label, value) {
+    if (value ~ /[[:cntrl:]]/) {
+        recipe_error("'" label "' contains a control character")
+        return 0
+    }
+    return 1
+}
+
+function recipe_valid_dependency_list(label, value,    count, items, i, item,
+                                      position, name, wanted) {
+    if (value == "")
+        return 1
+    count = split(value, items, ",")
+    for (i = 1; i <= count; i++) {
+        item = items[i]
+        position = match(item, />=|<=|=|>|</)
+        if (position) {
+            name = substr(item, 1, RSTART - 1)
+            wanted = substr(item, RSTART + RLENGTH)
+        } else {
+            name = item
+            wanted = ""
+        }
+        if (name !~ /^[A-Za-z0-9][A-Za-z0-9+_.-]*$/ ||
+            (position && wanted !~ /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/)) {
+            recipe_error("invalid " label " entry '" item "'")
+            return 0
+        }
+    }
+    return 1
+}
+
+function recipe_process(line,    key, value, separator) {
+    line = recipe_trim(line)
+    if (line == "" || line ~ /^#/)
+        return
+
+    separator = match(line, /[ \t]/)
+    if (!separator) {
+        recipe_error("expected 'key value' record")
+        return
+    }
+    key = substr(line, 1, RSTART - 1)
+    value = recipe_trim(substr(line, RSTART + RLENGTH))
+    if (value == "") {
+        recipe_error("empty value for '" key "'")
+        return
+    }
+
+    if (key == "name" || key == "version" || key == "release" ||
+        key == "arch" || key == "description") {
+        recipe_set_once(key, value)
+    } else if (key == "depend" || key == "builddep" || key == "optional") {
+        recipe_append_dependency(key, value)
+    } else if (key == "source" || key == "hash" || key == "prepare" ||
+               key == "configure" || key == "build" || key == "install") {
+        # mkpkg uses these later; the indexer only records that the fields are valid.
+    } else {
+        recipe_error("unknown field '" key "'")
+    }
+}
+
+{
+    recipe_line = $0
+    sub(/\r$/, "", recipe_line)
+    if (recipe_continuing)
+        recipe_logical = recipe_logical recipe_line
+    else
+        recipe_logical = recipe_line
+
+    if (recipe_logical ~ /\\[ \t]*$/) {
+        sub(/\\[ \t]*$/, "", recipe_logical)
+        recipe_logical = recipe_logical " "
+        recipe_continuing = 1
+        next
+    }
+
+    recipe_continuing = 0
+    recipe_process(recipe_logical)
+    recipe_logical = ""
+}
+
+END {
+    if (recipe_continuing)
+        recipe_error("unterminated line continuation")
+
+    if (!recipe_seen["name"])
+        recipe_error("missing required 'name' field")
+    if (!recipe_seen["version"])
+        recipe_error("missing required 'version' field")
+    if (!recipe_seen["release"])
+        recipe_error("missing required 'release' field")
+    if (!recipe_seen["arch"]) {
+        if (recipe_default_arch == "") {
+            "uname -m" | getline recipe_default_arch
+            close("uname -m")
+        }
+        recipe_value["arch"] = recipe_default_arch
+    }
+
+    recipe_value["name"] = recipe_expand(recipe_value["name"])
+    recipe_value["version"] = recipe_expand(recipe_value["version"])
+    recipe_value["release"] = recipe_expand(recipe_value["release"])
+    recipe_value["arch"] = recipe_expand(recipe_value["arch"])
+    recipe_value["description"] = recipe_expand(recipe_value["description"])
+    recipe_value["depend"] = recipe_expand(recipe_value["depend"])
+    recipe_value["builddep"] = recipe_expand(recipe_value["builddep"])
+    recipe_value["optional"] = recipe_expand(recipe_value["optional"])
+
+    if (recipe_value["name"] !~ /^[A-Za-z0-9][A-Za-z0-9+_.-]*$/)
+        recipe_error("invalid package name '" recipe_value["name"] "'")
+    if (recipe_value["version"] !~ \
+        /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/)
+        recipe_error("invalid version '" recipe_value["version"] "'")
+    if (recipe_value["release"] !~ \
+        /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/)
+        recipe_error("invalid release '" recipe_value["release"] "'")
+    if (recipe_value["arch"] !~ /^[A-Za-z0-9][A-Za-z0-9._+-]*$/)
+        recipe_error("invalid architecture '" recipe_value["arch"] "'")
+
+    recipe_safe_field("name", recipe_value["name"])
+    recipe_safe_field("version", recipe_value["version"])
+    recipe_safe_field("release", recipe_value["release"])
+    recipe_safe_field("arch", recipe_value["arch"])
+    recipe_safe_field("description", recipe_value["description"])
+    recipe_safe_field("repository", recipe_repo)
+    recipe_safe_field("recipe path", recipe_path)
+    recipe_valid_dependency_list("dependency", recipe_value["depend"])
+    recipe_valid_dependency_list("build dependency", recipe_value["builddep"])
+    recipe_valid_dependency_list("optional dependency", recipe_value["optional"])
+
+    if (recipe_repo !~ /^[[:alnum:]][[:alnum:].+_-]*$/)
+        recipe_error("invalid repository name '" recipe_repo "'")
+    if (recipe_priority !~ /^-?[[:digit:]]+$/)
+        recipe_error("invalid repository priority '" recipe_priority "'")
+    if (recipe_path == "")
+        recipe_error("recipe path is empty")
+    if (length(recipe_sha256) != 64 || recipe_sha256 !~ /^[[:xdigit:]]+$/)
+        recipe_error("invalid recipe SHA-256")
+
+    if (recipe_failed)
+        exit 2
+
+    OFS = "\t"
+    print recipe_value["name"], recipe_value["version"],
+          recipe_value["release"], recipe_value["arch"],
+          recipe_value["depend"], recipe_value["builddep"],
+          recipe_value["optional"], recipe_repo, recipe_priority,
+          recipe_path, recipe_value["description"], tolower(recipe_sha256)
+}
diff --git a/lib/relations.awk b/lib/relations.awk
new file mode 100644
index 0000000..b2f91b0
--- /dev/null
+++ b/lib/relations.awk
@@ -0,0 +1,95 @@
+# Reverse dependency and path queries. These only need package names, not
+# version constraint evaluation.
+
+function rel_err(msg,    c) {
+    c = "cat >&2"
+    print (program == "" ? "sget" : program) ": " msg | c
+    close(c)
+    failed = 1
+}
+
+function rel_dep_name(spec,    p) {
+    gsub(/^[ \t]+|[ \t]+$/, "", spec)
+    p = match(spec, />=|<=|=|>|</)
+    if (p) spec = substr(spec, 1, RSTART - 1)
+    return spec
+}
+
+function rel_add_edges(pkg, list,    n, a, i, d) {
+    if (list == "") return
+    n = split(list, a, /,/)
+    for (i = 1; i <= n; i++) {
+        d = rel_dep_name(a[i])
+        if (d != "") edge[pkg SUBSEP d] = 1
+    }
+}
+
+function rel_sort(a, n,    gap,i,j,x) {
+    gap=int(n/2)
+    while (gap>0) {
+        for (i=gap+1;i<=n;i++) {
+            x=a[i]; j=i
+            while (j>gap && a[j-gap]>x) { a[j]=a[j-gap]; j-=gap }
+            a[j]=x
+        }
+        gap=int(gap/2)
+    }
+}
+
+function rel_find(cur, target,    k,p,d) {
+    if (cur == target) return 1
+    seen[cur]=1
+    for (k in edge) {
+        split(k,p,SUBSEP)
+        if (p[1] != cur) continue
+        d=p[2]
+        if (seen[d]) continue
+        parent[d]=cur
+        if (rel_find(d,target)) return 1
+    }
+    return 0
+}
+
+BEGIN { FS=OFS="\t" }
+/^$/ { next }
+{
+    if (NF != 12) { rel_err(FILENAME ":" FNR ": malformed index record"); next }
+    name=$1; prio=$9+0
+    if (!(name in selected) || prio > priority[name]) {
+        selected[name]=1
+        priority[name]=prio
+        runtime[name]=$5
+        build[name]=$6
+    }
+}
+END {
+    if (failed) exit 2
+    for (pkg in selected) {
+        rel_add_edges(pkg, runtime[pkg])
+        if (include_build+0) rel_add_edges(pkg, build[pkg])
+    }
+
+    if (action == "dependees") {
+        if (!(query in selected)) { rel_err("package '" query "' not found"); exit 3 }
+        n=0
+        for (k in edge) {
+            split(k,p,SUBSEP)
+            if (p[2] == query && !out[p[1]]++) names[++n]=p[1]
+        }
+        rel_sort(names,n)
+        for (i=1;i<=n;i++) print names[i]
+    } else if (action == "why") {
+        if (!(root in selected)) { rel_err("package '" root "' not found"); exit 3 }
+        if (!(query in selected)) { rel_err("package '" query "' not found"); exit 3 }
+        if (!rel_find(root,query)) {
+            rel_err("'" root "' does not depend on '" query "'")
+            exit 5
+        }
+        n=0; cur=query; path[++n]=cur
+        while (cur != root) { cur=parent[cur]; path[++n]=cur }
+        for (i=n;i>=1;i--) print path[i]
+    } else {
+        rel_err("unknown relation action '" action "'")
+        exit 2
+    }
+}
diff --git a/lib/repository.awk b/lib/repository.awk
new file mode 100644
index 0000000..f898766
--- /dev/null
+++ b/lib/repository.awk
@@ -0,0 +1,130 @@
+# Parse and print repository configuration. Input is: repo NAME PATH [PRIORITY]
+
+function repository_trim(value) {
+    sub(/^[ \t]+/, "", value)
+    sub(/[ \t]+$/, "", value)
+    return value
+}
+
+function repository_stderr(message,    command) {
+    command = "cat >&2"
+    print message | command
+    close(command)
+}
+
+function repository_error(message) {
+    repository_stderr((program == "" ? "src" : program) ": " message)
+    repository_failed = 1
+}
+
+{
+    repository_line = $0
+    sub(/\r$/, "", repository_line)
+    sub(/[ \t]*#.*/, "", repository_line)
+    repository_line = repository_trim(repository_line)
+    if (repository_line == "")
+        next
+
+    repository_count_fields = split(repository_line, repository_fields,
+                                    /[ \t]+/)
+    if (repository_count_fields < 3 || repository_count_fields > 4 ||
+        repository_fields[1] != "repo") {
+        repository_error(FILENAME ":" FNR \
+                         ": expected 'repo NAME PATH [PRIORITY]'")
+        next
+    }
+
+    repository_name = repository_fields[2]
+    repository_path = repository_fields[3]
+    repository_priority = repository_count_fields == 4 \
+                          ? repository_fields[4] : 0
+
+    if (repository_name !~ /^[[:alnum:]][[:alnum:].+_-]*$/) {
+        repository_error(FILENAME ":" FNR ": invalid repository name '" \
+                         repository_name "'")
+        next
+    }
+    if (repository_path == "" || repository_path ~ /[[:cntrl:]]/) {
+        repository_error(FILENAME ":" FNR ": invalid path for repository '" \
+                         repository_name "'")
+        next
+    }
+    if (repository_priority !~ /^-?[[:digit:]]+$/) {
+        repository_error(FILENAME ":" FNR ": invalid priority '" \
+                         repository_priority "'")
+        next
+    }
+    if (repository_seen[repository_name]) {
+        repository_error(FILENAME ":" FNR ": duplicate repository '" \
+                         repository_name "'")
+        next
+    }
+
+    repository_seen[repository_name] = 1
+    repository_total++
+    repository_names[repository_total] = repository_name
+    repository_paths[repository_total] = repository_path
+    repository_priorities[repository_total] = repository_priority
+}
+
+END {
+    if (repository_failed)
+        exit 2
+
+    OFS = "\t"
+    if (action == "normalize" || action == "raw") {
+        for (repository_i = 1; repository_i <= repository_total;
+             repository_i++)
+            print repository_names[repository_i],
+                  repository_paths[repository_i],
+                  repository_priorities[repository_i], repository_i
+    } else if (action == "list" || action == "") {
+        for (repository_i = 1; repository_i <= repository_total;
+             repository_i++)
+            printf "%s\tpriority %s\t%s\n",
+                   repository_names[repository_i],
+                   repository_priorities[repository_i],
+                   repository_paths[repository_i]
+    } else if (action == "status" || action == "status_raw") {
+        repository_index_state = "missing"
+        if (index_file != "") {
+            while ((repository_getline = \
+                    (getline repository_index_line < index_file)) > 0) {
+                repository_index_fields_count = split(repository_index_line,
+                                                      repository_index_fields,
+                                                      "\t")
+                if (repository_index_fields_count == 12)
+                    repository_package_count[repository_index_fields[8]]++
+            }
+            if (repository_getline == 0)
+                repository_index_state = "present"
+            close(index_file)
+        }
+
+        for (repository_i = 1; repository_i <= repository_total;
+             repository_i++) {
+            repository_name = repository_names[repository_i]
+            if (action == "status_raw")
+                print repository_name,
+                      repository_priorities[repository_i],
+                      repository_paths[repository_i],
+                      (repository_index_state == "present" \
+                       ? repository_package_count[repository_name] + 0 \
+                       : "not-indexed")
+            else if (repository_index_state == "present")
+                printf "%s: %d packages (priority %s, %s)\n",
+                       repository_name,
+                       repository_package_count[repository_name] + 0,
+                       repository_priorities[repository_i],
+                       repository_paths[repository_i]
+            else
+                printf "%s: not indexed (priority %s, %s)\n",
+                       repository_name,
+                       repository_priorities[repository_i],
+                       repository_paths[repository_i]
+        }
+    } else {
+        repository_error("unknown repository action '" action "'")
+        exit 2
+    }
+}
diff --git a/lib/version.awk b/lib/version.awk
new file mode 100644
index 0000000..54205ac
--- /dev/null
+++ b/lib/version.awk
@@ -0,0 +1,146 @@
+# POSIX AWK version comparison. Versions are split into numeric and alphabetic
+# runs; punctuation separates runs, leading zeroes do not change numeric order,
+# and `~` sorts before other tokens. This is SPS's own small comparison rule.
+
+function version_tokenize(version, values, types,    count, i, c, start) {
+    for (i in values)
+        delete values[i]
+    for (i in types)
+        delete types[i]
+
+    count = 0
+    i = 1
+    while (i <= length(version)) {
+        c = substr(version, i, 1)
+        if (c == "~") {
+            count++
+            values[count] = c
+            types[count] = "tilde"
+            i++
+        } else if (c ~ /[[:digit:]]/) {
+            start = i
+            while (i <= length(version) &&
+                   substr(version, i, 1) ~ /[[:digit:]]/)
+                i++
+            count++
+            values[count] = substr(version, start, i - start)
+            types[count] = "number"
+        } else if (c ~ /[[:alpha:]]/) {
+            start = i
+            while (i <= length(version) &&
+                   substr(version, i, 1) ~ /[[:alpha:]]/)
+                i++
+            count++
+            values[count] = tolower(substr(version, start, i - start))
+            types[count] = "text"
+        } else {
+            i++
+        }
+    }
+    return count
+}
+
+function version_compare_number(left, right,    a, b) {
+    a = left
+    b = right
+    sub(/^0+/, "", a)
+    sub(/^0+/, "", b)
+    if (a == "")
+        a = "0"
+    if (b == "")
+        b = "0"
+
+    if (length(a) < length(b))
+        return -1
+    if (length(a) > length(b))
+        return 1
+    if ("x" a < "x" b)
+        return -1
+    if ("x" a > "x" b)
+        return 1
+    return 0
+}
+
+function version_remaining_sign(values, types, first, count,    i, value) {
+    for (i = first; i <= count; i++) {
+        if (types[i] == "tilde")
+            return -1
+        if (types[i] == "number") {
+            value = values[i]
+            sub(/^0+/, "", value)
+            if (value != "")
+                return 1
+        } else {
+            return 1
+        }
+    }
+    return 0
+}
+
+function version_compare(left, right,    left_count, right_count, count,
+                         i, left_type, right_type, result) {
+    left_count = version_tokenize(left, version_left_values,
+                                  version_left_types)
+    right_count = version_tokenize(right, version_right_values,
+                                   version_right_types)
+    count = left_count < right_count ? left_count : right_count
+
+    for (i = 1; i <= count; i++) {
+        left_type = version_left_types[i]
+        right_type = version_right_types[i]
+
+        if (left_type == "tilde" || right_type == "tilde") {
+            if (left_type != right_type)
+                return left_type == "tilde" ? -1 : 1
+            continue
+        }
+
+        if (left_type != right_type)
+            return left_type == "number" ? 1 : -1
+
+        if (left_type == "number")
+            result = version_compare_number(version_left_values[i],
+                                            version_right_values[i])
+        else if (version_left_values[i] < version_right_values[i])
+            result = -1
+        else if (version_left_values[i] > version_right_values[i])
+            result = 1
+        else
+            result = 0
+
+        if (result != 0)
+            return result
+    }
+
+    if (left_count > count)
+        return version_remaining_sign(version_left_values,
+                                      version_left_types,
+                                      count + 1, left_count)
+    if (right_count > count)
+        return -version_remaining_sign(version_right_values,
+                                       version_right_types,
+                                       count + 1, right_count)
+    return 0
+}
+
+function version_satisfies(have, operator, wanted,    comparison) {
+    comparison = version_compare(have, wanted)
+    if (operator == "=")
+        return comparison == 0
+    if (operator == ">")
+        return comparison > 0
+    if (operator == ">=")
+        return comparison >= 0
+    if (operator == "<")
+        return comparison < 0
+    if (operator == "<=")
+        return comparison <= 0
+    return 0
+}
+
+END {
+    if (version_action == "compare")
+        print version_compare(version_a, version_b)
+    else if (version_action == "satisfies")
+        print version_satisfies(version_a, version_operator, version_b) ? 1 : 0
+}
diff --git a/man/mkpkg.1 b/man/mkpkg.1
new file mode 100644
index 0000000..e918228
--- /dev/null
+++ b/man/mkpkg.1
@@ -0,0 +1,82 @@
+.TH MKPKG 1 "2026-08-30" "SPS 1.0" "SPS Commands"
+.SH NAME
+mkpkg \- build an SPS source recipe into a binary package
+.SH SYNOPSIS
+.B mkpkg
+.RI [ options ]
+.RI [ recipe ]
+.SH DESCRIPTION
+.B mkpkg
+reads declarative metadata from a recipe, verifies sources, executes only the
+named build phase records, stages installation away from the live root,
+generates SPS metadata, manifests, and hashes, then writes a standard tar package.
+The default recipe is
+.BR ./recipe .
+A directory argument means its
+.B recipe
+file.
+.SH OPTIONS
+.TP
+.BI "-o, --output " dir
+Write the completed package to DIR.
+.TP
+.BI "-C, --compression " type
+Select
+.BR auto ,
+.BR none ,
+.BR gzip ,
+.BR xz ,
+or
+.BR zstd .
+.TP
+.B --no-download
+Require remote sources to already exist in the source cache.
+.TP
+.B --keep-build
+Keep build and staging directories for inspection after exit.
+.SH RECIPES
+Required keys are
+.BR name ,
+.BR version ,
+and
+.BR release .
+Common repeatable keys include
+.BR source ,
+.BR hash ,
+.BR depend ,
+.BR builddep ,
+.B optional
+and the phase records
+.BR prepare ,
+.BR configure ,
+.BR build ,
+and
+.BR install .
+Metadata parsing never executes these phase records.
+.PP
+A recipe directory may provide explicit lifecycle scripts in
+.B hooks/pre-install,
+hooks/post-install, hooks/pre-remove, and hooks/post-remove. No other hook
+names are accepted.
+.SH ENVIRONMENT
+.B SPS_ARCH
+provides the default architecture.
+.B SPS_MAKEJOBS
+provides per-package parallelism and is exposed to build commands as
+.BR MAKEJOBS .
+.B SPS_CACHE
+contains downloaded sources;
+.B SPS_BUILD
+contains temporary build/staging trees.
+.SH OUTPUT
+On success the final stdout line is the absolute or requested path of the
+created package artifact, suitable for consumption by
+.BR sget (1).
+.SH EXIT STATUS
+Returns 0 on success, 2 for usage, 3 for missing recipe/source, 4 for invalid
+metadata/package content, 6 for checksum failure, 8 for filesystem failure,
+9 for download failure, and 10 for build failure.
+.SH SEE ALSO
+.BR sget (1),
+.BR pkin (1),
+.BR sps.conf (5)
diff --git a/man/pkcheck.1 b/man/pkcheck.1
new file mode 100644
index 0000000..b052ee4
--- /dev/null
+++ b/man/pkcheck.1
@@ -0,0 +1,39 @@
+.TH PKCHECK 1 "2026-08-30" "SPS 1.0" "SPS Commands"
+.SH NAME
+pkcheck \- verify SPS installed package state
+.SH SYNOPSIS
+.nf
+pkcheck [PACKAGE]
+pkcheck --all
+pkcheck --missing [PACKAGE]
+pkcheck --modified [PACKAGE]
+pkcheck --database
+.fi
+.SH DESCRIPTION
+.B pkcheck
+compares installed package records with the target filesystem and validates
+database relationships. It reports facts and performs no repair.
+.SH OPTIONS
+.TP
+.B --all
+Verify all installed manifests and regular-file hashes.
+.TP
+.B --missing
+Report package-owned paths absent from the target root.
+.TP
+.B --modified
+Report regular files whose SHA-256 digest differs from the installed package
+record. Administrator-edited protected configuration therefore appears here by
+design.
+.TP
+.B --database
+Validate installed metadata, manifests, hashes, hooks, owner mappings, and the
+explicit world set without hashing live payload files.
+.SH EXIT STATUS
+Returns 0 when requested checks pass, 1 when missing/modified payload is found,
+2 for usage, 3 when a named package is absent, and 11 for malformed or
+inconsistent database state.
+.SH SEE ALSO
+.BR pkstat (1),
+.BR pkin (1),
+.BR sps.conf (5)
diff --git a/man/pkdel.1 b/man/pkdel.1
new file mode 100644
index 0000000..84ce8c6
--- /dev/null
+++ b/man/pkdel.1
@@ -0,0 +1,35 @@
+.TH PKDEL 1 "2026-08-30" "SPS 1.0" "SPS Commands"
+.SH NAME
+pkdel \- remove one installed SPS package
+.SH SYNOPSIS
+.B pkdel
+.RB [ --plan ]
+.I package
+.SH DESCRIPTION
+.B pkdel
+is the low-level removal tool. It removes paths still owned by one package,
+prunes empty package directories where possible, and commits updated ownership
+and world state. It does no dependency solving; use
+.BR "sget remove"
+for dependency-aware administration.
+.SH OPTIONS
+.TP
+.B --plan
+Report which package paths would be removed or retained without changing the
+filesystem or database.
+.SH PROTECTED FILES
+A locally modified regular file under an
+.B SPS_PRESERVE
+prefix is retained and becomes unowned rather than being silently deleted.
+.SH HOOKS
+Visible pre-remove and post-remove package hooks may run. A failing pre-remove
+hook aborts removal. A failing post-remove hook returns status 10 after the
+removal and operation history have already committed.
+.SH EXIT STATUS
+Returns 0 on success, 2 for usage, 3 when the package is absent, 7 for unsafe
+filesystem conflict, 8 for permission failure, 10 for hook failure, and 11 for
+malformed/unavailable database state.
+.SH SEE ALSO
+.BR pkin (1),
+.BR pkstat (1),
+.BR sget (1)
diff --git a/man/pkin.1 b/man/pkin.1
new file mode 100644
index 0000000..7c4d254
--- /dev/null
+++ b/man/pkin.1
@@ -0,0 +1,59 @@
+.TH PKIN 1 "2026-08-30" "SPS 1.0" "SPS Commands"
+.SH NAME
+pkin \- install or upgrade one SPS binary package
+.SH SYNOPSIS
+.B pkin
+.RB [ --explicit | --dependency ]
+.I package
+.SH DESCRIPTION
+.B pkin
+is the low-level SPS binary installer. It validates one SPS tar archive,
+checks payload hashes, architecture, target paths and ownership conflicts,
+installs below
+.BR SPS_ROOT ,
+and commits plain-text package state. It does not search repositories or solve
+dependencies.
+.SH OPTIONS
+.TP
+.B --explicit
+Add the package to the explicit world set. This is the default.
+.TP
+.B --dependency
+Install without adding the package to the explicit world set.
+.SH CONFIGURATION PRESERVATION
+Regular files below prefixes configured by
+.B SPS_PRESERVE
+(default
+.BR etc )
+are protected when locally modified. On upgrade the local file remains. If the
+new packaged default changed, it is written beside the file as
+.B .sps-new
+(or a versioned .sps-new name when necessary).
+.SH HOOKS
+Visible package hooks may run before/after install or upgrade. A failing
+pre-install hook aborts before payload installation. A failing post-install
+hook returns status 10 after committed package state and history are retained.
+.SH OWNERSHIP
+When run as uid 0, new payload files and package-created directories are
+normalized to uid/gid 0 while keeping their packaged modes. Existing shared
+directories keep their current ownership and mode.
+.SH DATABASE
+Installed records live below
+.BR $SPS_DB/installed .
+.B owners
+maps non-directory payload paths to packages and
+.B world
+contains explicit roots. SPS serializes writers with
+.BR $SPS_DB/.lock
+and can recover a stale lock only when its recorded numeric PID is verifiably
+not alive.
+.SH EXIT STATUS
+Returns 0 on success, 2 for usage, 3 for unavailable archive, 4 for invalid or
+wrong-architecture package, 6 for payload checksum failure, 7 for conflict or
+unsafe target path, 8 for permission/filesystem failure, 10 for hook failure,
+and 11 for package database failure.
+.SH SEE ALSO
+.BR mkpkg (1),
+.BR pkdel (1),
+.BR pkstat (1),
+.BR pkmark (1)
diff --git a/man/pkmark.1 b/man/pkmark.1
new file mode 100644
index 0000000..38cb81b
--- /dev/null
+++ b/man/pkmark.1
@@ -0,0 +1,29 @@
+.TH PKMARK 1 "2026-08-30" "SPS 1.0" "SPS Commands"
+.SH NAME
+pkmark \- change SPS explicit package reasons
+.SH SYNOPSIS
+.nf
+pkmark explicit PACKAGE...
+pkmark dependency PACKAGE...
+.fi
+.SH DESCRIPTION
+.B pkmark
+changes whether already-installed packages are roots of the explicit
+.B world
+set without reinstalling them.
+.TP
+.B explicit
+Add each package to world.
+.TP
+.B dependency
+Remove each package from world. The package remains installed and may later be
+reported by
+.B pkstat --orphans
+if no explicit package reaches it.
+.SH EXIT STATUS
+Returns 0 on success, 2 for usage, 3 when a named package is not installed,
+and 11 for database/locking failure.
+.SH SEE ALSO
+.BR pkstat (1),
+.BR sget (1),
+.BR pkin (1)
diff --git a/man/pkstat.1 b/man/pkstat.1
new file mode 100644
index 0000000..3c2968f
--- /dev/null
+++ b/man/pkstat.1
@@ -0,0 +1,42 @@
+.TH PKSTAT 1 "2026-08-30" "SPS 1.0" "SPS Commands"
+.SH NAME
+pkstat \- inspect locally installed SPS package state
+.SH SYNOPSIS
+.nf
+pkstat [PACKAGE]
+pkstat --files PACKAGE
+pkstat --owns PATH
+pkstat --explicit
+pkstat --orphans
+pkstat --size PACKAGE
+.fi
+.SH DESCRIPTION
+.B pkstat
+answers installed-state queries entirely from local metadata. It does not
+refresh repositories or access the network.
+.SH OPTIONS
+.TP
+.BI --files " package"
+List the package manifest.
+.TP
+.BI --owns " path"
+Print the package owning a non-directory payload path.
+.TP
+.B --explicit
+List the explicit world set.
+.TP
+.B --orphans
+List dependency-installed packages unreachable from every explicit world root
+through installed runtime dependency records. This is graph reachability, so
+entire abandoned dependency chains are reported.
+.TP
+.BI --size " package"
+Print the current byte size of regular files in the package manifest.
+.SH EXIT STATUS
+Returns 0 on success, 2 for usage, 3 for missing package/owner, 8 for
+unreadable filesystem state, and 11 for malformed/unavailable database state.
+.SH SEE ALSO
+.BR pkcheck (1),
+.BR pkmark (1),
+.BR pkin (1),
+.BR pkdel (1)
diff --git a/man/sget.1 b/man/sget.1
new file mode 100644
index 0000000..77a97dc
--- /dev/null
+++ b/man/sget.1
@@ -0,0 +1,100 @@
+.TH SGET 1 "2026-08-30" "SPS 1.0" "SPS Commands"
+.SH NAME
+sget \- resolve and coordinate SPS source packages
+.SH SYNOPSIS
+.nf
+sget search PATTERN [--raw]
+sget info PACKAGE [--raw]
+sget depends PACKAGE [--raw] [--build]
+sget dependees PACKAGE [--build]
+sget why PACKAGE DEPENDENCY [--build]
+sget install [--plan] [--nodeps] [--reinstall] PACKAGE...
+sget upgrade [--plan] [--downgrade]
+sget remove [--plan] [--nodeps] PACKAGE...
+.fi
+.SH DESCRIPTION
+.B sget
+is the high-level SPS coordinator.  It reads the repository index produced by
+.BR src (1),
+resolves package graphs, calls
+.BR mkpkg (1)
+when a binary artifact is not already cached, and hands archives to
+.BR pkin (1).
+It does not refresh repositories implicitly.
+.SH COMMANDS
+.TP
+.BI search " pattern"
+Search selected package names and descriptions.  With
+.B --raw
+print stable tab-separated records.
+.TP
+.BI info " package"
+Show the selected indexed package record.
+.TP
+.BI depends " package"
+Show the runtime dependency closure.  With
+.B --build
+also include build dependencies; with
+.B --raw
+print dependency build order.
+.TP
+.BI dependees " package"
+List selected packages that directly depend on the package.  With
+.B --build
+include build dependencies.
+.TP
+.BI why " package dependency"
+Print one selected dependency path from PACKAGE to DEPENDENCY.  With
+.B --build
+build dependencies may participate.
+.TP
+.BI install " package..."
+Resolve and install requested packages. Requested roots are explicit; packages
+needed only by the graph are dependency-installed. Exact installed roots are
+promoted to explicit without an unnecessary reinstall.
+.TP
+.B upgrade
+Resolve the installed explicit world set and upgrade its selected dependency
+closure. A locally newer installed version is kept unless
+.B --downgrade
+is supplied.
+.TP
+.BI remove " package..."
+Remove packages through
+.BR pkdel (1).
+By default SPS refuses a removal when another installed package outside the
+requested removal set directly requires it.
+.SH OPTIONS
+.TP
+.B --plan
+Print package-level actions without building, installing, removing, or changing
+package state.
+.TP
+.B --nodeps
+For install, operate only on the explicitly named packages. For remove, bypass
+the reverse-dependency safety check. This is an explicit administrator
+override.
+.TP
+.B --reinstall
+With install, rebuild/reinstall an exact selected package when necessary;
+an exact cached binary artifact may be reused.
+.TP
+.B --downgrade
+With upgrade, permit the configured repository selection to replace a newer
+installed version.
+.SH CACHE
+Completed binary artifacts are kept below
+.BR $SPS_CACHE/packages .
+An exact name-version-release-architecture archive may be reused. Distributions
+must increment a package release when build inputs or package behavior change
+without an upstream version change.
+.SH EXIT STATUS
+Returns 0 on success, 2 for usage, 3 for missing package/index state, 4 for
+invalid package metadata or architecture, 5 for dependency failure, and
+otherwise propagates meaningful lower-level build/install/remove statuses.
+.SH SEE ALSO
+.BR src (1),
+.BR mkpkg (1),
+.BR pkin (1),
+.BR pkdel (1),
+.BR pkmark (1)
diff --git a/man/sps.conf.5 b/man/sps.conf.5
new file mode 100644
index 0000000..b6ee9a2
--- /dev/null
+++ b/man/sps.conf.5
@@ -0,0 +1,98 @@
+.TH SPS.CONF 5 "2026-08-30" "SPS 1.0" "SPS File Formats"
+.SH NAME
+sps.conf, repos.conf \- SPS configuration and repository declarations
+.SH DESCRIPTION
+SPS configuration is line-oriented text, not shell code. Blank lines and lines
+beginning with
+.B #
+are ignored. Each setting is a key followed by whitespace and its value.
+.SH GENERAL SETTINGS
+.TP
+.BI root " path"
+Target filesystem root; default
+.BR / .
+.TP
+.BI db " path"
+Installed package database; default
+.BR /var/lib/sps .
+.TP
+.BI cache " path"
+Source, repository-index, and built-package cache; default
+.BR /var/cache/sps .
+.TP
+.BI build " path"
+Temporary build/staging root; default
+.BR /var/tmp/sps .
+.TP
+.BI makejobs " count"
+Positive per-package build parallelism exported to recipes as
+.BR MAKEJOBS .
+.TP
+.BI compression " type"
+Package compression:
+.BR auto ,
+.BR none ,
+.BR gzip ,
+.BR xz ,
+or
+.BR zstd .
+.B zst
+is accepted as an alias for zstd.
+.TP
+.BI arch " name"
+Target/build architecture. The default is the local result of
+.BR "uname -m" .
+Packages marked
+.B any
+or
+.B noarch
+are architecture-independent.
+.TP
+.BI preserve " prefixes"
+Comma-separated package-relative path prefixes whose locally modified regular
+files are not silently replaced/deleted. Default:
+.BR etc .
+Use
+.B none
+to disable the policy.
+.SH REPOSITORIES
+A declaration has the form:
+.PP
+.nf
+repo NAME PATH [PRIORITY]
+.fi
+.PP
+PATH is a local directory tree. Larger numeric priorities win; configuration
+order breaks equal priorities. Repository transport is outside SPS.
+.SH ENVIRONMENT
+.BR SPS_ROOT ,
+.BR SPS_DB ,
+.BR SPS_CACHE ,
+.BR SPS_BUILD ,
+.BR SPS_MAKEJOBS ,
+.BR SPS_COMPRESSION ,
+.BR SPS_ARCH ,
+and
+.B SPS_PRESERVE
+override corresponding file settings. Set
+.B SPS_CONFIG
+or
+.B SPS_REPOS_CONFIG
+to select alternate files.
+.SH EXAMPLE
+.nf
+root /
+db /var/lib/sps
+cache /var/cache/sps
+build /var/tmp/sps
+makejobs 8
+compression zstd
+preserve etc
+
+repo core  /usr/src/core  100
+repo local /usr/src/local 200
+.fi
+.SH SEE ALSO
+.BR src (1),
+.BR sget (1),
+.BR mkpkg (1)
diff --git a/man/src.1 b/man/src.1
new file mode 100644
index 0000000..3670a73
--- /dev/null
+++ b/man/src.1
@@ -0,0 +1,61 @@
+.TH SRC 1 "2026-08-30" "SPS 1.0" "SPS Commands"
+.SH NAME
+src \- update and query SPS source repository indexes
+.SH SYNOPSIS
+.nf
+src update
+src search PATTERN [--raw]
+src show PACKAGE [--raw]
+src which PACKAGE
+src list [--raw]
+src status [--raw]
+.fi
+.SH DESCRIPTION
+.B src
+manages source repository metadata. It never builds or installs packages.
+Configured repositories are local directory trees. Use git, rsync, a release
+archive, or a distribution update script to put repository data on disk.
+.SH COMMANDS
+.TP
+.B update
+Scan configured repositories, validate recipes, and atomically replace the
+local package index. Invalid input leaves the previous index in place.
+.TP
+.BI search " pattern"
+Search indexed names and descriptions.
+.TP
+.BI show " package"
+Display the selected package record.
+.TP
+.BI which " package"
+Show the selected repository and lower-priority candidates.
+.TP
+.B list
+List configured repositories.
+.TP
+.B status
+Show repository paths, priorities, and index state.
+.PP
+.B --raw
+selects stable tab-separated output where supported.
+.SH SELECTION
+Higher numeric repository priority wins. Configuration order breaks equal
+priority, followed by lexical recipe path for deterministic selection.
+.SH FILES
+.TP
+.B /etc/sps/sps.conf
+General SPS configuration.
+.TP
+.B /etc/sps/repos.conf
+Repository declarations.
+.TP
+.B $SPS_CACHE/indexes/packages.index
+Generated local package index.
+.SH EXIT STATUS
+Returns 0 on success, 2 for usage/configuration, 3 for missing index/package,
+4 for malformed recipes/indexes, 6 for hashing failure, and 8 for permission
+failure.
+.SH SEE ALSO
+.BR sget (1),
+.BR mkpkg (1),
+.BR sps.conf (5)
diff --git a/tests/run b/tests/run
new file mode 100755
index 0000000..0c47769
--- /dev/null
+++ b/tests/run
@@ -0,0 +1,30 @@
+#!/bin/sh
+set -eu
+
+case $0 in
+	*/*) test_dir=${0%/*} ;;
+	*) test_dir=. ;;
+esac
+test_dir=$(CDPATH= cd -- "$test_dir" 2>/dev/null && pwd)
+
+status=0
+count=0
+for test_file in "$test_dir"/test_*.sh; do
+	[ -f "$test_file" ] || continue
+	count=$((count + 1))
+	printf '%s\n' "==> ${test_file##*/}"
+	if "${TEST_SHELL:-/bin/sh}" "$test_file"; then
+		printf '%s\n' "ok - ${test_file##*/}"
+	else
+		printf '%s\n' "not ok - ${test_file##*/}" >&2
+		status=1
+	fi
+done
+
+[ "$count" -gt 0 ] || {
+	printf '%s\n' 'tests/run: no tests found' >&2
+	exit 1
+}
+
+exit "$status"
+
diff --git a/tests/test_build.sh b/tests/test_build.sh
new file mode 100755
index 0000000..6aeb1a4
--- /dev/null
+++ b/tests/test_build.sh
@@ -0,0 +1,159 @@
+#!/bin/sh
+
+set -eu
+
+test_dir=$(CDPATH= cd "$(dirname "$0")" 2>/dev/null && pwd) || exit 1
+project_dir=$(CDPATH= cd "$test_dir/.." 2>/dev/null && pwd) || exit 1
+mkpkg=$project_dir/bin/mkpkg
+
+fail()
+{
+	printf 'test_build: %s\n' "$*" >&2
+	exit 1
+}
+
+assert_equal()
+{
+	assert_expected=$1
+	assert_actual=$2
+	assert_message=$3
+	[ "$assert_expected" = "$assert_actual" ] ||
+		fail "$assert_message (expected '$assert_expected', got '$assert_actual')"
+}
+
+sha256_file()
+{
+	if command -v sha256sum >/dev/null 2>&1; then
+		sha256sum "$1" | awk '{ print $1 }'
+	elif command -v shasum >/dev/null 2>&1; then
+		shasum -a 256 "$1" | awk '{ print $1 }'
+	elif command -v openssl >/dev/null 2>&1; then
+		openssl dgst -sha256 "$1" | awk '{ print $NF }'
+	else
+		fail "no SHA-256 utility available for test fixture"
+	fi
+}
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-test-build.XXXXXX") ||
+	fail "cannot create temporary directory"
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+
+mkdir -p "$tmp/recipe" "$tmp/out" "$tmp/root"
+cp "$project_dir/examples/hello/recipe" "$tmp/recipe/recipe"
+cp "$project_dir/examples/hello/hello.sh" "$tmp/recipe/hello.sh"
+
+artifact=$(
+	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 \
+	"$mkpkg" --no-download --output "$tmp/out" "$tmp/recipe/recipe"
+) || fail "offline hello package build failed"
+
+expected_artifact=$tmp/out/hello-sps-1.0-1-any.pkg.tar
+assert_equal "$expected_artifact" "$artifact" "mkpkg did not print the artifact path"
+[ -f "$artifact" ] || fail "package archive was not created"
+
+tar -tf "$artifact" | sed 's#^\./##' >"$tmp/members"
+for member in .SPS/meta .SPS/files .SPS/hashes usr/bin/hello-sps; do
+	grep -qx "$member" "$tmp/members" || fail "archive is missing $member"
+done
+[ ! -s "$tmp/duplicates" ] || fail "internal test error"
+LC_ALL=C sort "$tmp/members" | uniq -d >"$tmp/duplicates"
+[ ! -s "$tmp/duplicates" ] || fail "archive contains duplicate members"
+
+mkdir "$tmp/extract"
+tar -xf "$artifact" -C "$tmp/extract"
+
+hello_output=$(sh "$tmp/extract/usr/bin/hello-sps")
+assert_equal "hello from SPS" "$hello_output" "installed hello program returned unexpected output"
+
+awk -F '\t' '
+$1 == "format" && $2 == "1" { format = 1 }
+$1 == "name" && $2 == "hello-sps" { name = 1 }
+$1 == "version" && $2 == "1.0" { version = 1 }
+$1 == "release" && $2 == "1" { release = 1 }
+$1 == "arch" && $2 == "any" { arch = 1 }
+END { exit !(format && name && version && release && arch) }
+' "$tmp/extract/.SPS/meta" || fail "package metadata is incomplete"
+
+cat >"$tmp/expected-files" <<'EOF'
+usr/
+usr/bin/
+usr/bin/hello-sps
+EOF
+cmp "$tmp/expected-files" "$tmp/extract/.SPS/files" >/dev/null 2>&1 ||
+	fail "package manifest is not normalized and deterministic"
+
+expected_hash=25576de85c3f0b4dc3e7847956daf896c450318a00bb1b818869accf8fdc7268
+awk -F '\t' -v expected="$expected_hash" '
+$1 == "sha256" && $2 == expected && $3 == "usr/bin/hello-sps" { found = 1 }
+END { exit !found }
+' "$tmp/extract/.SPS/hashes" || fail "payload hash record is incorrect"
+
+# A build must stay in its staging tree, never SPS_ROOT.
+[ ! -e "$tmp/root/usr/bin/hello-sps" ] || fail "builder modified the target root"
+
+# Bad source hashes have their own stable exit status.
+mkdir "$tmp/bad-recipe" "$tmp/bad-out"
+cp "$tmp/recipe/hello.sh" "$tmp/bad-recipe/hello.sh"
+sed 's/sha256:2/sha256:3/' "$tmp/recipe/recipe" >"$tmp/bad-recipe/recipe"
+set +e
+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 \
+"$mkpkg" --no-download --output "$tmp/bad-out" "$tmp/bad-recipe/recipe" \
+	>"$tmp/bad.stdout" 2>"$tmp/bad.stderr"
+bad_status=$?
+set -e
+assert_equal 6 "$bad_status" "checksum failure returned the wrong status"
+grep -q 'checksum failure' "$tmp/bad.stderr" ||
+	fail "checksum diagnostic did not explain the failure"
+
+# Local tar sources unpack below WORK. Recipe variables expand as data and
+# missing arch falls back to uname -m.
+mkdir -p "$tmp/tar-source/archive-hello-1.0" "$tmp/tar-recipe" "$tmp/tar-out"
+cp "$tmp/recipe/hello.sh" "$tmp/tar-source/archive-hello-1.0/hello.sh"
+tar -cf "$tmp/tar-recipe/archive-hello-1.0.tar" \
+	-C "$tmp/tar-source" archive-hello-1.0
+archive_hash=$(sha256_file "$tmp/tar-recipe/archive-hello-1.0.tar")
+cat >"$tmp/tar-recipe/recipe.in" <<'EOF'
+name        archive-hello
+version     1.0
+release     1
+description Exercises local tar extraction and default architecture
+source      ${name}-${version}.tar
+hash        sha256:SOURCE_HASH
+install     mkdir -p "$PKG/usr/bin"
+install     cp hello.sh "$PKG/usr/bin/archive-hello"
+install     chmod 755 "$PKG/usr/bin/archive-hello"
+EOF
+sed "s/SOURCE_HASH/$archive_hash/" "$tmp/tar-recipe/recipe.in" >"$tmp/tar-recipe/recipe"
+
+tar_artifact=$(
+	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 \
+	"$mkpkg" --no-download --output "$tmp/tar-out" "$tmp/tar-recipe/recipe"
+) || fail "local tar source build failed"
+default_arch=$(uname -m)
+assert_equal "$tmp/tar-out/archive-hello-1.0-1-$default_arch.pkg.tar" \
+	"$tar_artifact" "default architecture or metadata expansion is incorrect"
+tar -xOf "$tar_artifact" ./usr/bin/archive-hello >"$tmp/archive-hello.out" 2>/dev/null ||
+	tar -xOf "$tar_artifact" usr/bin/archive-hello >"$tmp/archive-hello.out" ||
+	fail "archive-source payload is missing"
+assert_equal "hello from SPS" "$(sh "$tmp/archive-hello.out")" \
+	"archive-source payload returned unexpected output"
+
+printf '%s\n' 'test_build: ok'
diff --git a/tests/test_common.sh b/tests/test_common.sh
new file mode 100755
index 0000000..7e4de33
--- /dev/null
+++ b/tests/test_common.sh
@@ -0,0 +1,49 @@
+#!/bin/sh
+set -eu
+
+case $0 in */*) test_dir=${0%/*} ;; *) test_dir=. ;; esac
+project_dir=$(CDPATH= cd -- "$test_dir/.." 2>/dev/null && pwd)
+. "$project_dir/lib/common.sh"
+
+tmp=${TMPDIR:-/tmp}/sps-common.$$
+trap 'rm -rf "$tmp"' EXIT HUP INT TERM
+mkdir -p "$tmp/root/etc/sps"
+
+test_root=$tmp/root
+config_file=$test_root/etc/sps/sps.conf
+repos_file=$test_root/etc/sps/repos.conf
+{
+	printf '%s\n' 'db /state/db'
+	printf '%s\n' 'cache /state/cache'
+	printf '%s\n' 'build /state/build'
+	printf '%s\n' 'makejobs 3'
+	printf '%s\n' 'arch test-arch'
+	printf '%s\n' 'preserve etc,usr/local/etc'
+	printf '%s\n' "repo local $tmp/repo 90"
+} > "$config_file"
+printf '%s\n' "repo extra $tmp/extra 10" > "$repos_file"
+
+SPS_ROOT=$test_root
+SPS_CONFIG=$config_file
+SPS_REPOS_CONFIG=$repos_file
+export SPS_ROOT SPS_CONFIG SPS_REPOS_CONFIG
+unset SPS_DB SPS_CACHE SPS_BUILD SPS_MAKEJOBS SPS_COMPRESSION
+sps_load_config
+
+[ "$SPS_DB" = "$test_root/state/db" ]
+[ "$SPS_CACHE" = "$test_root/state/cache" ]
+[ "$SPS_BUILD" = "$test_root/state/build" ]
+[ "$SPS_MAKEJOBS" = 3 ]
+[ "$SPS_ARCH" = test-arch ]
+[ "$SPS_PRESERVE" = etc,usr/local/etc ]
+[ "$(sps_repo_lines | wc -l | awk '{ print $1 }')" = 2 ]
+sps_validate_package_name 'libc++'
+sps_validate_package_name 'compat..name'
+! sps_validate_package_name '../bad'
+sps_tab_safe 'ordinary value'
+! sps_tab_safe "bad	value"
+
+created=$(sps_mktemp_dir "$tmp" common)
+[ -d "$created" ]
+
+printf '%s\n' 'common configuration tests passed'
diff --git a/tests/test_compression.sh b/tests/test_compression.sh
new file mode 100755
index 0000000..66023d5
--- /dev/null
+++ b/tests/test_compression.sh
@@ -0,0 +1,81 @@
+#!/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
+pkin=$project_dir/bin/pkin
+pkdel=$project_dir/bin/pkdel
+
+fail()
+{
+    printf 'test_compression: %s\n' "$*" >&2
+    exit 1
+}
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-test-compression.XXXXXX") ||
+    fail 'cannot create temporary directory'
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+
+mkdir -p "$tmp/recipe"
+cp "$project_dir/examples/hello/recipe" "$tmp/recipe/recipe"
+cp "$project_dir/examples/hello/hello.sh" "$tmp/recipe/hello.sh"
+
+for compression in gzip xz zstd; do
+    command -v "$compression" >/dev/null 2>&1 || continue
+
+    case $compression in
+        gzip) suffix=.gz ;;
+        xz) suffix=.xz ;;
+        zstd) suffix=.zst ;;
+    esac
+
+    root=$tmp/root-$compression
+    db=$tmp/db-$compression
+    cache=$tmp/cache-$compression
+    build=$tmp/build-$compression
+    out=$tmp/out-$compression
+    mkdir -p "$root" "$out"
+
+    artifact=$(
+        SPS_ROOT=$root \
+        SPS_DB=$db \
+        SPS_CACHE=$cache \
+        SPS_BUILD=$build \
+        SPS_CONFIG=/dev/null \
+        SPS_REPOS_CONFIG=/dev/null \
+        SPS_LIBDIR=$project_dir/lib \
+        "$mkpkg" --no-download --compression "$compression" \
+            --output "$out" "$tmp/recipe/recipe"
+    ) || fail "$compression package build failed"
+
+    expected=$out/hello-sps-1.0-1-any.pkg.tar$suffix
+    [ "$artifact" = "$expected" ] ||
+        fail "$compression build returned unexpected artifact '$artifact'"
+    [ -f "$artifact" ] || fail "$compression package archive was not created"
+
+    SPS_ROOT=$root \
+    SPS_DB=$db \
+    SPS_CACHE=$cache \
+    SPS_BUILD=$build \
+    SPS_CONFIG=/dev/null \
+    SPS_REPOS_CONFIG=/dev/null \
+    SPS_LIBDIR=$project_dir/lib \
+        "$pkin" "$artifact" >/dev/null ||
+        fail "pkin could not install $compression package"
+
+    [ "$(sh "$root/usr/bin/hello-sps")" = 'hello from SPS' ] ||
+        fail "$compression package payload is incorrect"
+
+    SPS_ROOT=$root \
+    SPS_DB=$db \
+    SPS_CACHE=$cache \
+    SPS_BUILD=$build \
+    SPS_CONFIG=/dev/null \
+    SPS_REPOS_CONFIG=/dev/null \
+    SPS_LIBDIR=$project_dir/lib \
+        "$pkdel" hello-sps >/dev/null ||
+        fail "pkdel could not remove $compression package"
+done
+
+printf '%s\n' 'test_compression: ok'
diff --git a/tests/test_dependencies.sh b/tests/test_dependencies.sh
new file mode 100755
index 0000000..b0ec233
--- /dev/null
+++ b/tests/test_dependencies.sh
@@ -0,0 +1,195 @@
+#!/bin/sh
+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' 'dependency tests skipped: lib/common.sh is not integrated'
+    exit 0
+fi
+
+fail()
+{
+    printf 'test_dependencies: %s\n' "$*" >&2
+    exit 1
+}
+
+contains()
+{
+    case $1 in
+        *"$2"*) return 0 ;;
+        *) fail "expected output to contain: $2" ;;
+    esac
+}
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-dependencies.XXXXXX")
+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/fakebin"
+: > "$tmp/sps.conf"
+printf 'repo test %s 10\n' "$tmp/repo" > "$tmp/repos.conf"
+
+make_recipe()
+{
+    recipe_dir=$1
+    recipe_name=$2
+    recipe_version=$3
+    shift 3
+    {
+        printf 'name %s\n' "$recipe_name"
+        printf 'version %s\n' "$recipe_version"
+        printf '%s\n' 'release 1'
+        printf 'description Test package %s\n' "$recipe_name"
+        for recipe_record
+        do
+            printf '%s\n' "$recipe_record"
+        done
+        printf '%s\n' 'install true'
+    } > "$recipe_dir/recipe"
+}
+
+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'
+
+SPS_ROOT=$tmp/root
+SPS_DB=$tmp/db
+SPS_CACHE=$tmp/cache
+SPS_BUILD=$tmp/build
+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
+
+src=$project_dir/bin/src
+sget=$project_dir/bin/sget
+"$src" update >/dev/null
+
+tree=$("$sget" depends app)
+contains "$tree" 'app'
+contains "$tree" 'lib'
+contains "$tree" 'base'
+case $tree in
+    *tool*) fail 'ordinary depends output unexpectedly included build dependencies' ;;
+esac
+
+tree_with_build=$("$sget" depends app --build)
+contains "$tree_with_build" 'tool'
+
+plan=$("$sget" install --plan app)
+plan_names=$(printf '%s\n' "$plan" |
+    awk '/^  / { print $2 }')
+expected_names=$(printf '%s\n' base lib tool app)
+[ "$plan_names" = "$expected_names" ] ||
+    fail "unexpected dependency order: $plan_names"
+
+nodeps_plan=$("$sget" install --plan --nodeps app)
+nodeps_names=$(printf '%s\n' "$nodeps_plan" |
+    awk '/^  / { print $2 }')
+[ "$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'
+fi
+contains "$(sed -n '1,20p' "$tmp/broken.err")" "dependency 'unavailable'"
+
+if "$sget" install --plan cycle-a > "$tmp/cycle.out" 2> "$tmp/cycle.err"; then
+    fail 'dependency cycle unexpectedly resolved'
+fi
+contains "$(sed -n '1,20p' "$tmp/cycle.err")" 'dependency cycle:'
+
+[ "$(awk -v version_action=compare -v version_a=1.10 -v version_b=1.9 \
+    -f "$project_dir/lib/version.awk" /dev/null)" = 1 ] ||
+    fail 'numeric version comparison is incorrect'
+[ "$(awk -v version_action=compare -v version_a=1.0~rc1 -v version_b=1.0 \
+    -f "$project_dir/lib/version.awk" /dev/null)" = -1 ] ||
+    fail 'pre-release version comparison is incorrect'
+
+mkdir -p "$tmp/db/installed/base"
+{
+    printf 'format\t1\n'
+    printf 'name\tbase\n'
+    printf 'version\t0.9\n'
+    printf 'release\t1\n'
+    printf 'arch\t%s\n' "$(uname -m)"
+} >"$tmp/db/installed/base/meta"
+: >"$tmp/db/installed/base/files"
+: >"$tmp/db/installed/base/hashes"
+outdated_plan=$("$sget" install --plan app)
+case $outdated_plan in
+    *'base 1.0-1'*) ;;
+    *) fail 'outdated installed dependency was incorrectly skipped' ;;
+esac
+sed 's/version\t0.9/version\t1.0/' "$tmp/db/installed/base/meta" >"$tmp/base.meta"
+mv "$tmp/base.meta" "$tmp/db/installed/base/meta"
+skipped_plan=$("$sget" install --plan app)
+case $skipped_plan in
+    *'base 1.0-1'*) fail 'exact installed package was not skipped from the build plan' ;;
+esac
+if "$sget" install --plan base > "$tmp/explicit.out" \
+    2> "$tmp/explicit.err"; then
+    :
+else
+    fail 'planning an already-installed package failed'
+fi
+contains "$(cat "$tmp/explicit.out")" 'explicit'
+
+cat > "$tmp/fakebin/mkpkg" <<'EOF_FAKE_MKPKG'
+#!/bin/sh
+printf "mkpkg\t%s\n" "$PWD" >> "$TEST_LOG"
+name=$(awk '$1=="name" {print $2; exit}' recipe)
+version=$(awk '$1=="version" {print $2; exit}' recipe)
+release=$(awk '$1=="release" {print $2; exit}' recipe)
+arch=$(awk '$1=="arch" {print $2; exit}' recipe)
+[ -n "$arch" ] || arch=$(uname -m)
+work=$PWD/.fakepkg.$$
+mkdir -p "$work/.SPS"
+printf "format\t1\nname\t%s\nversion\t%s\nrelease\t%s\narch\t%s\n" \
+    "$name" "$version" "$release" "$arch" > "$work/.SPS/meta"
+: > "$work/.SPS/files"
+: > "$work/.SPS/hashes"
+artifact=$PWD/$name-$version-$release-$arch.pkg.tar
+tar -cf "$artifact" -C "$work" .
+rm -rf "$work"
+printf "%s\n" "$artifact"
+EOF_FAKE_MKPKG
+cat > "$tmp/fakebin/pkin" <<'EOF_FAKE_PKIN'
+#!/bin/sh
+printf "pkin\t%s\t%s\n" "$SPS_INSTALL_REASON" "$1" >> "$TEST_LOG"
+exit 0
+EOF_FAKE_PKIN
+chmod +x "$tmp/fakebin/mkpkg" "$tmp/fakebin/pkin"
+
+TEST_LOG=$tmp/install.log
+export TEST_LOG
+: > "$TEST_LOG"
+PATH="$tmp/fakebin:$PATH" "$sget" install app > "$tmp/install.out"
+
+[ "$(awk -F '	' '$1 == "mkpkg" { count++ } END { print count + 0 }' \
+    "$TEST_LOG")" = 3 ] ||
+    fail 'sget did not build exactly the unresolved packages'
+[ "$(awk -F '	' '$1 == "pkin" && $2 == "dependency" { count++ }
+                  END { print count + 0 }' "$TEST_LOG")" = 2 ] ||
+    fail 'dependencies were not passed to pkin with dependency reason'
+[ "$(awk -F '	' '$1 == "pkin" && $2 == "explicit" { count++ }
+                  END { print count + 0 }' "$TEST_LOG")" = 1 ] ||
+    fail 'requested package was not passed to pkin with explicit reason'
+
+old_log_lines=$(wc -l < "$TEST_LOG" | awk '{ print $1 }')
+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'
+
+printf '%s\n' 'dependency resolution and install orchestration tests passed'
diff --git a/tests/test_highlevel.sh b/tests/test_highlevel.sh
new file mode 100755
index 0000000..957c123
--- /dev/null
+++ b/tests/test_highlevel.sh
@@ -0,0 +1,122 @@
+#!/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
+fail() { printf 'test_highlevel: %s\n' "$*" >&2; exit 1; }
+contains() { case $1 in *"$2"*) : ;; *) fail "expected output to contain '$2'" ;; esac; }
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-highlevel.XXXXXX") || fail mktemp
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+root=$tmp/root; db=$tmp/db; cache=$tmp/cache; build=$tmp/build; repo=$tmp/repo
+mkdir -p "$root/etc/sps" "$db/installed" "$cache" "$build" "$repo/base" "$repo/lib" "$repo/app"
+: >"$db/owners"; : >"$db/world"; : >"$db/history"
+printf 'repo test %s 10\n' "$repo" >"$tmp/repos.conf"
+: >"$tmp/sps.conf"
+
+export SPS_ROOT=$root SPS_DB=$db SPS_CACHE=$cache SPS_BUILD=$build
+export SPS_CONFIG=$tmp/sps.conf SPS_REPOS_CONFIG=$tmp/repos.conf SPS_LIBDIR=$project_dir/lib
+PATH=$project_dir/bin:$PATH
+export PATH
+
+recipe()
+{
+    dir=$1 name=$2 version=$3 dep=${4-}
+    {
+        printf 'name %s\n' "$name"
+        printf 'version %s\n' "$version"
+        printf 'release 1\n'
+        printf 'arch any\n'
+        printf 'description high-level %s\n' "$name"
+        [ -z "$dep" ] || printf 'depend %s\n' "$dep"
+        printf 'install mkdir -p "$PKG/usr/share/sps-test"\n'
+        printf 'install printf "%%s\\n" "%s" > "$PKG/usr/share/sps-test/%s"\n' "$version" "$name"
+    } >"$dir/recipe"
+}
+
+recipe "$repo/base" base 1.0
+recipe "$repo/lib" lib 1.0 base
+recipe "$repo/app" app 1.0 lib
+src update >/dev/null
+sget install app >"$tmp/install.out"
+[ "$(cat "$root/usr/share/sps-test/base")" = 1.0 ] || fail 'base was not installed'
+[ "$(cat "$root/usr/share/sps-test/lib")" = 1.0 ] || fail 'lib was not installed'
+[ "$(cat "$root/usr/share/sps-test/app")" = 1.0 ] || fail 'app was not installed'
+grep -qx app "$db/world" || fail 'requested app was not explicit'
+! grep -qx base "$db/world" || fail 'dependency base was explicit'
+[ -z "$(pkstat --orphans)" ] || fail 'reachable dependencies were reported as orphans'
+
+[ "$(sget dependees base)" = lib ] || fail 'dependees query is wrong'
+why=$(sget why app base)
+[ "$why" = "$(printf 'app\nlib\nbase')" ] || fail "why path is wrong: $why"
+
+set +e
+sget remove --plan lib >"$tmp/remove-block.out" 2>"$tmp/remove-block.err"
+status=$?
+set -e
+[ "$status" -eq 5 ] || fail "unsafe high-level removal returned $status"
+contains "$(cat "$tmp/remove-block.err")" 'app requires lib'
+
+# Dependencies can move forward even when the explicit root stays at the same
+# version.
+recipe "$repo/base" base 2.0
+recipe "$repo/lib" lib 2.0 base
+src update >/dev/null
+plan=$(sget upgrade --plan)
+contains "$plan" 'upgrade    base 2.0-1'
+contains "$plan" 'upgrade    lib 2.0-1'
+sget upgrade >"$tmp/upgrade.out"
+[ "$(cat "$root/usr/share/sps-test/base")" = 2.0 ] || fail 'base was not upgraded'
+[ "$(cat "$root/usr/share/sps-test/lib")" = 2.0 ] || fail 'lib was not upgraded'
+
+# An exact reinstall should reuse the binary cache.
+reinstall=$(sget install --reinstall app)
+contains "$reinstall" 'using cached app-1.0-1-any.pkg.tar'
+
+# Do not trust the cache filename. A different valid package under this name
+# must be rejected before pkin sees it.
+app_cache=$(find "$cache/packages" -type f -name 'app-1.0-1-any.pkg.tar*' | head -1)
+base_cache=$(find "$cache/packages" -type f -name 'base-2.0-1-any.pkg.tar*' | head -1)
+[ -n "$app_cache" ] && [ -n "$base_cache" ] || fail 'expected binary cache artifacts are missing'
+cp "$app_cache" "$tmp/app-cache.good"
+cp "$base_cache" "$app_cache"
+set +e
+sget install --reinstall app >"$tmp/bad-cache.out" 2>"$tmp/bad-cache.err"
+status=$?
+set -e
+[ "$status" -eq 4 ] || fail "mismatched cached artifact returned $status"
+contains "$(cat "$tmp/bad-cache.err")" 'cached artifact identity is invalid'
+cp "$tmp/app-cache.good" "$app_cache"
+
+# Writers take the high-level transaction lock. Read-only plans still work
+# while that lock exists.
+mkdir "$db/.transaction"
+printf '%s\n' $$ >"$db/.transaction/pid"
+sget install --plan app >/dev/null || fail 'read-only plan was blocked by transaction lock'
+set +e
+sget install --reinstall app >"$tmp/tx.out" 2>"$tmp/tx.err"
+status=$?
+set -e
+[ "$status" -eq 11 ] || fail "concurrent transaction returned $status"
+contains "$(cat "$tmp/tx.err")" 'another high-level SPS transaction is active'
+rm -rf "$db/.transaction"
+
+sget remove app >/dev/null
+orphans=$(pkstat --orphans)
+[ "$orphans" = "$(printf 'base\nlib')" ] || fail "transitive orphan set is wrong: $orphans"
+
+# Asking for an installed dependency should just mark it explicit.
+promote=$(sget install base)
+contains "$promote" 'marked base as explicit'
+grep -qx base "$db/world" || fail 'exact dependency was not promoted to explicit'
+orphans=$(pkstat --orphans)
+[ "$orphans" = lib ] || fail "orphan reachability after promotion is wrong: $orphans"
+
+# When both are requested, remove the depender first.
+plan=$(sget remove --plan lib base)
+first=$(printf '%s\n' "$plan" | awk '/^  remove/ {print $2; exit}')
+[ "$first" = lib ] || fail "remove order did not put depender first: $plan"
+sget remove lib base >/dev/null
+[ -z "$(pkstat)" ] || fail 'packages remain after complete removal'
+
+printf '%s\n' 'test_highlevel: ok'
diff --git a/tests/test_install.sh b/tests/test_install.sh
new file mode 100755
index 0000000..046cd4a
--- /dev/null
+++ b/tests/test_install.sh
@@ -0,0 +1,69 @@
+#!/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
+
+fail()
+{
+    printf 'test_install: %s\n' "$*" >&2
+    exit 1
+}
+
+command -v make >/dev/null 2>&1 || {
+    printf '%s\n' 'test_install: skipped (make unavailable)'
+    exit 0
+}
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-test-install.XXXXXX") ||
+    fail 'cannot create temporary directory'
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+image=$tmp/image
+root=$tmp/root
+mkdir -p "$root/etc/sps"
+
+# Test the public make install targets, including config examples and man5.
+make -C "$project_dir" DESTDIR="$image" install install-config >/dev/null ||
+    fail 'make install/install-config failed'
+for required in \
+    "$image/usr/bin/src" \
+    "$image/usr/bin/sget" \
+    "$image/usr/bin/mkpkg" \
+    "$image/usr/bin/pkmark" \
+    "$image/usr/lib/sps/common.sh" \
+    "$image/usr/share/man/man1/sget.1" \
+    "$image/usr/share/man/man1/pkmark.1" \
+    "$image/usr/share/man/man5/sps.conf.5" \
+    "$image/etc/sps/sps.conf" \
+    "$image/etc/sps/repos.conf"
+do
+    [ -f "$required" ] || fail "installed file is missing: $required"
+done
+
+cp "$project_dir/examples/sps.conf" "$root/etc/sps/sps.conf"
+printf 'repo examples %s 100\n' "$project_dir/examples" >"$root/etc/sps/repos.conf"
+
+SPS_ROOT=$root
+SPS_COMPRESSION=none
+PATH=$image/usr/bin:$PATH
+export SPS_ROOT SPS_COMPRESSION PATH
+
+"$image/usr/bin/src" update >/dev/null || fail 'installed src update failed'
+plan=$("$image/usr/bin/sget" install --plan hello-sps) ||
+    fail 'installed sget plan failed'
+case $plan in
+    *'  hello-sps'*) ;;
+    *) fail 'installed sget plan omitted hello-sps' ;;
+esac
+"$image/usr/bin/sget" install hello-sps >/dev/null ||
+    fail 'installed sget/mkpkg/pkin pipeline failed'
+[ "$("$root/usr/bin/hello-sps")" = 'hello from SPS' ] ||
+    fail 'installed pipeline produced a broken payload'
+"$image/usr/bin/pkcheck" --all >/dev/null ||
+    fail 'installed pkcheck rejected a fresh installation'
+"$image/usr/bin/pkdel" hello-sps >/dev/null ||
+    fail 'installed pkdel failed'
+"$image/usr/bin/pkcheck" --database >/dev/null ||
+    fail 'database did not validate after installed-layout removal'
+
+printf '%s\n' 'test_install: ok'
diff --git a/tests/test_packages.sh b/tests/test_packages.sh
new file mode 100755
index 0000000..d875d12
--- /dev/null
+++ b/tests/test_packages.sh
@@ -0,0 +1,163 @@
+#!/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
+
+fail()
+{
+    printf 'test_packages: %s\n' "$*" >&2
+    exit 1
+}
+
+assert_status()
+{
+    expected_status=$1
+    shift
+    set +e
+    run_sps "$@" >"$tmp/stdout" 2>"$tmp/stderr"
+    actual_status=$?
+    set -e
+    [ "$actual_status" -eq "$expected_status" ] || {
+        sed 's/^/stdout: /' "$tmp/stdout" >&2
+        sed 's/^/stderr: /' "$tmp/stderr" >&2
+        fail "expected status $expected_status, got $actual_status from $*"
+    }
+}
+
+run_sps()
+{
+    SPS_ROOT=$root \
+    SPS_DB=$db \
+    SPS_CACHE=$cache \
+    SPS_BUILD=$build \
+    SPS_CONFIG=/dev/null \
+    SPS_REPOS_CONFIG=/dev/null \
+    SPS_LIBDIR=$project_dir/lib \
+    "$@"
+}
+
+make_package()
+{
+    package_name=$1
+    package_version=$2
+    package_path=$3
+    package_content=$4
+    package_depend=${5-}
+    package_extra_path=${6-}
+    package_extra_content=${7-}
+    package_stage=$tmp/stage-$package_name-$package_version
+    package_archive=$tmp/$package_name-$package_version.pkg.tar
+    rm -rf "$package_stage"
+    mkdir -p "$package_stage/.SPS" "$package_stage/$(dirname "$package_path")"
+    printf '%s\n' "$package_content" >"$package_stage/$package_path"
+    if [ -n "$package_extra_path" ]; then
+        mkdir -p "$package_stage/$(dirname "$package_extra_path")"
+        printf '%s\n' "$package_extra_content" >"$package_stage/$package_extra_path"
+    fi
+    {
+        printf 'format\t1\n'
+        printf 'name\t%s\n' "$package_name"
+        printf 'version\t%s\n' "$package_version"
+        printf 'release\t1\n'
+        printf 'arch\tany\n'
+        printf 'description\ttest package %s\n' "$package_name"
+        [ -z "$package_depend" ] || printf 'depend\t%s\n' "$package_depend"
+    } >"$package_stage/.SPS/meta"
+    : >"$package_stage/.SPS/files.unsorted"
+    (CDPATH= cd "$package_stage" && find . ! -name . ! -path './.SPS' ! -path './.SPS/*' -print) |
+    sed 's#^\./##' | while IFS= read -r package_entry; do
+        if [ -d "$package_stage/$package_entry" ] && [ ! -L "$package_stage/$package_entry" ]; then
+            printf '%s/\n' "$package_entry"
+        else
+            printf '%s\n' "$package_entry"
+        fi
+    done >"$package_stage/.SPS/files.unsorted"
+    LC_ALL=C sort "$package_stage/.SPS/files.unsorted" >"$package_stage/.SPS/files"
+    rm -f "$package_stage/.SPS/files.unsorted"
+    : >"$package_stage/.SPS/hashes"
+    while IFS= read -r package_entry || [ -n "$package_entry" ]; do
+        case $package_entry in */) continue ;; esac
+        if [ -f "$package_stage/$package_entry" ] && [ ! -L "$package_stage/$package_entry" ]; then
+            package_hash=$(sha256sum "$package_stage/$package_entry" | awk '{ print $1 }')
+            printf 'sha256\t%s\t%s\n' "$package_hash" "$package_entry" >>"$package_stage/.SPS/hashes"
+        fi
+    done <"$package_stage/.SPS/files"
+    tar -C "$package_stage" -cf "$package_archive" .
+    printf '%s\n' "$package_archive"
+}
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-test-packages.XXXXXX") ||
+    fail 'cannot create temporary directory'
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+root=$tmp/root
+db=$tmp/db
+cache=$tmp/cache
+build=$tmp/build
+mkdir -p "$root" "$cache" "$build"
+
+lib_archive=$(make_package libfoo 1.0 usr/lib/libfoo.so 'library version one')
+demo_v1=$(make_package demo 1.0 usr/bin/demo 'demo version one' libfoo \
+    usr/share/demo/obsolete 'remove on upgrade')
+
+run_sps "$project_dir/bin/pkin" --dependency "$lib_archive" >"$tmp/install-lib.out"
+run_sps "$project_dir/bin/pkin" "$demo_v1" >"$tmp/install-demo.out"
+[ "$(cat "$root/usr/bin/demo")" = 'demo version one' ] || fail 'initial payload was not installed'
+[ -f "$root/usr/share/demo/obsolete" ] || fail 'initial secondary payload is missing'
+grep -qx demo "$db/world" || fail 'explicit package was not added to world'
+! grep -qx libfoo "$db/world" || fail 'dependency package was added to world'
+grep -qx 'usr/bin/demo	demo' "$db/owners" || fail 'file owner was not recorded'
+
+owner_output=$(run_sps "$project_dir/bin/pkstat" --owns /usr/bin/demo)
+case $owner_output in 'demo 1.0-1 any') ;; *) fail "unexpected owner output: $owner_output" ;; esac
+run_sps "$project_dir/bin/pkstat" --files demo | grep -qx '/usr/bin/demo' ||
+    fail 'pkstat --files omitted the payload'
+run_sps "$project_dir/bin/pkstat" --explicit | grep -q '^demo ' ||
+    fail 'pkstat --explicit omitted demo'
+orphans=$(run_sps "$project_dir/bin/pkstat" --orphans)
+[ -z "$orphans" ] || fail "dependency in use was reported as orphan: $orphans"
+run_sps "$project_dir/bin/pkstat" --size demo | grep -q '^demo [1-9][0-9]*$' ||
+    fail 'pkstat --size did not report bytes'
+run_sps "$project_dir/bin/pkcheck" --all >"$tmp/check-clean.out" ||
+    fail 'freshly installed packages did not verify'
+
+rival_archive=$(make_package rival 1.0 usr/bin/demo 'collision')
+assert_status 7 "$project_dir/bin/pkin" "$rival_archive"
+[ "$(cat "$root/usr/bin/demo")" = 'demo version one' ] ||
+    fail 'conflict attempt changed the installed file'
+[ ! -d "$db/installed/rival" ] || fail 'conflicting package received a database record'
+
+demo_v2=$(make_package demo 2.0 usr/bin/demo 'demo version two' libfoo)
+run_sps "$project_dir/bin/pkin" "$demo_v2" >"$tmp/upgrade.out"
+[ "$(cat "$root/usr/bin/demo")" = 'demo version two' ] || fail 'upgrade did not replace payload'
+[ ! -e "$root/usr/share/demo/obsolete" ] || fail 'upgrade did not remove obsolete payload'
+grep -q 'upgrade demo 1.0 2.0' "$db/history" || fail 'upgrade was not recorded in history'
+for db_temporary in "$db"/.*-base.* "$db"/.*-new.*; do
+    if [ -f "$db_temporary" ] || [ -d "$db_temporary" ] || [ -L "$db_temporary" ]; then
+        fail "successful install left database temporary file ${db_temporary##*/}"
+    fi
+done
+
+printf '%s\n' 'locally modified' >"$root/usr/bin/demo"
+assert_status 1 "$project_dir/bin/pkcheck" --modified demo
+grep -q '^modified /usr/bin/demo demo' "$tmp/stdout" || fail 'modified-file diagnostic is unclear'
+rm -f "$root/usr/bin/demo"
+assert_status 1 "$project_dir/bin/pkcheck" --missing demo
+grep -q '^missing /usr/bin/demo demo' "$tmp/stdout" || fail 'missing-file diagnostic is unclear'
+run_sps "$project_dir/bin/pkin" "$demo_v2" >/dev/null
+
+run_sps "$project_dir/bin/pkdel" --plan demo >"$tmp/remove-plan"
+[ -f "$root/usr/bin/demo" ] || fail 'remove plan changed the target root'
+grep -q '^remove: /usr/bin/demo$' "$tmp/remove-plan" || fail 'remove plan omitted payload'
+run_sps "$project_dir/bin/pkdel" demo >"$tmp/remove.out"
+[ ! -e "$root/usr/bin/demo" ] || fail 'pkdel left package payload behind'
+[ ! -d "$db/installed/demo" ] || fail 'pkdel left installed metadata behind'
+! grep -q 'demo$' "$db/owners" || fail 'pkdel left owner records behind'
+orphans=$(run_sps "$project_dir/bin/pkstat" --orphans)
+[ "$orphans" = libfoo ] || fail "expected libfoo orphan, got '$orphans'"
+
+run_sps "$project_dir/bin/pkdel" libfoo >/dev/null
+run_sps "$project_dir/bin/pkcheck" --database | grep -qx 'database: ok' ||
+    fail 'empty package database did not validate'
+
+printf '%s\n' 'test_packages: ok'
diff --git a/tests/test_release.sh b/tests/test_release.sh
new file mode 100755
index 0000000..b588d58
--- /dev/null
+++ b/tests/test_release.sh
@@ -0,0 +1,152 @@
+#!/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
+fail() { printf 'test_release: %s\n' "$*" >&2; exit 1; }
+
+[ "$(cat "$project_dir/VERSION")" = 1.0.0 ] || fail 'VERSION is not 1.0.0'
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-release.XXXXXX") || fail 'mktemp failed'
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+root=$tmp/root
+db=$tmp/db
+cache=$tmp/cache
+build=$tmp/build
+mkdir -p "$root" "$cache" "$build" "$tmp/pkg"
+
+run()
+{
+    SPS_ROOT=$root SPS_DB=$db SPS_CACHE=$cache SPS_BUILD=$build \
+    SPS_CONFIG=/dev/null SPS_REPOS_CONFIG=/dev/null SPS_PRESERVE=etc \
+    SPS_LIBDIR=$project_dir/lib "$@"
+}
+
+make_recipe_pkg()
+{
+    dir=$1 name=$2 version=$3 arch=$4 config_text=$5
+    mkdir -p "$dir"
+    cat >"$dir/recipe" <<EOF_RECIPE
+name $name
+version $version
+release 1
+arch $arch
+description release regression package
+install mkdir -p "\$PKG/etc" "\$PKG/var/lib/$name"
+install printf '%s\\n' '$config_text' > "\$PKG/etc/$name.conf"
+install printf '%s\\n' '$version' > "\$PKG/var/lib/$name/data"
+install chmod 0700 "\$PKG/var/lib/$name"
+EOF_RECIPE
+    run "$project_dir/bin/mkpkg" --compression none --output "$tmp/pkg" "$dir/recipe"
+}
+
+recipe=$tmp/recipe
+artifact1=$(make_recipe_pkg "$recipe" relpkg 1.0 any default-v1)
+run "$project_dir/bin/pkin" "$artifact1" >/dev/null
+[ "$(cat "$root/etc/relpkg.conf")" = default-v1 ] || fail 'initial config missing'
+mode=$(LC_ALL=C ls -ld "$root/var/lib/relpkg" | awk '{ print substr($1,1,10) }')
+[ "$mode" = drwx------ ] || fail "directory mode not preserved: $mode"
+
+# Package reason can change without reinstalling the package.
+run "$project_dir/bin/pkmark" dependency relpkg >/dev/null
+! grep -qx relpkg "$db/world" || fail 'pkmark dependency did not clear world entry'
+run "$project_dir/bin/pkmark" explicit relpkg >/dev/null
+grep -qx relpkg "$db/world" || fail 'pkmark explicit did not add world entry'
+
+# A dead PID lock must be recoverable.
+mkdir "$db/.lock"
+printf '%s\n' 99999999 >"$db/.lock/pid"
+run "$project_dir/bin/pkmark" dependency relpkg >"$tmp/stale.out" 2>"$tmp/stale.err" ||
+    fail 'stale database lock was not recovered'
+grep -q 'stale package database lock' "$tmp/stale.err" || fail 'stale-lock recovery was silent'
+run "$project_dir/bin/pkmark" explicit relpkg >/dev/null
+
+# Keep a locally changed protected file. If the package default changed too,
+# leave the new copy next to it.
+printf '%s\n' local-admin-value >"$root/etc/relpkg.conf"
+artifact2=$(make_recipe_pkg "$recipe" relpkg 2.0 any default-v2)
+run "$project_dir/bin/pkin" "$artifact2" >"$tmp/upgrade.out" 2>"$tmp/upgrade.err"
+[ "$(cat "$root/etc/relpkg.conf")" = local-admin-value ] || fail 'upgrade overwrote protected config'
+[ "$(cat "$root/etc/relpkg.conf.sps-new")" = default-v2 ] || fail 'new config was not emitted as .sps-new'
+run "$project_dir/bin/pkcheck" --modified relpkg >"$tmp/modified.out" 2>/dev/null || status=$?
+[ "${status:-0}" -eq 1 ] || fail 'pkcheck did not report preserved config as modified'
+
+# Hooks are files beside the recipe and run with SPS_ROOT set.
+mkdir -p "$recipe/hooks"
+for hook in pre-install post-install pre-remove post-remove; do
+    cat >"$recipe/hooks/$hook" <<EOF_HOOK
+printf '%s\\n' '$hook' >> "\$SPS_ROOT/hook.log"
+EOF_HOOK
+done
+sed 's/version 2.0/version 3.0/' "$recipe/recipe" >"$recipe/recipe.new"
+mv "$recipe/recipe.new" "$recipe/recipe"
+artifact3=$(run "$project_dir/bin/mkpkg" --compression none --output "$tmp/pkg" "$recipe/recipe")
+run "$project_dir/bin/pkin" "$artifact3" >/dev/null
+[ "$(sed -n '1p' "$root/hook.log")" = pre-install ] || fail 'pre-install hook did not run'
+[ "$(sed -n '2p' "$root/hook.log")" = post-install ] || fail 'post-install hook did not run'
+run "$project_dir/bin/pkdel" relpkg >/dev/null
+[ "$(sed -n '3p' "$root/hook.log")" = pre-remove ] || fail 'pre-remove hook did not run'
+[ "$(sed -n '4p' "$root/hook.log")" = post-remove ] || fail 'post-remove hook did not run'
+[ "$(cat "$root/etc/relpkg.conf")" = local-admin-value ] || fail 'remove deleted modified protected config'
+
+# A root install must not inherit the builder's uid/gid. This check only runs
+# when the test suite itself is root.
+if [ "$(id -u)" -eq 0 ]; then
+    owner_dir=$tmp/owner
+    owner_artifact=$(make_recipe_pkg "$owner_dir" ownerpkg 1.0 any owner-default)
+    unpack=$tmp/owner-unpack
+    mkdir -p "$unpack"
+    tar -xf "$owner_artifact" -C "$unpack"
+    chown 1234:1234 "$unpack/var/lib/ownerpkg/data"
+    chmod 6751 "$unpack/var/lib/ownerpkg/data"
+    repacked=$tmp/pkg/ownerpkg-1.0-1-any-repacked.pkg.tar
+    tar -cf "$repacked" -C "$unpack" .
+    run "$project_dir/bin/pkin" "$repacked" >/dev/null
+    set -- $(LC_ALL=C ls -ln "$root/var/lib/ownerpkg/data")
+    [ "$3" = 0 ] && [ "$4" = 0 ] || fail "root install preserved builder uid/gid: $3:$4"
+    case $1 in -rwsr-s--x*) ;; *) fail "setuid/setgid file mode was not preserved: $1" ;; esac
+    run "$project_dir/bin/pkdel" ownerpkg >/dev/null
+fi
+
+# If a post hook fails, the already-committed package state and history must
+# still tell the truth.
+hookfail_dir=$tmp/hookfail
+mkdir -p "$hookfail_dir/hooks"
+cat >"$hookfail_dir/recipe" <<'EOF_HOOKFAIL_RECIPE'
+name hookfail
+version 1.0
+release 1
+arch any
+description post hook transaction regression
+install mkdir -p "$PKG/usr/share"
+install printf '%s\n' hookfail > "$PKG/usr/share/hookfail"
+EOF_HOOKFAIL_RECIPE
+printf '%s\n' 'exit 1' >"$hookfail_dir/hooks/post-install"
+printf '%s\n' 'exit 1' >"$hookfail_dir/hooks/post-remove"
+hookfail_artifact=$(run "$project_dir/bin/mkpkg" --compression none --output "$tmp/pkg" "$hookfail_dir/recipe")
+set +e
+run "$project_dir/bin/pkin" "$hookfail_artifact" >/dev/null 2>"$tmp/hookfail-install.err"
+status=$?
+set -e
+[ "$status" -eq 10 ] || fail "failing post-install hook returned $status"
+[ -d "$db/installed/hookfail" ] || fail 'post-install failure rolled back committed package metadata'
+grep -q ' install hookfail 1.0-1$' "$db/history" || fail 'post-install failure omitted committed install history'
+set +e
+run "$project_dir/bin/pkdel" hookfail >/dev/null 2>"$tmp/hookfail-remove.err"
+status=$?
+set -e
+[ "$status" -eq 10 ] || fail "failing post-remove hook returned $status"
+[ ! -d "$db/installed/hookfail" ] || fail 'post-remove failure restored a removed package'
+grep -q ' remove hookfail 1.0$' "$db/history" || fail 'post-remove failure omitted committed removal history'
+
+# Reject a foreign architecture before writing payload state.
+foreign_dir=$tmp/foreign
+foreign=$(make_recipe_pkg "$foreign_dir" foreign 1.0 definitely_foreign default)
+set +e
+run "$project_dir/bin/pkin" "$foreign" >"$tmp/foreign.out" 2>"$tmp/foreign.err"
+status=$?
+set -e
+[ "$status" -eq 4 ] || fail "foreign architecture returned $status instead of 4"
+[ ! -e "$root/var/lib/foreign/data" ] || fail 'foreign architecture payload was installed'
+
+printf '%s\n' 'test_release: ok'
diff --git a/tests/test_repository.sh b/tests/test_repository.sh
new file mode 100755
index 0000000..9b1ad1a
--- /dev/null
+++ b/tests/test_repository.sh
@@ -0,0 +1,137 @@
+#!/bin/sh
+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()
+{
+    printf 'test_repository: %s\n' "$*" >&2
+    exit 1
+}
+
+contains()
+{
+    case $1 in
+        *"$2"*) return 0 ;;
+        *) fail "expected output to contain: $2" ;;
+    esac
+}
+
+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"
+
+{
+    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 'repo later %s 10\n' "$tmp/later"
+} > "$tmp/repos.conf"
+
+SPS_ROOT=$tmp/root
+SPS_DB=$tmp/db
+SPS_CACHE=$tmp/cache
+SPS_BUILD=$tmp/build
+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
+
+src=$project_dir/bin/src
+update_output=$("$src" update)
+contains "$update_output" 'updated 3 repositories, 5 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'
+
+alpha_raw=$("$src" show alpha --raw)
+[ "$(printf '%s\n' "$alpha_raw" | awk -F '	' '{ print $8 }')" = extra ] ||
+    fail 'higher-priority repository did not win'
+[ "$(printf '%s\n' "$alpha_raw" | awk -F '	' '{ print $2 "-" $3 }')" = 2.0-3 ] ||
+    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'
+
+search_output=$("$src" search 'LOCAL REPOSITORY')
+contains "$search_output" 'alpha'
+contains "$search_output" 'extra'
+
+show_output=$("$src" show alpha)
+contains "$show_output" 'description: Alpha override'
+contains "$show_output" 'repository: extra'
+
+which_output=$("$src" which alpha)
+contains "$which_output" 'selected: extra'
+contains "$which_output" 'also available:'
+contains "$which_output" 'core'
+
+list_output=$("$src" list)
+contains "$list_output" 'core'
+contains "$list_output" 'priority 20'
+status_output=$("$src" status)
+contains "$status_output" 'core: 3 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'
+fi
+contains "$(sed -n '1,20p' "$tmp/remote.err")" 'unsupported remote location'
+[ "$(cksum "$index")" = "$old_index" ] ||
+    fail 'a failed update replaced the previous aggregate index'
+
+printf '%s\n' 'repository and query tests passed'
diff --git a/tests/test_security.sh b/tests/test_security.sh
new file mode 100755
index 0000000..ca424fe
--- /dev/null
+++ b/tests/test_security.sh
@@ -0,0 +1,226 @@
+#!/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
+
+fail()
+{
+    printf 'test_security: %s\n' "$*" >&2
+    exit 1
+}
+
+run_sps()
+{
+    SPS_ROOT=$root \
+    SPS_DB=$db \
+    SPS_CACHE=$cache \
+    SPS_BUILD=$build \
+    SPS_CONFIG=/dev/null \
+    SPS_REPOS_CONFIG=/dev/null \
+    SPS_LIBDIR=$project_dir/lib \
+    "$@"
+}
+
+expect_status()
+{
+    expected_status=$1
+    shift
+    set +e
+    run_sps "$@" >"$tmp/stdout" 2>"$tmp/stderr"
+    actual_status=$?
+    set -e
+    [ "$actual_status" -eq "$expected_status" ] || {
+        sed 's/^/stdout: /' "$tmp/stdout" >&2
+        sed 's/^/stderr: /' "$tmp/stderr" >&2
+        fail "expected status $expected_status, got $actual_status from $*"
+    }
+}
+
+fresh_root()
+{
+    test_number=$((test_number + 1))
+    root=$tmp/root-$test_number
+    db=$tmp/db-$test_number
+    cache=$tmp/cache-$test_number
+    build=$tmp/build-$test_number
+    mkdir -p "$root" "$cache" "$build"
+}
+
+make_basic()
+{
+    package_name=$1
+    package_version=$2
+    package_path=${3:-usr/bin/$package_name}
+    package_stage=$tmp/stage-$package_name-$package_version
+    package_archive=$tmp/$package_name-$package_version.pkg.tar
+    rm -rf "$package_stage"
+    mkdir -p "$package_stage/.SPS" "$package_stage/$(dirname "$package_path")"
+    printf '%s\n' "$package_name $package_version payload" >"$package_stage/$package_path"
+    {
+        printf 'format\t1\n'
+        printf 'name\t%s\n' "$package_name"
+        printf 'version\t%s\n' "$package_version"
+        printf 'release\t1\n'
+        printf 'arch\tany\n'
+    } >"$package_stage/.SPS/meta"
+    : >"$package_stage/.SPS/files.tmp"
+    (CDPATH= cd "$package_stage" && find . ! -name . ! -path './.SPS' ! -path './.SPS/*' -print) |
+    sed 's#^\./##' | while IFS= read -r package_entry; do
+        if [ -d "$package_stage/$package_entry" ] && [ ! -L "$package_stage/$package_entry" ]; then
+            printf '%s/\n' "$package_entry"
+        else
+            printf '%s\n' "$package_entry"
+        fi
+    done >"$package_stage/.SPS/files.tmp"
+    LC_ALL=C sort "$package_stage/.SPS/files.tmp" >"$package_stage/.SPS/files"
+    rm -f "$package_stage/.SPS/files.tmp"
+    package_hash=$(sha256sum "$package_stage/$package_path" | awk '{ print $1 }')
+    printf 'sha256\t%s\t%s\n' "$package_hash" "$package_path" >"$package_stage/.SPS/hashes"
+    tar -C "$package_stage" -cf "$package_archive" .
+    printf '%s\n' "$package_archive"
+}
+
+repack()
+{
+    repack_stage=$1
+    repack_archive=$2
+    tar -C "$repack_stage" -cf "$repack_archive" .
+}
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-test-security.XXXXXX") ||
+    fail 'cannot create temporary directory'
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+test_number=0
+
+# Reject traversal in the manifest before touching the root or database.
+fresh_root
+archive=$(make_basic traversal 1.0)
+stage=$tmp/stage-traversal-1.0
+printf '%s\n' '../escape' >"$stage/.SPS/files"
+: >"$stage/.SPS/hashes"
+repack "$stage" "$archive"
+expect_status 4 "$project_dir/bin/pkin" "$archive"
+[ ! -f "$tmp/escape" ] || fail 'manifest traversal escaped the fake root'
+[ ! -d "$db/installed/traversal" ] || fail 'unsafe manifest was recorded as installed'
+
+# Build a real ../ archive member. Most tar tools refuse to create one, so make
+# a normal header, patch the pathname in place, and recompute the ustar checksum.
+# This keeps the fixture portable across tar implementations.
+fresh_root
+mkdir -p "$tmp/raw-traversal/aa"
+printf '%s\n' sentinel >"$tmp/raw-traversal/aa/escape"
+tar -C "$tmp/raw-traversal" -cf "$tmp/raw-traversal.pkg.tar" aa/escape ||
+    fail 'test tar could not construct base traversal fixture'
+printf '%s' '../escape' | dd of="$tmp/raw-traversal.pkg.tar" bs=1 seek=0 conv=notrunc 2>/dev/null ||
+    fail 'cannot patch traversal archive member'
+printf '        ' | dd of="$tmp/raw-traversal.pkg.tar" bs=1 seek=148 conv=notrunc 2>/dev/null ||
+    fail 'cannot clear traversal header checksum'
+header_sum=$(od -An -tu1 -N512 "$tmp/raw-traversal.pkg.tar" |
+    awk '{ for (i = 1; i <= NF; i++) sum += $i } END { print sum + 0 }')
+# ustar checksum field: six octal digits, NUL, space.
+printf '%06o\0 ' "$header_sum" |
+    dd of="$tmp/raw-traversal.pkg.tar" bs=1 seek=148 conv=notrunc 2>/dev/null ||
+    fail 'cannot write traversal header checksum'
+[ "$(dd if="$tmp/raw-traversal.pkg.tar" bs=1 count=9 2>/dev/null)" = '../escape' ] ||
+    fail 'constructed traversal archive header is incorrect'
+expect_status 4 "$project_dir/bin/pkin" "$tmp/raw-traversal.pkg.tar"
+[ "$(cat "$tmp/raw-traversal/aa/escape")" = sentinel ] || fail 'archive traversal changed its source sentinel'
+
+# Required identity fields must be valid.
+fresh_root
+archive=$(make_basic badmeta 1.0)
+stage=$tmp/stage-badmeta-1.0
+awk '$1 != "version"' "$stage/.SPS/meta" >"$stage/.SPS/meta.new"
+mv "$stage/.SPS/meta.new" "$stage/.SPS/meta"
+repack "$stage" "$archive"
+expect_status 4 "$project_dir/bin/pkin" "$archive"
+
+# A well-formed but wrong digest is a checksum failure, not a format failure.
+fresh_root
+archive=$(make_basic badsum 1.0)
+stage=$tmp/stage-badsum-1.0
+printf 'sha256\t%s\tusr/bin/badsum\n' \
+    0000000000000000000000000000000000000000000000000000000000000000 \
+    >"$stage/.SPS/hashes"
+repack "$stage" "$archive"
+expect_status 6 "$project_dir/bin/pkin" "$archive"
+[ ! -f "$root/usr/bin/badsum" ] || fail 'checksum failure installed payload'
+
+# Old-style executable control members are not part of the package format.
+fresh_root
+archive=$(make_basic hook 1.0)
+stage=$tmp/stage-hook-1.0
+printf '%s\n' 'exit 0' >"$stage/.SPS/install"
+repack "$stage" "$archive"
+expect_status 4 "$project_dir/bin/pkin" "$archive"
+grep -q 'unsupported package control member' "$tmp/stderr" ||
+    fail 'hook rejection diagnostic is unclear'
+
+# Unowned existing files and symlink ancestors are conflicts.
+fresh_root
+archive=$(make_basic occupied 1.0)
+mkdir -p "$root/usr/bin"
+printf '%s\n' administrator >"$root/usr/bin/occupied"
+expect_status 7 "$project_dir/bin/pkin" "$archive"
+[ "$(cat "$root/usr/bin/occupied")" = administrator ] || fail 'unowned collision was overwritten'
+
+fresh_root
+archive=$(make_basic redirected 1.0)
+outside=$tmp/outside-target
+mkdir -p "$outside/bin"
+ln -s "$outside" "$root/usr"
+expect_status 7 "$project_dir/bin/pkin" "$archive"
+[ ! -f "$outside/bin/redirected" ] || fail 'target symlink redirected package payload'
+
+# Never follow a symlink where an installed database control file should be.
+fresh_root
+archive=$(make_basic dbtrap 1.0)
+mkdir -p "$db/installed"
+printf '%s\n' keep >"$tmp/db-victim"
+ln -s "$tmp/db-victim" "$db/owners"
+: >"$db/world"
+: >"$db/history"
+expect_status 11 "$project_dir/bin/pkin" "$archive"
+[ "$(cat "$tmp/db-victim")" = keep ] || fail 'symlinked database control was modified'
+
+# Package control metadata must be a regular file in the archive.
+fresh_root
+archive=$(make_basic controlink 1.0)
+stage=$tmp/stage-controlink-1.0
+cp "$stage/.SPS/meta" "$tmp/controlink-meta"
+rm -f "$stage/.SPS/meta"
+ln -s "$tmp/controlink-meta" "$stage/.SPS/meta"
+repack "$stage" "$archive"
+expect_status 4 "$project_dir/bin/pkin" "$archive"
+
+# A bad installed manifest must stop an upgrade before payload replacement.
+fresh_root
+archive_v1=$(make_basic guarded 1.0)
+run_sps "$project_dir/bin/pkin" "$archive_v1" >/dev/null
+archive_v2=$(make_basic guarded 2.0)
+printf '%s\n' '../../victim' >"$db/installed/guarded/files"
+expect_status 11 "$project_dir/bin/pkin" "$archive_v2"
+[ "$(cat "$root/usr/bin/guarded")" = 'guarded 1.0 payload' ] ||
+    fail 'malformed old record allowed payload replacement'
+
+# Payload symlinks are allowed, but no later payload entry may live below one.
+# Some tar implementations catch this first; pkin must catch it either way.
+fresh_root
+stage=$tmp/stage-linkparent
+archive=$tmp/linkparent.pkg.tar
+outside=$tmp/linkparent-outside
+mkdir -p "$stage/.SPS" "$outside"
+printf '%s\n' sentinel >"$outside/pwn"
+ln -s "$outside" "$stage/link"
+{
+    printf 'name\tlinkparent\nversion\t1.0\nrelease\t1\narch\tany\n'
+} >"$stage/.SPS/meta"
+printf 'link\nlink/pwn\n' >"$stage/.SPS/files"
+pwn_hash=$(sha256sum "$outside/pwn" | awk '{ print $1 }')
+printf 'sha256\t%s\tlink/pwn\n' "$pwn_hash" >"$stage/.SPS/hashes"
+tar -C "$stage" -cf "$archive" .SPS link link/pwn
+expect_status 4 "$project_dir/bin/pkin" "$archive"
+[ "$(cat "$outside/pwn")" = sentinel ] || fail 'archive symlink parent escaped extraction staging'
+
+printf '%s\n' 'test_security: ok'