aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.cargo/config.toml17
-rw-r--r--.gitignore23
-rw-r--r--Cargo.lock650
-rw-r--r--Cargo.toml114
-rw-r--r--README.txt84
-rw-r--r--about.hbs44
-rw-r--r--about.toml17
-rw-r--r--crates/README.txt24
-rw-r--r--deny.toml (renamed from docker-compose.yml)43
-rw-r--r--docs/OPERATIONS.txt322
-rw-r--r--docs/POLICY.txt74
-rw-r--r--infra/Caddyfile (renamed from Caddyfile)40
-rw-r--r--infra/Caddyfile.local37
-rw-r--r--infra/containers/cgit/.containerignore21
-rw-r--r--infra/containers/cgit/Containerfile27
-rw-r--r--infra/containers/cgit/cgitrc27
-rw-r--r--infra/containers/cgit/cgitrc.local27
-rw-r--r--infra/containers/cgit/entrypoint.sh20
-rw-r--r--infra/containers/cgit/nginx.conf46
-rw-r--r--infra/containers/cgit/supervisord.conf42
-rwxr-xr-xinfra/host-setup.sh28
-rw-r--r--infra/searxng/settings.yml (renamed from searxng/settings.yml)0
-rw-r--r--infra/services.toml47
-rw-r--r--infra/stack.service33
-rw-r--r--rust-toolchain.toml20
-rw-r--r--src/archive.rs406
-rw-r--r--src/files.rs336
-rw-r--r--src/images.rs215
-rw-r--r--src/main.rs252
-rw-r--r--src/process.rs70
-rw-r--r--src/project.rs535
-rw-r--r--src/release.rs131
-rw-r--r--web/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dpbin0 -> 1286 bytes
-rw-r--r--web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dpbin0 -> 1286 bytes
-rw-r--r--web/.well-known/openpgpkey/luuduct.ai/policy (renamed from www/.well-known/openpgpkey/luuduct.ai/policy)0
-rw-r--r--web/.well-known/openpgpkey/policy (renamed from www/.well-known/openpgpkey/policy)0
-rw-r--r--web/THIRD-PARTY-LICENSES.html961
-rw-r--r--web/index.html (renamed from www/index.html)38
-rw-r--r--web/styles.css (renamed from www/styles.css)0
-rw-r--r--www/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dpbin427 -> 0 bytes
-rw-r--r--www/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dpbin427 -> 0 bytes
41 files changed, 4674 insertions, 97 deletions
diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..e30612d
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,17 @@
+# 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/>.
+
+[alias]
+stack = "run --locked --"
diff --git a/.gitignore b/.gitignore
index bba2338..0ec5a8e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,20 +13,11 @@
# 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/>.
-# Environment & secrets
-.env
-*.rev
-*.key
-*.priv
-
-# Caddy TLS certs and runtime state (prevents permission errors)
-caddy/
-
-# Empty placeholder directories
-git/
-
-# OS / Editor clutter
-.DS_Store
+/.env
+/var/
+/.tools/
+/target/
+/bin/
+/dist/
*.swp
-*.bak
-www/favicon.svg
+.DS_Store
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..13099a6
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,650 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+# 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/>.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+]
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hybrid-array"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.191"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rustix"
+version = "1.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.154"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "stack"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "chrono",
+ "clap",
+ "dotenvy",
+ "flate2",
+ "hex",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tar",
+ "tempfile",
+ "toml",
+ "walkdir",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.8+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+
+[[package]]
+name = "xattr"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..e08c687
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,114 @@
+# 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/>.
+
+[package]
+name = "stack"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.99"
+authors = ["Doan Luu Duc Tai <doan@luuduct.ai>"]
+license = "AGPL-3.0-or-later"
+description = "Rust management, backup, and deployment for a personal web stack."
+repository = "https://git.doan.luuduct.ai/stack"
+readme = "README.txt"
+keywords = ["self-hosting", "deployment", "backup", "podman", "cli"]
+categories = ["command-line-utilities", "web-programming"]
+publish = false
+exclude = ["var", ".tools", "target", "bin", "dist"]
+
+[dependencies]
+anyhow = "1.0"
+chrono = { version = "0.4", default-features = false, features = ["now", "std"] }
+clap = { version = "4.6", features = ["derive"] }
+dotenvy = "0.15"
+hex = "0.4"
+flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+sha2 = "0.11"
+tar = "0.4"
+tempfile = "3"
+toml = "1.1"
+walkdir = "2"
+
+[profile.release]
+opt-level = 3
+strip = true
+lto = true
+incremental = false
+
+[workspace]
+members = ["."]
+resolver = "3"
+
+[lints]
+workspace = true
+
+[workspace.lints.rustdoc]
+broken_intra_doc_links = "deny"
+private_intra_doc_links = "deny"
+missing_crate_level_docs = "deny"
+
+[workspace.lints.rust]
+unused = { level = "deny", priority = -1 }
+nonstandard_style = { level = "deny", priority = -1 }
+future_incompatible = { level = "deny", priority = -1 }
+rust_2018_idioms = { level = "deny", priority = -1 }
+let_underscore = { level = "deny", priority = -1 }
+unsafe_code = "deny"
+unstable_features = "deny"
+missing_docs = "deny"
+missing_debug_implementations = "deny"
+unreachable_pub = "deny"
+trivial_casts = "deny"
+trivial_numeric_casts = "deny"
+unreachable_patterns = "deny"
+warnings = "deny"
+unused_qualifications = "deny"
+
+[workspace.lints.clippy]
+all = { level = "deny", priority = -1 }
+correctness = { level = "deny", priority = -1 }
+suspicious = { level = "deny", priority = -1 }
+cargo = { level = "deny", priority = -1 }
+style = { level = "deny", priority = -1 }
+complexity = { level = "deny", priority = -1 }
+perf = { level = "deny", priority = -1 }
+pedantic = { level = "deny", priority = -1 }
+nursery = { level = "deny", priority = -1 }
+todo = "deny"
+print_stdout = "deny"
+print_stderr = "deny"
+unwrap_used = "deny"
+expect_used = "deny"
+dbg_macro = "deny"
+mem_forget = "deny"
+exit = "deny"
+integer_division = "deny"
+clone_on_ref_ptr = "deny"
+indexing_slicing = "deny"
+arithmetic_side_effects = "deny"
+float_arithmetic = "deny"
+as_conversions = "deny"
+shadow_reuse = "deny"
+shadow_same = "deny"
+unseparated_literal_suffix = "deny"
+undocumented_unsafe_blocks = "deny"
+cast_possible_truncation = "deny"
+cast_possible_wrap = "deny"
+cast_sign_loss = "deny"
+missing_errors_doc = "deny"
+multiple_crate_versions = "allow"
+redundant_pub_crate = "allow"
diff --git a/README.txt b/README.txt
index a461850..70a5931 100644
--- a/README.txt
+++ b/README.txt
@@ -1,18 +1,74 @@
-================================================================================
-SERVER STACK FOR https://doan.luuduct.ai (stack)
-================================================================================
+Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
-Personal infrastructure stack.
+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.
-LICENSE
---------------------------------------------------------------------------------
-All software, configuration files, and documentation in this repository are
-licensed under the GNU Affero General Public License v3.0 or later
-(AGPL-3.0-or-later). See the LICENSE file for details.
+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.
-Licensing Exceptions & Public Domain Dedications:
+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/>.
-- www/.well-known/openpgpkey/**:
- Public cryptographic key material and Web Key Directory endpoints are
- dedicated to the public domain under CC0 1.0 Universal:
- https://creativecommons.org/publicdomain/zero/1.0/
+STACK
+
+ Personal web services, developed and managed through a root Cargo workspace.
+ Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+ GNU Affero General Public License, version 3 or later. See LICENSE.
+
+GETTING STARTED
+
+ Install Rust through rustup, Podman, Git, and GnuPG on a Linux development
+ machine. The pinned toolchain includes rustfmt, Clippy, and the static
+ x86_64 Linux target.
+
+ From this directory:
+
+ cargo stack dev
+ cargo stack status
+ cargo stack down
+
+ Browse http://localhost:8080, /git/, and /search/.
+ A fresh checkout generates a private .env automatically.
+ Local mode binds only loopback and does not request public certificates.
+
+DEVELOPMENT AND PRODUCTION
+
+ src/ holds the Rust management command. Future Rust application backends
+ belong in crates/. Add each crate to the root workspace explicitly and
+ inherit its strict workspace lints.
+
+ infra/ holds service definitions, proxy configuration, container sources,
+ and the production user service. web/ holds the public website and WKD.
+ docs/ contains plaintext operations and policy documentation.
+
+ Only .env holds configured secrets. .env.example documents the keys.
+ var/ contains private persistent data, container storage, temporary work,
+ and exports. Generated TLS private keys are runtime data under var/.
+ .env, var/, .tools/, target/, bin/, and dist/ are excluded from Git.
+
+ The three existing services are Caddy, cgit, and SearXNG. The management
+ command is Rust. SearXNG and the cgit container's upstream supervisor retain
+ their third-party Python implementation. No first-party Python is needed.
+
+ Build and test on the development machine. Deploy prebuilt release
+ archives. The production host needs Podman, Git, and GnuPG, but no Rust
+ toolchain, Cargo, Compose, or Docker daemon.
+
+CHECKS AND LICENSES
+
+ cargo install --locked --version 0.20.2 cargo-deny --root .tools
+ cargo install --locked --version 0.9.3 --features cli cargo-about --root .tools
+ cargo stack check
+ cargo stack release
+
+ Check runs formatting, Clippy, tests, rustdoc, cargo-deny, and cargo-about.
+ THIRD-PARTY-LICENSES.html is generated from Cargo.lock and about.hbs.
+ Human-readable license headings use GNU's names. SPDX identifiers and
+ original third-party legal texts retain their exact spelling.
+
+ Detailed manual backup, recovery, deployment, and host setup instructions
+ are in docs/OPERATIONS.txt. Read docs/POLICY.txt before changing conventions.
diff --git a/about.hbs b/about.hbs
new file mode 100644
index 0000000..a2b47c5
--- /dev/null
+++ b/about.hbs
@@ -0,0 +1,44 @@
+<!doctype html>
+<!--
+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/>.
+-->
+
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Stack — third-party licenses</title>
+ <style>body{max-width:80ch;margin:2rem auto;padding:0 1rem;font:1rem/1.6 system-ui}pre{white-space:pre-wrap;overflow-wrap:anywhere}section{margin:3rem 0}a{color:inherit}</style>
+</head>
+<body>
+{{#*inline "gnu-name"}}{{#if (eq id "MIT")}}Expat License{{else if (eq id "Apache-2.0")}}Apache License, Version 2.0{{else if (eq id "BSD-3-Clause")}}Modified BSD License{{else if (eq id "BSD-2-Clause")}}FreeBSD License{{else if (eq id "0BSD")}}Zero-clause BSD License{{else if (eq id "Unicode-3.0")}}Unicode License v3{{else if (eq id "Zlib")}}License of ZLib{{else if (eq id "AGPL-3.0-or-later")}}GNU Affero General Public License, version 3 or later{{else}}{{name}}{{/if}}{{/inline}}
+<h1>Third-party licenses for Stack</h1>
+<p>Stack is covered by the GNU Affero General Public License, version 3 or later.
+This page covers the Rust dependencies recorded in Cargo.lock. Supporting container
+applications and their operating-system packages have their own notices.</p>
+<p>Headings follow <a href="https://www.gnu.org/licenses/license-list.html">GNU's
+license names</a> where listed. SPDX identifiers remain available for exact identification.
+The original license texts below are unchanged.</p>
+<ul>{{#each overview}}<li><a href="#{{id}}">{{> gnu-name}}</a> ({{count}})</li>{{/each}}</ul>
+{{#each licenses}}
+<section id="license-{{@index}}" data-spdx="{{id}}">
+{{#if first_of_kind}}<h2 id="{{id}}">{{> gnu-name}}</h2>{{/if}}
+<ul>{{#each used_by}}<li><a href="{{#if crate.repository}}{{crate.repository}}{{else}}https://crates.io/crates/{{crate.name}}{{/if}}">{{crate.name}} {{crate.version}}</a></li>{{/each}}</ul>
+<pre>{{text}}</pre>
+</section>
+{{/each}}
+</body>
+</html>
diff --git a/about.toml b/about.toml
new file mode 100644
index 0000000..7ed8f8c
--- /dev/null
+++ b/about.toml
@@ -0,0 +1,17 @@
+# 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/>.
+
+accepted = ["MIT", "Apache-2.0", "BSD-3-Clause", "Unicode-3.0", "Zlib", "0BSD"]
+private = { ignore = true }
diff --git a/crates/README.txt b/crates/README.txt
new file mode 100644
index 0000000..296f1a9
--- /dev/null
+++ b/crates/README.txt
@@ -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/>.
+
+RUST BACKENDS
+
+ New first-party web backends belong in subdirectories here. Add their
+ paths to the workspace members in the root Cargo.toml. Use edition 2024,
+ AGPL-3.0-or-later, and workspace lints. Keep configured secrets in the one
+ root .env and pass only the required keys to each service.
+
+ The current stack has no blog or comment backend. This directory reserves
+ a place for those features without adding an unused service or database.
diff --git a/docker-compose.yml b/deny.toml
index cd92b8b..1b21572 100644
--- a/docker-compose.yml
+++ b/deny.toml
@@ -13,31 +13,22 @@
# 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/>.
-services:
- caddy:
- image: caddy:2-alpine
- restart: unless-stopped
- ports:
- - "80:80"
- - "443:443"
- extra_hosts:
- - "host.docker.internal:host-gateway"
- volumes:
- - ./Caddyfile:/etc/caddy/Caddyfile:ro
- - ./caddy/data:/data
- - ./caddy/config:/config
- - ./www:/srv:ro
- networks: [gateway]
+[licenses]
+allow = ["Apache-2.0", "BSD-3-Clause", "Unicode-3.0", "MIT", "0BSD", "Zlib"]
- searxng:
- image: searxng/searxng:latest
- restart: unless-stopped
- cap_drop: [ALL]
- security_opt: [no-new-privileges:true]
- env_file: .env
- volumes:
- - ./searxng:/etc/searxng
- networks: [gateway]
+[[licenses.exceptions]]
+name = "stack"
+allow = ["AGPL-3.0-or-later"]
-networks:
- gateway:
+[bans]
+multiple-versions = "deny"
+multiple-versions-include-dev = true
+wildcards = "deny"
+skip = []
+
+[advisories]
+yanked = "deny"
+
+[sources]
+unknown-registry = "deny"
+unknown-git = "deny"
diff --git a/docs/OPERATIONS.txt b/docs/OPERATIONS.txt
new file mode 100644
index 0000000..6cd9a6b
--- /dev/null
+++ b/docs/OPERATIONS.txt
@@ -0,0 +1,322 @@
+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/>.
+
+STACK OPERATIONS AND RECOVERY
+
+ Application root: ~/stack
+ Development machine: local Linux workstation
+ Production host: ops@23.155.44.67
+ Public source: https://git.doan.luuduct.ai/stack
+ License: GNU Affero General Public License, version 3 or later
+
+WHAT YOU NEED TO KEEP
+
+ A full encrypted export made with --include-images contains public source,
+ the compiled management command, .env, persistent application data,
+ certificate state, bare repositories, working Git history if present, and
+ the exact service images. It includes a file checksum manifest.
+
+ It excludes the container engine's storage, build caches, old exports,
+ host packages, host SSH keys, firewall rules, disks, and OS configuration.
+ These are recreated on a new host. No old server filesystem is required.
+
+ The public Git repository alone is not a backup: it excludes .env, live
+ data, private certificates, and application repositories under var/.
+
+ Your GPG private key and its passphrase are needed to decrypt backups.
+ Keep the private-key export and revocation certificate in KeePassXC and
+ keep an independent copy of the database. The server has only the public
+ key. This guide never needs your private key or password in chat.
+
+ Default backup recipient fingerprint:
+ 3346AA3150F8F16828BB0D14F8C66DF77AFECEA6
+
+DAILY DEVELOPMENT
+
+ Run these from ~/stack on your workstation:
+
+ cargo stack dev
+ cargo stack status
+ cargo stack down
+
+ Visit http://localhost:8080, http://localhost:8080/git/, and
+ http://localhost:8080/search/. To use different ports:
+
+ cargo stack dev --port 9080
+
+ Local mode binds loopback only, rewrites public service links locally,
+ keeps public DNS untouched, and does not request public TLS certificates.
+ It automatically creates .env with a random SearXNG secret if absent.
+ Edit .env to change configured secrets. Do not commit it.
+
+ Public service settings belong in infra/, and website content in web/.
+ New Rust backends belong in crates/, explicitly added to Cargo.toml.
+ Services are declared in infra/services.toml. A service receives only the
+ .env keys listed in its environment array, not every project secret.
+
+ Rust commands use a project-specific Podman store under var/podman/.
+ A bare 'podman ps' lists your normal store, which may be empty. To inspect
+ this project's store manually, run from ~/stack:
+
+ podman --root "$PWD/var/podman/storage" --runroot "$PWD/var/podman/run" ps -a
+
+ Reuse those two global options before 'logs CONTAINER', 'exec CONTAINER',
+ or 'images'. Container names are shown by cargo stack status.
+ Never copy var/podman/ to another machine; export images instead.
+
+STRICT LOCAL CHECKS
+
+ Install the two development-only tools once:
+
+ cargo install --locked --version 0.20.2 cargo-deny --root .tools
+ cargo install --locked --version 0.9.3 --features cli cargo-about --root .tools
+
+ Then run:
+
+ cargo stack check
+
+ This checks formatting, all workspace Clippy targets/features, tests,
+ private rustdoc, dependency advisories, duplicate versions, sources, and
+ licenses. It regenerates THIRD-PARTY-LICENSES.html through cargo-about.
+ Use cargo fmt --all when formatting needs correction.
+
+ After changing dependency requirements, update Cargo.lock deliberately,
+ run the complete check, and commit the lockfile and generated license page.
+ Never disable audit failures merely to get a release out.
+
+EXPORT AND BACK UP PRODUCTION
+
+ On the VPS:
+
+ cd ~/stack
+ ./bin/stack backup --include-images
+
+ The command briefly pauses the three services while copying consistent
+ application files, then resumes them before compression and encryption.
+ It uses the published public key, verifies the recipient fingerprint,
+ checks bare Git repositories, saves exact images, and produces:
+
+ var/exports/stack-TIMESTAMP.tar.gz.gpg
+ var/exports/stack-TIMESTAMP.tar.gz.gpg.sha256
+
+ Both files have restrictive permissions. Temporary plaintext staging is
+ removed on normal completion and handled errors. No scheduled backup job
+ is installed. You decide when and where to retain exports.
+
+ On your workstation, create a destination under stack and copy the two
+ actual filenames printed by the command:
+
+ mkdir -p ~/stack/var/backups/production
+ scp ops@23.155.44.67:stack/var/exports/stack-TIMESTAMP.tar.gz.gpg ~/stack/var/backups/production/
+ scp ops@23.155.44.67:stack/var/exports/stack-TIMESTAMP.tar.gz.gpg.sha256 ~/stack/var/backups/production/
+ cd ~/stack/var/backups/production
+ sha256sum -c stack-TIMESTAMP.tar.gz.gpg.sha256
+
+ Replace TIMESTAMP with the actual timestamp. A checksum detects accidental
+ corruption; keep the trusted encrypted file and checksum together.
+ Retain at least one copy on another device or location.
+
+ Once copied and verified, delete only the corresponding VPS export files
+ if desired. Do not delete var/data or .env.
+
+ A smaller export without images is available through ./bin/stack backup.
+ It needs registry/build access for local startup and is not a standalone
+ production deployment. Prefer --include-images for disaster recovery.
+
+RESTORE ON ANOTHER LINUX MACHINE
+
+ Install Podman, Git, GnuPG, and normal rootless networking prerequisites.
+ Rootless Podman needs at least 65536 subordinate UIDs and GIDs for the
+ target user. Use that distribution's supported Podman installation.
+ The current ready-made binary/images target x86_64 Linux (linux/amd64).
+
+ Copy the encrypted export and its checksum to the new machine. Verify
+ the checksum first. Import your secret-key backup if not already present:
+
+ gpg --import private-key-backup.asc
+
+ GnuPG asks for the key passphrase through its normal local interface.
+ Never pass it on the command line or put it in .env.
+
+ If you already have a compatible stack command, restore with it:
+
+ ./bin/stack restore /path/to/stack-TIMESTAMP.tar.gz.gpg ~/stack-restored
+
+ The destination must not exist. Restore decrypts privately, rejects unsafe
+ archive paths and links, checks every manifest entry, assigns a unique
+ project identity, and defaults to local mode. It does not start services.
+ It refuses to overwrite an existing installation.
+
+ For a new machine with no stack command or Rust toolchain, bootstrap with
+ standard GnuPG and tar. The shell commands below work in bash and fish:
+
+ umask 077
+ mkdir -p ~/stack-bootstrap
+ gpg --output ~/stack-bootstrap/export.tar.gz --decrypt /path/to/stack-TIMESTAMP.tar.gz.gpg
+ tar -xzf ~/stack-bootstrap/export.tar.gz -C ~/stack-bootstrap stack/bin/stack
+ ~/stack-bootstrap/stack/bin/stack restore /path/to/stack-TIMESTAMP.tar.gz.gpg ~/stack
+
+ Only use this bootstrap with your own trusted export; the extracted binary
+ is executable code. The restore command validates the complete archive.
+ After successful restoration, delete ~/stack-bootstrap, which contains
+ the temporary decrypted archive. Keep only the encrypted export as backup.
+
+ Start the recovered stack locally first:
+
+ cd ~/stack
+ ./bin/stack up --local --port 8080
+ ./bin/stack status
+
+ Saved images load automatically and their identities are checked against
+ the image lock. No registry pull or compilation is needed on a matching
+ architecture. Check the homepage, Git repositories, search, and WKD.
+ Then stop it with ./bin/stack down before configuring production.
+
+ A release archive can be restored by the same command. Releases contain
+ source, a binary, and images, but no .env or application data. Starting a
+ restored release creates fresh state; it does not recover lost data.
+
+SET UP A NEW PRODUCTION HOST
+
+ Restore to the target user's ~/stack, using the steps above. On Debian,
+ adapt the username 'ops' in infra/host-setup.sh if necessary, review it,
+ and run:
+
+ sudo sh ~/stack/infra/host-setup.sh
+
+ It installs Podman and networking tools, checks existing subordinate
+ UID/GID ranges, enables user linger for boot, and sets:
+
+ net.ipv4.ip_unprivileged_port_start=80
+
+ This allows rootless listeners on ports 80 and above system-wide; ports
+ below 80 remain privileged. It does not change SSH or your firewall.
+ If the user lacks subordinate ranges, allocate unused ranges through
+ the distribution's account administration before rerunning the script.
+
+ On other Linux distributions, install equivalent prerequisites and set
+ that sysctl and user linger using the host's supported tools. Production
+ requires a systemd user manager for this project's supplied boot service.
+
+ Allow inbound 22/tcp, 80/tcp, 443/tcp, and 443/udp through the host/provider
+ firewall, for IPv4 and IPv6 as applicable. Backend container ports must not
+ be published. Preserve your SSH access and verify the address before DNS
+ changes. There is no need to restore Docker's old generated firewall rules.
+
+ Point public A records to the new IPv4 address, and AAAA records only to a
+ working new IPv6 address. Remove stale AAAA records. Public hostnames are
+ listed in infra/Caddyfile. Update canonical hosts there if changing domains.
+ Keep the www aliases and openpgpkey names pointing to the canonical hosts.
+
+ Start production:
+
+ cd ~/stack
+ ./bin/stack up --production
+
+ Production binds 80/tcp, 443/tcp, and 443/udp. Caddy manages certificates
+ and renewals automatically, redirects HTTP to HTTPS, and redirects www
+ aliases to the canonical URLs. Certificate state is in var/data/caddy/.
+ Public certificate issuance needs working public DNS and connectivity.
+
+ Enable startup at boot:
+
+ systemctl --user link ~/stack/infra/stack.service
+ systemctl --user daemon-reload
+ systemctl --user enable --now stack.service
+ systemctl --user status stack.service
+
+ This unit assumes ~/stack. Edit its paths if deliberately using another
+ location. User linger keeps it running without an interactive login.
+ Application containers use an unless-stopped restart policy.
+
+ If offering SSH Git access at the former VPS path, create this optional
+ host symlink as the administrator:
+
+ sudo install -d -m 755 /srv/git
+ sudo ln -s /home/ops/stack/var/data/repos/stack /srv/git/stack
+
+ Do not overwrite an existing repository there. The current VPS already
+ has this link, updated for the new layout. Public HTTPS Git works without
+ the optional host link.
+
+MAKE AND DEPLOY A RELEASE
+
+ Develop and run the checks on your workstation, then:
+
+ cd ~/stack
+ cargo stack release
+
+ Release reruns the full check, builds a static Rust binary locally,
+ builds the cgit container locally, and packages the configured image set.
+ Outputs are dist/stack-release-TIMESTAMP.tar.gz and its .sha256 file.
+ No .env, live data, .git history, or previous backup is included.
+
+ Test the release in a fresh local directory before deploying, using the
+ restore command followed by up --local on an unused port. Inspect the
+ same four services and then stop the test installation.
+
+ Commit and push reviewed public changes with normal Git:
+
+ git add -A
+ git diff --cached --stat
+ git commit -m 'Describe the change'
+ git push origin main
+
+ .gitignore excludes configured secrets and all runtime/artifact trees.
+ Review the staged files. Deployment uses the release archive, not git pull.
+
+ Copy the actual archive and checksum to production:
+
+ ssh ops@23.155.44.67 'mkdir -p ~/stack/var/incoming'
+ scp dist/stack-release-TIMESTAMP.tar.gz ops@23.155.44.67:stack/var/incoming/
+ scp dist/stack-release-TIMESTAMP.tar.gz.sha256 ops@23.155.44.67:stack/var/incoming/
+
+ On the VPS:
+
+ cd ~/stack/var/incoming
+ sha256sum -c stack-release-TIMESTAMP.tar.gz.sha256
+ cd ~/stack
+ ./bin/stack activate var/incoming/stack-release-TIMESTAMP.tar.gz
+ ./bin/stack status
+
+ Activation verifies the release and platform, replaces public source and
+ the binary, loads exact images, preserves .env and var/data, and replaces
+ the running containers. It does not compile Rust or build container images.
+ Expect a brief interruption while containers are replaced. Take a verified
+ export first if a future change could require recovery.
+
+ After verifying production, delete the corresponding incoming archive and
+ checksum if you do not want release copies left on the VPS.
+
+RECOVERY LIMITS AND SECRET ROTATION
+
+ Exports capture this application, not an entire bootable VPS image.
+ Host account, SSH, firewall, and package setup remain host responsibilities.
+ Changing CPU architecture requires a native release build. Registry access
+ is needed only when images are omitted or rebuilding for another target.
+
+ Current persistent state is files, Git repositories, and Caddy state.
+ When adding a database, add its supported flush/dump procedure to the
+ export workflow and test recovery before relying on that database's backup.
+
+ If changing the OpenPGP backup recipient, publish its public key, update
+ the command's default fingerprint, and test encryption and restoration.
+ For one export, use --recipient FULL_FINGERPRINT --key /path/public-key.asc.
+ Keep old secret keys while any retained export still depends on them.
+
+ Legacy format-1 exports made before the Rust migration use their embedded
+ historical Python recovery helper. Keep that helper with those exports.
+ The current command intentionally accepts only its verified format-2
+ archives. Fresh format-2 production backups replace the normal workflow.
diff --git a/docs/POLICY.txt b/docs/POLICY.txt
new file mode 100644
index 0000000..f9e3614
--- /dev/null
+++ b/docs/POLICY.txt
@@ -0,0 +1,74 @@
+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/>.
+
+PROJECT CONVENTIONS
+
+ License: GNU Affero General Public License, version 3 or later.
+ SPDX identifier: AGPL-3.0-or-later.
+ Copyright holder: Doan Luu Duc Tai <doan@luuduct.ai>, 2026.
+
+ Every nontrivial first-party source, configuration, template, and prose
+ file carries the AGPL copyright and permission header. LICENSE is the
+ unmodified license text. Binary public keys and empty WKD policy files
+ retain their protocol format. Generated Cargo.lock retains Cargo's own
+ metadata as well as the project notice.
+
+ Do not add explanatory source comments. Express intent through structure
+ and names. Legal headers are the required exception. Use .txt for prose
+ documentation. The generated HTML license page is an explicit exception.
+ Rust documentation attributes provide crate metadata without comments.
+
+ Keep configured secrets only in the root .env. Never commit .env or var/.
+ .env.example lists required keys with placeholders. Public hostnames,
+ public GPG keys, paths, configuration, and source belong in Git.
+ TLS account/certificate private keys, databases, and other generated
+ private state are persistent data in var/data, not .env configuration.
+ The GPG secret key and revocation certificate remain outside this project.
+
+STRICTNESS
+
+ Preserve the requested Rust and Clippy deny groups, including pedantic
+ and nursery. The one additional exception is redundant_pub_crate: its
+ advice to make internal binary items public conflicts with unreachable_pub,
+ which remains denied. Internal interfaces remain explicitly crate-private.
+ No unsafe code, unwrap, expect, todo, debug output, arithmetic shortcuts,
+ or broad warning suppression is introduced.
+
+ cargo-deny rejects duplicate crate versions, wildcard requirements, unknown
+ registries, unknown Git sources, and yanked versions. There are no duplicate
+ version skip exceptions. Its AGPL allowance is scoped to this package.
+ The dependency allowlist retains the requested permissive licenses and
+ Zlib; an unused allowance may produce an informational audit warning.
+
+ Human license names follow https://www.gnu.org/licenses/license-list.html.
+ In particular, display Expat License for SPDX MIT. Do not alter original
+ copyright notices, legal texts, crate metadata, or machine SPDX identifiers.
+ cargo-about covers Rust dependencies. Supporting container applications
+ and their operating-system packages carry additional upstream licenses.
+
+PORTABILITY
+
+ All first-party project files and state live below stack/. Standard host
+ integration consists of packages, subordinate UID/GID allocations, one
+ low-port sysctl setting, user linger, and a systemd user-service link.
+ Host SSH keys, firewall policy, accounts, and disks are host administration,
+ not application export contents.
+
+ Releases currently contain a static x86_64 Linux command and native
+ linux/amd64 container images. They are portable across compatible x86_64
+ Linux machines. An ARM machine needs a native release build; the command
+ rejects mismatched production images. Development mode may fetch/build
+ native images on an architecture mismatch. Container source and Rust
+ source are public so new targets can be added without restoring an OS.
diff --git a/Caddyfile b/infra/Caddyfile
index 862ba00..3146529 100644
--- a/Caddyfile
+++ b/infra/Caddyfile
@@ -13,7 +13,12 @@
# 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/>.
+(https_security) {
+ header >Strict-Transport-Security "max-age=31536000"
+}
+
luuduct.ai {
+ import https_security
handle /.well-known/openpgpkey/* {
root * /srv
header Content-Type application/octet-stream
@@ -26,29 +31,28 @@ luuduct.ai {
}
www.luuduct.ai {
+ import https_security
redir https://doan.luuduct.ai{uri} permanent
}
doan.luuduct.ai {
+ import https_security
root * /srv
file_server
redir /git /git/
- handle /send-message {
- reverse_proxy host.docker.internal:8085
- }
-
handle /cgit* {
- reverse_proxy host.docker.internal:8082
+ reverse_proxy cgit:8080
}
handle /git/* {
- reverse_proxy host.docker.internal:8082
+ reverse_proxy cgit:8080
}
}
openpgpkey.luuduct.ai {
+ import https_security
handle /.well-known/openpgpkey/* {
root * /srv
header Content-Type application/octet-stream
@@ -57,11 +61,33 @@ openpgpkey.luuduct.ai {
}
git.doan.luuduct.ai {
+ import https_security
redir /{path}.git /{path} permanent
- reverse_proxy host.docker.internal:8082
+ reverse_proxy cgit:8080
}
search.doan.luuduct.ai {
+ import https_security
reverse_proxy searxng:8080
}
+
+www.doan.luuduct.ai {
+ import https_security
+ redir https://doan.luuduct.ai{uri} permanent
+}
+
+www.git.doan.luuduct.ai {
+ import https_security
+ redir https://git.doan.luuduct.ai{uri} permanent
+}
+
+www.search.doan.luuduct.ai {
+ import https_security
+ redir https://search.doan.luuduct.ai{uri} permanent
+}
+
+www.openpgpkey.luuduct.ai {
+ import https_security
+ redir https://openpgpkey.luuduct.ai{uri} permanent
+}
diff --git a/infra/Caddyfile.local b/infra/Caddyfile.local
new file mode 100644
index 0000000..03b4a16
--- /dev/null
+++ b/infra/Caddyfile.local
@@ -0,0 +1,37 @@
+# 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/>.
+
+:80 {
+ @git path /git /git/* /cgit.js /cgit.png
+ handle @git {
+ redir /git /git/
+ uri strip_prefix /git
+ reverse_proxy cgit:8080
+ }
+
+ @search path /search /search/*
+ handle @search {
+ redir /search /search/
+ uri strip_prefix /search
+ reverse_proxy searxng:8080 {
+ header_up X-Script-Name /search
+ }
+ }
+
+ handle {
+ root * /srv
+ file_server
+ }
+}
diff --git a/infra/containers/cgit/.containerignore b/infra/containers/cgit/.containerignore
new file mode 100644
index 0000000..4fb13e4
--- /dev/null
+++ b/infra/containers/cgit/.containerignore
@@ -0,0 +1,21 @@
+# 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/>.
+
+*
+!Containerfile
+!entrypoint.sh
+!nginx.conf
+!supervisord.conf
+!cgitrc
diff --git a/infra/containers/cgit/Containerfile b/infra/containers/cgit/Containerfile
new file mode 100644
index 0000000..3bd130a
--- /dev/null
+++ b/infra/containers/cgit/Containerfile
@@ -0,0 +1,27 @@
+# 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/>.
+
+FROM docker.io/library/debian:trixie-slim@sha256:1e1a22175fbf1b32157ddaf0219e1db4e5401fab2b94ed285add64d18587cd2d
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ cgit fcgiwrap nginx-light supervisor curl ca-certificates git \
+ && rm -rf /var/lib/apt/lists/* /etc/nginx/sites-enabled/default
+COPY nginx.conf /etc/nginx/nginx.conf
+COPY supervisord.conf /etc/supervisor/stack.conf
+COPY cgitrc /etc/cgitrc
+COPY entrypoint.sh /usr/local/bin/stack-cgit
+RUN chmod 755 /usr/local/bin/stack-cgit
+EXPOSE 8080
+ENTRYPOINT ["/usr/local/bin/stack-cgit"]
diff --git a/infra/containers/cgit/cgitrc b/infra/containers/cgit/cgitrc
new file mode 100644
index 0000000..0b563e0
--- /dev/null
+++ b/infra/containers/cgit/cgitrc
@@ -0,0 +1,27 @@
+# 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/>.
+
+root-title=Doan Luu Duc Tai's repositories
+root-desc=Personal git repos
+virtual-root=/
+clone-prefix=https://git.doan.luuduct.ai
+css=/cgit.css
+favicon=/favicon.ico
+remove-suffix=1
+defbranch=main
+cache-root=/var/cache/cgit
+cache-size=1000
+cache-scanrc-ttl=1
+scan-path=/srv/git
diff --git a/infra/containers/cgit/cgitrc.local b/infra/containers/cgit/cgitrc.local
new file mode 100644
index 0000000..3c8d657
--- /dev/null
+++ b/infra/containers/cgit/cgitrc.local
@@ -0,0 +1,27 @@
+# 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/>.
+
+root-title=Doan Luu Duc Tai's repositories (local)
+root-desc=Restored local copy
+virtual-root=/git/
+clone-prefix=http://localhost:8080/git
+css=/git/cgit.css
+favicon=/git/favicon.ico
+remove-suffix=1
+defbranch=main
+cache-root=/var/cache/cgit
+cache-size=1000
+cache-scanrc-ttl=1
+scan-path=/srv/git
diff --git a/infra/containers/cgit/entrypoint.sh b/infra/containers/cgit/entrypoint.sh
new file mode 100644
index 0000000..5de3a06
--- /dev/null
+++ b/infra/containers/cgit/entrypoint.sh
@@ -0,0 +1,20 @@
+#!/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
+mkdir -p /run/fcgiwrap /var/cache/cgit
+chown www-data:www-data /run/fcgiwrap /var/cache/cgit
+exec /usr/bin/supervisord -n -c /etc/supervisor/stack.conf
diff --git a/infra/containers/cgit/nginx.conf b/infra/containers/cgit/nginx.conf
new file mode 100644
index 0000000..df50345
--- /dev/null
+++ b/infra/containers/cgit/nginx.conf
@@ -0,0 +1,46 @@
+# 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/>.
+
+user www-data;
+worker_processes auto;
+pid /run/nginx.pid;
+error_log /dev/stderr warn;
+events { worker_connections 1024; }
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+ access_log /dev/stdout;
+ map $http_x_forwarded_proto $cgi_https {
+ default "";
+ https on;
+ }
+ server {
+ listen 8080;
+ server_name _;
+ root /usr/share/cgit;
+ location ~* ^/(cgit\.(css|png|js)|favicon\.ico|robots\.txt)$ {
+ expires 30d;
+ }
+ location / {
+ include /etc/nginx/fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME /usr/lib/cgit/cgit.cgi;
+ fastcgi_param PATH_INFO $uri;
+ fastcgi_param QUERY_STRING $args;
+ fastcgi_param HTTP_HOST $http_host;
+ fastcgi_param HTTPS $cgi_https;
+ fastcgi_pass unix:/run/fcgiwrap/socket;
+ }
+ }
+}
diff --git a/infra/containers/cgit/supervisord.conf b/infra/containers/cgit/supervisord.conf
new file mode 100644
index 0000000..1a93bb5
--- /dev/null
+++ b/infra/containers/cgit/supervisord.conf
@@ -0,0 +1,42 @@
+# 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/>.
+
+[supervisord]
+nodaemon=true
+user=root
+logfile=/dev/null
+pidfile=/run/supervisord.pid
+
+[program:fcgiwrap]
+command=/usr/sbin/fcgiwrap -f -s unix:/run/fcgiwrap/socket
+user=www-data
+umask=0007
+autorestart=true
+stopasgroup=true
+killasgroup=true
+stdout_logfile=/dev/fd/1
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/fd/2
+stderr_logfile_maxbytes=0
+
+[program:nginx]
+command=/usr/sbin/nginx -g "daemon off;"
+autorestart=true
+stopasgroup=true
+killasgroup=true
+stdout_logfile=/dev/fd/1
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/fd/2
+stderr_logfile_maxbytes=0
diff --git a/infra/host-setup.sh b/infra/host-setup.sh
new file mode 100755
index 0000000..e73645b
--- /dev/null
+++ b/infra/host-setup.sh
@@ -0,0 +1,28 @@
+#!/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
+[ "$(id -u)" = 0 ] || { echo 'Run this script through sudo.' >&2; exit 1; }
+id ops >/dev/null
+apt-get update
+apt-get install -y --no-install-recommends podman uidmap passt slirp4netns fuse-overlayfs netavark aardvark-dns
+awk -F: '$1 == "ops" && $3 >= 65536 { found=1 } END { exit !found }' /etc/subuid
+awk -F: '$1 == "ops" && $3 >= 65536 { found=1 } END { exit !found }' /etc/subgid
+install -d -m 755 /etc/sysctl.d
+printf '%s\n' 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/90-stack-rootless-ports.conf
+sysctl -p /etc/sysctl.d/90-stack-rootless-ports.conf
+loginctl enable-linger ops
+printf '%s\n' 'Podman prerequisites ready. Existing production services remain running.'
diff --git a/searxng/settings.yml b/infra/searxng/settings.yml
index f8f7d6b..f8f7d6b 100644
--- a/searxng/settings.yml
+++ b/infra/searxng/settings.yml
diff --git a/infra/services.toml b/infra/services.toml
new file mode 100644
index 0000000..97091cf
--- /dev/null
+++ b/infra/services.toml
@@ -0,0 +1,47 @@
+# 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/>.
+
+format = 1
+
+[services.caddy]
+image = "docker.io/library/caddy:2-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648"
+gateway = true
+health = ["caddy", "validate", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
+mounts = [
+ { source = "@caddy", target = "/etc/caddy/Caddyfile", read_only = true },
+ { source = "var/data/caddy/data", target = "/data" },
+ { source = "var/data/caddy/config", target = "/config" },
+ { source = "@web", target = "/srv", read_only = true },
+]
+
+[services.cgit]
+image = "localhost/stack-cgit:0.1.0"
+build = "infra/containers/cgit"
+health = ["curl", "--fail", "--silent", "--max-time", "3", "http://127.0.0.1:8080/"]
+mounts = [
+ { source = "var/data/repos", target = "/srv/git", read_only = true },
+ { source = "@cgit", target = "/etc/cgitrc", read_only = true },
+]
+tmpfs = ["/run", "/var/cache/cgit"]
+
+[services.searxng]
+image = "docker.io/searxng/searxng:latest@sha256:1dab138ea70a8ceb4d1c182f5c8f256088406c1742b99e279976157dbe1e9759"
+environment = ["SEARXNG_SECRET"]
+drop_capabilities = true
+health = ["wget", "--quiet", "--spider", "http://127.0.0.1:8080/"]
+mounts = [
+ { source = "var/data/searxng", target = "/etc/searxng" },
+]
+tmpfs = ["/var/cache/searxng:mode=1777"]
diff --git a/infra/stack.service b/infra/stack.service
new file mode 100644
index 0000000..64012a9
--- /dev/null
+++ b/infra/stack.service
@@ -0,0 +1,33 @@
+# 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/>.
+
+[Unit]
+Description=Personal web stack
+Wants=network-online.target
+After=network-online.target
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+WorkingDirectory=%h/stack
+ExecStart=%h/stack/bin/stack up --production
+ExecStop=%h/stack/bin/stack down
+TimeoutStartSec=300
+TimeoutStopSec=120
+Restart=on-failure
+RestartSec=10
+
+[Install]
+WantedBy=default.target
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 0000000..7c40415
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,20 @@
+# 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/>.
+
+[toolchain]
+channel = "1.99.1"
+profile = "minimal"
+components = ["clippy", "rustfmt"]
+targets = ["x86_64-unknown-linux-musl"]
diff --git a/src/archive.rs b/src/archive.rs
new file mode 100644
index 0000000..e7e6bc5
--- /dev/null
+++ b/src/archive.rs
@@ -0,0 +1,406 @@
+// 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/>.
+
+use std::collections::BTreeSet;
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+
+use anyhow::{Context, Result, ensure};
+use serde_json::Value;
+
+use crate::project::{Mode, Project, Settings, field};
+use crate::{files, process};
+
+pub(crate) const RECIPIENT: &str = "3346AA3150F8F16828BB0D14F8C66DF77AFECEA6";
+const KEY: &str = "web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp";
+
+#[derive(Debug)]
+struct Paused<'a> {
+ project: &'a Project,
+ ids: Vec<String>,
+}
+
+impl Paused<'_> {
+ fn resume(&mut self) -> Result<()> {
+ let mut failure = None;
+ for id in self.ids.iter().rev() {
+ if let Err(error) =
+ process::capture(self.project.command("podman")?.args(["unpause", id]))
+ {
+ process::error(format_args!("Could not resume container {id}: {error:#}"))?;
+ failure = Some(error);
+ }
+ }
+ self.ids.clear();
+ if let Some(error) = failure {
+ return Err(error);
+ }
+ Ok(())
+ }
+}
+
+impl Drop for Paused<'_> {
+ fn drop(&mut self) {
+ if let Err(error) = self.resume() {
+ drop(process::error(format_args!(
+ "Container resume failed: {error:#}"
+ )));
+ }
+ }
+}
+
+pub(crate) fn containers(project: &Project, settings: &Settings) -> Result<Vec<Value>> {
+ let records = project.inspect_containers(settings)?;
+ let actual: BTreeSet<_> = records
+ .iter()
+ .map(|record| {
+ field(record, &["Config", "Labels", "io.stack.service"])?
+ .as_str()
+ .context("Missing service label")
+ })
+ .collect::<Result<_>>()?;
+ let configuration = project.configuration(settings)?;
+ let expected: BTreeSet<_> = configuration.services.keys().map(String::as_str).collect();
+ ensure!(
+ actual == expected,
+ "Running services differ from configuration; refusing an incomplete backup"
+ );
+ Ok(records)
+}
+
+fn snapshot(project: &Project, records: &[Value], stage: &Path) -> Result<()> {
+ let mut guard = Paused {
+ project,
+ ids: Vec::new(),
+ };
+ for record in records {
+ if field(record, &["State", "Running"])?.as_bool() == Some(true)
+ && field(record, &["State", "Paused"])?.as_bool() == Some(false)
+ {
+ let id = field(record, &["Id"])?
+ .as_str()
+ .context("Missing container ID")?;
+ process::capture(project.command("podman")?.args(["pause", id]))?;
+ guard.ids.push(id.to_owned());
+ }
+ }
+ let result = snapshot_files(project, records, stage);
+ let resume = guard.resume();
+ result?;
+ resume
+}
+
+fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result<()> {
+ files::copy_source(&project.root, stage)?;
+ files::copy_tree(&project.root.join(".env"), &stage.join(".env"))?;
+ if project.root.join(".git").is_dir() {
+ files::copy_tree(&project.root.join(".git"), &stage.join(".git"))?;
+ }
+ let data = project.root.join("var/data");
+ for item in fs::read_dir(&data)? {
+ let entry = item?;
+ if entry.file_name() != "caddy" {
+ files::copy_tree(
+ &entry.path(),
+ &stage.join("var/data").join(entry.file_name()),
+ )?;
+ }
+ }
+ let caddy = records
+ .iter()
+ .find(|record| {
+ field(record, &["Config", "Labels", "io.stack.service"])
+ .ok()
+ .and_then(Value::as_str)
+ == Some("caddy")
+ })
+ .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"] {
+ process::capture(
+ project
+ .command("podman")?
+ .args(["cp", &format!("{id}:/{name}")])
+ .arg(&target),
+ )?;
+ }
+ fs::create_dir_all(stage.join("bin"))?;
+ let executable = [
+ project.root.join("bin/stack"),
+ project
+ .root
+ .join("target/x86_64-unknown-linux-musl/release/stack"),
+ ]
+ .into_iter()
+ .find(|path| path.is_file())
+ .context("Build a release locally before exporting so the backup includes a portable binary")?;
+ fs::copy(executable, stage.join("bin/stack"))?;
+ Ok(())
+}
+
+pub(crate) fn backup(
+ project: &Project,
+ destination: &Path,
+ with_images: bool,
+ recipient: &str,
+ public_key: Option<&Path>,
+) -> Result<()> {
+ ensure!(
+ recipient.len() == 40 && recipient.bytes().all(|byte| byte.is_ascii_hexdigit()),
+ "A full 40-character recipient fingerprint is required"
+ );
+ files::private_dir(destination)?;
+ let settings = project.settings()?;
+ let records = containers(project, &settings)?;
+ let temp = tempfile::Builder::new()
+ .prefix(".export-")
+ .tempdir_in(destination)?;
+ let home = temp.path().join("gnupg");
+ files::private_dir(&home)?;
+ let key = public_key.map_or_else(|| project.root.join(KEY), Path::to_path_buf);
+ let operation = backup_inner(
+ project,
+ destination,
+ with_images,
+ &Encryption {
+ recipient,
+ key: &key,
+ home: &home,
+ },
+ &records,
+ temp.path(),
+ );
+ drop(process::capture(
+ Command::new("gpgconf")
+ .arg("--homedir")
+ .arg(&home)
+ .args(["--kill", "all"]),
+ ));
+ operation
+}
+
+#[derive(Debug)]
+struct Encryption<'a> {
+ recipient: &'a str,
+ key: &'a Path,
+ home: &'a Path,
+}
+
+fn backup_inner(
+ project: &Project,
+ destination: &Path,
+ with_images: bool,
+ encryption: &Encryption<'_>,
+ records: &[Value],
+ temp: &Path,
+) -> Result<()> {
+ let Encryption {
+ recipient,
+ key,
+ home,
+ } = encryption;
+ process::capture(
+ Command::new("gpg")
+ .args(["--no-options", "--batch", "--homedir"])
+ .arg(home)
+ .args(["--import"])
+ .arg(key),
+ )?;
+ let listed = process::text(
+ Command::new("gpg")
+ .args(["--no-options", "--batch", "--homedir"])
+ .arg(home)
+ .args(["--with-colons", "--list-keys", recipient]),
+ )?;
+ ensure!(
+ listed
+ .lines()
+ .filter(|line| line.starts_with("fpr:"))
+ .any(|line| line
+ .split(':')
+ .nth(9)
+ .is_some_and(|found| found.eq_ignore_ascii_case(recipient))),
+ "Public key fingerprint does not match the requested backup recipient"
+ );
+ let stage = temp.join("stack");
+ files::private_dir(&stage)?;
+ snapshot(project, records, &stage)?;
+ for entry in fs::read_dir(stage.join("var/data/repos"))? {
+ let repo = entry?.path();
+ if repo.is_dir() {
+ process::capture(
+ Command::new("git")
+ .arg("--git-dir")
+ .arg(repo)
+ .args(["fsck", "--full"]),
+ )?;
+ }
+ }
+ if with_images {
+ crate::images::save(project, records, &stage.join("var"))?;
+ }
+ files::manifest(&stage, "backup")?;
+ let plain = temp.join("stack.tar.gz");
+ files::pack(&stage, &plain)?;
+ let name = format!(
+ "stack-{}.tar.gz.gpg",
+ chrono::Utc::now().format("%Y%m%dT%H%M%SZ")
+ );
+ let encrypted = temp.join(&name);
+ process::run(
+ Command::new("gpg")
+ .args(["--no-options", "--batch", "--homedir"])
+ .arg(home)
+ .args([
+ "--rfc4880",
+ "--trust-model",
+ "always",
+ "--recipient",
+ recipient,
+ "--cipher-algo",
+ "AES256",
+ "--compress-algo",
+ "none",
+ "--set-filename",
+ "stack.tar.gz",
+ "--output",
+ ])
+ .arg(&encrypted)
+ .arg("--encrypt")
+ .arg(plain),
+ )?;
+ let output = destination.join(&name);
+ ensure!(
+ !output.exists(),
+ "An export with this timestamp already exists"
+ );
+ fs::rename(&encrypted, &output)?;
+ let digest = format!("{} {name}\n", files::hash_file(&output)?);
+ files::write(
+ &destination.join(format!("{name}.sha256")),
+ digest.as_bytes(),
+ 0o600,
+ )?;
+ process::line(format_args!("Encrypted export: {}", output.display()))
+}
+
+pub(crate) fn restore(archive: &Path, destination: &Path) -> Result<()> {
+ ensure!(!destination.exists(), "Restore destination must not exist");
+ let parent = destination
+ .parent()
+ .context("Restore destination has no parent")?;
+ fs::create_dir_all(parent)?;
+ let temp = tempfile::Builder::new()
+ .prefix(".restore-")
+ .tempdir_in(parent)?;
+ let decrypted = temp.path().join("stack.tar.gz");
+ let source = if archive.extension().is_some_and(|suffix| suffix == "gpg") {
+ process::run(
+ Command::new("gpg")
+ .args(["--no-options", "--output"])
+ .arg(&decrypted)
+ .arg("--decrypt")
+ .arg(archive),
+ )?;
+ &decrypted
+ } else {
+ archive
+ };
+ let stage = temp.path().join("stack");
+ files::extract(source, &stage)?;
+ let manifest = files::verify(&stage)?;
+ ensure!(
+ manifest.kind == "backup" || manifest.kind == "release",
+ "Unknown archive kind"
+ );
+ let project = Project::at(&stage)?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Local;
+ settings.project = format!(
+ "stack-{}",
+ files::hash_bytes(destination.as_os_str().as_encoded_bytes())
+ .chars()
+ .take(10)
+ .collect::<String>()
+ );
+ project.save(&settings)?;
+ if stage.join(".git").is_dir() {
+ process::capture(
+ Command::new("git")
+ .arg("-C")
+ .arg(&stage)
+ .args(["remote", "set-url", "origin"])
+ .arg(destination.join("var/data/repos/stack")),
+ )?;
+ }
+ fs::rename(&stage, destination)?;
+ process::line(format_args!(
+ "Restored and verified {}. No services started.",
+ destination.display()
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use anyhow::Result;
+
+ use crate::files;
+ use crate::project::{Mode, Project};
+
+ #[test]
+ fn restore_is_local_and_refuses_an_existing_destination() -> Result<()> {
+ let workspace = tempfile::tempdir()?;
+ let source = workspace.path().join("source");
+ files::private_dir(&source)?;
+ fs::write(source.join(".env"), "SEARXNG_SECRET=private\n")?;
+ let project = Project::at(&source)?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Production;
+ project.save(&settings)?;
+ 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)?;
+ assert_eq!(Project::at(&destination)?.settings()?.mode, Mode::Local);
+ assert_eq!(
+ fs::read_to_string(destination.join(".env"))?,
+ "SEARXNG_SECRET=private\n"
+ );
+ assert!(super::restore(&archive, &destination).is_err());
+ 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")?;
+ 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");
+ assert!(super::restore(&archive, &destination).is_err());
+ assert!(!destination.exists());
+ Ok(())
+ }
+}
diff --git a/src/files.rs b/src/files.rs
new file mode 100644
index 0000000..b939311
--- /dev/null
+++ b/src/files.rs
@@ -0,0 +1,336 @@
+// 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/>.
+
+use std::collections::BTreeMap;
+use std::fs::{self, File, OpenOptions, Permissions};
+use std::io::{Read, Write};
+use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
+use std::path::{Component, Path, PathBuf};
+
+use anyhow::{Context, Result, bail, ensure};
+use flate2::Compression;
+use flate2::read::GzDecoder;
+use flate2::write::GzEncoder;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use walkdir::WalkDir;
+
+pub(crate) const SOURCE: &[&str] = &[
+ "Cargo.toml",
+ "Cargo.lock",
+ "rust-toolchain.toml",
+ ".cargo",
+ ".gitignore",
+ ".forgejo",
+ "src",
+ "tests",
+ "crates",
+ "infra",
+ "web",
+ "docs",
+ "README.txt",
+ "LICENSE",
+ "deny.toml",
+ "about.toml",
+ "about.hbs",
+ ".env.example",
+];
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Manifest {
+ pub(crate) format: u32,
+ pub(crate) kind: String,
+ pub(crate) version: String,
+ pub(crate) created_utc: String,
+ pub(crate) files: BTreeMap<String, String>,
+}
+
+pub(crate) fn private_dir(path: &Path) -> Result<()> {
+ fs::DirBuilder::new()
+ .recursive(true)
+ .mode(0o700)
+ .create(path)?;
+ fs::set_permissions(path, Permissions::from_mode(0o700))?;
+ Ok(())
+}
+
+pub(crate) fn write(path: &Path, data: &[u8], mode: u32) -> Result<()> {
+ let parent = path.parent().context("File has no parent directory")?;
+ fs::create_dir_all(parent)?;
+ let mut pending = tempfile::NamedTempFile::new_in(parent)?;
+ pending
+ .as_file_mut()
+ .set_permissions(Permissions::from_mode(mode))?;
+ pending.write_all(data)?;
+ pending.as_file().sync_all()?;
+ pending.persist(path).map_err(|error| error.error)?;
+ Ok(())
+}
+
+pub(crate) fn hash_bytes(bytes: &[u8]) -> String {
+ hex::encode(Sha256::digest(bytes))
+}
+
+pub(crate) fn hash_file(path: &Path) -> Result<String> {
+ let mut file = File::open(path).with_context(|| format!("Cannot read {}", path.display()))?;
+ let mut digest = Sha256::new();
+ let mut buffer = [0; 8192];
+ loop {
+ let length = file.read(&mut buffer)?;
+ if length == 0 {
+ break;
+ }
+ digest.update(buffer.get(..length).context("Invalid read length")?);
+ }
+ Ok(hex::encode(digest.finalize()))
+}
+
+pub(crate) fn relative(path: &Path, base: &Path) -> Result<String> {
+ Ok(path
+ .strip_prefix(base)?
+ .to_str()
+ .context("Paths must be UTF-8")?
+ .to_owned())
+}
+
+pub(crate) fn regular_tree(path: &Path) -> Result<()> {
+ for item in WalkDir::new(path).follow_links(false) {
+ let entry = item?;
+ ensure!(
+ entry.file_type().is_dir() || entry.file_type().is_file(),
+ "Links and special files are not accepted: {}",
+ entry.path().display()
+ );
+ }
+ Ok(())
+}
+
+pub(crate) fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
+ regular_tree(source)?;
+ if source.is_file() {
+ let parent = destination.parent().context("File has no parent")?;
+ fs::create_dir_all(parent)?;
+ fs::copy(source, destination)?;
+ return Ok(());
+ }
+ for item in WalkDir::new(source).follow_links(false) {
+ let entry = item?;
+ let target = destination.join(entry.path().strip_prefix(source)?);
+ if entry.file_type().is_dir() {
+ fs::create_dir_all(&target)?;
+ fs::set_permissions(&target, entry.metadata()?.permissions())?;
+ } else {
+ let parent = target.parent().context("File has no parent")?;
+ fs::create_dir_all(parent)?;
+ fs::copy(entry.path(), &target)?;
+ }
+ }
+ Ok(())
+}
+
+pub(crate) fn copy_source(root: &Path, destination: &Path) -> Result<()> {
+ for name in SOURCE {
+ let source = root.join(name);
+ if source.exists() {
+ copy_tree(&source, &destination.join(name))?;
+ }
+ }
+ Ok(())
+}
+
+pub(crate) fn inventory(root: &Path) -> Result<BTreeMap<String, String>> {
+ let mut files = BTreeMap::new();
+ for item in WalkDir::new(root).follow_links(false) {
+ let entry = item?;
+ ensure!(!entry.file_type().is_symlink(), "Archive contains a link");
+ if entry.file_type().is_file() {
+ let name = relative(entry.path(), root)?;
+ if name != "manifest.json" {
+ files.insert(name, hash_file(entry.path())?);
+ }
+ }
+ }
+ Ok(files)
+}
+
+pub(crate) fn manifest(root: &Path, kind: &str) -> Result<()> {
+ let record = Manifest {
+ format: 2,
+ kind: kind.to_owned(),
+ version: env!("CARGO_PKG_VERSION").to_owned(),
+ created_utc: chrono::Utc::now().to_rfc3339(),
+ files: inventory(root)?,
+ };
+ write(
+ &root.join("manifest.json"),
+ &serde_json::to_vec_pretty(&record)?,
+ 0o600,
+ )
+}
+
+pub(crate) fn verify(root: &Path) -> Result<Manifest> {
+ let record: Manifest = serde_json::from_reader(File::open(root.join("manifest.json"))?)?;
+ ensure!(
+ record.format == 2,
+ "Unsupported export format; legacy Python exports retain their embedded recovery helper"
+ );
+ ensure!(
+ inventory(root)? == record.files,
+ "Archive file list or checksums do not match the manifest"
+ );
+ Ok(record)
+}
+
+pub(crate) fn pack(root: &Path, archive: &Path) -> Result<()> {
+ regular_tree(root)?;
+ let file = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(archive)?;
+ let encoder = GzEncoder::new(file, Compression::new(3));
+ let mut builder = tar::Builder::new(encoder);
+ builder.follow_symlinks(false);
+ builder.append_dir_all("stack", root)?;
+ builder.into_inner()?.finish()?.sync_all()?;
+ Ok(())
+}
+
+pub(crate) fn member_path(path: &Path, destination: &Path) -> Result<PathBuf> {
+ let mut parts = path.components();
+ ensure!(
+ parts.next() == Some(Component::Normal("stack".as_ref())),
+ "Archive must have a stack root"
+ );
+ let mut target = destination.to_path_buf();
+ for part in parts {
+ match part {
+ Component::Normal(name) => target.push(name),
+ _ => bail!("Unsafe archive path: {}", path.display()),
+ }
+ }
+ Ok(target)
+}
+
+pub(crate) fn extract(archive: &Path, destination: &Path) -> Result<()> {
+ private_dir(destination)?;
+ let mut reader = tar::Archive::new(GzDecoder::new(File::open(archive)?));
+ let mut directories = Vec::new();
+ for item in reader.entries()? {
+ let mut entry = item?;
+ let kind = entry.header().entry_type();
+ ensure!(
+ kind.is_dir() || kind.is_file(),
+ "Archive links and special files are forbidden"
+ );
+ let target = member_path(&entry.path()?, destination)?;
+ let mode = entry.header().mode()? & 0o777;
+ if kind.is_dir() {
+ fs::create_dir_all(&target)?;
+ directories.push((target, mode | 0o700));
+ } else {
+ let parent = target.parent().context("File has no parent")?;
+ fs::create_dir_all(parent)?;
+ let mut output = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(mode)
+ .open(&target)?;
+ std::io::copy(&mut entry, &mut output)?;
+ output.sync_all()?;
+ }
+ }
+ for (path, mode) in directories.into_iter().rev() {
+ fs::set_permissions(path, Permissions::from_mode(mode))?;
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+ use std::path::Path;
+
+ use anyhow::Result;
+
+ use super::{copy_source, extract, manifest, member_path, pack, verify};
+
+ #[test]
+ fn rejects_paths_outside_archive_root() {
+ let destination = Path::new("/restore");
+ for name in ["/etc/passwd", "../escape", "stack/../escape", "other/file"] {
+ assert!(member_path(Path::new(name), destination).is_err());
+ }
+ assert_eq!(
+ member_path(Path::new("stack/web/index.html"), destination).ok(),
+ Some(destination.join("web/index.html"))
+ );
+ }
+
+ #[test]
+ fn detects_modified_and_extra_files() -> Result<()> {
+ let root = tempfile::tempdir()?;
+ fs::write(root.path().join("public.txt"), "original")?;
+ manifest(root.path(), "release")?;
+ assert!(verify(root.path()).is_ok());
+ fs::write(root.path().join("public.txt"), "changed")?;
+ assert!(verify(root.path()).is_err());
+ fs::write(root.path().join("public.txt"), "original")?;
+ fs::write(root.path().join("unexpected.txt"), "extra")?;
+ assert!(verify(root.path()).is_err());
+ Ok(())
+ }
+
+ #[test]
+ fn source_export_excludes_secrets_and_runtime() -> Result<()> {
+ let source = tempfile::tempdir()?;
+ let target = tempfile::tempdir()?;
+ fs::write(source.path().join("Cargo.toml"), "source")?;
+ fs::write(source.path().join(".env"), "private")?;
+ fs::create_dir(source.path().join("var"))?;
+ fs::write(source.path().join("var/private.key"), "private")?;
+ copy_source(source.path(), target.path())?;
+ assert!(target.path().join("Cargo.toml").is_file());
+ assert!(!target.path().join(".env").exists());
+ assert!(!target.path().join("var").exists());
+ Ok(())
+ }
+
+ #[test]
+ fn archives_round_trip_without_overwriting() -> Result<()> {
+ let workspace = tempfile::tempdir()?;
+ let source = workspace.path().join("source");
+ fs::create_dir(&source)?;
+ fs::write(source.join(".env"), "secret")?;
+ manifest(&source, "backup")?;
+ let archive = workspace.path().join("backup.tar.gz");
+ pack(&source, &archive)?;
+ let restored = workspace.path().join("restored");
+ extract(&archive, &restored)?;
+ assert!(verify(&restored).is_ok());
+ assert_eq!(fs::read_to_string(restored.join(".env"))?, "secret");
+ assert!(extract(&archive, &restored).is_err());
+ Ok(())
+ }
+
+ #[test]
+ fn rejects_symbolic_links() -> Result<()> {
+ let root = tempfile::tempdir()?;
+ std::os::unix::fs::symlink("/etc/passwd", root.path().join("link"))?;
+ assert!(super::regular_tree(root.path()).is_err());
+ Ok(())
+ }
+}
diff --git a/src/images.rs b/src/images.rs
new file mode 100644
index 0000000..11daaea
--- /dev/null
+++ b/src/images.rs
@@ -0,0 +1,215 @@
+// 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/>.
+
+use std::collections::BTreeMap;
+use std::fs::{self, File};
+use std::path::Path;
+
+use anyhow::{Context, Result, ensure};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::project::{Mode, Project, Settings, field};
+use crate::{files, process};
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+struct Image {
+ reference: String,
+ architecture: String,
+ layers: Value,
+ id: String,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+struct ImageLock {
+ format: u32,
+ platform: String,
+ services: BTreeMap<String, Image>,
+}
+
+fn inspect(project: &Project, name: &str) -> Result<Value> {
+ let records: Vec<Value> = serde_json::from_slice(
+ &process::capture(project.command("podman")?.args(["image", "inspect", name]))?.stdout,
+ )?;
+ records
+ .into_iter()
+ .next()
+ .context("Docker returned no image")
+}
+
+pub(crate) fn save(project: &Project, containers: &[Value], destination: &Path) -> Result<()> {
+ fs::create_dir_all(destination)?;
+ let platform = project.platform()?;
+ let mut services = BTreeMap::new();
+ let mut new_tags = Vec::new();
+ let result = save_inner(
+ project,
+ containers,
+ destination,
+ &mut services,
+ &mut new_tags,
+ );
+ for tag in new_tags {
+ if let Err(error) = process::capture(project.command("podman")?.args(["image", "rm", &tag]))
+ {
+ process::error(format_args!(
+ "Could not remove temporary image tag {tag}: {error:#}"
+ ))?;
+ }
+ }
+ result?;
+ let lock = ImageLock {
+ format: 2,
+ platform,
+ services,
+ };
+ files::write(
+ &destination.join("image-lock.json"),
+ &serde_json::to_vec_pretty(&lock)?,
+ 0o600,
+ )
+}
+
+fn save_inner(
+ project: &Project,
+ containers: &[Value],
+ destination: &Path,
+ services: &mut BTreeMap<String, Image>,
+ new_tags: &mut Vec<String>,
+) -> Result<()> {
+ for container in containers {
+ let service = field(container, &["Config", "Labels", "io.stack.service"])?
+ .as_str()
+ .context("Missing service label")?;
+ let id = field(container, &["Image"])?
+ .as_str()
+ .context("Missing image ID")?;
+ let suffix: String = id.trim_start_matches("sha256:").chars().take(16).collect();
+ let alias = format!("localhost/stack-export-{service}:{suffix}");
+ if !process::available(
+ project
+ .command("podman")?
+ .args(["image", "inspect", &alias]),
+ ) {
+ process::capture(
+ project
+ .command("podman")?
+ .args(["image", "tag", id, &alias]),
+ )?;
+ new_tags.push(alias.clone());
+ }
+ let actual = inspect(project, &alias)?;
+ services.insert(
+ service.to_owned(),
+ Image {
+ reference: alias,
+ architecture: field(&actual, &["Architecture"])?
+ .as_str()
+ .context("Missing architecture")?
+ .to_owned(),
+ layers: field(&actual, &["RootFS", "Layers"])?.clone(),
+ id: field(&actual, &["Id"])?
+ .as_str()
+ .context("Missing image ID")?
+ .trim_start_matches("sha256:")
+ .to_owned(),
+ },
+ );
+ }
+ let mut command = project.command("podman")?;
+ command
+ .args([
+ "save",
+ "--multi-image-archive",
+ "--format",
+ "docker-archive",
+ "--output",
+ ])
+ .arg(destination.join("images.tar"));
+ for image in services.values() {
+ command.arg(&image.reference);
+ }
+ process::run(&mut command)
+}
+
+pub(crate) fn prepare(project: &Project, settings: &mut Settings) -> Result<()> {
+ let path = project.root.join("var/image-lock.json");
+ if settings.online || !path.is_file() {
+ return Ok(());
+ }
+ let lock: ImageLock = serde_json::from_reader(File::open(path)?)?;
+ ensure!(lock.format == 2, "Unsupported image lock format");
+ let platform = project.platform()?;
+ if platform.trim() != lock.platform {
+ ensure!(
+ settings.mode == Mode::Local,
+ "Release images target {}, but this server is {}; build a native release locally",
+ lock.platform,
+ platform.trim()
+ );
+ settings.online = true;
+ return process::line(format_args!(
+ "Using native images/builds for {}",
+ platform.trim()
+ ));
+ }
+ let missing = lock.services.values().any(|item| {
+ project.command("podman").is_ok_and(|mut command| {
+ !process::available(command.args(["image", "inspect", &item.reference]))
+ })
+ });
+ if missing {
+ let archive = project.root.join("var/images.tar");
+ ensure!(
+ archive.is_file(),
+ "Release image archive is missing; deploy a complete release"
+ );
+ process::run(
+ project
+ .command("podman")?
+ .args(["image", "load", "--input"])
+ .arg(archive),
+ )?;
+ }
+ for (service, image) in lock.services {
+ let actual = inspect(project, &image.reference)?;
+ ensure!(
+ field(&actual, &["Architecture"])?.as_str() == Some(image.architecture.as_str())
+ && field(&actual, &["RootFS", "Layers"])? == &image.layers
+ && field(&actual, &["Id"])?
+ .as_str()
+ .context("Missing image ID")?
+ .trim_start_matches("sha256:")
+ == image.id,
+ "Loaded image does not match its content lock: {service}"
+ );
+ }
+ Ok(())
+}
+
+pub(crate) fn configured(project: &Project, settings: &Settings) -> Result<Vec<Value>> {
+ let config = project.configuration(settings)?;
+ let mut records = Vec::new();
+ for (service, value) in config.services {
+ let actual = inspect(project, &value.image)?;
+ records.push(serde_json::json!({
+ "Image": field(&actual, &["Id"])?,
+ "Config": {"Labels": {"io.stack.service": service}}
+ }));
+ }
+ Ok(records)
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..ec8784e
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,252 @@
+// 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/>.
+
+#![doc = "Rust commands for personal web infrastructure."]
+
+pub(crate) mod archive;
+pub(crate) mod files;
+pub(crate) mod images;
+pub(crate) mod process;
+pub(crate) mod project;
+pub(crate) mod release;
+
+use std::path::{Path, PathBuf};
+use std::process::ExitCode;
+
+use anyhow::Result;
+use clap::{Parser, Subcommand};
+
+use crate::project::{Mode, Project};
+
+#[derive(Debug, Parser)]
+#[command(
+ name = "stack",
+ version,
+ about = "Develop, deploy, and back up the personal web stack"
+)]
+struct Cli {
+ #[arg(
+ long,
+ global = true,
+ help = "Application root; otherwise discovered from the working directory or binary"
+ )]
+ root: Option<PathBuf>,
+ #[command(subcommand)]
+ command: Action,
+}
+
+#[derive(Debug, Subcommand)]
+enum Action {
+ #[command(about = "Start the local development services")]
+ Dev {
+ #[arg(long, default_value_t = 8080)]
+ port: u16,
+ },
+ #[command(about = "Start or update services in the selected mode")]
+ Up {
+ #[arg(long, conflicts_with = "local")]
+ production: bool,
+ #[arg(long)]
+ local: bool,
+ #[arg(long)]
+ port: Option<u16>,
+ #[arg(long)]
+ online: bool,
+ },
+ #[command(about = "Show service status")]
+ Status,
+ #[command(about = "Stop services while retaining data")]
+ Down,
+ #[command(about = "Create a GPG-encrypted application export")]
+ Backup {
+ destination: Option<PathBuf>,
+ #[arg(long)]
+ include_images: bool,
+ #[arg(long, default_value = archive::RECIPIENT)]
+ recipient: String,
+ #[arg(long)]
+ key: Option<PathBuf>,
+ },
+ #[command(
+ about = "Decrypt, validate, and restore into a new directory without starting services"
+ )]
+ Restore {
+ archive: PathBuf,
+ destination: PathBuf,
+ },
+ #[command(about = "Run formatting, strict lints, tests, documentation, and dependency checks")]
+ Check,
+ #[command(about = "Check and build a portable production release locally")]
+ Release { destination: Option<PathBuf> },
+ #[command(about = "Install a prebuilt release without compiling on the server")]
+ Activate { archive: PathBuf },
+ #[command(about = "Generate the third-party license page with GNU license names")]
+ Licenses,
+}
+
+fn main() -> ExitCode {
+ let arguments = match Cli::try_parse() {
+ Ok(arguments) => arguments,
+ Err(error) => {
+ let status = if error.use_stderr() {
+ ExitCode::FAILURE
+ } else {
+ ExitCode::SUCCESS
+ };
+ drop(error.print());
+ return status;
+ }
+ };
+ match execute(arguments) {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ drop(process::error(format_args!("Error: {error:#}")));
+ ExitCode::FAILURE
+ }
+ }
+}
+
+fn execute(arguments: Cli) -> Result<()> {
+ if let Action::Restore {
+ archive,
+ destination,
+ } = &arguments.command
+ {
+ return archive::restore(&absolute(archive)?, &absolute(destination)?);
+ }
+ let project = Project::discover(arguments.root.as_deref())?;
+ let _operation = project.lock()?;
+ match arguments.command {
+ Action::Dev { port } => {
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Local;
+ settings.port = port;
+ images::prepare(&project, &mut settings)?;
+ project.up(&settings)
+ }
+ Action::Up {
+ production,
+ local,
+ port,
+ online,
+ } => {
+ let mut settings = project.settings()?;
+ if production {
+ settings.mode = Mode::Production;
+ }
+ if local {
+ settings.mode = Mode::Local;
+ }
+ if let Some(selected_port) = port {
+ settings.port = selected_port;
+ }
+ if online {
+ settings.online = true;
+ }
+ images::prepare(&project, &mut settings)?;
+ project.up(&settings)
+ }
+ Action::Status => project.status(&project.settings()?),
+ Action::Down => project.down(&project.settings()?),
+ Action::Backup {
+ destination,
+ include_images,
+ recipient,
+ key,
+ } => {
+ let output = destination.map_or_else(
+ || Ok(project.root.join("var/exports")),
+ |path| absolute(&path),
+ )?;
+ archive::backup(
+ &project,
+ &output,
+ include_images,
+ &recipient,
+ key.as_deref(),
+ )
+ }
+ Action::Release { destination } => {
+ let output = destination
+ .map_or_else(|| Ok(project.root.join("dist")), |path| absolute(&path))?;
+ release::create(&project, &output)
+ }
+ Action::Activate { archive } => release::activate(&absolute(&archive)?, &project),
+ Action::Check => check(&project),
+ Action::Licenses => licenses(&project),
+ Action::Restore { .. } => anyhow::bail!("Restore action was not handled"),
+ }
+}
+
+fn absolute(path: &Path) -> Result<PathBuf> {
+ Ok(if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ std::env::current_dir()?.join(path)
+ })
+}
+
+fn licenses(project: &Project) -> Result<()> {
+ process::run(project.command("cargo")?.args([
+ "about",
+ "generate",
+ "about.hbs",
+ "--output-file",
+ "web/THIRD-PARTY-LICENSES.html",
+ ]))
+}
+
+fn check(project: &Project) -> Result<()> {
+ process::run(
+ project
+ .command("cargo")?
+ .args(["fmt", "--all", "--", "--check"]),
+ )?;
+ process::run(project.command("cargo")?.args([
+ "clippy",
+ "--locked",
+ "--workspace",
+ "--all-targets",
+ "--all-features",
+ "--",
+ "-D",
+ "warnings",
+ ]))?;
+ process::run(project.command("cargo")?.args([
+ "test",
+ "--locked",
+ "--workspace",
+ "--all-targets",
+ "--all-features",
+ ]))?;
+ process::run(
+ project
+ .command("cargo")?
+ .env("RUSTDOCFLAGS", "-D warnings")
+ .args([
+ "doc",
+ "--locked",
+ "--workspace",
+ "--no-deps",
+ "--document-private-items",
+ ]),
+ )?;
+ process::run(
+ project
+ .command("cargo")?
+ .args(["deny", "--all-features", "check"]),
+ )?;
+ licenses(project)
+}
diff --git a/src/process.rs b/src/process.rs
new file mode 100644
index 0000000..eb48bc8
--- /dev/null
+++ b/src/process.rs
@@ -0,0 +1,70 @@
+// 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/>.
+
+use std::fmt;
+use std::io::{self, Write};
+use std::process::{Command, Output};
+
+use anyhow::{Context, Result, bail};
+
+pub(crate) fn capture(command: &mut Command) -> Result<Output> {
+ let output = command.output().with_context(|| {
+ format!(
+ "Could not start {}",
+ command.get_program().to_string_lossy()
+ )
+ })?;
+ if !output.status.success() {
+ bail!(
+ "{} failed ({}): {}",
+ command.get_program().to_string_lossy(),
+ output.status,
+ String::from_utf8_lossy(&output.stderr).trim()
+ );
+ }
+ Ok(output)
+}
+
+pub(crate) fn text(command: &mut Command) -> Result<String> {
+ String::from_utf8(capture(command)?.stdout).context("Command returned non-UTF-8 output")
+}
+
+pub(crate) fn run(command: &mut Command) -> Result<()> {
+ let status = command.status().with_context(|| {
+ format!(
+ "Could not start {}",
+ command.get_program().to_string_lossy()
+ )
+ })?;
+ if !status.success() {
+ bail!(
+ "{} failed ({status})",
+ command.get_program().to_string_lossy()
+ );
+ }
+ Ok(())
+}
+
+pub(crate) fn available(command: &mut Command) -> bool {
+ command.output().is_ok_and(|output| output.status.success())
+}
+
+pub(crate) fn line(message: fmt::Arguments<'_>) -> Result<()> {
+ writeln!(io::stdout().lock(), "{message}").context("Could not write command output")
+}
+
+pub(crate) fn error(message: fmt::Arguments<'_>) -> io::Result<()> {
+ writeln!(io::stderr().lock(), "{message}")
+}
diff --git a/src/project.rs b/src/project.rs
new file mode 100644
index 0000000..bb96fe0
--- /dev/null
+++ b/src/project.rs
@@ -0,0 +1,535 @@
+// 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/>.
+
+use std::collections::BTreeMap;
+use std::env;
+use std::fs::{self, File, OpenOptions, Permissions};
+use std::io::Read;
+use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
+use std::path::{Component, Path, PathBuf};
+use std::process::Command;
+use std::time::Duration;
+
+use anyhow::{Context, Result, bail, ensure};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::{files, process};
+
+#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub(crate) enum Mode {
+ #[default]
+ Local,
+ Production,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Settings {
+ pub(crate) mode: Mode,
+ pub(crate) port: u16,
+ pub(crate) project: String,
+ #[serde(default)]
+ pub(crate) online: bool,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Configuration {
+ pub(crate) format: u32,
+ pub(crate) services: BTreeMap<String, Service>,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Service {
+ pub(crate) image: String,
+ pub(crate) build: Option<String>,
+ #[serde(default)]
+ gateway: bool,
+ #[serde(default)]
+ environment: Vec<String>,
+ #[serde(default)]
+ drop_capabilities: bool,
+ health: Vec<String>,
+ mounts: Vec<Mount>,
+ #[serde(default)]
+ tmpfs: Vec<String>,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+struct Mount {
+ source: String,
+ target: String,
+ #[serde(default)]
+ read_only: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct Project {
+ pub(crate) root: PathBuf,
+}
+
+impl Project {
+ pub(crate) fn discover(explicit: Option<&Path>) -> Result<Self> {
+ if let Some(path) = explicit {
+ return Self::at(path);
+ }
+ let current = env::current_dir()?;
+ for candidate in current.ancestors() {
+ if candidate.join("infra/services.toml").is_file() {
+ return Self::at(candidate);
+ }
+ }
+ let executable = env::current_exe()?;
+ for candidate in executable.ancestors().skip(1).take(3) {
+ if candidate.join("infra/services.toml").is_file() {
+ return Self::at(candidate);
+ }
+ }
+ bail!("Run from the stack directory or supply --root PATH")
+ }
+
+ pub(crate) fn at(path: &Path) -> Result<Self> {
+ let root = if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ env::current_dir()?.join(path)
+ };
+ Ok(Self { root })
+ }
+
+ pub(crate) fn settings(&self) -> Result<Settings> {
+ let path = self.root.join("var/state.json");
+ if path.exists() {
+ return Ok(serde_json::from_reader(File::open(path)?)?);
+ }
+ Ok(Settings {
+ mode: Mode::Local,
+ port: 8080,
+ project: format!(
+ "stack-{}",
+ files::hash_bytes(self.root.as_os_str().as_encoded_bytes())
+ .chars()
+ .take(10)
+ .collect::<String>()
+ ),
+ online: false,
+ })
+ }
+
+ pub(crate) fn save(&self, settings: &Settings) -> Result<()> {
+ files::write(
+ &self.root.join("var/state.json"),
+ &serde_json::to_vec_pretty(settings)?,
+ 0o600,
+ )
+ }
+
+ pub(crate) fn lock(&self) -> Result<File> {
+ files::private_dir(&self.root.join("var"))?;
+ let file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .truncate(false)
+ .mode(0o600)
+ .open(self.root.join("var/operation.lock"))?;
+ file.try_lock()
+ .context("Another stack operation is already running")?;
+ Ok(file)
+ }
+
+ pub(crate) fn command(&self, name: &str) -> Result<Command> {
+ let mut command = Command::new(name);
+ command.current_dir(&self.root);
+ if let Some(tools) = self
+ .root
+ .ancestors()
+ .map(|p| p.join(".tools"))
+ .find(|p| p.join("bin/cargo-deny").is_file())
+ {
+ let mut paths = vec![tools.join("bin")];
+ if let Some(inherited) = env::var_os("PATH") {
+ paths.extend(env::split_paths(&inherited));
+ }
+ command.env("PATH", env::join_paths(paths)?);
+ }
+ if name == "podman" {
+ command
+ .arg("--root")
+ .arg(self.root.join("var/podman/storage"))
+ .arg("--runroot")
+ .arg(self.root.join("var/podman/run"));
+ }
+ Ok(command)
+ }
+
+ pub(crate) fn platform(&self) -> Result<String> {
+ let info: Value = serde_json::from_slice(
+ &process::capture(self.command("podman")?.args(["info", "--format", "json"]))?.stdout,
+ )?;
+ Ok(format!(
+ "{}/{}",
+ field(&info, &["host", "os"])?
+ .as_str()
+ .context("Missing host OS")?,
+ field(&info, &["host", "arch"])?
+ .as_str()
+ .context("Missing host architecture")?
+ ))
+ }
+
+ pub(crate) fn configuration(&self, settings: &Settings) -> Result<Configuration> {
+ let mut config: Configuration =
+ toml::from_str(&fs::read_to_string(self.root.join("infra/services.toml"))?)?;
+ ensure!(
+ config.format == 1 && config.services.contains_key("caddy"),
+ "Invalid services configuration"
+ );
+ if self.root.join("var/image-lock.json").is_file() && !settings.online {
+ let lock: Value =
+ serde_json::from_reader(File::open(self.root.join("var/image-lock.json"))?)?;
+ for (name, service) in &mut config.services {
+ field(&lock, &["services", name, "reference"])?
+ .as_str()
+ .context("Invalid image lock")?
+ .clone_into(&mut service.image);
+ }
+ }
+ Ok(config)
+ }
+
+ pub(crate) fn initialize(&self, settings: &Settings) -> Result<()> {
+ files::private_dir(&self.root.join("var/data"))?;
+ let env_file = self.root.join(".env");
+ if !env_file.exists() {
+ let mut random = [0; 32];
+ File::open("/dev/urandom")?.read_exact(&mut random)?;
+ let secret = hex::encode(random);
+ files::write(
+ &env_file,
+ format!("SEARXNG_SECRET={secret}\n").as_bytes(),
+ 0o600,
+ )?;
+ }
+ fs::set_permissions(&env_file, Permissions::from_mode(0o600))?;
+ for name in ["repos", "caddy/data", "caddy/config", "searxng"] {
+ fs::create_dir_all(self.root.join("var/data").join(name))?;
+ }
+ fs::set_permissions(
+ self.root.join("var/data/repos"),
+ Permissions::from_mode(0o755),
+ )?;
+ let repository = self.root.join("var/data/repos/stack");
+ if !repository.exists() {
+ if self.root.join(".git").is_dir() {
+ process::capture(
+ self.command("git")?
+ .args(["clone", "--bare", "."])
+ .arg(&repository),
+ )?;
+ } else {
+ process::capture(
+ self.command("git")?
+ .args(["init", "--bare", "--initial-branch=main"])
+ .arg(&repository),
+ )?;
+ }
+ }
+ files::copy_tree(
+ &self.root.join("infra/searxng"),
+ &self.root.join("var/data/searxng"),
+ )?;
+ if settings.mode == Mode::Local {
+ self.local_site(settings.port)?;
+ }
+ Ok(())
+ }
+
+ fn local_site(&self, port: u16) -> Result<()> {
+ let generated = self.root.join("var/local/web");
+ if generated.exists() {
+ files::regular_tree(&generated)?;
+ fs::remove_dir_all(&generated)?;
+ }
+ files::copy_tree(&self.root.join("web"), &generated)?;
+ let index = generated.join("index.html");
+ let html = fs::read_to_string(&index)?
+ .replace("https://git.doan.luuduct.ai", "/git")
+ .replace("https://search.doan.luuduct.ai", "/search")
+ .replace("https://openpgpkey.luuduct.ai", "");
+ files::write(&index, html.as_bytes(), 0o644)?;
+ let template = self.root.join("infra/containers/cgit/cgitrc.local");
+ let content = fs::read_to_string(&template)?
+ .lines()
+ .map(|line| {
+ if line.starts_with("clone-prefix=") {
+ format!("clone-prefix=http://localhost:{port}/git")
+ } else {
+ line.to_owned()
+ }
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+ files::write(
+ &self.root.join("var/local/cgitrc"),
+ format!("{content}\n").as_bytes(),
+ 0o644,
+ )
+ }
+
+ pub(crate) fn container_name(settings: &Settings, service: &str) -> String {
+ format!("{}-{service}", settings.project)
+ }
+
+ pub(crate) fn inspect_containers(&self, settings: &Settings) -> Result<Vec<Value>> {
+ let output = process::text(self.command("podman")?.args([
+ "ps",
+ "--all",
+ "--quiet",
+ "--filter",
+ &format!("label=io.stack.project={}", settings.project),
+ ]))?;
+ let ids: Vec<_> = output.split_whitespace().collect();
+ ensure!(!ids.is_empty(), "No stack containers found");
+ Ok(serde_json::from_slice(
+ &process::capture(self.command("podman")?.arg("inspect").args(ids))?.stdout,
+ )?)
+ }
+
+ pub(crate) fn up(&self, settings: &Settings) -> Result<()> {
+ ensure!(
+ (1024..=65_534).contains(&settings.port),
+ "Local port must be between 1024 and 65534"
+ );
+ self.initialize(settings)?;
+ self.save(settings)?;
+ let config = self.configuration(settings)?;
+ let network = format!("{}-net", settings.project);
+ if !process::available(
+ self.command("podman")?
+ .args(["network", "exists", &network]),
+ ) {
+ process::capture(
+ self.command("podman")?
+ .args(["network", "create", &network]),
+ )?;
+ }
+ let environment: BTreeMap<String, String> =
+ dotenvy::from_path_iter(self.root.join(".env"))?
+ .collect::<std::result::Result<_, _>>()?;
+ for (name, service) in config
+ .services
+ .iter()
+ .filter(|(_, service)| !service.gateway)
+ .chain(
+ config
+ .services
+ .iter()
+ .filter(|(_, service)| service.gateway),
+ )
+ {
+ self.ensure_image(settings, service)?;
+ self.start_service(settings, &network, name, service, &environment)?;
+ self.wait_service(settings, name, service)?;
+ }
+ process::line(format_args!(
+ "Running {}",
+ if settings.mode == Mode::Production {
+ "https://doan.luuduct.ai".to_owned()
+ } else {
+ format!("http://localhost:{}", settings.port)
+ }
+ ))
+ }
+
+ pub(crate) fn ensure_image(&self, settings: &Settings, service: &Service) -> Result<()> {
+ if settings.mode == Mode::Local
+ && (settings.online || !self.root.join("var/image-lock.json").exists())
+ && let Some(context) = &service.build
+ {
+ return process::run(
+ self.command("podman")?
+ .args(["build", "--tag", &service.image])
+ .arg(self.path(context)?),
+ );
+ }
+ if process::available(
+ self.command("podman")?
+ .args(["image", "exists", &service.image]),
+ ) {
+ return Ok(());
+ }
+ ensure!(
+ settings.mode == Mode::Local,
+ "Production images must be built and deployed from the development machine"
+ );
+ process::run(self.command("podman")?.args(["pull", &service.image]))
+ }
+
+ fn start_service(
+ &self,
+ settings: &Settings,
+ network: &str,
+ name: &str,
+ service: &Service,
+ environment: &BTreeMap<String, String>,
+ ) -> Result<()> {
+ let container = Self::container_name(settings, name);
+ let mut command = self.command("podman")?;
+ command.args([
+ "run",
+ "--detach",
+ "--replace",
+ "--pull",
+ "never",
+ "--name",
+ &container,
+ "--network",
+ network,
+ "--network-alias",
+ name,
+ "--restart",
+ "unless-stopped",
+ "--label",
+ &format!("io.stack.project={}", settings.project),
+ "--label",
+ &format!("io.stack.service={name}"),
+ "--security-opt",
+ "no-new-privileges",
+ ]);
+ if service.drop_capabilities {
+ command.args(["--cap-drop", "ALL"]);
+ }
+ if service.gateway {
+ let ports = if settings.mode == Mode::Local {
+ let tls = settings.port.checked_add(1).context("Port overflow")?;
+ [
+ format!("127.0.0.1:{}:80", settings.port),
+ format!("127.0.0.1:{tls}:443"),
+ format!("127.0.0.1:{tls}:443/udp"),
+ ]
+ } else {
+ [
+ "80:80".to_owned(),
+ "443:443".to_owned(),
+ "443:443/udp".to_owned(),
+ ]
+ };
+ for port in ports {
+ command.args(["--publish", &port]);
+ }
+ }
+ for key in &service.environment {
+ command.arg("--env").arg(key).env(
+ key,
+ environment
+ .get(key)
+ .with_context(|| format!("Missing secret {key} in .env"))?,
+ );
+ }
+ for mount in &service.mounts {
+ let source = match mount.source.as_str() {
+ "@caddy" => self.root.join(if settings.mode == Mode::Local {
+ "infra/Caddyfile.local"
+ } else {
+ "infra/Caddyfile"
+ }),
+ "@web" => self.root.join(if settings.mode == Mode::Local {
+ "var/local/web"
+ } else {
+ "web"
+ }),
+ "@cgit" => self.root.join(if settings.mode == Mode::Local {
+ "var/local/cgitrc"
+ } else {
+ "infra/containers/cgit/cgitrc"
+ }),
+ other => self.path(other)?,
+ };
+ let mode = if mount.read_only { "ro,z" } else { "rw,z" };
+ command
+ .arg("--volume")
+ .arg(format!("{}:{}:{mode}", source.display(), mount.target));
+ }
+ for mount in &service.tmpfs {
+ command.args(["--tmpfs", mount]);
+ }
+ process::capture(command.arg(&service.image))?;
+ Ok(())
+ }
+
+ fn wait_service(&self, settings: &Settings, name: &str, service: &Service) -> Result<()> {
+ let container = Self::container_name(settings, name);
+ for _attempt in 0..120 {
+ if process::available(
+ self.command("podman")?
+ .args(["exec", &container])
+ .args(&service.health),
+ ) {
+ return Ok(());
+ }
+ std::thread::sleep(Duration::from_millis(500));
+ }
+ bail!("Service did not become ready: {name}; inspect its Podman logs")
+ }
+
+ pub(crate) fn status(&self, settings: &Settings) -> Result<()> {
+ process::run(self.command("podman")?.args([
+ "ps",
+ "--all",
+ "--filter",
+ &format!("label=io.stack.project={}", settings.project),
+ ]))
+ }
+
+ pub(crate) fn down(&self, settings: &Settings) -> Result<()> {
+ for name in self.configuration(settings)?.services.keys() {
+ process::capture(self.command("podman")?.args([
+ "rm",
+ "--force",
+ "--ignore",
+ &Self::container_name(settings, name),
+ ]))?;
+ }
+ process::capture(self.command("podman")?.args([
+ "network",
+ "rm",
+ "--ignore",
+ &format!("{}-net", settings.project),
+ ]))?;
+ Ok(())
+ }
+
+ fn path(&self, relative: &str) -> Result<PathBuf> {
+ let path = Path::new(relative);
+ ensure!(
+ path.components()
+ .all(|part| matches!(part, Component::Normal(_))),
+ "Service paths must stay inside stack"
+ );
+ Ok(self.root.join(path))
+ }
+}
+
+pub(crate) fn field<'a>(value: &'a Value, path: &[&str]) -> Result<&'a Value> {
+ path.iter().try_fold(value, |node, key| {
+ node.get(key)
+ .with_context(|| format!("Missing JSON field {key}"))
+ })
+}
diff --git a/src/release.rs b/src/release.rs
new file mode 100644
index 0000000..88b4093
--- /dev/null
+++ b/src/release.rs
@@ -0,0 +1,131 @@
+// 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/>.
+
+use std::fs;
+use std::path::Path;
+
+use anyhow::{Context, Result, ensure};
+
+use crate::project::{Mode, Project};
+use crate::{files, images, process};
+
+pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> {
+ crate::check(project)?;
+ process::run(project.command("cargo")?.args([
+ "build",
+ "--locked",
+ "--release",
+ "--target",
+ "x86_64-unknown-linux-musl",
+ ]))?;
+ files::write(
+ &project.root.join("bin/stack"),
+ &fs::read(
+ project
+ .root
+ .join("target/x86_64-unknown-linux-musl/release/stack"),
+ )?,
+ 0o755,
+ )?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Local;
+ settings.online = true;
+ for service in project.configuration(&settings)?.services.values() {
+ project.ensure_image(&settings, service)?;
+ }
+ files::private_dir(destination)?;
+ let temp = tempfile::Builder::new()
+ .prefix(".release-")
+ .tempdir_in(destination)?;
+ let stage = temp.path().join("stack");
+ files::private_dir(&stage)?;
+ files::copy_source(&project.root, &stage)?;
+ fs::create_dir_all(stage.join("bin"))?;
+ fs::copy(
+ project
+ .root
+ .join("target/x86_64-unknown-linux-musl/release/stack"),
+ stage.join("bin/stack"),
+ )?;
+ images::save(
+ project,
+ &images::configured(project, &settings)?,
+ &stage.join("var"),
+ )?;
+ files::manifest(&stage, "release")?;
+ let output = destination.join(format!(
+ "stack-release-{}.tar.gz",
+ chrono::Utc::now().format("%Y%m%dT%H%M%SZ")
+ ));
+ files::pack(&stage, &output)?;
+ let filename = output
+ .file_name()
+ .context("Missing archive name")?
+ .to_string_lossy();
+ files::write(
+ &output.with_extension("gz.sha256"),
+ format!("{} {filename}\n", files::hash_file(&output)?).as_bytes(),
+ 0o600,
+ )?;
+ process::line(format_args!("Release: {}", output.display()))
+}
+
+pub(crate) fn activate(archive: &Path, project: &Project) -> Result<()> {
+ let temp = tempfile::Builder::new()
+ .prefix(".activate-")
+ .tempdir_in(&project.root)?;
+ let stage = temp.path().join("stack");
+ files::extract(archive, &stage)?;
+ ensure!(
+ files::verify(&stage)?.kind == "release",
+ "Activation requires a release archive"
+ );
+ let pending = Project::at(&stage)?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Production;
+ settings.online = false;
+ let lock = stage.join("var/image-lock.json");
+ let info: serde_json::Value = serde_json::from_slice(&fs::read(&lock)?)?;
+ ensure!(
+ info.get("platform").and_then(serde_json::Value::as_str)
+ == Some(project.platform()?.as_str()),
+ "Release architecture does not match the server"
+ );
+ let _config = pending.configuration(&settings)?;
+ for name in files::SOURCE {
+ let source = stage.join(name);
+ if source.exists() {
+ let target = project.root.join(name);
+ if target.is_dir() {
+ files::regular_tree(&target)?;
+ fs::remove_dir_all(&target)?;
+ }
+ files::copy_tree(&source, &target)?;
+ }
+ }
+ files::write(
+ &project.root.join("bin/stack"),
+ &fs::read(stage.join("bin/stack"))?,
+ 0o755,
+ )?;
+ for name in ["images.tar", "image-lock.json"] {
+ files::copy_tree(
+ &stage.join("var").join(name),
+ &project.root.join("var").join(name),
+ )?;
+ }
+ images::prepare(project, &mut settings)?;
+ project.up(&settings)
+}
diff --git a/web/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp b/web/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp
new file mode 100644
index 0000000..1b90e81
--- /dev/null
+++ b/web/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp
Binary files differ
diff --git a/web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp b/web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp
new file mode 100644
index 0000000..1b90e81
--- /dev/null
+++ b/web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp
Binary files differ
diff --git a/www/.well-known/openpgpkey/luuduct.ai/policy b/web/.well-known/openpgpkey/luuduct.ai/policy
index e69de29..e69de29 100644
--- a/www/.well-known/openpgpkey/luuduct.ai/policy
+++ b/web/.well-known/openpgpkey/luuduct.ai/policy
diff --git a/www/.well-known/openpgpkey/policy b/web/.well-known/openpgpkey/policy
index e69de29..e69de29 100644
--- a/www/.well-known/openpgpkey/policy
+++ b/web/.well-known/openpgpkey/policy
diff --git a/web/THIRD-PARTY-LICENSES.html b/web/THIRD-PARTY-LICENSES.html
new file mode 100644
index 0000000..cd129d9
--- /dev/null
+++ b/web/THIRD-PARTY-LICENSES.html
@@ -0,0 +1,961 @@
+<!doctype html>
+<!--
+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/>.
+-->
+
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Stack — third-party licenses</title>
+ <style>body{max-width:80ch;margin:2rem auto;padding:0 1rem;font:1rem/1.6 system-ui}pre{white-space:pre-wrap;overflow-wrap:anywhere}section{margin:3rem 0}a{color:inherit}</style>
+</head>
+<body>
+
+<h1>Third-party licenses for Stack</h1>
+<p>Stack is covered by the GNU Affero General Public License, version 3 or later.
+This page covers the Rust dependencies recorded in Cargo.lock. Supporting container
+applications and their operating-system packages have their own notices.</p>
+<p>Headings follow <a href="https://www.gnu.org/licenses/license-list.html">GNU's
+license names</a> where listed. SPDX identifiers remain available for exact identification.
+The original license texts below are unchanged.</p>
+<ul><li><a href="#MIT">Expat License</a> (71)</li><li><a href="#Unicode-3.0">Unicode License v3</a> (1)</li></ul>
+<section id="license-0" data-spdx="MIT">
+<h2 id="MIT">Expat License</h2>
+<ul><li><a href="https://github.com/allan2/dotenvy">dotenvy 0.15.7</a></li></ul>
+<pre># The MIT License (MIT)
+
+Copyright (c) 2014 Santiago Lapresta and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the &quot;Software&quot;), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.</pre>
+</section>
+<section id="license-1" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/KokaKiwi/rust-hex">hex 0.4.3</a></li></ul>
+<pre>Copyright (c) 2013-2014 The Rust Project Developers.
+Copyright (c) 2015-2020 The rust-hex Developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the &quot;Software&quot;), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+</pre>
+</section>
+<section id="license-2" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/rust-lang/cfg-if">cfg-if 1.0.5</a></li><li><a href="https://github.com/alexcrichton/filetime">filetime 0.2.29</a></li></ul>
+<pre>Copyright (c) 2014 Alex Crichton
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-3" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/lambda-fairy/rust-errno">errno 0.3.14</a></li></ul>
+<pre>Copyright (c) 2014 Chris Wong
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-4" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/bitflags/bitflags">bitflags 2.13.2</a></li><li><a href="https://github.com/rust-num/num-traits">num-traits 0.2.19</a></li></ul>
+<pre>Copyright (c) 2014 The Rust Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-5" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/rust-lang/flate2-rs">flate2 1.1.10</a></li></ul>
+<pre>Copyright (c) 2014-2026 Alex Crichton
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-6" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/Stebalien/tempfile">tempfile 3.27.0</a></li><li><a href="https://github.com/Stebalien/xattr">xattr 1.7.0</a></li></ul>
+<pre>Copyright (c) 2015 Steven Allen
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-7" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/withoutboats/heck">heck 0.5.0</a></li></ul>
+<pre>Copyright (c) 2015 The Rust Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-8" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/alacritty/vte">utf8parse 0.2.2</a></li></ul>
+<pre>Copyright (c) 2016 Joe Wilm
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-9" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/RustCrypto/hashes">sha2 0.11.1</a></li></ul>
+<pre>Copyright (c) 2016-2026 The RustCrypto Project Developers
+Copyright (c) 2016 Artyom Pavlov
+Copyright (c) 2009-2013 Mozilla Foundation
+Copyright (c) 2006-2009 Graydon Hoare
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-10" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/RustCrypto/traits">digest 0.11.3</a></li></ul>
+<pre>Copyright (c) 2017-2025 RustCrypto Developers
+Copyright (c) 2017 Artyom Pavlov
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-11" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/cuviper/autocfg">autocfg 1.5.1</a></li></ul>
+<pre>Copyright (c) 2018 Josh Stone
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-12" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/RustCrypto/utils">block-buffer 0.12.1</a></li></ul>
+<pre>Copyright (c) 2018-2025 The RustCrypto Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-13" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/rust-random/getrandom">getrandom 0.4.3</a></li></ul>
+<pre>Copyright (c) 2018-2026 The rust-random Project Developers
+Copyright (c) 2014 The Rust Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-14" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/RustCrypto/formats">const-oid 0.10.2</a></li><li><a href="https://github.com/RustCrypto/utils">cpufeatures 0.3.1</a></li></ul>
+<pre>Copyright (c) 2020-2026 The RustCrypto Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-15" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/RustCrypto/traits">crypto-common 0.2.2</a></li></ul>
+<pre>Copyright (c) 2021-2026 RustCrypto Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-16" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/RustCrypto/hybrid-array">hybrid-array 0.4.15</a></li></ul>
+<pre>Copyright (c) 2022-2026 The RustCrypto Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-17" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/rust-cli/anstyle.git">anstream 1.0.0</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstyle-parse 1.0.0</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstyle-query 1.1.5</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstyle-wincon 3.0.11</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstyle 1.0.14</a></li><li><a href="https://github.com/clap-rs/clap">clap 4.6.7</a></li><li><a href="https://github.com/clap-rs/clap">clap_builder 4.6.7</a></li><li><a href="https://github.com/clap-rs/clap">clap_derive 4.6.7</a></li><li><a href="https://github.com/clap-rs/clap">clap_lex 1.1.1</a></li><li><a href="https://github.com/rust-cli/anstyle.git">colorchoice 1.0.5</a></li><li><a href="https://github.com/polyfill-rs/is_terminal_polyfill">is_terminal_polyfill 1.70.2</a></li><li><a href="https://github.com/polyfill-rs/once_cell_polyfill">once_cell_polyfill 1.70.2</a></li><li><a href="https://github.com/toml-rs/toml">serde_spanned 1.1.2</a></li><li><a href="https://github.com/toml-rs/toml">toml 1.1.8+spec-1.1.0</a></li><li><a href="https://github.com/toml-rs/toml">toml_datetime 1.1.2+spec-1.1.0</a></li><li><a href="https://github.com/toml-rs/toml">toml_parser 1.1.5+spec-1.1.0</a></li><li><a href="https://github.com/toml-rs/toml">toml_writer 1.1.3+spec-1.1.0</a></li></ul>
+<pre>Copyright (c) Individual contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the &quot;Software&quot;), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+</pre>
+</section>
+<section id="license-18" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/rust-lang/libc">libc 0.2.191</a></li></ul>
+<pre>Copyright (c) The Rust Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-19" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/composefs/tar-rs">tar 0.4.46</a></li></ul>
+<pre>Copyright (c) The tar-rs Project Contributors
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-20" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/srijs/rust-crc32fast">crc32fast 1.5.3</a></li></ul>
+<pre>MIT License
+
+Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the &quot;Software&quot;), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+</pre>
+</section>
+<section id="license-21" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/chronotope/chrono">chrono 0.4.45</a></li><li><a href="https://github.com/r-efi/r-efi">r-efi 6.0.0</a></li><li><a href="https://github.com/microsoft/windows-rs">windows-link 0.2.1</a></li><li><a href="https://github.com/microsoft/windows-rs">windows-sys 0.61.2</a></li></ul>
+<pre>MIT License
+
+Copyright (c) &lt;year&gt; &lt;copyright holders&gt;
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+associated documentation files (the &quot;Software&quot;), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial
+portions of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
+LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
+EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-22" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/mcountryman/simd-adler32">simd-adler32 0.3.10</a></li></ul>
+<pre>MIT License
+
+Copyright (c) [2021] [Marvin Countryman]
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the &quot;Software&quot;), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+</pre>
+</section>
+<section id="license-23" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide">miniz_oxide 0.9.1</a></li></ul>
+<pre>MIT License
+
+Copyright 2013-2014 RAD Game Tools and Valve Software
+Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC
+Copyright (c) 2017 Frommi
+Copyright (c) 2017-2024 oyvindln
+
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the &quot;Software&quot;), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+</pre>
+</section>
+<section id="license-24" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide">miniz_oxide 0.9.1</a></li></ul>
+<pre>MIT License
+
+Copyright 2013-2014 RAD Game Tools and Valve Software
+Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC
+Copyright (c) 2017 Frommi
+Copyright (c) 2017-2024 oyvindln
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the &quot;Software&quot;), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+</pre>
+</section>
+<section id="license-25" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/oyvindln/adler2">adler2 2.0.1</a></li><li><a href="https://github.com/dtolnay/anyhow">anyhow 1.0.104</a></li><li><a href="https://github.com/smol-rs/fastrand">fastrand 2.5.0</a></li><li><a href="https://github.com/dtolnay/itoa">itoa 1.0.18</a></li><li><a href="https://github.com/sunfishcode/linux-raw-sys">linux-raw-sys 0.12.1</a></li><li><a href="https://github.com/matklad/once_cell">once_cell 1.21.4</a></li><li><a href="https://github.com/dtolnay/proc-macro2">proc-macro2 1.0.107</a></li><li><a href="https://github.com/dtolnay/quote">quote 1.0.47</a></li><li><a href="https://github.com/bytecodealliance/rustix">rustix 1.1.6</a></li><li><a href="https://github.com/serde-rs/serde">serde 1.0.229</a></li><li><a href="https://github.com/serde-rs/serde">serde_core 1.0.229</a></li><li><a href="https://github.com/serde-rs/serde">serde_derive 1.0.229</a></li><li><a href="https://github.com/serde-rs/json">serde_json 1.0.154</a></li><li><a href="https://github.com/dtolnay/syn">syn 3.0.9</a></li><li><a href="https://github.com/dtolnay/unicode-ident">unicode-ident 1.0.27</a></li><li><a href="https://github.com/dtolnay/zmij">zmij 1.0.23</a></li></ul>
+<pre>Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the &quot;Software&quot;), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-26" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/winnow-rs/winnow">winnow 1.0.4</a></li></ul>
+<pre>Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+&quot;Software&quot;), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+</pre>
+</section>
+<section id="license-27" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/paholg/typenum">typenum 1.20.1</a></li></ul>
+<pre>The MIT License (MIT)
+
+Copyright (c) 2014 Paho Lurie-Gregg
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the &quot;Software&quot;), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+</pre>
+</section>
+<section id="license-28" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/BurntSushi/memchr">memchr 2.8.3</a></li><li><a href="https://github.com/BurntSushi/walkdir">walkdir 2.5.0</a></li></ul>
+<pre>The MIT License (MIT)
+
+Copyright (c) 2015 Andrew Gallant
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the &quot;Software&quot;), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+</pre>
+</section>
+<section id="license-29" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/rapidfuzz/strsim-rs">strsim 0.11.1</a></li></ul>
+<pre>The MIT License (MIT)
+
+Copyright (c) 2015 Danny Guo
+Copyright (c) 2016 Titus Wormer &lt;tituswormer@gmail.com&gt;
+Copyright (c) 2018 Akash Kurdekar
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the &quot;Software&quot;), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+</pre>
+</section>
+<section id="license-30" data-spdx="MIT">
+
+<ul><li><a href="https://github.com/BurntSushi/same-file">same-file 1.0.6</a></li><li><a href="https://github.com/BurntSushi/winapi-util">winapi-util 0.1.11</a></li></ul>
+<pre>The MIT License (MIT)
+
+Copyright (c) 2017 Andrew Gallant
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the &quot;Software&quot;), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+</pre>
+</section>
+<section id="license-31" data-spdx="Unicode-3.0">
+<h2 id="Unicode-3.0">Unicode License v3</h2>
+<ul><li><a href="https://github.com/dtolnay/unicode-ident">unicode-ident 1.0.27</a></li></ul>
+<pre>UNICODE LICENSE V3
+
+COPYRIGHT AND PERMISSION NOTICE
+
+Copyright © 1991-2023 Unicode, Inc.
+
+NOTICE TO USER: Carefully read the following legal agreement. BY
+DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
+SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
+TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
+DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of data files and any associated documentation (the &quot;Data Files&quot;) or
+software and any associated documentation (the &quot;Software&quot;) to deal in the
+Data Files or Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, and/or sell
+copies of the Data Files or Software, and to permit persons to whom the
+Data Files or Software are furnished to do so, provided that either (a)
+this copyright and permission notice appear with all copies of the Data
+Files or Software, or (b) this copyright and permission notice appear in
+associated Documentation.
+
+THE DATA FILES AND SOFTWARE ARE PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY
+KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
+THIRD PARTY RIGHTS.
+
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
+BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL 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 THE DATA
+FILES OR SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder shall
+not be used in advertising or otherwise to promote the sale, use or other
+dealings in these Data Files or Software without prior written
+authorization of the copyright holder.
+</pre>
+</section>
+</body>
+</html>
diff --git a/www/index.html b/web/index.html
index 5911c41..4d808a8 100644
--- a/www/index.html
+++ b/web/index.html
@@ -42,7 +42,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|____/ \___/_/ \_\_| \_| LUU DUC TAI
</a>
-<a href="/">Home</a> <a href="#projects">Projects</a> <a href="#services">Services</a>
+<a href="/">Home</a> <a href="#services">Services</a>
+------------------------------------------------------------------------------+
@@ -50,46 +50,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
| Doan Luu Duc Tai |
| |
| Email: <a href="mailto:doan@luuduct.ai">doan@luuduct.ai</a> |
-| GnuPG: <a href="https://openpgpkey.luuduct.ai/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp?l=doan">8990FFC0F9D95FAA4C04520BADF3DE23F687C623</a> |
+| GnuPG: <a href="https://openpgpkey.luuduct.ai/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp?l=doan">3346AA3150F8F16828BB0D14F8C66DF77AFECEA6</a> |
| |
| <a href="https://smp11.simplex.im/a#ikrTZ8sfleSZEifKV2wI8foAVwkbKfOoKc6TO2P4ZT8">SimpleX Chat</a> |
-| <a href="https://signal.me/#eu/Xr5_r_ei8UYUpd7Lus4536PnlKvW28LXBjg3Lb71BtFp4iDVXoRF4ZVPQYw_0Guk">Signal</a> |
| |
+------------------------------------------------------------------------------+
-<span id="projects">Projects</span>
-________________________________________________________________________________
-
-
-<a href="https://git.doan.luuduct.ai/csprdg/">csprdg</a>
- Conjecturally Secure Pseudo-Random Document Generator
-
-
-<a href="https://git.doan.luuduct.ai/csprsg/">csprsg</a>
- Conjecturally Secure Pseudo-Random Spreadsheet Generator
-
-
-<a href="https://git.doan.luuduct.ai/fileshare/">fileshare</a>
- LAN File Server
-
-
-<a href="https://git.doan.luuduct.ai/kanji-deck/">kanji-deck</a>
- personal kanji deck (you are better off using Anki ahaha)
-
-
-<a href="https://git.doan.luuduct.ai/stack/">stack</a>
- Personal infrastructure stack.
-
-
-<a href="https://git.doan.luuduct.ai/vfetch/">vfetch</a>
- neofetch but the ascii part is a video
-
-
-
-
<span id="services">Services</span>
________________________________________________________________________________
@@ -111,7 +80,8 @@ ________________________________________________________________________________
</pre>
<div class="source-footer">
- <a href="https://git.doan.luuduct.ai/stack/tree/www/index.html">[ Source Code ]</a>
+ <a href="https://git.doan.luuduct.ai/stack/tree/web/index.html">[ Source Code ]</a>
+ <a href="/THIRD-PARTY-LICENSES.html">[ Third-party Licenses ]</a>
</div>
</div>
diff --git a/www/styles.css b/web/styles.css
index f2f158c..f2f158c 100644
--- a/www/styles.css
+++ b/web/styles.css
diff --git a/www/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp b/www/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp
deleted file mode 100644
index f0fa994..0000000
--- a/www/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp
+++ /dev/null
Binary files differ
diff --git a/www/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp b/www/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp
deleted file mode 100644
index f0fa994..0000000
--- a/www/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp
+++ /dev/null
Binary files differ