From a80df869a92c330fbad9695057720b11a18af901 Mon Sep 17 00:00:00 2001 From: Doan Luu Duc Tai Date: Sat, 31 Oct 2026 13:08:08 -0500 Subject: Forbid Rust lints and enforce strict typed TypeScript linting --- Cargo.lock | 19 - Cargo.toml | 157 +++- README.txt | 6 +- clippy.toml | 16 + crates/chat-crypto/browser/protocol.d.ts | 6 +- crates/chat-crypto/browser/wasi.ts | 27 +- crates/chat-crypto/src/application.rs | 15 +- crates/chat-crypto/tests/browser.ts | 19 +- crates/chat-server/Cargo.toml | 4 +- crates/chat-server/src/app.rs | 199 ++++ crates/chat-server/src/app/cli.rs | 167 ++++ crates/chat-server/src/app/http.rs | 243 +++++ crates/chat-server/src/app/maintenance.rs | 72 ++ crates/chat-server/src/app/service.rs | 438 +++++++++ crates/chat-server/src/app/store.rs | 182 ++++ crates/chat-server/src/http.rs | 243 ----- crates/chat-server/src/main.rs | 202 +---- crates/chat-server/src/maintenance.rs | 72 -- crates/chat-server/src/service.rs | 438 --------- crates/chat-server/src/store.rs | 182 ---- crates/chat-server/tests/browser.ts | 104 ++- crates/chat-server/tests/progress.ts | 28 +- crates/chat-server/web/app.ts | 165 ++-- crates/chat-server/web/worker.ts | 4 +- crates/config/src/lib.rs | 43 +- docs/LINTING.txt | 194 ++++ docs/POLICY.txt | 18 +- docs/TYPESCRIPT.txt | 18 +- eslint.config.ts | 53 ++ infra/chat/build-source.sh | 2 +- infra/chat/build-typescript.sh | 6 +- package-lock.json | 1396 ++++++++++++++++++++++++++++- package.json | 17 +- src/app.rs | 252 ++++++ src/app/archive.rs | 475 ++++++++++ src/app/chat.rs | 177 ++++ src/app/cli.rs | 329 +++++++ src/app/files.rs | 384 ++++++++ src/app/images.rs | 215 +++++ src/app/process.rs | 70 ++ src/app/project.rs | 539 +++++++++++ src/app/release.rs | 127 +++ src/app/render.rs | 130 +++ src/archive.rs | 464 ---------- src/chat.rs | 177 ---- src/files.rs | 343 ------- src/images.rs | 215 ----- src/main.rs | 324 +------ src/process.rs | 70 -- src/project.rs | 536 ----------- src/release.rs | 127 --- src/render.rs | 114 --- tests/browser-support.ts | 23 +- tsconfig.browser.json | 1 + tsconfig.json | 4 + tsconfig.tools.json | 33 + tsconfig.worker.json | 1 + web/THIRD-PARTY-LICENSES.html | 6 +- 58 files changed, 6081 insertions(+), 3810 deletions(-) create mode 100644 clippy.toml create mode 100644 crates/chat-server/src/app.rs create mode 100644 crates/chat-server/src/app/cli.rs create mode 100644 crates/chat-server/src/app/http.rs create mode 100644 crates/chat-server/src/app/maintenance.rs create mode 100644 crates/chat-server/src/app/service.rs create mode 100644 crates/chat-server/src/app/store.rs delete mode 100644 crates/chat-server/src/http.rs delete mode 100644 crates/chat-server/src/maintenance.rs delete mode 100644 crates/chat-server/src/service.rs delete mode 100644 crates/chat-server/src/store.rs create mode 100644 docs/LINTING.txt create mode 100644 eslint.config.ts create mode 100644 src/app.rs create mode 100644 src/app/archive.rs create mode 100644 src/app/chat.rs create mode 100644 src/app/cli.rs create mode 100644 src/app/files.rs create mode 100644 src/app/images.rs create mode 100644 src/app/process.rs create mode 100644 src/app/project.rs create mode 100644 src/app/release.rs create mode 100644 src/app/render.rs delete mode 100644 src/archive.rs delete mode 100644 src/chat.rs delete mode 100644 src/files.rs delete mode 100644 src/images.rs delete mode 100644 src/process.rs delete mode 100644 src/project.rs delete mode 100644 src/release.rs delete mode 100644 src/render.rs create mode 100644 tsconfig.tools.json diff --git a/Cargo.lock b/Cargo.lock index 1a2f533..0a57f91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,7 +345,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", - "clap_derive", ] [[package]] @@ -360,18 +359,6 @@ dependencies = [ "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 3.0.9", -] - [[package]] name = "clap_lex" version = "1.1.1" @@ -694,12 +681,6 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 327372d..a0e247d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ exclude = ["var", ".tools", "target", "bin", "dist"] stack-config = { path = "crates/config", version = "0.1.0" } anyhow = "1.0" chrono = { version = "0.4", default-features = false, features = ["now", "std"] } -clap = { version = "4.6", features = ["derive"] } +clap = "4.6" hex = "0.4" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } serde = { version = "1.0", features = ["derive"] } @@ -62,58 +62,117 @@ resolver = "3" workspace = true [workspace.lints.rustdoc] -broken_intra_doc_links = "deny" -private_intra_doc_links = "deny" -missing_crate_level_docs = "deny" +all = { level = "forbid", priority = -1 } +broken_intra_doc_links = "forbid" +private_intra_doc_links = "forbid" +missing_crate_level_docs = "forbid" [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" +future_incompatible = { level = "forbid", priority = -1 } +rust_2018_idioms = { level = "forbid", priority = -1 } +let_underscore = { level = "forbid", priority = -1 } +unsafe_code = "forbid" +unstable_features = "forbid" +missing_docs = "forbid" +missing_debug_implementations = "forbid" +unreachable_pub = "forbid" +trivial_casts = "forbid" +trivial_numeric_casts = "forbid" +unreachable_patterns = "forbid" +warnings = "forbid" unused_qualifications = "deny" +unused_imports = "forbid" +unused_variables = "forbid" +unused_visibilities = "forbid" +unused_assignments = "forbid" +dead_code = "forbid" +unused_mut = "forbid" +unreachable_cfg_select_predicates = "forbid" +unreachable_code = "forbid" +unused_must_use = "forbid" +unused_unsafe = "forbid" +path_statements = "forbid" +unused_macros = "forbid" +unused_macro_rules = "forbid" +unused_allocation = "forbid" +unused_doc_comments = "forbid" +unused_extern_crates = "forbid" +unused_features = "forbid" +unused_labels = "forbid" +unused_parens = "forbid" +unused_braces = "forbid" +redundant_semicolons = "forbid" +map_unit_fn = "forbid" +non_snake_case = "forbid" +ambiguous_negative_literals = "forbid" +ffi_unwind_calls = "forbid" +keyword_idents = { level = "forbid", priority = -1 } +macro_use_extern_crate = "forbid" +meta_variable_misuse = "forbid" +non_ascii_idents = "forbid" +unsafe_op_in_unsafe_fn = "forbid" +unsafe_attr_outside_unsafe = "forbid" +unused_lifetimes = "forbid" +unfulfilled_lint_expectations = "forbid" +unused_import_braces = "forbid" +unnameable_types = "forbid" + [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" +all = { level = "forbid", priority = -1 } +correctness = { level = "forbid", priority = -1 } +suspicious = { level = "forbid", priority = -1 } +cargo = { level = "forbid", priority = -1 } +style = { level = "forbid", priority = -1 } +complexity = { level = "forbid", priority = -1 } +perf = { level = "forbid", priority = -1 } +pedantic = { level = "forbid", priority = -1 } +nursery = { level = "forbid", priority = -1 } +todo = "forbid" +print_stdout = "forbid" +print_stderr = "forbid" +unwrap_used = "forbid" +expect_used = "forbid" +dbg_macro = "forbid" +mem_forget = "forbid" +exit = "forbid" +integer_division = "forbid" +clone_on_ref_ptr = "forbid" +indexing_slicing = "forbid" +arithmetic_side_effects = "forbid" +float_arithmetic = "forbid" +as_conversions = "forbid" +shadow_reuse = "forbid" +shadow_same = "forbid" +unseparated_literal_suffix = "forbid" +undocumented_unsafe_blocks = "forbid" +cast_possible_truncation = "forbid" +cast_possible_wrap = "forbid" +cast_sign_loss = "forbid" +missing_errors_doc = "forbid" +multiple_crate_versions = "forbid" +redundant_pub_crate = "forbid" + +allow_attributes = "forbid" +allow_attributes_without_reason = "forbid" +panic = "forbid" +panic_in_result_fn = "forbid" +unimplemented = "forbid" +unreachable = "forbid" +infinite_loop = "forbid" +get_unwrap = "forbid" +unwrap_in_result = "forbid" +lossy_float_literal = "forbid" +fn_to_numeric_cast_any = "forbid" +as_underscore = "forbid" +as_pointer_underscore = "forbid" +host_endian_bytes = "forbid" +mem_replace_option_with_some = "forbid" +rc_mutex = "forbid" +rest_pat_in_fully_bound_structs = "forbid" +string_slice = "forbid" +suspicious_xor_used_as_pow = "forbid" +tests_outside_test_module = "forbid" +large_include_file = "forbid" diff --git a/README.txt b/README.txt index bfe5bab..516f09b 100644 --- a/README.txt +++ b/README.txt @@ -41,7 +41,8 @@ GETTING STARTED DEVELOPMENT AND PRODUCTION - src/ holds the Rust management command. Future Rust application backends + src/main.rs forwards to the private Rust management implementation in + src/app.rs and src/app/. Future Rust application backends belong in crates/. Add each crate to the root workspace explicitly and inherit its strict workspace lints. @@ -74,7 +75,8 @@ CHECKS AND LICENSES Check builds and checks TypeScript, then runs Rust formatting, Clippy, tests, rustdoc, cargo-deny, and cargo-about. npm development dependencies are pinned - in package-lock.json; run npm audit to check their advisories. + in package-lock.json. Typed ESLint, both compiler checks, and npm audit are + included. See docs/LINTING.txt for policy choices and tested exceptions. 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. diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..5513c98 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,16 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# 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 . + +allowed-duplicate-crates = ["generic-array", "getrandom", "syn"] diff --git a/crates/chat-crypto/browser/protocol.d.ts b/crates/chat-crypto/browser/protocol.d.ts index 6803c4c..30b5bf8 100644 --- a/crates/chat-crypto/browser/protocol.d.ts +++ b/crates/chat-crypto/browser/protocol.d.ts @@ -49,9 +49,9 @@ declare namespace StackChat { interface WorkerRequest { bytes: ArrayBuffer; request: CryptoRequest } interface Execution { output: string; randomCalls: number; randomBytes: number } interface Wasi { - runCommand(bytes: ArrayBuffer, request: CryptoRequest): Promise; - runProbe(bytes: ArrayBuffer): Promise; + readonly runCommand: (bytes: ArrayBuffer, request: CryptoRequest) => Promise; + readonly runProbe: (bytes: ArrayBuffer) => Promise; } } -declare var stackChatWasi: Readonly; +declare const stackChatWasi: Readonly; diff --git a/crates/chat-crypto/browser/wasi.ts b/crates/chat-crypto/browser/wasi.ts index a037519..f2d9290 100644 --- a/crates/chat-crypto/browser/wasi.ts +++ b/crates/chat-crypto/browser/wasi.ts @@ -15,8 +15,12 @@ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0 +function isStart(value: WebAssembly.ExportValue | undefined): value is () => unknown { + return typeof value === 'function' && value.length === 0; +} + async function execute(bytes: ArrayBuffer, input: string): Promise { - let memory: WebAssembly.Memory | undefined; + const instanceState: { memory: WebAssembly.Memory | undefined } = { memory: undefined }; let output = ''; let errors = ''; const stdoutDecoder = new TextDecoder('utf-8', { fatal: true }); @@ -26,15 +30,16 @@ async function execute(bytes: ArrayBuffer, input: string): Promise { - if (!memory) throw new Error('WASI memory is unavailable'); + const memory = instanceState.memory; + if (memory === undefined) throw new Error('WASI memory is unavailable'); return memory.buffer; }; const data = (): DataView => new DataView(memoryBuffer()); - const put32 = (address: number, value: number) => data().setUint32(address, value, true); - const noEntries = (count: number, size: number) => { put32(count, 0); put32(size, 0); return 0; }; + const put32 = (address: number, value: number): void => { data().setUint32(address, value, true); }; + const noEntries = (count: number, size: number): number => { put32(count, 0); put32(size, 0); return 0; }; class Exit extends Error { readonly code: number; - constructor(code: number) { super(`WASI exit ${code}`); this.code = code; } + constructor(code: number) { super(`WASI exit ${String(code)}`); this.code = code; } } const wasi: Record = { args_sizes_get: noEntries, @@ -108,7 +113,7 @@ async function execute(bytes: ArrayBuffer, input: string): Promise 76; + ]) wasi[name] = (): number => 76; const module = await WebAssembly.compile(bytes); for (const entry of WebAssembly.Module.imports(module)) { if (entry.module !== 'wasi_snapshot_preview1' || typeof wasi[entry.name] !== 'function') { @@ -118,17 +123,17 @@ async function execute(bytes: ArrayBuffer, input: string): Promise { const result = await execute(bytes, ''); if (!result.output.startsWith('PASS: backend=OpenSSL ') || result.randomCalls === 0) { throw new Error('OpenSSL probe did not pass'); @@ -146,6 +151,6 @@ async function runCommand(bytes: ArrayBuffer, request: StackChat.CryptoRequest): return response; } -globalThis.stackChatWasi = Object.freeze({ runCommand, runProbe }); +Object.defineProperty(globalThis, 'stackChatWasi', { value: Object.freeze({ runCommand, runProbe }), writable: false, configurable: false }); // @license-end diff --git a/crates/chat-crypto/src/application.rs b/crates/chat-crypto/src/application.rs index 963e1de..c13f4f4 100644 --- a/crates/chat-crypto/src/application.rs +++ b/crates/chat-crypto/src/application.rs @@ -37,7 +37,7 @@ const FORMAT: &str = "stack-chat-file/v2;argon2id-m65536-t3-p1;aes256-gcm"; const LIMIT: usize = 262_144; #[derive(Deserialize, JsonSerialize)] -#[serde(deny_unknown_fields)] +#[serde(crate = "serde", deny_unknown_fields)] struct Identity { origin: String, name: String, @@ -46,7 +46,7 @@ struct Identity { } #[derive(Deserialize, JsonSerialize)] -#[serde(deny_unknown_fields)] +#[serde(crate = "serde", deny_unknown_fields)] struct Envelope { format: String, salt: String, @@ -56,14 +56,19 @@ struct Envelope { } #[derive(Deserialize, JsonSerialize)] -#[serde(untagged)] +#[serde(crate = "serde", untagged)] enum FileCiphertext { Compact(String), Wrapped(Vec), } #[derive(Deserialize)] -#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +#[serde( + crate = "serde", + tag = "op", + rename_all = "snake_case", + deny_unknown_fields +)] enum Request { Create { origin: String, @@ -105,7 +110,7 @@ enum Request { } #[derive(Deserialize)] -#[serde(deny_unknown_fields)] +#[serde(crate = "serde", deny_unknown_fields)] struct EncryptedMessage { sender: String, ciphertext: String, diff --git a/crates/chat-crypto/tests/browser.ts b/crates/chat-crypto/tests/browser.ts index 75095ce..fd16529 100644 --- a/crates/chat-crypto/tests/browser.ts +++ b/crates/chat-crypto/tests/browser.ts @@ -13,33 +13,32 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import assert from 'node:assert/strict'; import { chromium } from 'playwright'; -import { serverOrigin } from '../../../tests/browser-support.js'; +import { asyncServer, closeBrowser, serverOrigin } from '../../../tests/browser-support.js'; const assets = new Map([ ['/wasi.mjs', [resolve('var/build/typescript/crates/chat-crypto/browser/wasi.js'), 'text/javascript']], ['/probe.wasm', [resolve('target/wasm32-wasip1/release/stack-chat-crypto.wasm'), 'application/wasm']], ]); -const server = createServer(async (request, response) => { +const server = asyncServer(async (request, response) => { response.setHeader('Cache-Control', 'no-store'); if (request.url === '/') { response.setHeader('Content-Type', 'text/html'); response.end('Local cryptographic compatibility test'); return; } - const asset = assets.get(request.url || ''); - if (!asset) { response.writeHead(404); response.end(); return; } + const asset = assets.get((request.url ?? '')); + if (asset === undefined) { response.writeHead(404); response.end(); return; } response.setHeader('Content-Type', asset[1]); response.end(await readFile(asset[0])); }); await new Promise(resolveReady => server.listen(0, '127.0.0.1', resolveReady)); const origin = serverOrigin(server); const browser = await chromium.launch({headless: true, channel: "chromium"}); -const deadline = setTimeout(() => browser.close(), 120000); +const deadline = setTimeout(() => { closeBrowser(browser); }, 120000); try { const page = await browser.newPage(); const external: string[] = []; @@ -50,13 +49,13 @@ try { const result = await page.evaluate(async () => { const host = '/wasi.mjs'; await import(host); - const { runProbe } = globalThis.stackChatWasi; + const { runProbe } = stackChatWasi; const bytes = await (await fetch('/probe.wasm', { cache: 'no-store' })).arrayBuffer(); const valid = await runProbe(bytes); const again = await runProbe(bytes); - const getRandomValues = crypto.getRandomValues; + const getRandomValues = crypto.getRandomValues.bind(crypto); let rejected = false; - crypto.getRandomValues = () => { throw new Error('Entropy unavailable'); }; + crypto.getRandomValues = (): never => { throw new Error('Entropy unavailable'); }; try { await runProbe(bytes); } catch (error) { rejected = (error instanceof Error ? error.message : String(error)).includes("Entropy unavailable"); } finally { crypto.getRandomValues = getRandomValues; } const modified = new Uint8Array(bytes.slice(0)); @@ -80,7 +79,7 @@ try { assert.deepEqual(await page.evaluate(async () => indexedDB.databases()), []); assert.equal(await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).length), 0); console.log(result.valid.output.trim()); - console.log(`Browser CSPRNG: ${result.valid.randomCalls} calls, ${result.valid.randomBytes} bytes; missing entropy and unknown imports rejected.`); + console.log(`Browser CSPRNG: ${String(result.valid.randomCalls)} calls, ${String(result.valid.randomBytes)} bytes; missing entropy and unknown imports rejected.`); console.log('PASS: actual Chromium WASM execution; no external requests, cookies, web storage, or IndexedDB.'); } finally { clearTimeout(deadline); diff --git a/crates/chat-server/Cargo.toml b/crates/chat-server/Cargo.toml index 6ebb2df..9a6ef7e 100644 --- a/crates/chat-server/Cargo.toml +++ b/crates/chat-server/Cargo.toml @@ -31,14 +31,14 @@ stack-config = { path = "../config", version = "0.1.0" } anyhow = "1" axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] } base64 = "0.22" -clap = { version = "4.6", features = ["derive"] } +clap = "4.6" hex = "0.4" openssl = "0.10" rusqlite = { version = "0.41", default-features = false, features = ["bundled", "backup"] } serde_json = "1" stack-chat-crypto = { path = "../chat-crypto", version = "0.1.0" } tokio-util = { version = "0.7", features = ["io"] } -tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "signal", "sync", "fs"] } +tokio = { version = "1", features = ["rt-multi-thread", "net", "signal", "sync", "fs"] } [dev-dependencies] tempfile = "3" diff --git a/crates/chat-server/src/app.rs b/crates/chat-server/src/app.rs new file mode 100644 index 0000000..31efd28 --- /dev/null +++ b/crates/chat-server/src/app.rs @@ -0,0 +1,199 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +#![doc = "Encrypted private chat server and backend maintenance commands."] + +mod cli; +mod http; +mod maintenance; +mod service; +mod store; + +use crate::app::cli::Arguments; +use anyhow::{Context, Result, ensure}; +use std::io::{Read, Write}; +use std::net::SocketAddr; +use std::process::ExitCode; + +fn initialize(root: &std::path::Path) -> Result<()> { + let env = root.join(".env"); + let existing = if env.exists() { + std::fs::read_to_string(&env)? + } else { + String::new() + }; + ensure!( + !existing + .lines() + .any(|line| line.starts_with("CHAT_SETUP_TOKEN=")), + "CHAT_SETUP_TOKEN already exists" + ); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&env)?; + std::fs::set_permissions(&env, std::os::unix::fs::PermissionsExt::from_mode(0o600))?; + writeln!(file, "\nCHAT_SETUP_TOKEN={}", service::token()?)?; + if !existing + .lines() + .any(|line| line.starts_with("CHAT_ORIGIN=")) + { + writeln!(file, "CHAT_ORIGIN=http://127.0.0.1:9080")?; + } + stack_config::initialize_public(root) +} + +fn healthcheck(mut address: SocketAddr) -> Result<()> { + if address.ip().is_unspecified() { + address.set_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); + } + let mut socket = + std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_secs(2))?; + socket.set_read_timeout(Some(std::time::Duration::from_secs(2)))?; + socket.set_write_timeout(Some(std::time::Duration::from_secs(2)))?; + socket.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?; + let mut response = String::new(); + socket.take(8192).read_to_string(&mut response)?; + ensure!( + response.starts_with("HTTP/1.0 200") || response.starts_with("HTTP/1.1 200"), + "Chat health endpoint failed" + ); + Ok(()) +} + +fn validate_origin( + configuration: &stack_config::Config, + bind: SocketAddr, + proxy: bool, +) -> Result<()> { + let origin = configuration.value("CHAT_ORIGIN")?; + if proxy { + let public = configuration.public_values(None)?; + let canonical = public + .get("CHAT_PUBLIC_ORIGIN") + .context("Missing public chat origin")?; + let local = format!( + "http://{}:{}", + configuration.value("CHAT_LOCAL_HOST")?, + configuration.port("LOCAL_HTTP_PORT")? + ); + ensure!( + origin == canonical || origin == local, + "Gateway chat origin must match the configured HTTPS hostname or dedicated local hostname" + ); + } else { + ensure!( + bind.ip().is_loopback() && origin == format!("http://{bind}"), + "Direct development chat requires an exact loopback HTTP origin" + ); + } + Ok(()) +} + +async fn serve(arguments: Arguments) -> Result<()> { + let root = arguments.root.canonicalize()?; + if arguments.init { + return initialize(&root); + } + let configuration = if arguments.listener.proxy { + stack_config::Config::from_environment()? + } else { + stack_config::Config::load(&root)? + }; + let bind = arguments + .listener + .bind + .unwrap_or(configuration.chat_bind()?); + if arguments.healthcheck { + return healthcheck(bind); + } + let data = root.join("var/data/chat"); + std::fs::create_dir_all(&data)?; + std::fs::set_permissions(&data, std::os::unix::fs::PermissionsExt::from_mode(0o700))?; + let database = data.join("chat.sqlite"); + let _server_lock = if arguments.snapshot.is_none() && !arguments.owner_fingerprint { + Some(maintenance::lock(&data)?) + } else { + None + }; + let mut store = store::Store::open(&database)?; + std::fs::set_permissions( + &database, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + if arguments.owner_fingerprint { + return maintenance::fingerprint(&store); + } + if let Some(expected) = arguments.reset_owner { + return maintenance::reset(&mut store, &expected, bind, arguments.listener.proxy); + } + if let Some(destination) = arguments.snapshot { + store.snapshot(&destination)?; + std::fs::set_permissions( + destination, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + return Ok(()); + } + let origin = configuration.value("CHAT_ORIGIN")?; + validate_origin(&configuration, bind, arguments.listener.proxy)?; + let setup = configuration + .value("CHAT_SETUP_TOKEN") + .context("Run --init to configure chat")?; + let state = service::Service::new(store, origin.to_owned(), setup.to_owned()); + let listener = tokio::net::TcpListener::bind(bind).await?; + writeln!( + std::io::stdout().lock(), + "Chat ready: {origin} (owner setup: /#setup)" + )?; + let local = (arguments.listener.proxy && origin.starts_with("http://")) + .then(|| configuration.port("LOCAL_HTTP_PORT")) + .transpose()?; + http::serve(listener, state, &root, configuration.public_values(local)?).await +} + +fn execute(arguments: Arguments) -> Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(serve(arguments)) +} + +#[doc = "Runs the checked command-line entry point and returns its process status."] +#[must_use] +pub fn run() -> ExitCode { + let arguments = match Arguments::try_parse() { + Ok(arguments) => arguments, + Err(error) => { + let status = if error.use_stderr() { + ExitCode::from(2) + } else { + ExitCode::SUCCESS + }; + drop(error.print()); + return status; + } + }; + match execute(arguments) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + drop(writeln!( + std::io::stderr().lock(), + "Chat server failed: {error:#}" + )); + ExitCode::FAILURE + } + } +} diff --git a/crates/chat-server/src/app/cli.rs b/crates/chat-server/src/app/cli.rs new file mode 100644 index 0000000..1296df4 --- /dev/null +++ b/crates/chat-server/src/app/cli.rs @@ -0,0 +1,167 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +use clap::{Arg, ArgAction, Command}; +use std::ffi::OsString; +use std::net::SocketAddr; +use std::path::PathBuf; + +#[derive(Debug)] + +pub(super) struct Arguments { + pub(super) root: PathBuf, + + pub(super) listener: Listener, + + pub(super) healthcheck: bool, + + pub(super) init: bool, + + pub(super) reset_owner: Option, + + pub(super) owner_fingerprint: bool, + + pub(super) snapshot: Option, +} + +#[derive(Debug)] +pub(super) struct Listener { + pub(super) bind: Option, + + pub(super) proxy: bool, +} + +fn flag(name: &'static str) -> Arg { + Arg::new(name).long(name).action(ArgAction::SetTrue) +} + +fn path(name: &'static str) -> Arg { + Arg::new(name) + .long(name) + .value_parser(clap::builder::PathBufValueParser::new()) +} + +fn command() -> Command { + Command::new("stack-chat-server").about("Run private chat directly or behind the stack gateway") + .args([ + path("root").default_value("."), + Arg::new("bind").long("bind").value_parser(str::parse::), + flag("proxy").help("Run behind the stack gateway using its explicit container environment"), + flag("healthcheck").help("Check the local HTTP health endpoint without opening the database"), + flag("init").conflicts_with_all(["reset-owner", "owner-fingerprint", "snapshot"]), + Arg::new("reset-owner").long("reset-owner").value_name("FINGERPRINT").conflicts_with_all(["owner-fingerprint", "snapshot"]) + .help("Permanently clear the matching owner registration and all chat history after key loss"), + flag("owner-fingerprint").conflicts_with("snapshot").help("Print the registered owner's public fingerprint"), + path("snapshot"), + ]) +} + +impl Arguments { + pub(super) fn try_parse() -> Result { + Self::try_parse_from(std::env::args_os()) + } + + fn try_parse_from( + arguments: impl IntoIterator + Clone>, + ) -> Result { + let mut values = command().try_get_matches_from(arguments)?; + let root = values.remove_one("root").ok_or_else(|| { + clap::Error::raw( + clap::error::ErrorKind::MissingRequiredArgument, + "Missing application root", + ) + })?; + Ok(Self { + root, + listener: Listener { + bind: values.remove_one("bind"), + proxy: values.get_flag("proxy"), + }, + healthcheck: values.get_flag("healthcheck"), + init: values.get_flag("init"), + reset_owner: values.remove_one("reset-owner"), + owner_fingerprint: values.get_flag("owner-fingerprint"), + snapshot: values.remove_one("snapshot"), + }) + } +} + +#[cfg(test)] +mod tests { + use super::{Arguments, command}; + use anyhow::{Result, ensure}; + use std::path::Path; + + #[test] + fn checked_command_schema() { + command().debug_assert(); + } + + #[test] + fn listener_defaults_and_maintenance_conflicts() -> Result<()> { + let defaults = Arguments::try_parse_from(["stack-chat-server"])?; + anyhow::ensure!((defaults.root) == (Path::new(".")), "Test invariant failed"); + anyhow::ensure!( + !defaults.listener.proxy && defaults.listener.bind.is_none(), + "Test invariant failed" + ); + let parsed = Arguments::try_parse_from([ + "stack-chat-server", + "--root", + "/tmp/stack", + "--bind", + "127.0.0.1:9180", + "--proxy", + "--healthcheck", + ])?; + anyhow::ensure!( + (parsed.root) == (Path::new("/tmp/stack")), + "Test invariant failed" + ); + ensure!( + parsed + .listener + .bind + .is_some_and(|address| address.port() == 9180), + "Listener port differs" + ); + anyhow::ensure!( + parsed.listener.proxy && parsed.healthcheck, + "Test invariant failed" + ); + for args in [ + vec!["stack-chat-server", "--bind", "garbage"], + vec!["stack-chat-server", "--init", "--snapshot", "snapshot"], + vec![ + "stack-chat-server", + "--reset-owner", + "abc", + "--owner-fingerprint", + ], + vec![ + "stack-chat-server", + "--owner-fingerprint", + "--snapshot", + "snapshot", + ], + ] { + anyhow::ensure!( + Arguments::try_parse_from(args).is_err(), + "Test invariant failed" + ); + } + Ok(()) + } +} diff --git a/crates/chat-server/src/app/http.rs b/crates/chat-server/src/app/http.rs new file mode 100644 index 0000000..5b1941a --- /dev/null +++ b/crates/chat-server/src/app/http.rs @@ -0,0 +1,243 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +use crate::app::service::Service; +use anyhow::Result; +use axum::{ + Json, Router, + body::Body, + extract::{DefaultBodyLimit, State}, + http::{HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Redirect, Response}, + routing::{get, post}, +}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tokio::sync::Semaphore; + +#[derive(Clone)] +struct App { + service: Arc>, + permits: Arc, + root: PathBuf, + origin: String, + public: std::collections::BTreeMap, +} + +fn secured(mut response: Response) -> Response { + for (name, value) in [ + ("cache-control", "no-store, max-age=0"), + ( + "content-security-policy", + "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; style-src 'self'; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'", + ), + ("referrer-policy", "no-referrer"), + ("x-content-type-options", "nosniff"), + ("cross-origin-opener-policy", "same-origin"), + ("cross-origin-resource-policy", "same-origin"), + ( + "permissions-policy", + "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + ), + ] { + response + .headers_mut() + .insert(name, HeaderValue::from_static(value)); + } + response +} + +async fn api(State(app): State, headers: HeaderMap, Json(request): Json) -> Response { + if headers.get("origin").and_then(|value| value.to_str().ok()) != Some(app.origin.as_str()) { + return secured( + ( + StatusCode::FORBIDDEN, + Json(json!({"error":"Origin rejected"})), + ) + .into_response(), + ); + } + let Ok(permit) = Arc::clone(&app.permits).try_acquire_owned() else { + return secured( + ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({"error":"Server busy"})), + ) + .into_response(), + ); + }; + let result = tokio::task::spawn_blocking(move || { + let _permit = permit; + app.service + .lock() + .map_err(|_| anyhow::anyhow!("Service unavailable"))? + .call(&request) + }) + .await; + match result { + Ok(Ok(value)) => secured(Json(value).into_response()), + _ => secured((StatusCode::BAD_REQUEST,Json(json!({"error":"Request rejected. Unlock again if your session expired; check credentials, message limits, and conversation access."}))).into_response()), + } +} + +fn packaged_asset<'a>(root: &Path, packaged: &'a str, development: &'a str) -> &'a str { + if root.join(packaged).is_file() { + packaged + } else { + development + } +} + +fn asset_location(root: &Path, path: &str) -> Option<(&'static str, &'static str)> { + let assets = [ + ( + "/", + "bin/chat/web/index.html", + "crates/chat-server/web/index.html", + "text/html; charset=utf-8", + ), + ( + "/app.js", + "bin/chat/web/app.js", + "var/build/typescript/crates/chat-server/web/app.js", + "text/javascript; charset=utf-8", + ), + ( + "/worker.js", + "bin/chat/web/worker.js", + "var/build/typescript/crates/chat-server/web/worker.js", + "text/javascript; charset=utf-8", + ), + ( + "/wasi.mjs", + "bin/chat/wasi.mjs", + "var/build/typescript/crates/chat-crypto/browser/wasi.js", + "text/javascript; charset=utf-8", + ), + ( + "/style.css", + "bin/chat/web/style.css", + "crates/chat-server/web/style.css", + "text/css; charset=utf-8", + ), + ( + "/crypto.wasm", + "bin/chat/crypto.wasm", + "target/wasm32-wasip1/release/stack-chat-crypto.wasm", + "application/wasm", + ), + ( + "/javascript.html", + "bin/chat/web/javascript.html", + "crates/chat-server/web/javascript.html", + "text/html; charset=utf-8", + ), + ( + "/SOURCE.tar.gz", + "bin/chat/SOURCE.tar.gz", + "var/build/chat-source.tar.gz", + "application/gzip", + ), + ( + "/LIMITATIONS.txt", + "docs/CHAT-APPLICATION.txt", + "docs/CHAT-APPLICATION.txt", + "text/plain; charset=utf-8", + ), + ( + "/LICENSE", + "LICENSE", + "LICENSE", + "text/plain; charset=utf-8", + ), + ]; + assets + .into_iter() + .find(|(route, _, _, _)| *route == path) + .map(|(_, packaged, development, mime)| (packaged_asset(root, packaged, development), mime)) +} + +async fn asset(State(app): State, uri: axum::http::Uri) -> Response { + if uri.path() == "/THIRD-PARTY-LICENSES.html" { + let Some(origin) = app.public.get("SITE_ORIGIN") else { + return secured(StatusCode::INTERNAL_SERVER_ERROR.into_response()); + }; + return secured( + Redirect::permanent(&format!("{origin}/THIRD-PARTY-LICENSES.html")).into_response(), + ); + } + let Some((path, mime)) = asset_location(&app.root, uri.path()) else { + return secured(StatusCode::NOT_FOUND.into_response()); + }; + if matches!(uri.path(), "/" | "/javascript.html") { + let Ok(bytes) = tokio::fs::read(app.root.join(path)).await else { + return secured(StatusCode::NOT_FOUND.into_response()); + }; + let rendered = String::from_utf8(bytes) + .map_err(anyhow::Error::from) + .and_then(|source| stack_config::render(&source, &app.public)); + return rendered.map_or_else( + |_| secured(StatusCode::INTERNAL_SERVER_ERROR.into_response()), + |html| secured(([("content-type", mime)], html).into_response()), + ); + } + let Ok(file) = tokio::fs::File::open(app.root.join(path)).await else { + return secured(StatusCode::NOT_FOUND.into_response()); + }; + let length = file.metadata().await.ok().map(|metadata| metadata.len()); + let mut response = Response::new(Body::from_stream(tokio_util::io::ReaderStream::new(file))); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static(mime)); + if let Some(header_length) = + length.and_then(|value| HeaderValue::from_str(&value.to_string()).ok()) + { + response + .headers_mut() + .insert("content-length", header_length); + } + secured(response) +} + +pub(super) async fn serve( + listener: tokio::net::TcpListener, + service: Service, + root: &Path, + public: std::collections::BTreeMap, +) -> Result<()> { + let app = App { + origin: service.origin.clone(), + service: Arc::new(Mutex::new(service)), + permits: Arc::new(Semaphore::new(4)), + root: root.to_path_buf(), + public, + }; + let routes = Router::new() + .route( + "/healthz", + get(|| async { secured("ok\n".into_response()) }), + ) + .route("/api", post(api)) + .fallback(get(asset)) + .layer(DefaultBodyLimit::max(262_144)) + .with_state(app); + axum::serve(listener, routes) + .with_graceful_shutdown(async { + drop(tokio::signal::ctrl_c().await); + }) + .await?; + Ok(()) +} diff --git a/crates/chat-server/src/app/maintenance.rs b/crates/chat-server/src/app/maintenance.rs new file mode 100644 index 0000000..647dff9 --- /dev/null +++ b/crates/chat-server/src/app/maintenance.rs @@ -0,0 +1,72 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +use crate::app::store::Store; +use anyhow::{Context, Result, ensure}; +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::net::{SocketAddr, TcpListener}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; + +pub(super) fn lock(directory: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(directory.join("server.lock"))?; + file.try_lock().context( + "Chat is already running. Stop it before starting another server or resetting the owner.", + )?; + Ok(file) +} + +pub(super) fn fingerprint(store: &Store) -> Result<()> { + let certificate = store.owner()?.context("No owner identity is registered")?; + writeln!( + std::io::stdout().lock(), + "{}", + stack_chat_crypto::public_identity(&certificate)? + )?; + Ok(()) +} + +pub(super) fn reset( + store: &mut Store, + expected: &str, + address: SocketAddr, + proxy: bool, +) -> Result<()> { + let _port = if proxy { + None + } else { + ensure!( + address.ip().is_loopback(), + "Reset must use the local chat address" + ); + Some( + TcpListener::bind(address) + .context("Stop the chat server before resetting its owner")?, + ) + }; + store.reset_owner(expected)?; + writeln!( + std::io::stdout().lock(), + "Owner registration and chat history cleared. Restart chat and use OWNER SETUP to create a new owner file. Existing backup archives were not changed." + )?; + Ok(()) +} diff --git a/crates/chat-server/src/app/service.rs b/crates/chat-server/src/app/service.rs new file mode 100644 index 0000000..d5433e7 --- /dev/null +++ b/crates/chat-server/src/app/service.rs @@ -0,0 +1,438 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +use crate::app::store::Store; +use anyhow::{Context, Result, ensure}; +use base64::{Engine, prelude::BASE64_STANDARD}; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +struct Challenge { + certificate: String, + text: String, + expiry: Instant, + setup: bool, +} + +struct Session { + identity: String, + expiry: Instant, +} + +pub(super) struct Service { + pub(super) store: Store, + pub(super) origin: String, + setup_token: String, + challenges: HashMap, + sessions: HashMap, + window: Instant, + attempts: u16, +} + +pub(super) fn token() -> Result { + let mut bytes = [0; 32]; + openssl::rand::rand_bytes(&mut bytes)?; + Ok(hex::encode(bytes)) +} + +pub(super) fn field<'a>(request: &'a Value, key: &str) -> Result<&'a str> { + request + .get(key) + .and_then(Value::as_str) + .context("Missing string field") +} + +fn ciphertext(request: &Value) -> Result<&str> { + let encoded = field(request, "ciphertext")?; + ensure!( + encoded.len() <= 65_536 && BASE64_STANDARD.decode(encoded)?.len() >= 64, + "Invalid ciphertext length" + ); + Ok(encoded) +} + +fn identifier(value: &str) -> Result<()> { + ensure!( + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()), + "Invalid identifier" + ); + Ok(()) +} + +impl Service { + pub(super) fn new(store: Store, origin: String, setup_token: String) -> Self { + Self { + store, + origin, + setup_token, + challenges: HashMap::new(), + sessions: HashMap::new(), + window: Instant::now(), + attempts: 0, + } + } + + fn owner_id(&self) -> Result { + stack_chat_crypto::public_identity(&self.store.owner()?.context("Owner not initialized")?) + } + + fn challenge(&mut self, request: &Value) -> Result { + if self.window.elapsed() >= Duration::from_secs(60) { + self.window = Instant::now(); + self.attempts = 0; + } + ensure!(self.attempts < 60, "Authentication rate exceeded"); + self.attempts = self + .attempts + .checked_add(1) + .context("Rate counter overflow")?; + self.challenges + .retain(|_, challenge| challenge.expiry > Instant::now()); + ensure!(self.challenges.len() < 128, "Too many pending challenges"); + let certificate = field(request, "public")?; + let fingerprint = stack_chat_crypto::public_identity(certificate)?; + let setup = self.store.owner()?.is_none(); + if setup { + let supplied = field(request, "setup_token")?; + ensure!( + self.setup_token.len() == 64 + && supplied.len() == 64 + && openssl::memcmp::eq(supplied.as_bytes(), self.setup_token.as_bytes()), + "Invalid setup credential" + ); + } + let nonce = token()?; + let expires = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() + .checked_add(60) + .context("Clock overflow")?; + let owner = if setup { + fingerprint.clone() + } else { + self.owner_id()? + }; + let text = format!( + "stack-chat-auth/v1\n{}\n{fingerprint}\n{nonce}\n{expires}\n{owner}", + self.origin + ); + self.challenges.insert( + nonce.clone(), + Challenge { + certificate: certificate.into(), + text: text.clone(), + expiry: Instant::now() + .checked_add(Duration::from_secs(60)) + .context("Clock overflow")?, + setup, + }, + ); + Ok(json!({"id":nonce,"challenge":text})) + } + + fn authenticate(&mut self, request: &Value) -> Result { + let challenge = self + .challenges + .remove(field(request, "id")?) + .context("Missing or consumed challenge")?; + ensure!(challenge.expiry > Instant::now(), "Expired challenge"); + stack_chat_crypto::verify_proof( + &challenge.certificate, + &challenge.text, + field(request, "signature")?, + )?; + let identity = stack_chat_crypto::public_identity(&challenge.certificate)?; + if challenge.setup { + self.store.install_owner(&challenge.certificate)?; + self.setup_token.clear(); + } + let owner = identity == self.owner_id()?; + if !owner { + self.store.register(&identity, &challenge.certificate)?; + } + self.sessions + .retain(|_, session| session.expiry > Instant::now()); + ensure!(self.sessions.len() < 256, "Too many sessions"); + let credential = token()?; + self.sessions.insert( + credential.clone(), + Session { + identity: identity.clone(), + expiry: Instant::now() + .checked_add(Duration::from_mins(30)) + .context("Clock overflow")?, + }, + ); + Ok(json!({"token":credential,"owner":owner,"id":identity,"expires_seconds":1800})) + } + + fn principal(&mut self, request: &Value) -> Result { + self.sessions + .retain(|_, session| session.expiry > Instant::now()); + Ok(self + .sessions + .get(field(request, "token")?) + .context("Missing or expired session")? + .identity + .clone()) + } + + fn authorized(&self, request: &Value, identity: &str) -> Result { + let conversation = field(request, "conversation")?; + identifier(conversation)?; + ensure!( + identity == conversation || identity == self.owner_id()?, + "Forbidden conversation" + ); + self.store.conversation(conversation)?; + Ok(conversation.into()) + } + + pub(super) fn call(&mut self, request: &Value) -> Result { + match field(request, "op")? { + "config" => Ok( + json!({"owner":self.store.owner()?,"origin":self.origin,"protocol":"stack-chat/v1"}), + ), + "challenge" => self.challenge(request), + "authenticate" => self.authenticate(request), + _ => self.authenticated(request), + } + } + + fn authenticated(&mut self, request: &Value) -> Result { + let identity = self.principal(request)?; + let operation = field(request, "op")?; + if operation == "lock" { + self.sessions.remove(field(request, "token")?); + return Ok(json!({"locked":true})); + } + if operation == "inbox" { + ensure!(identity == self.owner_id()?, "Owner only"); + return self.store.inbox(); + } + let conversation = self.authorized(request, &identity)?; + match operation { + "history" => self.store.messages( + &conversation, + request + .get("count") + .and_then(Value::as_u64) + .context("Missing count")?, + request.get("before").and_then(Value::as_u64).unwrap_or(0), + ), + "profile" => { + ensure!(identity == conversation, "Visitor only"); + self.store.profile(&conversation, ciphertext(request)?)?; + Ok(json!({"stored":true})) + } + "send" => { + let id = field(request, "id")?; + identifier(id)?; + self.store + .send(&conversation, id, &identity, ciphertext(request)?) + } + _ => anyhow::bail!("Unknown operation"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + struct Identity { + file: String, + public: String, + fingerprint: String, + } + + fn crypto(request: &Value) -> Result { + let response = + stack_chat_crypto::dispatch(&request.to_string()).map_err(anyhow::Error::msg)?; + Ok(serde_json::from_str(&response)?) + } + + fn identity(name: &str, owner: &str) -> Result { + let created = crypto( + &json!({"op":"create","origin":"http://127.0.0.1:9080","name":name,"password":"test-only-strong-password","owner":owner}), + )?; + let file = field(&created, "file")?; + let opened = crypto( + &json!({"op":"open","origin":"http://127.0.0.1:9080","file":file,"password":"test-only-strong-password"}), + )?; + Ok(Identity { + file: file.into(), + public: field(&opened, "public")?.into(), + fingerprint: field(&opened, "fingerprint")?.into(), + }) + } + + fn authenticate(service: &mut Service, identity: &Identity, setup: &str) -> Result { + let challenge = service + .call(&json!({"op":"challenge","public":identity.public,"setup_token":setup}))?; + let proof = crypto( + &json!({"op":"proof","file":identity.file,"password":"test-only-strong-password","origin":service.origin,"challenge":field(&challenge,"challenge")?}), + )?; + let request = json!({"op":"authenticate","id":field(&challenge,"id")?,"signature":field(&proof,"signature")?}); + let result = service.call(&request)?; + ensure!(service.call(&request).is_err(), "Challenge was replayed"); + Ok(result) + } + + fn reject_expired_and_failed_proofs(service: &mut Service, identity: &Identity) -> Result<()> { + let challenge = service.call(&json!({"op":"challenge","public":identity.public}))?; + let challenge_id = field(&challenge, "id")?; + service + .challenges + .get_mut(challenge_id) + .context("Challenge")? + .expiry = Instant::now(); + let expired = json!({"op":"authenticate","id":challenge_id,"signature":""}); + ensure!( + service.call(&expired).is_err(), + "Expired challenge accepted" + ); + ensure!( + !service.challenges.contains_key(challenge_id), + "Expired challenge retained" + ); + let invalid = service.call(&json!({"op":"challenge","public":identity.public}))?; + let invalid_id = field(&invalid, "id")?; + ensure!( + service + .call(&json!({"op":"authenticate","id":invalid_id,"signature":""})) + .is_err(), + "Invalid proof accepted" + ); + ensure!( + !service.challenges.contains_key(invalid_id), + "Failed challenge reusable" + ); + service.attempts = 60; + ensure!( + service + .call(&json!({"op":"challenge","public":identity.public})) + .is_err(), + "Challenge rate bound" + ); + ensure!( + service.store.install_owner(&identity.public).is_err(), + "Owner overwritten" + ); + Ok(()) + } + + #[test] + fn authorization_replays_history_and_snapshot_recovery() -> Result<()> { + let directory = TempDir::new()?; + let path = directory.path().join("chat.sqlite"); + let setup = token()?; + let mut service = Service::new( + Store::open(&path)?, + "http://127.0.0.1:9080".into(), + setup.clone(), + ); + let owner = identity("Owner", "")?; + ensure!( + service + .call(&json!({"op":"challenge","public":owner.public,"setup_token":"wrong"})) + .is_err(), + "Setup unauthenticated" + ); + let owner_session = authenticate(&mut service, &owner, &setup)?; + ensure!( + owner_session.get("owner") == Some(&json!(true)), + "Owner role" + ); + let visitor = identity("Visitor", &owner.public)?; + let visitor_session = authenticate(&mut service, &visitor, "")?; + let visitor_token = field(&visitor_session, "token")?; + let owner_token = field(&owner_session, "token")?; + let ciphertext = BASE64_STANDARD.encode([42; 128]); + let message_id = token()?; + let send = json!({"op":"send","conversation":visitor.fingerprint,"token":visitor_token,"id":message_id,"ciphertext":ciphertext}); + service.call(&send)?; + service.call(&send)?; + let conflicting = json!({"op":"send","conversation":visitor.fingerprint,"token":visitor_token,"id":message_id,"ciphertext":BASE64_STANDARD.encode([43;128])}); + ensure!( + service.call(&conflicting).is_err(), + "Conflicting ID accepted" + ); + let empty = service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":0}))?; + ensure!( + empty + .get("messages") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty), + "N=0" + ); + ensure!( + empty + .get("conversation") + .and_then(|value| value.get("total")) + == Some(&json!(1)), + "Retry duplicated message" + ); + ensure!( + service + .call(&json!({"op":"inbox","token":visitor_token})) + .is_err(), + "Visitor read inbox" + ); + ensure!(service.call(&json!({"op":"history","conversation":owner.fingerprint,"token":visitor_token,"count":1})).is_err(), "Wrong conversation accepted"); + service.call(&json!({"op":"send","conversation":visitor.fingerprint,"token":owner_token,"id":token()?,"ciphertext":ciphertext}))?; + let latest = service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":1}))?; + let sequence = latest + .get("messages") + .and_then(Value::as_array) + .and_then(|messages| messages.first()) + .and_then(|message| message.get("seq")) + .context("Sequence")?; + let older = service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":1,"before":sequence}))?; + ensure!( + older + .get("messages") + .and_then(Value::as_array) + .is_some_and(|messages| messages.len() == 1), + "Pagination" + ); + ensure!(service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":51})).is_err(), "History quota"); + reject_expired_and_failed_proofs(&mut service, &visitor)?; + let snapshot = directory.path().join("snapshot.sqlite"); + service.store.snapshot(&snapshot)?; + let restored = Store::open(&snapshot)?; + ensure!( + restored.owner()? == Some(owner.public), + "Owner missing after restore" + ); + ensure!( + restored.conversation(&visitor.fingerprint)?.get("total") == Some(&json!(2)), + "Restored count" + ); + service.call(&json!({"op":"lock","token":visitor_token}))?; + ensure!(service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":0})).is_err(), "Locked session accepted"); + let session = service.sessions.get_mut(owner_token).context("Session")?; + session.expiry = Instant::now(); + ensure!( + service + .call(&json!({"op":"inbox","token":owner_token})) + .is_err(), + "Expired session accepted" + ); + Ok(()) + } +} diff --git a/crates/chat-server/src/app/store.rs b/crates/chat-server/src/app/store.rs new file mode 100644 index 0000000..23e8ba1 --- /dev/null +++ b/crates/chat-server/src/app/store.rs @@ -0,0 +1,182 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +use anyhow::{Context, Result, ensure}; +use rusqlite::{Connection, OptionalExtension, params}; +use serde_json::{Value, json}; +use std::path::Path; + +pub(super) struct Store { + connection: Connection, +} + +impl Store { + pub(super) fn open(path: &Path) -> Result { + let connection = Connection::open(path)?; + connection.execute_batch("PRAGMA foreign_keys=ON; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON; CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS conversations (id TEXT PRIMARY KEY, cert TEXT NOT NULL, profile TEXT); CREATE TABLE IF NOT EXISTS messages (seq INTEGER PRIMARY KEY AUTOINCREMENT, conversation TEXT NOT NULL REFERENCES conversations(id), id TEXT NOT NULL UNIQUE, sender TEXT NOT NULL, ciphertext TEXT NOT NULL);")?; + Ok(Self { connection }) + } + + pub(super) fn owner(&self) -> Result> { + Ok(self + .connection + .query_row("SELECT value FROM metadata WHERE key='owner'", [], |row| { + row.get(0) + }) + .optional()?) + } + + pub(super) fn install_owner(&self, cert: &str) -> Result<()> { + ensure!(self.owner()?.is_none(), "Already initialized"); + self.connection + .execute("INSERT INTO metadata VALUES ('owner', ?1)", [cert])?; + Ok(()) + } + + pub(super) fn reset_owner(&mut self, expected: &str) -> Result<()> { + let owner = self.owner()?.context("No owner identity is registered")?; + let fingerprint = stack_chat_crypto::public_identity(&owner)?; + ensure!( + expected.len() == 64 && expected.eq_ignore_ascii_case(&fingerprint), + "Owner fingerprint does not match; no data was cleared" + ); + let transaction = self.connection.transaction()?; + transaction.execute_batch("DELETE FROM messages; DELETE FROM conversations; DELETE FROM metadata WHERE key='owner'; DELETE FROM sqlite_sequence WHERE name='messages';")?; + transaction.commit()?; + Ok(()) + } + + pub(super) fn register(&self, id: &str, cert: &str) -> Result<()> { + if self.connection.query_row( + "SELECT EXISTS(SELECT 1 FROM conversations WHERE id=?1)", + [id], + |row| row.get::<_, bool>(0), + )? { + return Ok(()); + } + let count: i64 = + self.connection + .query_row("SELECT COUNT(*) FROM conversations", [], |row| row.get(0))?; + ensure!(count < 1000, "Conversation limit reached"); + self.connection.execute( + "INSERT INTO conversations (id,cert) VALUES (?1,?2)", + params![id, cert], + )?; + Ok(()) + } + + pub(super) fn conversation(&self, id: &str) -> Result { + let (cert, profile): (String, Option) = self.connection.query_row( + "SELECT cert,profile FROM conversations WHERE id=?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let (total, visitor): (i64, i64) = self.connection.query_row( + "SELECT COUNT(*),COALESCE(SUM(sender=?1),0) FROM messages WHERE conversation=?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + Ok( + json!({"id":id,"public":cert,"profile":profile,"total":total,"visitor_sent":visitor,"owner_sent":total.checked_sub(visitor).context("Counter invariant")?}), + ) + } + + pub(super) fn inbox(&self) -> Result { + let mut statement = self + .connection + .prepare("SELECT id FROM conversations ORDER BY id")?; + let ids = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + let conversations = ids + .iter() + .map(|id| self.conversation(id)) + .collect::>>()?; + Ok(json!({"conversations":conversations})) + } + + pub(super) fn profile(&self, id: &str, ciphertext: &str) -> Result<()> { + self.connection.execute( + "UPDATE conversations SET profile=?2 WHERE id=?1 AND profile IS NULL", + params![id, ciphertext], + )?; + Ok(()) + } + + pub(super) fn messages(&self, id: &str, count: u64, before: u64) -> Result { + ensure!( + count <= 50 && before <= i64::MAX.unsigned_abs(), + "Invalid history window" + ); + let mut statement = self.connection.prepare("SELECT seq,id,sender,ciphertext FROM messages WHERE conversation=?1 AND (?3=0 OR seq(0)?,"id":row.get::<_,String>(1)?,"sender":row.get::<_,String>(2)?,"ciphertext":row.get::<_,String>(3)?})))?.collect::>>()?; + Ok(json!({"conversation":self.conversation(id)?,"messages":messages})) + } + + pub(super) fn send( + &mut self, + conversation: &str, + id: &str, + sender: &str, + ciphertext: &str, + ) -> Result { + let transaction = self.connection.transaction()?; + let existing: Option<(i64, String, String, String)> = transaction + .query_row( + "SELECT seq,conversation,sender,ciphertext FROM messages WHERE id=?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + if let Some((seq, previous_conversation, previous_sender, previous_ciphertext)) = existing { + ensure!( + previous_conversation == conversation + && previous_sender == sender + && previous_ciphertext == ciphertext, + "Conflicting message identifier" + ); + transaction.commit()?; + return Ok(json!({"seq":seq,"conversation":self.conversation(conversation)?})); + } + let count: i64 = transaction.query_row( + "SELECT COUNT(*) FROM messages WHERE conversation=?1", + [conversation], + |row| row.get(0), + )?; + let used: i64 = transaction.query_row( + "SELECT COALESCE(SUM(LENGTH(ciphertext)),0) FROM messages", + [], + |row| row.get(0), + )?; + ensure!( + count < 10_000 && used < 1_000_000_000, + "Storage quota reached" + ); + transaction.execute( + "INSERT INTO messages (conversation,id,sender,ciphertext) VALUES (?1,?2,?3,?4)", + params![conversation, id, sender, ciphertext], + )?; + let sequence = transaction.last_insert_rowid(); + transaction.commit()?; + Ok(json!({"seq":sequence,"conversation":self.conversation(conversation)?})) + } + + pub(super) fn snapshot(&self, destination: &Path) -> Result<()> { + ensure!(!destination.exists(), "Snapshot destination already exists"); + self.connection + .backup("main", destination, None::)?; + Ok(()) + } +} diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/http.rs deleted file mode 100644 index cb21b1f..0000000 --- a/crates/chat-server/src/http.rs +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// 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 . - -use crate::service::Service; -use anyhow::Result; -use axum::{ - Json, Router, - body::Body, - extract::{DefaultBodyLimit, State}, - http::{HeaderMap, HeaderValue, StatusCode}, - response::{IntoResponse, Redirect, Response}, - routing::{get, post}, -}; -use serde_json::{Value, json}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; -use tokio::sync::Semaphore; - -#[derive(Clone)] -struct App { - service: Arc>, - permits: Arc, - root: PathBuf, - origin: String, - public: std::collections::BTreeMap, -} - -fn secured(mut response: Response) -> Response { - for (name, value) in [ - ("cache-control", "no-store, max-age=0"), - ( - "content-security-policy", - "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; style-src 'self'; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'", - ), - ("referrer-policy", "no-referrer"), - ("x-content-type-options", "nosniff"), - ("cross-origin-opener-policy", "same-origin"), - ("cross-origin-resource-policy", "same-origin"), - ( - "permissions-policy", - "camera=(), microphone=(), geolocation=(), payment=(), usb=()", - ), - ] { - response - .headers_mut() - .insert(name, HeaderValue::from_static(value)); - } - response -} - -async fn api(State(app): State, headers: HeaderMap, Json(request): Json) -> Response { - if headers.get("origin").and_then(|value| value.to_str().ok()) != Some(app.origin.as_str()) { - return secured( - ( - StatusCode::FORBIDDEN, - Json(json!({"error":"Origin rejected"})), - ) - .into_response(), - ); - } - let Ok(permit) = Arc::clone(&app.permits).try_acquire_owned() else { - return secured( - ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({"error":"Server busy"})), - ) - .into_response(), - ); - }; - let result = tokio::task::spawn_blocking(move || { - let _permit = permit; - app.service - .lock() - .map_err(|_| anyhow::anyhow!("Service unavailable"))? - .call(&request) - }) - .await; - match result { - Ok(Ok(value)) => secured(Json(value).into_response()), - _ => secured((StatusCode::BAD_REQUEST,Json(json!({"error":"Request rejected. Unlock again if your session expired; check credentials, message limits, and conversation access."}))).into_response()), - } -} - -fn packaged_asset<'a>(root: &Path, packaged: &'a str, development: &'a str) -> &'a str { - if root.join(packaged).is_file() { - packaged - } else { - development - } -} - -fn asset_location(root: &Path, path: &str) -> Option<(&'static str, &'static str)> { - let assets = [ - ( - "/", - "bin/chat/web/index.html", - "crates/chat-server/web/index.html", - "text/html; charset=utf-8", - ), - ( - "/app.js", - "bin/chat/web/app.js", - "var/build/typescript/crates/chat-server/web/app.js", - "text/javascript; charset=utf-8", - ), - ( - "/worker.js", - "bin/chat/web/worker.js", - "var/build/typescript/crates/chat-server/web/worker.js", - "text/javascript; charset=utf-8", - ), - ( - "/wasi.mjs", - "bin/chat/wasi.mjs", - "var/build/typescript/crates/chat-crypto/browser/wasi.js", - "text/javascript; charset=utf-8", - ), - ( - "/style.css", - "bin/chat/web/style.css", - "crates/chat-server/web/style.css", - "text/css; charset=utf-8", - ), - ( - "/crypto.wasm", - "bin/chat/crypto.wasm", - "target/wasm32-wasip1/release/stack-chat-crypto.wasm", - "application/wasm", - ), - ( - "/javascript.html", - "bin/chat/web/javascript.html", - "crates/chat-server/web/javascript.html", - "text/html; charset=utf-8", - ), - ( - "/SOURCE.tar.gz", - "bin/chat/SOURCE.tar.gz", - "var/build/chat-source.tar.gz", - "application/gzip", - ), - ( - "/LIMITATIONS.txt", - "docs/CHAT-APPLICATION.txt", - "docs/CHAT-APPLICATION.txt", - "text/plain; charset=utf-8", - ), - ( - "/LICENSE", - "LICENSE", - "LICENSE", - "text/plain; charset=utf-8", - ), - ]; - assets - .into_iter() - .find(|(route, _, _, _)| *route == path) - .map(|(_, packaged, development, mime)| (packaged_asset(root, packaged, development), mime)) -} - -async fn asset(State(app): State, uri: axum::http::Uri) -> Response { - if uri.path() == "/THIRD-PARTY-LICENSES.html" { - let Some(origin) = app.public.get("SITE_ORIGIN") else { - return secured(StatusCode::INTERNAL_SERVER_ERROR.into_response()); - }; - return secured( - Redirect::permanent(&format!("{origin}/THIRD-PARTY-LICENSES.html")).into_response(), - ); - } - let Some((path, mime)) = asset_location(&app.root, uri.path()) else { - return secured(StatusCode::NOT_FOUND.into_response()); - }; - if matches!(uri.path(), "/" | "/javascript.html") { - let Ok(bytes) = tokio::fs::read(app.root.join(path)).await else { - return secured(StatusCode::NOT_FOUND.into_response()); - }; - let rendered = String::from_utf8(bytes) - .map_err(anyhow::Error::from) - .and_then(|source| stack_config::render(&source, &app.public)); - return rendered.map_or_else( - |_| secured(StatusCode::INTERNAL_SERVER_ERROR.into_response()), - |html| secured(([("content-type", mime)], html).into_response()), - ); - } - let Ok(file) = tokio::fs::File::open(app.root.join(path)).await else { - return secured(StatusCode::NOT_FOUND.into_response()); - }; - let length = file.metadata().await.ok().map(|metadata| metadata.len()); - let mut response = Response::new(Body::from_stream(tokio_util::io::ReaderStream::new(file))); - response - .headers_mut() - .insert("content-type", HeaderValue::from_static(mime)); - if let Some(header_length) = - length.and_then(|value| HeaderValue::from_str(&value.to_string()).ok()) - { - response - .headers_mut() - .insert("content-length", header_length); - } - secured(response) -} - -pub(crate) async fn serve( - listener: tokio::net::TcpListener, - service: Service, - root: &Path, - public: std::collections::BTreeMap, -) -> Result<()> { - let app = App { - origin: service.origin.clone(), - service: Arc::new(Mutex::new(service)), - permits: Arc::new(Semaphore::new(4)), - root: root.to_path_buf(), - public, - }; - let routes = Router::new() - .route( - "/healthz", - get(|| async { secured("ok\n".into_response()) }), - ) - .route("/api", post(api)) - .fallback(get(asset)) - .layer(DefaultBodyLimit::max(262_144)) - .with_state(app); - axum::serve(listener, routes) - .with_graceful_shutdown(async { - drop(tokio::signal::ctrl_c().await); - }) - .await?; - Ok(()) -} diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs index 1d600b7..942a379 100644 --- a/crates/chat-server/src/main.rs +++ b/crates/chat-server/src/main.rs @@ -13,204 +13,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -#![doc = "Encrypted private chat server and backend maintenance commands."] +#![doc = "Checked command-line entry point for the personal infrastructure stack."] -mod http; -mod maintenance; -mod service; -mod store; +mod app; -use anyhow::{Context, Result, ensure}; -use clap::Parser; -use std::io::{Read, Write}; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::process::ExitCode; +pub use app::run; -#[derive(Debug, Parser)] -#[command(about = "Run private chat directly or behind the stack gateway")] -struct Arguments { - #[arg(long, default_value = ".")] - root: PathBuf, - #[command(flatten)] - listener: Listener, - #[arg( - long, - help = "Check the local HTTP health endpoint without opening the database" - )] - healthcheck: bool, - #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint", "snapshot"])] - init: bool, - #[arg(long, value_name = "FINGERPRINT", conflicts_with_all = ["owner_fingerprint", "snapshot"], help = "Permanently clear the matching owner registration and all chat history after key loss")] - reset_owner: Option, - #[arg( - long, - conflicts_with = "snapshot", - help = "Print the registered owner's public fingerprint" - )] - owner_fingerprint: bool, - #[arg(long)] - snapshot: Option, -} - -#[derive(Debug, clap::Args)] -struct Listener { - #[arg(long)] - bind: Option, - #[arg( - long, - help = "Run behind the stack gateway using its explicit container environment" - )] - proxy: bool, -} - -fn initialize(root: &std::path::Path) -> Result<()> { - let env = root.join(".env"); - let existing = if env.exists() { - std::fs::read_to_string(&env)? - } else { - String::new() - }; - ensure!( - !existing - .lines() - .any(|line| line.starts_with("CHAT_SETUP_TOKEN=")), - "CHAT_SETUP_TOKEN already exists" - ); - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&env)?; - std::fs::set_permissions(&env, std::os::unix::fs::PermissionsExt::from_mode(0o600))?; - writeln!(file, "\nCHAT_SETUP_TOKEN={}", service::token()?)?; - if !existing - .lines() - .any(|line| line.starts_with("CHAT_ORIGIN=")) - { - writeln!(file, "CHAT_ORIGIN=http://127.0.0.1:9080")?; - } - stack_config::initialize_public(root) -} - -fn healthcheck(mut address: SocketAddr) -> Result<()> { - if address.ip().is_unspecified() { - address.set_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); - } - let mut socket = - std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_secs(2))?; - socket.set_read_timeout(Some(std::time::Duration::from_secs(2)))?; - socket.set_write_timeout(Some(std::time::Duration::from_secs(2)))?; - socket.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?; - let mut response = String::new(); - socket.take(8192).read_to_string(&mut response)?; - ensure!( - response.starts_with("HTTP/1.0 200") || response.starts_with("HTTP/1.1 200"), - "Chat health endpoint failed" - ); - Ok(()) -} - -fn validate_origin( - configuration: &stack_config::Config, - bind: SocketAddr, - proxy: bool, -) -> Result<()> { - let origin = configuration.value("CHAT_ORIGIN")?; - if proxy { - let public = configuration.public_values(None)?; - let canonical = public - .get("CHAT_PUBLIC_ORIGIN") - .context("Missing public chat origin")?; - let local = format!( - "http://{}:{}", - configuration.value("CHAT_LOCAL_HOST")?, - configuration.port("LOCAL_HTTP_PORT")? - ); - ensure!( - origin == canonical || origin == local, - "Gateway chat origin must match the configured HTTPS hostname or dedicated local hostname" - ); - } else { - ensure!( - bind.ip().is_loopback() && origin == format!("http://{bind}"), - "Direct development chat requires an exact loopback HTTP origin" - ); - } - Ok(()) -} - -async fn run(arguments: Arguments) -> Result<()> { - let root = arguments.root.canonicalize()?; - if arguments.init { - return initialize(&root); - } - let configuration = if arguments.listener.proxy { - stack_config::Config::from_environment()? - } else { - stack_config::Config::load(&root)? - }; - let bind = arguments - .listener - .bind - .unwrap_or(configuration.chat_bind()?); - if arguments.healthcheck { - return healthcheck(bind); - } - let data = root.join("var/data/chat"); - std::fs::create_dir_all(&data)?; - std::fs::set_permissions(&data, std::os::unix::fs::PermissionsExt::from_mode(0o700))?; - let database = data.join("chat.sqlite"); - let _server_lock = if arguments.snapshot.is_none() && !arguments.owner_fingerprint { - Some(maintenance::lock(&data)?) - } else { - None - }; - let mut store = store::Store::open(&database)?; - std::fs::set_permissions( - &database, - std::os::unix::fs::PermissionsExt::from_mode(0o600), - )?; - if arguments.owner_fingerprint { - return maintenance::fingerprint(&store); - } - if let Some(expected) = arguments.reset_owner { - return maintenance::reset(&mut store, &expected, bind, arguments.listener.proxy); - } - if let Some(destination) = arguments.snapshot { - store.snapshot(&destination)?; - std::fs::set_permissions( - destination, - std::os::unix::fs::PermissionsExt::from_mode(0o600), - )?; - return Ok(()); - } - let origin = configuration.value("CHAT_ORIGIN")?; - validate_origin(&configuration, bind, arguments.listener.proxy)?; - let setup = configuration - .value("CHAT_SETUP_TOKEN") - .context("Run --init to configure chat")?; - let state = service::Service::new(store, origin.to_owned(), setup.to_owned()); - let listener = tokio::net::TcpListener::bind(bind).await?; - writeln!( - std::io::stdout().lock(), - "Chat ready: {origin} (owner setup: /#setup)" - )?; - let local = (arguments.listener.proxy && origin.starts_with("http://")) - .then(|| configuration.port("LOCAL_HTTP_PORT")) - .transpose()?; - http::serve(listener, state, &root, configuration.public_values(local)?).await -} - -#[tokio::main] -async fn main() -> ExitCode { - match run(Arguments::parse()).await { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - drop(writeln!( - std::io::stderr().lock(), - "Chat server failed: {error:#}" - )); - ExitCode::FAILURE - } - } +fn main() -> std::process::ExitCode { + run() } diff --git a/crates/chat-server/src/maintenance.rs b/crates/chat-server/src/maintenance.rs deleted file mode 100644 index d416d73..0000000 --- a/crates/chat-server/src/maintenance.rs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// 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 . - -use crate::store::Store; -use anyhow::{Context, Result, ensure}; -use std::fs::{File, OpenOptions}; -use std::io::Write; -use std::net::{SocketAddr, TcpListener}; -use std::os::unix::fs::OpenOptionsExt; -use std::path::Path; - -pub(crate) fn lock(directory: &Path) -> Result { - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .mode(0o600) - .open(directory.join("server.lock"))?; - file.try_lock().context( - "Chat is already running. Stop it before starting another server or resetting the owner.", - )?; - Ok(file) -} - -pub(crate) fn fingerprint(store: &Store) -> Result<()> { - let certificate = store.owner()?.context("No owner identity is registered")?; - writeln!( - std::io::stdout().lock(), - "{}", - stack_chat_crypto::public_identity(&certificate)? - )?; - Ok(()) -} - -pub(crate) fn reset( - store: &mut Store, - expected: &str, - address: SocketAddr, - proxy: bool, -) -> Result<()> { - let _port = if proxy { - None - } else { - ensure!( - address.ip().is_loopback(), - "Reset must use the local chat address" - ); - Some( - TcpListener::bind(address) - .context("Stop the chat server before resetting its owner")?, - ) - }; - store.reset_owner(expected)?; - writeln!( - std::io::stdout().lock(), - "Owner registration and chat history cleared. Restart chat and use OWNER SETUP to create a new owner file. Existing backup archives were not changed." - )?; - Ok(()) -} diff --git a/crates/chat-server/src/service.rs b/crates/chat-server/src/service.rs deleted file mode 100644 index 3f9b6ee..0000000 --- a/crates/chat-server/src/service.rs +++ /dev/null @@ -1,438 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// 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 . - -use crate::store::Store; -use anyhow::{Context, Result, ensure}; -use base64::{Engine, prelude::BASE64_STANDARD}; -use serde_json::{Value, json}; -use std::collections::HashMap; -use std::time::{Duration, Instant}; - -struct Challenge { - certificate: String, - text: String, - expiry: Instant, - setup: bool, -} - -struct Session { - identity: String, - expiry: Instant, -} - -pub(crate) struct Service { - pub(crate) store: Store, - pub(crate) origin: String, - setup_token: String, - challenges: HashMap, - sessions: HashMap, - window: Instant, - attempts: u16, -} - -pub(crate) fn token() -> Result { - let mut bytes = [0; 32]; - openssl::rand::rand_bytes(&mut bytes)?; - Ok(hex::encode(bytes)) -} - -pub(crate) fn field<'a>(request: &'a Value, key: &str) -> Result<&'a str> { - request - .get(key) - .and_then(Value::as_str) - .context("Missing string field") -} - -fn ciphertext(request: &Value) -> Result<&str> { - let encoded = field(request, "ciphertext")?; - ensure!( - encoded.len() <= 65_536 && BASE64_STANDARD.decode(encoded)?.len() >= 64, - "Invalid ciphertext length" - ); - Ok(encoded) -} - -fn identifier(value: &str) -> Result<()> { - ensure!( - value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()), - "Invalid identifier" - ); - Ok(()) -} - -impl Service { - pub(crate) fn new(store: Store, origin: String, setup_token: String) -> Self { - Self { - store, - origin, - setup_token, - challenges: HashMap::new(), - sessions: HashMap::new(), - window: Instant::now(), - attempts: 0, - } - } - - fn owner_id(&self) -> Result { - stack_chat_crypto::public_identity(&self.store.owner()?.context("Owner not initialized")?) - } - - fn challenge(&mut self, request: &Value) -> Result { - if self.window.elapsed() >= Duration::from_secs(60) { - self.window = Instant::now(); - self.attempts = 0; - } - ensure!(self.attempts < 60, "Authentication rate exceeded"); - self.attempts = self - .attempts - .checked_add(1) - .context("Rate counter overflow")?; - self.challenges - .retain(|_, challenge| challenge.expiry > Instant::now()); - ensure!(self.challenges.len() < 128, "Too many pending challenges"); - let certificate = field(request, "public")?; - let fingerprint = stack_chat_crypto::public_identity(certificate)?; - let setup = self.store.owner()?.is_none(); - if setup { - let supplied = field(request, "setup_token")?; - ensure!( - self.setup_token.len() == 64 - && supplied.len() == 64 - && openssl::memcmp::eq(supplied.as_bytes(), self.setup_token.as_bytes()), - "Invalid setup credential" - ); - } - let nonce = token()?; - let expires = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs() - .checked_add(60) - .context("Clock overflow")?; - let owner = if setup { - fingerprint.clone() - } else { - self.owner_id()? - }; - let text = format!( - "stack-chat-auth/v1\n{}\n{fingerprint}\n{nonce}\n{expires}\n{owner}", - self.origin - ); - self.challenges.insert( - nonce.clone(), - Challenge { - certificate: certificate.into(), - text: text.clone(), - expiry: Instant::now() - .checked_add(Duration::from_secs(60)) - .context("Clock overflow")?, - setup, - }, - ); - Ok(json!({"id":nonce,"challenge":text})) - } - - fn authenticate(&mut self, request: &Value) -> Result { - let challenge = self - .challenges - .remove(field(request, "id")?) - .context("Missing or consumed challenge")?; - ensure!(challenge.expiry > Instant::now(), "Expired challenge"); - stack_chat_crypto::verify_proof( - &challenge.certificate, - &challenge.text, - field(request, "signature")?, - )?; - let identity = stack_chat_crypto::public_identity(&challenge.certificate)?; - if challenge.setup { - self.store.install_owner(&challenge.certificate)?; - self.setup_token.clear(); - } - let owner = identity == self.owner_id()?; - if !owner { - self.store.register(&identity, &challenge.certificate)?; - } - self.sessions - .retain(|_, session| session.expiry > Instant::now()); - ensure!(self.sessions.len() < 256, "Too many sessions"); - let credential = token()?; - self.sessions.insert( - credential.clone(), - Session { - identity: identity.clone(), - expiry: Instant::now() - .checked_add(Duration::from_mins(30)) - .context("Clock overflow")?, - }, - ); - Ok(json!({"token":credential,"owner":owner,"id":identity,"expires_seconds":1800})) - } - - fn principal(&mut self, request: &Value) -> Result { - self.sessions - .retain(|_, session| session.expiry > Instant::now()); - Ok(self - .sessions - .get(field(request, "token")?) - .context("Missing or expired session")? - .identity - .clone()) - } - - fn authorized(&self, request: &Value, identity: &str) -> Result { - let conversation = field(request, "conversation")?; - identifier(conversation)?; - ensure!( - identity == conversation || identity == self.owner_id()?, - "Forbidden conversation" - ); - self.store.conversation(conversation)?; - Ok(conversation.into()) - } - - pub(crate) fn call(&mut self, request: &Value) -> Result { - match field(request, "op")? { - "config" => Ok( - json!({"owner":self.store.owner()?,"origin":self.origin,"protocol":"stack-chat/v1"}), - ), - "challenge" => self.challenge(request), - "authenticate" => self.authenticate(request), - _ => self.authenticated(request), - } - } - - fn authenticated(&mut self, request: &Value) -> Result { - let identity = self.principal(request)?; - let operation = field(request, "op")?; - if operation == "lock" { - self.sessions.remove(field(request, "token")?); - return Ok(json!({"locked":true})); - } - if operation == "inbox" { - ensure!(identity == self.owner_id()?, "Owner only"); - return self.store.inbox(); - } - let conversation = self.authorized(request, &identity)?; - match operation { - "history" => self.store.messages( - &conversation, - request - .get("count") - .and_then(Value::as_u64) - .context("Missing count")?, - request.get("before").and_then(Value::as_u64).unwrap_or(0), - ), - "profile" => { - ensure!(identity == conversation, "Visitor only"); - self.store.profile(&conversation, ciphertext(request)?)?; - Ok(json!({"stored":true})) - } - "send" => { - let id = field(request, "id")?; - identifier(id)?; - self.store - .send(&conversation, id, &identity, ciphertext(request)?) - } - _ => anyhow::bail!("Unknown operation"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - struct Identity { - file: String, - public: String, - fingerprint: String, - } - - fn crypto(request: &Value) -> Result { - let response = - stack_chat_crypto::dispatch(&request.to_string()).map_err(anyhow::Error::msg)?; - Ok(serde_json::from_str(&response)?) - } - - fn identity(name: &str, owner: &str) -> Result { - let created = crypto( - &json!({"op":"create","origin":"http://127.0.0.1:9080","name":name,"password":"test-only-strong-password","owner":owner}), - )?; - let file = field(&created, "file")?; - let opened = crypto( - &json!({"op":"open","origin":"http://127.0.0.1:9080","file":file,"password":"test-only-strong-password"}), - )?; - Ok(Identity { - file: file.into(), - public: field(&opened, "public")?.into(), - fingerprint: field(&opened, "fingerprint")?.into(), - }) - } - - fn authenticate(service: &mut Service, identity: &Identity, setup: &str) -> Result { - let challenge = service - .call(&json!({"op":"challenge","public":identity.public,"setup_token":setup}))?; - let proof = crypto( - &json!({"op":"proof","file":identity.file,"password":"test-only-strong-password","origin":service.origin,"challenge":field(&challenge,"challenge")?}), - )?; - let request = json!({"op":"authenticate","id":field(&challenge,"id")?,"signature":field(&proof,"signature")?}); - let result = service.call(&request)?; - ensure!(service.call(&request).is_err(), "Challenge was replayed"); - Ok(result) - } - - fn reject_expired_and_failed_proofs(service: &mut Service, identity: &Identity) -> Result<()> { - let challenge = service.call(&json!({"op":"challenge","public":identity.public}))?; - let challenge_id = field(&challenge, "id")?; - service - .challenges - .get_mut(challenge_id) - .context("Challenge")? - .expiry = Instant::now(); - let expired = json!({"op":"authenticate","id":challenge_id,"signature":""}); - ensure!( - service.call(&expired).is_err(), - "Expired challenge accepted" - ); - ensure!( - !service.challenges.contains_key(challenge_id), - "Expired challenge retained" - ); - let invalid = service.call(&json!({"op":"challenge","public":identity.public}))?; - let invalid_id = field(&invalid, "id")?; - ensure!( - service - .call(&json!({"op":"authenticate","id":invalid_id,"signature":""})) - .is_err(), - "Invalid proof accepted" - ); - ensure!( - !service.challenges.contains_key(invalid_id), - "Failed challenge reusable" - ); - service.attempts = 60; - ensure!( - service - .call(&json!({"op":"challenge","public":identity.public})) - .is_err(), - "Challenge rate bound" - ); - ensure!( - service.store.install_owner(&identity.public).is_err(), - "Owner overwritten" - ); - Ok(()) - } - - #[test] - fn authorization_replays_history_and_snapshot_recovery() -> Result<()> { - let directory = TempDir::new()?; - let path = directory.path().join("chat.sqlite"); - let setup = token()?; - let mut service = Service::new( - Store::open(&path)?, - "http://127.0.0.1:9080".into(), - setup.clone(), - ); - let owner = identity("Owner", "")?; - ensure!( - service - .call(&json!({"op":"challenge","public":owner.public,"setup_token":"wrong"})) - .is_err(), - "Setup unauthenticated" - ); - let owner_session = authenticate(&mut service, &owner, &setup)?; - ensure!( - owner_session.get("owner") == Some(&json!(true)), - "Owner role" - ); - let visitor = identity("Visitor", &owner.public)?; - let visitor_session = authenticate(&mut service, &visitor, "")?; - let visitor_token = field(&visitor_session, "token")?; - let owner_token = field(&owner_session, "token")?; - let ciphertext = BASE64_STANDARD.encode([42; 128]); - let message_id = token()?; - let send = json!({"op":"send","conversation":visitor.fingerprint,"token":visitor_token,"id":message_id,"ciphertext":ciphertext}); - service.call(&send)?; - service.call(&send)?; - let conflicting = json!({"op":"send","conversation":visitor.fingerprint,"token":visitor_token,"id":message_id,"ciphertext":BASE64_STANDARD.encode([43;128])}); - ensure!( - service.call(&conflicting).is_err(), - "Conflicting ID accepted" - ); - let empty = service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":0}))?; - ensure!( - empty - .get("messages") - .and_then(Value::as_array) - .is_some_and(Vec::is_empty), - "N=0" - ); - ensure!( - empty - .get("conversation") - .and_then(|value| value.get("total")) - == Some(&json!(1)), - "Retry duplicated message" - ); - ensure!( - service - .call(&json!({"op":"inbox","token":visitor_token})) - .is_err(), - "Visitor read inbox" - ); - ensure!(service.call(&json!({"op":"history","conversation":owner.fingerprint,"token":visitor_token,"count":1})).is_err(), "Wrong conversation accepted"); - service.call(&json!({"op":"send","conversation":visitor.fingerprint,"token":owner_token,"id":token()?,"ciphertext":ciphertext}))?; - let latest = service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":1}))?; - let sequence = latest - .get("messages") - .and_then(Value::as_array) - .and_then(|messages| messages.first()) - .and_then(|message| message.get("seq")) - .context("Sequence")?; - let older = service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":1,"before":sequence}))?; - ensure!( - older - .get("messages") - .and_then(Value::as_array) - .is_some_and(|messages| messages.len() == 1), - "Pagination" - ); - ensure!(service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":51})).is_err(), "History quota"); - reject_expired_and_failed_proofs(&mut service, &visitor)?; - let snapshot = directory.path().join("snapshot.sqlite"); - service.store.snapshot(&snapshot)?; - let restored = Store::open(&snapshot)?; - ensure!( - restored.owner()? == Some(owner.public), - "Owner missing after restore" - ); - ensure!( - restored.conversation(&visitor.fingerprint)?.get("total") == Some(&json!(2)), - "Restored count" - ); - service.call(&json!({"op":"lock","token":visitor_token}))?; - ensure!(service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":0})).is_err(), "Locked session accepted"); - let session = service.sessions.get_mut(owner_token).context("Session")?; - session.expiry = Instant::now(); - ensure!( - service - .call(&json!({"op":"inbox","token":owner_token})) - .is_err(), - "Expired session accepted" - ); - Ok(()) - } -} diff --git a/crates/chat-server/src/store.rs b/crates/chat-server/src/store.rs deleted file mode 100644 index 0e09de7..0000000 --- a/crates/chat-server/src/store.rs +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// 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 . - -use anyhow::{Context, Result, ensure}; -use rusqlite::{Connection, OptionalExtension, params}; -use serde_json::{Value, json}; -use std::path::Path; - -pub(crate) struct Store { - connection: Connection, -} - -impl Store { - pub(crate) fn open(path: &Path) -> Result { - let connection = Connection::open(path)?; - connection.execute_batch("PRAGMA foreign_keys=ON; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON; CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS conversations (id TEXT PRIMARY KEY, cert TEXT NOT NULL, profile TEXT); CREATE TABLE IF NOT EXISTS messages (seq INTEGER PRIMARY KEY AUTOINCREMENT, conversation TEXT NOT NULL REFERENCES conversations(id), id TEXT NOT NULL UNIQUE, sender TEXT NOT NULL, ciphertext TEXT NOT NULL);")?; - Ok(Self { connection }) - } - - pub(crate) fn owner(&self) -> Result> { - Ok(self - .connection - .query_row("SELECT value FROM metadata WHERE key='owner'", [], |row| { - row.get(0) - }) - .optional()?) - } - - pub(crate) fn install_owner(&self, cert: &str) -> Result<()> { - ensure!(self.owner()?.is_none(), "Already initialized"); - self.connection - .execute("INSERT INTO metadata VALUES ('owner', ?1)", [cert])?; - Ok(()) - } - - pub(crate) fn reset_owner(&mut self, expected: &str) -> Result<()> { - let owner = self.owner()?.context("No owner identity is registered")?; - let fingerprint = stack_chat_crypto::public_identity(&owner)?; - ensure!( - expected.len() == 64 && expected.eq_ignore_ascii_case(&fingerprint), - "Owner fingerprint does not match; no data was cleared" - ); - let transaction = self.connection.transaction()?; - transaction.execute_batch("DELETE FROM messages; DELETE FROM conversations; DELETE FROM metadata WHERE key='owner'; DELETE FROM sqlite_sequence WHERE name='messages';")?; - transaction.commit()?; - Ok(()) - } - - pub(crate) fn register(&self, id: &str, cert: &str) -> Result<()> { - if self.connection.query_row( - "SELECT EXISTS(SELECT 1 FROM conversations WHERE id=?1)", - [id], - |row| row.get::<_, bool>(0), - )? { - return Ok(()); - } - let count: i64 = - self.connection - .query_row("SELECT COUNT(*) FROM conversations", [], |row| row.get(0))?; - ensure!(count < 1000, "Conversation limit reached"); - self.connection.execute( - "INSERT INTO conversations (id,cert) VALUES (?1,?2)", - params![id, cert], - )?; - Ok(()) - } - - pub(crate) fn conversation(&self, id: &str) -> Result { - let (cert, profile): (String, Option) = self.connection.query_row( - "SELECT cert,profile FROM conversations WHERE id=?1", - [id], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; - let (total, visitor): (i64, i64) = self.connection.query_row( - "SELECT COUNT(*),COALESCE(SUM(sender=?1),0) FROM messages WHERE conversation=?1", - [id], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; - Ok( - json!({"id":id,"public":cert,"profile":profile,"total":total,"visitor_sent":visitor,"owner_sent":total.checked_sub(visitor).context("Counter invariant")?}), - ) - } - - pub(crate) fn inbox(&self) -> Result { - let mut statement = self - .connection - .prepare("SELECT id FROM conversations ORDER BY id")?; - let ids = statement - .query_map([], |row| row.get::<_, String>(0))? - .collect::>>()?; - let conversations = ids - .iter() - .map(|id| self.conversation(id)) - .collect::>>()?; - Ok(json!({"conversations":conversations})) - } - - pub(crate) fn profile(&self, id: &str, ciphertext: &str) -> Result<()> { - self.connection.execute( - "UPDATE conversations SET profile=?2 WHERE id=?1 AND profile IS NULL", - params![id, ciphertext], - )?; - Ok(()) - } - - pub(crate) fn messages(&self, id: &str, count: u64, before: u64) -> Result { - ensure!( - count <= 50 && before <= i64::MAX.unsigned_abs(), - "Invalid history window" - ); - let mut statement = self.connection.prepare("SELECT seq,id,sender,ciphertext FROM messages WHERE conversation=?1 AND (?3=0 OR seq(0)?,"id":row.get::<_,String>(1)?,"sender":row.get::<_,String>(2)?,"ciphertext":row.get::<_,String>(3)?})))?.collect::>>()?; - Ok(json!({"conversation":self.conversation(id)?,"messages":messages})) - } - - pub(crate) fn send( - &mut self, - conversation: &str, - id: &str, - sender: &str, - ciphertext: &str, - ) -> Result { - let transaction = self.connection.transaction()?; - let existing: Option<(i64, String, String, String)> = transaction - .query_row( - "SELECT seq,conversation,sender,ciphertext FROM messages WHERE id=?1", - [id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), - ) - .optional()?; - if let Some((seq, previous_conversation, previous_sender, previous_ciphertext)) = existing { - ensure!( - previous_conversation == conversation - && previous_sender == sender - && previous_ciphertext == ciphertext, - "Conflicting message identifier" - ); - transaction.commit()?; - return Ok(json!({"seq":seq,"conversation":self.conversation(conversation)?})); - } - let count: i64 = transaction.query_row( - "SELECT COUNT(*) FROM messages WHERE conversation=?1", - [conversation], - |row| row.get(0), - )?; - let used: i64 = transaction.query_row( - "SELECT COALESCE(SUM(LENGTH(ciphertext)),0) FROM messages", - [], - |row| row.get(0), - )?; - ensure!( - count < 10_000 && used < 1_000_000_000, - "Storage quota reached" - ); - transaction.execute( - "INSERT INTO messages (conversation,id,sender,ciphertext) VALUES (?1,?2,?3,?4)", - params![conversation, id, sender, ciphertext], - )?; - let sequence = transaction.last_insert_rowid(); - transaction.commit()?; - Ok(json!({"seq":sequence,"conversation":self.conversation(conversation)?})) - } - - pub(crate) fn snapshot(&self, destination: &Path) -> Result<()> { - ensure!(!destination.exists(), "Snapshot destination already exists"); - self.connection - .backup("main", destination, None::)?; - Ok(()) - } -} diff --git a/crates/chat-server/tests/browser.ts b/crates/chat-server/tests/browser.ts index 74a9df2..468a5f5 100644 --- a/crates/chat-server/tests/browser.ts +++ b/crates/chat-server/tests/browser.ts @@ -19,20 +19,20 @@ import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename, stat, readdir import { tmpdir } from 'node:os'; import { resolve, join } from 'node:path'; import { randomBytes } from 'node:crypto'; -import { spawn, execFileSync, spawnSync, execFile } from 'node:child_process'; +import { spawn, execFileSync, spawnSync, execFile, type SpawnSyncReturns } from 'node:child_process'; import { promisify } from 'node:util'; import { chromium, firefox, type Browser, type BrowserContext, type Page } from 'playwright'; -import { libreJSAddon, list, record, requestBody, required, serverOrigin, string } from '../../../tests/browser-support.js'; +import { closeBrowser, libreJSAddon, list, record, requestBody, required, serverOrigin, string } from '../../../tests/browser-support.js'; const run = promisify(execFile); const testRoot = process.env['CHAT_TEST_ROOT']; const packaged = Boolean(testRoot); -let root = testRoot ? resolve(testRoot) : await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); +let root = testRoot !== undefined && testRoot.length > 0 ? resolve(testRoot) : await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); const initialRoot = root; const token = packaged - ? (await readFile(join(root,'.env'),'utf8')).split('\n').find(line => line.startsWith('CHAT_SETUP_TOKEN='))?.slice('CHAT_SETUP_TOKEN='.length) || '' + ? ((await readFile(join(root,'.env'),'utf8')).split('\n').find(line => line.startsWith('CHAT_SETUP_TOKEN='))?.slice('CHAT_SETUP_TOKEN='.length) ?? '') : randomBytes(32).toString('hex'); -const origin = process.env['CHAT_TEST_ORIGIN'] || `http://127.0.0.1:${process.env['CHAT_TEST_PORT'] || '9081'}`; +const origin = (process.env['CHAT_TEST_ORIGIN'] ?? `http://127.0.0.1:${(process.env['CHAT_TEST_PORT'] ?? '9081')}`); if (packaged) { const fixture = await readFile(join(root,'.env'),'utf8'); const address = new URL(origin); @@ -53,32 +53,32 @@ if (packaged) { await mkdir(join(root,'var'), {recursive:true}); await symlink(resolve('var/build'),join(root,'var/build')); } -async function stack(...args: string[]) { +async function stack(...args: string[]): Promise<{ stdout: string; stderr: string; }> { return run(join(root,'bin/stack'),args,{cwd:root,env:{...process.env,GNUPGHOME:gpgHome},maxBuffer:8*1024*1024,timeout:300000}); } -async function restorePackage() { +async function restorePackage(): Promise { const listed = await run('gpg',['--no-options','--homedir',gpgHome,'--with-colons','--list-keys']); const fingerprint = required(required(listed.stdout.split('\n').find(line => line.startsWith('fpr:')), 'Missing key fingerprint').split(':')[9], 'Missing key fingerprint field'); const key = join(testHome,'recipient.asc'); const exported = await run('gpg',['--no-options','--homedir',gpgHome,'--armor','--export',fingerprint]); await writeFile(key,exported.stdout); const backup = await stack('backup','--include-images','--recipient',fingerprint,'--key',key); - const archive = backup.stdout.match(/Encrypted export: (.+)/)?.[1]; - assert.ok(archive,backup.stdout); + const archive = (/Encrypted export: (.+)/.exec(backup.stdout))?.[1]; + assert.ok(archive !== undefined && archive.length > 0,backup.stdout); assert.equal((await stat(archive)).mode & 0o777,0o600); assert.equal((await stat(archive + '.sha256')).mode & 0o777,0o600); await stopServer(); - const destination = `${initialRoot}-restored-${++restorations}`; + const destination = `${initialRoot}-restored-${String(++restorations)}`; await stack('restore',archive,destination); root = destination; assert.equal(await readFile(join(root,'.env'),'utf8'),await readFile(join(initialRoot,'.env'),'utf8')); for (const name of ['target','.tools']) assert.equal(spawnSync('test',['-e',join(root,name)]).status,1); await launchServer(); - console.log(`PASS: compiler-free HTTPS stack recovered from encrypted export ${restorations}.`); + console.log(`PASS: compiler-free HTTPS stack recovered from encrypted export ${String(restorations)}.`); } let server: ReturnType | undefined; let serverErrors = ''; -async function verifyGateway() { +async function verifyGateway(): Promise { const url = new URL(origin); const args = ['--noproxy','*','--silent','--show-error','--fail']; if (url.hostname.endsWith('.localhost')) { @@ -90,7 +90,7 @@ async function verifyGateway() { const health = await run('curl',[...args,origin + '/healthz']); assert.equal(health.stdout,'ok\n'); } -async function launchServer() { +async function launchServer(): Promise { if (packaged) { await stack('up','--production'); let failure; @@ -101,17 +101,20 @@ async function launchServer() { throw failure; } server = spawn(resolve('target/debug/stack-chat-server'), ['--root',root,'--bind',new URL(origin).host], {stdio:['ignore','pipe','pipe']}); - server.stderr?.on('data', bytes => { serverErrors += bytes.toString(); }); + server.stderr?.on('data', (bytes: unknown) => { + if (!Buffer.isBuffer(bytes)) throw new Error('Expected server diagnostics as bytes'); + serverErrors += bytes.toString(); + }); await Promise.race([ - new Promise((ready, reject) => { server?.stdout?.once('data', () => ready()); server?.once('exit', () => reject(new Error(serverErrors || 'Server exited'))); }), - new Promise((_, reject) => setTimeout(() => reject(new Error('Server did not start')), 20000).unref()), + new Promise((ready, reject) => { server?.stdout?.once('data', () => { ready(); }); server?.once('exit', () => { reject(new Error((serverErrors.length > 0 ? serverErrors : 'Server exited'))); }); }), + new Promise((_, reject) => setTimeout(() => { reject(new Error('Server did not start')); }, 20000).unref()), ]); } -async function stopServer() { +async function stopServer(): Promise { if (packaged) { await stack('down'); return; } - if (!server) return; + if (server === undefined) return; if (server.exitCode !== null || server.signalCode !== null) return; - const stopped = new Promise(done => server?.once('exit', () => done())); + const stopped = new Promise(done => server?.once('exit', () => { done(); })); server.kill('SIGINT'); await stopped; } @@ -128,21 +131,21 @@ if (libre) { 'extensions.update.enabled':false,'app.update.auto':false,'datareporting.healthreport.uploadEnabled':false, 'browser.sessionstore.resume_from_crash':false, }}); - browser = { close: () => required(persistent, 'Missing Firefox context').close() }; + browser = { close: (): Promise => required(persistent, 'Missing Firefox context').close() }; } else { browser = await chromium.launch({headless:true,channel:'chromium'}); } -const deadline = setTimeout(() => browser.close(), 300000); +const deadline = setTimeout(() => { closeBrowser(browser); }, 300000); const contexts: BrowserContext[] = []; const external: string[] = []; const errors: string[] = []; const bodies: ReturnType[] = []; -async function page() { - const context = required(persistent || await ('newContext' in browser ? browser.newContext({acceptDownloads:true,ignoreHTTPSErrors:packaged}) : Promise.reject(new Error('Missing browser context'))), 'Missing browser context'); +async function page(): Promise { + const context = required(persistent ?? await ('newContext' in browser ? browser.newContext({acceptDownloads:true,ignoreHTTPSErrors:packaged}) : Promise.reject(new Error('Missing browser context'))), 'Missing browser context'); contexts.push(context); const page = await context.newPage(); page.setDefaultTimeout(90000); page.on('pageerror', error => errors.push(error.message)); const navigate = page.goto.bind(page); - page.goto = async (...args: Parameters) => { + page.goto = async (...args: Parameters): ReturnType => { for (let attempt = 0; ; attempt += 1) { try { return await navigate(...args); } catch (error) { if (attempt >= 2 || !(error instanceof Error && error.message.includes('ERR_NETWORK_CHANGED'))) throw error; } @@ -155,15 +158,15 @@ async function page() { }); return page; } -async function waitEntry(page: Page) { await page.locator('#create-choice').waitFor({state:'visible'}); } -async function waitStatus(page: Page, text: string) { await page.waitForFunction(value => document.getElementById('status')?.textContent?.includes(value), text); } -async function create(page: Page, name: string, owner = false) { +async function waitEntry(page: Page): Promise { await page.locator('#create-choice').waitFor({state:'visible'}); } +async function waitStatus(page: Page, text: string): Promise { await page.waitForFunction(value => document.getElementById('status')?.textContent.includes(value), text); } +async function create(page: Page, name: string, owner = false): Promise<{ name: string; mimeType: string; buffer: Buffer; }> { const previousAuthentications = bodies.filter(body => body.op === 'authenticate').length; if (owner) await page.fill('#setup-token', token); else await page.click('#create-choice'); await page.fill('#name', name); await page.fill('#new-password', password); await page.fill('#confirm-password', password); - const downloaded = Promise.race([page.waitForEvent('download'), page.waitForFunction(() => /failed|timed out/i.test(document.getElementById('status')?.textContent || '')).then(async () => { throw new Error(await page.locator('#status').textContent() || 'Missing status'); })]); + const downloaded = Promise.race([page.waitForEvent('download'), page.waitForFunction(() => /failed|timed out/i.test((document.getElementById('status')?.textContent ?? ''))).then(async () => { throw new Error((await page.locator('#status').textContent() ?? 'Missing status')); })]); await page.click('#create-form button'); const download = await downloaded; const bytes = await readFile(required(await download.path(), 'Missing download')); @@ -180,18 +183,18 @@ async function create(page: Page, name: string, owner = false) { assert.equal(bodies.filter(body => body.op === 'authenticate').length,previousAuthentications); return {name:'identity.chat',mimeType:'application/octet-stream',buffer:bytes}; } -async function unlock(page: Page, file: { name: string; mimeType: string; buffer: Buffer }, phrase = password) { +async function unlock(page: Page, file: { name: string; mimeType: string; buffer: Buffer }, phrase = password): Promise { await page.setInputFiles('#file', file); await page.fill('#password', phrase); await page.click('#unlock-form button'); } -async function storageEmpty(page: Page) { +async function storageEmpty(page: Page): Promise { assert.equal(await page.evaluate(() => localStorage.length + sessionStorage.length),0); assert.deepEqual(await page.evaluate(() => indexedDB.databases()),[]); assert.equal(await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).length),0); assert.deepEqual(await page.context().cookies(),[]); } -async function verifyLibreJS() { +async function verifyLibreJS(): Promise { let executed = false; const controlServer = createServer((request,response) => { response.setHeader('Cache-Control','no-store'); @@ -221,12 +224,15 @@ try { await malformed.close(); const owner = await page(); const response = await owner.goto(origin); - await owner.locator('#owner-choice').waitFor({state:'visible'}).catch(async error => { throw new Error(`${error.message}; status=${await owner.locator('#status').textContent()}`); }); + await owner.locator('#owner-choice').waitFor({state:'visible'}).catch(async (error: unknown) => { + const currentStatus = await owner.locator('#status').textContent(); + throw new Error(`${String(error)}; status=${currentStatus ?? ''}`, { cause: error }); + }); await owner.click('#owner-choice'); - assert.match(required(response, 'Missing response').headers()['cache-control'] || '', /no-store/); - assert.match(required(response, 'Missing response').headers()['content-security-policy'] || '', /frame-ancestors 'none'/); + assert.match((required(response, 'Missing response').headers()['cache-control'] ?? ''), /no-store/); + assert.match((required(response, 'Missing response').headers()['content-security-policy'] ?? ''), /frame-ancestors 'none'/); await owner.locator('#setup-token').waitFor({state:'visible'}); - assert.match(await owner.locator('#create-title').textContent() || '',/owner only/); + assert.match((await owner.locator('#create-title').textContent() ?? ''),/owner only/); assert.equal(await owner.locator('.introduction h1').count(),0); assert.equal(await owner.locator('.libre-badge a[rel=jslicense]').count(),1); assert.equal(await owner.locator('.source-footer a[rel=jslicense]').count(),0); @@ -234,7 +240,7 @@ try { const ownerFile = await create(owner,'Doan',true); await unlock(owner,ownerFile); await waitStatus(owner,'Unlocked in memory only'); - assert.match(await owner.locator('#who').textContent() || '', /OWNER/); + assert.match((await owner.locator('#who').textContent() ?? ''), /OWNER/); const visitor = await page(); await visitor.goto(origin); await waitEntry(visitor); @@ -246,7 +252,7 @@ try { await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); assert.equal(await visitor.locator('#messages li').count(),0); - assert.match(await visitor.locator('#counts').textContent() || '',/Total: 0/); + assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 0/); const malicious = ' hello λ'; await visitor.fill('#text',malicious); await visitor.click('#send-form button'); @@ -254,7 +260,7 @@ try { await owner.click('#refresh-inbox'); await owner.locator('#conversations button').first().click(); await waitStatus(owner,'Conversation opened'); - assert.match(await owner.locator('#thread-name').textContent() || '',/A friend/); + assert.match((await owner.locator('#thread-name').textContent() ?? ''),/A friend/); assert.equal(await owner.locator('#messages li').count(),0); await owner.route('**/api', async route => { const body = requestBody(required(route.request().postData(), 'Missing API body')); @@ -280,7 +286,7 @@ try { await visitor.click('#history-form button'); await waitStatus(visitor,'Selected history decrypted'); assert.equal(await visitor.locator('#messages li').count(),2); - assert.match(await visitor.locator('#counts').textContent() || '',/Total: 2 \| Sent: 1 \| Received: 1/); + assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 2 \| Sent: 1 \| Received: 1/); let interrupted = false; await visitor.route('**/api',async route => { const body = requestBody(required(route.request().postData(), 'Missing API request')); @@ -290,10 +296,10 @@ try { await visitor.fill('#text','Safe retry after a lost response'); await visitor.click('#send-form button'); await visitor.locator('#retry').waitFor({state:'visible'}); - await visitor.waitForFunction(() => !document.querySelector('#retry')?.disabled); + await visitor.waitForFunction(() => !(document.querySelector('#retry')?.disabled === true)); await visitor.click('#retry'); await waitStatus(visitor,'Encrypted message stored'); - assert.match(await visitor.locator('#counts').textContent() || '',/Total: 3/); + assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 3/); await visitor.reload(); await waitEntry(visitor); assert.equal(await visitor.locator('#messages li').count(),0); @@ -302,12 +308,12 @@ try { await visitor.click('#open-choice'); await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); - assert.match(await visitor.locator('#counts').textContent() || '',/Total: 3/); + assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 3/); assert.equal(await visitor.locator('#messages li').count(),0); await visitor.fill('#history-count','1'); await visitor.click('#history-form button'); await waitStatus(visitor,'Selected history decrypted'); - assert.match(await visitor.locator('#messages p').textContent() || '',/Safe retry/); + assert.match((await visitor.locator('#messages p').textContent() ?? ''),/Safe retry/); await visitor.click('#older'); await waitStatus(visitor,'Selected history decrypted'); assert.equal(await visitor.locator('#messages li').count(),2); @@ -329,7 +335,7 @@ try { await visitor.click('#open-choice'); await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); - assert.match(await visitor.locator('#counts').textContent() || '',/Total: 3/); + assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 3/); await visitor.fill('#history-count','3'); await visitor.click('#history-form button'); await waitStatus(visitor,'Selected history decrypted'); @@ -342,7 +348,7 @@ try { await owner.click('#owner-open'); await unlock(owner,ownerFile); await waitStatus(owner,'Unlocked in memory only'); - assert.match(await owner.locator('#who').textContent() || '',/OWNER/); + assert.match((await owner.locator('#who').textContent() ?? ''),/OWNER/); await visitor.fill('#history-count','3'); await visitor.click('#history-form button'); await visitor.click('#lock'); @@ -358,7 +364,7 @@ try { await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); for (let index = 1; index <= 51; index += 1) { - await visitor.fill('#text',`Batch fixture ${index}`); + await visitor.fill('#text',`Batch fixture ${String(index)}`); await visitor.click('#send-form button'); await waitStatus(visitor,'Encrypted message stored'); } @@ -392,7 +398,7 @@ try { } const blocked = await visitor.request.post(origin + '/api',{data:{op:'config'},headers:{Origin:'https://foreign.invalid'}}); assert.equal(blocked.status(),403); - const administrative = (...args: string[]) => packaged + const administrative = (...args: string[]): SpawnSyncReturns => packaged ? spawnSync(join(root,'bin/stack'),['chat',...args],{cwd:root,encoding:'utf8'}) : spawnSync(resolve('target/debug/stack-chat-server'),['--root',root,'--bind',new URL(origin).host,...args],{encoding:'utf8'}); const registered = administrative('--owner-fingerprint'); @@ -426,10 +432,10 @@ try { await waitStatus(visitor,'Pinned owner key differs'); assert.equal(await visitor.locator('#chat').isVisible(),false); assert.equal(serverErrors,''); - if (process.env['CHAT_SCREENSHOT']) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true}); + if (((process.env['CHAT_SCREENSHOT'] ?? '').length > 0)) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true}); if (libre) { const addon = libreJSAddon(await readFile(join(testHome,'firefox-profile','extensions.json'),'utf8')); - assert.ok(addon?.active,'LibreJS must actually be active'); + assert.ok(addon.active,'LibreJS must actually be active'); console.log(`LibreJS ${addon.version} is active in Firefox; complete chat workflow succeeded.`); } console.log('PASS: real browser/WASI owner setup, protected file recovery, wrong password, visitor/owner encrypted messages, safe text rendering, N=0, counts, dynamic 0-to-total selection and 50-row batching, pagination, retry deduplication, reload, live SQLite snapshot restoration and both identity recoveries, explicit lock, owner setup/access, backend-only reset guards and fresh registration, old key-pin rejection, origin rejection, malformed configuration and history rejection, no browser storage or external requests, no private uploads or database plaintext.'); diff --git a/crates/chat-server/tests/progress.ts b/crates/chat-server/tests/progress.ts index 59c939d..610488c 100644 --- a/crates/chat-server/tests/progress.ts +++ b/crates/chat-server/tests/progress.ts @@ -14,12 +14,12 @@ // along with this program. If not, see . import assert from 'node:assert/strict'; -import { createServer, type ServerResponse } from 'node:http'; +import type { ServerResponse } from 'node:http'; import { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { chromium, firefox, type BrowserContext, type Page } from 'playwright'; -import { libreJSAddon, required, serverOrigin } from '../../../tests/browser-support.js'; +import { asyncServer, libreJSAddon, required, serverOrigin } from '../../../tests/browser-support.js'; const libre = process.env['CHAT_LIBREJS'] === '1'; const profile = await mkdtemp(join(tmpdir(),'stack-chat-progress-')); @@ -31,7 +31,7 @@ let configReady: Promise | undefined; let signalConfig: (() => void) | undefined; let origin = ''; const errors: string[] = []; -const server = createServer(async (request,response) => { +const server = asyncServer(async (request,response) => { response.setHeader('Cache-Control','no-store'); if (request.url === '/crypto.wasm') { if (mode === 'failure') { response.writeHead(503); response.end(); return; } @@ -43,9 +43,9 @@ const server = createServer(async (request,response) => { } if (request.url === '/api') { configuration = response; required(signalConfig, 'Missing configuration signal')(); return; } const assets: Record = { '/':'crates/chat-server/web/index.html','/app.js':'var/build/typescript/crates/chat-server/web/app.js','/style.css':'crates/chat-server/web/style.css' }; - const path = assets[request.url || '']; - if (!path) { response.writeHead(404); response.end(); return; } - const mime = request.url === '/' ? 'text/html' : request.url?.endsWith('.js') ? 'text/javascript' : 'text/css'; + const path = assets[(request.url ?? '')]; + if (path === undefined || path.length === 0) { response.writeHead(404); response.end(); return; } + const mime = request.url === '/' ? 'text/html' : (request.url?.endsWith('.js') === true) ? 'text/javascript' : 'text/css'; response.setHeader('Content-Type',mime); let source = await readFile(resolve(path),'utf8'); if (request.url === '/') source = source.replaceAll('{{SITE_ORIGIN}}',origin); @@ -60,12 +60,12 @@ if (libre) { const addon = await readFile(resolve('.tools/librejs.xpi')); await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),addon); context = await firefox.launchPersistentContext(profile,{headless:true,firefoxUserPrefs:{'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15,'extensions.update.enabled':false}}); - browser = {close:() => context.close()}; + browser = {close:(): Promise => context.close()}; } else { browser = await chromium.launch({headless:true,channel:'chromium'}); context = await browser.newContext(); } -async function open(selected: 'known' | 'unknown' | 'failure') { +async function open(selected: 'known' | 'unknown' | 'failure'): Promise { mode = selected; configuration = undefined; configReady = new Promise(done => { signalConfig = done; }); @@ -75,11 +75,11 @@ async function open(selected: 'known' | 'unknown' | 'failure') { await page.goto(origin); return page; } -async function waitStatus(page: Page, part: string) { - await page.waitForFunction(value => document.getElementById('status')?.textContent?.includes(value),part); - return await page.locator('#status').textContent() || ''; +async function waitStatus(page: Page, part: string): Promise { + await page.waitForFunction(value => document.getElementById('status')?.textContent.includes(value),part); + return (await page.locator('#status').textContent() ?? ''); } -async function complete(page: Page, offset: number) { +async function complete(page: Page, offset: number): Promise { required(transfer, 'Missing transfer').end(bytes.subarray(offset)); await waitStatus(page,'Connecting to chat'); await configReady; @@ -98,7 +98,7 @@ try { assert.match(await waitStatus(known,'2.0 /'),/39%/); await known.screenshot({path:resolve('.tools/chat-download-progress.png'),fullPage:true}); await known.clock.fastForward(44000); - assert.ok(!(await known.locator('#status').textContent() || '').includes('stalled')); + assert.ok(!((await known.locator('#status').textContent() ?? '')).includes('stalled')); await complete(known,2097152); const unknown = await open('unknown'); assert.equal(await waitStatus(unknown,'1.0 MiB received'),'Downloading cryptographic module... 1.0 MiB received'); @@ -111,7 +111,7 @@ try { await waitStatus(stalled,'1.0 /'); await stalled.clock.fastForward(45001); await waitStatus(stalled,'stalled for 45 seconds'); - assert.match(await stalled.locator('#status').textContent() || '',/reload/); + assert.match((await stalled.locator('#status').textContent() ?? ''),/reload/); assert.equal(await stalled.locator('#owner-choice').isVisible(),false); await stalled.close(); assert.deepEqual(errors,[]); diff --git a/crates/chat-server/web/app.ts b/crates/chat-server/web/app.ts index 79a827e..de3abb7 100644 --- a/crates/chat-server/web/app.ts +++ b/crates/chat-server/web/app.ts @@ -17,7 +17,7 @@ function element(id: string): HTMLElement { const value = document.getElementById(id); - if (!value) throw new Error(`Missing interface element: ${id}`); + if (!(value !== null)) throw new Error(`Missing interface element: ${id}`); return value; } @@ -57,23 +57,28 @@ const workers = new Map void>(); const blobs = new Set(); function configuration(): StackChat.Configuration { - if (!config) throw new Error('Chat configuration is not loaded'); + if (config === undefined) throw new Error('Chat configuration is not loaded'); return config; } +function hasOwner(): boolean { + const owner = config?.owner; + return owner !== undefined && owner !== null && owner.length > 0; +} + function ownerKey(): string { const owner = configuration().owner; - if (!owner) throw new Error('Owner setup is required first'); + if (owner === null || owner.length === 0) throw new Error('Owner setup is required first'); return owner; } function activeSession(): StackChat.Session { - if (!session) throw new Error('Unlock your identity file first'); + if (session === undefined) throw new Error('Unlock your identity file first'); return session; } function conversation(): StackChat.Conversation { - if (!selected) throw new Error('Choose a conversation first'); + if (selected === undefined) throw new Error('Choose a conversation first'); return selected; } @@ -171,41 +176,41 @@ async function api(request: StackChat.Api[K]['req }); const result = record(await response.json()); if (current !== generation) throw new Error('Session locked'); - if (!response.ok || result['error']) throw new Error(typeof result['error'] === 'string' ? result['error'] : 'Server request failed'); + if (!response.ok || 'error' in result) throw new Error(typeof result['error'] === 'string' ? result['error'] : 'Server request failed'); return apiReaders[request.op](result); } async function cryptoOperation(request: StackChat.Crypto[K]['request'] & { op: K }): Promise { const current = generation; - if (!wasm) throw new Error('Cryptographic module is not loaded'); + if (wasm === undefined) throw new Error('Cryptographic module is not loaded'); const bytes = await wasm; if (current !== generation) throw new Error('Session locked'); return new Promise((resolve, reject) => { const worker = new Worker('/worker.js'); - const stop = () => { worker.terminate(); workers.delete(worker); clearTimeout(timer); }; + const stop = (): void => { worker.terminate(); workers.delete(worker); clearTimeout(timer); }; const timer = setTimeout(() => { stop(); reject(new Error('Cryptographic operation timed out')); }, 90000); workers.set(worker, () => { stop(); reject(new Error('Session locked')); }); - worker.onerror = () => { stop(); reject(new Error('Cryptographic worker failed')); }; - worker.onmessage = (event: MessageEvent) => { + worker.onerror = (): void => { stop(); reject(new Error('Cryptographic worker failed')); }; + worker.onmessage = (event: MessageEvent): void => { stop(); if (current !== generation) { reject(new Error('Session locked')); return; } try { const message = record(event.data); if ('error' in message) throw new Error(stringField(message, 'error')); resolve(cryptoReaders[request.op](message['result'])); - } catch (error) { reject(error); } + } catch (error) { reject(error instanceof Error ? error : new Error('Cryptographic response rejected', { cause: error })); } }; worker.postMessage({ bytes, request }); }); } function protectRequest(op: K, fields: Omit): StackChat.Protected & { op: K } & typeof fields { - if (!credentials) throw new Error('Unlock your identity file first'); + if (credentials === undefined) throw new Error('Unlock your identity file first'); return { op, ...credentials, origin: location.origin, ...fields }; } -function clearState() { +function clearState(): void { generation += 1; busy = false; for (const button of document.querySelectorAll('button')) button.disabled = false; @@ -227,13 +232,18 @@ function clearState() { } for (const id of ['messages', 'conversations', 'who', 'fingerprint', 'counts', 'thread-name', 'conversation-fingerprint']) element(id).replaceChildren(); for (const id of ['create', 'unlock', 'chat', 'thread', 'inbox', 'older', 'retry', 'owner-access']) show(id, false); - show('entry', Boolean(config?.owner)); + show('entry', hasOwner()); show('owner-entry'); setStatus('Locked. Select your file and enter its password to return.'); } -function action(id: string, callback: () => Promise): void { - element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', async event => { +function background(operation: () => Promise): void { + const current = generation; + operation().catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); }); +} + +function action(id: string, callback: () => void | Promise): void { + const handler = async (event: Event): Promise => { event.preventDefault(); if (busy) return; busy = true; @@ -248,7 +258,8 @@ function action(id: string, callback: () => Promise): void { for (const button of document.querySelectorAll('button')) button.disabled = false; } } - }); + }; + element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', event => { background(() => handler(event)); }); } function download(file: string): void { @@ -261,13 +272,13 @@ function download(file: string): void { setTimeout(() => { URL.revokeObjectURL(url); blobs.delete(url); }, 30000); } -async function createIdentity() { +async function createIdentity(): Promise { const password = input('new-password').value; if (password !== input('confirm-password').value) throw new Error('Passwords do not match'); - if (!configuration().owner && element('setup-label').hidden) throw new Error('Owner setup is required first'); - setupToken = !configuration().owner ? input('setup-token').value : undefined; + if (!hasOwner() && element('setup-label').hasAttribute('hidden')) throw new Error('Owner setup is required first'); + setupToken = !hasOwner() ? input('setup-token').value : undefined; setStatus('Generating your purpose-specific identity and protecting its file...'); - const result = await cryptoOperation({ op: 'create', origin: location.origin, name: input('name').value, password, owner: configuration().owner || '' }); + const result = await cryptoOperation({ op: 'create', origin: location.origin, name: input('name').value, password, owner: configuration().owner ?? '' }); pendingFile = result.file; download(result.file); form('create-form').reset(); @@ -277,16 +288,16 @@ async function createIdentity() { setStatus('Reopen your downloaded identity file to verify recovery.'); } -async function unlockIdentity() { +async function unlockIdentity(): Promise { const file = input('file').files?.[0]; - if (!file || file.size > 262144) throw new Error('Choose an identity file no larger than 256 KiB'); + if (file === undefined || file.size > 262144) throw new Error('Choose an identity file no larger than 256 KiB'); const fileText = await file.text(); - if (pendingFile && fileText !== pendingFile) throw new Error('Reopen the exact identity file you just downloaded'); + if (((pendingFile ?? '').length > 0) && fileText !== pendingFile) throw new Error('Reopen the exact identity file you just downloaded'); const candidate = { file: fileText, password: input('password').value }; form('unlock-form').reset(); setStatus('Unlocking locally and verifying your identity...'); const identity = await cryptoOperation({ op: 'open', ...candidate, origin: location.origin }); - if (configuration().owner && identity.owner !== configuration().owner) throw new Error('Pinned owner key differs from this server. Stop and verify the change with Doan.'); + if (hasOwner() && identity.owner !== configuration().owner) throw new Error('Pinned owner key differs from this server. Stop and verify the change with Doan.'); const challenge = await api({ op: 'challenge', public: identity.public, setup_token: setupToken }); const proof = await cryptoOperation({ op: 'proof', ...candidate, origin: location.origin, challenge: challenge.challenge }); const authenticated = await api({ op: 'authenticate', id: challenge.id, signature: proof.signature }); @@ -301,7 +312,7 @@ async function unlockIdentity() { show('owner-access', false); show('unlock', false); show('chat'); - element('who').textContent = `${identity.name} [${activeSession().owner === true ? 'OWNER' : 'VISITOR'}]`; + element('who').textContent = `${identity.name} [${activeSession().owner ? 'OWNER' : 'VISITOR'}]`; element('fingerprint').textContent = `Identity: ${identity.fingerprint}`; if (authenticated.owner) { activeSession().owner = true; @@ -329,15 +340,15 @@ function checkedPayload(payload: string, kind: 'profile' | 'message', conversati return { id: stringField(value, 'id'), text: stringField(value, 'text') }; } -async function inbox() { +async function inbox(): Promise { const result = await api({ op: 'inbox' }); element('conversations').replaceChildren(); for (const conversation of result.conversations) { const button = document.createElement('button'); - button.textContent = `${conversation.id.slice(0, 12)} / ${conversation.total} messages`; - button.addEventListener('click', async () => { + button.textContent = `${conversation.id.slice(0, 12)} / ${String(conversation.total)} messages`; + const handler = async (): Promise => { if (busy) return; - if (pendingSend) { setStatus('Retry the pending message, or lock before switching conversations.'); return; } + if ((pendingSend !== undefined)) { setStatus('Retry the pending message, or lock before switching conversations.'); return; } busy = true; const current = generation; setStatus('Opening conversation...'); @@ -351,8 +362,9 @@ async function inbox() { element('messages').replaceChildren(); show('thread'); let name = conversation.id.slice(0, 12); - if (conversation.profile) { - const result = await cryptoOperation(protectRequest('decrypt', { sender: conversation.public, ciphertext: conversation.profile })); + const certificate = conversation.profile; + if (certificate !== undefined && certificate !== null && certificate.length > 0) { + const result = await cryptoOperation(protectRequest('decrypt', { sender: conversation.public, ciphertext: certificate })); if (result.signer !== conversation.id) throw new Error('Profile signer fingerprint mismatch'); const profile = checkedPayload(result.payload, 'profile', conversation.id, conversation.id); if (typeof profile.name !== 'string' || profile.name.length > 80) throw new Error('Invalid encrypted profile'); @@ -363,7 +375,8 @@ async function inbox() { setStatus('Conversation opened. Choose how many messages to load.'); } catch (error) { setStatus(errorMessage(error)); } finally { if (generation === current) busy = false; } - }); + }; + button.addEventListener('click', () => { background(handler); }); element('conversations').append(button); } } @@ -371,10 +384,10 @@ async function inbox() { function counters(conversation: StackChat.Conversation): void { if (!Number.isSafeInteger(conversation.total) || conversation.total < 0 || conversation.total > 10000) throw new Error('Invalid conversation count'); input('history-count').max = String(conversation.total); - element('history-range').textContent = `Latest messages (0-${conversation.total})`; + element('history-range').textContent = `Latest messages (0-${String(conversation.total)})`; const sent = activeSession().owner ? conversation.owner_sent : conversation.visitor_sent; const received = activeSession().owner ? conversation.visitor_sent : conversation.owner_sent; - element('counts').textContent = `Total: ${conversation.total} | Sent: ${sent} | Received: ${received}`; + element('counts').textContent = `Total: ${String(conversation.total)} | Sent: ${String(sent)} | Received: ${String(received)}`; } async function renderHistoryBatch(messages: StackChat.Message[]): Promise { @@ -382,14 +395,14 @@ async function renderHistoryBatch(messages: StackChat.Message[]): Promise const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests })); for (let index = 0; index < messages.length; index += 1) { const message = messages[index]; - if (!message) throw new Error('Missing history message'); + if (message === undefined) throw new Error('Missing history message'); const item = document.createElement('li'); const label = document.createElement('small'); - label.textContent = `#${message.seq} / ${message.sender === activeSession().id ? 'YOU' : 'THEM'}`; + label.textContent = `#${String(message.seq)} / ${message.sender === activeSession().id ? 'YOU' : 'THEM'}`; const text = document.createElement('p'); try { const entry = decrypted.messages[index]; - if (!entry || 'error' in entry) throw new Error('Invalid ciphertext or signature'); + if (entry === undefined || 'error' in entry) throw new Error('Invalid ciphertext or signature'); if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch'); const payload = checkedPayload(entry.payload, 'message', conversation().id, message.sender); if (payload.id !== message.id || typeof payload.text !== 'string' || new TextEncoder().encode(payload.text).length > 8192) throw new Error('Message binding mismatch'); @@ -400,7 +413,7 @@ async function renderHistoryBatch(messages: StackChat.Message[]): Promise } } -async function refreshCounts() { +async function refreshCounts(): Promise { const conversationId = conversation().id; const result = await api({ op: 'history', conversation: conversationId, count: 0, before: 0 }); if (selected?.id === conversationId) counters(result.conversation); @@ -408,10 +421,10 @@ async function refreshCounts() { } async function loadHistory(count: number, cursor = 0): Promise { - if (!selected) throw new Error('Choose a conversation first'); + if (selected === undefined) throw new Error('Choose a conversation first'); const currentConversation = await refreshCounts(); - if (!Number.isSafeInteger(count) || count < 0 || count > currentConversation.total) throw new Error(`Choose 0 to ${currentConversation.total} messages`); - if (!cursor) { element('messages').replaceChildren(); before = 0; } + if (!Number.isSafeInteger(count) || count < 0 || count > currentConversation.total) throw new Error(`Choose 0 to ${String(currentConversation.total)} messages`); + if (!(cursor !== 0)) { element('messages').replaceChildren(); before = 0; } if (count === 0) { input('history-count').value = '0'; show('older', false); @@ -426,10 +439,10 @@ async function loadHistory(count: number, cursor = 0): Promise { const result = await api({ op: 'history', conversation: conversation().id, count: size, before: next }); counters(result.conversation); if (!Array.isArray(result.messages) || result.messages.length > size) throw new Error('Invalid history response'); - if (!result.messages.length) { exhausted = true; break; } + if (!(result.messages.length !== 0)) { exhausted = true; break; } const sequence = Math.min(...result.messages.map(message => message.seq)); if (!Number.isSafeInteger(sequence) || sequence <= 0 || (next > 0 && sequence >= next)) throw new Error('Invalid history cursor'); - setStatus(`Decrypting and verifying selected messages locally (${count - remaining + result.messages.length}/${count})...`); + setStatus(`Decrypting and verifying selected messages locally (${String(count - remaining + result.messages.length)}/${String(count)})...`); await renderHistoryBatch(result.messages); remaining -= result.messages.length; next = sequence; @@ -440,8 +453,8 @@ async function loadHistory(count: number, cursor = 0): Promise { setStatus('Selected history decrypted and verified locally.'); } -async function submitPending() { - if (!pendingSend) throw new Error('No pending message'); +async function submitPending(): Promise { + if (pendingSend === undefined) throw new Error('No pending message'); const result = await api(pendingSend); counters(result.conversation); pendingSend = undefined; @@ -450,11 +463,11 @@ async function submitPending() { setStatus('Encrypted message stored. Load history to read messages.'); } -async function send() { - if (!selected) throw new Error('Choose a conversation first'); - if (pendingSend) throw new Error('Retry or lock before composing another message'); +async function send(): Promise { + if (selected === undefined) throw new Error('Choose a conversation first'); + if ((pendingSend !== undefined)) throw new Error('Retry or lock before composing another message'); const text = messageInput().value; - if (!text.trim() || new TextEncoder().encode(text).length > 8192) throw new Error('Enter 1 to 8192 UTF-8 bytes of text'); + if (!((text.trim()).length > 0) || new TextEncoder().encode(text).length > 8192) throw new Error('Enter 1 to 8192 UTF-8 bytes of text'); const id = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''); const recipient = activeSession().owner ? conversation().public : ownerKey(); setStatus('Signing and encrypting for both participants locally...'); @@ -464,62 +477,66 @@ async function send() { await submitPending(); } -function ownerSetup() { +function ownerSetup(): void { show('entry', false); show('owner-entry', false); show('unlock', false); - show('create', !configuration().owner); - show('owner-access', Boolean(configuration().owner)); - show('setup-label', !configuration().owner); + show('create', !hasOwner()); + show('owner-access', hasOwner()); + show('setup-label', !hasOwner()); element('create-title').textContent = 'Create the owner identity (owner only)'; setStatus(''); } -action('create-choice', async () => { show('entry', false); show('owner-entry', false); show('setup-label', false); element('create-title').textContent = 'Create a new chat'; show('create'); setStatus(''); }); -action('open-choice', async () => { show('entry', false); show('owner-entry', false); show('unlock'); setStatus(''); }); -action('owner-choice', async () => { window.history.replaceState(null, '', '#setup'); ownerSetup(); }); -action('owner-open', async () => { show('owner-access', false); show('unlock'); setStatus(''); }); +action('create-choice', () => { show('entry', false); show('owner-entry', false); show('setup-label', false); element('create-title').textContent = 'Create a new chat'; show('create'); setStatus(''); }); +action('open-choice', () => { show('entry', false); show('owner-entry', false); show('unlock'); setStatus(''); }); +action('owner-choice', () => { window.history.replaceState(null, '', '#setup'); ownerSetup(); }); +action('owner-open', () => { show('owner-access', false); show('unlock'); setStatus(''); }); action('create-form', createIdentity); action('unlock-form', unlockIdentity); element('history-count').addEventListener('focus', () => { const current = generation; - if (session && selected && !busy) refreshCounts().catch(error => { if (current === generation) setStatus(errorMessage(error)); }); + if ((session !== undefined) && (selected !== undefined) && !busy) refreshCounts().catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); }); }); action('history-form', () => loadHistory(Number(input('history-count').value))); action('send-form', send); action('retry', submitPending); action('refresh-inbox', inbox); action('older', () => loadHistory(Number(input('history-count').value), before)); -element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' }).catch(() => {}); clearState(); }); +element('lock').addEventListener('click', () => { + const current = generation; + if (session !== undefined) api({ op: 'lock' }).catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); }); + clearState(); +}); window.addEventListener('pagehide', clearState); window.addEventListener('pageshow', event => { if (event.persisted) clearState(); }); -async function downloadCrypto() { +async function downloadCrypto(): Promise { const controller = new AbortController(); let timer: ReturnType | undefined; let reader: ReadableStreamDefaultReader> | undefined; - const refreshTimeout = () => { + const refreshTimeout = (): void => { clearTimeout(timer); - timer = setTimeout(() => controller.abort(), 45000); + timer = setTimeout(() => { controller.abort(); }, 45000); }; try { refreshTimeout(); const response = await fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit', signal: controller.signal }); - if (!response.ok) throw new Error(`Cryptographic module download failed (HTTP ${response.status}). Reload to try again.`); - const length = response.headers.get('content-encoding') ? 0 : Number(response.headers.get('content-length')); + if (!response.ok) throw new Error(`Cryptographic module download failed (HTTP ${String(response.status)}). Reload to try again.`); + const length = ((response.headers.get('content-encoding') ?? '').length > 0) ? 0 : Number(response.headers.get('content-length')); const total = Number.isSafeInteger(length) && length > 0 ? length : 0; const chunks: Uint8Array[] = []; let received = 0; - const progress = () => { + const progress = (): void => { const loaded = (received / 1048576).toFixed(1); - if (total) { + if ((total !== 0)) { const percent = Math.min(100, Math.floor(received * 100 / total)); const filled = Math.floor(percent / 5); - setStatus(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${percent}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`); + setStatus(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${String(percent)}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`); } else setStatus(`Downloading cryptographic module... ${loaded} MiB received`); }; progress(); - if (!response.body) throw new Error('Cryptographic module response has no body'); + if (!(response.body !== null)) throw new Error('Cryptographic module response has no body'); reader = response.body.getReader(); for (;;) { const { done, value } = await reader.read(); @@ -529,13 +546,13 @@ async function downloadCrypto() { received += value.byteLength; progress(); } - if (!received) throw new Error('The cryptographic module download was empty. Reload to try again.'); + if (!(received !== 0)) throw new Error('The cryptographic module download was empty. Reload to try again.'); const bytes = new Uint8Array(received); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return bytes.buffer; } catch (error) { - if (controller.signal.aborted) throw new Error('Cryptographic module download stalled for 45 seconds. Check your connection and reload to try again.'); + if (controller.signal.aborted) throw new Error('Cryptographic module download stalled for 45 seconds. Check your connection and reload to try again.', { cause: error }); throw error; } finally { clearTimeout(timer); @@ -543,18 +560,18 @@ async function downloadCrypto() { } } -async function start() { +async function start(): Promise { wasm = downloadCrypto(); await wasm; setStatus('Cryptographic module downloaded. Connecting to chat...'); config = await api({ op: 'config' }); if (configuration().origin !== location.origin) throw new Error('Server origin mismatch'); show('owner-entry'); - show('entry', Boolean(configuration().owner)); + show('entry', hasOwner()); setStatus(''); if (location.hash === '#setup') ownerSetup(); } -start().catch(error => setStatus(errorMessage(error))); +start().catch((error: unknown) => { setStatus(errorMessage(error)); }); // @license-end diff --git a/crates/chat-server/web/worker.ts b/crates/chat-server/web/worker.ts index d4367bf..d3a1c68 100644 --- a/crates/chat-server/web/worker.ts +++ b/crates/chat-server/web/worker.ts @@ -17,9 +17,9 @@ importScripts('/wasi.mjs'); -self.onmessage = async (event: MessageEvent) => { +self.onmessage = async (event: MessageEvent): Promise => { try { - const result = await self.stackChatWasi.runCommand(event.data.bytes, event.data.request); + const result = await stackChatWasi.runCommand(event.data.bytes, event.data.request); self.postMessage({ result }); } catch { self.postMessage({ error: 'Cryptographic operation failed. Check your file, password, origin, and sender.' }); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index ce509b6..f5896d8 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -338,36 +338,47 @@ mod tests { let error = Config::load(root.path()) .err() .context("Malformed secret must fail")?; - assert!(!format!("{error:#}").contains("private-malformed-sentinel")); + anyhow::ensure!( + !format!("{error:#}").contains("private-malformed-sentinel"), + "Test invariant failed" + ); fs::write( root.path().join(".env"), "SITE_HOST=home.example.net\nGIT_HOST=source.example.net\nHTTPS_PORT=8443\nLOCAL_HTTP_PORT=8180\nCHAT_SETUP_TOKEN=private-token-sentinel\nSEARXNG_SECRET=private-search-sentinel\n", )?; let config = Config::load(root.path())?; - assert_eq!( - config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", None)?, - "https://home.example.net:8443 https://source.example.net:8443" + anyhow::ensure!( + (config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", None)?) + == ("https://home.example.net:8443 https://source.example.net:8443"), + "Test invariant failed" + ); + anyhow::ensure!( + (config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", Some(8180))?) + == ("http://localhost:8180 /git"), + "Test invariant failed" + ); + anyhow::ensure!( + config.render("{{CHAT_SETUP_TOKEN}}", None).is_err(), + "Test invariant failed" ); - assert_eq!( - config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", Some(8180))?, - "http://localhost:8180 /git" + anyhow::ensure!( + !format!("{config:?}").contains("private-token"), + "Test invariant failed" ); - assert!(config.render("{{CHAT_SETUP_TOKEN}}", None).is_err()); - assert!(!format!("{config:?}").contains("private-token")); initialize_public(root.path())?; - assert_eq!( - Config::load(root.path())?.value("SITE_HOST")?, - "home.example.net" + anyhow::ensure!( + (Config::load(root.path())?.value("SITE_HOST")?) == ("home.example.net"), + "Test invariant failed" ); - assert_eq!( - Config::load(root.path())?.value("CHAT_SETUP_TOKEN")?, - "private-token-sentinel" + anyhow::ensure!( + (Config::load(root.path())?.value("CHAT_SETUP_TOKEN")?) == ("private-token-sentinel"), + "Test invariant failed" ); fs::write( root.path().join(".env"), "SITE_HOST=\"valid.example { respond bad }\"\n", )?; - assert!(Config::load(root.path()).is_err()); + anyhow::ensure!(Config::load(root.path()).is_err(), "Test invariant failed"); Ok(()) } } diff --git a/docs/LINTING.txt b/docs/LINTING.txt new file mode 100644 index 0000000..6185b0d --- /dev/null +++ b/docs/LINTING.txt @@ -0,0 +1,194 @@ +Copyright (C) 2026 Doan Luu Duc Tai + +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 . + +STRICT LINT POLICY + + Work is on linting/strict, based on the TypeScript migration. Main and + production are separate until this branch is explicitly merged and deployed. + Cargo.toml is the workspace policy; all four packages inherit it. + + Forbid rejects source-level attempts to lower a lint. Deny rejects a + violation but permits a narrower source-level override. Neither prevents + someone from changing the build configuration itself. Compiler/tool versions + are pinned, and the checked build is the supported development path. + +RUST AND CLIPPY + + Every previously denied individual Clippy rule and every enabled Clippy + group now uses forbid. There are no blanket Clippy allow entries. This + includes all, correctness, suspicious, cargo, style, complexity, perf, + pedantic, nursery, multiple_crate_versions, and redundant_pub_crate. + All Rustdoc lints in its supported all group are forbidden too. + + Rust safety, documentation, visibility, casts, edition/future compatibility, + underscore bindings, and warnings use forbid. Unused-group members that do + not conflict with generated code are independently forbidden. Additional + restrictions cover panic/unimplemented/unreachable paths, panic inside Result + functions, unsafe allowance attributes, numeric/pointer casts, native-endian + data, string slicing, infinite loops, and test placement. Cargo.toml lists + the precise rules. Contradictory restriction groups are not enabled wholesale. + + The supported workarounds were implemented instead of lowering these rules: + + Clap derives became explicit checked Clap builders. All argument parsing + stays in Clap; application code does not implement a shell parser. + Both command schemas have regression tests for modes, required paths, + listener parsing, global --root, and maintenance conflicts. Help flags + and rejected-option exit codes were compared against the previous binaries. + The unused Clap derive feature and its lockfile dependency were removed. + + Tokio's runtime attribute became an explicit checked runtime builder. + Runtime construction errors propagate through the existing error path. + The broad generated expect/style/complexity allowances are no longer needed. + + Management and server implementations live inside private app namespaces. + Internal interfaces use pub(super), narrower than crate visibility. Only + the documented program entry point is re-exported at the binary root. + This satisfies both redundant_pub_crate and unreachable_pub without + exposing helper modules or allowing either lint. + + Serde containers specify crate = "serde". This supported attribute avoids + the generated extern-crate branch and its unused_extern_crates and + clippy::useless_attribute allowances. File and wire representations retain + their existing field names, ordering, tagging, and unknown-field checks. + + Result-returning tests report failed checks with anyhow::ensure instead + of panicking. This permits panic_in_result_fn without suppressions. + +THREE REMAINING RUST DENY SETTINGS + + unused, nonstandard_style, and unused_qualifications remain deny. This is + a generated-code compatibility requirement, not permission to add source + allowances. clippy::allow_attributes and allow_attributes_without_reason + are forbidden for first-party source. + + The pinned Serde derive implementation unconditionally emits allowances for + non_upper_case_globals, unused_attributes, unused_qualifications, and + clippy::absolute_paths in serde_derive/src/dummy.rs. Its generated field + identifier visitor also allows non_camel_case_types. These are generated + attributes even when the first-party type has correct names and no redundant + qualification. They cannot be removed by renaming fields or imports. + + The supported crate-path workaround was applied and all macros were inspected. + Repeating Clippy with -F unused, -F nonstandard_style, or + -F unused_qualifications still rejects the pinned Serde expansion. There is + no supported derive setting to suppress that unconditional wrapper. Further + changes would require forking the generator or replacing the serialization + implementation, including security-sensitive identity/envelope/request formats. + Those are explicit upstream/format migrations, not configuration fixes. + + Every other unused-group member and non_snake_case is independently forbidden. + warnings itself is forbidden; the explicit deny groups provide the narrowly + required effective levels for the generated attributes. No first-party + allow/expect attributes were added. + +DUPLICATE DEPENDENCIES + + clippy.toml restricts allowed-duplicate-crates to generic-array, getrandom, + and syn: the names actually diagnosed in this workspace. The Clippy lint + and cargo group remain forbidden for every other dependency. + + The incompatible lines come from the pinned crypto/parser/build graph: + generic-array 0.14.7 and 1.4.5 + getrandom 0.2.17 and 0.4.3 + syn 2.0.119 and 3.0.9 + + These are different semver compatibility lines, not duplicate patch releases. + Unifying them requires upstream API changes or older dependencies. No crypto + algorithm/backend changes or dependency downgrades were made for a lint. + cargo-deny continues to enforce its exact reviewed version exceptions in + deny.toml, including build/development dependencies. Clippy's name-only + configuration does not replace that stronger version-specific audit. + +TYPESCRIPT + + The compiler checks strict mode and the additional indexed access, optional + property, unused, return, switch, override, and import checks. noCheck and + allowJs are explicitly false. Both declaration-library skip flags are false. + isolatedModules, erasableSyntaxOnly, and verbatimModuleSyntax remain enabled. + Browser/worker configurations use Bundler resolution with Preserve output; + they contain no runtime imports. Tests and tooling use NodeNext separately. + + eslint.config.ts extends the typed strict and stylistic TypeScript presets. + All diagnostics are errors and any warning fails the check. Inline config + is disabled; suppression directives and unsafe assertions are prohibited. + Additional rules reject ambiguous boolean coercion, unhandled/misused promises, + explicit or leaked any, non-null assertions, unsafe calls/returns/arguments, + eval, unsafe casts, and unchecked discriminated-union switches. Functions + expose checked return types; callback contexts retain their declared contracts. + + UI async tasks now have explicit rejection handlers at the DOM boundary. + Test HTTP handlers also catch rejected work rather than returning unobserved + promises to Node. WASI validates its start export before calling it, and the + shared host is installed as a frozen, non-writable global property. Dynamic + JSON and worker boundaries still validate unknown values at runtime. + + TypeScript 7.0.2 remains the emitting compiler, installed under the explicit + typescript-native npm alias. typescript-eslint 8.72.1 supports TypeScript + >=4.8.4 <6.1.0 and requires the JavaScript compiler API, so TypeScript 6.0.3 + supplies that supported lint API. Sources are checked by both compilers; + no unsupported-peer or ignore-peer-dependency install flags are used. + Every invocation names the intended compiler path to avoid npm bin collisions. + + Isolated declaration emission is a library-distribution constraint, not an + additional application safety check. This project serves classic scripts + for the tested LibreJS path and does not publish a TypeScript library. + moduleDetection=force is used for tests/tools; changing browser scripts to + ESM solely for that setting would change their loading/LibreJS contract. + TypeScript 7 removed allowSyntheticDefaultImports=false, so that obsolete + switch is not used. Typed linting checks unsafe interop at use sites. + +DEVELOPMENT DEPENDENCY ADVISORY + + ESLint and typescript-eslint currently request minimatch 10. The public + GHSA-g3hv-75wg-m249 advisory affects minimatch <11.0.1. The root npm override + pins the patched 11.0.3 release instead of ignoring the advisory or applying + npm audit fix --force. Its supported exports and Node engine were reviewed; + typed linting and rule-selection/negative-control checks exercise compatibility. + Remove the override when upstream ranges admit a patched version naturally. + + npm audit --audit-level=info is part of npm run check and therefore the Cargo + check/release path. Development dependency findings fail the build too. + Installed tools preserve their original notices and machine SPDX identifiers. + Human documentation calls SPDX MIT the Expat License. No third-party + JavaScript dependency is delivered to visitors. + +CHECKS AND EXPORTS + + npm run build + cargo stack check + cargo stack release + + The first checks both TypeScript compilers, typed ESLint, and npm advisories, + then emits the browser/worker/test artifacts. Cargo also runs formatting, + strict Clippy, all workspace tests, strict Rustdoc, cargo-deny, and cargo-about. + Browser checks and GNU LibreJS controls are documented in TYPESCRIPT.txt. + + clippy.toml, eslint.config.ts, every tsconfig, and both lockfiles are included + in corresponding sources, releases, and backups. Installed node_modules, + runtime state, and the private .env remain outside public exports. Nothing + introduces another deployment environment file or a production Node runtime. + +PRIMARY REFERENCES + + https://doc.rust-lang.org/stable/rustc/lints/levels.html + https://doc.rust-lang.org/clippy/lints.html + https://doc.rust-lang.org/clippy/lint_configuration.html + https://serde.rs/container-attrs.html + https://github.com/serde-rs/serde/blob/v1.0.229/serde_derive/src/dummy.rs + https://www.typescriptlang.org/tsconfig/ + https://typescript-eslint.io/users/configs/ + https://typescript-eslint.io/users/dependency-versions/ + https://github.com/advisories/GHSA-g3hv-75wg-m249 diff --git a/docs/POLICY.txt b/docs/POLICY.txt index 1f54398..4c6c6d3 100644 --- a/docs/POLICY.txt +++ b/docs/POLICY.txt @@ -45,12 +45,14 @@ PROJECT CONVENTIONS 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. + All enabled Clippy groups and individual rules are forbidden, with no + blanket allow entries. Rust and Rustdoc checks use forbid wherever supported + by generated code. Three Rust settings remain deny for pinned Serde derives; + their unaffected members are independently forbidden. First-party lint + allowances are forbidden. See LINTING.txt for tested workarounds, generated + attribute evidence, and the narrow duplicate-dependency configuration. + Private app namespaces retain narrow internal visibility while both + redundant_pub_crate and unreachable_pub are enforced. cargo-deny rejects duplicate crate versions, wildcard requirements, unknown registries, unknown Git sources, and yanked versions. The chat prototype has @@ -63,7 +65,9 @@ STRICTNESS Browser logic and automation use strict TypeScript configurations with separate browser/worker/Node environments and checked untrusted responses. npm dependencies are exact, development-only, integrity-locked, and installed - with lifecycle scripts disabled. See TYPESCRIPT.txt for the build contract. + with lifecycle scripts disabled. Typed ESLint forbids unsafe any/assertions, + unchecked promises, and suppression directives. Both supported compiler APIs + and npm advisories are checked. See TYPESCRIPT.txt and LINTING.txt. Human license names follow https://www.gnu.org/licenses/license-list.html. In particular, display Expat License for SPDX MIT. Do not alter original diff --git a/docs/TYPESCRIPT.txt b/docs/TYPESCRIPT.txt index d50ea95..7fbc5a4 100644 --- a/docs/TYPESCRIPT.txt +++ b/docs/TYPESCRIPT.txt @@ -40,6 +40,10 @@ SOURCE LAYOUT tests/browser-support.ts Checked fixture parsing and typed browser-automation helpers. + eslint.config.ts and tsconfig.tools.json supply the typed lint-tool checks. + src/app and crates/chat-server/src/app contain the private Rust CLI + implementations; their parser and runtime changes are recorded in LINTING.txt. + tsconfig.json supplies shared strict checks. The browser, worker, and test configurations use separate DOM, WebWorker, and Node environments. Production browser builds cannot accidentally rely on Node APIs. @@ -53,6 +57,10 @@ DEVELOPMENT Install Node >=22.18.0 and npm on the development machine. Root package.json and package-lock.json pin TypeScript 7.0.2, Playwright 1.64.1, Node type declarations, platform compiler packages, and their integrity hashes. + ESLint 10.14.0 and typescript-eslint 8.72.1 provide typed strict linting. + TypeScript 6.0.3 supplies their supported JavaScript API; the pinned native + TypeScript 7.0.2 alias remains the emitting compiler. Both check the sources. + LINTING.txt documents the advisory-driven minimatch override. There are no npm application/runtime dependencies. TypeScript and Playwright use the Apache License, Version 2.0; Node declarations and undici-types use the Expat License. Original notices remain in the @@ -63,7 +71,8 @@ DEVELOPMENT npm run build infra/chat/build-typescript.sh installs the locked development packages - with lifecycle scripts disabled, checks all three configurations, and + with lifecycle scripts disabled, checks browser/worker/test/tool configurations, + typed ESLint, both compiler versions, and npm advisories, then emits JavaScript under var/build/typescript. The npm cache stays under .tools/npm-cache. node_modules and generated outputs are ignored by Git. Generated scripts are readable and unminified; no bundler is needed. @@ -72,7 +81,7 @@ DEVELOPMENT cargo-about license generation. cargo stack release builds TypeScript before packaging the complete production release. - Check without installing packages or emitting output after npm run build: + Recheck without reinstalling packages after npm run build: npm run check @@ -92,8 +101,9 @@ DEVELOPMENT .tools/librejs.xpi as documented in CHAT-APPLICATION.txt. The complete chat test proves LibreJS is active and rejects an unlicensed control. Run chat variants sequentially because they share the default test port. - npm audit checks the development dependency lock separately from - cargo-deny; it does not audit Rust, browser-delivered code, or containers. + npm audit checks development dependencies as part of npm run check and the + Cargo check/release path. It does not audit Rust, browser-delivered code, or + containers; cargo-deny remains the separate strict Rust dependency audit. RELEASES, SOURCE, AND LIBREJS diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..4ad21d6 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,53 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import { defineConfig } from 'eslint/config'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); + +export default defineConfig( + { ignores: ['var/**', '.tools/**', 'node_modules/**', 'target/**', 'bin/**', 'dist/**'] }, + { + files: ['**/*.ts'], + extends: [js.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], + languageOptions: { + parserOptions: { + project: ['tsconfig.browser.json', 'tsconfig.worker.json', 'tsconfig.tests.json', 'tsconfig.tools.json'], + tsconfigRootDir: root, + }, + }, + linterOptions: { noInlineConfig: true, reportUnusedDisableDirectives: 'error', reportUnusedInlineConfigs: 'error' }, + rules: { + '@typescript-eslint/ban-ts-comment': ['error', { 'ts-check': true, 'ts-expect-error': true, 'ts-ignore': true, 'ts-nocheck': true }], + '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'never' }], + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/explicit-module-boundary-types': 'error', + '@typescript-eslint/no-unsafe-type-assertion': 'error', + '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: false }], + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/strict-boolean-expressions': ['error', { allowString: false, allowNumber: false, allowNullableObject: false, allowNullableBoolean: false, allowNullableString: false, allowNullableNumber: false, allowAny: false }], + '@typescript-eslint/switch-exhaustiveness-check': ['error', { requireDefaultForNonUnion: true }], + '@typescript-eslint/use-unknown-in-catch-callback-variable': 'error', + 'no-restricted-syntax': ['error', { selector: 'TSAnyKeyword', message: 'Use unknown and validate untrusted values.' }, { selector: 'TSNonNullExpression', message: 'Check presence explicitly.' }], + 'no-eval': 'error', + 'no-implied-eval': 'error', + 'eqeqeq': ['error', 'always'], + }, + }, +); diff --git a/infra/chat/build-source.sh b/infra/chat/build-source.sh index e173774..475b519 100644 --- a/infra/chat/build-source.sh +++ b/infra/chat/build-source.sh @@ -23,7 +23,7 @@ cargo vendor --locked var/build/chat-vendor | sed 's|directory = "var/build/chat cp "$ROOT/.tools/wasi-source/openssl.tar.gz" "$BUILD/chat-openssl-3.6.5.tar.gz" printf '%s %s\n' a2157c2830efdec3788939b00c9b0638306d3f0bbb76dc4832ee503bb397df98 "$BUILD/chat-openssl-3.6.5.tar.gz" | sha256sum --check source_list="$BUILD/chat-source-files.txt" -find package.json package-lock.json tsconfig.json tsconfig.browser.json tsconfig.worker.json tsconfig.tests.json tests Cargo.toml Cargo.lock rust-toolchain.toml .cargo .gitignore .gitattributes src crates infra web docs README.txt LICENSE deny.toml about.toml about.hbs .env.example -type f -print > "$source_list" +find clippy.toml eslint.config.ts package.json package-lock.json tsconfig.json tsconfig.browser.json tsconfig.worker.json tsconfig.tests.json tsconfig.tools.json tests Cargo.toml Cargo.lock rust-toolchain.toml .cargo .gitignore .gitattributes src crates infra web docs README.txt LICENSE deny.toml about.toml about.hbs .env.example -type f -print > "$source_list" tar -czf "$BUILD/chat-source.tar.gz.pending" --verbatim-files-from -T "$source_list" -C "$BUILD" chat-vendor chat-vendor-config.txt chat-openssl-3.6.5.tar.gz mv "$BUILD/chat-source.tar.gz.pending" "$BUILD/chat-source.tar.gz" sha256sum "$BUILD/chat-source.tar.gz" diff --git a/infra/chat/build-typescript.sh b/infra/chat/build-typescript.sh index d9d44ad..14cae95 100644 --- a/infra/chat/build-typescript.sh +++ b/infra/chat/build-typescript.sh @@ -19,6 +19,6 @@ ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" cd "$ROOT" npm ci --ignore-scripts --no-audit --no-fund --cache "$ROOT/.tools/npm-cache" npm run check -node_modules/.bin/tsc -p tsconfig.browser.json -node_modules/.bin/tsc -p tsconfig.worker.json -node_modules/.bin/tsc -p tsconfig.tests.json +node node_modules/typescript-native/bin/tsc -p tsconfig.browser.json +node node_modules/typescript-native/bin/tsc -p tsconfig.worker.json +node node_modules/typescript-native/bin/tsc -p tsconfig.tests.json diff --git a/package-lock.json b/package-lock.json index 240cca5..f260383 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,14 +25,288 @@ "version": "0.1.0", "license": "AGPL-3.0-or-later", "devDependencies": { + "@eslint/js": "10.0.1", "@types/node": "26.6.6", + "eslint": "10.14.0", "playwright": "1.64.1", - "typescript": "7.0.2" + "typescript": "6.0.3", + "typescript-eslint": "8.72.1", + "typescript-native": "npm:typescript@7.0.2" }, "engines": { "node": ">=22.18.0" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz", + "integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.6.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", @@ -43,6 +317,236 @@ "undici-types": "~8.9.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz", + "integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/type-utils": "8.72.1", + "@typescript-eslint/utils": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.72.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", + "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz", + "integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz", + "integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.72.1", + "@typescript-eslint/types": "^8.72.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz", + "integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz", + "integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz", + "integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/utils": "8.72.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz", + "integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz", + "integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.72.1", + "@typescript-eslint/tsconfig-utils": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz", + "integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz", + "integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -383,51 +887,830 @@ "node": ">=16.20.0" } }, - "node_modules/playwright": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", - "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.64.1" - }, + "license": "MIT", "bin": { - "playwright": "cli.js" + "acorn": "bin/acorn" }, "engines": { - "node": ">=20" + "node": ">=0.4.0" } }, - "node_modules/playwright-core": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", - "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=20" + "node": "18 || 20 || >=22" } }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.14.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz", + "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.8", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.6", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.3.tgz", + "integrity": "sha512-iny/40PEGkNJQ5/Pv6z3nm6FbRAby32GeZRjCUJk2WnNDbgPSTcdI+DDvLqsgBdD4cD5sMtMheqPPnehCCa8Nw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/nonrecursive-glob-match": "^1.0.3", + "brace-expansion": "^5.0.12" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.1.tgz", + "integrity": "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.72.1", + "@typescript-eslint/parser": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/utils": "8.72.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-native": { + "name": "typescript", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", @@ -453,6 +1736,55 @@ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 98cad2c..f6c725e 100644 --- a/package.json +++ b/package.json @@ -25,14 +25,25 @@ }, "scripts": { "build": "sh infra/chat/build-typescript.sh", - "check": "tsc -p tsconfig.browser.json --noEmit && tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.tests.json --noEmit", + "check": "npm run typecheck && node node_modules/typescript-native/bin/tsc -p tsconfig.tools.json && npm run lint && npm audit --audit-level=info", "test:crypto": "node var/build/typescript/crates/chat-crypto/tests/browser.js", "test:chat": "node var/build/typescript/crates/chat-server/tests/browser.js", - "test:progress": "node var/build/typescript/crates/chat-server/tests/progress.js" + "test:progress": "node var/build/typescript/crates/chat-server/tests/progress.js", + "typecheck": "npm run typecheck:native && npm run typecheck:compat", + "lint": "eslint --no-config-lookup --config var/build/typescript/eslint.config.js --max-warnings 0 .", + "typecheck:native": "node node_modules/typescript-native/bin/tsc -p tsconfig.browser.json --noEmit && node node_modules/typescript-native/bin/tsc -p tsconfig.worker.json --noEmit && node node_modules/typescript-native/bin/tsc -p tsconfig.tests.json --noEmit && node node_modules/typescript-native/bin/tsc -p tsconfig.tools.json --noEmit", + "typecheck:compat": "node node_modules/typescript/bin/tsc -p tsconfig.browser.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.worker.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.tests.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.tools.json --noEmit" }, "devDependencies": { "@types/node": "26.6.6", "playwright": "1.64.1", - "typescript": "7.0.2" + "typescript": "6.0.3", + "typescript-native": "npm:typescript@7.0.2", + "eslint": "10.14.0", + "@eslint/js": "10.0.1", + "typescript-eslint": "8.72.1" + }, + "overrides": { + "minimatch": "11.0.3" } } diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..d655774 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,252 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +#![doc = "Rust commands for personal web infrastructure."] + +mod archive; +mod chat; +mod cli; +mod files; +mod images; +mod process; +mod project; +mod release; +mod render; + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use crate::app::cli::{Action, Cli}; +use anyhow::Result; + +use crate::app::project::{Mode, Project}; + +#[doc = "Runs the checked command-line entry point and returns its process status."] +#[must_use] +pub fn run() -> 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())?; + if let Action::Chat { + init, + reset_owner, + owner_fingerprint, + } = arguments.command + { + return chat(&project, init, reset_owner.as_deref(), owner_fingerprint); + } + + let _operation = project.lock()?; + match arguments.command { + Action::Dev { port } => { + let mut settings = project.settings()?; + settings.mode = Mode::Local; + if let Some(selected_port) = port { + settings.port = selected_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::Config { production, port } => { + let mut settings = project.settings()?; + settings.mode = if production { + Mode::Production + } else { + Mode::Local + }; + if let Some(selected_port) = port { + settings.port = selected_port; + } + stack_config::initialize_public(&project.root)?; + render::prepare(&project, &settings)?; + process::line(format_args!( + "Public configuration: {}", + render::directory(&project, &settings).display() + )) + } + 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), + )?; + let configuration = stack_config::Config::load(&project.root)?; + let selected_recipient = + recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned()); + archive::backup( + &project, + &output, + include_images, + &selected_recipient, + key.as_deref(), + ) + } + 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::Chat { .. } => anyhow::bail!("Chat action was not handled"), + Action::Restore { .. } => anyhow::bail!("Restore action was not handled"), + } +} + +fn chat( + project: &Project, + init: bool, + reset_owner: Option<&str>, + owner_fingerprint: bool, +) -> Result<()> { + if project.settings()?.mode == Mode::Production { + return chat::admin(project, init, reset_owner, owner_fingerprint); + } + let mut command = project.command("cargo")?; + command + .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"]) + .arg(&project.root); + if init { + command.arg("--init"); + } + if let Some(fingerprint) = reset_owner { + command.arg("--reset-owner").arg(fingerprint); + } + if owner_fingerprint { + command.arg("--owner-fingerprint"); + } + process::run(&mut command) +} + +fn absolute(path: &Path) -> Result { + 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", + "--workspace", + "--locked", + "about.hbs", + "--output-file", + "web/THIRD-PARTY-LICENSES.html", + ])) +} + +fn check(project: &Project) -> Result<()> { + process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?; + 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", + "--workspace", + "--all-features", + "check", + ]))?; + licenses(project) +} diff --git a/src/app/archive.rs b/src/app/archive.rs new file mode 100644 index 0000000..2a78644 --- /dev/null +++ b/src/app/archive.rs @@ -0,0 +1,475 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +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::app::project::{Mode, Project, Settings, field}; +use crate::app::{files, process}; + +#[derive(Debug)] +struct Paused<'a> { + project: &'a Project, + ids: Vec, +} + +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(super) fn containers(project: &Project, settings: &Settings) -> Result> { + 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::>()?; + 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, &["Config", "Labels", "io.stack.service"])?.as_str() != Some("chat") + && 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_chat(root: &Path, stage: &Path, name: &str) -> Result<()> { + let source = root.join("var/data").join(name).join("chat.sqlite"); + if !source.is_file() { + return Ok(()); + } + let directory = stage.join("var/data").join(name); + files::private_dir(&directory)?; + let database = + rusqlite::Connection::open_with_flags(source, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; + database.busy_timeout(std::time::Duration::from_secs(5))?; + let destination = directory.join("chat.sqlite"); + database.backup("main", &destination, None::)?; + fs::set_permissions( + destination, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + Ok(()) +} + +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 !["caddy", "chat", "chat-local"] + .iter() + .any(|name| entry.file_name() == *name) + { + files::copy_tree( + &entry.path(), + &stage.join("var/data").join(entry.file_name()), + )?; + } + } + for name in ["chat", "chat-local"] { + snapshot_chat(&project.root, stage, 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"))?; + crate::app::chat::copy_artifacts(project, stage)?; + Ok(()) +} + +pub(super) 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 configuration = stack_config::Config::load(&project.root)?; + let key = public_key.map_or_else( + || Ok(project.root.join(configuration.value("BACKUP_PUBLIC_KEY")?)), + |path| Ok::<_, anyhow::Error>(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::app::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::set_permissions( + &encrypted, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + 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(super) 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::() + ); + 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::app::files; + use crate::app::project::{Mode, Project}; + + #[test] + fn chat_snapshot_is_consistent_and_private() -> Result<()> { + let workspace = tempfile::tempdir()?; + let root = workspace.path().join("source"); + let stage = workspace.path().join("snapshot"); + files::private_dir(&root.join("var/data/chat"))?; + let connection = rusqlite::Connection::open(root.join("var/data/chat/chat.sqlite"))?; + connection.execute_batch( + "CREATE TABLE messages (body TEXT); INSERT INTO messages VALUES ('ciphertext-before');", + )?; + let transaction = connection.unchecked_transaction()?; + transaction.execute("INSERT INTO messages VALUES ('uncommitted')", [])?; + super::snapshot_chat(&root, &stage, "chat")?; + let snapshot = stage.join("var/data/chat/chat.sqlite"); + let restored = rusqlite::Connection::open(&snapshot)?; + let count: i64 = + restored.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?; + anyhow::ensure!((count) == (1), "Test invariant failed"); + anyhow::ensure!( + (std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions()) + & 0o777) + == (0o600), + "Test invariant failed" + ); + transaction.rollback()?; + Ok(()) + } + + #[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)?; + anyhow::ensure!( + (Project::at(&destination)?.settings()?.mode) == (Mode::Local), + "Test invariant failed" + ); + anyhow::ensure!( + (fs::read_to_string(destination.join(".env"))?) == ("SEARXNG_SECRET=private\n"), + "Test invariant failed" + ); + anyhow::ensure!( + super::restore(&archive, &destination).is_err(), + "Test invariant failed" + ); + 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"); + anyhow::ensure!( + super::restore(&archive, &destination).is_err(), + "Test invariant failed" + ); + anyhow::ensure!(!destination.exists(), "Test invariant failed"); + Ok(()) + } +} diff --git a/src/app/chat.rs b/src/app/chat.rs new file mode 100644 index 0000000..1647018 --- /dev/null +++ b/src/app/chat.rs @@ -0,0 +1,177 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +use crate::app::project::{Mode, Project, Settings}; +use crate::app::{files, process}; +use anyhow::{Context, Result, ensure}; +use stack_config::Config; +use std::fs; +use std::net::IpAddr; +use std::path::Path; +use std::process::Command; + +pub(super) fn web_bind(configuration: &Config) -> Result { + let address: IpAddr = configuration.value("WEB_BIND")?.parse()?; + Ok(match address { + IpAddr::V4(_) => address.to_string(), + IpAddr::V6(_) => format!("[{address}]"), + }) +} + +pub(super) fn environment( + command: &mut Command, + configuration: &Config, + settings: &Settings, +) -> Result<()> { + let local = (settings.mode == Mode::Local).then_some(settings.port); + let mut public = configuration.public_values(local)?; + let origin = public + .get("CHAT_PUBLIC_ORIGIN") + .context("Missing chat gateway origin")? + .clone(); + public.insert("CHAT_ORIGIN".into(), origin); + public.insert("CHAT_BIND".into(), "0.0.0.0:8080".into()); + for (key, value) in public { + command.arg("--env").arg(&key).env(key, value); + } + Ok(()) +} + +pub(super) fn build(project: &Project) -> Result<()> { + process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?; + process::run(project.command("sh")?.arg("infra/chat/build-native.sh"))?; + let wasm_build = if project + .root + .join(".tools/openssl-wasi/lib/libcrypto.a") + .is_file() + { + "infra/chat/build-browser.sh" + } else { + "infra/chat/build-openssl-wasi.sh" + }; + process::run(project.command("sh")?.arg(wasm_build))?; + process::run(project.command("sh")?.arg("infra/chat/build-source.sh"))?; + package(project) +} + +pub(super) fn package(project: &Project) -> Result<()> { + let directory = project.root.join("bin/chat"); + fs::create_dir_all(&directory)?; + for (source, target) in [ + ( + "target/x86_64-unknown-linux-musl/release/stack-chat-server", + "server", + ), + ( + "target/wasm32-wasip1/release/stack-chat-crypto.wasm", + "crypto.wasm", + ), + ("var/build/chat-source.tar.gz", "SOURCE.tar.gz"), + ("infra/containers/chat/Containerfile", "Containerfile"), + ( + "var/build/typescript/crates/chat-crypto/browser/wasi.js", + "wasi.mjs", + ), + ( + "var/build/typescript/crates/chat-server/web/app.js", + "web/app.js", + ), + ( + "var/build/typescript/crates/chat-server/web/worker.js", + "web/worker.js", + ), + ("web/THIRD-PARTY-LICENSES.html", "THIRD-PARTY-LICENSES.html"), + ("docs/CHAT-APPLICATION.txt", "CHAT-APPLICATION.txt"), + ("docs/CHAT-PRODUCTION.txt", "CHAT-PRODUCTION.txt"), + ("LICENSE", "LICENSE"), + ] { + files::copy_tree(&project.root.join(source), &directory.join(target))?; + } + for name in ["index.html", "style.css", "javascript.html"] { + files::copy_tree( + &project.root.join("crates/chat-server/web").join(name), + &directory.join("web").join(name), + )?; + } + Ok(()) +} + +pub(super) fn copy_artifacts(project: &Project, destination: &Path) -> Result<()> { + let artifacts = project.root.join("bin/chat"); + ensure!( + artifacts.join("server").is_file() + && artifacts.join("crypto.wasm").is_file() + && artifacts.join("SOURCE.tar.gz").is_file(), + "Build a complete chat release locally before export" + ); + files::copy_tree(&artifacts, &destination.join("bin/chat")) +} + +pub(super) fn admin( + project: &Project, + init: bool, + reset_owner: Option<&str>, + owner_fingerprint: bool, +) -> Result<()> { + let settings = project.settings()?; + if init { + project.initialize(&settings)?; + return process::line(format_args!( + "Owner setup token is available in the single root .env" + )); + } + ensure!( + reset_owner.is_some() || owner_fingerprint, + "Use up --production to run chat; maintenance options are --owner-fingerprint and --reset-owner" + ); + let configuration = Config::load(&project.root)?; + let services = project.configuration(&settings)?; + let service = services + .services + .get("chat") + .context("Chat is not packaged in this release")?; + let mut command = project.command("podman")?; + command + .args([ + "run", + "--rm", + "--network", + "none", + "--read-only", + "--read-only-tmpfs=false", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--env", + "CHAT_SETUP_TOKEN", + ]) + .env("CHAT_SETUP_TOKEN", configuration.value("CHAT_SETUP_TOKEN")?); + environment(&mut command, &configuration, &settings)?; + command + .arg("--volume") + .arg(format!( + "{}:/app/var/data/chat:rw,z", + project.root.join("var/data/chat").display() + )) + .arg(&service.image); + if let Some(fingerprint) = reset_owner { + command.arg("--reset-owner").arg(fingerprint); + } + if owner_fingerprint { + command.arg("--owner-fingerprint"); + } + process::run(&mut command) +} diff --git a/src/app/cli.rs b/src/app/cli.rs new file mode 100644 index 0000000..1830ec1 --- /dev/null +++ b/src/app/cli.rs @@ -0,0 +1,329 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +use clap::{Arg, ArgAction, ArgMatches, Command, error::ErrorKind}; +use std::ffi::OsString; +use std::path::PathBuf; + +#[derive(Debug)] + +pub(super) struct Cli { + pub(super) root: Option, + + pub(super) command: Action, +} + +#[derive(Debug)] +pub(super) enum Action { + Chat { + init: bool, + + reset_owner: Option, + + owner_fingerprint: bool, + }, + + Dev { + port: Option, + }, + + Up { + production: bool, + + local: bool, + + port: Option, + + online: bool, + }, + + Config { + production: bool, + + port: Option, + }, + + Status, + + Down, + + Backup { + destination: Option, + + include_images: bool, + + recipient: Option, + + key: Option, + }, + + Restore { + archive: PathBuf, + destination: PathBuf, + }, + + Check, + + Release { + destination: Option, + }, + + Activate { + archive: PathBuf, + }, + + Licenses, +} + +fn flag(name: &'static str) -> Arg { + Arg::new(name).long(name).action(ArgAction::SetTrue) +} + +fn path(name: &'static str) -> Arg { + Arg::new(name).value_parser(clap::builder::PathBufValueParser::new()) +} + +fn port() -> Arg { + Arg::new("port") + .long("port") + .value_parser(clap::builder::RangedU64ValueParser::::new()) +} + +fn development() -> [Command; 3] { + [ + Command::new("dev") + .about("Start the local development services") + .arg(port()), + Command::new("up") + .about("Start or update services in the selected mode") + .args([ + flag("production").conflicts_with("local"), + flag("local"), + port(), + flag("online"), + ]), + Command::new("config") + .about("Render public Caddy, website, and cgit configuration without starting services") + .args([flag("production"), port()]), + ] +} + +fn archives() -> [Command; 4] { + [ + Command::new("backup") + .about("Create a GPG-encrypted application export") + .args([ + path("destination"), + flag("include-images"), + Arg::new("recipient").long("recipient"), + path("key").long("key"), + ]), + Command::new("restore") + .about("Decrypt, validate, and restore into a new directory without starting services") + .args([ + path("archive").required(true), + path("destination").required(true), + ]), + Command::new("release") + .about("Check and build a portable production release locally") + .arg(path("destination")), + Command::new("activate") + .about("Install a prebuilt release without compiling on the server") + .arg(path("archive").required(true)), + ] +} + +fn command() -> Command { + Command::new("stack") + .version(env!("CARGO_PKG_VERSION")) + .about("Develop, deploy, and back up the personal web stack") + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + path("root").long("root").global(true).help( + "Application root; otherwise discovered from the working directory or binary", + ), + ) + .subcommand( + Command::new("chat") + .about("Run experimental local chat or administer its packaged deployment") + .args([ + flag("init").conflicts_with_all(["reset-owner", "owner-fingerprint"]), + Arg::new("reset-owner") + .long("reset-owner") + .value_name("FINGERPRINT") + .conflicts_with("owner-fingerprint") + .help( + "Permanently clear owner registration and chat history after key loss", + ), + flag("owner-fingerprint") + .help("Print the registered owner's public fingerprint"), + ]), + ) + .subcommands(development()) + .subcommands(archives()) + .subcommands([ + Command::new("status").about("Show service status"), + Command::new("down").about("Stop services while retaining data"), + Command::new("check") + .about("Run formatting, strict lints, tests, documentation, and dependency checks"), + Command::new("licenses") + .about("Generate the third-party license page with GNU license names"), + ]) +} + +fn required( + matches: &mut ArgMatches, + name: &str, +) -> Result { + matches.remove_one(name).ok_or_else(|| { + clap::Error::raw( + ErrorKind::MissingRequiredArgument, + format!("Missing {name}"), + ) + }) +} + +fn action(name: &str, args: &mut ArgMatches) -> Result { + Ok(match name { + "chat" => Action::Chat { + init: args.get_flag("init"), + reset_owner: args.remove_one("reset-owner"), + owner_fingerprint: args.get_flag("owner-fingerprint"), + }, + "dev" => Action::Dev { + port: args.remove_one("port"), + }, + "up" => Action::Up { + production: args.get_flag("production"), + local: args.get_flag("local"), + port: args.remove_one("port"), + online: args.get_flag("online"), + }, + "config" => Action::Config { + production: args.get_flag("production"), + port: args.remove_one("port"), + }, + "status" => Action::Status, + "down" => Action::Down, + "backup" => Action::Backup { + destination: args.remove_one("destination"), + include_images: args.get_flag("include-images"), + recipient: args.remove_one("recipient"), + key: args.remove_one("key"), + }, + "restore" => Action::Restore { + archive: required(args, "archive")?, + destination: required(args, "destination")?, + }, + "check" => Action::Check, + "release" => Action::Release { + destination: args.remove_one("destination"), + }, + "activate" => Action::Activate { + archive: required(args, "archive")?, + }, + "licenses" => Action::Licenses, + _ => { + return Err(clap::Error::raw( + ErrorKind::InvalidSubcommand, + "Unknown command", + )); + } + }) +} + +impl Cli { + pub(super) fn try_parse() -> Result { + Self::try_parse_from(std::env::args_os()) + } + + fn try_parse_from( + arguments: impl IntoIterator + Clone>, + ) -> Result { + let mut matches = command().try_get_matches_from(arguments)?; + let root = matches.remove_one("root"); + let (name, mut arguments) = matches.remove_subcommand().ok_or_else(|| { + clap::Error::raw(ErrorKind::MissingSubcommand, "Select a stack command") + })?; + Ok(Self { + root, + command: action(&name, &mut arguments)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{Action, Cli, command}; + use anyhow::Result; + use std::path::PathBuf; + + #[test] + fn checked_command_schema() { + command().debug_assert(); + } + + #[test] + fn modes_conflicts_and_required_paths() -> Result<()> { + let parsed = Cli::try_parse_from([ + "stack", + "up", + "--root", + "/tmp/stack", + "--local", + "--port", + "8180", + "--online", + ])?; + anyhow::ensure!( + (parsed.root) == (Some(PathBuf::from("/tmp/stack"))), + "Test invariant failed" + ); + anyhow::ensure!( + matches!( + parsed.command, + Action::Up { + production: false, + local: true, + port: Some(8180), + online: true + } + ), + "Test invariant failed" + ); + for args in [ + vec!["stack", "up", "--local", "--production"], + vec!["stack", "dev", "--port", "65536"], + vec!["stack", "chat", "--init", "--reset-owner", "abc"], + vec![ + "stack", + "chat", + "--reset-owner", + "abc", + "--owner-fingerprint", + ], + vec!["stack", "restore", "archive"], + vec!["stack", "activate"], + ] { + anyhow::ensure!(Cli::try_parse_from(args).is_err(), "Test invariant failed"); + } + anyhow::ensure!( + matches!(Cli::try_parse_from(["stack", "restore", "export.gpg", "new-root"])?.command, Action::Restore { archive, destination } if archive == std::path::Path::new("export.gpg") && destination == std::path::Path::new("new-root")), + "Test invariant failed" + ); + Ok(()) + } +} diff --git a/src/app/files.rs b/src/app/files.rs new file mode 100644 index 0000000..2c612a9 --- /dev/null +++ b/src/app/files.rs @@ -0,0 +1,384 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +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(super) const SOURCE: &[&str] = &[ + "Cargo.toml", + "Cargo.lock", + "clippy.toml", + "eslint.config.ts", + "package.json", + "package-lock.json", + "tsconfig.json", + "tsconfig.browser.json", + "tsconfig.worker.json", + "tsconfig.tests.json", + "tsconfig.tools.json", + "rust-toolchain.toml", + ".cargo", + ".gitignore", + ".gitattributes", + ".forgejo", + "src", + "tests", + "crates", + "infra", + "web", + "docs", + "README.txt", + "LICENSE", + "deny.toml", + "about.toml", + "about.hbs", + ".env.example", +]; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Manifest { + pub(super) format: u32, + pub(super) kind: String, + pub(super) version: String, + pub(super) created_utc: String, + pub(super) files: BTreeMap, +} + +pub(super) 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(super) 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(super) fn hash_bytes(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +pub(super) fn hash_file(path: &Path) -> Result { + 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(super) fn relative(path: &Path, base: &Path) -> Result { + Ok(path + .strip_prefix(base)? + .to_str() + .context("Paths must be UTF-8")? + .to_owned()) +} + +pub(super) 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(super) 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(super) 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(super) fn inventory(root: &Path) -> Result> { + 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(super) 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(super) fn verify(root: &Path) -> Result { + 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(super) 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(super) fn member_path(path: &Path, destination: &Path) -> Result { + 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(super) 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() -> Result<()> { + let destination = Path::new("/restore"); + for name in ["/etc/passwd", "../escape", "stack/../escape", "other/file"] { + anyhow::ensure!( + member_path(Path::new(name), destination).is_err(), + "Test invariant failed" + ); + } + anyhow::ensure!( + (member_path(Path::new("stack/web/index.html"), destination).ok()) + == (Some(destination.join("web/index.html"))), + "Test invariant failed" + ); + Ok(()) + } + + #[test] + fn detects_modified_and_extra_files() -> Result<()> { + let root = tempfile::tempdir()?; + fs::write(root.path().join("public.txt"), "original")?; + manifest(root.path(), "release")?; + anyhow::ensure!(verify(root.path()).is_ok(), "Test invariant failed"); + fs::write(root.path().join("public.txt"), "changed")?; + anyhow::ensure!(verify(root.path()).is_err(), "Test invariant failed"); + fs::write(root.path().join("public.txt"), "original")?; + fs::write(root.path().join("unexpected.txt"), "extra")?; + anyhow::ensure!(verify(root.path()).is_err(), "Test invariant failed"); + 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")?; + for name in ["clippy.toml", "eslint.config.ts", "tsconfig.tools.json"] { + fs::write(source.path().join(name), "public lint configuration")?; + } + fs::create_dir_all(source.path().join("node_modules"))?; + fs::write( + source.path().join("node_modules/private-fixture"), + "private", + )?; + 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())?; + anyhow::ensure!( + target.path().join("Cargo.toml").is_file(), + "Test invariant failed" + ); + anyhow::ensure!( + !target.path().join(".env").exists(), + "Test invariant failed" + ); + anyhow::ensure!(!target.path().join("var").exists(), "Test invariant failed"); + for name in ["clippy.toml", "eslint.config.ts", "tsconfig.tools.json"] { + anyhow::ensure!( + target.path().join(name).is_file(), + "Required lint configuration omitted from export" + ); + } + anyhow::ensure!( + !target.path().join("node_modules").exists(), + "Installed development packages entered export" + ); + 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)?; + anyhow::ensure!(verify(&restored).is_ok(), "Test invariant failed"); + anyhow::ensure!( + (fs::read_to_string(restored.join(".env"))?) == ("secret"), + "Test invariant failed" + ); + anyhow::ensure!( + extract(&archive, &restored).is_err(), + "Test invariant failed" + ); + Ok(()) + } + + #[test] + fn rejects_symbolic_links() -> Result<()> { + let root = tempfile::tempdir()?; + std::os::unix::fs::symlink("/etc/passwd", root.path().join("link"))?; + anyhow::ensure!( + super::regular_tree(root.path()).is_err(), + "Test invariant failed" + ); + Ok(()) + } +} diff --git a/src/app/images.rs b/src/app/images.rs new file mode 100644 index 0000000..fac7aa7 --- /dev/null +++ b/src/app/images.rs @@ -0,0 +1,215 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +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::app::project::{Mode, Project, Settings, field}; +use crate::app::{files, process}; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +struct Image { + reference: String, + architecture: String, + layers: Value, + id: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +struct ImageLock { + format: u32, + platform: String, + services: BTreeMap, +} + +fn inspect(project: &Project, name: &str) -> Result { + let records: Vec = serde_json::from_slice( + &process::capture(project.command("podman")?.args(["image", "inspect", name]))?.stdout, + )?; + records + .into_iter() + .next() + .context("Podman returned no image") +} + +pub(super) 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, + new_tags: &mut Vec, +) -> 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(super) 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(super) fn configured(project: &Project, settings: &Settings) -> Result> { + 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/app/process.rs b/src/app/process.rs new file mode 100644 index 0000000..02f6a6b --- /dev/null +++ b/src/app/process.rs @@ -0,0 +1,70 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +use std::fmt; +use std::io::{self, Write}; +use std::process::{Command, Output}; + +use anyhow::{Context, Result, bail}; + +pub(super) fn capture(command: &mut Command) -> Result { + 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(super) fn text(command: &mut Command) -> Result { + String::from_utf8(capture(command)?.stdout).context("Command returned non-UTF-8 output") +} + +pub(super) 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(super) fn available(command: &mut Command) -> bool { + command.output().is_ok_and(|output| output.status.success()) +} + +pub(super) fn line(message: fmt::Arguments<'_>) -> Result<()> { + writeln!(io::stdout().lock(), "{message}").context("Could not write command output") +} + +pub(super) fn error(message: fmt::Arguments<'_>) -> io::Result<()> { + writeln!(io::stderr().lock(), "{message}") +} diff --git a/src/app/project.rs b/src/app/project.rs new file mode 100644 index 0000000..ac55ff3 --- /dev/null +++ b/src/app/project.rs @@ -0,0 +1,539 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +use std::collections::BTreeMap; +use std::env; +use std::fs::{self, File, OpenOptions, Permissions}; +use std::io::{Read, Write}; +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 stack_config::Config; + +use crate::app::{files, process}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(crate = "serde", rename_all = "lowercase")] +pub(super) enum Mode { + #[default] + Local, + Production, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Settings { + pub(super) mode: Mode, + pub(super) port: u16, + pub(super) project: String, + #[serde(default)] + pub(super) online: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Configuration { + pub(super) format: u32, + pub(super) services: BTreeMap, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Service { + pub(super) image: String, + pub(super) build: Option, + #[serde(default)] + gateway: bool, + #[serde(default)] + environment: Vec, + #[serde(default)] + drop_capabilities: bool, + #[serde(default)] + read_only: bool, + health: Vec, + mounts: Vec, + #[serde(default)] + tmpfs: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +struct Mount { + source: String, + target: String, + #[serde(default)] + read_only: bool, +} + +#[derive(Debug)] +pub(super) struct Project { + pub(super) root: PathBuf, +} + +impl Project { + pub(super) fn discover(explicit: Option<&Path>) -> Result { + 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(super) fn at(path: &Path) -> Result { + let root = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir()?.join(path) + }; + Ok(Self { root }) + } + + pub(super) fn settings(&self) -> Result { + let path = self.root.join("var/state.json"); + if path.exists() { + let mut settings: Settings = serde_json::from_reader(File::open(path)?)?; + let config = Config::load(&self.root)?; + if config.is_explicit("LOCAL_HTTP_PORT") { + settings.port = config.port("LOCAL_HTTP_PORT")?; + } + return Ok(settings); + } + Ok(Settings { + mode: Mode::Local, + port: Config::load(&self.root)?.port("LOCAL_HTTP_PORT")?, + project: format!( + "stack-{}", + files::hash_bytes(self.root.as_os_str().as_encoded_bytes()) + .chars() + .take(10) + .collect::() + ), + online: false, + }) + } + + pub(super) fn save(&self, settings: &Settings) -> Result<()> { + files::write( + &self.root.join("var/state.json"), + &serde_json::to_vec_pretty(settings)?, + 0o600, + ) + } + + pub(super) fn lock(&self) -> Result { + 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(super) fn command(&self, name: &str) -> Result { + 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" { + let version = process::text(Command::new("podman").arg("--version"))?; + let major = version + .split_whitespace() + .nth(2) + .and_then(|value| value.split('.').next()) + .context("Cannot determine the Podman version")? + .parse::()?; + if major < 6 { + command.env( + "CONTAINERS_CONF_OVERRIDE", + self.root.join("infra/podman.conf"), + ); + } + command + .arg("--root") + .arg(self.root.join("var/podman/storage")) + .arg("--runroot") + .arg(self.root.join("var/podman/run")); + } + Ok(command) + } + + pub(super) fn platform(&self) -> Result { + 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(super) fn configuration(&self, settings: &Settings) -> Result { + 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(super) fn initialize(&self, settings: &Settings) -> Result<()> { + files::private_dir(&self.root.join("var/data"))?; + let env_file = self.root.join(".env"); + for key in ["SEARXNG_SECRET", "CHAT_SETUP_TOKEN"] { + if !Config::load(&self.root)?.is_explicit(key) { + let mut random = [0; 32]; + File::open("/dev/urandom")?.read_exact(&mut random)?; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(&env_file)?; + writeln!(file, "\n{key}={}", hex::encode(random))?; + } + } + stack_config::initialize_public(&self.root)?; + fs::set_permissions(&env_file, Permissions::from_mode(0o600))?; + for name in ["chat", "chat-local"] { + files::private_dir(&self.root.join("var/data").join(name))?; + } + 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"), + )?; + crate::app::render::prepare(self, settings) + } + + pub(super) fn container_name(settings: &Settings, service: &str) -> String { + format!("{}-{service}", settings.project) + } + + pub(super) fn inspect_containers(&self, settings: &Settings) -> Result> { + 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(super) 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 = Config::load(&self.root)?; + 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 { + environment + .public_values(None)? + .get("SITE_ORIGIN") + .context("Missing public site origin")? + .to_owned() + } else { + format!("http://localhost:{}", settings.port) + } + )) + } + + pub(super) 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 + { + if context == "bin/chat" && !self.root.join("bin/chat/server").is_file() { + crate::app::chat::build(self)?; + } + 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: &Config, + ) -> 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.read_only { + command.args(["--read-only", "--read-only-tmpfs=false"]); + } + 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"), + ] + .to_vec() + } else { + let http = environment.port("HTTP_PORT")?; + let https = environment.port("HTTPS_PORT")?; + vec![ + format!("{}:{http}:{http}", crate::app::chat::web_bind(environment)?), + format!( + "{}:{https}:{https}", + crate::app::chat::web_bind(environment)? + ), + format!( + "{}:{https}:{https}/udp", + crate::app::chat::web_bind(environment)? + ), + ] + }; + for port in ports { + command.args(["--publish", &port]); + } + } + for key in &service.environment { + command + .arg("--env") + .arg(key) + .env(key, environment.value(key)?); + } + if name == "chat" { + crate::app::chat::environment(&mut command, environment, settings)?; + } + for mount in &service.mounts { + let source = match mount.source.as_str() { + "@caddy" => crate::app::render::directory(self, settings).join("Caddyfile"), + "@web" => crate::app::render::directory(self, settings).join("web"), + "@cgit" => crate::app::render::directory(self, settings).join("cgitrc"), + "@chat" => self.root.join(if settings.mode == Mode::Local { + "var/data/chat-local" + } else { + "var/data/chat" + }), + 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(super) fn status(&self, settings: &Settings) -> Result<()> { + process::run(self.command("podman")?.args([ + "ps", + "--all", + "--filter", + &format!("label=io.stack.project={}", settings.project), + ])) + } + + pub(super) 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), + ]))?; + } + let network = format!("{}-net", settings.project); + if process::available( + self.command("podman")? + .args(["network", "exists", &network]), + ) { + process::capture(self.command("podman")?.args(["network", "rm", &network]))?; + } + Ok(()) + } + + fn path(&self, relative: &str) -> Result { + 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(super) 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/app/release.rs b/src/app/release.rs new file mode 100644 index 0000000..268a3c2 --- /dev/null +++ b/src/app/release.rs @@ -0,0 +1,127 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result, ensure}; + +use crate::app::project::{Mode, Project}; +use crate::app::{files, images, process}; + +pub(super) fn create(project: &Project, destination: &Path) -> Result<()> { + crate::app::check(project)?; + crate::app::chat::build(project)?; + 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)?; + crate::app::chat::copy_artifacts(project, &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(super) 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, + )?; + crate::app::chat::copy_artifacts(&pending, &project.root)?; + 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/src/app/render.rs b/src/app/render.rs new file mode 100644 index 0000000..8a2d2ee --- /dev/null +++ b/src/app/render.rs @@ -0,0 +1,130 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +use crate::app::files; +use crate::app::project::{Mode, Project, Settings}; +use anyhow::Result; +use stack_config::Config; +use std::fs; +use std::path::PathBuf; + +pub(super) fn directory(project: &Project, settings: &Settings) -> PathBuf { + project.root.join(match settings.mode { + Mode::Local => "var/local", + Mode::Production => "var/production", + }) +} + +pub(super) fn prepare(project: &Project, settings: &Settings) -> Result<()> { + let config = Config::load(&project.root)?; + let local = (settings.mode == Mode::Local).then_some(settings.port); + let output = directory(project, settings); + let web = output.join("web"); + if web.exists() { + files::regular_tree(&web)?; + fs::remove_dir_all(&web)?; + } + files::copy_tree(&project.root.join("web"), &web)?; + let file = web.join("index.html"); + files::write( + &file, + config + .render(&fs::read_to_string(&file)?, local)? + .as_bytes(), + 0o644, + )?; + let template = project.root.join(if settings.mode == Mode::Local { + "infra/Caddyfile.local" + } else { + "infra/Caddyfile" + }); + files::write( + &output.join("Caddyfile"), + config + .render(&fs::read_to_string(template)?, local)? + .as_bytes(), + 0o644, + )?; + let cgit = fs::read_to_string(project.root.join("infra/containers/cgit/cgitrc"))?; + let mut rendered = config.render(&cgit, local)?; + if let Some(port) = local { + rendered = rendered.replace( + "clone-prefix=/git", + &format!("clone-prefix=http://localhost:{port}/git"), + ); + } + files::write(&output.join("cgitrc"), rendered.as_bytes(), 0o644) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn custom_domains_and_ports_reach_public_outputs_without_secrets() -> Result<()> { + let root = tempfile::tempdir()?; + for name in ["web", "infra"] { + files::copy_tree( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(name), + &root.path().join(name), + )?; + } + fs::write( + root.path().join(".env"), + "SITE_HOST=home.example.net\nGIT_HOST=git.example.net\nHTTP_PORT=8181\nHTTPS_PORT=8443\nSEARXNG_SECRET=do-not-publish-this-sentinel\n", + )?; + let project = Project::at(root.path())?; + let mut settings = project.settings()?; + settings.mode = Mode::Production; + prepare(&project, &settings)?; + let output = directory(&project, &settings); + let caddy = fs::read_to_string(output.join("Caddyfile"))?; + anyhow::ensure!( + caddy.contains("https://home.example.net:8443"), + "Test invariant failed" + ); + anyhow::ensure!(caddy.contains("http_port 8181"), "Test invariant failed"); + anyhow::ensure!(!caddy.contains("do-not-publish"), "Test invariant failed"); + let homepage = fs::read_to_string(output.join("web/index.html"))?; + anyhow::ensure!( + homepage.contains("https://git.example.net:8443"), + "Test invariant failed" + ); + let stylesheet = files::hash_file(&root.path().join("web/styles.css"))?; + anyhow::ensure!( + homepage.contains(&format!("/styles.css?v={stylesheet}")), + "Test invariant failed" + ); + anyhow::ensure!( + homepage.contains("https://chat.doan.luuduct.ai:8443/LIMITATIONS.txt"), + "Test invariant failed" + ); + settings.mode = Mode::Local; + settings.port = 8180; + prepare(&project, &settings)?; + let preview = fs::read_to_string(directory(&project, &settings).join("web/index.html"))?; + anyhow::ensure!(preview.contains("href=\"/git/\""), "Test invariant failed"); + anyhow::ensure!( + preview.contains("http://chat.localhost:8180/LIMITATIONS.txt"), + "Test invariant failed" + ); + anyhow::ensure!( + fs::read_to_string(directory(&project, &settings).join("cgitrc"))? + .contains("clone-prefix=http://localhost:8180/git"), + "Test invariant failed" + ); + Ok(()) + } +} diff --git a/src/archive.rs b/src/archive.rs deleted file mode 100644 index 1e58992..0000000 --- a/src/archive.rs +++ /dev/null @@ -1,464 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// 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 . - -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}; - -#[derive(Debug)] -struct Paused<'a> { - project: &'a Project, - ids: Vec, -} - -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> { - 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::>()?; - 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, &["Config", "Labels", "io.stack.service"])?.as_str() != Some("chat") - && 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_chat(root: &Path, stage: &Path, name: &str) -> Result<()> { - let source = root.join("var/data").join(name).join("chat.sqlite"); - if !source.is_file() { - return Ok(()); - } - let directory = stage.join("var/data").join(name); - files::private_dir(&directory)?; - let database = - rusqlite::Connection::open_with_flags(source, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; - database.busy_timeout(std::time::Duration::from_secs(5))?; - let destination = directory.join("chat.sqlite"); - database.backup("main", &destination, None::)?; - fs::set_permissions( - destination, - std::os::unix::fs::PermissionsExt::from_mode(0o600), - )?; - Ok(()) -} - -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 !["caddy", "chat", "chat-local"] - .iter() - .any(|name| entry.file_name() == *name) - { - files::copy_tree( - &entry.path(), - &stage.join("var/data").join(entry.file_name()), - )?; - } - } - for name in ["chat", "chat-local"] { - snapshot_chat(&project.root, stage, 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"))?; - crate::chat::copy_artifacts(project, stage)?; - 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 configuration = stack_config::Config::load(&project.root)?; - let key = public_key.map_or_else( - || Ok(project.root.join(configuration.value("BACKUP_PUBLIC_KEY")?)), - |path| Ok::<_, anyhow::Error>(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::set_permissions( - &encrypted, - std::os::unix::fs::PermissionsExt::from_mode(0o600), - )?; - 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::() - ); - 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 chat_snapshot_is_consistent_and_private() -> Result<()> { - let workspace = tempfile::tempdir()?; - let root = workspace.path().join("source"); - let stage = workspace.path().join("snapshot"); - files::private_dir(&root.join("var/data/chat"))?; - let connection = rusqlite::Connection::open(root.join("var/data/chat/chat.sqlite"))?; - connection.execute_batch( - "CREATE TABLE messages (body TEXT); INSERT INTO messages VALUES ('ciphertext-before');", - )?; - let transaction = connection.unchecked_transaction()?; - transaction.execute("INSERT INTO messages VALUES ('uncommitted')", [])?; - super::snapshot_chat(&root, &stage, "chat")?; - let snapshot = stage.join("var/data/chat/chat.sqlite"); - let restored = rusqlite::Connection::open(&snapshot)?; - let count: i64 = - restored.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?; - assert_eq!(count, 1); - assert_eq!( - std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions()) & 0o777, - 0o600 - ); - transaction.rollback()?; - Ok(()) - } - - #[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/chat.rs b/src/chat.rs deleted file mode 100644 index 0739f22..0000000 --- a/src/chat.rs +++ /dev/null @@ -1,177 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// 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 . - -use crate::project::{Mode, Project, Settings}; -use crate::{files, process}; -use anyhow::{Context, Result, ensure}; -use stack_config::Config; -use std::fs; -use std::net::IpAddr; -use std::path::Path; -use std::process::Command; - -pub(crate) fn web_bind(configuration: &Config) -> Result { - let address: IpAddr = configuration.value("WEB_BIND")?.parse()?; - Ok(match address { - IpAddr::V4(_) => address.to_string(), - IpAddr::V6(_) => format!("[{address}]"), - }) -} - -pub(crate) fn environment( - command: &mut Command, - configuration: &Config, - settings: &Settings, -) -> Result<()> { - let local = (settings.mode == Mode::Local).then_some(settings.port); - let mut public = configuration.public_values(local)?; - let origin = public - .get("CHAT_PUBLIC_ORIGIN") - .context("Missing chat gateway origin")? - .clone(); - public.insert("CHAT_ORIGIN".into(), origin); - public.insert("CHAT_BIND".into(), "0.0.0.0:8080".into()); - for (key, value) in public { - command.arg("--env").arg(&key).env(key, value); - } - Ok(()) -} - -pub(crate) fn build(project: &Project) -> Result<()> { - process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?; - process::run(project.command("sh")?.arg("infra/chat/build-native.sh"))?; - let wasm_build = if project - .root - .join(".tools/openssl-wasi/lib/libcrypto.a") - .is_file() - { - "infra/chat/build-browser.sh" - } else { - "infra/chat/build-openssl-wasi.sh" - }; - process::run(project.command("sh")?.arg(wasm_build))?; - process::run(project.command("sh")?.arg("infra/chat/build-source.sh"))?; - package(project) -} - -pub(crate) fn package(project: &Project) -> Result<()> { - let directory = project.root.join("bin/chat"); - fs::create_dir_all(&directory)?; - for (source, target) in [ - ( - "target/x86_64-unknown-linux-musl/release/stack-chat-server", - "server", - ), - ( - "target/wasm32-wasip1/release/stack-chat-crypto.wasm", - "crypto.wasm", - ), - ("var/build/chat-source.tar.gz", "SOURCE.tar.gz"), - ("infra/containers/chat/Containerfile", "Containerfile"), - ( - "var/build/typescript/crates/chat-crypto/browser/wasi.js", - "wasi.mjs", - ), - ( - "var/build/typescript/crates/chat-server/web/app.js", - "web/app.js", - ), - ( - "var/build/typescript/crates/chat-server/web/worker.js", - "web/worker.js", - ), - ("web/THIRD-PARTY-LICENSES.html", "THIRD-PARTY-LICENSES.html"), - ("docs/CHAT-APPLICATION.txt", "CHAT-APPLICATION.txt"), - ("docs/CHAT-PRODUCTION.txt", "CHAT-PRODUCTION.txt"), - ("LICENSE", "LICENSE"), - ] { - files::copy_tree(&project.root.join(source), &directory.join(target))?; - } - for name in ["index.html", "style.css", "javascript.html"] { - files::copy_tree( - &project.root.join("crates/chat-server/web").join(name), - &directory.join("web").join(name), - )?; - } - Ok(()) -} - -pub(crate) fn copy_artifacts(project: &Project, destination: &Path) -> Result<()> { - let artifacts = project.root.join("bin/chat"); - ensure!( - artifacts.join("server").is_file() - && artifacts.join("crypto.wasm").is_file() - && artifacts.join("SOURCE.tar.gz").is_file(), - "Build a complete chat release locally before export" - ); - files::copy_tree(&artifacts, &destination.join("bin/chat")) -} - -pub(crate) fn admin( - project: &Project, - init: bool, - reset_owner: Option<&str>, - owner_fingerprint: bool, -) -> Result<()> { - let settings = project.settings()?; - if init { - project.initialize(&settings)?; - return process::line(format_args!( - "Owner setup token is available in the single root .env" - )); - } - ensure!( - reset_owner.is_some() || owner_fingerprint, - "Use up --production to run chat; maintenance options are --owner-fingerprint and --reset-owner" - ); - let configuration = Config::load(&project.root)?; - let services = project.configuration(&settings)?; - let service = services - .services - .get("chat") - .context("Chat is not packaged in this release")?; - let mut command = project.command("podman")?; - command - .args([ - "run", - "--rm", - "--network", - "none", - "--read-only", - "--read-only-tmpfs=false", - "--cap-drop", - "ALL", - "--security-opt", - "no-new-privileges", - "--env", - "CHAT_SETUP_TOKEN", - ]) - .env("CHAT_SETUP_TOKEN", configuration.value("CHAT_SETUP_TOKEN")?); - environment(&mut command, &configuration, &settings)?; - command - .arg("--volume") - .arg(format!( - "{}:/app/var/data/chat:rw,z", - project.root.join("var/data/chat").display() - )) - .arg(&service.image); - if let Some(fingerprint) = reset_owner { - command.arg("--reset-owner").arg(fingerprint); - } - if owner_fingerprint { - command.arg("--owner-fingerprint"); - } - process::run(&mut command) -} diff --git a/src/files.rs b/src/files.rs deleted file mode 100644 index 81c57b0..0000000 --- a/src/files.rs +++ /dev/null @@ -1,343 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// 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 . - -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", - "package.json", - "package-lock.json", - "tsconfig.json", - "tsconfig.browser.json", - "tsconfig.worker.json", - "tsconfig.tests.json", - "rust-toolchain.toml", - ".cargo", - ".gitignore", - ".gitattributes", - ".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, -} - -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 { - 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 { - 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> { - 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 { - 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 { - 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 deleted file mode 100644 index 30019ca..0000000 --- a/src/images.rs +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// 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 . - -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, -} - -fn inspect(project: &Project, name: &str) -> Result { - let records: Vec = serde_json::from_slice( - &process::capture(project.command("podman")?.args(["image", "inspect", name]))?.stdout, - )?; - records - .into_iter() - .next() - .context("Podman 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, - new_tags: &mut Vec, -) -> 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> { - 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 index e6323e2..942a379 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,326 +13,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -#![doc = "Rust commands for personal web infrastructure."] +#![doc = "Checked command-line entry point for the personal infrastructure stack."] -pub(crate) mod archive; -pub(crate) mod chat; -pub(crate) mod files; -pub(crate) mod images; -pub(crate) mod process; -pub(crate) mod project; -pub(crate) mod release; -pub(crate) mod render; +mod app; -use std::path::{Path, PathBuf}; -use std::process::ExitCode; +pub use app::run; -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, - #[command(subcommand)] - command: Action, -} - -#[derive(Debug, Subcommand)] -enum Action { - #[command(about = "Run experimental local chat or administer its packaged deployment")] - Chat { - #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint"])] - init: bool, - #[arg( - long, - value_name = "FINGERPRINT", - conflicts_with = "owner_fingerprint", - help = "Permanently clear owner registration and chat history after key loss" - )] - reset_owner: Option, - #[arg(long, help = "Print the registered owner's public fingerprint")] - owner_fingerprint: bool, - }, - #[command(about = "Start the local development services")] - Dev { - #[arg(long)] - port: Option, - }, - #[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, - #[arg(long)] - online: bool, - }, - #[command( - about = "Render public Caddy, website, and cgit configuration without starting services" - )] - Config { - #[arg(long)] - production: bool, - #[arg(long)] - port: Option, - }, - #[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, - #[arg(long)] - include_images: bool, - #[arg(long)] - recipient: Option, - #[arg(long)] - key: Option, - }, - #[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 }, - #[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())?; - if let Action::Chat { - init, - reset_owner, - owner_fingerprint, - } = arguments.command - { - return chat(&project, init, reset_owner.as_deref(), owner_fingerprint); - } - - let _operation = project.lock()?; - match arguments.command { - Action::Dev { port } => { - let mut settings = project.settings()?; - settings.mode = Mode::Local; - if let Some(selected_port) = port { - settings.port = selected_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::Config { production, port } => { - let mut settings = project.settings()?; - settings.mode = if production { - Mode::Production - } else { - Mode::Local - }; - if let Some(selected_port) = port { - settings.port = selected_port; - } - stack_config::initialize_public(&project.root)?; - render::prepare(&project, &settings)?; - process::line(format_args!( - "Public configuration: {}", - render::directory(&project, &settings).display() - )) - } - 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), - )?; - let configuration = stack_config::Config::load(&project.root)?; - let selected_recipient = - recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned()); - archive::backup( - &project, - &output, - include_images, - &selected_recipient, - key.as_deref(), - ) - } - 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::Chat { .. } => anyhow::bail!("Chat action was not handled"), - Action::Restore { .. } => anyhow::bail!("Restore action was not handled"), - } -} - -fn chat( - project: &Project, - init: bool, - reset_owner: Option<&str>, - owner_fingerprint: bool, -) -> Result<()> { - if project.settings()?.mode == Mode::Production { - return chat::admin(project, init, reset_owner, owner_fingerprint); - } - let mut command = project.command("cargo")?; - command - .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"]) - .arg(&project.root); - if init { - command.arg("--init"); - } - if let Some(fingerprint) = reset_owner { - command.arg("--reset-owner").arg(fingerprint); - } - if owner_fingerprint { - command.arg("--owner-fingerprint"); - } - process::run(&mut command) -} - -fn absolute(path: &Path) -> Result { - 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", - "--workspace", - "--locked", - "about.hbs", - "--output-file", - "web/THIRD-PARTY-LICENSES.html", - ])) -} - -fn check(project: &Project) -> Result<()> { - process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?; - 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", - "--workspace", - "--all-features", - "check", - ]))?; - licenses(project) +fn main() -> std::process::ExitCode { + run() } diff --git a/src/process.rs b/src/process.rs deleted file mode 100644 index eb48bc8..0000000 --- a/src/process.rs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// 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 . - -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 { - 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::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 deleted file mode 100644 index 57185b1..0000000 --- a/src/project.rs +++ /dev/null @@ -1,536 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// 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 . - -use std::collections::BTreeMap; -use std::env; -use std::fs::{self, File, OpenOptions, Permissions}; -use std::io::{Read, Write}; -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 stack_config::Config; - -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, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct Service { - pub(crate) image: String, - pub(crate) build: Option, - #[serde(default)] - gateway: bool, - #[serde(default)] - environment: Vec, - #[serde(default)] - drop_capabilities: bool, - #[serde(default)] - read_only: bool, - health: Vec, - mounts: Vec, - #[serde(default)] - tmpfs: Vec, -} - -#[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 { - 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 { - 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 { - let path = self.root.join("var/state.json"); - if path.exists() { - let mut settings: Settings = serde_json::from_reader(File::open(path)?)?; - let config = Config::load(&self.root)?; - if config.is_explicit("LOCAL_HTTP_PORT") { - settings.port = config.port("LOCAL_HTTP_PORT")?; - } - return Ok(settings); - } - Ok(Settings { - mode: Mode::Local, - port: Config::load(&self.root)?.port("LOCAL_HTTP_PORT")?, - project: format!( - "stack-{}", - files::hash_bytes(self.root.as_os_str().as_encoded_bytes()) - .chars() - .take(10) - .collect::() - ), - 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 { - 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 { - 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" { - let version = process::text(Command::new("podman").arg("--version"))?; - let major = version - .split_whitespace() - .nth(2) - .and_then(|value| value.split('.').next()) - .context("Cannot determine the Podman version")? - .parse::()?; - if major < 6 { - command.env( - "CONTAINERS_CONF_OVERRIDE", - self.root.join("infra/podman.conf"), - ); - } - 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 { - 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 { - 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"); - for key in ["SEARXNG_SECRET", "CHAT_SETUP_TOKEN"] { - if !Config::load(&self.root)?.is_explicit(key) { - let mut random = [0; 32]; - File::open("/dev/urandom")?.read_exact(&mut random)?; - let mut file = OpenOptions::new() - .create(true) - .append(true) - .mode(0o600) - .open(&env_file)?; - writeln!(file, "\n{key}={}", hex::encode(random))?; - } - } - stack_config::initialize_public(&self.root)?; - fs::set_permissions(&env_file, Permissions::from_mode(0o600))?; - for name in ["chat", "chat-local"] { - files::private_dir(&self.root.join("var/data").join(name))?; - } - 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"), - )?; - crate::render::prepare(self, settings) - } - - pub(crate) fn container_name(settings: &Settings, service: &str) -> String { - format!("{}-{service}", settings.project) - } - - pub(crate) fn inspect_containers(&self, settings: &Settings) -> Result> { - 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 = Config::load(&self.root)?; - 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 { - environment - .public_values(None)? - .get("SITE_ORIGIN") - .context("Missing public site origin")? - .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 - { - if context == "bin/chat" && !self.root.join("bin/chat/server").is_file() { - crate::chat::build(self)?; - } - 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: &Config, - ) -> 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.read_only { - command.args(["--read-only", "--read-only-tmpfs=false"]); - } - 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"), - ] - .to_vec() - } else { - let http = environment.port("HTTP_PORT")?; - let https = environment.port("HTTPS_PORT")?; - vec![ - format!("{}:{http}:{http}", crate::chat::web_bind(environment)?), - format!("{}:{https}:{https}", crate::chat::web_bind(environment)?), - format!( - "{}:{https}:{https}/udp", - crate::chat::web_bind(environment)? - ), - ] - }; - for port in ports { - command.args(["--publish", &port]); - } - } - for key in &service.environment { - command - .arg("--env") - .arg(key) - .env(key, environment.value(key)?); - } - if name == "chat" { - crate::chat::environment(&mut command, environment, settings)?; - } - for mount in &service.mounts { - let source = match mount.source.as_str() { - "@caddy" => crate::render::directory(self, settings).join("Caddyfile"), - "@web" => crate::render::directory(self, settings).join("web"), - "@cgit" => crate::render::directory(self, settings).join("cgitrc"), - "@chat" => self.root.join(if settings.mode == Mode::Local { - "var/data/chat-local" - } else { - "var/data/chat" - }), - 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), - ]))?; - } - let network = format!("{}-net", settings.project); - if process::available( - self.command("podman")? - .args(["network", "exists", &network]), - ) { - process::capture(self.command("podman")?.args(["network", "rm", &network]))?; - } - Ok(()) - } - - fn path(&self, relative: &str) -> Result { - 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 deleted file mode 100644 index 92f2df4..0000000 --- a/src/release.rs +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// 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 . - -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)?; - crate::chat::build(project)?; - 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)?; - crate::chat::copy_artifacts(project, &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, - )?; - crate::chat::copy_artifacts(&pending, &project.root)?; - 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/src/render.rs b/src/render.rs deleted file mode 100644 index a552643..0000000 --- a/src/render.rs +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// 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 . - -use crate::files; -use crate::project::{Mode, Project, Settings}; -use anyhow::Result; -use stack_config::Config; -use std::fs; -use std::path::PathBuf; - -pub(crate) fn directory(project: &Project, settings: &Settings) -> PathBuf { - project.root.join(match settings.mode { - Mode::Local => "var/local", - Mode::Production => "var/production", - }) -} - -pub(crate) fn prepare(project: &Project, settings: &Settings) -> Result<()> { - let config = Config::load(&project.root)?; - let local = (settings.mode == Mode::Local).then_some(settings.port); - let output = directory(project, settings); - let web = output.join("web"); - if web.exists() { - files::regular_tree(&web)?; - fs::remove_dir_all(&web)?; - } - files::copy_tree(&project.root.join("web"), &web)?; - let file = web.join("index.html"); - files::write( - &file, - config - .render(&fs::read_to_string(&file)?, local)? - .as_bytes(), - 0o644, - )?; - let template = project.root.join(if settings.mode == Mode::Local { - "infra/Caddyfile.local" - } else { - "infra/Caddyfile" - }); - files::write( - &output.join("Caddyfile"), - config - .render(&fs::read_to_string(template)?, local)? - .as_bytes(), - 0o644, - )?; - let cgit = fs::read_to_string(project.root.join("infra/containers/cgit/cgitrc"))?; - let mut rendered = config.render(&cgit, local)?; - if let Some(port) = local { - rendered = rendered.replace( - "clone-prefix=/git", - &format!("clone-prefix=http://localhost:{port}/git"), - ); - } - files::write(&output.join("cgitrc"), rendered.as_bytes(), 0o644) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn custom_domains_and_ports_reach_public_outputs_without_secrets() -> Result<()> { - let root = tempfile::tempdir()?; - for name in ["web", "infra"] { - files::copy_tree( - &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(name), - &root.path().join(name), - )?; - } - fs::write( - root.path().join(".env"), - "SITE_HOST=home.example.net\nGIT_HOST=git.example.net\nHTTP_PORT=8181\nHTTPS_PORT=8443\nSEARXNG_SECRET=do-not-publish-this-sentinel\n", - )?; - let project = Project::at(root.path())?; - let mut settings = project.settings()?; - settings.mode = Mode::Production; - prepare(&project, &settings)?; - let output = directory(&project, &settings); - let caddy = fs::read_to_string(output.join("Caddyfile"))?; - assert!(caddy.contains("https://home.example.net:8443")); - assert!(caddy.contains("http_port 8181")); - assert!(!caddy.contains("do-not-publish")); - let homepage = fs::read_to_string(output.join("web/index.html"))?; - assert!(homepage.contains("https://git.example.net:8443")); - let stylesheet = files::hash_file(&root.path().join("web/styles.css"))?; - assert!(homepage.contains(&format!("/styles.css?v={stylesheet}"))); - assert!(homepage.contains("https://chat.doan.luuduct.ai:8443/LIMITATIONS.txt")); - settings.mode = Mode::Local; - settings.port = 8180; - prepare(&project, &settings)?; - let preview = fs::read_to_string(directory(&project, &settings).join("web/index.html"))?; - assert!(preview.contains("href=\"/git/\"")); - assert!(preview.contains("http://chat.localhost:8180/LIMITATIONS.txt")); - assert!( - fs::read_to_string(directory(&project, &settings).join("cgitrc"))? - .contains("clone-prefix=http://localhost:8180/git") - ); - Ok(()) - } -} diff --git a/tests/browser-support.ts b/tests/browser-support.ts index 223b2b8..d4ecef9 100644 --- a/tests/browser-support.ts +++ b/tests/browser-support.ts @@ -14,7 +14,7 @@ // along with this program. If not, see . import assert from 'node:assert/strict'; -import type { Server } from 'node:http'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; export function required(value: T | undefined | null, message: string): T { assert.ok(value !== undefined && value !== null, message); @@ -43,8 +43,8 @@ export function list(value: unknown): unknown[] { export function serverOrigin(server: Server): string { const address = server.address(); - assert.ok(address && typeof address !== 'string'); - return `http://127.0.0.1:${address.port}`; + assert.ok(address !== null && typeof address !== 'string'); + return `http://127.0.0.1:${String(address.port)}`; } export function requestBody(text: string): { op: string; count: number; [key: string]: unknown } { @@ -54,7 +54,7 @@ export function requestBody(text: string): { op: string; count: number; [key: st export function libreJSAddon(text: string): { active: boolean; version: string } { const addon = list(record(JSON.parse(text))['addons']).map(record).find(value => value['id'] === 'jid1-KtlZuoiikVfFew@jetpack'); - assert.ok(addon); + assert.ok((addon !== undefined)); assert.equal(addon['active'], true); return { active: true, version: string(addon['version']) }; } @@ -63,3 +63,18 @@ declare global { interface Window { unlicensedControl?: boolean; chatInjection?: boolean } const wasm: Promise; } + +export function asyncServer(handler: (request: IncomingMessage, response: ServerResponse) => Promise): Server { + return createServer((request, response) => { + handler(request, response).catch((error: unknown) => { + response.destroy(error instanceof Error ? error : new Error('Fixture request failed', { cause: error })); + }); + }); +} + +export function closeBrowser(browser: { close(): Promise }): void { + browser.close().catch((error: unknown) => { + process.exitCode = 1; + process.stderr.write(`Browser shutdown failed: ${String(error)}\n`); + }); +} diff --git a/tsconfig.browser.json b/tsconfig.browser.json index 3f0b667..8836df0 100644 --- a/tsconfig.browser.json +++ b/tsconfig.browser.json @@ -21,6 +21,7 @@ "DOM.Iterable" ], "module": "Preserve", + "moduleResolution": "Bundler", "moduleDetection": "legacy", "rootDir": ".", "outDir": "var/build/typescript", diff --git a/tsconfig.json b/tsconfig.json index bf78056..89a29b0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,10 @@ "compilerOptions": { "target": "ES2022", "strict": true, + "noCheck": false, + "allowJs": false, + "skipDefaultLibCheck": false, + "isolatedModules": true, "erasableSyntaxOnly": true, "verbatimModuleSyntax": true, "noUncheckedSideEffectImports": true, diff --git a/tsconfig.tools.json b/tsconfig.tools.json new file mode 100644 index 0000000..fbe511e --- /dev/null +++ b/tsconfig.tools.json @@ -0,0 +1,33 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": [ + "ES2022" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "moduleDetection": "force", + "rootDir": ".", + "outDir": "var/build/typescript", + "types": [ + "node" + ] + }, + "files": [ + "eslint.config.ts" + ] +} diff --git a/tsconfig.worker.json b/tsconfig.worker.json index dd328b0..64dbd52 100644 --- a/tsconfig.worker.json +++ b/tsconfig.worker.json @@ -20,6 +20,7 @@ "WebWorker" ], "module": "Preserve", + "moduleResolution": "Bundler", "moduleDetection": "legacy", "rootDir": ".", "outDir": "var/build/typescript", diff --git a/web/THIRD-PARTY-LICENSES.html b/web/THIRD-PARTY-LICENSES.html index ab8e687..b0e7c92 100644 --- a/web/THIRD-PARTY-LICENSES.html +++ b/web/THIRD-PARTY-LICENSES.html @@ -32,7 +32,7 @@ applications and their operating-system packages have their own notices.

Headings follow GNU's license names where listed. SPDX identifiers remain available for exact identification. The original license texts below are unchanged.

- +

Apache License, Version 2.0

@@ -1790,7 +1790,7 @@ DEALINGS IN THE SOFTWARE.
- +
Copyright (c) 2015 The Rust Project Developers
 
 Permission is hereby granted, free of charge, to any
@@ -3258,7 +3258,7 @@ DEALINGS IN THE SOFTWARE.
 
- +
Copyright (c) Individual contributors
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
-- 
cgit v1.2.3