summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.txt8
-rw-r--r--docs/CHAT-PRODUCTION.txt4
-rw-r--r--docs/GUIX.txt207
-rw-r--r--docs/MAP.txt29
-rw-r--r--docs/OPERATIONS.txt7
-rw-r--r--docs/POLICY.txt4
-rw-r--r--infra/guix/bootstrap.sh86
-rw-r--r--infra/guix/build.sh37
-rw-r--r--infra/guix/bundle.sh56
-rw-r--r--infra/guix/containers.conf24
-rw-r--r--infra/guix/manifest.scm19
-rw-r--r--infra/guix/restore.sh90
-rw-r--r--infra/guix/run.sh45
-rw-r--r--infra/guix/test-bootstrap.sh118
-rw-r--r--infra/guix/test-recovery.sh133
-rw-r--r--infra/guix/test.sh166
-rw-r--r--infra/stack.service4
-rw-r--r--src/app.rs44
-rw-r--r--src/app/archive.rs263
-rw-r--r--src/app/cli.rs21
-rw-r--r--src/app/files.rs77
-rw-r--r--src/app/images.rs29
-rw-r--r--src/app/release.rs2
23 files changed, 1427 insertions, 46 deletions
diff --git a/README.txt b/README.txt
index c680f43..ab5a3bd 100644
--- a/README.txt
+++ b/README.txt
@@ -22,7 +22,7 @@ STACK
START LOCAL
- Linux tools: rustup, Podman, Git, GnuPG, Node >=22.18.0, npm.
+ Linux tools: rustup, Podman, Git, GnuPG, Node >=22.18.0, npm, bubblewrap.
Native chat checks: GCC-compatible C compiler, Linux UAPI headers, libclang,
pkg-config, make, Perl, curl, tar, OpenSSL >=3.5 development libraries.
Pinned Rust includes rustfmt, Clippy, static x86_64 Linux target.
@@ -75,3 +75,9 @@ CHAT — EXPERIMENTAL
Packaged HTTPS, owner setup, recovery: docs/CHAT-PRODUCTION.txt.
SQLite snapshots use bundled SQLite C library. Static releases use OpenSSL,
hash-verified musl headers, Rust musl target. VPS needs no compiler.
+
+GUIX — EXPERIMENTAL
+
+ Single .tar.gz capsule with encrypted private state: docs/GUIX.txt.
+ Canonical Guix store needs privileged bootstrap; existing managed stores refused.
+ Real Guix lifecycle/clean-host restore remain unproven. Do not deploy yet.
diff --git a/docs/CHAT-PRODUCTION.txt b/docs/CHAT-PRODUCTION.txt
index fdb48a6..2507e07 100644
--- a/docs/CHAT-PRODUCTION.txt
+++ b/docs/CHAT-PRODUCTION.txt
@@ -106,8 +106,8 @@ DEPLOY AND CREATE THE REAL OWNER FILE
firewall rules. Caddy obtains and renews public certificates automatically.
Public certificate validation requires standard reachable ACME ports.
- The systemd user service runs ./bin/stack up --production at boot and
- ./bin/stack down on stop. Podman restarts
+ The supplied systemd user service uses ./bin/run for up --production and down;
+ the launcher selects an included Guix runtime when present. Podman restarts
failed containers. Complete matching-architecture releases need no compilation
or registry pull. Check all four services after activation:
diff --git a/docs/GUIX.txt b/docs/GUIX.txt
new file mode 100644
index 0000000..05aa572
--- /dev/null
+++ b/docs/GUIX.txt
@@ -0,0 +1,207 @@
+Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+GUIX RECOVERY
+
+ Goal: one .tar.gz, unpack/bootstrap, one command restores and starts services.
+ Private state is GPG-encrypted inside the tarball. Bootstrap, binaries and
+ public release/source are plaintext. Outer tarball is not encrypted or signed.
+ Trust its origin; SHA256SUMS detects corruption, not malicious replacement.
+ Keep decryption key/passphrase separately; never send them through chat.
+
+ Guix supplies Podman/tools. Existing Caddy/cgit/SearXNG/chat OCI applications,
+ static musl/OpenSSL builds, domains, HTTPS/WKD and crypto remain.
+ No new Rust/npm dependency, Guix System install, or native service rewrite.
+ One root .env and public .env.example; databases/TLS/owner/history: var/data.
+
+BUILD LOCAL
+
+ sh infra/guix/build.sh
+ cargo run --release --locked -- release
+
+ Needs working Guix daemon, x86_64 Linux and rootless Podman prerequisites.
+ Channel commits: bin/guix-channels.scm. time-machine builds the manifest's
+ bin/guix-runtime.tar.gz. Release runs full checks and packages matching source,
+ binaries, browser assets and saved images. No compilation on recovery host.
+ Podman info smoke test is not clean-host/container lifecycle proof.
+
+ Ordinary pack uses canonical /gnu/store. Former -RR wrapper creates one-ID
+ user namespace; privileged helpers cannot map host subordinate IDs there.
+ Builder's canonical store could bypass relocation and hide this defect.
+ https://guix.gnu.org/manual/devel/en/html_node/Invoking-guix-pack.html
+ https://guix.gnu.org/manual/devel/en/html_node/Replicating-Guix.html
+ https://raw.githubusercontent.com/containers/podman/main/docs/tutorials/rootless_tutorial.md
+
+MAKE CARRY FILE
+
+ Production: create consistent encrypted export; copy and verify it locally:
+
+ ./bin/stack backup --include-images
+
+ Bundle a trusted freshly checked release with that export:
+
+ sh infra/guix/bundle.sh /path/backup.tar.gz.gpg /path/release.tar.gz /path/recovery.tar.gz
+
+ Bundler executes the trusted release's CLI to validate it with restore
+ --release. Backup-kind archives, private .env/var/data, missing runtime/chat
+ artifacts and missing saved images are refused before publication.
+ CLI, scripts, config, runtime and provenance all come from the same release.
+ Original release archive is unchanged and includes corresponding source and
+ LICENSE. Original encrypted backup retains prior source/work and rollback.
+ Runtime is duplicated outside release for bootstrap simplicity; extra disk
+ space avoids another installer/format. One carry file remains sufficient.
+
+ Old backups remain supported; release must implement this migration's
+ --release validation and runtime export. Blindly reusing old management binary
+ loses Guix files on the next backup. Restore now activates matching new source
+ and binary, including canonical infra/guix/run.sh needed by later backups.
+
+BOOTSTRAP HOST; RESTORE
+
+ Fresh x86_64 Linux: trusted shell, GNU coreutils/tar/gzip/findutils/diffutils,
+ awk, util-linux flock, sudo/root access. Bootstrap refuses /var/guix and foreign
+ /gnu trees. Installs only /gnu/store entries; never host /bin or /etc links.
+
+ tar -xzf recovery.tar.gz
+ sudo ./stack-recovery/bootstrap
+
+ Root-owned non-writable closure entries are staged on /gnu's filesystem,
+ published without overwriting, then recorded by pack digest. Retry reconciles
+ unfinished installs; later packs retain old entries. Root install records and
+ lock: /gnu/.stack-runtimes. Target needs no Guix daemon/downloads. Existing
+ managed Guix hosts need separate planning; do not merge unmanaged store items.
+
+ Administrator also supplies privileged newuidmap/newgidmap, >=65536 subordinate
+ UIDs/GIDs, user namespaces, writable login XDG_RUNTIME_DIR, low-port sysctl
+ net.ipv4.ip_unprivileged_port_start <=80. DNS/firewall/ports remain host duties.
+ OPERATIONS.txt Debian setup installs distro Podman too: existing fallback,
+ not evidence of a Guix-only target. Service user imports GPG key locally.
+
+ ./stack-recovery/restore /home/ops/stack
+
+ New destination required. Capsule checks hashes/bootstrap/prerequisites,
+ decrypts through Rust manifest/path validation, requires saved backup images,
+ then activates its paired release under the capsule's Guix runtime/config.
+ Activation installs new public source/binaries/images, preserves .git, .env,
+ var/data, and starts four services. Source worktree becomes new release;
+ original source remains in encrypted backup/history. Only tested version
+ pairs establish application/data compatibility; format-2 integrity cannot.
+
+ Failed activation retains data plus private var/guix-recovery intent marker.
+ Fix cause and rerun the same restore command with the same capsule/destination.
+ Matching backup/release digests permit resume without decrypting/restoring
+ twice; unrelated destinations are refused. Recovery lock excludes concurrent
+ activation and is closed in child processes. Success removes pending marker.
+ Do not replace an interrupted activation with plain up; it cannot finish
+ copying a release. Completed recoveries use normal management commands:
+
+ /home/ops/stack/bin/run up --production
+
+ Launcher supplies --root and Guix-only CONTAINERS_CONF; do not pass --root.
+ Runtime expands privately into content-addressed var/guix; old packs remain.
+ Activation atomically replaces the archive/provenance files, including existing
+ read-only packs; regression test covers replacement followed by export/restore.
+ Direct bin/stack and releases without pack use host tools. Guix symlinks remain
+ inside opaque runtime pack; Rust application extractor still rejects links.
+ Boot setup remains explicit: systemd user service plus linger, OPERATIONS.txt.
+ Unit uses bin/run at ~/stack; adapt paths for another destination.
+
+CHECKS; DEPLOYMENT GATE
+
+ sh infra/guix/test.sh
+ cargo stack check
+
+ Shell tests need bubblewrap. Synthetic packs test actual bootstrap/filesystem
+ behavior. Synthetic recovery tests actual orchestration, failed activation,
+ resume, intent/lock refusal, private sentinels, and child FD closure. Fake tools
+ do not prove real encryption, image import, services or kernel UID mapping.
+ Rust tests prove archive-kind refusal before publication and runtime roundtrip.
+ Actual static CLI also checked synthetic bundle validation/publication/privacy.
+ No strict Rust/Clippy/Rustdoc, typed ESLint, LibreJS or crypto gate weakened.
+ Existing documented lint/dependency exceptions remain unchanged.
+
+ Clean-host test exposed missing distro container signature policy. Image load
+ now supplies a temporary policy: reject by default; accept local Docker/OCI
+ archives only. Export supplies a separate temporary policy accepting only
+ containers-storage; registry transport remains rejected. Verified archive
+ manifests and architecture/layer/image-ID locks
+ still gate startup. Registry transport is not accepted by this load policy;
+ global host policy stays unchanged. Policies are generated by AGPL Rust code;
+ the foreign schema rejects comment/legal metadata, so no headerless source file
+ is added. The initial failed VM retained decrypted data and pending intent.
+
+ A second clean-host check found legacy backups had collapsed Caddy's /data
+ and /config into one caddy/ tree. After manifest verification, backup restore
+ moves credentials into data/caddy and autosave into config/caddy; bytes and
+ permissions are retained. Conflicting old/new layouts are refused before
+ publishing a destination. New exports copy /data/. and /config/. separately.
+
+ Before production, in order:
+ 1. Full checked release with real Guix runtime and source/binary pair.
+ 2. Fresh host without Guix/distro Podman/Git/GPG: offline bootstrap/restore;
+ four services, exact images, detached UID/network helpers.
+ 3. .env count, domains, HTTPS/WKD, original owner fingerprint; unchanged chat
+ rows and history decryption with both original participant identities.
+ 4. Backup recovered stack, second recovery, reboot startup.
+ 5. First host-Podman activation and Guix runtime upgrades. Ordinary direct
+ activate retains its process PATH; deploy through matching release runtime.
+ 6. Fresh verified independent production backup; deploy only prebuilt release;
+ verify public routes/state, retain prior release/runtime and encrypted export.
+
+ Rollback: stop new containers before old runtime binds production ports.
+ Keep .env/var/data; never delete closure entries used by running processes.
+
+VERIFIED — 2026-11-01
+
+ Offline KVM guest: official Debian 12 x86_64 image, no initial /gnu, Guix,
+ Podman, Git or GPG. Host supplies only documented kernel/account prerequisites.
+ Network restricted before bootstrap; readonly carry file plus separately
+ forwarded GPG agent; no private key copied. Bootstrap and one restore command
+ loaded four saved images and started all services without source builds.
+
+ Strict HTTPS verified all four domains using original certificates; both WKD
+ routes matched the original key. All 651 immutable snapshot files matched
+ after legacy Caddy path normalization: 18 active TLS keys, 16 certificates.
+ All 126 release source/binary files matched. Original .env, owner fingerprint
+ and every canonical SQLite table matched. Production snapshot contained zero
+ conversations/messages; original real-message decryption therefore untestable.
+ Disposable Chromium/Firefox tests verified both participant identities,
+ 54-message history, encrypted snapshots/recovery and GNU LibreJS 7.21.1.
+
+ Full local release gates passed live npm/Rust advisory checks, strict fmt,
+ Clippy, Rustdoc, typed ESLint and both TypeScript compilers; no suppressions.
+ Required OpenSSL 3.6.5 hybrid suites, protected OpenPGP v6 identity, wrong-pass
+ and tamper controls passed. Synthetic shell tests cover bootstrap collisions,
+ interrupted recovery/retry, lock exclusion and child descriptor closure.
+
+ Runtime: 500687877 bytes, SHA256:
+ c5c7b849da152ee7a98b01602a1e91285ce4e3a80021a9cecb5f1e594dfc7810
+ Guix channel 230aa373f315f247852ee07dff34146e9b480aec; 113 store entries.
+ Git 2.52.0, GnuPG 2.4.7, Podman 5.7.0, conmon 2.1.13, crun 1.25.1.
+ Empty helper lists force PATH lookup, selecting packed conmon/crun. Ordinary
+ host config stays unchanged. Rootless cleanup handles immutable user-owned
+ temporary trees without changing symlink targets or the published pack.
+ https://github.com/containers/podman/blob/v5.7.0/vendor/go.podman.io/common/pkg/config/config.go#L849-L875
+ https://github.com/containers/podman/blob/v5.7.0/cmd/podman/images/save.go#L91-L94
+
+ Deployment gate above still applies to each carried artifact. Retain complete
+ original worktree, binary/launcher, installed user unit and engine storage
+ independently before activation. Reload the installed unit after switching to
+ bin/run. For rollback stop Guix containers first, retain their engine tree,
+ restore prior engine path, binary/launcher and unit; invoke prior activation
+ with host PATH and Guix CONTAINERS_CONF overrides unset. Keep current .env and
+ var/data. An old release alone does not remove a newer Guix launcher/runtime.
+ Private evidence: var/guix-*.log and .tools/guix-production/. These are ignored
+ and include private state hashes; do not publish them with public source.
diff --git a/docs/MAP.txt b/docs/MAP.txt
index 6f5e13d..de546b8 100644
--- a/docs/MAP.txt
+++ b/docs/MAP.txt
@@ -15,7 +15,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
FILE MAP
- Public tree: every tracked file, including this map.
+ Public tree: every tracked file plus pending Guix source/docs and this map.
Static inventory; update when public paths change. Root: stack/.
Private/generated trees below list roles only; contents stay unlisted.
@@ -67,6 +67,7 @@ stack/ Personal service source, build recipes and recovery tooling.
| |-- CHAT-OPENSSL-MIGRATION.txt OpenSSL crypto migration evidence, dependencies, reviewed exceptions.
| |-- CHAT-PRODUCTION.txt Chat release packaging, HTTPS activation, owner setup, production recovery.
| |-- CONFIGURATION.txt Supported root settings, validation, public/private state boundaries.
+| |-- GUIX.txt Experimental Guix capsule, canonical-store bootstrap, gates and blockers.
| |-- LINTING.txt Strict Rust/browser checks, generated-code limits, justified exceptions.
| |-- MAP.txt This public-file tree and private/runtime directory roles.
| |-- OPERATIONS.txt Stack development, encrypted backup/restore, host setup, deployment.
@@ -90,6 +91,17 @@ stack/ Personal service source, build recipes and recovery tooling.
| | | `-- supervisord.conf Run nginx and fcgiwrap inside the cgit container.
| | `-- chat/ Prebuilt chat image recipe.
| | `-- Containerfile Package prebuilt static chat server and web/crypto assets.
+| |-- guix/ Experimental Guix runtime and recovery capsule tooling.
+| | |-- bootstrap.sh Root-owned canonical closure installation; refuse managed/foreign stores.
+| | |-- build.sh Capture Guix channels; build canonical runtime pack; probe Podman info.
+| | |-- bundle.sh Validate paired release; wrap encrypted backup, release and bootstrap.
+| | |-- containers.conf Guix-only PATH-selected conmon/crun and rootless networking.
+| | |-- manifest.scm Guix packages for the portable container/runtime tools.
+| | |-- restore.sh Check capsule/host, decrypt safe restore, require images, start production.
+| | |-- run.sh Select content-addressed Guix runtime; launch stack with explicit root.
+| | |-- test-bootstrap.sh Real bootstrap in isolated filesystem using synthetic closure packs.
+| | |-- test-recovery.sh Synthetic release activation, resume, intent/locks and private-state preservation.
+| | `-- test.sh Bootstrap, immutable cleanup, integrity, overwrite refusal and launcher fixtures.
| |-- searxng/ Search service configuration.
| | `-- settings.yml SearXNG defaults: private instance, rate limiting, HTML search.
| |-- Caddyfile Production domain routing, HTTPS, WKD and service reverse proxies.
@@ -100,11 +112,11 @@ stack/ Personal service source, build recipes and recovery tooling.
| `-- stack.service Systemd user boot unit for ~/stack start/stop.
|-- src/ Management executable source.
| |-- app/ Management command implementation modules.
-| | |-- archive.rs Consistent snapshots, encrypted exports and validated directory restores.
+| | |-- archive.rs Consistent snapshots, encrypted exports, legacy Caddy layout and validated restores.
| | |-- chat.rs Chat build/package artifacts, container settings and owner administration.
| | |-- cli.rs Management subcommands, flags and argument validation.
-| | |-- files.rs Safe filesystem/archive operations, manifests and hashes.
-| | |-- images.rs Export/import exact container images; enforce platform and content locks.
+| | |-- files.rs Safe filesystem/archive operations, manifests, hashes and runtime copies.
+| | |-- images.rs Export/import saved images with archive-only policy, platform and content locks.
| | |-- process.rs Run child processes; capture output and report failures/status.
| | |-- project.rs Discover/lock project; settings, Podman commands, service lifecycle.
| | |-- release.rs Build checked release archives; validate/install production artifacts.
@@ -152,7 +164,7 @@ PRIVATE AND GENERATED PATHS — NOT PUBLIC SOURCE
.env Sole private root configuration; never commit.
.git/ Local Git objects, refs, hooks and repository settings.
.tools/ Downloaded toolchains, build tools, sources and caches.
- bin/ Prebuilt management/chat commands and packaged chat assets.
+ bin/ Prebuilt management/chat commands, launcher and Guix pack.
dist/ Locally built release tarballs and checksum files.
node_modules/ Locked npm development dependencies; recreated by npm ci.
target/ Cargo build output, WASM targets and generated rustdoc.
@@ -166,10 +178,15 @@ PRIVATE AND GENERATED PATHS — NOT PUBLIC SOURCE
repos/ Served bare Git repositories; separate from root .git/.
searxng/ Runtime search configuration and persistent service files.
exports/ Encrypted application backups and checksum files.
+ guix/ Unpacked content-addressed runtime packs; old packs retained.
incoming/ Copied release archives and temporary deployment bootstrap.
podman/ Recreated container storage/run state; never transfer as backup.
graphify/ Public-source graph index, visual map and extraction artifacts.
Other var/ paths may hold generated fixtures or temporary work. This map does
not enumerate private files. Backups must keep .env and var/data; public Git
- alone cannot recover services. See OPERATIONS.txt.
+ alone cannot recover services. See OPERATIONS.txt and GUIX.txt.
+
+ Experimental host integration: /gnu/store holds canonical Guix packages;
+ /gnu/.stack-runtimes holds root-owned package install records and lock.
+ Neither holds application configuration, databases or TLS keys.
diff --git a/docs/OPERATIONS.txt b/docs/OPERATIONS.txt
index 55dc81c..91ab10b 100644
--- a/docs/OPERATIONS.txt
+++ b/docs/OPERATIONS.txt
@@ -18,6 +18,7 @@ STACK OPERATIONS
Root: ~/stack. Develop on local Linux; production: ops@23.155.44.67.
Public source: https://git.doan.luuduct.ai/stack.
GNU Affero General Public License, version 3 or later.
+ Experimental Guix capsule: GUIX.txt. Commands below use host Podman.
KEEP
@@ -68,7 +69,7 @@ CHECK LOCAL
cargo stack check
- Checks formatting, all workspace Clippy targets/features, tests, private
+ Checks Guix shell/bootstrap fixtures (bubblewrap), formatting, workspace Clippy, tests, private
rustdoc, advisories, duplicate versions, sources, licenses. cargo-about
regenerates THIRD-PARTY-LICENSES.html. Format with cargo fmt --all.
Dependency changes: update Cargo.lock deliberately; run full check; commit
@@ -184,8 +185,8 @@ NEW PRODUCTION HOST
systemctl --user enable --now stack.service
systemctl --user status stack.service
- Unit assumes ~/stack; edit paths for other location. Uses bin/stack.
- Linger keeps user service alive
+ Unit assumes ~/stack; edit paths for other location. Current unit uses bin/run:
+ confirm launcher exists before enabling. Linger keeps user service alive
without login. Containers use unless-stopped restart policy.
Optional former VPS SSH Git path; administrator only:
diff --git a/docs/POLICY.txt b/docs/POLICY.txt
index dea595b..747b774 100644
--- a/docs/POLICY.txt
+++ b/docs/POLICY.txt
@@ -32,6 +32,8 @@ PROJECT CONVENTIONS
Required legal headers are the exception. Prose uses .txt; generated HTML
license page is an explicit exception. Rust doc attributes supply crate
metadata without comments.
+ Temporary container import/export policies are generated protocol JSON: its strict
+ foreign schema rejects legal metadata. AGPL notice stays in the Rust generator.
One root .env holds deployment overrides/configured secrets. Never commit
.env or var/. Publish only root .env.example; ignore variants/nested examples.
@@ -76,6 +78,8 @@ PORTABILITY
packages, subordinate UID/GID allocations, one low-port sysctl, user linger,
systemd user-service link. Host SSH keys, firewall policy, accounts, and disks
are host administration, excluded from application exports.
+ Experimental Guix capsule also installs canonical /gnu/store packages and
+ /gnu/.stack-runtimes install records. Private application state stays in stack/.
Releases include static x86_64 Linux management/chat commands, WASM browser
crypto, exact corresponding sources, native linux/amd64 container images.
diff --git a/infra/guix/bootstrap.sh b/infra/guix/bootstrap.sh
new file mode 100644
index 0000000..6f53dbc
--- /dev/null
+++ b/infra/guix/bootstrap.sh
@@ -0,0 +1,86 @@
+#!/bin/sh
+# Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+set -eu
+umask 077
+fail() { printf '%s\n' "$1" >&2; exit 1; }
+safe_directory() {
+ [ -d "$1" ] && [ ! -L "$1" ] && [ "$(stat -c %u "$1")" = 0 ] &&
+ [ -z "$(find "$1" -maxdepth 0 -perm /022 -print)" ]
+}
+[ "$#" = 0 ] || fail 'Usage: sudo ./stack-recovery/bootstrap'
+[ "$(id -u)" = 0 ] || fail 'Run bootstrap explicitly as root; restore runs as the service user.'
+[ "$(uname -sm)" = 'Linux x86_64' ] || fail 'Requires x86_64 Linux.'
+command -v flock >/dev/null || fail 'Host util-linux flock is required.'
+command -v diff >/dev/null || fail 'Host GNU diffutils is required.'
+BUNDLE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+(cd "$BUNDLE" && sha256sum --check --strict SHA256SUMS)
+[ ! -e /var/guix ] && [ ! -L /var/guix ] || fail 'Managed Guix installation found; refusing bootstrap.'
+[ ! -L /gnu ] && [ ! -L /gnu/store ] || fail 'Symlink /gnu or store; refusing bootstrap.'
+STAGE=
+INITIAL=
+trap '[ -z "$STAGE" ] || rm -rf -- "$STAGE"; [ -z "$INITIAL" ] || rm -rf -- "$INITIAL"' EXIT HUP INT TERM
+if [ ! -e /gnu ]; then
+ INITIAL=$(mktemp -d /.stack-guix.XXXXXX)
+ chmod 755 "$INITIAL"
+ mkdir -m 755 "$INITIAL/store" "$INITIAL/.stack-runtimes"
+ mv -T --no-clobber -- "$INITIAL" /gnu
+ rm -rf -- "$INITIAL"
+ INITIAL=
+fi
+safe_directory /gnu && safe_directory /gnu/.stack-runtimes || fail 'Foreign or writable /gnu ownership metadata; refusing bootstrap.'
+exec 9>/gnu/.stack-runtimes/install.lock
+flock 9
+if [ -e /gnu/store ]; then
+ safe_directory /gnu/store || fail 'Foreign or writable Guix store; refusing bootstrap.'
+else
+ [ ! -L /gnu/store ] || fail 'Symlink store; refusing bootstrap.'
+ mkdir -m 755 /gnu/store
+fi
+DIGEST=$(sha256sum "$BUNDLE/runtime.tar.gz")
+DIGEST=${DIGEST%% *}
+MARKER=/gnu/.stack-runtimes/$DIGEST
+if [ -e "$MARKER" ] || [ -L "$MARKER" ]; then
+ [ -f "$MARKER" ] && [ ! -L "$MARKER" ] && [ "$(stat -c %u "$MARKER")" = 0 ] &&
+ [ -z "$(find "$MARKER" -perm /222 -print)" ] && [ "$(cat "$MARKER")" = "$DIGEST" ] ||
+ fail 'Invalid completed-runtime marker; refusing bootstrap.'
+ printf '%s\n' 'Guix runtime already installed.'
+ exit 0
+fi
+STAGE=$(mktemp -d /gnu/.stack-stage.XXXXXX)
+tar -xzf "$BUNDLE/runtime.tar.gz" --no-same-owner -C "$STAGE"
+[ -d "$STAGE/gnu" ] && [ ! -L "$STAGE/gnu" ] &&
+ [ -d "$STAGE/gnu/store" ] && [ ! -L "$STAGE/gnu/store" ] || fail 'Runtime has no canonical Guix store.'
+set -- "$STAGE/gnu/store/"*
+[ -e "$1" ] || [ -L "$1" ] || fail 'Runtime Guix store is empty.'
+chown -hR 0:0 "$STAGE/gnu/store"
+chmod -R a-w "$STAGE/gnu/store"
+for ENTRY do
+ TARGET=/gnu/store/${ENTRY##*/}
+ if [ -e "$TARGET" ] || [ -L "$TARGET" ]; then
+ diff -qr --no-dereference -- "$ENTRY" "$TARGET" || fail 'Conflicting Guix store entry; existing data retained.'
+ [ -z "$(find "$TARGET" \( ! -user 0 -o \( ! -type l -perm /222 \) \) -print -quit)" ] ||
+ fail 'Existing Guix store entry has unsafe ownership or write permissions.'
+ else
+ mv -T --no-clobber -- "$ENTRY" "$TARGET"
+ [ ! -e "$ENTRY" ] && [ ! -L "$ENTRY" ] || fail 'Guix store entry appeared during installation; retry bootstrap.'
+ fi
+done
+printf '%s\n' "$DIGEST" > "$STAGE/complete"
+chmod 444 "$STAGE/complete"
+mv -T --no-clobber -- "$STAGE/complete" "$MARKER"
+[ ! -e "$STAGE/complete" ] || fail 'Runtime marker appeared during installation; retry bootstrap.'
+printf '%s\n' 'Guix runtime installed. Run restore as the service user.'
diff --git a/infra/guix/build.sh b/infra/guix/build.sh
new file mode 100644
index 0000000..e5a0da8
--- /dev/null
+++ b/infra/guix/build.sh
@@ -0,0 +1,37 @@
+#!/bin/sh
+# Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+set -eu
+umask 077
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
+[ "$(uname -s):$(uname -m)" = Linux:x86_64 ] || { echo 'Requires x86_64 Linux.' >&2; exit 1; }
+command -v guix >/dev/null || { echo "Guix is required on the build machine." >&2; exit 1; }
+mkdir -p "$ROOT/bin"
+STAGE=$(mktemp -d "$ROOT/bin/.guix-build.XXXXXX")
+trap 'chmod -R u+w -- "$STAGE"; rm -rf -- "$STAGE"' EXIT HUP INT TERM
+guix describe --format=channels > "$STAGE/channels.scm"
+PACK=$(guix time-machine -C "$STAGE/channels.scm" -- pack \
+ --system=x86_64-linux -S /bin=bin -S /etc=etc \
+ -m "$ROOT/infra/guix/manifest.scm")
+cp -- "$PACK" "$STAGE/runtime.tar.gz"
+mkdir "$STAGE/runtime"
+tar -xzf "$STAGE/runtime.tar.gz" --no-same-owner -C "$STAGE/runtime"
+PATH="$STAGE/runtime/bin:$PATH" CONTAINERS_CONF="$ROOT/infra/guix/containers.conf" \
+ podman --root "$STAGE/storage" \
+ --runroot "$STAGE/run" info >/dev/null
+mv -- "$STAGE/runtime.tar.gz" "$ROOT/bin/guix-runtime.tar.gz"
+mv -- "$STAGE/channels.scm" "$ROOT/bin/guix-channels.scm"
+printf '%s\n' 'Guix runtime built. Full clean-host recovery test still required.'
diff --git a/infra/guix/bundle.sh b/infra/guix/bundle.sh
new file mode 100644
index 0000000..c6d5bb0
--- /dev/null
+++ b/infra/guix/bundle.sh
@@ -0,0 +1,56 @@
+#!/bin/sh
+# Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+set -eu
+umask 077
+[ "$#" = 3 ] || { echo 'Usage: sh infra/guix/bundle.sh BACKUP.tar.gz.gpg RELEASE.tar.gz OUTPUT.tar.gz' >&2; exit 1; }
+[ -f "$1" ] || { echo 'Encrypted backup missing.' >&2; exit 1; }
+case "$1" in *.tar.gz.gpg) ;; *) echo 'Requires an encrypted stack backup.' >&2; exit 1 ;; esac
+[ -f "$2" ] || { echo 'Trusted release missing.' >&2; exit 1; }
+case "$2" in *.tar.gz) ;; *) echo 'Requires a trusted public release archive.' >&2; exit 1 ;; esac
+case "$3" in *.tar.gz) ;; *) echo 'Output must end in .tar.gz.' >&2; exit 1 ;; esac
+[ ! -e "$3" ] || { echo 'Output already exists.' >&2; exit 1; }
+STAGE=$(mktemp -d "$(dirname -- "$3")/.recovery.XXXXXX")
+trap 'rm -rf -- "$STAGE"' EXIT HUP INT TERM
+tar -xOf "$2" stack/bin/stack > "$STAGE/verify"
+chmod 700 "$STAGE/verify"
+"$STAGE/verify" restore --release "$2" "$STAGE/release"
+RELEASE="$STAGE/release"
+[ ! -e "$RELEASE/.env" ] && [ ! -e "$RELEASE/var/data" ] || {
+ echo 'Release contains private application state; refusing plaintext publication.' >&2; exit 1;
+}
+for FILE in bin/stack bin/guix-runtime.tar.gz bin/guix-channels.scm \
+ bin/chat/server bin/chat/crypto.wasm bin/chat/SOURCE.tar.gz infra/services.toml \
+ infra/guix/bootstrap.sh infra/guix/restore.sh infra/guix/run.sh \
+ infra/guix/containers.conf var/image-lock.json var/images.tar LICENSE; do
+ [ -f "$RELEASE/$FILE" ] || { echo "Release missing $FILE; build a complete release first." >&2; exit 1; }
+done
+mkdir "$STAGE/stack-recovery"
+cp -- "$1" "$STAGE/stack-recovery/backup.tar.gz.gpg"
+cp -- "$2" "$STAGE/stack-recovery/release.tar.gz"
+cp -- "$RELEASE/bin/stack" "$STAGE/stack-recovery/stack"
+cp -- "$RELEASE/bin/guix-runtime.tar.gz" "$STAGE/stack-recovery/runtime.tar.gz"
+cp -- "$RELEASE/bin/guix-channels.scm" "$STAGE/stack-recovery/channels.scm"
+cp -- "$RELEASE/infra/guix/restore.sh" "$STAGE/stack-recovery/restore"
+cp -- "$RELEASE/infra/guix/bootstrap.sh" "$STAGE/stack-recovery/bootstrap"
+cp -- "$RELEASE/infra/guix/run.sh" "$STAGE/stack-recovery/run"
+cp -- "$RELEASE/infra/guix/containers.conf" "$STAGE/stack-recovery/containers.conf"
+cp -- "$RELEASE/LICENSE" "$STAGE/stack-recovery/LICENSE"
+chmod 700 "$STAGE/stack-recovery/restore" "$STAGE/stack-recovery/bootstrap" "$STAGE/stack-recovery/run" "$STAGE/stack-recovery/stack"
+(cd "$STAGE/stack-recovery" && sha256sum backup.tar.gz.gpg release.tar.gz stack runtime.tar.gz channels.scm restore bootstrap run containers.conf LICENSE > SHA256SUMS)
+tar -czf "$STAGE/bundle.tar.gz" -C "$STAGE" stack-recovery
+ln -- "$STAGE/bundle.tar.gz" "$3"
+printf 'Recovery bundle: %s\n' "$3"
diff --git a/infra/guix/containers.conf b/infra/guix/containers.conf
new file mode 100644
index 0000000..ab74f9e
--- /dev/null
+++ b/infra/guix/containers.conf
@@ -0,0 +1,24 @@
+# Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+[engine]
+conmon_path = []
+runtime = "crun"
+
+[engine.runtimes]
+crun = []
+
+[network]
+default_rootless_network_cmd = "slirp4netns"
diff --git a/infra/guix/manifest.scm b/infra/guix/manifest.scm
new file mode 100644
index 0000000..7e9cf19
--- /dev/null
+++ b/infra/guix/manifest.scm
@@ -0,0 +1,19 @@
+; Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+;
+; This program is free software: you can redistribute it and/or modify
+; it under the terms of the GNU Affero General Public License as
+; published by the Free Software Foundation, either version 3 of the
+; License, or (at your option) any later version.
+;
+; This program is distributed in the hope that it will be useful,
+; but WITHOUT ANY WARRANTY; without even the implied warranty of
+; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+; GNU Affero General Public License for more details.
+;
+; You should have received a copy of the GNU Affero General Public License
+; along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+(specifications->manifest
+ '("bash" "coreutils" "tar" "gzip" "git" "gnupg" "nss-certs"
+ "podman" "crun" "conmon" "slirp4netns" "fuse-overlayfs"
+ "netavark" "aardvark-dns"))
diff --git a/infra/guix/restore.sh b/infra/guix/restore.sh
new file mode 100644
index 0000000..22e7e10
--- /dev/null
+++ b/infra/guix/restore.sh
@@ -0,0 +1,90 @@
+#!/bin/sh
+# Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+set -eu
+umask 077
+[ "$#" = 1 ] || { echo 'Usage: ./restore /absolute/path/to/new-stack' >&2; exit 1; }
+case "$1" in /*) ;; *) echo 'Destination must be absolute.' >&2; exit 1 ;; esac
+[ "$(uname -s):$(uname -m)" = Linux:x86_64 ] || { echo 'Requires x86_64 Linux.' >&2; exit 1; }
+[ "$(id -u)" != 0 ] || { echo 'Run as the service user, not root.' >&2; exit 1; }
+BUNDLE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+(cd "$BUNDLE" && sha256sum --check --strict SHA256SUMS)
+RELEASE_DIGEST=$(sha256sum "$BUNDLE/release.tar.gz")
+RELEASE_DIGEST=${RELEASE_DIGEST%% *}
+BACKUP_DIGEST=$(sha256sum "$BUNDLE/backup.tar.gz.gpg")
+BACKUP_DIGEST=${BACKUP_DIGEST%% *}
+INTENT="$RELEASE_DIGEST:$BACKUP_DIGEST"
+MARKER="$1/var/guix-recovery"
+pending() {
+ [ -f "$MARKER" ] && [ ! -L "$MARKER" ] && [ -O "$MARKER" ] &&
+ [ "$(cat "$MARKER")" = "$INTENT" ]
+}
+RESUME=false
+if [ -e "$1" ] || [ -L "$1" ]; then
+ pending || { echo 'Destination exists without a matching pending recovery; refusing to overwrite.' >&2; exit 1; }
+ RESUME=true
+fi
+DIGEST=$(sha256sum "$BUNDLE/runtime.tar.gz")
+DIGEST=${DIGEST%% *}
+[ -f "/gnu/.stack-runtimes/$DIGEST" ] && [ "$(stat -c %u "/gnu/.stack-runtimes/$DIGEST")" = 0 ] || {
+ echo 'Install the trusted closure first: sudo ./stack-recovery/bootstrap' >&2; exit 1;
+}
+for HELPER in newuidmap newgidmap; do
+ command -v "$HELPER" >/dev/null || { echo "Install host $HELPER before restoring." >&2; exit 1; }
+done
+for MAPPING in /etc/subuid /etc/subgid; do
+ awk -F: -v name="$(id -un)" -v uid="$(id -u)" \
+ '($1 == name || $1 == uid) && $3 >= 65536 { found=1 } END { exit !found }' "$MAPPING" || {
+ echo "Allocate at least 65536 subordinate IDs in $MAPPING for $(id -un)." >&2; exit 1;
+ }
+done
+[ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ] && [ -w "$XDG_RUNTIME_DIR" ] || {
+ echo 'A writable XDG_RUNTIME_DIR is required; use a normal login session.' >&2; exit 1;
+}
+[ "$(cat /proc/sys/net/ipv4/ip_unprivileged_port_start)" -le 80 ] || {
+ echo 'Host administrator must set net.ipv4.ip_unprivileged_port_start=80.' >&2; exit 1;
+}
+STAGE=$(mktemp -d)
+trap 'chmod -R u+w -- "$STAGE"; rm -rf -- "$STAGE"' EXIT HUP INT TERM
+mkdir "$STAGE/runtime"
+tar -xzf "$BUNDLE/runtime.tar.gz" --no-same-owner -C "$STAGE/runtime"
+[ -x "$STAGE/runtime/bin/podman" ] && [ -x "$STAGE/runtime/bin/gpg" ] || {
+ echo 'Guix closure incomplete; rerun the trusted capsule bootstrap.' >&2; exit 1;
+}
+PATH="$STAGE/runtime/bin:$PATH"
+CONTAINERS_CONF="$BUNDLE/containers.conf"
+export PATH CONTAINERS_CONF
+podman --root "$STAGE/storage" --runroot "$STAGE/run" info >/dev/null
+if [ "$RESUME" = false ]; then
+ "$BUNDLE/stack" restore "$BUNDLE/backup.tar.gz.gpg" "$1"
+fi
+LOCK="$1/var/guix-recovery.lock"
+[ ! -L "$LOCK" ] && { [ ! -e "$LOCK" ] || [ -f "$LOCK" ]; } || {
+ echo 'Recovery lock is not a regular file.' >&2; exit 1;
+}
+exec 9>>"$LOCK"
+flock -n 9 || { echo 'Another recovery is running for this destination.' >&2; exit 1; }
+if [ "$RESUME" = true ]; then
+ pending || { echo 'Pending recovery changed; refusing activation.' >&2; exit 1; }
+else
+ printf '%s\n' "$INTENT" > "$MARKER"
+fi
+[ -f "$1/var/image-lock.json" ] && [ -f "$1/var/images.tar" ] || {
+ echo 'Backup has no complete image export; recovered data retained. Use backup --include-images.' >&2; exit 1;
+}
+printf '%s\n' 'Data restored. Activating paired release; rerun this restore command after any failure.'
+"$BUNDLE/stack" --root "$1" activate "$BUNDLE/release.tar.gz" 9>&-
+rm -- "$MARKER"
diff --git a/infra/guix/run.sh b/infra/guix/run.sh
new file mode 100644
index 0000000..e1b9b99
--- /dev/null
+++ b/infra/guix/run.sh
@@ -0,0 +1,45 @@
+#!/bin/sh
+# Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+set -eu
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+if [ -f "$ROOT/bin/guix-runtime.tar.gz" ]; then
+ DIGEST=$(sha256sum "$ROOT/bin/guix-runtime.tar.gz")
+ DIGEST=${DIGEST%% *}
+ RUNTIME="$ROOT/var/guix/$DIGEST"
+ if [ ! -d "$RUNTIME" ]; then
+ umask 077
+ mkdir -p "$ROOT/var/guix"
+ STAGE=$(mktemp -d "$ROOT/var/guix/.unpack.XXXXXX")
+ trap 'chmod -R u+w -- "$STAGE"; rm -rf -- "$STAGE"' EXIT HUP INT TERM
+ tar -xzf "$ROOT/bin/guix-runtime.tar.gz" --no-same-owner -C "$STAGE"
+ [ -x "$STAGE/bin/podman" ] && [ -x "$STAGE/bin/gpg" ] || {
+ echo 'Guix closure unavailable; run the trusted recovery capsule bootstrap as root first.' >&2; exit 1;
+ }
+ mv -T -- "$STAGE" "$RUNTIME"
+ trap - EXIT HUP INT TERM
+ fi
+ [ -x "$RUNTIME/bin/podman" ] && [ -x "$RUNTIME/bin/gpg" ] || {
+ echo 'Guix closure unavailable; run the trusted recovery capsule bootstrap as root first.' >&2; exit 1;
+ }
+ PATH="$RUNTIME/bin:$PATH"
+ CONTAINERS_CONF="$ROOT/infra/guix/containers.conf"
+ export PATH CONTAINERS_CONF
+ SSL_CERT_DIR="$RUNTIME/etc/ssl/certs"
+ SSL_CERT_FILE="$SSL_CERT_DIR/ca-certificates.crt"
+ export SSL_CERT_DIR SSL_CERT_FILE
+fi
+exec "$ROOT/bin/stack" --root "$ROOT" "$@"
diff --git a/infra/guix/test-bootstrap.sh b/infra/guix/test-bootstrap.sh
new file mode 100644
index 0000000..470ebeb
--- /dev/null
+++ b/infra/guix/test-bootstrap.sh
@@ -0,0 +1,118 @@
+#!/bin/sh
+# Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+set -eu
+umask 077
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
+command -v bwrap >/dev/null || { echo 'Bootstrap tests require bubblewrap.' >&2; exit 1; }
+STAGE=$(mktemp -d)
+trap 'chmod -R u+w "$STAGE"; rm -rf -- "$STAGE"' EXIT HUP INT TERM
+mkdir -p "$STAGE/capsule" "$STAGE/pack/gnu/store/00000000000000000000000000000000-tool/bin" \
+ "$STAGE/pack/gnu/store/11111111111111111111111111111111-profile/bin" "$STAGE/pack/etc"
+cp "$ROOT/infra/guix/bootstrap.sh" "$STAGE/capsule/bootstrap"
+printf '%s\n' '#!/bin/sh' 'printf "%s\n" canonical-tool' > "$STAGE/pack/gnu/store/00000000000000000000000000000000-tool/bin/tool"
+chmod 755 "$STAGE/pack/gnu/store/00000000000000000000000000000000-tool/bin/tool"
+ln -s /gnu/store/00000000000000000000000000000000-tool/bin/tool "$STAGE/pack/gnu/store/11111111111111111111111111111111-profile/bin/tool"
+ln -s /gnu/store/11111111111111111111111111111111-profile/bin "$STAGE/pack/bin"
+printf '%s\n' 'Must stay inside pack.' > "$STAGE/pack/etc/pack-sentinel"
+tar -czf "$STAGE/capsule/runtime.tar.gz" -C "$STAGE/pack" .
+(cd "$STAGE/capsule" && sha256sum bootstrap runtime.tar.gz > SHA256SUMS)
+cp -a "$STAGE/capsule" "$STAGE/tampered"
+printf '%s\n' 'tamper' >> "$STAGE/tampered/runtime.tar.gz"
+cp -a "$STAGE/capsule" "$STAGE/next"
+mkdir "$STAGE/pack/gnu/store/22222222222222222222222222222222-next"
+printf '%s\n' 'Second closure.' > "$STAGE/pack/gnu/store/22222222222222222222222222222222-next/value"
+tar -czf "$STAGE/next/runtime.tar.gz" -C "$STAGE/pack" .
+(cd "$STAGE/next" && sha256sum bootstrap runtime.tar.gz > SHA256SUMS)
+bwrap --unshare-user --uid 0 --gid 0 --unshare-pid --cap-add CAP_DAC_OVERRIDE \
+ --tmpfs / --ro-bind /usr /usr \
+ --symlink usr/bin /bin --symlink usr/lib /lib --symlink usr/lib /lib64 \
+ --ro-bind "$STAGE/capsule" /capsule --ro-bind "$STAGE/next" /next \
+ --ro-bind "$STAGE/tampered" /tampered --proc /proc --dev /dev --tmpfs /tmp \
+ --chdir / /bin/sh -eu <<'TEST'
+fail() { printf '%s\n' "$1" >&2; exit 1; }
+reject() { if sh "$1/bootstrap"; then fail "$2"; fi; }
+TOOL=/gnu/store/00000000000000000000000000000000-tool/bin/tool
+PROFILE=/gnu/store/11111111111111111111111111111111-profile
+mkdir /etc
+printf '%s\n' 'Host configuration.' > /etc/host-sentinel
+[ ! -e /gnu ] && [ ! -e /var/guix ]
+if sh /capsule/bootstrap extra; then fail 'Accepted bootstrap arguments.'; fi
+reject /tampered 'Accepted altered runtime.'
+[ ! -e /gnu ]
+sh /capsule/bootstrap > /tmp/bootstrap-first.log &
+FIRST=$!
+sh /capsule/bootstrap
+wait "$FIRST"
+DIGEST=$(sha256sum /capsule/runtime.tar.gz)
+DIGEST=${DIGEST%% *}
+[ "$(cat "/gnu/.stack-runtimes/$DIGEST")" = "$DIGEST" ]
+[ -z "$(find /gnu/store -mindepth 1 ! -user 0 -print -quit)" ]
+[ -z "$(find /gnu/store -mindepth 1 ! -type l -perm /222 -print -quit)" ]
+[ "$("$PROFILE/bin/tool")" = canonical-tool ]
+[ "$(readlink "$PROFILE/bin/tool")" = "$TOOL" ]
+[ "$(readlink /bin)" = usr/bin ]
+[ "$(cat /etc/host-sentinel)" = 'Host configuration.' ]
+[ ! -e /etc/pack-sentinel ]
+INODE=$(stat -c %i "/gnu/.stack-runtimes/$DIGEST")
+sh /capsule/bootstrap
+[ "$(stat -c %i "/gnu/.stack-runtimes/$DIGEST")" = "$INODE" ]
+rm "/gnu/.stack-runtimes/$DIGEST"
+chmod -R u+w "$PROFILE"
+rm -rf "$PROFILE"
+sh /capsule/bootstrap
+[ "$("$PROFILE/bin/tool")" = canonical-tool ]
+[ -f "/gnu/.stack-runtimes/$DIGEST" ]
+[ -z "$(find /gnu -maxdepth 1 -name '.stack-stage.*' -print -quit)" ]
+sh /next/bootstrap
+[ "$("$PROFILE/bin/tool")" = canonical-tool ]
+[ "$(cat /gnu/store/22222222222222222222222222222222-next/value)" = 'Second closure.' ]
+[ -f "/gnu/.stack-runtimes/$DIGEST" ]
+rm "/gnu/.stack-runtimes/$DIGEST"
+chmod u+w "$TOOL"
+printf '%s\n' 'conflicting content' >> "$TOOL"
+chmod a-w "$TOOL"
+reject /capsule 'Overwrote conflicting store entry.'
+[ ! -e "/gnu/.stack-runtimes/$DIGEST" ]
+[ -z "$(find /gnu -maxdepth 1 -name '.stack-stage.*' -print -quit)" ]
+tail -n 1 "$TOOL" | grep -Fx 'conflicting content'
+chmod -R u+w /gnu
+rm -rf /gnu
+mkdir -p /var/guix
+reject /capsule 'Accepted managed Guix installation.'
+[ ! -e /gnu ]
+rm -rf /var/guix
+mkdir -p /gnu/store
+printf '%s\n' 'Foreign store.' > /gnu/store/sentinel
+reject /capsule 'Accepted foreign store.'
+[ "$(cat /gnu/store/sentinel)" = 'Foreign store.' ]
+rm -rf /gnu
+mkdir /outside
+ln -s /outside /gnu
+reject /capsule 'Accepted symlink /gnu.'
+[ -z "$(find /outside -mindepth 1 -print -quit)" ]
+rm /gnu
+mkdir -p /gnu/.stack-runtimes
+chmod 755 /gnu /gnu/.stack-runtimes
+ln -s /outside /gnu/store
+reject /capsule 'Accepted symlink store.'
+[ -z "$(find /outside -mindepth 1 -print -quit)" ]
+rm /gnu/store
+chmod 777 /gnu/.stack-runtimes
+reject /capsule 'Accepted writable ownership metadata.'
+[ ! -e /gnu/store ]
+printf '%s\n' 'Canonical bootstrap, retry, idempotency, preservation and refusal checks passed.'
+TEST
diff --git a/infra/guix/test-recovery.sh b/infra/guix/test-recovery.sh
new file mode 100644
index 0000000..c03ed2f
--- /dev/null
+++ b/infra/guix/test-recovery.sh
@@ -0,0 +1,133 @@
+#!/bin/sh
+# Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+set -eu
+umask 077
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
+command -v bwrap >/dev/null || { echo 'Recovery tests require bubblewrap.' >&2; exit 1; }
+STAGE=$(mktemp -d)
+trap 'chmod -R u+w "$STAGE"; rm -rf -- "$STAGE"' EXIT HUP INT TERM
+mkdir -p "$STAGE/capsule" "$STAGE/runtime/bin" "$STAGE/test-bin"
+cp "$ROOT/infra/guix/restore.sh" "$STAGE/capsule/restore"
+cp "$ROOT/infra/guix/run.sh" "$STAGE/capsule/run"
+cp "$ROOT/infra/guix/containers.conf" "$STAGE/capsule/containers.conf"
+printf '%s\n' 'opaque encrypted backup fixture' > "$STAGE/capsule/backup.tar.gz.gpg"
+printf '%s\n' 'opaque release fixture' > "$STAGE/capsule/release.tar.gz"
+printf '%s\n' 80 > "$STAGE/lowport"
+printf '%s\n' '#!/bin/sh' 'exit 0' > "$STAGE/runtime/bin/gpg"
+cat > "$STAGE/runtime/bin/podman" <<'PODMAN'
+#!/bin/sh
+set -eu
+[ "$CONTAINERS_CONF" = /capsule/containers.conf ]
+printf '%s\n' "$*" >> /tmp/podman.calls
+PODMAN
+cat > "$STAGE/test-bin/id" <<'ID'
+#!/bin/sh
+case "$1" in -u) echo 1000 ;; -un) echo service ;; *) exit 1 ;; esac
+ID
+for HELPER in newuidmap newgidmap; do
+ printf '%s\n' '#!/bin/sh' 'exit 0' > "$STAGE/test-bin/$HELPER"
+done
+cat > "$STAGE/capsule/stack" <<'STACK'
+#!/bin/sh
+set -eu
+[ "$CONTAINERS_CONF" = /capsule/containers.conf ]
+case "$(command -v podman)" in /tmp/*/runtime/bin/podman) ;; *) exit 97 ;; esac
+case "$1" in
+ restore)
+ [ "$#" = 3 ] && [ "$2" = /capsule/backup.tar.gz.gpg ]
+ mkdir -p "$3/var/data" "$3/bin"
+ printf '%s\n' private-environment > "$3/.env"
+ printf '%s\n' encrypted-history > "$3/var/data/history"
+ printf '%s\n' images > "$3/var/images.tar"
+ printf '%s\n' image-lock > "$3/var/image-lock.json"
+ printf '%s\n' '#!/bin/sh' 'exit 99' > "$3/bin/stack"
+ chmod 700 "$3/bin/stack"
+ printf '%s\n' old-source > "$3/source"
+ echo restored >> /tmp/restorations
+ ;;
+ --root)
+ [ "$#" = 4 ] && [ "$3" = activate ] && [ "$4" = /capsule/release.tar.gz ]
+ [ ! -e /proc/self/fd/9 ]
+ [ "$(cat "$4")" = 'opaque release fixture' ]
+ echo activated >> /tmp/activations
+ [ ! -f /tmp/fail-activation ] || exit 42
+ mkdir -p "$2/infra/guix"
+ printf '%s\n' '#!/bin/sh' 'echo new-management' > "$2/bin/stack"
+ chmod 700 "$2/bin/stack"
+ printf '%s\n' new-source > "$2/source"
+ cp /capsule/run "$2/infra/guix/run.sh"
+ cp /capsule/run "$2/bin/run"
+ cp /capsule/containers.conf "$2/infra/guix/containers.conf"
+ ;;
+ *) exit 98 ;;
+esac
+STACK
+chmod 700 "$STAGE/capsule/stack" "$STAGE/runtime/bin/"* "$STAGE/test-bin/"*
+tar -czf "$STAGE/capsule/runtime.tar.gz" -C "$STAGE/runtime" .
+(cd "$STAGE/capsule" && sha256sum backup.tar.gz.gpg release.tar.gz runtime.tar.gz restore run stack containers.conf > SHA256SUMS)
+bwrap --unshare-user --uid 0 --gid 0 --unshare-pid --cap-add CAP_DAC_OVERRIDE \
+ --tmpfs / --ro-bind /usr /usr \
+ --symlink usr/bin /bin --symlink usr/lib /lib --symlink usr/lib /lib64 \
+ --ro-bind "$STAGE/capsule" /fixture --ro-bind "$STAGE/test-bin" /test-bin \
+ --proc /proc --dev /dev --tmpfs /tmp \
+ --ro-bind "$STAGE/lowport" /proc/sys/net/ipv4/ip_unprivileged_port_start \
+ --setenv PATH /test-bin:/usr/bin --setenv XDG_RUNTIME_DIR /tmp/xdg \
+ --chdir / /bin/sh -eu <<'TEST'
+fail() { printf '%s\n' "$1" >&2; exit 1; }
+reject() { if sh /capsule/restore "$1"; then fail "$2"; fi; }
+cp -a /fixture /capsule
+mkdir -p /etc /gnu/.stack-runtimes /tmp/xdg
+printf '%s\n' service:100000:65536 > /etc/subuid
+cp /etc/subuid /etc/subgid
+DIGEST=$(sha256sum /capsule/runtime.tar.gz)
+DIGEST=${DIGEST%% *}
+printf '%s\n' "$DIGEST" > "/gnu/.stack-runtimes/$DIGEST"
+touch /tmp/fail-activation
+STATUS=0
+sh /capsule/restore /recovered || STATUS=$?
+[ "$STATUS" = 42 ] || fail 'Did not reach the paired release activation.'
+[ -f /recovered/var/guix-recovery ]
+[ "$(cat /recovered/.env)" = private-environment ]
+[ "$(cat /recovered/var/data/history)" = encrypted-history ]
+[ "$(cat /recovered/source)" = old-source ]
+cp /capsule/backup.tar.gz.gpg /tmp/original-backup
+echo changed >> /capsule/backup.tar.gz.gpg
+(cd /capsule && sha256sum backup.tar.gz.gpg release.tar.gz runtime.tar.gz restore run stack containers.conf > SHA256SUMS)
+reject /recovered 'Accepted a different backup for pending recovery.'
+[ "$(wc -l < /tmp/restorations)" = 1 ] && [ "$(wc -l < /tmp/activations)" = 1 ]
+cp /tmp/original-backup /capsule/backup.tar.gz.gpg
+(cd /capsule && sha256sum backup.tar.gz.gpg release.tar.gz runtime.tar.gz restore run stack containers.conf > SHA256SUMS)
+exec 9>/recovered/var/guix-recovery.lock
+flock 9
+reject /recovered 'Accepted concurrent recovery.' 9>&-
+exec 9>&-
+[ "$(wc -l < /tmp/activations)" = 1 ]
+rm /tmp/fail-activation
+sh /capsule/restore /recovered
+[ "$(wc -l < /tmp/restorations)" = 1 ] && [ "$(wc -l < /tmp/activations)" = 2 ]
+[ ! -e /recovered/var/guix-recovery ]
+[ "$(/recovered/bin/stack)" = new-management ] && [ "$(cat /recovered/source)" = new-source ]
+cmp /capsule/run /recovered/infra/guix/run.sh
+cmp /capsule/containers.conf /recovered/infra/guix/containers.conf
+[ "$(cat /recovered/.env)" = private-environment ]
+[ "$(cat /recovered/var/data/history)" = encrypted-history ]
+mkdir /unrelated
+reject /unrelated 'Accepted unrelated existing destination.'
+reject /recovered 'Repeated a completed recovery.'
+[ "$(wc -l < /tmp/restorations)" = 1 ] && [ "$(wc -l < /tmp/activations)" = 2 ]
+printf '%s\n' 'Synthetic recovery orchestration, resume, intent, locks and preservation passed; no real encryption or container lifecycle proved.'
+TEST
diff --git a/infra/guix/test.sh b/infra/guix/test.sh
new file mode 100644
index 0000000..4425303
--- /dev/null
+++ b/infra/guix/test.sh
@@ -0,0 +1,166 @@
+#!/bin/sh
+# Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+set -eu
+umask 077
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
+for SCRIPT in "$ROOT/infra/guix/"*.sh; do
+ sh -n "$SCRIPT"
+done
+sh "$ROOT/infra/guix/test-bootstrap.sh"
+sh "$ROOT/infra/guix/test-recovery.sh"
+STAGE=$(mktemp -d)
+trap 'find "$STAGE" -type d -exec chmod u+w {} +; rm -rf -- "$STAGE"' EXIT HUP INT TERM
+mkdir -p "$STAGE/source/infra/guix" "$STAGE/source/bin" "$STAGE/fake/bin" "$STAGE/toolbin" \
+ "$STAGE/fake/gnu/store/readonly" "$STAGE/sentinel"
+cp "$ROOT/infra/guix/"*.sh "$STAGE/source/infra/guix/"
+cp "$ROOT/infra/podman.conf" "$STAGE/source/infra/podman.conf"
+cp "$ROOT/infra/guix/containers.conf" "$STAGE/source/infra/guix/containers.conf"
+printf '%s\n' 'ciphertext fixture' > "$STAGE/backup.tar.gz.gpg"
+printf '%s\n' 'channels fixture' > "$STAGE/source/bin/guix-channels.scm"
+printf '%s\n' '#!/bin/sh' 'printf "%s\n" "${CONTAINERS_CONF:-}" > "$0.environment"' 'printf "%s\n" "$@"' > "$STAGE/source/bin/stack"
+chmod 700 "$STAGE/source/bin/stack"
+for TOOL in podman gpg; do
+ printf '%s\n' '#!/bin/sh' 'exit 0' > "$STAGE/fake/bin/$TOOL"
+ chmod 700 "$STAGE/fake/bin/$TOOL"
+done
+printf '%s\n' 'Immutable runtime content.' > "$STAGE/fake/gnu/store/readonly/value"
+printf '%s\n' 'Independent sentinel.' > "$STAGE/sentinel/value"
+ln -s "$STAGE/sentinel" "$STAGE/fake/gnu/store/readonly/outside"
+chmod 555 "$STAGE/sentinel"
+chmod 444 "$STAGE/sentinel/value"
+chmod 555 "$STAGE/fake/gnu/store" "$STAGE/fake/gnu/store/readonly"
+chmod 444 "$STAGE/fake/gnu/store/readonly/value"
+tar -czf "$STAGE/runtime.tar.gz" -C "$STAGE/fake" .
+chmod -R u+w "$STAGE/fake/gnu/store"
+cat > "$STAGE/toolbin/guix" <<'GUIX'
+#!/bin/sh
+set -eu
+case "$1" in
+ describe) printf '%s\n' 'channels fixture' ;;
+ time-machine) printf '%s\n' "$FAKE_PACK" ;;
+ *) exit 1 ;;
+esac
+GUIX
+chmod 700 "$STAGE/toolbin/guix"
+FAKE_PACK="$STAGE/runtime.tar.gz" PATH="$STAGE/toolbin:$PATH" sh "$STAGE/source/infra/guix/build.sh"
+[ -z "$(find "$STAGE/source/bin" -maxdepth 1 -name '.guix-build.*' -print -quit)" ] || {
+ echo 'Build left immutable staging directories.' >&2; exit 1;
+}
+[ "$(stat -c %a "$STAGE/sentinel")" = 555 ]
+[ "$(stat -c %a "$STAGE/sentinel/value")" = 444 ]
+cmp "$STAGE/runtime.tar.gz" "$STAGE/source/bin/guix-runtime.tar.gz"
+mkdir -p "$STAGE/release/stack/bin/chat" "$STAGE/release/stack/infra/guix" "$STAGE/release/stack/var"
+cp "$STAGE/source/bin/guix-runtime.tar.gz" "$STAGE/source/bin/guix-channels.scm" "$STAGE/release/stack/bin/"
+cp "$STAGE/source/infra/guix/"*.sh "$STAGE/release/stack/infra/guix/"
+cp "$ROOT/infra/guix/containers.conf" "$STAGE/release/stack/infra/guix/containers.conf"
+cp "$ROOT/LICENSE" "$STAGE/release/stack/LICENSE"
+printf '%s\n' 'services fixture' > "$STAGE/release/stack/infra/services.toml"
+printf '%s\n' 'image lock fixture' > "$STAGE/release/stack/var/image-lock.json"
+printf '%s\n' 'image export fixture' > "$STAGE/release/stack/var/images.tar"
+for ARTIFACT in server crypto.wasm SOURCE.tar.gz; do
+ printf '%s\n' 'chat artifact fixture' > "$STAGE/release/stack/bin/chat/$ARTIFACT"
+done
+cat > "$STAGE/release/stack/bin/stack" <<'STACK'
+#!/bin/sh
+set -eu
+[ "$#" = 4 ] && [ "$1" = restore ] && [ "$2" = --release ] || exit 1
+mkdir "$4"
+tar -xzf "$3" --strip-components=1 -C "$4"
+STACK
+chmod 700 "$STAGE/release/stack/bin/stack"
+tar -czf "$STAGE/release.tar.gz" -C "$STAGE/release" stack
+sh "$STAGE/source/infra/guix/bundle.sh" "$STAGE/backup.tar.gz.gpg" "$STAGE/release.tar.gz" "$STAGE/recovery.tar.gz"
+if sh "$STAGE/source/infra/guix/bundle.sh" "$STAGE/backup.tar.gz.gpg" "$STAGE/release.tar.gz" "$STAGE/recovery.tar.gz"; then
+ echo 'Overwrote existing archive.' >&2; exit 1
+fi
+tar -xzf "$STAGE/recovery.tar.gz" -C "$STAGE"
+[ -x "$STAGE/stack-recovery/bootstrap" ] || { echo 'Store bootstrap missing from capsule.' >&2; exit 1; }
+cmp "$ROOT/infra/guix/containers.conf" "$STAGE/stack-recovery/containers.conf"
+(cd "$STAGE/stack-recovery" && sha256sum --check --strict SHA256SUMS)
+cmp "$STAGE/backup.tar.gz.gpg" "$STAGE/stack-recovery/backup.tar.gz.gpg"
+cmp "$STAGE/release.tar.gz" "$STAGE/stack-recovery/release.tar.gz"
+cmp "$STAGE/release/stack/bin/stack" "$STAGE/stack-recovery/stack"
+cmp "$STAGE/release/stack/LICENSE" "$STAGE/stack-recovery/LICENSE"
+[ "$(stat -c %a "$STAGE/recovery.tar.gz")" = 600 ]
+if cmp -s "$STAGE/source/bin/stack" "$STAGE/stack-recovery/stack"; then
+ echo 'Bundled source-tree executable instead of release executable.' >&2; exit 1
+fi
+if "$STAGE/stack-recovery/restore" "$STAGE/source"; then
+ echo 'Accepted existing restore destination.' >&2; exit 1
+fi
+cp "$STAGE/source/infra/guix/run.sh" "$STAGE/source/bin/run"
+OUTPUT=$(sh "$STAGE/source/bin/run" status)
+EXPECTED=$(printf '%s\n' --root "$STAGE/source" status)
+[ "$OUTPUT" = "$EXPECTED" ]
+[ "$(cat "$STAGE/source/bin/stack.environment")" = "$STAGE/source/infra/guix/containers.conf" ]
+mv "$STAGE/source/bin/guix-runtime.tar.gz" "$STAGE/saved-runtime.tar.gz"
+OUTPUT=$(env -u CONTAINERS_CONF sh "$STAGE/source/bin/run" status)
+[ "$OUTPUT" = "$EXPECTED" ]
+[ "$(cat "$STAGE/source/bin/stack.environment")" = '' ]
+mv "$STAGE/saved-runtime.tar.gz" "$STAGE/source/bin/guix-runtime.tar.gz"
+find "$STAGE/source/var/guix" -name podman | read -r MATCH
+PUBLISHED=$(sha256sum "$STAGE/source/bin/guix-runtime.tar.gz")
+PUBLISHED=${PUBLISHED%% *}
+DIRECTORY_MODE=$(stat -c %a "$STAGE/source/var/guix/$PUBLISHED/gnu/store/readonly")
+FILE_MODE=$(stat -c %a "$STAGE/source/var/guix/$PUBLISHED/gnu/store/readonly/value")
+chmod -R a-w "$STAGE/fake/gnu/store"
+tar -czf "$STAGE/failed-runtime.tar.gz" -C "$STAGE/fake" bin/podman gnu
+chmod -R u+w "$STAGE/fake/gnu/store"
+cp "$STAGE/failed-runtime.tar.gz" "$STAGE/source/bin/guix-runtime.tar.gz"
+if sh "$STAGE/source/bin/run" status; then
+ echo 'Accepted runtime missing GnuPG.' >&2; exit 1;
+fi
+[ -z "$(find "$STAGE/source/var/guix" -maxdepth 1 -name '.unpack.*' -print -quit)" ] || {
+ echo 'Failed launcher left immutable staging directories.' >&2; exit 1;
+}
+[ -x "$STAGE/source/var/guix/$PUBLISHED/bin/gpg" ]
+[ "$(stat -c %a "$STAGE/source/var/guix/$PUBLISHED/gnu/store/readonly")" = "$DIRECTORY_MODE" ]
+[ "$(stat -c %a "$STAGE/source/var/guix/$PUBLISHED/gnu/store/readonly/value")" = "$FILE_MODE" ]
+[ "$(stat -c %a "$STAGE/sentinel")" = 555 ]
+[ "$(stat -c %a "$STAGE/sentinel/value")" = 444 ]
+cmp "$STAGE/runtime.tar.gz" "$STAGE/stack-recovery/runtime.tar.gz"
+cp "$STAGE/runtime.tar.gz" "$STAGE/source/bin/guix-runtime.tar.gz"
+printf 'tamper' >> "$STAGE/stack-recovery/backup.tar.gz.gpg"
+if (cd "$STAGE/stack-recovery" && sha256sum --check --strict SHA256SUMS); then
+ echo 'Accepted changed ciphertext.' >&2; exit 1
+fi
+rm "$STAGE/release/stack/bin/guix-channels.scm"
+tar -czf "$STAGE/missing.tar.gz" -C "$STAGE/release" stack
+if sh "$STAGE/source/infra/guix/bundle.sh" "$STAGE/backup.tar.gz.gpg" "$STAGE/missing.tar.gz" "$STAGE/other.tar.gz"; then
+ echo 'Accepted missing provenance.' >&2; exit 1
+fi
+cp "$STAGE/source/bin/guix-channels.scm" "$STAGE/release/stack/bin/guix-channels.scm"
+for ARTIFACT in image-lock.json images.tar; do
+ mv "$STAGE/release/stack/var/$ARTIFACT" "$STAGE/$ARTIFACT"
+ tar -czf "$STAGE/missing-image.tar.gz" -C "$STAGE/release" stack
+ if sh "$STAGE/source/infra/guix/bundle.sh" "$STAGE/backup.tar.gz.gpg" "$STAGE/missing-image.tar.gz" "$STAGE/missing-image-output.tar.gz"; then
+ echo 'Accepted release without complete saved images.' >&2; exit 1
+ fi
+ [ ! -e "$STAGE/missing-image-output.tar.gz" ]
+ mv "$STAGE/$ARTIFACT" "$STAGE/release/stack/var/$ARTIFACT"
+done
+for PRIVATE in .env var/data; do
+ mkdir -p "$(dirname "$STAGE/release/stack/$PRIVATE")"
+ printf '%s\n' 'private fixture' > "$STAGE/release/stack/$PRIVATE"
+ tar -czf "$STAGE/private.tar.gz" -C "$STAGE/release" stack
+ if sh "$STAGE/source/infra/guix/bundle.sh" "$STAGE/backup.tar.gz.gpg" "$STAGE/private.tar.gz" "$STAGE/private-output.tar.gz"; then
+ echo 'Accepted private release contents.' >&2; exit 1
+ fi
+ [ ! -e "$STAGE/private-output.tar.gz" ]
+ rm "$STAGE/release/stack/$PRIVATE"
+done
+printf '%s\n' 'Bundle, immutable cleanup, integrity, overwrite, launcher, and missing-artifact checks passed.'
diff --git a/infra/stack.service b/infra/stack.service
index 702da97..24e807a 100644
--- a/infra/stack.service
+++ b/infra/stack.service
@@ -23,8 +23,8 @@ Type=oneshot
RemainAfterExit=yes
KillMode=process
WorkingDirectory=%h/stack
-ExecStart=%h/stack/bin/stack up --production
-ExecStop=%h/stack/bin/stack down
+ExecStart=%h/stack/bin/run up --production
+ExecStop=%h/stack/bin/run down
TimeoutStartSec=300
TimeoutStopSec=120
Restart=on-failure
diff --git a/src/app.rs b/src/app.rs
index d655774..02f91ee 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -61,9 +61,10 @@ fn execute(arguments: Cli) -> Result<()> {
if let Action::Restore {
archive,
destination,
+ release,
} = &arguments.command
{
- return archive::restore(&absolute(archive)?, &absolute(destination)?);
+ return archive::restore(&absolute(archive)?, &absolute(destination)?, *release);
}
let project = Project::discover(arguments.root.as_deref())?;
if let Action::Chat {
@@ -132,22 +133,13 @@ fn execute(arguments: Cli) -> Result<()> {
include_images,
recipient,
key,
- } => {
- let output = destination.map_or_else(
- || Ok(project.root.join("var/exports")),
- |path| absolute(&path),
- )?;
- let configuration = stack_config::Config::load(&project.root)?;
- let selected_recipient =
- recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned());
- archive::backup(
- &project,
- &output,
- include_images,
- &selected_recipient,
- key.as_deref(),
- )
- }
+ } => backup(
+ &project,
+ destination,
+ include_images,
+ recipient,
+ key.as_deref(),
+ ),
Action::Release { destination } => {
let output = destination
.map_or_else(|| Ok(project.root.join("dist")), |path| absolute(&path))?;
@@ -161,6 +153,23 @@ fn execute(arguments: Cli) -> Result<()> {
}
}
+fn backup(
+ project: &Project,
+ destination: Option<PathBuf>,
+ include_images: bool,
+ recipient: Option<String>,
+ key: Option<&Path>,
+) -> Result<()> {
+ let output = destination.map_or_else(
+ || Ok(project.root.join("var/exports")),
+ |path| absolute(&path),
+ )?;
+ let configuration = stack_config::Config::load(&project.root)?;
+ let selected_recipient =
+ recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned());
+ archive::backup(project, &output, include_images, &selected_recipient, key)
+}
+
fn chat(
project: &Project,
init: bool,
@@ -207,6 +216,7 @@ fn licenses(project: &Project) -> Result<()> {
}
fn check(project: &Project) -> Result<()> {
+ process::run(project.command("sh")?.arg("infra/guix/test.sh"))?;
process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?;
process::run(
project
diff --git a/src/app/archive.rs b/src/app/archive.rs
index 2a78644..0965430 100644
--- a/src/app/archive.rs
+++ b/src/app/archive.rs
@@ -152,13 +152,13 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result<
})
.context("Caddy is not present")?;
let id = field(caddy, &["Id"])?.as_str().context("Caddy has no ID")?;
- let target = stage.join("var/data/caddy");
- fs::create_dir_all(&target)?;
for name in ["data", "config"] {
+ let target = stage.join("var/data/caddy").join(name);
+ files::private_dir(&target)?;
process::capture(
project
.command("podman")?
- .args(["cp", &format!("{id}:/{name}")])
+ .args(["cp", &format!("{id}:/{name}/.")])
.arg(&target),
)?;
}
@@ -174,6 +174,7 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result<
.context("Build a release locally before exporting so the backup includes a portable binary")?;
fs::copy(executable, stage.join("bin/stack"))?;
crate::app::chat::copy_artifacts(project, stage)?;
+ files::copy_runtime(&project.root, stage)?;
Ok(())
}
@@ -331,7 +332,29 @@ fn backup_inner(
process::line(format_args!("Encrypted export: {}", output.display()))
}
-pub(super) fn restore(archive: &Path, destination: &Path) -> Result<()> {
+fn restore_caddy(stage: &Path) -> Result<()> {
+ let root = stage.join("var/data/caddy");
+ let legacy = root.join("caddy");
+ if !legacy.exists() {
+ return Ok(());
+ }
+ let data = root.join("data/caddy");
+ let config = root.join("config/caddy");
+ ensure!(
+ !data.exists() && !config.exists(),
+ "Backup contains ambiguous legacy and current Caddy layouts"
+ );
+ files::private_dir(&root.join("data"))?;
+ fs::rename(&legacy, &data)?;
+ let autosave = data.join("autosave.json");
+ if autosave.exists() {
+ files::private_dir(&config)?;
+ fs::rename(autosave, config.join("autosave.json"))?;
+ }
+ Ok(())
+}
+
+pub(super) fn restore(archive: &Path, destination: &Path, require_release: bool) -> Result<()> {
ensure!(!destination.exists(), "Restore destination must not exist");
let parent = destination
.parent()
@@ -357,9 +380,16 @@ pub(super) fn restore(archive: &Path, destination: &Path) -> Result<()> {
files::extract(source, &stage)?;
let manifest = files::verify(&stage)?;
ensure!(
+ !require_release || manifest.kind == "release",
+ "A public release archive is required"
+ );
+ ensure!(
manifest.kind == "backup" || manifest.kind == "release",
"Unknown archive kind"
);
+ if manifest.kind == "backup" {
+ restore_caddy(&stage)?;
+ }
let project = Project::at(&stage)?;
let mut settings = project.settings()?;
settings.mode = Mode::Local;
@@ -390,8 +420,9 @@ pub(super) fn restore(archive: &Path, destination: &Path) -> Result<()> {
#[cfg(test)]
mod tests {
use std::fs;
+ use std::os::unix::fs::PermissionsExt;
- use anyhow::Result;
+ use anyhow::{Context, Result};
use crate::app::files;
use crate::app::project::{Mode, Project};
@@ -415,9 +446,7 @@ mod tests {
restored.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
anyhow::ensure!((count) == (1), "Test invariant failed");
anyhow::ensure!(
- (std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions())
- & 0o777)
- == (0o600),
+ (fs::metadata(snapshot)?.permissions().mode() & 0o777) == (0o600),
"Test invariant failed"
);
transaction.rollback()?;
@@ -438,7 +467,7 @@ mod tests {
let archive = workspace.path().join("backup.tar.gz");
files::pack(&source, &archive)?;
let destination = workspace.path().join("restored");
- super::restore(&archive, &destination)?;
+ super::restore(&archive, &destination, false)?;
anyhow::ensure!(
(Project::at(&destination)?.settings()?.mode) == (Mode::Local),
"Test invariant failed"
@@ -448,26 +477,236 @@ mod tests {
"Test invariant failed"
);
anyhow::ensure!(
- super::restore(&archive, &destination).is_err(),
+ super::restore(&archive, &destination, false).is_err(),
"Test invariant failed"
);
Ok(())
}
#[test]
+ fn restore_legacy_caddy_uses_active_mounts_without_changing_the_backup() -> Result<()> {
+ for autosave in [false, true] {
+ let workspace = tempfile::tempdir()?;
+ let source = workspace.path().join("source");
+ let legacy = source.join("var/data/caddy/caddy");
+ files::private_dir(&legacy)?;
+ for (name, bytes, mode) in [
+ ("acme/account.key", b"account fixture".as_slice(), 0o600),
+ ("certificates/site.key", b"key fixture".as_slice(), 0o600),
+ (
+ "certificates/site.crt",
+ b"certificate fixture".as_slice(),
+ 0o644,
+ ),
+ ] {
+ files::write(&legacy.join(name), bytes, mode)?;
+ }
+ if autosave {
+ files::write(&legacy.join("autosave.json"), b"config fixture", 0o600)?;
+ }
+ files::manifest(&source, "backup")?;
+ let original = files::inventory(&source)?;
+ let archive = workspace.path().join("backup.tar.gz");
+ files::pack(&source, &archive)?;
+ let digest = files::hash_file(&archive)?;
+ let destination = workspace.path().join("restored");
+ super::restore(&archive, &destination, false)?;
+ for name in [
+ "acme/account.key",
+ "certificates/site.key",
+ "certificates/site.crt",
+ ] {
+ let restored = destination.join("var/data/caddy/data/caddy").join(name);
+ anyhow::ensure!(
+ fs::read(&restored)? == fs::read(legacy.join(name))?,
+ "Caddy credential bytes changed"
+ );
+ anyhow::ensure!(
+ fs::metadata(&restored)?.permissions().mode() & 0o777
+ == fs::metadata(legacy.join(name))?.permissions().mode() & 0o777,
+ "Caddy credential permissions changed"
+ );
+ }
+ anyhow::ensure!(
+ fs::metadata(destination.join("var/data/caddy/data/caddy"))?
+ .permissions()
+ .mode()
+ & 0o777
+ == 0o700,
+ "Caddy directory permissions changed"
+ );
+ let config = destination.join("var/data/caddy/config/caddy/autosave.json");
+ anyhow::ensure!(config.exists() == autosave, "Autosave presence changed");
+ if autosave {
+ anyhow::ensure!(
+ fs::read(&config)? == b"config fixture",
+ "Autosave bytes changed"
+ );
+ anyhow::ensure!(
+ fs::metadata(config)?.permissions().mode() & 0o777 == 0o600,
+ "Autosave permissions changed"
+ );
+ }
+ anyhow::ensure!(
+ !destination.join("var/data/caddy/caddy").exists()
+ && !destination
+ .join("var/data/caddy/data/caddy/autosave.json")
+ .exists(),
+ "Legacy Caddy files remain outside their active mounts"
+ );
+ anyhow::ensure!(
+ files::inventory(&source)? == original,
+ "Backup source changed"
+ );
+ anyhow::ensure!(
+ files::hash_file(&archive)? == digest,
+ "Backup archive changed"
+ );
+ }
+ Ok(())
+ }
+
+ #[test]
+ fn restore_rejects_ambiguous_caddy_layouts_without_publishing() -> Result<()> {
+ for name in ["data", "config"] {
+ let workspace = tempfile::tempdir()?;
+ let source = workspace.path().join("source");
+ files::write(
+ &source.join("var/data/caddy/caddy/site.key"),
+ b"legacy",
+ 0o600,
+ )?;
+ files::write(
+ &source
+ .join("var/data/caddy")
+ .join(name)
+ .join("caddy/current"),
+ b"current",
+ 0o600,
+ )?;
+ files::manifest(&source, "backup")?;
+ let original = files::inventory(&source)?;
+ let archive = workspace.path().join("backup.tar.gz");
+ files::pack(&source, &archive)?;
+ let digest = files::hash_file(&archive)?;
+ let destination = workspace.path().join("restored");
+ anyhow::ensure!(
+ super::restore(&archive, &destination, false).is_err(),
+ "Ambiguous Caddy layouts were accepted"
+ );
+ anyhow::ensure!(!destination.exists(), "Ambiguous backup was published");
+ anyhow::ensure!(
+ files::inventory(&source)? == original,
+ "Backup source changed"
+ );
+ anyhow::ensure!(
+ files::hash_file(&archive)? == digest,
+ "Backup archive changed"
+ );
+ }
+ Ok(())
+ }
+
+ #[test]
+ fn restore_preserves_current_caddy_layout() -> Result<()> {
+ let workspace = tempfile::tempdir()?;
+ let source = workspace.path().join("source");
+ let data = source.join("var/data/caddy/data/caddy");
+ let config = source.join("var/data/caddy/config/caddy");
+ files::write(&data.join("certificates/site.key"), b"key fixture", 0o600)?;
+ files::write(&config.join("autosave.json"), b"config fixture", 0o600)?;
+ files::manifest(&source, "backup")?;
+ let archive = workspace.path().join("backup.tar.gz");
+ files::pack(&source, &archive)?;
+ let destination = workspace.path().join("restored");
+ super::restore(&archive, &destination, false)?;
+ anyhow::ensure!(
+ files::inventory(&source.join("var/data/caddy"))?
+ == files::inventory(&destination.join("var/data/caddy"))?,
+ "Current Caddy layout changed"
+ );
+ anyhow::ensure!(
+ fs::metadata(destination.join("var/data/caddy/data/caddy/certificates/site.key"))?
+ .permissions()
+ .mode()
+ & 0o777
+ == 0o600,
+ "Current Caddy key permissions changed"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn release_requirement_rejects_backup_and_accepts_release() -> Result<()> {
+ let workspace = tempfile::tempdir()?;
+ let source = workspace.path().join("source");
+ files::private_dir(&source)?;
+ fs::write(source.join(".env"), "private fixture")?;
+ files::write(
+ &source.join("var/data/caddy/caddy/site.key"),
+ b"legacy fixture",
+ 0o600,
+ )?;
+ files::write(
+ &source.join("var/data/caddy/data/caddy/site.key"),
+ b"current fixture",
+ 0o600,
+ )?;
+ files::manifest(&source, "backup")?;
+ let original = files::inventory(&source)?;
+ let backup = workspace.path().join("backup.tar.gz");
+ files::pack(&source, &backup)?;
+ let destination = workspace.path().join("restored");
+ let error = super::restore(&backup, &destination, true)
+ .err()
+ .context("Private backup accepted as public release")?;
+ anyhow::ensure!(
+ error.to_string().contains("public release archive"),
+ "Caddy normalization ran before the release guard"
+ );
+ anyhow::ensure!(!destination.exists(), "Rejected backup was published");
+ anyhow::ensure!(
+ files::inventory(&source)? == original,
+ "Rejected backup changed the original source"
+ );
+ fs::remove_file(source.join(".env"))?;
+ fs::remove_dir_all(source.join("var/data"))?;
+ files::manifest(&source, "release")?;
+ let release = workspace.path().join("release.tar.gz");
+ files::pack(&source, &release)?;
+ super::restore(&release, &destination, true)?;
+ anyhow::ensure!(destination.is_dir(), "Public release was not restored");
+ anyhow::ensure!(
+ !destination.join(".env").exists(),
+ "Private backup contents entered public release"
+ );
+ Ok(())
+ }
+
+ #[test]
fn corrupt_restore_never_publishes_a_destination() -> Result<()> {
let workspace = tempfile::tempdir()?;
let source = workspace.path().join("source");
files::private_dir(&source)?;
fs::write(source.join(".env"), "original")?;
+ for name in ["caddy", "data/caddy"] {
+ files::write(
+ &source.join("var/data/caddy").join(name).join("site.key"),
+ b"key fixture",
+ 0o600,
+ )?;
+ }
files::manifest(&source, "backup")?;
fs::write(source.join(".env"), "tampered")?;
let archive = workspace.path().join("backup.tar.gz");
files::pack(&source, &archive)?;
let destination = workspace.path().join("restored");
+ let error = super::restore(&archive, &destination, false)
+ .err()
+ .context("Corrupt archive was accepted")?;
anyhow::ensure!(
- super::restore(&archive, &destination).is_err(),
- "Test invariant failed"
+ error.to_string().contains("manifest"),
+ "Manifest was not verified first"
);
anyhow::ensure!(!destination.exists(), "Test invariant failed");
Ok(())
diff --git a/src/app/cli.rs b/src/app/cli.rs
index 1830ec1..f886812 100644
--- a/src/app/cli.rs
+++ b/src/app/cli.rs
@@ -72,6 +72,7 @@ pub(super) enum Action {
Restore {
archive: PathBuf,
destination: PathBuf,
+ release: bool,
},
Check,
@@ -135,6 +136,7 @@ fn archives() -> [Command; 4] {
.args([
path("archive").required(true),
path("destination").required(true),
+ flag("release").help("Require a public release archive"),
]),
Command::new("release")
.about("Check and build a portable production release locally")
@@ -227,6 +229,7 @@ fn action(name: &str, args: &mut ArgMatches) -> Result<Action, clap::Error> {
"restore" => Action::Restore {
archive: required(args, "archive")?,
destination: required(args, "destination")?,
+ release: args.get_flag("release"),
},
"check" => Action::Check,
"release" => Action::Release {
@@ -272,6 +275,22 @@ mod tests {
use std::path::PathBuf;
#[test]
+ fn restore_accepts_release_requirement() -> Result<()> {
+ let parsed = Cli::try_parse_from([
+ "stack",
+ "restore",
+ "--release",
+ "release.tar.gz",
+ "new-root",
+ ])?;
+ anyhow::ensure!(
+ matches!(parsed.command, Action::Restore { release: true, .. }),
+ "Release requirement missing"
+ );
+ Ok(())
+ }
+
+ #[test]
fn checked_command_schema() {
command().debug_assert();
}
@@ -321,7 +340,7 @@ mod tests {
anyhow::ensure!(Cli::try_parse_from(args).is_err(), "Test invariant failed");
}
anyhow::ensure!(
- matches!(Cli::try_parse_from(["stack", "restore", "export.gpg", "new-root"])?.command, Action::Restore { archive, destination } if archive == std::path::Path::new("export.gpg") && destination == std::path::Path::new("new-root")),
+ matches!(Cli::try_parse_from(["stack", "restore", "export.gpg", "new-root"])?.command, Action::Restore { archive, destination, release: false } if archive == std::path::Path::new("export.gpg") && destination == std::path::Path::new("new-root")),
"Test invariant failed"
);
Ok(())
diff --git a/src/app/files.rs b/src/app/files.rs
index 2c612a9..e96e5a4 100644
--- a/src/app/files.rs
+++ b/src/app/files.rs
@@ -161,6 +161,34 @@ pub(super) fn copy_source(root: &Path, destination: &Path) -> Result<()> {
Ok(())
}
+pub(super) fn copy_runtime(root: &Path, destination: &Path) -> Result<()> {
+ let archive = root.join("bin/guix-runtime.tar.gz");
+ let channels = root.join("bin/guix-channels.scm");
+ ensure!(
+ archive.is_file() == channels.is_file(),
+ "Guix runtime archive and channel provenance must both be present"
+ );
+ if archive.is_file() {
+ let bin = destination.join("bin");
+ fs::create_dir_all(&bin)?;
+ for name in ["guix-runtime.tar.gz", "guix-channels.scm"] {
+ let source = root.join("bin").join(name);
+ regular_tree(&source)?;
+ let pending = tempfile::NamedTempFile::new_in(&bin)?;
+ fs::copy(source, pending.path())?;
+ pending.as_file().sync_all()?;
+ pending
+ .persist(bin.join(name))
+ .map_err(|error| error.error)?;
+ }
+ }
+ let launcher = root.join("infra/guix/run.sh");
+ if launcher.is_file() {
+ write(&destination.join("bin/run"), &fs::read(launcher)?, 0o755)?;
+ }
+ Ok(())
+}
+
pub(super) fn inventory(root: &Path) -> Result<BTreeMap<String, String>> {
let mut files = BTreeMap::new();
for item in WalkDir::new(root).follow_links(false) {
@@ -279,6 +307,55 @@ mod tests {
use super::{copy_source, extract, manifest, member_path, pack, verify};
#[test]
+ fn runtime_artifacts_survive_archive_round_trip() -> Result<()> {
+ let source = tempfile::tempdir()?;
+ let stage = tempfile::tempdir()?;
+ fs::create_dir_all(source.path().join("bin"))?;
+ fs::create_dir_all(source.path().join("infra/guix"))?;
+ fs::write(source.path().join("infra/guix/run.sh"), "launcher")?;
+ super::copy_runtime(source.path(), stage.path())?;
+ anyhow::ensure!(stage.path().join("bin/run").is_file(), "Launcher missing");
+ fs::write(source.path().join("bin/guix-runtime.tar.gz"), "opaque pack")?;
+ anyhow::ensure!(
+ super::copy_runtime(source.path(), stage.path()).is_err(),
+ "Missing provenance accepted"
+ );
+ fs::write(
+ source.path().join("bin/guix-channels.scm"),
+ "pinned channels",
+ )?;
+ super::copy_runtime(source.path(), stage.path())?;
+ fs::set_permissions(
+ stage.path().join("bin/guix-runtime.tar.gz"),
+ std::os::unix::fs::PermissionsExt::from_mode(0o400),
+ )?;
+ fs::write(
+ source.path().join("bin/guix-runtime.tar.gz"),
+ "updated pack",
+ )?;
+ super::copy_runtime(source.path(), stage.path())?;
+ anyhow::ensure!(
+ fs::read_to_string(stage.path().join("bin/guix-runtime.tar.gz"))? == "updated pack",
+ "Read-only runtime was not replaced"
+ );
+ manifest(stage.path(), "backup")?;
+ let output = source.path().join("backup.tar.gz");
+ pack(stage.path(), &output)?;
+ let restored = source.path().join("restored");
+ extract(&output, &restored)?;
+ verify(&restored)?;
+ anyhow::ensure!(
+ fs::read_to_string(restored.join("bin/guix-runtime.tar.gz"))? == "updated pack",
+ "Runtime changed during recovery"
+ );
+ anyhow::ensure!(
+ fs::read_to_string(restored.join("bin/guix-channels.scm"))? == "pinned channels",
+ "Provenance changed during recovery"
+ );
+ Ok(())
+ }
+
+ #[test]
fn rejects_paths_outside_archive_root() -> Result<()> {
let destination = Path::new("/restore");
for name in ["/etc/passwd", "../escape", "stack/../escape", "other/file"] {
diff --git a/src/app/images.rs b/src/app/images.rs
index fac7aa7..b1105c2 100644
--- a/src/app/images.rs
+++ b/src/app/images.rs
@@ -130,10 +130,22 @@ fn save_inner(
},
);
}
+ let policy = tempfile::NamedTempFile::new()?;
+ serde_json::to_writer(
+ policy.as_file(),
+ &serde_json::json!({
+ "default": [{"type": "reject"}],
+ "transports": {
+ "containers-storage": {"": [{"type": "insecureAcceptAnything"}]}
+ }
+ }),
+ )?;
let mut command = project.command("podman")?;
command
+ .args(["save", "--signature-policy"])
+ .arg(policy.path());
+ command
.args([
- "save",
"--multi-image-archive",
"--format",
"docker-archive",
@@ -178,10 +190,23 @@ pub(super) fn prepare(project: &Project, settings: &mut Settings) -> Result<()>
archive.is_file(),
"Release image archive is missing; deploy a complete release"
);
+ let policy = tempfile::NamedTempFile::new()?;
+ serde_json::to_writer(
+ policy.as_file(),
+ &serde_json::json!({
+ "default": [{"type": "reject"}],
+ "transports": {
+ "docker-archive": {"": [{"type": "insecureAcceptAnything"}]},
+ "oci-archive": {"": [{"type": "insecureAcceptAnything"}]}
+ }
+ }),
+ )?;
process::run(
project
.command("podman")?
- .args(["image", "load", "--input"])
+ .args(["image", "load", "--signature-policy"])
+ .arg(policy.path())
+ .arg("--input")
.arg(archive),
)?;
}
diff --git a/src/app/release.rs b/src/app/release.rs
index 268a3c2..40d8d72 100644
--- a/src/app/release.rs
+++ b/src/app/release.rs
@@ -47,6 +47,7 @@ pub(super) fn create(project: &Project, destination: &Path) -> Result<()> {
files::private_dir(&stage)?;
files::copy_source(&project.root, &stage)?;
crate::app::chat::copy_artifacts(project, &stage)?;
+ files::copy_runtime(&project.root, &stage)?;
fs::create_dir_all(stage.join("bin"))?;
fs::copy(
project
@@ -116,6 +117,7 @@ pub(super) fn activate(archive: &Path, project: &Project) -> Result<()> {
0o755,
)?;
crate::app::chat::copy_artifacts(&pending, &project.root)?;
+ files::copy_runtime(&stage, &project.root)?;
for name in ["images.tar", "image-lock.json"] {
files::copy_tree(
&stage.join("var").join(name),