SPS · log · files · refs · tags · releases · people · languages · GitHub · commit
0137cde
first commit
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'