From 496ed969fbbee427776fdebbf996349690be09f9 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Tue, 16 Jun 2026 18:03:59 +0200 Subject: [PATCH 01/29] chore: update task status and refine acceptance criteria for detached backend mode --- ...d-backend-mode-with-multiple-dashboards.md | 86 ++++++++++++++++--- 1 file changed, 76 insertions(+), 10 deletions(-) diff --git a/backlog/tasks/task-1.18 - Support-detached-backend-mode-with-multiple-dashboards.md b/backlog/tasks/task-1.18 - Support-detached-backend-mode-with-multiple-dashboards.md index 4dd4360..f63a75c 100644 --- a/backlog/tasks/task-1.18 - Support-detached-backend-mode-with-multiple-dashboards.md +++ b/backlog/tasks/task-1.18 - Support-detached-backend-mode-with-multiple-dashboards.md @@ -1,10 +1,11 @@ --- id: TASK-1.18 title: Support detached backend mode with multiple dashboards -status: To Do -assignee: [] +status: In Progress +assignee: + - '@agent' created_date: '2026-06-10 12:04' -updated_date: '2026-06-16 15:23' +updated_date: '2026-06-16 16:01' labels: [] milestone: m-2 dependencies: @@ -12,6 +13,7 @@ dependencies: - TASK-1.12 references: - docs/ARCHITECTURE.md + - 'https://docs.rs/whirlwind/latest/whirlwind/' parent_task_id: TASK-1 priority: medium ordinal: 18000 @@ -20,20 +22,84 @@ ordinal: 18000 ## Description -Add the future runtime architecture where entrypoints talk to a single per-user backend daemon and zero or more dashboard/TUI clients can attach independently. The current default remains the simpler single instance dashboard+backend model; this task should introduce the explicit detached backend/dashboard mode when the product is ready for it, including a user-facing detached startup option instead of requiring dashboard ownership. +Rebaseline Cadder's future runtime architecture around a single per-user `cadderd` operational backend and zero-to-many attach-only clients. `cadderd` is the only process that owns the backend and the real Caddy runtime. `cadder-tui` and the PATH-facing `caddy` shim never auto-start the daemon; they attach when it is available, surface offline or unavailable state when it is not, and keep working across daemon restarts. `cadder-tui` may expose an explicit user-triggered action to start `cadderd`, but backend startup is never an automatic side effect of opening a dashboard or running a shim command. This task should establish that detached backend foundation for multiple dashboards, while leaving the future desktop `cadder` app to a later task. ## Acceptance Criteria -- [ ] #1 Cadder supports a backend-only daemon mode that can run without owning an interactive dashboard lifecycle. -- [ ] #2 Multiple dashboard/TUI clients can connect to the same backend concurrently, query state, subscribe to changes, and exit without stopping the backend unless they explicitly request shutdown. -- [ ] #3 The default startup path remains dashboard+backend unless the user opts into detached backend mode. -- [ ] #4 CLI/config options clearly distinguish default dashboard+backend startup, dashboard-only attach, and backend-only detached startup, including an explicit detached flag or equivalent user-facing option. -- [ ] #5 Tests cover zero-to-many entrypoints with zero-to-many dashboard clients and verify dashboard disconnects do not remove entrypoint registrations or stop the backend. -- [ ] #6 Architecture and user documentation explain both runtime modes and when to use each one. +- [ ] #1 Cadder supports a backend-only `cadderd` mode that runs independently of any interactive dashboard lifecycle and remains the only owner of the real Caddy runtime. +- [ ] #2 Multiple dashboard/TUI clients can connect to the same backend concurrently, query state, subscribe to changes, disconnect independently, and reconnect without stopping the backend or removing shim registrations. +- [ ] #3 `cadder-tui` and the `caddy` shim never auto-start `cadderd`; when the daemon is unavailable they surface explicit offline or unavailable guidance instead of starting it implicitly. +- [ ] #4 `cadder-tui` can offer a user-triggered backend start action, but that action is explicit and distinct from ordinary attach, reconnect, and refresh behavior. +- [ ] #5 CLI and UX clearly distinguish backend operation, dashboard attach, explicit backend start, and backend shutdown; `caddy run` requires a running backend and fails with a clear Cadder-owned message when `cadderd` is unavailable. +- [ ] #6 Tests cover zero-to-many entrypoints with zero-to-many dashboard clients, including late backend availability, backend loss and reconnect, dashboard disconnects, and shim behavior when no backend is running. +- [ ] #7 Architecture and user documentation explain the manual-daemon-first attach model, process-role boundaries, and when to use explicit backend start from the dashboard. +## Implementation Plan + +## Goal +Establish Cadder's detached runtime foundation around a single manual-start per-user `cadderd` backend and zero-to-many attach-only clients, so multiple dashboards can observe the same backend without owning its lifecycle. + +## Scope +- Rebaseline runtime ownership so `cadderd` is the only backend process and the only owner of the real Caddy runtime. +- Keep `cadder-tui` and the `caddy` shim as attach-only clients that never auto-start the daemon. +- Allow `cadder-tui` to expose an explicit user-triggered backend start action, but keep that distinct from normal attach and reconnect behavior. +- Keep future desktop `cadder` semantics aligned with this model, but do not implement that application in this task. +- Cover reconnect and offline behavior, many dashboards, and shim behavior when the daemon is absent. + +## Key Files And Modules +- `crates/cadder-daemon/src/ipc.rs` +- `crates/cadder-daemon/src/lib.rs` +- `crates/cadder-daemon/src/state.rs` +- `crates/cadderd/src/main.rs` +- `crates/cadder-shim/src/main.rs` +- `crates/cadder-tui/src/main.rs` +- `crates/cadder-tui/src/model.rs` +- `crates/cadder-daemon/tests/ipc_lifecycle.rs` +- `docs/ARCHITECTURE.md` +- `README.md` +- `docs/site/src/content/docs/user-guide/tui-diagnostics.mdx` +- `docs/site/src/content/docs/reference/runtime-configuration.mdx` + +## Implementation Steps +1. Rebaseline the daemon-launch contract so `ensure_daemon_running_with_options` is no longer used implicitly by attach-only clients. Replace automatic startup paths with explicit connection attempts and clear unavailable-daemon diagnostics. +2. Update `cadder-shim` so `caddy run` only attaches to an already running daemon. If `cadderd` is unavailable, fail with a Cadder-owned operational message that explains how to start the backend manually. +3. Update `cadder-tui` startup so opening the dashboard never starts `cadderd` automatically. The TUI should boot into an offline or attach state, keep the UI responsive, and continue trying to detect and attach to the daemon in the background. +4. Add a deliberate user-triggered backend start path in the TUI. The control and messaging should clearly distinguish explicit backend start from ordinary reconnect and refresh behavior. +5. Extend the IPC client layer with a durable state-subscription path built on `subscribe-state-request`, so dashboards can observe state changes continuously after they attach rather than relying only on periodic snapshot polling. Keep polling or reconnect probes only as a fallback for daemon detection and recovery. +6. Fix daemon shutdown semantics so `ShutdownDaemonRequest` can terminate the actual `cadderd` process after replying to the client, not only stop the owned Caddy runtime. Ensure connected dashboards surface the loss of backend cleanly and can reattach after a later manual restart. +7. Preserve the existing ownership boundaries for shim registrations: dashboard disconnects must not remove registrations or stop the backend, while shim disconnect cleanup continues to remove only the registration owned by that shim session. +8. Expand automated coverage for zero-to-many dashboards and zero-to-many shims, including attach before backend startup, attach after backend startup, backend loss while dashboards are open, dashboard reconnect after backend restart, explicit backend start from the TUI, and shim failure behavior when no backend is running. +9. Update architecture and user documentation to describe the manual-daemon-first attach model, the process-role boundaries (`cadderd`, `cadder-tui`, `caddy`), and the explicit backend start and shutdown operations available to users. + +## Validation +- `cargo fmt --check` +- `cargo clippy --workspace --all-targets -- -D warnings` +- `cargo test --workspace` +- `cargo run -p xtask -- check` +- Focused: `cargo test -p cadder-daemon --test ipc_lifecycle` +- Focused: `cargo test -p cadder-tui` + +## Assumptions +- `cadder-tui` is the only dashboard delivered in this task; the future desktop `cadder` application remains out of scope but should follow the same attach-first contract later. +- `--no-start` may be retained temporarily as a compatibility alias if needed, but the resulting behavior must still be "never auto-start the daemon". +- Background daemon detection in the TUI means a lightweight reconnect or detection loop, not blocking startup and not busy-looping. +- Optional library evaluation: `whirlwind` may be considered only if subscriber or client bookkeeping becomes simpler with its concurrent sharded map or set types than with the existing Tokio and standard synchronization primitives. It is not required for this task. + +## Risks And Boundaries +- This task changes the current runtime contract from auto-start to manual-daemon-first; docs, help text, and user-facing errors must be updated consistently to avoid operator confusion. +- Do not introduce OS service managers, autostart agents, or network-remote dashboards in this task. +- Do not broaden scope into the future `cadder` desktop application implementation. +- Keep the backend single-instance per runtime directory; multiple dashboards do not imply multiple backend owners. + + +## Implementation Notes + + +Plan approved and recorded before implementation. Task rebaselined to the manual-daemon-first attach model per user approval, and the description plus acceptance criteria were updated to remove automatic dashboard or shim daemon startup. + ## Definition of Done From f45870dc1f43b20f219f0684eab4c9e89a75d0b5 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Tue, 16 Jun 2026 18:41:09 +0200 Subject: [PATCH 02/29] refactor: implement detached backend mode with explicit control for Cadder --- README.md | 11 +- ...d-backend-mode-with-multiple-dashboards.md | 56 +++- crates/cadder-daemon/src/ipc.rs | 67 ++++- crates/cadder-daemon/src/lib.rs | 4 +- crates/cadder-daemon/src/state.rs | 39 ++- crates/cadder-daemon/tests/ipc_lifecycle.rs | 114 ++++++- crates/cadder-shim/src/main.rs | 95 +++++- crates/cadder-tui/src/main.rs | 278 +++++++++++++----- crates/cadder-tui/src/model.rs | 12 +- docs/ARCHITECTURE.md | 16 +- docs/site/src/content/docs/index.mdx | 8 +- .../docs/quick-start/getting-started.mdx | 5 +- .../docs/reference/runtime-configuration.mdx | 2 + .../content/docs/user-guide/how-to-use.mdx | 12 +- .../content/docs/user-guide/path-and-shim.mdx | 4 +- .../docs/user-guide/tui-diagnostics.mdx | 8 +- docs/verification/testcontainers-e2e.md | 2 +- docs/verification/tui-smoke.md | 8 +- 18 files changed, 607 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index d52fde4..61603b5 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ Published documentation: Cadder ships three executables: -- `caddy` is the Caddy-compatible shim. For `caddy run`, it starts or connects to `cadderd`, registers the current project's Caddyfile, keeps that registration alive while the shim process runs, and unregisters on exit. Other Caddy commands are delegated to the safely resolved real Caddy binary. +- `caddy` is the Caddy-compatible shim. For `caddy run`, it attaches to a running `cadderd`, registers the current project's Caddyfile, keeps that registration alive while the shim process runs, and unregisters on exit. If the backend is unavailable, Cadder fails with guidance instead of starting it implicitly. Other Caddy commands are delegated to the safely resolved real Caddy binary. - `cadderd` is the per-user daemon. It owns local IPC, entrypoint registrations, adapted Caddy config composition, the generated effective runtime config, the real Caddy process it starts, diagnostics, and bounded log storage. -- `cadder-tui` is the terminal UI. It connects to the daemon, can start it unless `--no-start` is used, and shows overview state, entrypoints, domains, per-domain logs, diagnostics, filters, toggles, log export, and daemon shutdown. +- `cadder-tui` is the terminal UI. It attaches to the daemon, shows overview state, entrypoints, domains, per-domain logs, diagnostics, filters, toggles, log export, and daemon shutdown, and exposes an explicit `s` action when the backend needs to be started. Each executable supports `--help` and `--version`. @@ -20,8 +20,8 @@ Each executable supports `--help` and `--version`. 1. Download the latest Cadder release for your operating system from [GitHub Releases](https://github.com/MrMaxie/Cadder/releases). 2. Create `cadder.toml` next to Cadder with the path to the real Caddy binary. -3. Run a project through Cadder's `caddy` shim. -4. Open `cadder-tui` for state, domains, logs, and diagnostics. +3. Start `cadderd`, then run a project through Cadder's `caddy` shim. +4. Open `cadder-tui` for state, domains, logs, diagnostics, and explicit backend control. Full setup docs: @@ -33,10 +33,13 @@ Full setup docs: ## Commands ```sh +cadderd caddy run cadder-tui ``` +`caddy run` now requires a running Cadder backend. Start `cadderd` directly or open `cadder-tui` and press `s` before retrying the shim command. + For a local checkout, run the project checks with: ```sh diff --git a/backlog/tasks/task-1.18 - Support-detached-backend-mode-with-multiple-dashboards.md b/backlog/tasks/task-1.18 - Support-detached-backend-mode-with-multiple-dashboards.md index f63a75c..fd3ada6 100644 --- a/backlog/tasks/task-1.18 - Support-detached-backend-mode-with-multiple-dashboards.md +++ b/backlog/tasks/task-1.18 - Support-detached-backend-mode-with-multiple-dashboards.md @@ -1,11 +1,11 @@ --- id: TASK-1.18 title: Support detached backend mode with multiple dashboards -status: In Progress +status: Done assignee: - '@agent' created_date: '2026-06-10 12:04' -updated_date: '2026-06-16 16:01' +updated_date: '2026-06-16 16:35' labels: [] milestone: m-2 dependencies: @@ -14,6 +14,24 @@ dependencies: references: - docs/ARCHITECTURE.md - 'https://docs.rs/whirlwind/latest/whirlwind/' +modified_files: + - README.md + - crates/cadder-daemon/src/ipc.rs + - crates/cadder-daemon/src/lib.rs + - crates/cadder-daemon/src/state.rs + - crates/cadder-daemon/tests/ipc_lifecycle.rs + - crates/cadder-shim/src/main.rs + - crates/cadder-tui/src/main.rs + - crates/cadder-tui/src/model.rs + - docs/ARCHITECTURE.md + - docs/site/src/content/docs/index.mdx + - docs/site/src/content/docs/quick-start/getting-started.mdx + - docs/site/src/content/docs/reference/runtime-configuration.mdx + - docs/site/src/content/docs/user-guide/how-to-use.mdx + - docs/site/src/content/docs/user-guide/path-and-shim.mdx + - docs/site/src/content/docs/user-guide/tui-diagnostics.mdx + - docs/verification/testcontainers-e2e.md + - docs/verification/tui-smoke.md parent_task_id: TASK-1 priority: medium ordinal: 18000 @@ -27,13 +45,13 @@ Rebaseline Cadder's future runtime architecture around a single per-user `cadder ## Acceptance Criteria -- [ ] #1 Cadder supports a backend-only `cadderd` mode that runs independently of any interactive dashboard lifecycle and remains the only owner of the real Caddy runtime. -- [ ] #2 Multiple dashboard/TUI clients can connect to the same backend concurrently, query state, subscribe to changes, disconnect independently, and reconnect without stopping the backend or removing shim registrations. -- [ ] #3 `cadder-tui` and the `caddy` shim never auto-start `cadderd`; when the daemon is unavailable they surface explicit offline or unavailable guidance instead of starting it implicitly. -- [ ] #4 `cadder-tui` can offer a user-triggered backend start action, but that action is explicit and distinct from ordinary attach, reconnect, and refresh behavior. -- [ ] #5 CLI and UX clearly distinguish backend operation, dashboard attach, explicit backend start, and backend shutdown; `caddy run` requires a running backend and fails with a clear Cadder-owned message when `cadderd` is unavailable. -- [ ] #6 Tests cover zero-to-many entrypoints with zero-to-many dashboard clients, including late backend availability, backend loss and reconnect, dashboard disconnects, and shim behavior when no backend is running. -- [ ] #7 Architecture and user documentation explain the manual-daemon-first attach model, process-role boundaries, and when to use explicit backend start from the dashboard. +- [x] #1 Cadder supports a backend-only `cadderd` mode that runs independently of any interactive dashboard lifecycle and remains the only owner of the real Caddy runtime. +- [x] #2 Multiple dashboard/TUI clients can connect to the same backend concurrently, query state, subscribe to changes, disconnect independently, and reconnect without stopping the backend or removing shim registrations. +- [x] #3 `cadder-tui` and the `caddy` shim never auto-start `cadderd`; when the daemon is unavailable they surface explicit offline or unavailable guidance instead of starting it implicitly. +- [x] #4 `cadder-tui` can offer a user-triggered backend start action, but that action is explicit and distinct from ordinary attach, reconnect, and refresh behavior. +- [x] #5 CLI and UX clearly distinguish backend operation, dashboard attach, explicit backend start, and backend shutdown; `caddy run` requires a running backend and fails with a clear Cadder-owned message when `cadderd` is unavailable. +- [x] #6 Tests cover zero-to-many entrypoints with zero-to-many dashboard clients, including late backend availability, backend loss and reconnect, dashboard disconnects, and shim behavior when no backend is running. +- [x] #7 Architecture and user documentation explain the manual-daemon-first attach model, process-role boundaries, and when to use explicit backend start from the dashboard. ## Implementation Plan @@ -99,10 +117,26 @@ Establish Cadder's detached runtime foundation around a single manual-start per- Plan approved and recorded before implementation. Task rebaselined to the manual-daemon-first attach model per user approval, and the description plus acceptance criteria were updated to remove automatic dashboard or shim daemon startup. + +Implemented the detached manual-daemon-first contract across daemon, shim, TUI, tests, and docs. `caddy run` now requires an already running `cadderd` and returns a Cadder-owned guidance message instead of auto-starting the backend. `cadder-tui` now launches in attach-only mode, keeps `s` as an explicit backend start action, retries attach with `r`, upgrades successful attaches into a durable `subscribe-state-request` stream, and surfaces backend loss cleanly before reattaching after later availability. `ShutdownDaemonRequest` now stops the owned real Caddy runtime and terminates the actual daemon process after replying. Added IPC lifecycle coverage for multiple dashboard subscribers and daemon self-shutdown, plus shim/TUI tests for attach-only and reconnect flows. Validation run: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo run -p xtask -- check`, `cargo run -p xtask -- coverage`. Coverage gate passed at 87.37% lines (8051/9215). + +Fresh-eyes review found a shutdown-signal race in `ShutdownSignal::wait()` that could let `cadderd` miss a successful `ShutdownDaemonRequest` notification and continue accepting IPC clients. Fixed by registering the `Notify` waiter before checking the atomic shutdown flag, then reran focused IPC verification plus full `xtask` validation and coverage. Final coverage is 87.28% lines on the updated state. + +Attempted an independent Claude audit twice via `claude --print --dangerously-skip-permissions --permission-mode bypassPermissions --effort max --output-format text`. The CLI answered a trivial heartbeat prompt but both substantive audit runs stalled without producing output, so no Claude findings were available for this closeout. +## Final Summary + + +Rebased Cadder onto a manual-daemon-first runtime contract where `cadderd` is the only backend owner and both `cadder-tui` and the `caddy` shim are attach-only clients by default. The shim now fails `caddy run` with Cadder-owned startup guidance when the backend is unavailable, while the TUI starts in offline attach mode, offers an explicit `s` backend start action, upgrades successful attaches into durable `subscribe-state-request` updates, and handles backend loss plus later reattach without taking ownership of daemon lifecycle. + +Extended the daemon IPC layer with zero-to-many state subscriptions and daemon self-shutdown semantics, including stopping the real Caddy runtime and ending the daemon accept loop after `ShutdownDaemonRequest`. Added coverage for multi-dashboard subscriptions, backend shutdown behavior, attach-only shim/TUI flows, and updated architecture, user docs, and verification guides to explain the detached backend model and explicit operator actions. + +Validation on the final state: `cargo fmt --check`, `cargo test -p cadder-daemon --test ipc_lifecycle shutdown_daemon_request_stops_server_and_rejects_new_clients`, `cargo run -p xtask -- check`, `cargo run -p xtask -- coverage`. Coverage passed at 87.28% lines. Residual risk is low; the main follow-up is future live/manual smoke validation against a real backend environment if release confidence needs to go beyond the automated coverage already added. + + ## Definition of Done -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold +- [x] #1 Tests or explicit verification were run for the changed behavior +- [x] #2 Coverage was measured and remains at or above the project threshold diff --git a/crates/cadder-daemon/src/ipc.rs b/crates/cadder-daemon/src/ipc.rs index ed756f3..5e6d426 100644 --- a/crates/cadder-daemon/src/ipc.rs +++ b/crates/cadder-daemon/src/ipc.rs @@ -3,8 +3,8 @@ use anyhow::{Context, Result, anyhow}; use cadder_protocol::{ BasicResponse, HeartbeatEntrypointRequest, IpcEnvelope, QueryIisBindingsRequest, QueryLogsRequest, QueryStateRequest, RegisterEntrypointRequest, SetDomainEnabledRequest, - SetEntrypointEnabledRequest, SetIisHandoffRequest, ShutdownDaemonRequest, SubscribeStateRequest, - UnregisterEntrypointRequest, message_types, + SetEntrypointEnabledRequest, SetIisHandoffRequest, ShutdownDaemonRequest, StateChangedEvent, + SubscribeStateRequest, UnregisterEntrypointRequest, message_types, }; use fs4::{FileExt, TryLockError}; use interprocess::local_socket::{ @@ -45,9 +45,11 @@ impl DaemonServer { .try_overwrite(true) .create_tokio() .context("create local IPC listener")?; + let shutdown_signal = self.state.shutdown_signal(); loop { tokio::select! { + _ = shutdown_signal.wait() => break, changed = shutdown.changed() => { if changed.is_ok() && *shutdown.borrow() { break; @@ -294,6 +296,12 @@ impl CadderClient { CadderSession::connect_name(name, self.paths.socket_name().to_string()).await?; session.request(message_type, response_type, request).await } + + pub async fn subscribe_state(&self, request_id: String) -> Result { + let name = self.paths.socket_name().to_ns_name::()?; + let session = CadderSession::connect_name(name, self.paths.socket_name().to_string()).await?; + session.subscribe_state(request_id).await + } } #[derive(Debug)] @@ -336,9 +344,13 @@ impl CadderSession { let mut line = String::new(); self.reader.read_line(&mut line).await?; if line.is_empty() { - return Err(anyhow!( - "daemon closed the IPC connection without a response" - )); + return Err( + io::Error::new( + io::ErrorKind::UnexpectedEof, + "daemon closed the IPC connection without a response", + ) + .into(), + ); } let envelope: IpcEnvelope = serde_json::from_str(line.trim_end())?; if envelope.message_type != response_type { @@ -349,6 +361,51 @@ impl CadderSession { } Ok(envelope.decode()?) } + + pub async fn subscribe_state(self, request_id: String) -> Result { + let mut subscription = StateSubscription { + reader: self.reader, + writer: self.writer, + }; + write_envelope( + &mut subscription.writer, + message_types::SUBSCRIBE_STATE_REQUEST, + &SubscribeStateRequest { request_id }, + ) + .await?; + Ok(subscription) + } +} + +#[derive(Debug)] +pub struct StateSubscription { + reader: BufReader>, + writer: tokio::io::WriteHalf, +} + +impl StateSubscription { + pub async fn next_event(&mut self) -> Result { + let mut line = String::new(); + self.reader.read_line(&mut line).await?; + if line.is_empty() { + return Err( + io::Error::new( + io::ErrorKind::UnexpectedEof, + "daemon closed the state subscription", + ) + .into(), + ); + } + let envelope: IpcEnvelope = serde_json::from_str(line.trim_end())?; + if envelope.message_type != message_types::STATE_CHANGED_EVENT { + return Err(anyhow!( + "unexpected response type `{}`, expected `{}`", + envelope.message_type, + message_types::STATE_CHANGED_EVENT + )); + } + Ok(envelope.decode()?) + } } #[derive(Debug, Clone, Default)] diff --git a/crates/cadder-daemon/src/lib.rs b/crates/cadder-daemon/src/lib.rs index 6d14733..57a9a54 100644 --- a/crates/cadder-daemon/src/lib.rs +++ b/crates/cadder-daemon/src/lib.rs @@ -11,8 +11,8 @@ pub use caddy::{CaddyConfigAdapter, CaddyConfigCoordinator, RealCaddyResolver}; pub use config::{CONFIG_FILE_NAME, CadderConfig, CaddyRuntimeConfig}; pub use iis::{IisBindingRecord, IisMetadataStore, IisProvider}; pub use ipc::{ - CadderClient, CadderSession, DaemonLaunchOptions, DaemonServer, ensure_daemon_running, - ensure_daemon_running_with_options, + CadderClient, CadderSession, DaemonLaunchOptions, DaemonServer, StateSubscription, + ensure_daemon_running, ensure_daemon_running_with_options, }; pub use logs::{CaddyLogStore, Redactor}; pub use paths::{DaemonLock, RuntimePaths}; diff --git a/crates/cadder-daemon/src/state.rs b/crates/cadder-daemon/src/state.rs index d0c9857..0004163 100644 --- a/crates/cadder-daemon/src/state.rs +++ b/crates/cadder-daemon/src/state.rs @@ -21,9 +21,12 @@ use cadder_protocol::{ use chrono::Utc; use std::{ collections::{BTreeMap, BTreeSet}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, }; -use tokio::sync::{Mutex, broadcast}; +use tokio::sync::{Mutex, Notify, broadcast}; #[derive(Debug, Clone)] pub struct DaemonState { @@ -33,6 +36,7 @@ pub struct DaemonState { iis_provider: IisProvider, iis_store: IisMetadataStore, iis_operation: Arc>, + shutdown_signal: ShutdownSignal, } #[derive(Debug)] @@ -42,6 +46,29 @@ struct DaemonInner { sequence: u64, } +#[derive(Debug, Clone, Default)] +pub(crate) struct ShutdownSignal { + requested: Arc, + notify: Arc, +} + +impl ShutdownSignal { + fn request(&self) { + if !self.requested.swap(true, Ordering::SeqCst) { + self.notify.notify_waiters(); + } + } + + pub async fn wait(&self) { + let notified = self.notify.notified(); + tokio::pin!(notified); + if self.requested.load(Ordering::SeqCst) { + return; + } + notified.await; + } +} + impl DaemonState { pub fn new(coordinator: CaddyConfigCoordinator) -> Self { let (events, _) = broadcast::channel(256); @@ -56,6 +83,7 @@ impl DaemonState { iis_provider: IisProvider::system(), iis_store: IisMetadataStore::memory(), iis_operation: Arc::new(Mutex::new(())), + shutdown_signal: ShutdownSignal::default(), } } @@ -94,6 +122,10 @@ impl DaemonState { self.events.subscribe() } + pub(crate) fn shutdown_signal(&self) -> ShutdownSignal { + self.shutdown_signal.clone() + } + pub fn logs(&self) -> CaddyLogStore { self.logs.clone() } @@ -377,6 +409,9 @@ impl DaemonState { pub async fn shutdown(&self) -> BasicResponse { let mut inner = self.inner.lock().await; let result = inner.coordinator.shutdown().await; + if result.is_ok() { + self.shutdown_signal.request(); + } BasicResponse { request_id: "shutdown".to_string(), accepted: result.is_ok(), diff --git a/crates/cadder-daemon/tests/ipc_lifecycle.rs b/crates/cadder-daemon/tests/ipc_lifecycle.rs index e92c2a0..ce43305 100644 --- a/crates/cadder-daemon/tests/ipc_lifecycle.rs +++ b/crates/cadder-daemon/tests/ipc_lifecycle.rs @@ -8,7 +8,7 @@ use cadder_protocol::{ LogStreamIdentity, LogStreamStatus, OwnerProcessIdentity, QueryLogsRequest, QueryLogsResponse, QueryStateRequest, QueryStateResponse, RegisterEntrypointRequest, RegisterEntrypointResponse, SetDomainEnabledRequest, SetEntrypointEnabledRequest, ShimRunMetadata, SourcePath, - UnregisterEntrypointRequest, message_types, new_request_id, + StateChangeKind, UnregisterEntrypointRequest, message_types, new_request_id, }; use chrono::Utc; use std::{ @@ -30,6 +30,80 @@ async fn ipc_lifecycle_starts_with_zero_registrations() { harness.shutdown().await; } +#[tokio::test] +async fn ipc_state_subscriptions_broadcast_updates_to_multiple_dashboard_clients() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + let mut first = harness + .client + .subscribe_state(new_request_id("dashboard-1")) + .await + .unwrap(); + let mut second = harness + .client + .subscribe_state(new_request_id("dashboard-2")) + .await + .unwrap(); + + let first_initial = tokio::time::timeout(Duration::from_secs(1), first.next_event()) + .await + .unwrap() + .unwrap(); + let second_initial = tokio::time::timeout(Duration::from_secs(1), second.next_event()) + .await + .unwrap() + .unwrap(); + assert_eq!(first_initial.change_kind, StateChangeKind::Snapshot); + assert_eq!(second_initial.change_kind, StateChangeKind::Snapshot); + assert!(first_initial.snapshot.registrations.is_empty()); + assert!(second_initial.snapshot.registrations.is_empty()); + + let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + assert!( + register_on_session( + &mut session, + registration("shim-1", "nonce-1", &harness.config_path) + ) + .await + .accepted + ); + + let first_update = tokio::time::timeout(Duration::from_secs(1), first.next_event()) + .await + .unwrap() + .unwrap(); + let second_update = tokio::time::timeout(Duration::from_secs(1), second.next_event()) + .await + .unwrap() + .unwrap(); + assert_eq!( + first_update.change_kind, + StateChangeKind::RegistrationsChanged + ); + assert_eq!( + second_update.change_kind, + StateChangeKind::RegistrationsChanged + ); + assert_eq!(first_update.snapshot.registrations.len(), 1); + assert_eq!(second_update.snapshot.registrations.len(), 1); + + drop(first); + let toggle = + set_domain_enabled(&harness.client, "shim-1", "api.smarketing.localhost", false).await; + assert!(toggle.accepted, "{}", toggle.message); + + let second_follow_up = tokio::time::timeout(Duration::from_secs(1), second.next_event()) + .await + .unwrap() + .unwrap(); + assert_eq!( + second_follow_up.change_kind, + StateChangeKind::RegistrationsChanged + ); + assert_eq!(second_follow_up.snapshot.registrations.len(), 1); + harness.shutdown().await; +} + #[tokio::test] async fn ipc_lifecycle_registers_one_shim_and_applies_config() { let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); @@ -50,6 +124,44 @@ async fn ipc_lifecycle_registers_one_shim_and_applies_config() { harness.shutdown().await; } +#[tokio::test] +async fn shutdown_daemon_request_stops_server_and_rejects_new_clients() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + + let response: BasicResponse = harness + .client + .request( + message_types::SHUTDOWN_DAEMON_REQUEST, + message_types::SHUTDOWN_DAEMON_RESPONSE, + &cadder_protocol::ShutdownDaemonRequest { + request_id: new_request_id("shutdown-daemon"), + }, + ) + .await + .unwrap(); + assert!(response.accepted, "{}", response.message); + + for _ in 0..50 { + let result = harness + .client + .request::<_, QueryStateResponse>( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("after-shutdown"), + }, + ) + .await; + if result.is_err() { + return; + } + sleep(Duration::from_millis(20)).await; + } + + panic!("daemon server still accepted new IPC clients after shutdown request"); +} + #[tokio::test] async fn ipc_lifecycle_supports_many_registrations_and_owner_cleanup() { let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); diff --git a/crates/cadder-shim/src/main.rs b/crates/cadder-shim/src/main.rs index f3ec0f9..3a3eba2 100644 --- a/crates/cadder-shim/src/main.rs +++ b/crates/cadder-shim/src/main.rs @@ -1,8 +1,5 @@ use anyhow::{Context, Result, anyhow}; -use cadder_daemon::{ - CadderSession, DaemonLaunchOptions, RealCaddyResolver, RuntimePaths, - ensure_daemon_running_with_options, -}; +use cadder_daemon::{CadderSession, RealCaddyResolver, RuntimePaths}; use cadder_protocol::{ ActivationState, BasicResponse, EntrypointInstanceIdentity, EntrypointRegistration, HeartbeatEntrypointRequest, LogStreamIdentity, OwnerProcessIdentity, RegisterEntrypointRequest, @@ -24,7 +21,7 @@ use tokio::{process::Command, sync::Mutex, time::interval}; name = "caddy", version, about = "Cadder PATH-facing Caddy shim", - long_about = "Acts as the Cadder-managed caddy command. `caddy run` starts or connects to cadderd and registers the current project; other commands are delegated to the safely resolved real Caddy binary." + long_about = "Acts as the Cadder-managed caddy command. `caddy run` requires a running cadderd backend and registers the current project; other commands are delegated to the safely resolved real Caddy binary." )] struct ShimArgs { #[arg(long = "cadder-runtime-dir", hide = true)] @@ -73,16 +70,13 @@ async fn main() -> Result { async fn run_managed(args: ShimArgs) -> Result { let paths = RuntimePaths::resolve(args.runtime_dir)?; - ensure_daemon_running_with_options( - &paths, - DaemonLaunchOptions { - explicit_daemon: args.daemon_path, - real_caddy_command: args.real_caddy_command, - shim_path: env::current_exe().ok(), - }, - ) - .await?; - let session = std::sync::Arc::new(Mutex::new(CadderSession::connect(&paths).await?)); + let session = match CadderSession::connect(&paths).await { + Ok(session) => std::sync::Arc::new(Mutex::new(session)), + Err(error) => { + eprintln!("{}", managed_backend_unavailable_message(&paths, &error)); + return Ok(ExitCode::FAILURE); + } + }; let registration = build_registration(&args.caddy_args)?; let registration_id = registration.registration_id.clone(); let shim_session_nonce = registration.entrypoint_instance.shim_session_nonce.clone(); @@ -148,6 +142,44 @@ async fn run_managed(args: ShimArgs) -> Result { Ok(ExitCode::SUCCESS) } +fn managed_backend_unavailable_message(paths: &RuntimePaths, error: &anyhow::Error) -> String { + let runtime_dir = paths.runtime_dir().display(); + let retry = format!( + "Start `cadderd --runtime-dir \"{}\"` explicitly, or open `cadder-tui` and press s, then retry `caddy run`.", + runtime_dir + ); + + if daemon_error_indicates_not_running(error) { + format!("Cadder backend `cadderd` is not running for runtime `{runtime_dir}`. {retry}") + } else { + format!( + "Cadder could not attach `caddy run` to backend runtime `{runtime_dir}`: {}. {retry}", + format_error_chain(error) + ) + } +} + +fn daemon_error_indicates_not_running(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + cause.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::UnexpectedEof + ) + }) + }) +} + +fn format_error_chain(error: &anyhow::Error) -> String { + let mut messages = error.chain().map(ToString::to_string).collect::>(); + messages.dedup(); + messages.join(": ") +} + fn build_registration(args: &[String]) -> Result { let now = Utc::now(); let cwd = env::current_dir().context("resolve current directory")?; @@ -352,4 +384,37 @@ mod tests { assert_eq!(code, ExitCode::FAILURE); } + + #[test] + fn managed_backend_unavailable_message_explains_manual_backend_start() { + let paths = RuntimePaths::resolve(Some(std::env::temp_dir().join("cadder-shim-test"))).unwrap(); + let error = anyhow::Error::from(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "connection refused", + )); + + let message = managed_backend_unavailable_message(&paths, &error); + + assert!(message.contains("Cadder backend `cadderd` is not running")); + assert!(message.contains("Start `cadderd --runtime-dir")); + assert!(message.contains("retry `caddy run`")); + } + + #[tokio::test] + async fn run_managed_returns_failure_when_backend_is_unavailable() { + let runtime_dir = std::env::temp_dir().join(format!( + "cadder-shim-missing-backend-{}", + std::process::id() + )); + let code = run_managed(ShimArgs { + runtime_dir: Some(runtime_dir), + daemon_path: None, + real_caddy_command: None, + caddy_args: vec!["run".to_string()], + }) + .await + .unwrap(); + + assert_eq!(code, ExitCode::FAILURE); + } } diff --git a/crates/cadder-tui/src/main.rs b/crates/cadder-tui/src/main.rs index 8e473ce..c32de9c 100644 --- a/crates/cadder-tui/src/main.rs +++ b/crates/cadder-tui/src/main.rs @@ -8,7 +8,7 @@ use cadder_protocol::{ ActivationState, BasicResponse, ConfigApplyStatus, GuiStateSnapshot, LogEntry, LogSeverity, LogStreamIdentity, LogStreamStatus, QueryLogsRequest, QueryLogsResponse, QueryStateRequest, QueryStateResponse, RuntimeStatus, SetDomainEnabledRequest, SetEntrypointEnabledRequest, - ShutdownDaemonRequest, message_types, new_request_id, + ShutdownDaemonRequest, StateChangedEvent, message_types, new_request_id, }; #[cfg(windows)] use cadder_protocol::{ @@ -33,14 +33,14 @@ use tokio::sync::mpsc; const STATE_REFRESH_INTERVAL: Duration = Duration::from_secs(2); const LOG_REFRESH_INTERVAL: Duration = Duration::from_millis(750); -const DAEMON_START_MESSAGE: &str = "Starting cadderd and reconnecting."; +const DAEMON_START_MESSAGE: &str = "Starting cadderd."; #[derive(Debug, Parser)] #[command( name = "cadder-tui", version, about = "Cadder terminal UI for daemon state, domains, logs, and diagnostics", - long_about = "Opens the Cadder terminal UI. It connects to cadderd, starts it by default, and displays entrypoints, domains, logs, diagnostics, activation controls, and daemon shutdown." + long_about = "Opens the Cadder terminal UI. It attaches to an existing cadderd backend, displays entrypoints, domains, logs, diagnostics, activation controls, and offers an explicit start action when the backend is unavailable." )] struct Args { #[arg( @@ -49,18 +49,21 @@ struct Args { )] runtime_dir: Option, - #[arg(long, help = "Path to a cadderd executable to start when needed")] + #[arg( + long, + help = "Path to a cadderd executable for the explicit backend start action" + )] daemon_path: Option, #[arg( long, - help = "Command or path passed to cadderd for starting the real Caddy binary" + help = "Command or path passed to cadderd when explicitly starting the real Caddy binary" )] real_caddy_command: Option, #[arg( long, - help = "Connect to an existing daemon instead of starting cadderd" + help = "Deprecated compatibility alias; the dashboard is attach-only by default and never auto-starts cadderd" )] no_start: bool, } @@ -78,10 +81,9 @@ async fn main() -> Result<()> { }; let mut app = TuiApp::new(paths, launch_options); if no_start { - app.start_state_refresh(); - } else { - app.start_daemon(); + app.message = "`--no-start` is now the default attach-only behavior.".to_string(); } + app.start_state_refresh(); let terminal = ratatui::init(); let result = app.run(terminal).await; @@ -100,6 +102,8 @@ struct TuiApp { state_request_in_flight: bool, state_refresh_pending: bool, state_request_serial: u64, + state_subscription_running: bool, + state_subscription_generation: u64, daemon_start_in_flight: bool, toggle_request_in_flight: bool, shutdown_request_in_flight: bool, @@ -115,7 +119,15 @@ enum AppResponse { serial: u64, result: Result, }, - DaemonStart(Result), + StateEvent { + generation: u64, + event: StateChangedEvent, + }, + StateSubscriptionClosed { + generation: u64, + issue: DaemonConnectionIssue, + }, + DaemonStart(Result<(), String>), Toggle(Result), #[cfg(windows)] IisBindings(Result), @@ -151,6 +163,8 @@ impl TuiApp { state_request_in_flight: false, state_refresh_pending: false, state_request_serial: 0, + state_subscription_running: false, + state_subscription_generation: 0, daemon_start_in_flight: false, toggle_request_in_flight: false, shutdown_request_in_flight: false, @@ -295,14 +309,28 @@ impl TuiApp { } self.start_pending_state_refresh(); } + AppResponse::StateEvent { generation, event } => { + if generation != self.state_subscription_generation { + continue; + } + self.state_subscription_running = true; + self.apply_state_event(event); + } + AppResponse::StateSubscriptionClosed { generation, issue } => { + if generation != self.state_subscription_generation { + continue; + } + self.state_subscription_running = false; + if !self.daemon_start_in_flight { + self.apply_daemon_issue("Daemon unavailable", issue); + } + } AppResponse::DaemonStart(result) => { self.daemon_start_in_flight = false; match result { - Ok(response) => { - self.apply_state_response(response); - if self.model.can_use_daemon() { - self.message = "cadderd started and connected.".to_string(); - } + Ok(()) => { + self.message = "cadderd started. Attaching dashboard.".to_string(); + self.start_state_subscription(); } Err(error) => { self.model.mark_daemon_start_failed(error.clone()); @@ -316,7 +344,7 @@ impl TuiApp { match result { Ok(response) => { self.message = response.message; - self.start_state_refresh(); + self.refresh_state_after_mutation(); } Err(error) => self.apply_daemon_request_error("Toggle failed", error), } @@ -347,7 +375,7 @@ impl TuiApp { Ok(response) => { self.message = iis_handoff_message(&response); self.start_iis_refresh(); - self.start_state_refresh(); + self.refresh_state_after_mutation(); } Err(error) => self.apply_daemon_request_error("IIS handoff failed", error), } @@ -388,6 +416,8 @@ impl TuiApp { self.shutdown_request_in_flight = false; match result { Ok(response) => { + self.state_subscription_generation += 1; + self.state_subscription_running = false; self.model.mark_daemon_unavailable( DaemonUnavailableKind::NotRunning, "Daemon shutdown was requested.", @@ -417,10 +447,7 @@ impl TuiApp { fn maybe_refresh_state(&mut self) { if self.daemon_start_in_flight || self.state_request_in_flight - || matches!( - self.model.daemon.state, - model::DaemonConnectionState::StartFailed { .. } - ) + || (self.model.can_use_daemon() && self.state_subscription_running) || self.last_state_refresh.elapsed() < STATE_REFRESH_INTERVAL { return; @@ -430,14 +457,10 @@ impl TuiApp { fn apply_state_response(&mut self, response: QueryStateResponse) { if let Some(snapshot) = response.snapshot { - #[cfg(windows)] - let was_connected = self.model.can_use_daemon(); - self.model.set_snapshot(snapshot); - self.model.mark_daemon_connected(); + self.apply_snapshot(snapshot); self.message = response.message; - #[cfg(windows)] - if !was_connected { - self.start_iis_refresh(); + if !self.state_subscription_running { + self.start_state_subscription(); } } else { let message = "Daemon returned no state snapshot."; @@ -450,7 +473,7 @@ impl TuiApp { fn start_state_refresh(&mut self) { if self.daemon_start_in_flight { - self.message = "Daemon start is already in progress.".to_string(); + self.message = "Backend start is already in progress.".to_string(); return; } if self.state_request_in_flight { @@ -479,6 +502,47 @@ impl TuiApp { }); } + fn start_state_subscription(&mut self) { + if self.state_subscription_running { + return; + } + self.state_subscription_running = true; + self.state_subscription_generation += 1; + let generation = self.state_subscription_generation; + let client = self.client.clone(); + let responses = self.responses_tx.clone(); + tokio::spawn(async move { + let mut subscription = match client + .subscribe_state(new_request_id("tui-subscribe")) + .await + { + Ok(subscription) => subscription, + Err(error) => { + let _ = responses.send(AppResponse::StateSubscriptionClosed { + generation, + issue: classify_daemon_error(error), + }); + return; + } + }; + + loop { + match subscription.next_event().await { + Ok(event) => { + let _ = responses.send(AppResponse::StateEvent { generation, event }); + } + Err(error) => { + let _ = responses.send(AppResponse::StateSubscriptionClosed { + generation, + issue: classify_daemon_error(error), + }); + return; + } + } + } + }); + } + fn start_pending_state_refresh(&mut self) { if !self.state_refresh_pending { return; @@ -488,8 +552,12 @@ impl TuiApp { } fn start_daemon(&mut self) { + if self.model.can_use_daemon() { + self.message = "Dashboard is already attached to cadderd.".to_string(); + return; + } if !self.model.can_start_daemon() || self.daemon_start_in_flight { - self.message = "Daemon start is already in progress.".to_string(); + self.message = "Backend start is already in progress.".to_string(); return; } self.daemon_start_in_flight = true; @@ -500,26 +568,12 @@ impl TuiApp { self.message = DAEMON_START_MESSAGE.to_string(); let paths = self.paths.clone(); let launch_options = self.launch_options.clone(); - let client = self.client.clone(); let responses = self.responses_tx.clone(); tokio::spawn(async move { - let result = async { - ensure_daemon_running_with_options(&paths, launch_options) - .await - .context("start cadderd")?; - client - .request( - message_types::QUERY_STATE_REQUEST, - message_types::QUERY_STATE_RESPONSE, - &QueryStateRequest { - request_id: new_request_id("tui-state"), - }, - ) - .await - .context("refresh daemon state after start") - } - .await - .map_err(|error| format_error_chain(&error)); + let result = ensure_daemon_running_with_options(&paths, launch_options) + .await + .context("start cadderd") + .map_err(|error| format_error_chain(&error)); let _ = responses.send(AppResponse::DaemonStart(result)); }); } @@ -820,7 +874,10 @@ impl TuiApp { self .model .mark_daemon_unavailable(issue.kind, issue.message.clone()); - self.message = format!("{prefix}: {}. Press s to start/reconnect.", issue.message); + self.message = format!( + "{prefix}: {}. Press r to retry attach or s to start cadderd.", + issue.message + ); } fn apply_daemon_request_error(&mut self, prefix: &str, error: String) { @@ -858,6 +915,29 @@ impl TuiApp { self.message = format!("Exported {}", output_path.display()); Ok(()) } + + fn apply_snapshot(&mut self, snapshot: GuiStateSnapshot) -> bool { + let was_connected = self.model.can_use_daemon(); + self.model.set_snapshot(snapshot); + self.model.mark_daemon_connected(); + #[cfg(windows)] + if !was_connected { + self.start_iis_refresh(); + } + !was_connected + } + + fn apply_state_event(&mut self, event: StateChangedEvent) { + if self.apply_snapshot(event.snapshot) { + self.message = "Attached to cadderd.".to_string(); + } + } + + fn refresh_state_after_mutation(&mut self) { + if !self.state_subscription_running { + self.start_state_refresh(); + } + } } enum ToggleRequest { @@ -867,7 +947,7 @@ enum ToggleRequest { fn daemon_action_unavailable(action: &str) -> String { format!( - "{action} requires a connected daemon. Press s to start/reconnect, r to retry, or q to quit." + "{action} requires a connected daemon. Press r to retry attach, s to start cadderd, or q to quit." ) } @@ -932,7 +1012,11 @@ fn classify_daemon_error(error: anyhow::Error) -> DaemonConnectionIssue { cause.downcast_ref::().is_some_and(|error| { matches!( error.kind(), - io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused + io::ErrorKind::NotFound + | io::ErrorKind::ConnectionRefused + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::ConnectionReset + | io::ErrorKind::UnexpectedEof ) }) }) { @@ -1201,8 +1285,7 @@ impl TuiApp { iis_route_host, describe_log_severity(self.model.logs.minimum_severity) ); - let navigation_help = - "Tab/Shift+Tab/Left/Right views r refresh s start/reconnect f search Space toggle/apply"; + let navigation_help = "Tab/Shift+Tab/Left/Right views r attach/refresh s start backend f search Space toggle/apply"; #[cfg(windows)] let action_help = "IIS: / route host Enter refresh Space handoff/restore Settings: Up/Down severity Enter apply Logs: p pause Enter refresh x export d shutdown q quit"; #[cfg(not(windows))] @@ -1765,7 +1848,7 @@ mod tests { "long help output should describe --daemon-path: {help}" ); assert!( - help.contains("Connect to an existing daemon"), + help.contains("attach-only by default"), "long help output should describe --no-start: {help}" ); } @@ -1836,7 +1919,7 @@ mod tests { assert!(text.contains("Daemon")); assert!(text.contains("Not running")); assert!(text.contains("Press s to start")); - assert!(text.contains("s start/reconnect")); + assert!(text.contains("s start backend")); } #[test] @@ -2395,26 +2478,40 @@ mod tests { } #[tokio::test] - async fn daemon_start_response_marks_connected_after_valid_snapshot() { + async fn daemon_start_response_starts_attach_flow_and_applies_subscription_snapshot() { let mut app = test_app(); app.daemon_start_in_flight = true; app.model.mark_daemon_starting(DAEMON_START_MESSAGE); app .responses_tx - .send(AppResponse::DaemonStart(Ok(QueryStateResponse { - request_id: "state".to_string(), - accepted: true, - message: "connected".to_string(), - snapshot: Some(snapshot()), - }))) + .send(AppResponse::DaemonStart(Ok(()))) .unwrap(); app.drain_responses(); assert!(!app.daemon_start_in_flight); + assert!(app.state_subscription_running); + assert_eq!(app.message, "cadderd started. Attaching dashboard."); + + app + .responses_tx + .send(AppResponse::StateEvent { + generation: app.state_subscription_generation, + event: StateChangedEvent { + request_id: "subscribe".to_string(), + sequence_number: 0, + change_kind: cadder_protocol::StateChangeKind::Snapshot, + snapshot: snapshot(), + registration_id: None, + }, + }) + .unwrap(); + + app.drain_responses(); + assert!(app.model.can_use_daemon()); assert_eq!(app.model.daemon.state.label(), "Connected"); - assert_eq!(app.message, "cadderd started and connected."); + assert_eq!(app.message, "Attached to cadderd."); } #[tokio::test] @@ -2438,7 +2535,7 @@ mod tests { assert_eq!(app.model.daemon.state.label(), "Starting"); app.start_daemon(); - assert_eq!(app.message, "Daemon start is already in progress."); + assert_eq!(app.message, "Backend start is already in progress."); drain_until(&mut app, |app| !app.daemon_start_in_flight).await; @@ -2448,8 +2545,13 @@ mod tests { app.last_state_refresh = Instant::now() - STATE_REFRESH_INTERVAL - Duration::from_millis(10); app.maybe_refresh_state(); - assert!(!app.state_request_in_flight); - assert_eq!(app.model.daemon.state.label(), "Start failed"); + assert!(app.state_request_in_flight); + drain_until(&mut app, |app| !app.state_request_in_flight).await; + assert!(matches!( + app.model.daemon.state, + model::DaemonConnectionState::NotRunning { .. } + | model::DaemonConnectionState::ConnectionFailed { .. } + )); } #[tokio::test] @@ -2466,6 +2568,49 @@ mod tests { assert!(!app.model.can_use_daemon()); } + #[tokio::test] + async fn subscription_loss_marks_dashboard_unavailable_and_manual_attach_recovers() { + let mut app = test_app(); + app.state_subscription_running = true; + app.state_subscription_generation = 2; + + app + .responses_tx + .send(AppResponse::StateSubscriptionClosed { + generation: 2, + issue: DaemonConnectionIssue { + kind: DaemonUnavailableKind::NotRunning, + message: "backend exited".to_string(), + }, + }) + .unwrap(); + + app.drain_responses(); + + assert!(!app.model.can_use_daemon()); + assert!(app.message.contains("retry attach")); + + app.state_request_serial = 4; + app.state_request_in_flight = true; + app + .responses_tx + .send(AppResponse::State { + serial: 4, + result: Ok(QueryStateResponse { + request_id: "reconnect".to_string(), + accepted: true, + message: "attached".to_string(), + snapshot: Some(snapshot()), + }), + }) + .unwrap(); + + app.drain_responses(); + + assert!(app.model.can_use_daemon()); + assert!(app.state_subscription_running); + } + #[tokio::test] async fn maybe_refresh_state_starts_after_interval_without_overlap() { let mut app = test_app(); @@ -2698,6 +2843,7 @@ mod tests { #[test] fn apply_state_response_uses_snapshot_or_guidance_message() { let mut app = test_app(); + app.state_subscription_running = true; app.apply_state_response(QueryStateResponse { request_id: "state".to_string(), diff --git a/crates/cadder-tui/src/model.rs b/crates/cadder-tui/src/model.rs index 85c5145..2c1fafc 100644 --- a/crates/cadder-tui/src/model.rs +++ b/crates/cadder-tui/src/model.rs @@ -153,13 +153,17 @@ impl DaemonConnectionState { pub fn guidance(&self) -> &'static str { match self { - Self::Connected => "Use r to refresh state or d to request daemon shutdown.", + Self::Connected => { + "Use r to refresh or reattach the dashboard, or d to request backend shutdown." + } Self::Starting { .. } => "Starting cadderd; controls stay responsive.", - Self::NotRunning { .. } => "Press s to start cadderd, r to retry connection, or q to quit.", + Self::NotRunning { .. } => "Press s to start cadderd, r to retry attach, or q to quit.", Self::ConnectionFailed { .. } => { - "Press s to start/reconnect, r to retry connection, or q to quit." + "Press r to retry attach, s to start cadderd if needed, or q to quit." + } + Self::StartFailed { .. } => { + "Fix the backend start error, then press s to retry or r to attach if cadderd is already running." } - Self::StartFailed { .. } => "Fix the start error, then press s to retry or q to quit.", } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d269997..a36becc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,8 +5,8 @@ Cadder is a cross-platform Rust daemon, shim, and terminal UI for routing projec ## Process Roles - `cadderd`: per-user daemon. It owns registrations, local IPC, Caddyfile adaptation, effective Caddy config composition, the Cadder-owned real Caddy process, runtime diagnostics, and bounded log storage. -- `caddy`: PATH-facing shim. It intentionally shadows Caddy for managed `caddy run` commands, starts or connects to `cadderd`, registers the caller's config, heartbeats while alive, and unregisters on exit. -- `cadder-tui`: Ratatui/Crossterm UI. It connects to the daemon and shows overview, entrypoints, grouped domains, per-domain logs, diagnostics, search/filtering, and activation toggles. +- `caddy`: PATH-facing shim. It intentionally shadows Caddy for managed `caddy run` commands, attaches to an already running `cadderd`, registers the caller's config, heartbeats while alive, and unregisters on exit. +- `cadder-tui`: Ratatui/Crossterm UI. It attaches to the daemon and shows overview, entrypoints, grouped domains, per-domain logs, diagnostics, search/filtering, activation toggles, and explicit backend start or shutdown actions. - Real Caddy runtime: resolved external binary. Cadder never embeds Caddy and must not recursively execute its own shim. All three Cadder binaries expose `--help` and `--version` through their Clap command definitions. The release-facing command names are `cadderd`, `caddy`, and `cadder-tui`. @@ -20,7 +20,7 @@ Cadder uses per-user runtime paths from `directories::ProjectDirs`, with `CADDER - an effective generated Caddy JSON config file; - daemon metadata and bounded in-memory state. -The daemon is started on demand by `caddy` and `cadder-tui` unless callers opt out. Launch attempts are serialized with a per-runtime launch lock before spawning `cadderd`; concurrent callers wait for the daemon socket instead of spawning another child, while the daemon's own runtime lock remains the final ownership guard. `cadder-tui` treats daemon startup and the first state query as recoverable background work: if the daemon is not running, cannot be reached, or fails to start, the terminal UI still opens and shows an explicit recovery state with retry, start/reconnect, and quit controls. OS services are intentionally deferred; v1 is a user daemon model. +`cadderd` is a manually started per-user backend. The `caddy` shim and `cadder-tui` are attach-only clients: they connect to an existing daemon when it is available, surface explicit unavailable guidance when it is not, and never spawn it as an implicit side effect of opening a dashboard or running `caddy run`. Explicit backend start is limited to direct `cadderd` execution or deliberate client actions such as pressing `s` in `cadder-tui`. Explicit launch attempts still use a per-runtime launch lock before spawning `cadderd`, so concurrent start requests wait for the daemon socket instead of spawning another child, while the daemon's own runtime lock remains the final ownership guard. OS services are intentionally deferred; v1 remains a user daemon model. ## IPC Boundary @@ -61,7 +61,7 @@ The TOML schema is: real_command = "/absolute/path/to/caddy" ``` -Cadder no longer treats `caddy-real` as a built-in default. Users may still configure `caddy-real` explicitly through CLI, TOML, or environment variables. PATH fallback excludes the current executable and the known shim path from `CADDER_CADDY_SHIM_PATH`; when the shim starts the daemon, it passes its own path through that variable. +Cadder no longer treats `caddy-real` as a built-in default. Users may still configure `caddy-real` explicitly through CLI, TOML, or environment variables. PATH fallback excludes the current executable and the known shim path from `CADDER_CADDY_SHIM_PATH`, so Cadder does not resolve its own shim as real Caddy. For each registration, Cadder runs: @@ -93,13 +93,13 @@ After planning the backend binding, Cadder removes the original public binding, `cadder-tui` exposes per-domain logs from the Domains view. Pressing `Enter` or `l` on a domain row opens a log-focused view bound to that domain's `LogStreamIdentity`; the view keeps the selected stream even if registrations change later, so it never silently falls back to an entrypoint or unrelated domain. -The TUI refreshes daemon state snapshots automatically on a short fixed interval while retaining `r` for explicit refreshes. It also loads a bounded log page through `query-logs-request`, stores the returned cursor, and tails by issuing follow-up requests with that cursor. Auto-tail is enabled by default and can be paused with `p`; while paused, the TUI keeps keyboard handling responsive and avoids automatic log refreshes until the user resumes or manually refreshes with `Enter`. Log severity is primarily controlled from the Settings view; applying a new severity resets the cursor before reloading so entries from different filters are not mixed. +The TUI attempts to attach without spawning the daemon, upgrades successful attaches into a durable `subscribe-state-request` stream, and keeps periodic `query-state-request` probes only for late daemon availability and recovery after backend loss. It also loads a bounded log page through `query-logs-request`, stores the returned cursor, and tails by issuing follow-up requests with that cursor. Auto-tail is enabled by default and can be paused with `p`; while paused, the TUI keeps keyboard handling responsive and avoids automatic log refreshes until the user resumes or manually refreshes with `Enter`. Log severity is primarily controlled from the Settings view; applying a new severity resets the cursor before reloading so entries from different filters are not mixed. On Windows, `cadder-tui` also exposes an IIS Handoff view. The view lists site, protocol, IP address, port, host header, handoff state, and safety details. Pressing `Enter` refreshes IIS discovery; pressing `Space` hands off an available binding or restores a handed-off binding. Before dispatching a mixed-elevation action, the TUI states why administrator approval may be requested. After the daemon responds, the status line summarizes succeeded, approved, denied, failed, and follow-up operation steps. For wildcard or empty-host IIS rows, press `/`, type the route host or URL, press `Enter` to keep the value, then press `Space`. The view is absent on non-Windows platforms rather than shown as a disabled placeholder. -Log refresh, state refresh, IIS discovery, activation toggles, IIS handoff actions, and shutdown requests are dispatched as short background IPC tasks. The TUI accepts at most one active state refresh and one active log refresh for the current stream, and surfaces IPC failures as a read-error state rather than blocking terminal input. +Log refresh, late-availability probes, IIS discovery, activation toggles, IIS handoff actions, and shutdown requests are dispatched as short background IPC tasks. The TUI accepts at most one active state probe, one active state subscription stream, and one active log refresh for the current stream, and surfaces IPC failures as a read-error state rather than blocking terminal input. -When the daemon is unavailable, the Overview view shows the daemon state as not running, connection failed, starting, or start failed. Press `s` to start `cadderd` through the same `ensure_daemon_running_with_options` path used by the shim, using the TUI's runtime directory, daemon path, and real Caddy command options. Press `r` to retry a state refresh without spawning a daemon. Daemon-dependent actions such as activation toggles, log refreshes, IIS discovery/handoff, and shutdown are gated until a valid state snapshot marks the daemon connected; navigation, retry, start/reconnect, settings, existing log export, and quit remain responsive. +When the daemon is unavailable, the Overview view shows the daemon state as not running, connection failed, starting, or start failed. Press `s` to explicitly start `cadderd` with the TUI's runtime directory, daemon path, and real Caddy command options. Press `r` to retry attach or refresh without spawning a daemon. Daemon-dependent actions such as activation toggles, log refreshes, IIS discovery/handoff, and shutdown are gated until a valid state snapshot marks the daemon connected; navigation, retry, explicit start, settings, existing log export, and quit remain responsive. `ShutdownDaemonRequest` stops the owned real Caddy runtime and then terminates the actual `cadderd` process after it replies to the caller. The log store reports stream status and retention metadata in `query-logs-response`, including active, empty, stale, removed, read-error, gap, more-before, and truncated-by-retention states. Diagnostic exports are timestamped text files in the caller's current working directory and contain only the daemon-redacted `LogEntry.raw_message` content plus stream metadata. @@ -118,7 +118,7 @@ The packaging workflow does not modify PATH, shell profiles, package-manager shi Image assets under `assets/` are documentation and release artwork only. They are not copied into the runtime layout and are not required by the daemon, shim, TUI, or verification commands. -The current runtime model remains a single per-user daemon that owns the backend and serves the TUI/dashboard state. A future detached backend with multiple independent dashboards is intentionally outside the current implementation. +The current runtime model is a single per-user daemon that owns the backend and serves zero-to-many independent dashboards or shim clients. Future UI surfaces, including a desktop `cadder` application, should attach to that same backend instead of changing ownership semantics. ## Workspace Layout diff --git a/docs/site/src/content/docs/index.mdx b/docs/site/src/content/docs/index.mdx index e3dfc9a..4b15550 100644 --- a/docs/site/src/content/docs/index.mdx +++ b/docs/site/src/content/docs/index.mdx @@ -23,9 +23,9 @@ That makes local reverse-proxy setups easier to run side by side. Cadder can com ## Binaries -- `caddy` is the Caddy-compatible shim. For `caddy run`, it starts or connects to `cadderd`, registers the current project, and unregisters on exit. Other Caddy commands are delegated to the real Caddy binary. +- `caddy` is the Caddy-compatible shim. For `caddy run`, it attaches to a running `cadderd`, registers the current project, and unregisters on exit. If the backend is unavailable, Cadder reports how to start it instead of spawning it implicitly. Other Caddy commands are delegated to the real Caddy binary. - `cadderd` is the daemon. It owns registrations, generated Caddy config, the real Caddy process it starts, diagnostics, and bounded logs. -- `cadder-tui` is the terminal UI for inspecting domains, entrypoints, diagnostics, logs, filters, toggles, and daemon shutdown. +- `cadder-tui` is the terminal UI for inspecting domains, entrypoints, diagnostics, logs, filters, toggles, daemon shutdown, and explicit backend start when needed. Each binary supports `--help` and `--version`. @@ -54,6 +54,6 @@ Each binary supports `--help` and `--version`. 1. Download the release for your operating system. 2. Create `cadder.toml` with the path to the real Caddy binary. See [cadder.toml](user-guide/cadder-toml/) for the supported forms. -3. Run Cadder's `caddy` shim instead of the real `caddy` command. +3. Start `cadderd`, then run Cadder's `caddy` shim instead of the real `caddy` command. -Open `cadder-tui` whenever you want to inspect active projects, domains, logs, or diagnostics. +Open `cadder-tui` whenever you want to inspect active projects, domains, logs, diagnostics, or explicitly start or stop the backend. diff --git a/docs/site/src/content/docs/quick-start/getting-started.mdx b/docs/site/src/content/docs/quick-start/getting-started.mdx index 097494b..342c9a0 100644 --- a/docs/site/src/content/docs/quick-start/getting-started.mdx +++ b/docs/site/src/content/docs/quick-start/getting-started.mdx @@ -22,16 +22,17 @@ You can also run Cadder by direct path. That is useful for testing or for setups ## Run a project ```sh +cadderd caddy run ``` -The shim starts or connects to `cadderd`, registers the project Caddyfile, and keeps that registration alive while the command is running. +The shim attaches to the running `cadderd` backend, registers the project Caddyfile, and keeps that registration alive while the command is running. If the backend is unavailable, Cadder reports how to start it instead of spawning it implicitly. ```sh cadder-tui ``` -Use the TUI to inspect current state, domains, logs, diagnostics, and toggles. +Use the TUI to inspect current state, domains, logs, diagnostics, toggles, and explicit backend controls. If you prefer, you can also open `cadder-tui` first and press `s` to start the backend from inside the dashboard. ## OS cookbooks diff --git a/docs/site/src/content/docs/reference/runtime-configuration.mdx b/docs/site/src/content/docs/reference/runtime-configuration.mdx index 91b57bf..8c6a07f 100644 --- a/docs/site/src/content/docs/reference/runtime-configuration.mdx +++ b/docs/site/src/content/docs/reference/runtime-configuration.mdx @@ -21,6 +21,8 @@ caddy --cadder-runtime-dir run This is useful for tests, demos, and isolated sessions that should not share daemon state with the normal user runtime. +Use the same runtime override when you start `cadderd`, attach with `cadder-tui`, or run `caddy --cadder-runtime-dir run`. The shim and dashboard only attach to a backend that is already running for that exact runtime directory. + ## Runtime files The daemon runtime directory contains: diff --git a/docs/site/src/content/docs/user-guide/how-to-use.mdx b/docs/site/src/content/docs/user-guide/how-to-use.mdx index 585e56b..3a9b470 100644 --- a/docs/site/src/content/docs/user-guide/how-to-use.mdx +++ b/docs/site/src/content/docs/user-guide/how-to-use.mdx @@ -16,18 +16,26 @@ The `caddy` command is Cadder's shim. Use it for project `caddy run` workflows t 1. Get the release for your operating system from [GitHub Releases](https://github.com/MrMaxie/Cadder/releases/latest). 2. Point Cadder at the real Caddy binary with [cadder.toml](../cadder-toml/). -3. Run your project through Cadder: +3. Start the Cadder backend: + +```sh +cadderd +``` + +4. Run your project through Cadder: ```sh caddy run ``` -4. Inspect the daemon when needed: +5. Inspect the daemon when needed: ```sh cadder-tui ``` +`caddy run` attaches to the running backend. If `cadderd` is unavailable, Cadder reports how to start it instead of starting it implicitly. + ## Direct or PATH-based use Both styles are valid. diff --git a/docs/site/src/content/docs/user-guide/path-and-shim.mdx b/docs/site/src/content/docs/user-guide/path-and-shim.mdx index 8877785..f2c4687 100644 --- a/docs/site/src/content/docs/user-guide/path-and-shim.mdx +++ b/docs/site/src/content/docs/user-guide/path-and-shim.mdx @@ -3,7 +3,7 @@ title: PATH and shim strategy description: Use Cadder with direct paths, a user-managed PATH directory, or an explicit shim copy without implying automatic PATH mutation. --- -Cadder's `caddy` binary is a compatibility shim. It is useful when you want existing `caddy run` habits to register projects with Cadder. +Cadder's `caddy` binary is a compatibility shim. It is useful when you want existing `caddy run` habits to register projects with Cadder, while keeping daemon ownership in `cadderd`. Cadder does not need to own your shell setup. Use direct paths, put the shim on `PATH`, or wrap it in your own tooling. @@ -25,6 +25,8 @@ If you want `caddy run` to go through Cadder, copy or symlink the Cadder shim in The real Caddy binary still needs to be resolvable by Cadder. The recommended place is [cadder.toml](../cadder-toml/). +The shim does not start `cadderd` automatically. Start `cadderd` yourself, or open `cadder-tui` and press `s`, before running `caddy run`. + ## Keep a separate real Caddy path The practical idea is simple: diff --git a/docs/site/src/content/docs/user-guide/tui-diagnostics.mdx b/docs/site/src/content/docs/user-guide/tui-diagnostics.mdx index 61cbf74..8abed42 100644 --- a/docs/site/src/content/docs/user-guide/tui-diagnostics.mdx +++ b/docs/site/src/content/docs/user-guide/tui-diagnostics.mdx @@ -3,7 +3,7 @@ title: TUI and diagnostics description: Inspect daemon state, domains, per-domain logs, and diagnostics through cadder-tui. --- -`cadder-tui` opens the Cadder dashboard. It starts `cadderd` by default unless `--no-start` is passed. +`cadder-tui` opens the Cadder dashboard. It attaches to an existing `cadderd` backend by default and never starts it implicitly. Use `s` for the explicit backend start action when the daemon is unavailable. ```sh cadder-tui @@ -11,6 +11,8 @@ cadder-tui --no-start cadder-tui --runtime-dir ``` +`--no-start` remains as a compatibility alias, but attach-only behavior is already the default. + ## Views - Overview shows runtime status, config status, entrypoint count, domain count, and active domain count. @@ -20,12 +22,14 @@ cadder-tui --runtime-dir - Settings controls the minimum log severity shown in Logs. - Diagnostics shows config and runtime diagnostics, including domain conflicts and runtime apply failures. -The TUI refreshes daemon state automatically on a short interval. Press `r` when you want an explicit refresh immediately. +The TUI refreshes daemon state automatically after it attaches and keeps background attach probes running while the daemon is unavailable. Press `r` when you want an explicit attach or refresh immediately. ## Controls - `Tab`, `Shift+Tab`, `Left`, and `Right` switch views with wraparound. - Arrow keys move selection. +- `s` explicitly starts `cadderd` when the backend is unavailable. +- `r` retries attach or refreshes the current daemon state. - `Space` toggles the selected entrypoint or domain when the current view supports it. - `f` enters search mode. - In IIS Handoff on Windows, `/` enters route-host input for wildcard or empty-host bindings. diff --git a/docs/verification/testcontainers-e2e.md b/docs/verification/testcontainers-e2e.md index a5b818d..463e64f 100644 --- a/docs/verification/testcontainers-e2e.md +++ b/docs/verification/testcontainers-e2e.md @@ -39,7 +39,7 @@ error. - Shim unregister/exit cleanup removes only the exiting registration. - Duplicate domains report `domain-conflict`. - Invalid Caddyfiles report `adapt-failed`. -- Daemon runtime shutdown stops real Caddy while the container is still alive. +- Daemon shutdown stops real Caddy and terminates `cadderd` while the container is still alive. ## Run Record diff --git a/docs/verification/tui-smoke.md b/docs/verification/tui-smoke.md index cc92eab..8124062 100644 --- a/docs/verification/tui-smoke.md +++ b/docs/verification/tui-smoke.md @@ -22,12 +22,12 @@ Run the checklist on each backend available for the platform under test: ## Checklist -- Launch `cargo run -p cadder-tui -- --runtime-dir --no-start` while no daemon is running for that runtime and confirm the Overview view renders without layout corruption, panic, immediate exit, or broken terminal echo/cursor behavior. +- Launch `cargo run -p cadder-tui -- --runtime-dir ` while no daemon is running for that runtime and confirm the Overview view renders without layout corruption, panic, immediate exit, or broken terminal echo/cursor behavior. Optionally repeat with `--no-start` and confirm it behaves the same as the default attach-only launch. - Confirm Overview shows `Daemon: Not running` or `Daemon: Connection failed` with actionable guidance to press `s`, `r`, or `q`. -- Confirm the footer advertises `s start/reconnect`. +- Confirm the footer advertises `s start backend`. - Press `r` and confirm the TUI remains usable if the daemon is still unavailable. - Relaunch with `--daemon-path ` if `cadderd` is not next to `cadder-tui` or on `PATH`, press `s`, and confirm the Overview state changes to `Starting` while input remains responsive. -- After a successful in-TUI start, confirm Overview changes to `Daemon: Connected` and normal state refresh resumes. +- After a successful in-TUI start, confirm Overview changes to `Daemon: Connected`, the dashboard attaches without restarting, and normal state refresh resumes. - If testing a start failure, pass a missing `--daemon-path`, press `s`, and confirm Overview changes to `Start failed` while retry with `s` and quit with `q` remain available. - Confirm Overview shows runtime status, config status, entrypoint count, domain count, and active domain count. - Change daemon state from a shim session and confirm Overview updates automatically within a few seconds; press `r` and confirm manual refresh still works. @@ -50,7 +50,7 @@ Run the checklist on each backend available for the platform under test: - With the domain log stream still open, switch to Settings, use `Up` and `Down` to choose `All`, `Info and higher`, `Warnings and errors`, or `Errors only`, then press `Enter` or `Space`; return to Logs and confirm the severity label and displayed page reset without mixing entries from different filters. - Press `Enter` in Logs and confirm manual refresh remains responsive. - Press `Tab` to open Diagnostics and confirm config/runtime diagnostics are shown when a conflict or runtime reload failure exists; otherwise confirm the empty diagnostics message is shown. -- Confirm the footer advertises `Tab`/`Shift+Tab`/`Left`/`Right`, manual refresh, daemon start/reconnect, Settings severity selection, log pause/refresh/export, daemon shutdown, and quit controls. +- Confirm the footer advertises `Tab`/`Shift+Tab`/`Left`/`Right`, manual refresh, explicit backend start, Settings severity selection, log pause/refresh/export, daemon shutdown, and quit controls. - On Windows, confirm the footer advertises IIS refresh and handoff/restore controls. - Press `d` and confirm the daemon shutdown request returns a status message and does not freeze terminal input. - Press `q` and confirm the terminal exits cleanly with normal echo/cursor behavior restored. From 22418ca3d88cfe890732e51ff458743e3292061f Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Tue, 16 Jun 2026 20:43:16 +0200 Subject: [PATCH 03/29] feat: add non-TUI CLI `cadderctl` for automation and inspection Introduced `cadderctl`, a command-line interface for non-interactive inspection and control of the Cadder daemon. This addition allows users to perform state queries, log retrieval, and manage entrypoints and domains without launching the TUI. Updated documentation to reflect the new CLI capabilities and its integration into the Cadder architecture. --- Cargo.lock | 15 + Cargo.toml | 1 + README.md | 15 +- ...CLI-for-inspecting-and-operating-Cadder.md | 119 +- crates/cadderctl/Cargo.toml | 20 + crates/cadderctl/src/app.rs | 1297 +++++++++++++++++ crates/cadderctl/src/cli.rs | 438 ++++++ crates/cadderctl/src/error.rs | 364 +++++ crates/cadderctl/src/lib.rs | 5 + crates/cadderctl/src/main.rs | 25 + crates/cadderctl/src/render.rs | 617 ++++++++ crates/cadderctl/src/view.rs | 455 ++++++ crates/cadderctl/tests/cli.rs | 433 ++++++ crates/cadderctl/tests/support/mod.rs | 227 +++ docs/ARCHITECTURE.md | 13 +- docs/site/astro.config.mjs | 1 + docs/site/src/content/docs/index.mdx | 4 +- .../docs/quick-start/getting-started.mdx | 7 +- .../docs/reference/runtime-configuration.mdx | 3 +- .../src/content/docs/user-guide/cadderctl.mdx | 96 ++ .../content/docs/user-guide/how-to-use.mdx | 7 +- xtask/src/main.rs | 37 +- 22 files changed, 4173 insertions(+), 26 deletions(-) create mode 100644 crates/cadderctl/Cargo.toml create mode 100644 crates/cadderctl/src/app.rs create mode 100644 crates/cadderctl/src/cli.rs create mode 100644 crates/cadderctl/src/error.rs create mode 100644 crates/cadderctl/src/lib.rs create mode 100644 crates/cadderctl/src/main.rs create mode 100644 crates/cadderctl/src/render.rs create mode 100644 crates/cadderctl/src/view.rs create mode 100644 crates/cadderctl/tests/cli.rs create mode 100644 crates/cadderctl/tests/support/mod.rs create mode 100644 docs/site/src/content/docs/user-guide/cadderctl.mdx diff --git a/Cargo.lock b/Cargo.lock index 7806924..4c59f48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,6 +426,21 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "cadderctl" +version = "0.8.0" +dependencies = [ + "anyhow", + "cadder-daemon", + "cadder-protocol", + "chrono", + "clap", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "cadderd" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index c184116..f9c87c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/cadderctl", "crates/cadder-daemon", "crates/cadder-protocol", "crates/cadder-shim", diff --git a/README.md b/README.md index 61603b5..cd87bc6 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,11 @@ Published documentation: ## What's included -Cadder ships three executables: +Cadder ships four executables: - `caddy` is the Caddy-compatible shim. For `caddy run`, it attaches to a running `cadderd`, registers the current project's Caddyfile, keeps that registration alive while the shim process runs, and unregisters on exit. If the backend is unavailable, Cadder fails with guidance instead of starting it implicitly. Other Caddy commands are delegated to the safely resolved real Caddy binary. - `cadderd` is the per-user daemon. It owns local IPC, entrypoint registrations, adapted Caddy config composition, the generated effective runtime config, the real Caddy process it starts, diagnostics, and bounded log storage. +- `cadderctl` is the non-TUI operator CLI. It attaches to the daemon for one-shot state queries, logs, and toggles, and provides an explicit `daemon start` path for scripting and automation. - `cadder-tui` is the terminal UI. It attaches to the daemon, shows overview state, entrypoints, domains, per-domain logs, diagnostics, filters, toggles, log export, and daemon shutdown, and exposes an explicit `s` action when the backend needs to be started. Each executable supports `--help` and `--version`. @@ -21,7 +22,7 @@ Each executable supports `--help` and `--version`. 1. Download the latest Cadder release for your operating system from [GitHub Releases](https://github.com/MrMaxie/Cadder/releases). 2. Create `cadder.toml` next to Cadder with the path to the real Caddy binary. 3. Start `cadderd`, then run a project through Cadder's `caddy` shim. -4. Open `cadder-tui` for state, domains, logs, diagnostics, and explicit backend control. +4. Use `cadderctl` for automation-friendly state and log queries, or open `cadder-tui` for the interactive dashboard. Full setup docs: @@ -34,12 +35,22 @@ Full setup docs: ```sh cadderd +cadderctl daemon status caddy run cadder-tui ``` `caddy run` now requires a running Cadder backend. Start `cadderd` directly or open `cadder-tui` and press `s` before retrying the shim command. +`cadderctl` gives you non-interactive inspection and control commands such as: + +```sh +cadderctl daemon status +cadderctl entrypoints list --output json +cadderctl domains disable app.localhost --registration shim-1 +cadderctl logs show --limit 20 domain app.localhost --registration shim-1 --output json +``` + For a local checkout, run the project checks with: ```sh diff --git a/backlog/tasks/task-1.28 - Add-non-TUI-CLI-for-inspecting-and-operating-Cadder.md b/backlog/tasks/task-1.28 - Add-non-TUI-CLI-for-inspecting-and-operating-Cadder.md index c07f75a..7ff2ef1 100644 --- a/backlog/tasks/task-1.28 - Add-non-TUI-CLI-for-inspecting-and-operating-Cadder.md +++ b/backlog/tasks/task-1.28 - Add-non-TUI-CLI-for-inspecting-and-operating-Cadder.md @@ -1,9 +1,11 @@ --- id: TASK-1.28 title: Add non-TUI CLI for inspecting and operating Cadder -status: To Do -assignee: [] +status: Done +assignee: + - '@agent' created_date: '2026-06-16 15:25' +updated_date: '2026-06-16 18:42' labels: - cli - automation @@ -17,6 +19,28 @@ references: documentation: - docs/ARCHITECTURE.md - docs/site/src/content/docs/reference/runtime-configuration.mdx +modified_files: + - Cargo.lock + - Cargo.toml + - README.md + - docs/ARCHITECTURE.md + - docs/site/astro.config.mjs + - docs/site/src/content/docs/index.mdx + - docs/site/src/content/docs/quick-start/getting-started.mdx + - docs/site/src/content/docs/reference/runtime-configuration.mdx + - docs/site/src/content/docs/user-guide/how-to-use.mdx + - docs/site/src/content/docs/user-guide/cadderctl.mdx + - xtask/src/main.rs + - crates/cadderctl/Cargo.toml + - crates/cadderctl/src/lib.rs + - crates/cadderctl/src/main.rs + - crates/cadderctl/src/cli.rs + - crates/cadderctl/src/error.rs + - crates/cadderctl/src/render.rs + - crates/cadderctl/src/view.rs + - crates/cadderctl/src/app.rs + - crates/cadderctl/tests/cli.rs + - crates/cadderctl/tests/support/mod.rs parent_task_id: TASK-1 priority: high ordinal: 25300 @@ -30,16 +54,91 @@ Add a command-line management interface so operators and automations can query C ## Acceptance Criteria -- [ ] #1 The CLI can inspect daemon status, entrypoints, domains, diagnostics, and recent logs without launching the TUI. -- [ ] #2 The CLI supports common management actions such as starting or reconnecting to the daemon, refreshing state, and enabling or disabling managed domains using non-interactive commands. -- [ ] #3 The CLI offers both human-readable output and a machine-readable mode suitable for scripts and agents. -- [ ] #4 Permission-sensitive or unavailable-daemon operations return typed errors, stable exit codes, and actionable guidance instead of interactive UI prompts. -- [ ] #5 Automated tests cover core commands, output modes, exit codes, and unavailable-daemon behavior. -- [ ] #6 User documentation explains command structure, scripting patterns, and how this CLI relates to the TUI and future agent integrations. +- [x] #1 The CLI can inspect daemon status, entrypoints, domains, diagnostics, and recent logs without launching the TUI. +- [x] #2 The CLI supports common management actions such as starting or reconnecting to the daemon, refreshing state, and enabling or disabling managed domains using non-interactive commands. +- [x] #3 The CLI offers both human-readable output and a machine-readable mode suitable for scripts and agents. +- [x] #4 Permission-sensitive or unavailable-daemon operations return typed errors, stable exit codes, and actionable guidance instead of interactive UI prompts. +- [x] #5 Automated tests cover core commands, output modes, exit codes, and unavailable-daemon behavior. +- [x] #6 User documentation explains command structure, scripting patterns, and how this CLI relates to the TUI and future agent integrations. +## Implementation Plan + + +1. Add a new operator-facing CLI crate and workspace binary, recommended as `cadderctl`, instead of overloading `cadder`, because the architecture notes reserve `cadder` for a future desktop surface. Wire Clap command metadata, shared runtime arguments, and release identity consistently with the existing Cadder binaries. +2. Keep the CLI attach-only by default. Reuse `RuntimePaths`, `CadderClient`, `CadderSession`, and `ensure_daemon_running_with_options` from `crates/cadder-daemon` so only an explicit `daemon start` path may launch `cadderd`, while inspect and mutate commands only attach to an already running daemon. +3. Define the v1 command surface around the existing daemon contracts: `daemon status`, `daemon start`, `daemon shutdown`; `entrypoints list|enable|disable`; `domains list|enable|disable`; `diagnostics show`; `logs show`; and continuous `watch` or `tail` flows for the core inspection paths. Reuse current IPC request and response types instead of introducing a parallel backend contract. +4. Implement a small typed CLI application layer that resolves selectors, normalizes target lookup, and maps daemon/protocol failures into explicit operator-facing error kinds. Cover unavailable daemon, start timeout, target not found, rejected operation, unsupported platform or operation, permission or elevation issues, invalid usage, and serialization or IPC failures. +5. Define stable exit codes and keep them centralized and testable: success, invalid usage, daemon unavailable, daemon start failure or timeout, target not found, conflict or rejected operation, permission or elevation issue, unsupported platform or operation, and IPC or serialization failure. Ensure `Ctrl+C` exits streaming commands cleanly with success when the user intentionally stops them. +6. Implement stable output modes. Use human-readable output by default for one-shot commands, `json` for machine-readable one-shot results, and `jsonl` plus human streaming output for `watch` and `tail` commands. Keep machine-readable envelopes stable, explicit, and free of interactive prompts. +7. Build `watch` and `tail` on the current transport model instead of adding new daemon features to this task. Use `subscribe_state` for state-oriented watches, and implement log tailing through repeated `query_logs` calls with cursor progression, including explicit handling for stream status, gaps, retention truncation, and daemon loss while streaming. +8. Keep scope aligned with the milestone by focusing on core scripting and operator workflows. Do not require full TUI parity. Include state, entrypoints, domains, diagnostics, recent logs, start and shutdown, and enable or disable actions; only include IIS-specific CLI control if the existing contracts and time budget make it clearly part of the same bounded surface. +9. Update packaging and documentation alongside the binary. Extend workspace membership, portable dist and package verification, README usage, architecture wording where needed, and user docs so the shipped command set, output modes, scripting patterns, and the relationship between CLI, TUI, and future agent integrations stay aligned. +10. Validate with focused CLI unit and integration coverage plus the normal repository gates. Add tests for Clap help and version metadata, selector parsing, output rendering, exit-code mapping, unavailable-daemon behavior, state watch subscription handling, log tail cursor behavior, retention and gap reporting, and packaging expectations. Then run `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo run -p xtask -- check`, and `cargo run -p xtask -- coverage`. + + +## Implementation Notes + + +Plan approved by the user and recorded before implementation. Task moved to In Progress and assigned to @agent. Approved scope includes `watch` and `tail` support in the non-TUI CLI surface. + +Confirmed that the current daemon IPC does not expose a dedicated refresh or reconnect mutation. The CLI implementation will stay within existing contracts by using one-shot `query_state` requests for explicit refreshes, `subscribe_state` for `watch`, and repeated `query_logs` cursor polling for `tail` instead of adding new daemon messages in this task. + +Implemented a new `cadderctl` workspace crate for non-interactive Cadder inspection and control. The CLI now covers daemon status/start/shutdown, entrypoint and domain listing/toggles, diagnostics, retained log queries, log tailing, and state watches with `human`, `json`, and `jsonl` output modes. + +The implementation stayed within existing daemon contracts: one-shot refreshes use `query_state`, `watch` uses `subscribe_state`, and `logs tail` advances by repeated `query_logs` cursor polling rather than adding new IPC messages. Packaging and docs were updated to ship and document `cadderctl` alongside the existing binaries. + +Fresh-eyes review found and fixed two contract issues before closeout: top-level CLI flags are now global so documented command examples work even when `--output` or `--runtime-dir` appear after subcommands, and `cadderctl --help` / `--version` now exit with status 0 instead of invalid-usage status 2. + +Verification run: +- `cargo fmt --check` +- `cargo clippy --workspace --all-targets -- -D warnings` +- `cargo test --workspace` +- `cargo run -p xtask -- check` +- `cargo run -p xtask -- coverage` + +Workspace line coverage now passes the project threshold at 87.42%. + +Attempted an additional external Claude audit per repo guidance, but the local `claude` CLI invocation produced no output for an extended period and had to be aborted, so there are no external review findings to record from that pass. + + +## Final Summary + + +Added a new operator-facing `cadderctl` workspace crate so Cadder can be inspected and controlled from scripts and non-interactive terminal workflows without opening `cadder-tui`. + +What changed: +- Implemented `daemon status|start|shutdown`, `entrypoints list|enable|disable`, `domains list|enable|disable`, `diagnostics show`, `logs show`, `logs tail`, and `watch status|entrypoints|domains|diagnostics`. +- Kept the CLI attach-only by default and reused existing daemon contracts instead of introducing new IPC messages. One-shot refreshes use fresh `query_state` calls, `watch` uses `subscribe_state`, and `logs tail` advances via repeated `query_logs` cursor polling. +- Added stable `human`, `json`, and `jsonl` output modes plus centralized typed error handling and stable exit codes for unavailable daemon, invalid usage, target-not-found, rejected/conflict, permission/elevation, start failure, unsupported operations, and IPC failures. +- Extended workspace packaging and verification so `cadderctl` ships with releases and is covered by dist validation. +- Updated README, architecture docs, and docs site content with command structure, scripting patterns, runtime override examples, and the relationship between `cadderctl` and the TUI. + +Why it changed: +- Operators, automations, and future agents need a stable non-TUI surface for querying daemon state, toggling routes, and retrieving logs without depending on an interactive dashboard. + +Impact: +- Cadder now exposes a scriptable CLI surface suitable for shell usage, CI checks, and agent integration while preserving the existing daemon ownership and runtime model. + +Tests and verification: +- `cargo fmt --check` +- `cargo clippy --workspace --all-targets -- -D warnings` +- `cargo test --workspace` +- `cargo run -p xtask -- check` +- `cargo run -p xtask -- coverage` +- Workspace line coverage after the change: 87.42% + +Key closeout fixes found during review: +- Made top-level CLI flags global so documented forms like `cadderctl logs show ... --output json` work after subcommands. +- Fixed `cadderctl --help` and `cadderctl --version` to exit with status `0` instead of invalid-usage status `2`. + +Risks / follow-ups: +- No dedicated reconnect or refresh daemon mutation was added in this task by design; the CLI intentionally relies on fresh state queries, state subscriptions, and log cursor polling over the existing protocol. +- An additional external Claude audit was attempted but the local `claude` CLI produced no output and had to be aborted, so no external findings were available from that pass. + + ## Definition of Done -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold +- [x] #1 Tests or explicit verification were run for the changed behavior +- [x] #2 Coverage was measured and remains at or above the project threshold diff --git a/crates/cadderctl/Cargo.toml b/crates/cadderctl/Cargo.toml new file mode 100644 index 0000000..5a00a5b --- /dev/null +++ b/crates/cadderctl/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "cadderctl" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Cadder non-TUI CLI for daemon state, domains, logs, and automation" + +[dependencies] +anyhow = "1.0.102" +cadder-daemon = { version = "0.8.0", path = "../cadder-daemon" } +cadder-protocol = { version = "0.8.0", path = "../cadder-protocol" } +chrono = { version = "0.4.45", features = ["serde"] } +clap = { version = "4.6.1", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +tokio = { version = "1.52.3", features = ["full"] } + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/crates/cadderctl/src/app.rs b/crates/cadderctl/src/app.rs new file mode 100644 index 0000000..25727fc --- /dev/null +++ b/crates/cadderctl/src/app.rs @@ -0,0 +1,1297 @@ +use crate::{ + cli::{ + CliArgs, Command, DaemonCommand, DiagnosticsCommand, DomainsCommand, LogsCommand, + LogsTargetCommand, OutputMode, WatchCommand, + }, + error::{ + CliError, CliErrorKind, daemon_error_indicates_unavailable, format_error_chain, start_guidance, + }, + render, + view::{ + ActionResultView, ConnectionStateView, DaemonStartView, LogsView, SelectedDomain, + daemon_status_connected, daemon_status_unavailable, diagnostics_view, domains_view, + entrypoints_view, map_severity, resolve_domain, + }, +}; +use anyhow::Result as AnyResult; +use cadder_daemon::{ + CadderClient, DaemonLaunchOptions, RuntimePaths, StateSubscription, + ensure_daemon_running_with_options, +}; +use cadder_protocol::{ + BasicResponse, GuiStateSnapshot, LogStreamIdentity, QueryLogsRequest, QueryStateRequest, + QueryStateResponse, SetDomainEnabledRequest, SetEntrypointEnabledRequest, ShutdownDaemonRequest, + StateChangedEvent, message_types, new_request_id, +}; +use chrono::Utc; +use serde::Serialize; +use serde_json::json; +use std::{ + io::{self, Write}, + time::Duration, +}; + +pub async fn run(args: CliArgs, stdout: &mut dyn Write, stderr: &mut dyn Write) -> io::Result { + let output = args.output; + match execute(args, stdout).await { + Ok(()) => Ok(0), + Err(ExecuteError::Cli(error)) => { + write_error(output, &error, stdout, stderr)?; + Ok(error.exit_code().code()) + } + Err(ExecuteError::Io(error)) => Err(error), + } +} + +#[derive(Debug)] +enum ExecuteError { + Cli(CliError), + Io(io::Error), +} + +impl From for ExecuteError { + fn from(value: CliError) -> Self { + Self::Cli(value) + } +} + +impl From for ExecuteError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +struct AppContext { + output: OutputMode, + paths: RuntimePaths, + client: CadderClient, + launch_options: DaemonLaunchOptions, +} + +impl AppContext { + fn new(args: &CliArgs) -> Result { + let command = args.command_label(); + let paths = RuntimePaths::resolve(args.runtime_dir.clone()).map_err(|error| { + CliError::invalid_usage( + command, + format!("Could not resolve the Cadder runtime directory: {error}."), + None, + ) + })?; + Ok(Self { + output: args.output, + client: CadderClient::new(paths.clone()), + paths, + launch_options: DaemonLaunchOptions { + explicit_daemon: args.daemon_path.clone(), + real_caddy_command: args.real_caddy_command.clone(), + shim_path: None, + }, + }) + } + + async fn query_state_response(&self) -> AnyResult { + self + .client + .request( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("ctl-state"), + }, + ) + .await + } + + async fn query_snapshot( + &self, + command: &'static str, + action: &str, + ) -> Result { + let response = self.query_state_response().await.map_err(|error| { + CliError::daemon_request(command, self.paths.runtime_dir(), action, &error) + })?; + response.snapshot.ok_or_else(|| { + CliError::new( + command, + CliErrorKind::IpcFailure, + "Cadder daemon returned no state snapshot.".to_string(), + Some( + "Retry the command after the daemon finishes its current state transition.".to_string(), + ), + ) + }) + } + + async fn request_basic( + &self, + command: &'static str, + action: &str, + message_type: &str, + response_type: &str, + request: &impl Serialize, + ) -> Result { + self + .client + .request(message_type, response_type, request) + .await + .map_err(|error| CliError::daemon_request(command, self.paths.runtime_dir(), action, &error)) + } + + async fn query_logs( + &self, + command: &'static str, + action: &str, + stream: LogStreamIdentity, + limit: usize, + cursor: Option, + minimum_severity: Option, + ) -> Result { + let response = self + .client + .request::<_, cadder_protocol::QueryLogsResponse>( + message_types::QUERY_LOGS_REQUEST, + message_types::QUERY_LOGS_RESPONSE, + &QueryLogsRequest { + request_id: new_request_id("ctl-logs"), + stream, + limit: Some(limit), + cursor, + minimum_severity, + }, + ) + .await + .map_err(|error| { + CliError::daemon_request(command, self.paths.runtime_dir(), action, &error) + })?; + Ok(LogsView::from(response)) + } + + async fn subscribe_state( + &self, + command: &'static str, + action: &str, + ) -> Result { + self + .client + .subscribe_state(new_request_id("ctl-watch")) + .await + .map_err(|error| CliError::daemon_request(command, self.paths.runtime_dir(), action, &error)) + } +} + +async fn execute(args: CliArgs, stdout: &mut dyn Write) -> Result<(), ExecuteError> { + let command = args.command_label(); + validate_output_mode(args.output, &args.command, command)?; + let context = AppContext::new(&args)?; + + match args.command { + Command::Daemon { command } => handle_daemon(&context, command, stdout).await, + Command::Entrypoints { command } => handle_entrypoints(&context, command, stdout).await, + Command::Domains { command } => handle_domains(&context, command, stdout).await, + Command::Diagnostics { command } => handle_diagnostics(&context, command, stdout).await, + Command::Logs { command } => handle_logs(&context, command, stdout).await, + Command::Watch { command } => handle_watch(&context, command, stdout).await, + } +} + +fn validate_output_mode( + output: OutputMode, + command: &Command, + label: &'static str, +) -> Result<(), CliError> { + let streaming = matches!( + command, + Command::Logs { + command: LogsCommand::Tail { .. } + } | Command::Watch { .. } + ); + + if streaming && output == OutputMode::Json { + return Err(CliError::invalid_usage( + label, + "Streaming commands support `human` or `jsonl` output.".to_string(), + Some("Use `--output jsonl` for streaming automation or omit `--output` for human-readable tail/watch output.".to_string()), + )); + } + + if !streaming && output == OutputMode::Jsonl { + return Err(CliError::invalid_usage( + label, + "`jsonl` output is only available for `watch` and `logs tail`.".to_string(), + Some("Use `--output json` for one-shot commands.".to_string()), + )); + } + + match command { + Command::Logs { + command: LogsCommand::Show { options, .. }, + } if options.limit == 0 || options.limit > 500 => { + return Err(CliError::invalid_usage( + label, + "`--limit` must be between 1 and 500.".to_string(), + None, + )); + } + Command::Logs { + command: LogsCommand::Tail { options, .. }, + } if options.read.limit == 0 || options.read.limit > 500 => { + return Err(CliError::invalid_usage( + label, + "`--limit` must be between 1 and 500.".to_string(), + None, + )); + } + Command::Logs { + command: LogsCommand::Tail { options, .. }, + } if options.poll_interval_ms < 50 || options.poll_interval_ms > 60_000 => { + return Err(CliError::invalid_usage( + label, + "`--poll-interval-ms` must be between 50 and 60000.".to_string(), + None, + )); + } + _ => {} + } + + Ok(()) +} + +async fn handle_daemon( + context: &AppContext, + command: DaemonCommand, + stdout: &mut dyn Write, +) -> Result<(), ExecuteError> { + match command { + DaemonCommand::Status => { + let view = match context.query_state_response().await { + Ok(response) => { + let Some(snapshot) = response.snapshot else { + return Err(CliError::new( + command.label(), + CliErrorKind::IpcFailure, + "Cadder daemon returned no state snapshot.".to_string(), + Some("Retry `cadderctl daemon status` after the daemon finishes its current state transition.".to_string()), + ) + .into()); + }; + daemon_status_connected(context.paths.runtime_dir(), &snapshot) + } + Err(error) if daemon_error_indicates_unavailable(&error) => daemon_status_unavailable( + context.paths.runtime_dir(), + connection_state_from_error(&error), + unavailable_message(context.paths.runtime_dir(), &error), + Some(start_guidance(context.paths.runtime_dir())), + ), + Err(error) => { + return Err( + CliError::daemon_request( + command.label(), + context.paths.runtime_dir(), + "query daemon status", + &error, + ) + .into(), + ); + } + }; + write_one_shot( + context.output, + command.label(), + &view, + stdout, + render::write_human_daemon_status, + )?; + } + DaemonCommand::Start => { + let already_running = match context.query_state_response().await { + Ok(_) => true, + Err(error) if daemon_error_indicates_unavailable(&error) => false, + Err(error) => { + return Err( + CliError::daemon_request( + command.label(), + context.paths.runtime_dir(), + "check daemon status before start", + &error, + ) + .into(), + ); + } + }; + + ensure_daemon_running_with_options(&context.paths, context.launch_options.clone()) + .await + .map_err(|error| { + CliError::daemon_start(command.label(), context.paths.runtime_dir(), &error) + })?; + let snapshot = context + .query_snapshot(command.label(), "query daemon status after start") + .await?; + let view = DaemonStartView { + started: !already_running, + message: if already_running { + "cadderd is already running.".to_string() + } else { + "cadderd started.".to_string() + }, + status: daemon_status_connected(context.paths.runtime_dir(), &snapshot), + }; + write_one_shot( + context.output, + command.label(), + &view, + stdout, + render::write_human_daemon_start, + )?; + } + DaemonCommand::Shutdown => { + let response = context + .request_basic( + command.label(), + "request daemon shutdown", + message_types::SHUTDOWN_DAEMON_REQUEST, + message_types::SHUTDOWN_DAEMON_RESPONSE, + &ShutdownDaemonRequest { + request_id: new_request_id("ctl-shutdown"), + }, + ) + .await?; + if !response.accepted { + return Err( + CliError::conflict_or_rejected( + command.label(), + response.message, + Some("Retry once Cadder has finished the current runtime operation.".to_string()), + ) + .into(), + ); + } + write_one_shot( + context.output, + command.label(), + &ActionResultView { + message: response.message, + }, + stdout, + render::write_human_action, + )?; + } + } + + Ok(()) +} + +async fn handle_entrypoints( + context: &AppContext, + command: crate::cli::EntrypointsCommand, + stdout: &mut dyn Write, +) -> Result<(), ExecuteError> { + match command { + crate::cli::EntrypointsCommand::List => { + let snapshot = context + .query_snapshot("entrypoints list", "query entrypoints") + .await?; + let view = entrypoints_view(&snapshot); + write_one_shot( + context.output, + "entrypoints list", + &view, + stdout, + render::write_human_entrypoints, + )?; + } + crate::cli::EntrypointsCommand::Enable { registration_id } => { + toggle_entrypoint(context, "entrypoints enable", registration_id, true, stdout).await?; + } + crate::cli::EntrypointsCommand::Disable { registration_id } => { + toggle_entrypoint( + context, + "entrypoints disable", + registration_id, + false, + stdout, + ) + .await?; + } + } + Ok(()) +} + +async fn toggle_entrypoint( + context: &AppContext, + command: &'static str, + registration_id: String, + enabled: bool, + stdout: &mut dyn Write, +) -> Result<(), ExecuteError> { + let response = context + .request_basic( + command, + "toggle entrypoint activation", + message_types::SET_ENTRYPOINT_ENABLED_REQUEST, + message_types::SET_ENTRYPOINT_ENABLED_RESPONSE, + &SetEntrypointEnabledRequest { + request_id: new_request_id("ctl-entrypoint-toggle"), + registration_id: registration_id.clone(), + shim_session_nonce: None, + enabled, + }, + ) + .await?; + if !response.accepted { + return Err( + CliError::target_not_found( + command, + format!("Entrypoint `{registration_id}` was not found."), + Some("Run `cadderctl entrypoints list` to inspect valid registration IDs.".to_string()), + ) + .into(), + ); + } + + write_one_shot( + context.output, + command, + &ActionResultView { + message: response.message, + }, + stdout, + render::write_human_action, + )?; + Ok(()) +} + +async fn handle_domains( + context: &AppContext, + command: DomainsCommand, + stdout: &mut dyn Write, +) -> Result<(), ExecuteError> { + match command { + DomainsCommand::List { registration } => { + let snapshot = context + .query_snapshot("domains list", "query domains") + .await?; + let view = domains_view(&snapshot, registration.as_deref()); + write_one_shot( + context.output, + "domains list", + &view, + stdout, + render::write_human_domains, + )?; + } + DomainsCommand::Enable { selector } => { + toggle_domain(context, "domains enable", selector, true, stdout).await?; + } + DomainsCommand::Disable { selector } => { + toggle_domain(context, "domains disable", selector, false, stdout).await?; + } + } + Ok(()) +} + +async fn toggle_domain( + context: &AppContext, + command: &'static str, + selector: crate::cli::DomainSelectorArgs, + enabled: bool, + stdout: &mut dyn Write, +) -> Result<(), ExecuteError> { + let snapshot = context.query_snapshot(command, "query domains").await?; + let selected = select_domain(command, &snapshot, &selector)?; + let response = context + .request_basic( + command, + "toggle domain activation", + message_types::SET_DOMAIN_ENABLED_REQUEST, + message_types::SET_DOMAIN_ENABLED_RESPONSE, + &SetDomainEnabledRequest { + request_id: new_request_id("ctl-domain-toggle"), + registration_id: selected.registration_id, + domain_key: selected.canonical_domain, + enabled, + }, + ) + .await?; + if !response.accepted { + return Err( + CliError::conflict_or_rejected( + command, + response.message, + Some("Refresh the domain list and retry the command with an explicit `--registration` filter if needed.".to_string()), + ) + .into(), + ); + } + write_one_shot( + context.output, + command, + &ActionResultView { + message: response.message, + }, + stdout, + render::write_human_action, + )?; + Ok(()) +} + +async fn handle_diagnostics( + context: &AppContext, + command: DiagnosticsCommand, + stdout: &mut dyn Write, +) -> Result<(), ExecuteError> { + match command { + DiagnosticsCommand::Show => { + let snapshot = context + .query_snapshot(command.label(), "query diagnostics") + .await?; + let view = diagnostics_view(&snapshot); + write_one_shot( + context.output, + command.label(), + &view, + stdout, + render::write_human_diagnostics, + )?; + } + } + Ok(()) +} + +async fn handle_logs( + context: &AppContext, + command: LogsCommand, + stdout: &mut dyn Write, +) -> Result<(), ExecuteError> { + match command { + LogsCommand::Show { target, options } => { + let target = resolve_logs_target(context, "logs show", target).await?; + let view = context + .query_logs( + "logs show", + "query retained logs", + target, + options.limit, + None, + map_severity(options.minimum_severity), + ) + .await?; + write_one_shot( + context.output, + "logs show", + &view, + stdout, + render::write_human_logs, + )?; + } + LogsCommand::Tail { target, options } => { + let target = resolve_logs_target(context, "logs tail", target).await?; + let mut cursor = None; + let mut last_status = None; + let minimum_severity = map_severity(options.read.minimum_severity); + let mut first_page = true; + + loop { + let view = context + .query_logs( + "logs tail", + "continue log tail", + target.clone(), + options.read.limit, + cursor.clone(), + minimum_severity, + ) + .await?; + if let Some(next_cursor) = &view.next_cursor { + cursor = Some(next_cursor.clone()); + } + let should_emit = first_page + || !view.entries.is_empty() + || view.has_gap + || view.truncated_by_retention + || last_status.is_some_and(|status| status != view.stream_status); + if should_emit { + if context.output == OutputMode::Human && !first_page { + writeln!(stdout)?; + } + write_logs_stream_event(context.output, "logsPage", "logs tail", &view, stdout)?; + } + last_status = Some(view.stream_status); + first_page = false; + + tokio::select! { + _ = tokio::signal::ctrl_c() => return Ok(()), + _ = tokio::time::sleep(Duration::from_millis(options.poll_interval_ms)) => {} + } + } + } + } + + Ok(()) +} + +async fn handle_watch( + context: &AppContext, + command: WatchCommand, + stdout: &mut dyn Write, +) -> Result<(), ExecuteError> { + let mut subscription = context + .subscribe_state(command.label(), "subscribe to daemon state") + .await?; + let mut first = true; + loop { + let event = tokio::select! { + _ = tokio::signal::ctrl_c() => return Ok(()), + result = subscription.next_event() => { + result.map_err(|error| CliError::daemon_request(command.label(), context.paths.runtime_dir(), "stream daemon state", &error))? + } + }; + + if context.output == OutputMode::Human && !first { + writeln!(stdout)?; + } + write_watch_event(context, &command, &event, stdout)?; + first = false; + } +} + +async fn resolve_logs_target( + context: &AppContext, + command: &'static str, + target: LogsTargetCommand, +) -> Result { + match target { + LogsTargetCommand::Runtime => Ok(LogStreamIdentity::runtime_control()), + LogsTargetCommand::Entrypoint { registration_id } => { + let snapshot = context.query_snapshot(command, "query entrypoints").await?; + let entrypoint = snapshot + .registrations + .iter() + .find(|entrypoint| entrypoint.registration_id == registration_id) + .ok_or_else(|| { + CliError::target_not_found( + command, + format!("Entrypoint `{registration_id}` was not found."), + Some("Run `cadderctl entrypoints list` to inspect valid registration IDs.".to_string()), + ) + })?; + Ok(entrypoint.log_stream.clone()) + } + LogsTargetCommand::Domain { + domain, + registration, + } => { + let snapshot = context.query_snapshot(command, "query domains").await?; + let selected = select_domain( + command, + &snapshot, + &crate::cli::DomainSelectorArgs { + domain, + registration, + }, + )?; + let domain = domains_view(&snapshot, Some(&selected.registration_id)) + .domains + .into_iter() + .find(|domain| domain.canonical_domain == selected.canonical_domain) + .ok_or_else(|| { + CliError::target_not_found( + command, + "The selected domain no longer exists in the current daemon snapshot.", + Some("Retry the command after refreshing the daemon state.".to_string()), + ) + })?; + Ok(domain.log_stream) + } + } +} + +fn select_domain( + command: &'static str, + snapshot: &GuiStateSnapshot, + selector: &crate::cli::DomainSelectorArgs, +) -> Result { + resolve_domain(snapshot, &selector.domain, selector.registration.as_deref()).map_err(|error| { + match error { + crate::view::DomainResolveError::NotFound { + canonical_domain, + requested_registration, + } => CliError::target_not_found( + command, + match requested_registration { + Some(registration_id) => format!( + "Domain `{canonical_domain}` was not found under entrypoint `{registration_id}`." + ), + None => format!("Domain `{canonical_domain}` was not found."), + }, + Some( + "Run `cadderctl domains list` to inspect the current domain registrations.".to_string(), + ), + ), + crate::view::DomainResolveError::Ambiguous { + canonical_domain, + matches, + } => CliError::conflict_or_rejected( + command, + format!( + "Domain `{canonical_domain}` matches multiple entrypoints: {}.", + matches.join(", ") + ), + Some( + "Retry the command with `--registration ` to choose the exact entrypoint." + .to_string(), + ), + ), + } + }) +} + +fn write_one_shot( + output: OutputMode, + command: &'static str, + value: &T, + stdout: &mut dyn Write, + human: fn(&T, &mut dyn Write) -> io::Result<()>, +) -> io::Result<()> { + match output { + OutputMode::Human => human(value, stdout), + OutputMode::Json => render::write_json_success(command, value, stdout), + OutputMode::Jsonl => unreachable!("jsonl is validated before execution"), + }?; + stdout.flush() +} + +fn write_logs_stream_event( + output: OutputMode, + event: &'static str, + command: &'static str, + view: &LogsView, + stdout: &mut dyn Write, +) -> io::Result<()> { + match output { + OutputMode::Human => render::write_human_logs(view, stdout), + OutputMode::Json => unreachable!("json output is invalid for streaming commands"), + OutputMode::Jsonl => render::write_jsonl( + &json!({ + "event": event, + "command": command, + "generatedAtUtc": Utc::now(), + "data": view, + }), + stdout, + ), + }?; + stdout.flush() +} + +fn write_watch_event( + context: &AppContext, + command: &WatchCommand, + event: &StateChangedEvent, + stdout: &mut dyn Write, +) -> io::Result<()> { + match command { + WatchCommand::Status => { + let status = daemon_status_connected(context.paths.runtime_dir(), &event.snapshot); + match context.output { + OutputMode::Human => { + writeln!( + stdout, + "Event: {:?} (sequence {})", + event.change_kind, event.sequence_number + )?; + if let Some(registration_id) = &event.registration_id { + writeln!(stdout, "Registration: {registration_id}")?; + } + render::write_human_daemon_status(&status, stdout) + } + OutputMode::Jsonl => render::write_jsonl( + &render::WatchEnvelope { + event: "stateChanged", + command: command.label(), + generated_at_utc: Utc::now(), + sequence_number: Some(event.sequence_number), + change_kind: Some(format!("{:?}", event.change_kind)), + registration_id: event.registration_id.as_deref(), + data: &status, + }, + stdout, + ), + OutputMode::Json => unreachable!("json output is invalid for watch commands"), + } + } + WatchCommand::Entrypoints => { + let entrypoints = entrypoints_view(&event.snapshot); + match context.output { + OutputMode::Human => { + writeln!( + stdout, + "Event: {:?} (sequence {})", + event.change_kind, event.sequence_number + )?; + render::write_human_entrypoints(&entrypoints, stdout) + } + OutputMode::Jsonl => render::write_jsonl( + &render::WatchEnvelope { + event: "stateChanged", + command: command.label(), + generated_at_utc: Utc::now(), + sequence_number: Some(event.sequence_number), + change_kind: Some(format!("{:?}", event.change_kind)), + registration_id: event.registration_id.as_deref(), + data: &entrypoints, + }, + stdout, + ), + OutputMode::Json => unreachable!("json output is invalid for watch commands"), + } + } + WatchCommand::Domains => { + let domains = domains_view(&event.snapshot, None); + match context.output { + OutputMode::Human => { + writeln!( + stdout, + "Event: {:?} (sequence {})", + event.change_kind, event.sequence_number + )?; + render::write_human_domains(&domains, stdout) + } + OutputMode::Jsonl => render::write_jsonl( + &render::WatchEnvelope { + event: "stateChanged", + command: command.label(), + generated_at_utc: Utc::now(), + sequence_number: Some(event.sequence_number), + change_kind: Some(format!("{:?}", event.change_kind)), + registration_id: event.registration_id.as_deref(), + data: &domains, + }, + stdout, + ), + OutputMode::Json => unreachable!("json output is invalid for watch commands"), + } + } + WatchCommand::Diagnostics => { + let diagnostics = diagnostics_view(&event.snapshot); + match context.output { + OutputMode::Human => { + writeln!( + stdout, + "Event: {:?} (sequence {})", + event.change_kind, event.sequence_number + )?; + render::write_human_diagnostics(&diagnostics, stdout) + } + OutputMode::Jsonl => render::write_jsonl( + &render::WatchEnvelope { + event: "stateChanged", + command: command.label(), + generated_at_utc: Utc::now(), + sequence_number: Some(event.sequence_number), + change_kind: Some(format!("{:?}", event.change_kind)), + registration_id: event.registration_id.as_deref(), + data: &diagnostics, + }, + stdout, + ), + OutputMode::Json => unreachable!("json output is invalid for watch commands"), + } + } + }?; + stdout.flush() +} + +fn write_error( + output: OutputMode, + error: &CliError, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> io::Result<()> { + match output { + OutputMode::Human => render::write_human_error(error, stderr), + OutputMode::Json => render::write_json_error(error, stdout), + OutputMode::Jsonl => render::write_jsonl( + &json!({ + "event": "error", + "command": error.command, + "generatedAtUtc": Utc::now(), + "error": { + "kind": error.kind, + "exitCode": error.exit_code().code(), + "message": error.message, + "guidance": error.guidance, + } + }), + stdout, + ), + }?; + + match output { + OutputMode::Human => stderr.flush(), + OutputMode::Json | OutputMode::Jsonl => stdout.flush(), + } +} + +fn unavailable_message(runtime_dir: &std::path::Path, error: &anyhow::Error) -> String { + match connection_state_from_error(error) { + ConnectionStateView::NotRunning => format!( + "Cadder daemon is not running for runtime `{}`.", + runtime_dir.display() + ), + ConnectionStateView::ConnectionFailed => format!( + "Could not attach to Cadder daemon for runtime `{}`: {}.", + runtime_dir.display(), + format_error_chain(error) + ), + ConnectionStateView::Connected => "Attached to cadderd.".to_string(), + } +} + +fn connection_state_from_error(error: &anyhow::Error) -> ConnectionStateView { + if error.chain().any(|cause| { + cause.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionAborted + ) + }) + }) { + ConnectionStateView::NotRunning + } else { + ConnectionStateView::ConnectionFailed + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::{DomainSelectorArgs, EntrypointsCommand, LogReadArgs, TailArgs}; + use cadder_protocol::{ + ActivationState, ConfigState, DomainName, EntrypointInstanceIdentity, EntrypointRegistration, + LogAttributionKind, LogEntry, LogEntryKind, LogSeverity, OwnerProcessIdentity, + RegisteredDomain, RuntimeState, SourcePath, StateChangeKind, + }; + use chrono::{TimeZone, Utc}; + use serde_json::Value; + + fn timestamp() -> chrono::DateTime { + Utc + .with_ymd_and_hms(2026, 6, 16, 19, 0, 0) + .single() + .unwrap() + } + + fn registration(id: &str, domains: &[&str]) -> EntrypointRegistration { + let now = timestamp(); + let identity = EntrypointInstanceIdentity { + instance_id: id.to_string(), + started_at_utc: now, + shim_session_nonce: format!("{id}-nonce"), + }; + EntrypointRegistration { + registration_id: id.to_string(), + entrypoint_instance: identity.clone(), + source_working_directory: SourcePath::new("D:/Projects/App", None), + source_config_path: SourcePath::new("D:/Projects/App/Caddyfile", None), + registered_domains: domains + .iter() + .map(|domain| RegisteredDomain { + name: DomainName::parse(*domain), + activation_state: ActivationState::Active, + log_stream: LogStreamIdentity::domain(domain), + }) + .collect(), + activation_state: ActivationState::Active, + owner_process: OwnerProcessIdentity { + process_id: 17, + process_start_time_utc: now, + shim_session_nonce: identity.shim_session_nonce.clone(), + executable_path: Some("caddy.exe".to_string()), + }, + log_stream: LogStreamIdentity::entrypoint(id), + shim_run: None, + created_at_utc: now, + last_heartbeat_utc: now, + } + } + + fn snapshot(registrations: Vec) -> GuiStateSnapshot { + GuiStateSnapshot { + captured_at_utc: timestamp(), + registrations, + runtime: RuntimeState::idle(), + config: ConfigState::idle(), + } + } + + fn test_context(output: OutputMode) -> (tempfile::TempDir, AppContext) { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let context = AppContext { + output, + client: CadderClient::new(paths.clone()), + paths, + launch_options: DaemonLaunchOptions::default(), + }; + (temp, context) + } + + fn sample_logs_view() -> LogsView { + LogsView { + stream: LogStreamIdentity::domain("app.localhost"), + stream_status: cadder_protocol::LogStreamStatus::Active, + entries: vec![LogEntry { + sequence_number: 3, + timestamp_utc: timestamp(), + severity: LogSeverity::Warn, + stream: LogStreamIdentity::domain("app.localhost"), + attribution_kind: LogAttributionKind::Domain, + entry_kind: LogEntryKind::Normal, + raw_message: "tail event".to_string(), + domain_key: Some("app.localhost".to_string()), + source_registration_id: Some("shim-1".to_string()), + source_instance_id: Some("shim-1-instance".to_string()), + operation: None, + }], + next_cursor: Some("seq:3".to_string()), + has_gap: false, + has_more_before: false, + truncated_by_retention: false, + } + } + + fn sample_event() -> StateChangedEvent { + StateChangedEvent { + request_id: "watch".to_string(), + sequence_number: 7, + change_kind: StateChangeKind::Snapshot, + snapshot: snapshot(vec![registration("shim-1", &["app.localhost"])]), + registration_id: Some("shim-1".to_string()), + } + } + + fn parse_json(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes).unwrap() + } + + #[test] + fn validate_output_mode_rejects_invalid_combinations_and_ranges() { + let watch = Command::Watch { + command: WatchCommand::Status, + }; + let err = validate_output_mode(OutputMode::Json, &watch, "watch status").unwrap_err(); + assert_eq!(err.kind, CliErrorKind::InvalidUsage); + + let domains = Command::Domains { + command: DomainsCommand::List { registration: None }, + }; + let err = validate_output_mode(OutputMode::Jsonl, &domains, "domains list").unwrap_err(); + assert_eq!(err.kind, CliErrorKind::InvalidUsage); + + let logs_show = Command::Logs { + command: LogsCommand::Show { + target: LogsTargetCommand::Runtime, + options: LogReadArgs { + limit: 0, + minimum_severity: None, + }, + }, + }; + let err = validate_output_mode(OutputMode::Human, &logs_show, "logs show").unwrap_err(); + assert!(err.message.contains("between 1 and 500")); + + let logs_tail = Command::Logs { + command: LogsCommand::Tail { + target: LogsTargetCommand::Runtime, + options: TailArgs { + read: LogReadArgs { + limit: 20, + minimum_severity: None, + }, + poll_interval_ms: 10, + }, + }, + }; + let err = validate_output_mode(OutputMode::Jsonl, &logs_tail, "logs tail").unwrap_err(); + assert!(err.message.contains("poll-interval-ms")); + + let entrypoints = Command::Entrypoints { + command: EntrypointsCommand::List, + }; + validate_output_mode(OutputMode::Json, &entrypoints, "entrypoints list").unwrap(); + } + + #[test] + fn select_domain_maps_not_found_and_ambiguity_into_cli_errors() { + let missing = select_domain( + "domains disable", + &snapshot(vec![registration("shim-1", &["app.localhost"])]), + &DomainSelectorArgs { + domain: "missing.localhost".to_string(), + registration: None, + }, + ) + .unwrap_err(); + assert_eq!(missing.kind, CliErrorKind::TargetNotFound); + assert!(missing.message.contains("missing.localhost")); + + let ambiguous = select_domain( + "domains disable", + &snapshot(vec![ + registration("shim-1", &["app.localhost"]), + registration("shim-2", &["app.localhost"]), + ]), + &DomainSelectorArgs { + domain: "app.localhost".to_string(), + registration: None, + }, + ) + .unwrap_err(); + assert_eq!(ambiguous.kind, CliErrorKind::ConflictOrRejected); + assert!(ambiguous.message.contains("shim-1, shim-2")); + } + + #[test] + fn unavailable_message_distinguishes_not_running_from_connection_failure() { + let not_running = anyhow::Error::from(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "connection refused", + )); + assert_eq!( + connection_state_from_error(¬_running), + ConnectionStateView::NotRunning + ); + assert!( + unavailable_message(std::path::Path::new("runtime"), ¬_running).contains("is not running") + ); + + let connection_failed = anyhow::Error::msg("protocol mismatch"); + assert_eq!( + connection_state_from_error(&connection_failed), + ConnectionStateView::ConnectionFailed + ); + assert!( + unavailable_message(std::path::Path::new("runtime"), &connection_failed) + .contains("Could not attach") + ); + } + + #[test] + fn write_helpers_emit_expected_human_and_machine_output() { + let action = ActionResultView { + message: "updated".to_string(), + }; + + let mut human = Vec::new(); + write_one_shot( + OutputMode::Human, + "domains enable", + &action, + &mut human, + render::write_human_action, + ) + .unwrap(); + assert_eq!(String::from_utf8(human).unwrap(), "updated\n"); + + let mut json = Vec::new(); + write_one_shot( + OutputMode::Json, + "domains enable", + &action, + &mut json, + render::write_human_action, + ) + .unwrap(); + let json = parse_json(&json); + assert_eq!(json["ok"], true); + assert_eq!(json["command"], "domains enable"); + + let logs = sample_logs_view(); + let mut stream = Vec::new(); + write_logs_stream_event( + OutputMode::Human, + "logsPage", + "logs tail", + &logs, + &mut stream, + ) + .unwrap(); + assert!(String::from_utf8(stream).unwrap().contains("tail event")); + + let mut stream = Vec::new(); + write_logs_stream_event( + OutputMode::Jsonl, + "logsPage", + "logs tail", + &logs, + &mut stream, + ) + .unwrap(); + let json = parse_json(&stream); + assert_eq!(json["event"], "logsPage"); + assert_eq!(json["command"], "logs tail"); + + let error = CliError::invalid_usage( + "logs tail", + "Streaming commands support `human` or `jsonl` output.", + Some("Use `--output jsonl`.".to_string()), + ); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + write_error(OutputMode::Human, &error, &mut stdout, &mut stderr).unwrap(); + assert!(String::from_utf8(stderr).unwrap().contains("Guidance:")); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + write_error(OutputMode::Json, &error, &mut stdout, &mut stderr).unwrap(); + let json = parse_json(&stdout); + assert_eq!(json["error"]["kind"], "invalidUsage"); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + write_error(OutputMode::Jsonl, &error, &mut stdout, &mut stderr).unwrap(); + let json = parse_json(&stdout); + assert_eq!(json["event"], "error"); + assert_eq!(json["error"]["exitCode"], 2); + } + + #[test] + fn write_watch_event_supports_all_views_in_human_and_jsonl() { + let event = sample_event(); + + let (_temp, human_context) = test_context(OutputMode::Human); + let expectations = [ + (WatchCommand::Status, "Runtime directory:"), + (WatchCommand::Entrypoints, "Registration: shim-1"), + (WatchCommand::Domains, "Domain: app.localhost"), + (WatchCommand::Diagnostics, "No diagnostics."), + ]; + for (command, expected) in expectations { + let mut output = Vec::new(); + write_watch_event(&human_context, &command, &event, &mut output).unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains("Event: Snapshot (sequence 7)")); + assert!(text.contains(expected), "missing `{expected}` in `{text}`"); + } + + let (_temp, jsonl_context) = test_context(OutputMode::Jsonl); + let expectations = [ + (WatchCommand::Status, "watch status"), + (WatchCommand::Entrypoints, "watch entrypoints"), + (WatchCommand::Domains, "watch domains"), + (WatchCommand::Diagnostics, "watch diagnostics"), + ]; + for (command, label) in expectations { + let mut output = Vec::new(); + write_watch_event(&jsonl_context, &command, &event, &mut output).unwrap(); + let json = parse_json(&output); + assert_eq!(json["event"], "stateChanged"); + assert_eq!(json["command"], label); + assert_eq!(json["sequenceNumber"], 7); + } + } +} diff --git a/crates/cadderctl/src/cli.rs b/crates/cadderctl/src/cli.rs new file mode 100644 index 0000000..fcc0f97 --- /dev/null +++ b/crates/cadderctl/src/cli.rs @@ -0,0 +1,438 @@ +use clap::{Args, Parser, Subcommand, ValueEnum}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum OutputMode { + Human, + Json, + Jsonl, +} + +impl OutputMode { + pub fn is_streaming(self) -> bool { + matches!(self, Self::Jsonl) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum SeverityArg { + Trace, + Debug, + Info, + Warn, + Error, + Fatal, +} + +#[derive(Debug, Clone, Parser)] +#[command( + name = "cadderctl", + version, + about = "Cadder non-TUI CLI for daemon state, domains, logs, and automation", + long_about = "Attaches to an existing Cadder daemon to inspect state, toggle managed routes, query diagnostics and logs, and explicitly start or stop cadderd for scripting and operator workflows." +)] +pub struct CliArgs { + #[arg( + long, + global = true, + help = "Override the Cadder runtime directory used to find daemon IPC and state" + )] + pub runtime_dir: Option, + + #[arg( + long, + global = true, + help = "Path to a cadderd executable for the explicit daemon start command" + )] + pub daemon_path: Option, + + #[arg( + long, + global = true, + help = "Command or path passed to cadderd when explicitly starting the real Caddy binary" + )] + pub real_caddy_command: Option, + + #[arg( + long, + global = true, + value_enum, + default_value_t = OutputMode::Human, + help = "Output mode: human for operators, json for one-shot scripting, jsonl for streaming commands" + )] + pub output: OutputMode, + + #[command(subcommand)] + pub command: Command, +} + +impl CliArgs { + pub fn command_label(&self) -> &'static str { + self.command.label() + } +} + +#[derive(Debug, Clone, Subcommand)] +pub enum Command { + Daemon { + #[command(subcommand)] + command: DaemonCommand, + }, + Entrypoints { + #[command(subcommand)] + command: EntrypointsCommand, + }, + Domains { + #[command(subcommand)] + command: DomainsCommand, + }, + Diagnostics { + #[command(subcommand)] + command: DiagnosticsCommand, + }, + Logs { + #[command(subcommand)] + command: LogsCommand, + }, + Watch { + #[command(subcommand)] + command: WatchCommand, + }, +} + +impl Command { + pub fn label(&self) -> &'static str { + match self { + Self::Daemon { command } => command.label(), + Self::Entrypoints { command } => command.label(), + Self::Domains { command } => command.label(), + Self::Diagnostics { command } => command.label(), + Self::Logs { command } => command.label(), + Self::Watch { command } => command.label(), + } + } +} + +#[derive(Debug, Clone, Subcommand)] +pub enum DaemonCommand { + Status, + Start, + Shutdown, +} + +impl DaemonCommand { + pub fn label(&self) -> &'static str { + match self { + Self::Status => "daemon status", + Self::Start => "daemon start", + Self::Shutdown => "daemon shutdown", + } + } +} + +#[derive(Debug, Clone, Subcommand)] +pub enum EntrypointsCommand { + List, + Enable { registration_id: String }, + Disable { registration_id: String }, +} + +impl EntrypointsCommand { + pub fn label(&self) -> &'static str { + match self { + Self::List => "entrypoints list", + Self::Enable { .. } => "entrypoints enable", + Self::Disable { .. } => "entrypoints disable", + } + } +} + +#[derive(Debug, Clone, Args)] +pub struct DomainSelectorArgs { + #[arg(help = "Domain name to select")] + pub domain: String, + + #[arg( + long, + help = "Entrypoint registration ID used to disambiguate repeated domain names" + )] + pub registration: Option, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum DomainsCommand { + List { + #[arg( + long, + help = "Only list domains from the selected entrypoint registration" + )] + registration: Option, + }, + Enable { + #[command(flatten)] + selector: DomainSelectorArgs, + }, + Disable { + #[command(flatten)] + selector: DomainSelectorArgs, + }, +} + +impl DomainsCommand { + pub fn label(&self) -> &'static str { + match self { + Self::List { .. } => "domains list", + Self::Enable { .. } => "domains enable", + Self::Disable { .. } => "domains disable", + } + } +} + +#[derive(Debug, Clone, Subcommand)] +pub enum DiagnosticsCommand { + Show, +} + +impl DiagnosticsCommand { + pub fn label(&self) -> &'static str { + "diagnostics show" + } +} + +#[derive(Debug, Clone, Args)] +pub struct LogReadArgs { + #[arg( + long, + default_value_t = 50, + help = "Maximum number of retained log entries to read per request" + )] + pub limit: usize, + + #[arg( + long, + value_enum, + help = "Only include log entries at or above this severity" + )] + pub minimum_severity: Option, +} + +#[derive(Debug, Clone, Args)] +pub struct TailArgs { + #[command(flatten)] + pub read: LogReadArgs, + + #[arg( + long, + default_value_t = 750, + help = "Polling interval in milliseconds for tailing current log streams" + )] + pub poll_interval_ms: u64, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum LogsTargetCommand { + Runtime, + Entrypoint { + registration_id: String, + }, + Domain { + domain: String, + #[arg( + long, + help = "Entrypoint registration ID used to disambiguate repeated domain names" + )] + registration: Option, + }, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum LogsCommand { + Show { + #[command(subcommand)] + target: LogsTargetCommand, + #[command(flatten)] + options: LogReadArgs, + }, + Tail { + #[command(subcommand)] + target: LogsTargetCommand, + #[command(flatten)] + options: TailArgs, + }, +} + +impl LogsCommand { + pub fn label(&self) -> &'static str { + match self { + Self::Show { .. } => "logs show", + Self::Tail { .. } => "logs tail", + } + } +} + +#[derive(Debug, Clone, Subcommand)] +pub enum WatchCommand { + Status, + Entrypoints, + Domains, + Diagnostics, +} + +impl WatchCommand { + pub fn label(&self) -> &'static str { + match self { + Self::Status => "watch status", + Self::Entrypoints => "watch entrypoints", + Self::Domains => "watch domains", + Self::Diagnostics => "watch diagnostics", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn command_metadata_matches_release_identity() { + let command = CliArgs::command(); + + assert_eq!(command.get_name(), "cadderctl"); + assert_eq!(command.get_version(), Some(env!("CARGO_PKG_VERSION"))); + assert_eq!( + command.get_about().map(ToString::to_string), + Some(env!("CARGO_PKG_DESCRIPTION").to_string()) + ); + } + + #[test] + fn long_help_describes_attach_and_start_behavior() { + let help = CliArgs::command().render_long_help().to_string(); + + assert!( + help.contains("Attaches to an existing Cadder daemon"), + "long help should describe attach-only behavior: {help}" + ); + assert!( + help.contains("explicitly start or stop cadderd"), + "long help should mention explicit daemon control: {help}" + ); + } + + #[test] + fn parses_domain_selector_with_registration() { + let args = CliArgs::parse_from([ + "cadderctl", + "domains", + "disable", + "app.localhost", + "--registration", + "shim-1", + ]); + + match args.command { + Command::Domains { + command: + DomainsCommand::Disable { + selector: + DomainSelectorArgs { + domain, + registration, + }, + }, + } => { + assert_eq!(domain, "app.localhost"); + assert_eq!(registration.as_deref(), Some("shim-1")); + } + other => panic!("unexpected command parsed: {other:?}"), + } + } + + #[test] + fn parses_logs_tail_target_and_options() { + let args = CliArgs::parse_from([ + "cadderctl", + "--output", + "jsonl", + "logs", + "tail", + "--limit", + "10", + "--minimum-severity", + "warn", + "--poll-interval-ms", + "1000", + "domain", + "app.localhost", + "--registration", + "shim-1", + ]); + + assert_eq!(args.output, OutputMode::Jsonl); + match args.command { + Command::Logs { + command: + LogsCommand::Tail { + target: + LogsTargetCommand::Domain { + domain, + registration, + }, + options, + }, + } => { + assert_eq!(domain, "app.localhost"); + assert_eq!(registration.as_deref(), Some("shim-1")); + assert_eq!(options.read.limit, 10); + assert_eq!(options.read.minimum_severity, Some(SeverityArg::Warn)); + assert_eq!(options.poll_interval_ms, 1000); + } + other => panic!("unexpected command parsed: {other:?}"), + } + } + + #[test] + fn parses_global_flags_after_subcommand_path() { + let args = CliArgs::parse_from([ + "cadderctl", + "logs", + "show", + "--limit", + "20", + "domain", + "app.localhost", + "--registration", + "shim-1", + "--output", + "json", + "--runtime-dir", + "C:/temp/cadder-runtime", + ]); + + assert_eq!(args.output, OutputMode::Json); + assert_eq!( + args.runtime_dir.as_deref(), + Some(std::path::Path::new("C:/temp/cadder-runtime")) + ); + + match args.command { + Command::Logs { + command: + LogsCommand::Show { + target: + LogsTargetCommand::Domain { + domain, + registration, + }, + options, + }, + } => { + assert_eq!(options.limit, 20); + assert_eq!(domain, "app.localhost"); + assert_eq!(registration.as_deref(), Some("shim-1")); + } + other => panic!("unexpected command parsed: {other:?}"), + } + } +} diff --git a/crates/cadderctl/src/error.rs b/crates/cadderctl/src/error.rs new file mode 100644 index 0000000..23a80fd --- /dev/null +++ b/crates/cadderctl/src/error.rs @@ -0,0 +1,364 @@ +use anyhow::Error; +use serde::Serialize; +use std::{ + fmt::{self, Display}, + path::Path, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum CliExitCode { + Success, + InvalidUsage, + DaemonUnavailable, + DaemonStartFailure, + TargetNotFound, + ConflictOrRejected, + PermissionOrElevation, + UnsupportedOperation, + IpcFailure, +} + +impl CliExitCode { + pub fn code(self) -> u8 { + match self { + Self::Success => 0, + Self::InvalidUsage => 2, + Self::DaemonUnavailable => 3, + Self::DaemonStartFailure => 4, + Self::TargetNotFound => 5, + Self::ConflictOrRejected => 6, + Self::PermissionOrElevation => 7, + Self::UnsupportedOperation => 8, + Self::IpcFailure => 9, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum CliErrorKind { + InvalidUsage, + DaemonUnavailable, + DaemonStartFailure, + TargetNotFound, + ConflictOrRejected, + PermissionOrElevation, + UnsupportedOperation, + IpcFailure, +} + +impl CliErrorKind { + pub fn exit_code(self) -> CliExitCode { + match self { + Self::InvalidUsage => CliExitCode::InvalidUsage, + Self::DaemonUnavailable => CliExitCode::DaemonUnavailable, + Self::DaemonStartFailure => CliExitCode::DaemonStartFailure, + Self::TargetNotFound => CliExitCode::TargetNotFound, + Self::ConflictOrRejected => CliExitCode::ConflictOrRejected, + Self::PermissionOrElevation => CliExitCode::PermissionOrElevation, + Self::UnsupportedOperation => CliExitCode::UnsupportedOperation, + Self::IpcFailure => CliExitCode::IpcFailure, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CliError { + pub kind: CliErrorKind, + pub message: String, + pub guidance: Option, + #[serde(skip)] + pub command: &'static str, +} + +impl CliError { + pub fn new( + command: &'static str, + kind: CliErrorKind, + message: impl Into, + guidance: Option, + ) -> Self { + Self { + kind, + message: message.into(), + guidance, + command, + } + } + + pub fn exit_code(&self) -> CliExitCode { + self.kind.exit_code() + } + + pub fn invalid_usage( + command: &'static str, + message: impl Into, + guidance: Option, + ) -> Self { + Self::new(command, CliErrorKind::InvalidUsage, message, guidance) + } + + pub fn target_not_found( + command: &'static str, + message: impl Into, + guidance: Option, + ) -> Self { + Self::new(command, CliErrorKind::TargetNotFound, message, guidance) + } + + pub fn conflict_or_rejected( + command: &'static str, + message: impl Into, + guidance: Option, + ) -> Self { + Self::new(command, CliErrorKind::ConflictOrRejected, message, guidance) + } + + pub fn unsupported( + command: &'static str, + message: impl Into, + guidance: Option, + ) -> Self { + Self::new( + command, + CliErrorKind::UnsupportedOperation, + message, + guidance, + ) + } + + pub fn daemon_request(command: &'static str, paths: &Path, action: &str, error: &Error) -> Self { + if error_indicates_permission(error) { + return Self::new( + command, + CliErrorKind::PermissionOrElevation, + format!("Could not {action}: {}.", format_error_chain(error)), + Some("Check file-system and local IPC permissions, then retry the command.".to_string()), + ); + } + + if daemon_error_indicates_unavailable(error) { + return Self::new( + command, + CliErrorKind::DaemonUnavailable, + format!( + "Cadder daemon is unavailable for runtime `{}`.", + paths.display() + ), + Some(start_guidance(paths)), + ); + } + + Self::new( + command, + CliErrorKind::IpcFailure, + format!("Could not {action}: {}.", format_error_chain(error)), + Some( + "The daemon responded unexpectedly or the IPC exchange failed before completion." + .to_string(), + ), + ) + } + + pub fn daemon_start(command: &'static str, paths: &Path, error: &Error) -> Self { + if error_indicates_permission(error) { + return Self::new( + command, + CliErrorKind::PermissionOrElevation, + format!("Could not start cadderd: {}.", format_error_chain(error)), + Some( + "Check the cadderd path, executable permissions, and runtime directory permissions before retrying." + .to_string(), + ), + ); + } + + Self::new( + command, + CliErrorKind::DaemonStartFailure, + format!("Could not start cadderd: {}.", format_error_chain(error)), + Some(format!( + "Retry `cadderctl daemon start --runtime-dir \"{}\"` after fixing the daemon path or startup problem.", + paths.display() + )), + ) + } +} + +impl Display for CliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for CliError {} + +pub fn format_error_chain(error: &Error) -> String { + let mut messages = error.chain().map(ToString::to_string).collect::>(); + messages.dedup(); + messages.join(": ") +} + +pub fn daemon_error_indicates_unavailable(error: &Error) -> bool { + error.chain().any(|cause| { + cause.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::UnexpectedEof + ) + }) + }) +} + +pub fn error_indicates_permission(error: &Error) -> bool { + error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|error| matches!(error.kind(), std::io::ErrorKind::PermissionDenied)) + }) || format_error_chain(error) + .to_ascii_lowercase() + .contains("permission denied") + || format_error_chain(error) + .to_ascii_lowercase() + .contains("access is denied") +} + +pub fn start_guidance(paths: &Path) -> String { + format!( + "Start `cadderd --runtime-dir \"{}\"` or run `cadderctl daemon start --runtime-dir \"{}\"`, then retry.", + paths.display(), + paths.display() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + + #[test] + fn exit_codes_remain_stable_for_all_error_kinds() { + assert_eq!(CliExitCode::Success.code(), 0); + assert_eq!(CliExitCode::InvalidUsage.code(), 2); + assert_eq!(CliExitCode::DaemonUnavailable.code(), 3); + assert_eq!(CliExitCode::DaemonStartFailure.code(), 4); + assert_eq!(CliExitCode::TargetNotFound.code(), 5); + assert_eq!(CliExitCode::ConflictOrRejected.code(), 6); + assert_eq!(CliExitCode::PermissionOrElevation.code(), 7); + assert_eq!(CliExitCode::UnsupportedOperation.code(), 8); + assert_eq!(CliExitCode::IpcFailure.code(), 9); + } + + #[test] + fn daemon_unavailable_maps_to_stable_exit_code() { + let error = Error::from(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "connection refused", + )); + + let mapped = CliError::daemon_request( + "domains list", + Path::new("runtime"), + "query domains", + &error, + ); + + assert_eq!(mapped.kind, CliErrorKind::DaemonUnavailable); + assert_eq!(mapped.exit_code().code(), 3); + assert!(mapped.guidance.unwrap().contains("cadderctl daemon start")); + } + + #[test] + fn permission_errors_map_to_permission_exit_code() { + let error = Error::from(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "permission denied", + )); + + let mapped = CliError::daemon_start("daemon start", Path::new("runtime"), &error); + + assert_eq!(mapped.kind, CliErrorKind::PermissionOrElevation); + assert_eq!(mapped.exit_code().code(), 7); + } + + #[test] + fn daemon_request_maps_unknown_errors_to_ipc_failure() { + let error = anyhow!("unexpected daemon envelope"); + + let mapped = CliError::daemon_request( + "logs show", + Path::new("runtime"), + "query retained logs", + &error, + ); + + assert_eq!(mapped.kind, CliErrorKind::IpcFailure); + assert!(mapped.message.contains("unexpected daemon envelope")); + assert!( + mapped + .guidance + .as_deref() + .is_some_and(|guidance| guidance.contains("IPC exchange failed")) + ); + } + + #[test] + fn daemon_start_maps_non_permission_errors_to_start_failure() { + let error = anyhow!("spawn failed"); + + let mapped = CliError::daemon_start("daemon start", Path::new("runtime"), &error); + + assert_eq!(mapped.kind, CliErrorKind::DaemonStartFailure); + assert_eq!(mapped.exit_code().code(), 4); + assert!(mapped.message.contains("spawn failed")); + } + + #[test] + fn helper_constructors_set_expected_error_kinds() { + assert_eq!( + CliError::invalid_usage("cmd", "bad", None).kind, + CliErrorKind::InvalidUsage + ); + assert_eq!( + CliError::target_not_found("cmd", "missing", None).kind, + CliErrorKind::TargetNotFound + ); + assert_eq!( + CliError::conflict_or_rejected("cmd", "busy", None).kind, + CliErrorKind::ConflictOrRejected + ); + assert_eq!( + CliError::unsupported("cmd", "unsupported", None).kind, + CliErrorKind::UnsupportedOperation + ); + } + + #[test] + fn permission_detection_accepts_access_denied_text() { + let error = anyhow!("Access is denied while opening the socket"); + + assert!(error_indicates_permission(&error)); + } + + #[test] + fn format_error_chain_deduplicates_repeated_messages() { + let error = anyhow!("broken").context("broken").context("top level"); + + assert_eq!(format_error_chain(&error), "top level: broken"); + } + + #[test] + fn daemon_unavailable_detection_accepts_connection_reset_variants() { + let error = Error::from(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "connection reset", + )); + + assert!(daemon_error_indicates_unavailable(&error)); + } +} diff --git a/crates/cadderctl/src/lib.rs b/crates/cadderctl/src/lib.rs new file mode 100644 index 0000000..b5f157b --- /dev/null +++ b/crates/cadderctl/src/lib.rs @@ -0,0 +1,5 @@ +pub mod app; +pub mod cli; +pub mod error; +pub mod render; +pub mod view; diff --git a/crates/cadderctl/src/main.rs b/crates/cadderctl/src/main.rs new file mode 100644 index 0000000..86947a9 --- /dev/null +++ b/crates/cadderctl/src/main.rs @@ -0,0 +1,25 @@ +use cadderctl::{app, cli::CliArgs}; +use clap::Parser; +use std::{io, process::ExitCode}; + +#[tokio::main] +async fn main() -> ExitCode { + let args = match CliArgs::try_parse() { + Ok(args) => args, + Err(error) => { + let exit_code = error.exit_code(); + let _ = error.print(); + return ExitCode::from(u8::try_from(exit_code).unwrap_or(2)); + } + }; + + let mut stdout = io::stdout().lock(); + let mut stderr = io::stderr().lock(); + match app::run(args, &mut stdout, &mut stderr).await { + Ok(code) => ExitCode::from(code), + Err(error) => { + eprintln!("I/O error: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/cadderctl/src/render.rs b/crates/cadderctl/src/render.rs new file mode 100644 index 0000000..4c89598 --- /dev/null +++ b/crates/cadderctl/src/render.rs @@ -0,0 +1,617 @@ +use crate::{ + error::CliError, + view::{ + ActionResultView, DaemonStartView, DaemonStatusView, DiagnosticsView, DomainListView, + EntrypointListView, LogsView, + }, +}; +use chrono::Utc; +use serde::Serialize; +use std::io::{self, Write}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SuccessEnvelope<'a, T> { + ok: bool, + command: &'a str, + generated_at_utc: chrono::DateTime, + data: &'a T, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorEnvelope<'a> { + ok: bool, + command: &'a str, + generated_at_utc: chrono::DateTime, + error: ErrorPayload<'a>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorPayload<'a> { + kind: crate::error::CliErrorKind, + exit_code: u8, + message: &'a str, + guidance: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WatchEnvelope<'a, T> { + pub event: &'a str, + pub command: &'a str, + pub generated_at_utc: chrono::DateTime, + pub sequence_number: Option, + pub change_kind: Option, + pub registration_id: Option<&'a str>, + pub data: &'a T, +} + +pub fn write_json_success( + command: &'static str, + data: &T, + writer: &mut dyn Write, +) -> io::Result<()> { + let envelope = SuccessEnvelope { + ok: true, + command, + generated_at_utc: Utc::now(), + data, + }; + serde_json::to_writer_pretty(&mut *writer, &envelope)?; + writeln!(writer) +} + +pub fn write_json_error(error: &CliError, writer: &mut dyn Write) -> io::Result<()> { + let envelope = ErrorEnvelope { + ok: false, + command: error.command, + generated_at_utc: Utc::now(), + error: ErrorPayload { + kind: error.kind, + exit_code: error.exit_code().code(), + message: &error.message, + guidance: error.guidance.as_deref(), + }, + }; + serde_json::to_writer_pretty(&mut *writer, &envelope)?; + writeln!(writer) +} + +pub fn write_jsonl(value: &T, writer: &mut dyn Write) -> io::Result<()> { + serde_json::to_writer(&mut *writer, value)?; + writeln!(writer) +} + +pub fn write_human_error(error: &CliError, writer: &mut dyn Write) -> io::Result<()> { + writeln!(writer, "Error: {}", error.message)?; + if let Some(guidance) = &error.guidance { + writeln!(writer, "Guidance: {guidance}")?; + } + Ok(()) +} + +pub fn write_human_daemon_status( + view: &DaemonStatusView, + writer: &mut dyn Write, +) -> io::Result<()> { + writeln!(writer, "Runtime directory: {}", view.runtime_dir)?; + writeln!(writer, "Connection: {:?}", view.connection_state)?; + writeln!(writer, "Message: {}", view.message)?; + if let Some(guidance) = &view.guidance { + writeln!(writer, "Guidance: {guidance}")?; + } + if let Some(captured) = view.captured_at_utc { + writeln!(writer, "Captured at: {captured}")?; + } + writeln!(writer, "Entrypoints: {}", view.counts.entrypoints)?; + writeln!(writer, "Domains: {}", view.counts.domains)?; + writeln!(writer, "Active domains: {}", view.counts.active_domains)?; + if let Some(runtime) = &view.runtime { + writeln!(writer, "Runtime status: {}", runtime.status)?; + if let Some(process_id) = runtime.process_id { + writeln!(writer, "Runtime process ID: {process_id}")?; + } + if let Some(version) = &runtime.version { + writeln!(writer, "Runtime version: {version}")?; + } + } + if let Some(config) = &view.config { + writeln!(writer, "Config status: {}", config.status)?; + } + Ok(()) +} + +pub fn write_human_daemon_start(view: &DaemonStartView, writer: &mut dyn Write) -> io::Result<()> { + writeln!(writer, "{}", view.message)?; + write_human_daemon_status(&view.status, writer) +} + +pub fn write_human_action(view: &ActionResultView, writer: &mut dyn Write) -> io::Result<()> { + writeln!(writer, "{}", view.message) +} + +pub fn write_human_entrypoints( + view: &EntrypointListView, + writer: &mut dyn Write, +) -> io::Result<()> { + writeln!(writer, "Captured at: {}", view.captured_at_utc)?; + if view.entrypoints.is_empty() { + return writeln!(writer, "No entrypoints registered."); + } + + for entrypoint in &view.entrypoints { + writeln!(writer, "Registration: {}", entrypoint.registration_id)?; + writeln!(writer, " Activation: {:?}", entrypoint.activation_state)?; + writeln!( + writer, + " Working directory: {}", + entrypoint.working_directory + )?; + writeln!(writer, " Config path: {}", entrypoint.config_path)?; + writeln!( + writer, + " Domains: {} total, {} active", + entrypoint.domain_count, entrypoint.active_domain_count + )?; + writeln!(writer, " Process ID: {}", entrypoint.process_id)?; + if let Some(adapter) = &entrypoint.adapter { + writeln!(writer, " Adapter: {adapter}")?; + } + if let Some(command_line) = &entrypoint.command_line { + writeln!(writer, " Command line: {command_line}")?; + } + } + Ok(()) +} + +pub fn write_human_domains(view: &DomainListView, writer: &mut dyn Write) -> io::Result<()> { + writeln!(writer, "Captured at: {}", view.captured_at_utc)?; + if view.domains.is_empty() { + return writeln!(writer, "No domains registered."); + } + + for domain in &view.domains { + writeln!(writer, "Domain: {}", domain.domain)?; + writeln!(writer, " Canonical: {}", domain.canonical_domain)?; + writeln!(writer, " Registration: {}", domain.registration_id)?; + writeln!(writer, " Activation: {:?}", domain.activation_state)?; + writeln!( + writer, + " Entrypoint activation: {:?}", + domain.entrypoint_activation_state + )?; + writeln!(writer, " Working directory: {}", domain.working_directory)?; + writeln!(writer, " Config path: {}", domain.config_path)?; + } + Ok(()) +} + +pub fn write_human_diagnostics(view: &DiagnosticsView, writer: &mut dyn Write) -> io::Result<()> { + writeln!(writer, "Captured at: {}", view.captured_at_utc)?; + writeln!(writer, "Runtime status: {}", view.runtime.status)?; + writeln!(writer, "Config status: {}", view.config.status)?; + if view.runtime_diagnostics.is_empty() && view.config_diagnostics.is_empty() { + return writeln!(writer, "No diagnostics."); + } + + for diagnostic in &view.runtime_diagnostics { + write_runtime_diagnostic(diagnostic, writer)?; + } + for diagnostic in &view.config_diagnostics { + write_config_diagnostic(diagnostic, writer)?; + } + Ok(()) +} + +pub fn write_human_logs(view: &LogsView, writer: &mut dyn Write) -> io::Result<()> { + writeln!(writer, "Stream: {}", view.stream.stream_id)?; + writeln!(writer, "Status: {:?}", view.stream_status)?; + if let Some(cursor) = &view.next_cursor { + writeln!(writer, "Next cursor: {cursor}")?; + } + if view.has_gap { + writeln!( + writer, + "Notice: retained log history has a gap before the newest entries." + )?; + } + if view.truncated_by_retention { + writeln!( + writer, + "Notice: older log entries were dropped by retention." + )?; + } + if view.entries.is_empty() { + return writeln!(writer, "No log entries matched the request."); + } + + for entry in &view.entries { + writeln!( + writer, + "[{}] {:?} {}", + entry.timestamp_utc, entry.severity, entry.raw_message + )?; + } + Ok(()) +} + +fn write_runtime_diagnostic( + diagnostic: &cadder_protocol::RuntimeDiagnostic, + writer: &mut dyn Write, +) -> io::Result<()> { + writeln!(writer, "Runtime diagnostic: {}", diagnostic.code)?; + writeln!(writer, " Message: {}", diagnostic.message)?; + if let Some(operation) = &diagnostic.operation { + writeln!(writer, " Operation: {operation}")?; + } + Ok(()) +} + +fn write_config_diagnostic( + diagnostic: &cadder_protocol::ConfigDiagnostic, + writer: &mut dyn Write, +) -> io::Result<()> { + writeln!(writer, "Config diagnostic: {}", diagnostic.code)?; + writeln!(writer, " Message: {}", diagnostic.message)?; + if let Some(domain) = &diagnostic.domain_key { + writeln!(writer, " Domain: {domain}")?; + } + if !diagnostic.source_config_paths.is_empty() { + writeln!( + writer, + " Source paths: {}", + diagnostic.source_config_paths.join(", ") + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + error::{CliError, CliErrorKind}, + view::{ConfigView, ConnectionStateView, CountsView, DomainView, EntrypointView, RuntimeView}, + }; + use cadder_protocol::{ + ActivationState, ConfigDiagnostic, LogAttributionKind, LogEntry, LogEntryKind, LogSeverity, + LogStreamIdentity, LogStreamStatus, RuntimeDiagnostic, + }; + use chrono::{TimeZone, Utc}; + use serde_json::Value; + + fn timestamp() -> chrono::DateTime { + Utc + .with_ymd_and_hms(2026, 6, 16, 18, 45, 0) + .single() + .unwrap() + } + + fn status_view() -> DaemonStatusView { + DaemonStatusView { + runtime_dir: "C:/runtime".to_string(), + connection_state: ConnectionStateView::Connected, + message: "Attached to cadderd.".to_string(), + guidance: Some("No action required.".to_string()), + captured_at_utc: Some(timestamp()), + runtime: Some(RuntimeView { + status: "Running".to_string(), + binary_path: Some("caddy.exe".to_string()), + version: Some("2.9.1".to_string()), + process_id: Some(42), + admin_endpoint: Some("http://127.0.0.1:2019".to_string()), + diagnostics: Vec::new(), + }), + config: Some(ConfigView { + status: "Applied".to_string(), + last_attempted_at_utc: Some(timestamp()), + last_successful_reload_at_utc: Some(timestamp()), + effective_config_hash: Some("abc123".to_string()), + diagnostics: Vec::new(), + }), + counts: CountsView { + entrypoints: 2, + domains: 4, + active_domains: 3, + }, + } + } + + fn entrypoint_list() -> EntrypointListView { + EntrypointListView { + captured_at_utc: timestamp(), + entrypoints: vec![EntrypointView { + registration_id: "shim-1".to_string(), + activation_state: ActivationState::Active, + working_directory: "D:/Projects/App".to_string(), + config_path: "D:/Projects/App/Caddyfile".to_string(), + started_at_utc: timestamp(), + last_heartbeat_utc: timestamp(), + process_id: 17, + executable_path: Some("caddy.exe".to_string()), + domain_count: 2, + active_domain_count: 1, + adapter: Some("caddyfile".to_string()), + command_line: Some("run --adapter caddyfile".to_string()), + }], + } + } + + fn domain_list() -> DomainListView { + DomainListView { + captured_at_utc: timestamp(), + domains: vec![DomainView { + registration_id: "shim-1".to_string(), + domain: "App.Localhost".to_string(), + canonical_domain: "app.localhost".to_string(), + activation_state: ActivationState::Inactive, + entrypoint_activation_state: ActivationState::Active, + working_directory: "D:/Projects/App".to_string(), + config_path: "D:/Projects/App/Caddyfile".to_string(), + log_stream: LogStreamIdentity::domain("app.localhost"), + }], + } + } + + fn diagnostics_view_with_entries() -> DiagnosticsView { + DiagnosticsView { + captured_at_utc: timestamp(), + runtime: RuntimeView { + status: "Running".to_string(), + binary_path: None, + version: None, + process_id: None, + admin_endpoint: None, + diagnostics: vec![RuntimeDiagnostic { + code: "runtime-warning".to_string(), + message: "Backend restart required".to_string(), + operation: Some("reload".to_string()), + }], + }, + config: ConfigView { + status: "Failed".to_string(), + last_attempted_at_utc: Some(timestamp()), + last_successful_reload_at_utc: None, + effective_config_hash: None, + diagnostics: vec![ConfigDiagnostic { + code: "config-error".to_string(), + message: "Duplicate host".to_string(), + domain_key: Some("app.localhost".to_string()), + source_config_paths: vec!["D:/Projects/App/Caddyfile".to_string()], + }], + }, + runtime_diagnostics: vec![RuntimeDiagnostic { + code: "runtime-warning".to_string(), + message: "Backend restart required".to_string(), + operation: Some("reload".to_string()), + }], + config_diagnostics: vec![ConfigDiagnostic { + code: "config-error".to_string(), + message: "Duplicate host".to_string(), + domain_key: Some("app.localhost".to_string()), + source_config_paths: vec!["D:/Projects/App/Caddyfile".to_string()], + }], + } + } + + fn logs_view_with_entry() -> LogsView { + LogsView { + stream: LogStreamIdentity::domain("app.localhost"), + stream_status: LogStreamStatus::Active, + entries: vec![LogEntry { + sequence_number: 7, + timestamp_utc: timestamp(), + severity: LogSeverity::Error, + stream: LogStreamIdentity::domain("app.localhost"), + attribution_kind: LogAttributionKind::Domain, + entry_kind: LogEntryKind::Normal, + raw_message: "backend failed".to_string(), + domain_key: Some("app.localhost".to_string()), + source_registration_id: Some("shim-1".to_string()), + source_instance_id: Some("instance-1".to_string()), + operation: Some("reload".to_string()), + }], + next_cursor: Some("seq:7".to_string()), + has_gap: true, + has_more_before: false, + truncated_by_retention: true, + } + } + + fn parse_json(text: &[u8]) -> Value { + serde_json::from_slice(text).unwrap() + } + + #[test] + fn json_helpers_write_expected_envelopes() { + let mut stdout = Vec::new(); + write_json_success("daemon status", &status_view(), &mut stdout).unwrap(); + let json = parse_json(&stdout); + assert_eq!(json["ok"], true); + assert_eq!(json["command"], "daemon status"); + assert_eq!(json["data"]["runtimeDir"], "C:/runtime"); + + let error = CliError::new( + "domains list", + CliErrorKind::DaemonUnavailable, + "daemon unavailable", + Some("start the daemon".to_string()), + ); + let mut stderr = Vec::new(); + write_json_error(&error, &mut stderr).unwrap(); + let json = parse_json(&stderr); + assert_eq!(json["ok"], false); + assert_eq!(json["error"]["kind"], "daemonUnavailable"); + assert_eq!(json["error"]["exitCode"], 3); + + let mut jsonl = Vec::new(); + write_jsonl(&serde_json::json!({ "event": "tick" }), &mut jsonl).unwrap(); + assert_eq!(String::from_utf8(jsonl).unwrap(), "{\"event\":\"tick\"}\n"); + } + + #[test] + fn human_status_and_actions_include_key_fields() { + let error = CliError::new( + "daemon start", + CliErrorKind::DaemonStartFailure, + "Could not start cadderd.", + Some("Check the path.".to_string()), + ); + let mut output = Vec::new(); + write_human_error(&error, &mut output).unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains("Error: Could not start cadderd.")); + assert!(text.contains("Guidance: Check the path.")); + + let mut output = Vec::new(); + write_human_daemon_status(&status_view(), &mut output).unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains("Runtime directory: C:/runtime")); + assert!(text.contains("Connection: Connected")); + assert!(text.contains("Runtime process ID: 42")); + assert!(text.contains("Config status: Applied")); + + let start_view = DaemonStartView { + started: false, + message: "cadderd is already running.".to_string(), + status: status_view(), + }; + let mut output = Vec::new(); + write_human_daemon_start(&start_view, &mut output).unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains("cadderd is already running.")); + + let mut output = Vec::new(); + write_human_action( + &ActionResultView { + message: "Domain activation updated.".to_string(), + }, + &mut output, + ) + .unwrap(); + assert_eq!( + String::from_utf8(output).unwrap(), + "Domain activation updated.\n" + ); + } + + #[test] + fn human_lists_cover_empty_and_populated_states() { + let mut output = Vec::new(); + write_human_entrypoints( + &EntrypointListView { + captured_at_utc: timestamp(), + entrypoints: Vec::new(), + }, + &mut output, + ) + .unwrap(); + assert!( + String::from_utf8(output) + .unwrap() + .contains("No entrypoints registered.") + ); + + let mut output = Vec::new(); + write_human_entrypoints(&entrypoint_list(), &mut output).unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains("Registration: shim-1")); + assert!(text.contains("Adapter: caddyfile")); + assert!(text.contains("Command line: run --adapter caddyfile")); + + let mut output = Vec::new(); + write_human_domains( + &DomainListView { + captured_at_utc: timestamp(), + domains: Vec::new(), + }, + &mut output, + ) + .unwrap(); + assert!( + String::from_utf8(output) + .unwrap() + .contains("No domains registered.") + ); + + let mut output = Vec::new(); + write_human_domains(&domain_list(), &mut output).unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains("Domain: App.Localhost")); + assert!(text.contains("Canonical: app.localhost")); + assert!(text.contains("Entrypoint activation: Active")); + } + + #[test] + fn human_diagnostics_cover_empty_and_populated_states() { + let empty = DiagnosticsView { + captured_at_utc: timestamp(), + runtime: RuntimeView { + status: "Idle".to_string(), + binary_path: None, + version: None, + process_id: None, + admin_endpoint: None, + diagnostics: Vec::new(), + }, + config: ConfigView { + status: "Idle".to_string(), + last_attempted_at_utc: None, + last_successful_reload_at_utc: None, + effective_config_hash: None, + diagnostics: Vec::new(), + }, + runtime_diagnostics: Vec::new(), + config_diagnostics: Vec::new(), + }; + + let mut output = Vec::new(); + write_human_diagnostics(&empty, &mut output).unwrap(); + assert!( + String::from_utf8(output) + .unwrap() + .contains("No diagnostics.") + ); + + let mut output = Vec::new(); + write_human_diagnostics(&diagnostics_view_with_entries(), &mut output).unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains("Runtime diagnostic: runtime-warning")); + assert!(text.contains("Operation: reload")); + assert!(text.contains("Config diagnostic: config-error")); + assert!(text.contains("Domain: app.localhost")); + assert!(text.contains("Source paths: D:/Projects/App/Caddyfile")); + } + + #[test] + fn human_logs_cover_empty_and_populated_states() { + let mut output = Vec::new(); + write_human_logs( + &LogsView { + stream: LogStreamIdentity::runtime_control(), + stream_status: LogStreamStatus::Empty, + entries: Vec::new(), + next_cursor: Some("seq:0".to_string()), + has_gap: true, + has_more_before: false, + truncated_by_retention: true, + }, + &mut output, + ) + .unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains("Stream: runtime-control")); + assert!(text.contains("Notice: retained log history has a gap")); + assert!(text.contains("Notice: older log entries were dropped by retention.")); + assert!(text.contains("No log entries matched the request.")); + + let mut output = Vec::new(); + write_human_logs(&logs_view_with_entry(), &mut output).unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains("Next cursor: seq:7")); + assert!(text.contains("backend failed")); + assert!(text.contains("Error")); + } +} diff --git a/crates/cadderctl/src/view.rs b/crates/cadderctl/src/view.rs new file mode 100644 index 0000000..8a45a9d --- /dev/null +++ b/crates/cadderctl/src/view.rs @@ -0,0 +1,455 @@ +use crate::cli::SeverityArg; +use cadder_protocol::{ + ActivationState, ConfigDiagnostic, ConfigState, EntrypointRegistration, GuiStateSnapshot, + LogEntry, LogSeverity, LogStreamIdentity, LogStreamStatus, QueryLogsResponse, RuntimeDiagnostic, + RuntimeState, canonicalize_domain, +}; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ConnectionStateView { + Connected, + NotRunning, + ConnectionFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DaemonStatusView { + pub runtime_dir: String, + pub connection_state: ConnectionStateView, + pub message: String, + pub guidance: Option, + pub captured_at_utc: Option>, + pub runtime: Option, + pub config: Option, + pub counts: CountsView, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DaemonStartView { + pub started: bool, + pub message: String, + pub status: DaemonStatusView, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionResultView { + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CountsView { + pub entrypoints: usize, + pub domains: usize, + pub active_domains: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeView { + pub status: String, + pub binary_path: Option, + pub version: Option, + pub process_id: Option, + pub admin_endpoint: Option, + pub diagnostics: Vec, +} + +impl From<&RuntimeState> for RuntimeView { + fn from(value: &RuntimeState) -> Self { + Self { + status: format!("{:?}", value.status), + binary_path: value.binary_path.clone(), + version: value.version.clone(), + process_id: value.process_id, + admin_endpoint: value.admin_endpoint.clone(), + diagnostics: value.diagnostics.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigView { + pub status: String, + pub last_attempted_at_utc: Option>, + pub last_successful_reload_at_utc: Option>, + pub effective_config_hash: Option, + pub diagnostics: Vec, +} + +impl From<&ConfigState> for ConfigView { + fn from(value: &ConfigState) -> Self { + Self { + status: format!("{:?}", value.status), + last_attempted_at_utc: value.last_attempted_at_utc, + last_successful_reload_at_utc: value.last_successful_reload_at_utc, + effective_config_hash: value.effective_config_hash.clone(), + diagnostics: value.diagnostics.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EntrypointView { + pub registration_id: String, + pub activation_state: ActivationState, + pub working_directory: String, + pub config_path: String, + pub started_at_utc: DateTime, + pub last_heartbeat_utc: DateTime, + pub process_id: u32, + pub executable_path: Option, + pub domain_count: usize, + pub active_domain_count: usize, + pub adapter: Option, + pub command_line: Option, +} + +impl From<&EntrypointRegistration> for EntrypointView { + fn from(value: &EntrypointRegistration) -> Self { + Self { + registration_id: value.registration_id.clone(), + activation_state: value.activation_state, + working_directory: value.source_working_directory.raw.clone(), + config_path: value.source_config_path.raw.clone(), + started_at_utc: value.entrypoint_instance.started_at_utc, + last_heartbeat_utc: value.last_heartbeat_utc, + process_id: value.owner_process.process_id, + executable_path: value.owner_process.executable_path.clone(), + domain_count: value.registered_domains.len(), + active_domain_count: value + .registered_domains + .iter() + .filter(|domain| domain.activation_state.is_enabled()) + .count(), + adapter: value.shim_run.as_ref().and_then(|run| run.adapter.clone()), + command_line: value.shim_run.as_ref().map(|run| run.command_line.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EntrypointListView { + pub captured_at_utc: DateTime, + pub entrypoints: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DomainView { + pub registration_id: String, + pub domain: String, + pub canonical_domain: String, + pub activation_state: ActivationState, + pub entrypoint_activation_state: ActivationState, + pub working_directory: String, + pub config_path: String, + pub log_stream: LogStreamIdentity, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DomainListView { + pub captured_at_utc: DateTime, + pub domains: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiagnosticsView { + pub captured_at_utc: DateTime, + pub runtime: RuntimeView, + pub config: ConfigView, + pub runtime_diagnostics: Vec, + pub config_diagnostics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LogsView { + pub stream: LogStreamIdentity, + pub stream_status: LogStreamStatus, + pub entries: Vec, + pub next_cursor: Option, + pub has_gap: bool, + pub has_more_before: bool, + pub truncated_by_retention: bool, +} + +impl From for LogsView { + fn from(value: QueryLogsResponse) -> Self { + Self { + stream: value.stream, + stream_status: value.stream_status, + entries: value.entries, + next_cursor: value.next_cursor, + has_gap: value.has_gap, + has_more_before: value.has_more_before, + truncated_by_retention: value.truncated_by_retention, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectedDomain { + pub registration_id: String, + pub canonical_domain: String, +} + +pub fn daemon_status_connected( + runtime_dir: &Path, + snapshot: &GuiStateSnapshot, +) -> DaemonStatusView { + DaemonStatusView { + runtime_dir: runtime_dir.display().to_string(), + connection_state: ConnectionStateView::Connected, + message: "Attached to cadderd.".to_string(), + guidance: None, + captured_at_utc: Some(snapshot.captured_at_utc), + runtime: Some(RuntimeView::from(&snapshot.runtime)), + config: Some(ConfigView::from(&snapshot.config)), + counts: counts(snapshot), + } +} + +pub fn daemon_status_unavailable( + runtime_dir: &Path, + state: ConnectionStateView, + message: String, + guidance: Option, +) -> DaemonStatusView { + DaemonStatusView { + runtime_dir: runtime_dir.display().to_string(), + connection_state: state, + message, + guidance, + captured_at_utc: None, + runtime: None, + config: None, + counts: CountsView::default(), + } +} + +pub fn entrypoints_view(snapshot: &GuiStateSnapshot) -> EntrypointListView { + EntrypointListView { + captured_at_utc: snapshot.captured_at_utc, + entrypoints: snapshot + .registrations + .iter() + .map(EntrypointView::from) + .collect(), + } +} + +pub fn domains_view( + snapshot: &GuiStateSnapshot, + registration_filter: Option<&str>, +) -> DomainListView { + let domains = snapshot + .registrations + .iter() + .filter(|registration| { + registration_filter.is_none_or(|filter| registration.registration_id == filter) + }) + .flat_map(|registration| { + registration + .registered_domains + .iter() + .map(move |domain| DomainView { + registration_id: registration.registration_id.clone(), + domain: domain.name.raw.clone(), + canonical_domain: domain.name.canonical.clone(), + activation_state: domain.activation_state, + entrypoint_activation_state: registration.activation_state, + working_directory: registration.source_working_directory.raw.clone(), + config_path: registration.source_config_path.raw.clone(), + log_stream: domain.log_stream.clone(), + }) + }) + .collect(); + + DomainListView { + captured_at_utc: snapshot.captured_at_utc, + domains, + } +} + +pub fn diagnostics_view(snapshot: &GuiStateSnapshot) -> DiagnosticsView { + DiagnosticsView { + captured_at_utc: snapshot.captured_at_utc, + runtime: RuntimeView::from(&snapshot.runtime), + config: ConfigView::from(&snapshot.config), + runtime_diagnostics: snapshot.runtime.diagnostics.clone(), + config_diagnostics: snapshot.config.diagnostics.clone(), + } +} + +pub fn counts(snapshot: &GuiStateSnapshot) -> CountsView { + CountsView { + entrypoints: snapshot.registrations.len(), + domains: snapshot + .registrations + .iter() + .map(|registration| registration.registered_domains.len()) + .sum(), + active_domains: snapshot + .registrations + .iter() + .flat_map(|registration| registration.registered_domains.iter()) + .filter(|domain| domain.activation_state.is_enabled()) + .count(), + } +} + +pub fn resolve_domain( + snapshot: &GuiStateSnapshot, + domain: &str, + registration: Option<&str>, +) -> Result { + let canonical = canonicalize_domain(domain); + let mut matches = Vec::new(); + for entrypoint in &snapshot.registrations { + if registration.is_some_and(|registration_id| entrypoint.registration_id != registration_id) { + continue; + } + + if entrypoint + .registered_domains + .iter() + .any(|candidate| candidate.name.canonical == canonical) + { + matches.push(SelectedDomain { + registration_id: entrypoint.registration_id.clone(), + canonical_domain: canonical.clone(), + }); + } + } + + match matches.as_slice() { + [] => Err(DomainResolveError::NotFound { + canonical_domain: canonical, + requested_registration: registration.map(ToString::to_string), + }), + [selected] => Ok(selected.clone()), + _ => Err(DomainResolveError::Ambiguous { + canonical_domain: canonical, + matches: matches + .into_iter() + .map(|entry| entry.registration_id) + .collect(), + }), + } +} + +pub fn map_severity(value: Option) -> Option { + match value { + None => None, + Some(SeverityArg::Trace) => Some(LogSeverity::Trace), + Some(SeverityArg::Debug) => Some(LogSeverity::Debug), + Some(SeverityArg::Info) => Some(LogSeverity::Info), + Some(SeverityArg::Warn) => Some(LogSeverity::Warn), + Some(SeverityArg::Error) => Some(LogSeverity::Error), + Some(SeverityArg::Fatal) => Some(LogSeverity::Fatal), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DomainResolveError { + NotFound { + canonical_domain: String, + requested_registration: Option, + }, + Ambiguous { + canonical_domain: String, + matches: Vec, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use cadder_protocol::{ + DomainName, EntrypointInstanceIdentity, OwnerProcessIdentity, RegisteredDomain, SourcePath, + }; + + fn registration(id: &str, domains: &[&str]) -> EntrypointRegistration { + let now = Utc::now(); + let identity = EntrypointInstanceIdentity { + instance_id: id.to_string(), + started_at_utc: now, + shim_session_nonce: format!("{id}-nonce"), + }; + EntrypointRegistration { + registration_id: id.to_string(), + entrypoint_instance: identity.clone(), + source_working_directory: SourcePath::new("/work", None), + source_config_path: SourcePath::new("/work/Caddyfile", None), + registered_domains: domains + .iter() + .map(|domain| RegisteredDomain { + name: DomainName::parse(*domain), + activation_state: ActivationState::Active, + log_stream: LogStreamIdentity::domain(domain), + }) + .collect(), + activation_state: ActivationState::Active, + owner_process: OwnerProcessIdentity { + process_id: 1, + process_start_time_utc: now, + shim_session_nonce: identity.shim_session_nonce.clone(), + executable_path: None, + }, + log_stream: LogStreamIdentity::entrypoint(id), + shim_run: None, + created_at_utc: now, + last_heartbeat_utc: now, + } + } + + fn snapshot(registrations: Vec) -> GuiStateSnapshot { + GuiStateSnapshot { + captured_at_utc: Utc::now(), + registrations, + runtime: RuntimeState::idle(), + config: ConfigState::idle(), + } + } + + #[test] + fn resolve_domain_finds_unique_match() { + let snapshot = snapshot(vec![registration("shim-1", &["app.localhost"])]); + + let selected = resolve_domain(&snapshot, "APP.localhost", None).unwrap(); + + assert_eq!(selected.registration_id, "shim-1"); + assert_eq!(selected.canonical_domain, "app.localhost"); + } + + #[test] + fn resolve_domain_reports_ambiguity_without_registration() { + let snapshot = snapshot(vec![ + registration("shim-1", &["app.localhost"]), + registration("shim-2", &["app.localhost"]), + ]); + + let error = resolve_domain(&snapshot, "app.localhost", None).unwrap_err(); + + assert_eq!( + error, + DomainResolveError::Ambiguous { + canonical_domain: "app.localhost".to_string(), + matches: vec!["shim-1".to_string(), "shim-2".to_string()], + } + ); + } +} diff --git a/crates/cadderctl/tests/cli.rs b/crates/cadderctl/tests/cli.rs new file mode 100644 index 0000000..e53583d --- /dev/null +++ b/crates/cadderctl/tests/cli.rs @@ -0,0 +1,433 @@ +mod support; + +use cadder_protocol::{ + ActivationState, LogSeverity, LogStreamIdentity, LogStreamStatus, QueryLogsRequest, + StateChangeKind, message_types, new_request_id, +}; +use cadderctl::{app, cli::CliArgs}; +use clap::Parser; +use serde_json::Value; +use std::{process::Command as ProcessCommand, time::Duration}; +use support::Harness; + +async fn run_cli(args: &[&str]) -> (u8, String, String) { + let cli = CliArgs::parse_from(std::iter::once("cadderctl").chain(args.iter().copied())); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let code = app::run(cli, &mut stdout, &mut stderr).await.unwrap(); + ( + code, + String::from_utf8(stdout).unwrap(), + String::from_utf8(stderr).unwrap(), + ) +} + +fn parse_json(text: &str) -> Value { + serde_json::from_str(text).unwrap() +} + +fn run_binary(args: &[&str]) -> std::process::Output { + ProcessCommand::new(env!("CARGO_BIN_EXE_cadderctl")) + .args(args) + .output() + .unwrap() +} + +#[tokio::test] +async fn daemon_status_json_reports_not_running_runtime_without_failing() { + let runtime_dir = tempfile::tempdir().unwrap(); + let runtime_dir_arg = runtime_dir.path().to_string_lossy().to_string(); + + let (code, stdout, _stderr) = run_cli(&[ + "--runtime-dir", + &runtime_dir_arg, + "--output", + "json", + "daemon", + "status", + ]) + .await; + + assert_eq!(code, 0); + let json = parse_json(&stdout); + assert_eq!(json["ok"], true); + assert_eq!(json["data"]["connectionState"], "notRunning"); +} + +#[tokio::test] +async fn domains_list_json_returns_typed_error_when_daemon_is_unavailable() { + let runtime_dir = tempfile::tempdir().unwrap(); + let runtime_dir_arg = runtime_dir.path().to_string_lossy().to_string(); + + let (code, stdout, _stderr) = run_cli(&[ + "--runtime-dir", + &runtime_dir_arg, + "--output", + "json", + "domains", + "list", + ]) + .await; + + assert_eq!(code, 3); + let json = parse_json(&stdout); + assert_eq!(json["ok"], false); + assert_eq!(json["error"]["kind"], "daemonUnavailable"); + assert_eq!(json["error"]["exitCode"], 3); +} + +#[tokio::test] +async fn entrypoints_and_domains_list_json_report_registered_state() { + let harness = Harness::start().await; + harness.register_entrypoint("shim-1").await; + let runtime_dir = harness.paths.runtime_dir().display().to_string(); + + let (entrypoints_code, entrypoints_stdout, _entrypoints_stderr) = run_cli(&[ + "--runtime-dir", + &runtime_dir, + "--output", + "json", + "entrypoints", + "list", + ]) + .await; + assert_eq!(entrypoints_code, 0); + let entrypoints_json = parse_json(&entrypoints_stdout); + assert_eq!( + entrypoints_json["data"]["entrypoints"] + .as_array() + .unwrap() + .len(), + 1 + ); + + let (domains_code, domains_stdout, _domains_stderr) = run_cli(&[ + "--runtime-dir", + &runtime_dir, + "--output", + "json", + "domains", + "list", + ]) + .await; + assert_eq!(domains_code, 0); + let domains_json = parse_json(&domains_stdout); + assert_eq!(domains_json["data"]["domains"].as_array().unwrap().len(), 4); + + harness.shutdown().await; +} + +#[tokio::test] +async fn domains_disable_updates_registered_state() { + let harness = Harness::start().await; + harness.register_entrypoint("shim-1").await; + let runtime_dir = harness.paths.runtime_dir().display().to_string(); + + let (code, _stdout, _stderr) = run_cli(&[ + "--runtime-dir", + &runtime_dir, + "--output", + "json", + "domains", + "disable", + "app.smarketing.localhost", + "--registration", + "shim-1", + ]) + .await; + + assert_eq!(code, 0); + let snapshot = harness.snapshot().await; + let domain = snapshot.registrations[0] + .registered_domains + .iter() + .find(|domain| domain.name.canonical == "app.smarketing.localhost") + .unwrap(); + assert_eq!(domain.activation_state, ActivationState::Inactive); + + harness.shutdown().await; +} + +#[tokio::test] +async fn logs_show_json_returns_entries_and_cursor() { + let harness = Harness::start().await; + harness.register_entrypoint("shim-1").await; + harness.append_domain_log("app.smarketing.localhost", LogSeverity::Info, "first log"); + harness.append_domain_log("app.smarketing.localhost", LogSeverity::Error, "second log"); + let runtime_dir = harness.paths.runtime_dir().display().to_string(); + + let (code, stdout, _stderr) = run_cli(&[ + "--runtime-dir", + &runtime_dir, + "--output", + "json", + "logs", + "show", + "--limit", + "1", + "domain", + "app.smarketing.localhost", + "--registration", + "shim-1", + ]) + .await; + + assert_eq!(code, 0); + let json = parse_json(&stdout); + let entries = json["data"]["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["rawMessage"], "second log"); + let sequence_number = entries[0]["sequenceNumber"].as_u64().unwrap(); + assert_eq!(json["data"]["nextCursor"], format!("seq:{sequence_number}")); + + harness.shutdown().await; +} + +#[tokio::test] +async fn state_subscription_yields_initial_snapshot_for_watch_transport() { + let harness = Harness::start().await; + harness.register_entrypoint("shim-1").await; + + let mut subscription = harness + .client + .subscribe_state(new_request_id("watch-test")) + .await + .unwrap(); + let event = tokio::time::timeout(Duration::from_secs(2), subscription.next_event()) + .await + .unwrap() + .unwrap(); + + assert_eq!(event.change_kind, StateChangeKind::Snapshot); + assert_eq!(event.snapshot.registrations.len(), 1); + + harness.shutdown().await; +} + +#[tokio::test] +async fn log_cursor_progression_supports_tail_transport() { + let harness = Harness::start().await; + harness.register_entrypoint("shim-1").await; + harness.append_domain_log("app.smarketing.localhost", LogSeverity::Info, "first log"); + harness.append_domain_log("app.smarketing.localhost", LogSeverity::Warn, "second log"); + + let first_page: cadder_protocol::QueryLogsResponse = harness + .client + .request( + message_types::QUERY_LOGS_REQUEST, + message_types::QUERY_LOGS_RESPONSE, + &QueryLogsRequest { + request_id: new_request_id("tail-page-1"), + stream: LogStreamIdentity::domain("app.smarketing.localhost"), + limit: Some(1), + cursor: None, + minimum_severity: None, + }, + ) + .await + .unwrap(); + assert_eq!(first_page.stream_status, LogStreamStatus::Active); + assert_eq!(first_page.entries.len(), 1); + assert_eq!(first_page.entries[0].raw_message, "second log"); + + let next_page: cadder_protocol::QueryLogsResponse = harness + .client + .request( + message_types::QUERY_LOGS_REQUEST, + message_types::QUERY_LOGS_RESPONSE, + &QueryLogsRequest { + request_id: new_request_id("tail-page-2"), + stream: LogStreamIdentity::domain("app.smarketing.localhost"), + limit: Some(10), + cursor: first_page.next_cursor.clone(), + minimum_severity: None, + }, + ) + .await + .unwrap(); + assert_eq!(next_page.stream_status, LogStreamStatus::Active); + assert!(next_page.entries.is_empty()); + + harness.shutdown().await; +} + +#[tokio::test] +async fn human_commands_cover_status_start_shutdown_lists_and_logs() { + let harness = Harness::start().await; + harness.register_entrypoint("shim-1").await; + harness.append_domain_log( + "app.smarketing.localhost", + LogSeverity::Error, + "domain log entry", + ); + let runtime_dir = harness.paths.runtime_dir().display().to_string(); + + let (code, stdout, stderr) = run_cli(&["--runtime-dir", &runtime_dir, "daemon", "status"]).await; + assert_eq!(code, 0); + assert!(stderr.is_empty()); + assert!(stdout.contains("Attached to cadderd.")); + + let (code, stdout, stderr) = run_cli(&["daemon", "start", "--runtime-dir", &runtime_dir]).await; + assert_eq!(code, 0); + assert!(stderr.is_empty()); + assert!(stdout.contains("cadderd is already running.")); + + let (code, stdout, stderr) = + run_cli(&["entrypoints", "list", "--runtime-dir", &runtime_dir]).await; + assert_eq!(code, 0); + assert!(stderr.is_empty()); + assert!(stdout.contains("Registration: shim-1")); + + let (code, stdout, stderr) = run_cli(&[ + "domains", + "list", + "--registration", + "shim-1", + "--runtime-dir", + &runtime_dir, + ]) + .await; + assert_eq!(code, 0); + assert!(stderr.is_empty()); + assert!(stdout.contains("Domain: app.smarketing.localhost")); + + let (code, stdout, stderr) = + run_cli(&["diagnostics", "show", "--runtime-dir", &runtime_dir]).await; + assert_eq!(code, 0); + assert!(stderr.is_empty()); + assert!(stdout.contains("No diagnostics.")); + + let (code, stdout, stderr) = run_cli(&[ + "logs", + "show", + "--limit", + "5", + "domain", + "app.smarketing.localhost", + "--registration", + "shim-1", + "--runtime-dir", + &runtime_dir, + ]) + .await; + assert_eq!(code, 0); + assert!(stderr.is_empty()); + assert!(stdout.contains("domain log entry")); + + let (code, stdout, stderr) = + run_cli(&["daemon", "shutdown", "--runtime-dir", &runtime_dir]).await; + assert_eq!(code, 0); + assert!(stderr.is_empty()); + assert!(stdout.contains("Daemon shutdown requested.")); +} + +#[tokio::test] +async fn toggles_and_invalid_selectors_return_typed_results() { + let harness = Harness::start().await; + harness.register_entrypoint("shim-1").await; + harness.register_entrypoint("shim-2").await; + let runtime_dir = harness.paths.runtime_dir().display().to_string(); + + let (code, stdout, _stderr) = run_cli(&[ + "--runtime-dir", + &runtime_dir, + "--output", + "json", + "entrypoints", + "disable", + "shim-1", + ]) + .await; + assert_eq!(code, 0); + assert_eq!(parse_json(&stdout)["ok"], true); + + let (code, stdout, _stderr) = run_cli(&[ + "--runtime-dir", + &runtime_dir, + "--output", + "json", + "entrypoints", + "enable", + "missing", + ]) + .await; + assert_eq!(code, 5); + assert_eq!(parse_json(&stdout)["error"]["kind"], "targetNotFound"); + + let (code, stdout, _stderr) = run_cli(&[ + "--runtime-dir", + &runtime_dir, + "--output", + "json", + "domains", + "disable", + "app.smarketing.localhost", + ]) + .await; + assert_eq!(code, 6); + assert_eq!(parse_json(&stdout)["error"]["kind"], "conflictOrRejected"); + + let (code, stdout, _stderr) = run_cli(&[ + "--runtime-dir", + &runtime_dir, + "--output", + "json", + "domains", + "enable", + "missing.localhost", + "--registration", + "shim-1", + ]) + .await; + assert_eq!(code, 5); + assert_eq!(parse_json(&stdout)["error"]["kind"], "targetNotFound"); + + harness.shutdown().await; +} + +#[tokio::test] +async fn invalid_output_modes_and_ranges_return_stable_errors() { + let (code, stdout, _stderr) = run_cli(&["--output", "json", "watch", "status"]).await; + assert_eq!(code, 2); + assert_eq!(parse_json(&stdout)["error"]["kind"], "invalidUsage"); + + let (code, stdout, _stderr) = run_cli(&["domains", "list", "--output", "jsonl"]).await; + assert_eq!(code, 2); + let json = parse_json(&stdout); + assert_eq!(json["event"], "error"); + assert_eq!(json["error"]["kind"], "invalidUsage"); + + let (code, stdout, _stderr) = run_cli(&[ + "--output", "json", "logs", "show", "--limit", "0", "runtime", + ]) + .await; + assert_eq!(code, 2); + assert_eq!(parse_json(&stdout)["error"]["kind"], "invalidUsage"); + + let (code, stdout, _stderr) = run_cli(&[ + "--output", + "jsonl", + "logs", + "tail", + "--poll-interval-ms", + "10", + "runtime", + ]) + .await; + assert_eq!(code, 2); + let json = parse_json(&stdout); + assert_eq!(json["event"], "error"); + assert_eq!(json["error"]["exitCode"], 2); +} + +#[test] +fn binary_help_version_and_invalid_usage_exit_codes_are_stable() { + let help = run_binary(&["--help"]); + assert_eq!(help.status.code(), Some(0)); + + let version = run_binary(&["--version"]); + assert_eq!(version.status.code(), Some(0)); + + let invalid = run_binary(&["logs"]); + assert_eq!(invalid.status.code(), Some(2)); +} diff --git a/crates/cadderctl/tests/support/mod.rs b/crates/cadderctl/tests/support/mod.rs new file mode 100644 index 0000000..47824a0 --- /dev/null +++ b/crates/cadderctl/tests/support/mod.rs @@ -0,0 +1,227 @@ +use cadder_daemon::{ + CadderClient, CadderSession, CaddyConfigAdapter, CaddyConfigCoordinator, DaemonServer, + DaemonState, ProcessRuntime, RealCaddyResolver, RuntimePaths, +}; +use cadder_protocol::{ + ActivationState, EntrypointInstanceIdentity, EntrypointRegistration, LogAttributionKind, + LogSeverity, LogStreamIdentity, OwnerProcessIdentity, QueryStateRequest, QueryStateResponse, + RegisterEntrypointRequest, RegisterEntrypointResponse, ShimRunMetadata, SourcePath, + message_types, new_request_id, +}; +use chrono::Utc; +use std::{ + fs, + path::{Path, PathBuf}, + sync::Mutex, + time::Duration, +}; +use tokio::{sync::watch, time::sleep}; + +const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../cadder-daemon/tests/fixtures/SmarketingReverseProxy.Caddyfile" +)); + +pub struct Harness { + pub client: CadderClient, + pub state: DaemonState, + pub paths: RuntimePaths, + pub config_path: PathBuf, + sessions: Mutex>, + shutdown_tx: watch::Sender, + _temp: tempfile::TempDir, +} + +impl Harness { + pub async fn start() -> Self { + let temp = tempfile::tempdir().unwrap(); + let runtime_dir = temp.path().join("runtime"); + let paths = RuntimePaths::resolve(Some(runtime_dir)).unwrap(); + paths.ensure_dirs().unwrap(); + let config_path = temp.path().join("Caddyfile"); + fs::write(&config_path, FIXTURE).unwrap(); + let command_log_path = temp.path().join("fake-caddy-commands.log"); + let fake_caddy_path = write_fake_caddy(temp.path(), &command_log_path); + + let resolver = RealCaddyResolver::new(Some(fake_caddy_path.display().to_string())); + let adapter = CaddyConfigAdapter::new(resolver.clone()); + let runtime = ProcessRuntime::new(resolver, paths.clone()); + let state = DaemonState::new(CaddyConfigCoordinator::new(adapter, runtime)); + let server = DaemonServer::new(paths.clone(), state.clone()); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + tokio::spawn(async move { + let _ = server.run_until(shutdown_rx).await; + }); + + let client = CadderClient::new(paths.clone()); + for _ in 0..50 { + if client + .request::<_, QueryStateResponse>( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("wait"), + }, + ) + .await + .is_ok() + { + return Self { + client, + state, + paths, + config_path, + sessions: Mutex::new(Vec::new()), + shutdown_tx, + _temp: temp, + }; + } + sleep(Duration::from_millis(20)).await; + } + + panic!("daemon server did not become ready"); + } + + pub async fn register_entrypoint(&self, id: &str) { + let mut session = CadderSession::connect(&self.paths).await.unwrap(); + let response: RegisterEntrypointResponse = session + .request( + message_types::REGISTER_ENTRYPOINT_REQUEST, + message_types::REGISTER_ENTRYPOINT_RESPONSE, + &RegisterEntrypointRequest { + request_id: new_request_id("test-register"), + registration: registration(id, &format!("{id}-nonce"), &self.config_path), + }, + ) + .await + .unwrap(); + assert!(response.accepted, "{}", response.message); + self.sessions.lock().unwrap().push(session); + } + + pub async fn snapshot(&self) -> cadder_protocol::GuiStateSnapshot { + let response: QueryStateResponse = self + .client + .request( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("test-query"), + }, + ) + .await + .unwrap(); + response.snapshot.unwrap() + } + + pub fn append_domain_log(&self, domain_key: &str, severity: LogSeverity, message: &str) { + self.state.logs().append( + LogStreamIdentity::domain(domain_key), + severity, + message, + LogAttributionKind::Domain, + None, + ); + } + + pub async fn shutdown(self) { + let _ = self.shutdown_tx.send(true); + } +} + +fn registration(id: &str, nonce: &str, config_path: &Path) -> EntrypointRegistration { + let now = Utc::now(); + let identity = EntrypointInstanceIdentity { + instance_id: id.to_string(), + started_at_utc: now, + shim_session_nonce: nonce.to_string(), + }; + EntrypointRegistration { + registration_id: id.to_string(), + entrypoint_instance: identity.clone(), + source_working_directory: SourcePath::new(".", None), + source_config_path: SourcePath::new( + config_path.display().to_string(), + Some(config_path.display().to_string()), + ), + registered_domains: Vec::new(), + activation_state: ActivationState::Active, + owner_process: OwnerProcessIdentity { + process_id: 1, + process_start_time_utc: now, + shim_session_nonce: nonce.to_string(), + executable_path: None, + }, + log_stream: LogStreamIdentity::entrypoint(id), + shim_run: Some(ShimRunMetadata { + adapter: Some("caddyfile".to_string()), + raw_arguments: vec!["run".to_string()], + command_line: "run".to_string(), + }), + created_at_utc: now, + last_heartbeat_utc: now, + } +} + +fn write_fake_caddy(dir: &Path, command_log_path: &Path) -> PathBuf { + const ADAPTED_JSON: &str = r#"{"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["api.smarketing.localhost","app.smarketing.localhost","mailbox.smarketing.localhost","storage.smarketing.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}}"#; + + #[cfg(windows)] + { + let path = dir.join("fake-caddy.cmd"); + fs::write( + &path, + format!( + r#"@echo off +echo %*>> "{command_log}" +if "%1"=="adapt" ( + echo {adapted_json} + exit 0 +) +if "%1"=="reload" ( + exit 0 +) +if "%1"=="run" ( + echo fake runtime started + exit 0 +) +exit 0 +"#, + command_log = command_log_path.display(), + adapted_json = ADAPTED_JSON, + ), + ) + .unwrap(); + path + } + + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + let path = dir.join("fake-caddy"); + fs::write( + &path, + format!( + r#"#!/usr/bin/env sh +printf '%s\n' "$*" >> '{command_log}' +if [ "$1" = "adapt" ]; then + printf '%s\n' '{adapted_json}' + exit 0 +fi +if [ "$1" = "reload" ]; then + exit 0 +fi +if [ "$1" = "run" ]; then echo fake runtime started; exit 0; fi +exit 0 +"#, + command_log = command_log_path.display(), + adapted_json = ADAPTED_JSON, + ), + ) + .unwrap(); + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).unwrap(); + path + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a36becc..ec85d80 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,15 +1,16 @@ # Cadder Architecture -Cadder is a cross-platform Rust daemon, shim, and terminal UI for routing project-local `caddy run` invocations into one persistent per-user Caddy runtime. +Cadder is a cross-platform Rust daemon, shim, non-TUI CLI, and terminal UI for routing project-local `caddy run` invocations into one persistent per-user Caddy runtime. ## Process Roles - `cadderd`: per-user daemon. It owns registrations, local IPC, Caddyfile adaptation, effective Caddy config composition, the Cadder-owned real Caddy process, runtime diagnostics, and bounded log storage. - `caddy`: PATH-facing shim. It intentionally shadows Caddy for managed `caddy run` commands, attaches to an already running `cadderd`, registers the caller's config, heartbeats while alive, and unregisters on exit. +- `cadderctl`: non-TUI CLI. It attaches to the daemon for scriptable inspection, log queries, entrypoint and domain toggles, diagnostics, and explicit daemon lifecycle control. - `cadder-tui`: Ratatui/Crossterm UI. It attaches to the daemon and shows overview, entrypoints, grouped domains, per-domain logs, diagnostics, search/filtering, activation toggles, and explicit backend start or shutdown actions. - Real Caddy runtime: resolved external binary. Cadder never embeds Caddy and must not recursively execute its own shim. -All three Cadder binaries expose `--help` and `--version` through their Clap command definitions. The release-facing command names are `cadderd`, `caddy`, and `cadder-tui`. +All four Cadder binaries expose `--help` and `--version` through their Clap command definitions. The release-facing command names are `cadderd`, `cadderctl`, `caddy`, and `cadder-tui`. ## Cross-Platform Runtime Model @@ -20,7 +21,7 @@ Cadder uses per-user runtime paths from `directories::ProjectDirs`, with `CADDER - an effective generated Caddy JSON config file; - daemon metadata and bounded in-memory state. -`cadderd` is a manually started per-user backend. The `caddy` shim and `cadder-tui` are attach-only clients: they connect to an existing daemon when it is available, surface explicit unavailable guidance when it is not, and never spawn it as an implicit side effect of opening a dashboard or running `caddy run`. Explicit backend start is limited to direct `cadderd` execution or deliberate client actions such as pressing `s` in `cadder-tui`. Explicit launch attempts still use a per-runtime launch lock before spawning `cadderd`, so concurrent start requests wait for the daemon socket instead of spawning another child, while the daemon's own runtime lock remains the final ownership guard. OS services are intentionally deferred; v1 remains a user daemon model. +`cadderd` is a manually started per-user backend. The `caddy` shim is attach-only, while `cadder-tui` and `cadderctl` are attach-only except for their explicit daemon start actions. They connect to an existing daemon when it is available, surface explicit unavailable guidance when it is not, and never spawn it as an implicit side effect of opening a dashboard, querying state, or running `caddy run`. Explicit backend start is limited to direct `cadderd` execution or deliberate client actions such as pressing `s` in `cadder-tui` or running `cadderctl daemon start`. Explicit launch attempts still use a per-runtime launch lock before spawning `cadderd`, so concurrent start requests wait for the daemon socket instead of spawning another child, while the daemon's own runtime lock remains the final ownership guard. OS services are intentionally deferred; v1 remains a user daemon model. ## IPC Boundary @@ -95,6 +96,8 @@ After planning the backend binding, Cadder removes the original public binding, The TUI attempts to attach without spawning the daemon, upgrades successful attaches into a durable `subscribe-state-request` stream, and keeps periodic `query-state-request` probes only for late daemon availability and recovery after backend loss. It also loads a bounded log page through `query-logs-request`, stores the returned cursor, and tails by issuing follow-up requests with that cursor. Auto-tail is enabled by default and can be paused with `p`; while paused, the TUI keeps keyboard handling responsive and avoids automatic log refreshes until the user resumes or manually refreshes with `Enter`. Log severity is primarily controlled from the Settings view; applying a new severity resets the cursor before reloading so entries from different filters are not mixed. +`cadderctl` reuses the same contracts for non-interactive workflows. One-shot inspection commands use `query-state-request` or `query-logs-request`, `watch` uses `subscribe-state-request`, domain and entrypoint toggles use the existing activation messages, and `logs tail` advances the daemon log cursor through repeated `query-logs-request` calls instead of introducing a new streaming backend protocol. + On Windows, `cadder-tui` also exposes an IIS Handoff view. The view lists site, protocol, IP address, port, host header, handoff state, and safety details. Pressing `Enter` refreshes IIS discovery; pressing `Space` hands off an available binding or restores a handed-off binding. Before dispatching a mixed-elevation action, the TUI states why administrator approval may be requested. After the daemon responds, the status line summarizes succeeded, approved, denied, failed, and follow-up operation steps. For wildcard or empty-host IIS rows, press `/`, type the route host or URL, press `Enter` to keep the value, then press `Space`. The view is absent on non-Windows platforms rather than shown as a disabled placeholder. Log refresh, late-availability probes, IIS discovery, activation toggles, IIS handoff actions, and shutdown requests are dispatched as short background IPC tasks. The TUI accepts at most one active state probe, one active state subscription stream, and one active log refresh for the current stream, and surfaces IPC failures as a read-error state rather than blocking terminal input. @@ -108,11 +111,12 @@ The log store reports stream status and retention metadata in `query-logs-respon `cargo run -p xtask -- dist --out ` builds release binaries and copies the current platform's executable names into a portable layout: - `cadderd` +- `cadderctl` - `cadder-tui` - `caddy` - `cadder.toml` -On Windows the binaries include the `.exe` suffix. `cargo run -p xtask -- verify-dist --dir ` checks the expected files and runs `caddy --cadder-shim-info` from the layout. `cargo run -p xtask -- package --out --platform --target ` builds the target-specific layout, wraps it in a versioned portable archive, and writes a neighboring `.sha256` checksum file. The package version defaults to the root `Cargo.toml` `[workspace.package]` version, while `--version ` remains available for explicit dry-run overrides. Windows artifacts are `.zip` archives; Linux and macOS artifacts are `.tar.gz` archives. +On Windows the binaries include the `.exe` suffix. `cargo run -p xtask -- verify-dist --dir ` checks the expected files, runs `caddy --cadder-shim-info`, and verifies `cadderctl --help` from the layout. `cargo run -p xtask -- package --out --platform --target ` builds the target-specific layout, wraps it in a versioned portable archive, and writes a neighboring `.sha256` checksum file. The package version defaults to the root `Cargo.toml` `[workspace.package]` version, while `--version ` remains available for explicit dry-run overrides. Windows artifacts are `.zip` archives; Linux and macOS artifacts are `.tar.gz` archives. The packaging workflow does not modify PATH, shell profiles, package-manager shims, OS services, or other system state. @@ -124,6 +128,7 @@ The current runtime model is a single per-user daemon that owns the backend and - `crates/cadder-protocol`: shared DTOs, activation/runtime/log states, IPC envelopes, and request/response contracts. - `crates/cadder-daemon`: runtime paths, daemon lock, local IPC server/client, registration state, Caddy integration, process runtime, and log store. +- `crates/cadderctl`: non-TUI CLI for daemon status, entrypoints, domains, diagnostics, and logs. - `crates/cadderd`: daemon binary. - `crates/cadder-shim`: package containing the PATH-facing `caddy` binary. - `crates/cadder-tui`: terminal UI. diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 8d8c527..282cb97 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -27,6 +27,7 @@ export default defineConfig({ label: 'User guide', items: [ { label: 'How to use', slug: 'user-guide/how-to-use' }, + { label: 'cadderctl', slug: 'user-guide/cadderctl' }, { label: 'cadder.toml', slug: 'user-guide/cadder-toml' }, { label: 'PATH and shim strategy', slug: 'user-guide/path-and-shim' }, { label: 'TUI and diagnostics', slug: 'user-guide/tui-diagnostics' }, diff --git a/docs/site/src/content/docs/index.mdx b/docs/site/src/content/docs/index.mdx index 4b15550..b2e8fc7 100644 --- a/docs/site/src/content/docs/index.mdx +++ b/docs/site/src/content/docs/index.mdx @@ -16,6 +16,7 @@ That makes local reverse-proxy setups easier to run side by side. Cadder can com - One local Caddy runtime can serve domains from many project Caddyfiles. - The `caddy` shim keeps `caddy run` usable for project workflows. +- `cadderctl` gives scripts and agents a non-interactive way to inspect and operate Cadder. - `cadder.toml` points Cadder at the real Caddy binary without replacing it. - `cadder-tui` shows entrypoints, domains, logs, diagnostics, and toggles. - Domains can be enabled or disabled without killing unrelated project sessions. @@ -25,6 +26,7 @@ That makes local reverse-proxy setups easier to run side by side. Cadder can com - `caddy` is the Caddy-compatible shim. For `caddy run`, it attaches to a running `cadderd`, registers the current project, and unregisters on exit. If the backend is unavailable, Cadder reports how to start it instead of spawning it implicitly. Other Caddy commands are delegated to the real Caddy binary. - `cadderd` is the daemon. It owns registrations, generated Caddy config, the real Caddy process it starts, diagnostics, and bounded logs. +- `cadderctl` is the non-TUI CLI for state, domains, diagnostics, logs, and explicit daemon lifecycle commands. - `cadder-tui` is the terminal UI for inspecting domains, entrypoints, diagnostics, logs, filters, toggles, daemon shutdown, and explicit backend start when needed. Each binary supports `--help` and `--version`. @@ -56,4 +58,4 @@ Each binary supports `--help` and `--version`. 2. Create `cadder.toml` with the path to the real Caddy binary. See [cadder.toml](user-guide/cadder-toml/) for the supported forms. 3. Start `cadderd`, then run Cadder's `caddy` shim instead of the real `caddy` command. -Open `cadder-tui` whenever you want to inspect active projects, domains, logs, diagnostics, or explicitly start or stop the backend. +Use `cadderctl` for automation-friendly inspection and toggles, or open `cadder-tui` when you want the interactive dashboard. diff --git a/docs/site/src/content/docs/quick-start/getting-started.mdx b/docs/site/src/content/docs/quick-start/getting-started.mdx index 342c9a0..5f5d0b0 100644 --- a/docs/site/src/content/docs/quick-start/getting-started.mdx +++ b/docs/site/src/content/docs/quick-start/getting-started.mdx @@ -1,6 +1,6 @@ --- title: Getting started -description: Download Cadder, configure real Caddy, run a project through the shim, and inspect it in the TUI. +description: Download Cadder, configure real Caddy, run a project through the shim, and inspect it in the CLI or TUI. --- ## What you need @@ -23,16 +23,19 @@ You can also run Cadder by direct path. That is useful for testing or for setups ```sh cadderd +cadderctl daemon status caddy run ``` The shim attaches to the running `cadderd` backend, registers the project Caddyfile, and keeps that registration alive while the command is running. If the backend is unavailable, Cadder reports how to start it instead of spawning it implicitly. ```sh +cadderctl entrypoints list --output json +cadderctl logs show --limit 20 domain app.localhost --registration shim-1 cadder-tui ``` -Use the TUI to inspect current state, domains, logs, diagnostics, toggles, and explicit backend controls. If you prefer, you can also open `cadder-tui` first and press `s` to start the backend from inside the dashboard. +Use `cadderctl` for automation-friendly state, domain, and log commands. Use the TUI to inspect current state interactively, browse logs, and use explicit backend controls. If you prefer, you can also open `cadder-tui` first and press `s` to start the backend from inside the dashboard. ## OS cookbooks diff --git a/docs/site/src/content/docs/reference/runtime-configuration.mdx b/docs/site/src/content/docs/reference/runtime-configuration.mdx index 8c6a07f..04380a4 100644 --- a/docs/site/src/content/docs/reference/runtime-configuration.mdx +++ b/docs/site/src/content/docs/reference/runtime-configuration.mdx @@ -15,13 +15,14 @@ Set `CADDER_RUNTIME_DIR` or pass `--runtime-dir` to use a specific directory: ```sh cadderd --runtime-dir +cadderctl --runtime-dir daemon status cadder-tui --runtime-dir caddy --cadder-runtime-dir run ``` This is useful for tests, demos, and isolated sessions that should not share daemon state with the normal user runtime. -Use the same runtime override when you start `cadderd`, attach with `cadder-tui`, or run `caddy --cadder-runtime-dir run`. The shim and dashboard only attach to a backend that is already running for that exact runtime directory. +Use the same runtime override when you start `cadderd`, attach with `cadderctl` or `cadder-tui`, or run `caddy --cadder-runtime-dir run`. The shim and both client surfaces only attach to a backend that is already running for that exact runtime directory unless you explicitly run `cadderctl daemon start` or use the TUI start action. ## Runtime files diff --git a/docs/site/src/content/docs/user-guide/cadderctl.mdx b/docs/site/src/content/docs/user-guide/cadderctl.mdx new file mode 100644 index 0000000..750bf8a --- /dev/null +++ b/docs/site/src/content/docs/user-guide/cadderctl.mdx @@ -0,0 +1,96 @@ +--- +title: cadderctl +description: Use the non-TUI Cadder CLI for automation, scripting, and operator workflows. +--- + +`cadderctl` is Cadder's non-interactive management CLI. It is designed for shell scripts, CI checks, agents, and operators who want state, diagnostics, and toggles without opening `cadder-tui`. + +## Command groups + +`cadderctl` currently exposes these top-level groups: + +```sh +cadderctl daemon status +cadderctl daemon start +cadderctl daemon shutdown +cadderctl entrypoints list +cadderctl entrypoints disable +cadderctl domains list +cadderctl domains disable --registration +cadderctl diagnostics show +cadderctl logs show --limit 20 domain --registration +cadderctl logs tail --limit 20 --poll-interval-ms 750 domain --registration +cadderctl watch status +``` + +`cadderctl` stays attach-only by default. Read-only commands, toggles, `watch`, and `logs tail` connect to an already running daemon. Only `cadderctl daemon start` may launch `cadderd`. + +## Output modes + +`cadderctl` supports three output modes: + +- `human` is the default for operators running one-shot commands by hand. +- `json` is for one-shot scripting and machine parsing. +- `jsonl` is for streaming commands such as `watch` and `logs tail`. + +Examples: + +```sh +cadderctl entrypoints list --output json +cadderctl domains list --output json +cadderctl watch diagnostics --output jsonl +cadderctl logs tail --output jsonl --limit 50 domain app.localhost --registration shim-1 +``` + +## Common scripting patterns + +Check whether the daemon is reachable without failing the script when it is stopped: + +```sh +cadderctl daemon status --output json +``` + +List current domains and parse them from JSON: + +```sh +cadderctl domains list --output json +``` + +Disable one managed domain explicitly: + +```sh +cadderctl domains disable app.localhost --registration shim-1 +``` + +Fetch recent logs for one domain: + +```sh +cadderctl logs show --limit 20 domain app.localhost --registration shim-1 --output json +``` + +Stream state changes for automation: + +```sh +cadderctl watch status --output jsonl +``` + +## Relation to the TUI + +`cadderctl` and `cadder-tui` share the same daemon contracts and runtime model. + +- Use `cadderctl` when you need predictable output, non-interactive toggles, or agent-friendly polling and watches. +- Use `cadder-tui` when you need interactive browsing, navigation, filters, and richer operator context. + +Neither tool changes daemon ownership semantics. They are clients of the same per-user backend. + +## Future agent integrations + +The command structure and output modes are intentionally stable so future Cadder-aware agents can: + +- inspect daemon reachability and configuration state; +- enumerate entrypoints and domains; +- request recent logs or stream new ones; +- toggle managed routes explicitly; +- map command failures to stable exit codes and typed machine-readable errors. + +That keeps agent workflows aligned with the same backend used by the interactive TUI instead of creating a parallel automation API. diff --git a/docs/site/src/content/docs/user-guide/how-to-use.mdx b/docs/site/src/content/docs/user-guide/how-to-use.mdx index 3a9b470..339e789 100644 --- a/docs/site/src/content/docs/user-guide/how-to-use.mdx +++ b/docs/site/src/content/docs/user-guide/how-to-use.mdx @@ -3,10 +3,11 @@ title: How to use description: Use Cadder releases, the caddy shim, and the TUI without changing Cadder itself. --- -The release contains four files you normally care about: +The release contains five files you normally care about: - `caddy` - `cadderd` +- `cadderctl` - `cadder-tui` - `cadder.toml` @@ -31,11 +32,15 @@ caddy run 5. Inspect the daemon when needed: ```sh +cadderctl daemon status +cadderctl domains list --output json cadder-tui ``` `caddy run` attaches to the running backend. If `cadderd` is unavailable, Cadder reports how to start it instead of starting it implicitly. +Use `cadderctl` when you want stable machine-readable output, non-interactive toggles, or scriptable log queries. Use `cadder-tui` when you want an interactive dashboard with navigation and live browsing. + ## Direct or PATH-based use Both styles are valid. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6ae7759..fe93548 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -115,6 +115,18 @@ fn verify_dist(options: &VerifyDistOptions) -> Result<()> { bail!("caddy --cadder-shim-info did not report the Cadder shim role"); } + let ctl = options + .dir + .join(exe_name("cadderctl", options.target.as_deref())); + let ctl_output = Command::new(&ctl) + .arg("--help") + .stdin(Stdio::null()) + .output() + .with_context(|| format!("run {}", ctl.display()))?; + if !ctl_output.status.success() { + bail!("cadderctl --help failed with {}", ctl_output.status); + } + Ok(()) } @@ -167,8 +179,8 @@ fn package_with_dist( Ok(()) } -fn portable_binaries() -> [&'static str; 3] { - ["cadderd", "cadder-tui", "caddy"] +fn portable_binaries() -> [&'static str; 4] { + ["cadderd", "cadderctl", "cadder-tui", "caddy"] } fn release_binary_path(name: &str, target: Option<&str>) -> PathBuf { @@ -198,6 +210,8 @@ fn build_release_binaries(target: Option<&str>) -> Result<()> { "-p", "cadderd", "-p", + "cadderctl", + "-p", "cadder-tui", "-p", "cadder-shim", @@ -707,7 +721,10 @@ edition = "2024" #[test] fn portable_layout_includes_expected_binaries() { - assert_eq!(portable_binaries(), ["cadderd", "cadder-tui", "caddy"]); + assert_eq!( + portable_binaries(), + ["cadderd", "cadderctl", "cadder-tui", "caddy"] + ); } #[test] @@ -844,7 +861,12 @@ edition = "2024" ); write_fake_portable_layout( &dist_options.out_dir, - &["cadderd.exe", "cadder-tui.exe", "caddy.exe"], + &[ + "cadderd.exe", + "cadderctl.exe", + "cadder-tui.exe", + "caddy.exe", + ], ) }, ) @@ -858,6 +880,7 @@ edition = "2024" &archive_path, &[ "cadder-1.2.3-windows-x64/", + "cadder-1.2.3-windows-x64/cadderctl.exe", "cadder-1.2.3-windows-x64/cadder-tui.exe", "cadder-1.2.3-windows-x64/cadder.toml", "cadder-1.2.3-windows-x64/cadderd.exe", @@ -891,7 +914,10 @@ edition = "2024" dist_options.target, Some("x86_64-unknown-linux-gnu".to_string()) ); - write_fake_portable_layout(&dist_options.out_dir, &["cadderd", "cadder-tui", "caddy"]) + write_fake_portable_layout( + &dist_options.out_dir, + &["cadderd", "cadderctl", "cadder-tui", "caddy"], + ) }, ) .unwrap(); @@ -904,6 +930,7 @@ edition = "2024" &archive_path, &[ "cadder-1.2.3-linux-x64/", + "cadder-1.2.3-linux-x64/cadderctl", "cadder-1.2.3-linux-x64/cadder-tui", "cadder-1.2.3-linux-x64/cadder.toml", "cadder-1.2.3-linux-x64/cadderd", From 9f8cf38eb27272aea38f89841a611f21a7e0734e Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Tue, 16 Jun 2026 21:40:38 +0200 Subject: [PATCH 04/29] feat: add local MCP server for Cadder agents Add a new local stdio cadder-mcp server with bounded inspect and management tools, MCP-specific redaction, and contract tests for unavailable-daemon behavior, action routing, and log safety. Extract a shared cadder-operator layer from cadderctl so CLI and MCP reuse daemon attach, query, toggle, and error-mapping logic, and update packaging and documentation to ship and explain the new MCP surface. --- Cargo.lock | 187 +++++- Cargo.toml | 2 + README.md | 8 +- ...Cadder-through-an-MCP-server-for-agents.md | 205 +++++- crates/cadder-mcp/Cargo.toml | 21 + crates/cadder-mcp/src/dto.rs | 260 ++++++++ crates/cadder-mcp/src/lib.rs | 616 ++++++++++++++++++ crates/cadder-mcp/src/main.rs | 7 + crates/cadder-mcp/src/redaction.rs | 179 +++++ crates/cadder-mcp/tests/mcp.rs | 171 +++++ crates/cadder-mcp/tests/support/mod.rs | 227 +++++++ crates/cadder-operator/Cargo.toml | 13 + crates/cadder-operator/src/error.rs | 330 ++++++++++ crates/cadder-operator/src/lib.rs | 18 + crates/cadder-operator/src/service.rs | 372 +++++++++++ crates/cadder-operator/src/view.rs | 442 +++++++++++++ crates/cadderctl/Cargo.toml | 1 + crates/cadderctl/src/app.rs | 300 ++++----- crates/cadderctl/src/error.rs | 367 +---------- crates/cadderctl/src/view.rs | 447 +------------ docs/ARCHITECTURE.md | 14 +- docs/site/astro.config.mjs | 1 + docs/site/src/content/docs/index.mdx | 4 +- .../docs/quick-start/getting-started.mdx | 3 +- .../content/docs/user-guide/cadder-mcp.mdx | 54 ++ .../src/content/docs/user-guide/cadderctl.mdx | 5 +- .../content/docs/user-guide/how-to-use.mdx | 6 +- xtask/src/main.rs | 25 +- 28 files changed, 3270 insertions(+), 1015 deletions(-) create mode 100644 crates/cadder-mcp/Cargo.toml create mode 100644 crates/cadder-mcp/src/dto.rs create mode 100644 crates/cadder-mcp/src/lib.rs create mode 100644 crates/cadder-mcp/src/main.rs create mode 100644 crates/cadder-mcp/src/redaction.rs create mode 100644 crates/cadder-mcp/tests/mcp.rs create mode 100644 crates/cadder-mcp/tests/support/mod.rs create mode 100644 crates/cadder-operator/Cargo.toml create mode 100644 crates/cadder-operator/src/error.rs create mode 100644 crates/cadder-operator/src/lib.rs create mode 100644 crates/cadder-operator/src/service.rs create mode 100644 crates/cadder-operator/src/view.rs create mode 100644 docs/site/src/content/docs/user-guide/cadder-mcp.mdx diff --git a/Cargo.lock b/Cargo.lock index 4c59f48..6beb77a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -388,6 +388,33 @@ dependencies = [ "tokio", ] +[[package]] +name = "cadder-mcp" +version = "0.8.0" +dependencies = [ + "anyhow", + "cadder-daemon", + "cadder-operator", + "cadder-protocol", + "chrono", + "clap", + "rmcp", + "serde", + "tempfile", + "tokio", +] + +[[package]] +name = "cadder-operator" +version = "0.8.0" +dependencies = [ + "anyhow", + "cadder-daemon", + "cadder-protocol", + "chrono", + "serde", +] + [[package]] name = "cadder-protocol" version = "0.8.0" @@ -432,6 +459,7 @@ version = "0.8.0" dependencies = [ "anyhow", "cadder-daemon", + "cadder-operator", "cadder-protocol", "chrono", "clap", @@ -1729,7 +1757,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix", + "nix 0.29.0", "winapi", ] @@ -1807,6 +1835,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -2030,6 +2070,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pear" version = "0.2.9" @@ -2242,6 +2288,20 @@ dependencies = [ "yansi", ] +[[package]] +name = "process-wrap" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" +dependencies = [ + "futures", + "indexmap 2.14.0", + "nix 0.31.3", + "tokio", + "tracing", + "windows", +] + [[package]] name = "prost" version = "0.14.4" @@ -2535,6 +2595,44 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "process-wrap", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "which", +] + +[[package]] +name = "rmcp-macros" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2649,12 +2747,26 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2720,6 +2832,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_json" version = "1.0.150" @@ -3069,7 +3192,7 @@ dependencies = [ "libc", "log", "memmem", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float", @@ -3822,6 +3945,15 @@ dependencies = [ "wezterm-dynamic", ] +[[package]] +name = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + [[package]] name = "widestring" version = "1.2.1" @@ -3850,6 +3982,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3863,6 +4016,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3891,6 +4055,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -3943,6 +4117,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index f9c87c4..2293a5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,7 @@ [workspace] members = [ + "crates/cadder-mcp", + "crates/cadder-operator", "crates/cadderctl", "crates/cadder-daemon", "crates/cadder-protocol", diff --git a/README.md b/README.md index cd87bc6..6f3d6b5 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,12 @@ Published documentation: ## What's included -Cadder ships four executables: +Cadder ships five executables: - `caddy` is the Caddy-compatible shim. For `caddy run`, it attaches to a running `cadderd`, registers the current project's Caddyfile, keeps that registration alive while the shim process runs, and unregisters on exit. If the backend is unavailable, Cadder fails with guidance instead of starting it implicitly. Other Caddy commands are delegated to the safely resolved real Caddy binary. - `cadderd` is the per-user daemon. It owns local IPC, entrypoint registrations, adapted Caddy config composition, the generated effective runtime config, the real Caddy process it starts, diagnostics, and bounded log storage. - `cadderctl` is the non-TUI operator CLI. It attaches to the daemon for one-shot state queries, logs, and toggles, and provides an explicit `daemon start` path for scripting and automation. +- `cadder-mcp` is the local stdio MCP server. It attaches to the same daemon, exposes entrypoints, domains, diagnostics, and logs as MCP tools, and keeps daemon startup explicit through a dedicated `cadder_start_daemon` tool. - `cadder-tui` is the terminal UI. It attaches to the daemon, shows overview state, entrypoints, domains, per-domain logs, diagnostics, filters, toggles, log export, and daemon shutdown, and exposes an explicit `s` action when the backend needs to be started. Each executable supports `--help` and `--version`. @@ -22,7 +23,7 @@ Each executable supports `--help` and `--version`. 1. Download the latest Cadder release for your operating system from [GitHub Releases](https://github.com/MrMaxie/Cadder/releases). 2. Create `cadder.toml` next to Cadder with the path to the real Caddy binary. 3. Start `cadderd`, then run a project through Cadder's `caddy` shim. -4. Use `cadderctl` for automation-friendly state and log queries, or open `cadder-tui` for the interactive dashboard. +4. Use `cadderctl` for automation-friendly state and log queries, start `cadder-mcp` from an MCP-capable host when you want agent tools, or open `cadder-tui` for the interactive dashboard. Full setup docs: @@ -36,6 +37,7 @@ Full setup docs: ```sh cadderd cadderctl daemon status +cadder-mcp --help caddy run cadder-tui ``` @@ -51,6 +53,8 @@ cadderctl domains disable app.localhost --registration shim-1 cadderctl logs show --limit 20 domain app.localhost --registration shim-1 --output json ``` +An MCP-capable host can also start `cadder-mcp` and interact with Cadder through local stdio tools. The server is intentionally local-only, applies extra redaction to returned text, and only launches the backend through the explicit `cadder_start_daemon` tool. + For a local checkout, run the project checks with: ```sh diff --git a/backlog/tasks/task-1.29 - Expose-Cadder-through-an-MCP-server-for-agents.md b/backlog/tasks/task-1.29 - Expose-Cadder-through-an-MCP-server-for-agents.md index 8e53dd4..8770a87 100644 --- a/backlog/tasks/task-1.29 - Expose-Cadder-through-an-MCP-server-for-agents.md +++ b/backlog/tasks/task-1.29 - Expose-Cadder-through-an-MCP-server-for-agents.md @@ -1,9 +1,11 @@ --- id: TASK-1.29 title: Expose Cadder through an MCP server for agents -status: To Do -assignee: [] +status: Done +assignee: + - '@agent' created_date: '2026-06-16 15:25' +updated_date: '2026-06-16 19:39' labels: - mcp - agent @@ -16,6 +18,33 @@ references: - crates/cadderd/src/main.rs documentation: - docs/ARCHITECTURE.md +modified_files: + - Cargo.toml + - Cargo.lock + - crates/cadder-operator/src/lib.rs + - crates/cadder-operator/src/error.rs + - crates/cadder-operator/src/view.rs + - crates/cadder-operator/src/service.rs + - crates/cadder-mcp/Cargo.toml + - crates/cadder-mcp/src/lib.rs + - crates/cadder-mcp/src/main.rs + - crates/cadder-mcp/src/dto.rs + - crates/cadder-mcp/src/redaction.rs + - crates/cadder-mcp/tests/support/mod.rs + - crates/cadder-mcp/tests/mcp.rs + - crates/cadderctl/Cargo.toml + - crates/cadderctl/src/app.rs + - crates/cadderctl/src/error.rs + - crates/cadderctl/src/view.rs + - xtask/src/main.rs + - README.md + - docs/ARCHITECTURE.md + - docs/site/astro.config.mjs + - docs/site/src/content/docs/index.mdx + - docs/site/src/content/docs/quick-start/getting-started.mdx + - docs/site/src/content/docs/user-guide/how-to-use.mdx + - docs/site/src/content/docs/user-guide/cadderctl.mdx + - docs/site/src/content/docs/user-guide/cadder-mcp.mdx parent_task_id: TASK-1 priority: high ordinal: 25400 @@ -29,16 +58,172 @@ Provide an MCP server so AI agents can inspect Cadder state and perform safe man ## Acceptance Criteria -- [ ] #1 MCP tools can inspect daemon health, registrations, domains, diagnostics, recent logs, and configuration summaries without requiring the TUI. -- [ ] #2 Safe management actions are exposed for the core non-interactive workflows, with typed results, redacted failures, and bounded output sizes. -- [ ] #3 The MCP surface works against the local daemon model by default and documents any trust, authentication, or exposure boundaries required for agent use. -- [ ] #4 Secrets and sensitive paths are redacted consistently from tool results, logs, and error messages. -- [ ] #5 Automated tests cover tool contracts, unavailable-daemon behavior, redaction, and action routing. -- [ ] #6 Agent-facing documentation explains how to start the MCP server, what tools it exposes, and when to prefer CLI or TUI instead. +- [x] #1 MCP tools can inspect daemon health, registrations, domains, diagnostics, recent logs, and configuration summaries without requiring the TUI. +- [x] #2 Safe management actions are exposed for the core non-interactive workflows, with typed results, redacted failures, and bounded output sizes. +- [x] #3 The MCP surface works against the local daemon model by default and documents any trust, authentication, or exposure boundaries required for agent use. +- [x] #4 Secrets and sensitive paths are redacted consistently from tool results, logs, and error messages. +- [x] #5 Automated tests cover tool contracts, unavailable-daemon behavior, redaction, and action routing. +- [x] #6 Agent-facing documentation explains how to start the MCP server, what tools it exposes, and when to prefer CLI or TUI instead. +## Implementation Plan + + +# MCP v1 Plan + +## Goal +Expose Cadder to local AI agents through a minimal, stable MCP server that reuses the existing `cadderctl` and daemon IPC model instead of introducing a parallel backend contract or screen-driving the TUI. + +## Milestone Fit +- Keep this task within milestone `m-2` by building on the already shipped `cadderctl` command model and current daemon IPC contracts. +- Preserve the local per-user daemon trust model. MCP v1 must remain a local `stdio` server that talks to the same daemon runtime directory model as `cadderctl`. +- Do not expand this task into remote access, authentication systems, full TUI parity, or a new daemon-side streaming protocol. + +## Scope Boundaries +Included in v1: +- Local MCP server binary and workspace wiring. +- Agent-facing inspect tools for daemon overview, entrypoints, domains, diagnostics, and recent logs. +- Bounded management tools for explicit daemon start plus entrypoint and domain activation toggles. +- Shared redaction rules for tool results, errors, and sensitive path exposure. +- Contract and integration tests for tool schemas, unavailable-daemon behavior, redaction, and action routing. +- Agent-facing documentation for startup, trust boundaries, tool usage, and when to prefer CLI or TUI. + +Explicitly out of scope for this task: +- Remote TCP or HTTP MCP transport. +- New daemon IPC messages unless implementation proves an existing contract is insufficient. +- IIS handoff MCP control. +- `watch` and `logs tail` streaming MCP tools. +- Daemon shutdown through MCP v1. +- Full TUI parity or unrestricted proxying to the real Caddy admin API. + +## Key Modules And Files +- `Cargo.toml` +- `crates/cadder-daemon/src/ipc.rs` +- `crates/cadder-daemon/src/lib.rs` +- `crates/cadder-daemon/src/logs.rs` +- `crates/cadder-protocol/src/lib.rs` +- `crates/cadderctl/src/app.rs` +- `crates/cadderctl/src/view.rs` +- `crates/cadderctl/src/error.rs` +- New `crates/cadder-mcp/` crate for the MCP server +- `README.md` +- `docs/ARCHITECTURE.md` +- Docs site pages under `docs/site/src/content/docs/` +- `xtask/src/main.rs` if packaging or verification expectations need updating + +## Implementation Steps +1. Extract a shared Cadder operator service layer from `cadderctl` so MCP and CLI can reuse the same attach, query, toggle, log-target resolution, daemon start, and typed error-mapping behavior without shelling out to `cadderctl`. +2. Keep the existing `CadderClient`, `CadderSession`, `StateSubscription`, and daemon IPC request and response types as the primary transport boundary. Reuse existing `query-state`, `query-logs`, `set-entrypoint-enabled`, `set-domain-enabled`, and explicit daemon start behavior rather than designing a second internal control path. +3. Create a new local-only `cadder-mcp` crate and binary that serves MCP over `stdio`. Accept runtime configuration inputs equivalent to the CLI runtime selection model, but do not expose any network listener in v1. +4. Define a minimal safe MCP tool surface using agent-facing DTOs rather than returning the raw daemon snapshot shape. The initial tool set should be: + - `cadder_get_overview` + - `cadder_list_entrypoints` + - `cadder_list_domains` + - `cadder_show_diagnostics` + - `cadder_get_logs` + - `cadder_start_daemon` + - `cadder_set_entrypoint_enabled` + - `cadder_set_domain_enabled` +5. Keep inspect tools attach-only. `cadder_start_daemon` is the only MCP tool that may launch `cadderd`. All other tools must return typed unavailable-daemon failures with guidance instead of causing side effects. +6. Reuse or adapt `cadderctl` view-building logic so MCP returns compact, stable, task-oriented payloads for overview, entrypoints, domains, diagnostics, and logs. Avoid exposing raw internal fields that are unnecessary for agent workflows. +7. Add bounded inputs and outputs for management and inspect tools. At minimum, validate log limits, selector ambiguity, and runtime path handling, and cap log result sizes so tool responses remain predictable for agents. +8. Implement MCP-specific redaction on top of the existing daemon log redaction. Preserve the current token-like log scrubbing, then additionally redact or normalize sensitive path and process detail fields before they are emitted through MCP. +9. Define path-redaction behavior explicitly. Prefer workspace-relative paths when the target is inside the repo or selected runtime context; otherwise return a reduced or redacted representation that remains useful for diagnostics without leaking unnecessary host-specific details. +10. Apply the same redaction discipline to tool success payloads, diagnostics, and typed failures. Error messages, guidance, configuration path summaries, and command metadata must not leak raw secrets or unnecessarily sensitive absolute paths. +11. Document the MCP trust boundary clearly: local `stdio` MCP server, same-user daemon assumptions, no remote exposure in v1, explicit daemon start semantics, and the intended division of responsibilities between MCP, `cadderctl`, and `cadder-tui`. +12. Update release and operator documentation so contributors and agents can discover the MCP binary, understand the tool names, and know when to use MCP versus CLI or TUI. + +## Redaction Requirements +- Reuse daemon-side redaction for retained log messages from `crates/cadder-daemon/src/logs.rs`. +- Redact token-like values, credentials, authorization fragments, and similar sensitive substrings from any MCP-visible free-text field. +- Redact or normalize absolute paths, executable paths, command lines, admin endpoints, and similar host-sensitive fields unless they are strictly necessary for safe agent operation. +- Ensure tool errors and guidance go through the same redaction path as normal tool results. +- Add regression tests that prove the same secret or path pattern is not exposed through logs, diagnostics, or failure payloads. + +## Contract Test Requirements +Add automated coverage at three levels: +1. Unit tests for shared operator service behavior: +- selector resolution +- bounded log option validation +- typed unavailable-daemon and rejected-operation mapping +- redaction helpers for strings and paths + +2. MCP contract tests: +- stable tool names and descriptions +- stable input validation behavior +- stable result envelope shapes for inspect and action tools +- typed error payloads for daemon unavailable, ambiguous selectors, unsupported operations, and rejected actions + +3. Integration tests using a local harness similar to `cadderctl` tests: +- inspect tools return expected data from the existing daemon IPC model +- management tools route to the correct daemon actions +- unavailable-daemon cases remain non-interactive and typed +- redaction is preserved in logs, diagnostics, and failures +- one `stdio` MCP smoke path covers initialize, tool discovery, and at least one inspect plus one action call + +## Validation +Run focused tests while implementing, then the normal repository gates before closeout: +- `cargo fmt --check` +- `cargo clippy --workspace --all-targets -- -D warnings` +- `cargo test --workspace` +- `cargo run -p xtask -- check` +- `cargo run -p xtask -- coverage` + +## Risks And Watchpoints +- Avoid duplicating `cadderctl` logic and drifting tool semantics between CLI and MCP. +- Avoid leaking host-specific or secret-bearing fields through apparently harmless diagnostics. +- Keep v1 tool scope intentionally small so tool names and schemas can stay stable. +- If implementation reveals that a current IPC response shape is too raw for safe MCP exposure, adapt through shared view or service DTOs first before proposing new daemon messages. +- If a necessary workflow would require broader actions such as shutdown or IIS mutation, stop and request an explicit scope decision instead of silently expanding the task. + + +## Implementation Notes + + +Plan approved and recorded before implementation. MCP v1 is intentionally limited to inspect plus bounded management actions on top of existing `cadderctl` and daemon IPC contracts; daemon shutdown, streaming tools, and IIS control remain out of scope unless explicitly approved later. + +Extracted a shared `cadder-operator` layer from `cadderctl` so CLI and MCP reuse the same daemon attach/query/toggle/log-target resolution logic and typed error mapping instead of drifting through separate implementations. + +Implemented `cadder-mcp` as a local stdio MCP server with bounded inspect and management tools, MCP-specific path/text redaction, and explicit daemon-start semantics limited to `cadder_start_daemon`. + +Updated release packaging and documentation to include `cadder-mcp`, plus validation for the portable layout and agent-facing docs about trust boundaries and when to prefer MCP versus CLI or TUI. + +Validation run after the final code changes: `cargo test -p cadder-operator`, `cargo test -p cadder-mcp`, `cargo test -p cadderctl --lib`, `cargo test -p xtask`, `cargo run -p xtask -- check`, `cargo run -p xtask -- coverage`, `bun run check`, and `bun run build` in `docs/site`. Workspace line coverage remained above the gate at 85.65% in `target/llvm-cov/coverage-summary.json`. + + +## Final Summary + + +## Summary +Added a new local stdio `cadder-mcp` server so agent hosts can inspect Cadder state and perform bounded management actions through a stable MCP tool surface instead of shelling out to `cadderctl` or screen-driving the TUI. + +## What Changed +- extracted a shared `cadder-operator` crate from `cadderctl` for daemon attach/query/toggle/log-target resolution, daemon start policy, typed error mapping, and shared state/view shaping +- refactored `cadderctl` to reuse the shared operator layer instead of keeping its own copies of the same logic +- added the `cadder-mcp` crate and binary with overview, entrypoint, domain, diagnostics, logs, daemon-start, and activation-toggle tools over local stdio +- added MCP-specific redaction for paths, free-form text, endpoints, and bounded log responses +- updated workspace packaging and `xtask` verification so portable layouts include `cadder-mcp` +- documented the MCP trust boundary, startup flow, tool surface, and when to use MCP versus CLI or TUI in the README, architecture notes, and docs site + +## Why +This gives AI agents a stable, structured local control surface for Cadder while preserving the existing per-user daemon model, keeping daemon startup explicit, and avoiding duplicate business logic between CLI and MCP clients. + +## Validation +- `cargo test -p cadder-operator` +- `cargo test -p cadder-mcp` +- `cargo test -p cadderctl --lib` +- `cargo test -p xtask` +- `cargo run -p xtask -- check` +- `cargo run -p xtask -- coverage` +- `bun run check` in `docs/site` +- `bun run build` in `docs/site` + +## Risks / Follow-ups +MCP v1 intentionally excludes streaming watch/tail tools, daemon shutdown, and IIS handoff control. Those should remain separate follow-up scope so the initial tool names and schemas stay stable. + + ## Definition of Done -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold +- [x] #1 Tests or explicit verification were run for the changed behavior +- [x] #2 Coverage was measured and remains at or above the project threshold diff --git a/crates/cadder-mcp/Cargo.toml b/crates/cadder-mcp/Cargo.toml new file mode 100644 index 0000000..98cc46d --- /dev/null +++ b/crates/cadder-mcp/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "cadder-mcp" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +anyhow = "=1.0.102" +cadder-daemon = { version = "0.8.0", path = "../cadder-daemon" } +cadder-operator = { version = "0.8.0", path = "../cadder-operator" } +cadder-protocol = { version = "0.8.0", path = "../cadder-protocol" } +clap = { version = "=4.6.1", features = ["derive"] } +rmcp = { version = "=1.7.0", features = ["transport-io"] } +serde = { version = "=1.0.228", features = ["derive"] } +tokio = { version = "=1.52.3", features = ["full"] } + +[dev-dependencies] +chrono = { version = "=0.4.45", features = ["serde"] } +rmcp = { version = "=1.7.0", features = ["client", "transport-child-process", "which-command"] } +tempfile = "=3.27.0" diff --git a/crates/cadder-mcp/src/dto.rs b/crates/cadder-mcp/src/dto.rs new file mode 100644 index 0000000..edd7236 --- /dev/null +++ b/crates/cadder-mcp/src/dto.rs @@ -0,0 +1,260 @@ +use rmcp::schemars; +use rmcp::schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ListEntrypointsParams { + pub registration_id: Option, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ListDomainsParams { + pub registration_id: Option, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GetLogsParams { + pub target: LogTargetKind, + pub registration_id: Option, + pub domain: Option, + pub limit: Option, + pub cursor: Option, + pub minimum_severity: Option, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SetEntrypointEnabledParams { + pub registration_id: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SetDomainEnabledParams { + pub domain: String, + pub registration_id: Option, + pub enabled: bool, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum LogTargetKind { + Runtime, + Entrypoint, + Domain, +} + +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SeverityParam { + Trace, + Debug, + Info, + Warn, + Error, + Fatal, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct OverviewOutput { + pub captured_at_utc: String, + pub runtime_dir: String, + pub trust_boundary: TrustBoundaryOutput, + pub counts: OverviewCountsOutput, + pub runtime: RuntimeSummaryOutput, + pub config: ConfigSummaryOutput, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TrustBoundaryOutput { + pub transport: String, + pub exposure: String, + pub daemon_model: String, + pub implicit_start: bool, + pub explicit_start_tool: String, + pub preferred_surfaces: Vec, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct OverviewCountsOutput { + pub entrypoints: usize, + pub domains: usize, + pub active_domains: usize, + pub runtime_diagnostic_count: usize, + pub config_diagnostic_count: usize, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeSummaryOutput { + pub status: String, + pub version: Option, + pub binary_path: Option, + pub admin_endpoint: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConfigSummaryOutput { + pub status: String, + pub effective_config_hash: Option, + pub last_attempted_at_utc: Option, + pub last_successful_reload_at_utc: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EntrypointListOutput { + pub captured_at_utc: String, + pub entrypoints: Vec, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EntrypointOutput { + pub registration_id: String, + pub activation_state: String, + pub working_directory: String, + pub config_path: String, + pub started_at_utc: String, + pub last_heartbeat_utc: String, + pub executable_path: Option, + pub domain_count: usize, + pub active_domain_count: usize, + pub domains: Vec, + pub adapter: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DomainListOutput { + pub captured_at_utc: String, + pub domains: Vec, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DomainOutput { + pub registration_id: String, + pub domain: String, + pub canonical_domain: String, + pub activation_state: String, + pub entrypoint_activation_state: String, + pub working_directory: String, + pub config_path: String, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DiagnosticsOutput { + pub captured_at_utc: String, + pub runtime_status: String, + pub config_status: String, + pub runtime_diagnostics: Vec, + pub config_diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeDiagnosticOutput { + pub code: String, + pub message: String, + pub operation: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConfigDiagnosticOutput { + pub code: String, + pub message: String, + pub domain_key: Option, + pub source_config_paths: Vec, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LogsOutput { + pub stream_id: String, + pub channel: String, + pub domain_key: Option, + pub stream_status: String, + pub entries: Vec, + pub next_cursor: Option, + pub has_gap: bool, + pub has_more_before: bool, + pub truncated_by_retention: bool, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LogEntryOutput { + pub sequence_number: u64, + pub timestamp_utc: String, + pub severity: String, + pub attribution_kind: String, + pub entry_kind: String, + pub message: String, + pub operation: Option, + pub source_registration_id: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct StartDaemonOutput { + pub started: bool, + pub message: String, + pub overview: OverviewOutput, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ToggleEntrypointOutput { + pub registration_id: String, + pub enabled: bool, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ToggleDomainOutput { + pub canonical_domain: String, + pub registration_id: Option, + pub enabled: bool, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ToolErrorResponse { + pub ok: bool, + pub error: ToolErrorPayload, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ToolErrorPayload { + pub kind: ToolErrorKind, + pub message: String, + pub guidance: Option, + pub retryable: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum ToolErrorKind { + InvalidUsage, + DaemonUnavailable, + DaemonStartFailure, + TargetNotFound, + ConflictOrRejected, + PermissionOrElevation, + UnsupportedOperation, + IpcFailure, +} diff --git a/crates/cadder-mcp/src/lib.rs b/crates/cadder-mcp/src/lib.rs new file mode 100644 index 0000000..b8db857 --- /dev/null +++ b/crates/cadder-mcp/src/lib.rs @@ -0,0 +1,616 @@ +mod dto; +mod redaction; + +use anyhow::Result; +use cadder_daemon::DaemonLaunchOptions; +use cadder_operator::{ + DomainSelector, OperatorContext, OperatorError, OperatorErrorKind, counts, diagnostics_view, + domains_view, entrypoints_view, +}; +use clap::Parser; +use dto::{ + ConfigDiagnosticOutput, ConfigSummaryOutput, DiagnosticsOutput, DomainListOutput, DomainOutput, + EntrypointListOutput, EntrypointOutput, GetLogsParams, ListDomainsParams, ListEntrypointsParams, + LogEntryOutput, LogTargetKind, LogsOutput, OverviewCountsOutput, OverviewOutput, + RuntimeDiagnosticOutput, RuntimeSummaryOutput, SetDomainEnabledParams, + SetEntrypointEnabledParams, SeverityParam, StartDaemonOutput, ToggleDomainOutput, + ToggleEntrypointOutput, ToolErrorKind, ToolErrorPayload, ToolErrorResponse, TrustBoundaryOutput, +}; +use redaction::{McpRedactor, truncate}; +use rmcp::{ + ServerHandler, ServiceExt, + handler::server::wrapper::{Json, Parameters}, + model::{CallToolResult, Implementation, ServerInfo}, + serde_json, tool, tool_handler, tool_router, + transport::stdio, +}; +use std::path::PathBuf; + +const MAX_LOG_LIMIT: usize = 200; +const DEFAULT_LOG_LIMIT: usize = 50; + +#[derive(Debug, Clone, Parser)] +#[command( + name = "cadder-mcp", + version, + about = "Cadder local stdio MCP server for agent-safe inspection and bounded management" +)] +pub struct Args { + #[arg( + long, + help = "Override the Cadder runtime directory used to find daemon IPC and state" + )] + pub runtime_dir: Option, + + #[arg( + long, + help = "Path to a cadderd executable for the explicit daemon start tool" + )] + pub daemon_path: Option, + + #[arg( + long, + help = "Command or path passed to cadderd when explicitly starting the real Caddy binary" + )] + pub real_caddy_command: Option, +} + +#[derive(Clone)] +pub struct CadderMcpServer { + operator: OperatorContext, + redactor: McpRedactor, +} + +impl CadderMcpServer { + pub fn new(args: Args) -> Result { + let operator = OperatorContext::new( + "cadder-mcp", + args.runtime_dir, + DaemonLaunchOptions { + explicit_daemon: args.daemon_path, + real_caddy_command: args.real_caddy_command, + shim_path: None, + }, + )?; + let redactor = McpRedactor::new( + std::env::current_dir().ok(), + operator.paths().runtime_dir().to_path_buf(), + ); + Ok(Self { operator, redactor }) + } + + pub async fn run_stdio(self) -> Result<()> { + let service = self.serve(stdio()).await?; + service.waiting().await?; + Ok(()) + } + + fn map_error(&self, error: OperatorError) -> CallToolResult { + let (kind, retryable, guidance) = match error.kind { + OperatorErrorKind::InvalidUsage => (ToolErrorKind::InvalidUsage, false, None), + OperatorErrorKind::DaemonUnavailable => ( + ToolErrorKind::DaemonUnavailable, + true, + Some("Call `cadder_start_daemon`, or start `cadderd` for the same runtime directory, then retry.".to_string()), + ), + OperatorErrorKind::DaemonStartFailure => ( + ToolErrorKind::DaemonStartFailure, + true, + Some("Fix the daemon path or startup configuration, then retry `cadder_start_daemon`.".to_string()), + ), + OperatorErrorKind::TargetNotFound => (ToolErrorKind::TargetNotFound, false, None), + OperatorErrorKind::ConflictOrRejected => ( + ToolErrorKind::ConflictOrRejected, + true, + Some("Refresh Cadder state and retry with a more explicit selector if needed.".to_string()), + ), + OperatorErrorKind::PermissionOrElevation => ( + ToolErrorKind::PermissionOrElevation, + true, + Some("Check local IPC and runtime-directory permissions, then retry.".to_string()), + ), + OperatorErrorKind::UnsupportedOperation => (ToolErrorKind::UnsupportedOperation, false, None), + OperatorErrorKind::IpcFailure => ( + ToolErrorKind::IpcFailure, + true, + Some("Retry after Cadder finishes its current transition or reconnect to the daemon.".to_string()), + ), + }; + + self.error_response( + kind, + self.redactor.redact_text(&error.message), + guidance, + retryable, + ) + } + + fn error_response( + &self, + kind: ToolErrorKind, + message: String, + guidance: Option, + retryable: bool, + ) -> CallToolResult { + let value = serde_json::to_value(ToolErrorResponse { + ok: false, + error: ToolErrorPayload { + kind, + message, + guidance, + retryable, + }, + }) + .expect("tool error response serializes"); + CallToolResult::structured_error(value) + } + + fn invalid_usage(&self, message: impl Into, guidance: Option) -> CallToolResult { + self.error_response( + ToolErrorKind::InvalidUsage, + self.redactor.redact_text(&message.into()), + guidance, + false, + ) + } + + fn build_overview(&self, snapshot: &cadder_protocol::GuiStateSnapshot) -> OverviewOutput { + let counts_view = counts(snapshot); + OverviewOutput { + captured_at_utc: snapshot.captured_at_utc.to_rfc3339(), + runtime_dir: self + .redactor + .redact_path(&self.operator.paths().runtime_dir().display().to_string()), + trust_boundary: TrustBoundaryOutput { + transport: "stdio".to_string(), + exposure: "local-only".to_string(), + daemon_model: "same-user per-runtime local daemon".to_string(), + implicit_start: false, + explicit_start_tool: "cadder_start_daemon".to_string(), + preferred_surfaces: vec![ + "Use MCP for bounded inspect and toggle workflows.".to_string(), + "Use cadderctl for watches, tails, and shell automation.".to_string(), + "Use cadder-tui for interactive browsing and richer operator context.".to_string(), + ], + }, + counts: OverviewCountsOutput { + entrypoints: counts_view.entrypoints, + domains: counts_view.domains, + active_domains: counts_view.active_domains, + runtime_diagnostic_count: snapshot.runtime.diagnostics.len(), + config_diagnostic_count: snapshot.config.diagnostics.len(), + }, + runtime: RuntimeSummaryOutput { + status: format!("{:?}", snapshot.runtime.status), + version: snapshot.runtime.version.clone(), + binary_path: snapshot + .runtime + .binary_path + .as_deref() + .map(|path| self.redactor.redact_path(path)), + admin_endpoint: self + .redactor + .redact_endpoint(snapshot.runtime.admin_endpoint.as_deref()), + }, + config: ConfigSummaryOutput { + status: format!("{:?}", snapshot.config.status), + effective_config_hash: snapshot.config.effective_config_hash.clone(), + last_attempted_at_utc: snapshot + .config + .last_attempted_at_utc + .map(|value| value.to_rfc3339()), + last_successful_reload_at_utc: snapshot + .config + .last_successful_reload_at_utc + .map(|value| value.to_rfc3339()), + }, + } + } + + fn build_entrypoints( + &self, + snapshot: &cadder_protocol::GuiStateSnapshot, + registration_filter: Option<&str>, + ) -> EntrypointListOutput { + let view = entrypoints_view(snapshot); + let entrypoints = view + .entrypoints + .into_iter() + .filter(|entrypoint| { + registration_filter.is_none_or(|filter| entrypoint.registration_id == filter) + }) + .map(|entrypoint| { + let domains = snapshot + .registrations + .iter() + .find(|registration| registration.registration_id == entrypoint.registration_id) + .map(|registration| { + registration + .registered_domains + .iter() + .map(|domain| domain.name.canonical.clone()) + .collect() + }) + .unwrap_or_default(); + + EntrypointOutput { + registration_id: entrypoint.registration_id, + activation_state: format!("{:?}", entrypoint.activation_state), + working_directory: self.redactor.redact_path(&entrypoint.working_directory), + config_path: self.redactor.redact_path(&entrypoint.config_path), + started_at_utc: entrypoint.started_at_utc.to_rfc3339(), + last_heartbeat_utc: entrypoint.last_heartbeat_utc.to_rfc3339(), + executable_path: entrypoint + .executable_path + .as_deref() + .map(|path| self.redactor.redact_path(path)), + domain_count: entrypoint.domain_count, + active_domain_count: entrypoint.active_domain_count, + domains, + adapter: entrypoint.adapter, + } + }) + .collect(); + + EntrypointListOutput { + captured_at_utc: view.captured_at_utc.to_rfc3339(), + entrypoints, + } + } + + fn build_domains(&self, view: cadder_operator::DomainListView) -> DomainListOutput { + DomainListOutput { + captured_at_utc: view.captured_at_utc.to_rfc3339(), + domains: view + .domains + .into_iter() + .map(|domain| DomainOutput { + registration_id: domain.registration_id, + domain: domain.domain, + canonical_domain: domain.canonical_domain, + activation_state: format!("{:?}", domain.activation_state), + entrypoint_activation_state: format!("{:?}", domain.entrypoint_activation_state), + working_directory: self.redactor.redact_path(&domain.working_directory), + config_path: self.redactor.redact_path(&domain.config_path), + }) + .collect(), + } + } + + fn build_diagnostics(&self, snapshot: &cadder_protocol::GuiStateSnapshot) -> DiagnosticsOutput { + let view = diagnostics_view(snapshot); + DiagnosticsOutput { + captured_at_utc: view.captured_at_utc.to_rfc3339(), + runtime_status: view.runtime.status, + config_status: view.config.status, + runtime_diagnostics: view + .runtime_diagnostics + .into_iter() + .map(|diagnostic| RuntimeDiagnosticOutput { + code: diagnostic.code, + message: self.redactor.redact_text(&diagnostic.message), + operation: diagnostic + .operation + .as_deref() + .map(|operation| truncate(&self.redactor.redact_text(operation), 120)), + }) + .collect(), + config_diagnostics: view + .config_diagnostics + .into_iter() + .map(|diagnostic| ConfigDiagnosticOutput { + code: diagnostic.code, + message: self.redactor.redact_text(&diagnostic.message), + domain_key: diagnostic.domain_key, + source_config_paths: diagnostic + .source_config_paths + .iter() + .map(|path| self.redactor.redact_path(path)) + .collect(), + }) + .collect(), + } + } + + fn build_logs(&self, view: cadder_operator::LogsView) -> LogsOutput { + LogsOutput { + stream_id: view.stream.stream_id, + channel: view.stream.channel, + domain_key: view.stream.domain_key, + stream_status: format!("{:?}", view.stream_status), + entries: view + .entries + .into_iter() + .map(|entry| LogEntryOutput { + sequence_number: entry.sequence_number, + timestamp_utc: entry.timestamp_utc.to_rfc3339(), + severity: format!("{:?}", entry.severity), + attribution_kind: format!("{:?}", entry.attribution_kind), + entry_kind: format!("{:?}", entry.entry_kind), + message: self.redactor.redact_text(&entry.raw_message), + operation: entry + .operation + .as_deref() + .map(|operation| truncate(&self.redactor.redact_text(operation), 120)), + source_registration_id: entry.source_registration_id, + }) + .collect(), + next_cursor: view.next_cursor, + has_gap: view.has_gap, + has_more_before: view.has_more_before, + truncated_by_retention: view.truncated_by_retention, + } + } +} + +#[tool_router] +impl CadderMcpServer { + #[tool( + name = "cadder_get_overview", + description = "Inspect local Cadder daemon health, runtime state, config summary, counts, and trust boundaries." + )] + async fn cadder_get_overview(&self) -> std::result::Result, CallToolResult> { + let snapshot = self + .operator + .query_snapshot("cadder_get_overview", "query daemon overview") + .await + .map_err(|error| self.map_error(error))?; + Ok(Json(self.build_overview(&snapshot))) + } + + #[tool( + name = "cadder_list_entrypoints", + description = "List Cadder entrypoints with bounded, redacted metadata suitable for agent inspection." + )] + async fn cadder_list_entrypoints( + &self, + Parameters(params): Parameters, + ) -> std::result::Result, CallToolResult> { + let snapshot = self + .operator + .query_snapshot("cadder_list_entrypoints", "query entrypoints") + .await + .map_err(|error| self.map_error(error))?; + Ok(Json(self.build_entrypoints( + &snapshot, + params.registration_id.as_deref(), + ))) + } + + #[tool( + name = "cadder_list_domains", + description = "List Cadder domains and their activation state without exposing raw host-specific paths." + )] + async fn cadder_list_domains( + &self, + Parameters(params): Parameters, + ) -> std::result::Result, CallToolResult> { + let snapshot = self + .operator + .query_snapshot("cadder_list_domains", "query domains") + .await + .map_err(|error| self.map_error(error))?; + let view = domains_view(&snapshot, params.registration_id.as_deref()); + Ok(Json(self.build_domains(view))) + } + + #[tool( + name = "cadder_show_diagnostics", + description = "Show bounded Cadder runtime and config diagnostics with secret and path redaction." + )] + async fn cadder_show_diagnostics( + &self, + ) -> std::result::Result, CallToolResult> { + let snapshot = self + .operator + .query_snapshot("cadder_show_diagnostics", "query diagnostics") + .await + .map_err(|error| self.map_error(error))?; + Ok(Json(self.build_diagnostics(&snapshot))) + } + + #[tool( + name = "cadder_get_logs", + description = "Read recent Cadder logs for the runtime, one entrypoint, or one domain with bounded results and redacted text." + )] + async fn cadder_get_logs( + &self, + Parameters(params): Parameters, + ) -> std::result::Result, CallToolResult> { + let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT); + if !(1..=MAX_LOG_LIMIT).contains(&limit) { + return Err(self.invalid_usage( + format!("`limit` must be between 1 and {MAX_LOG_LIMIT}."), + None, + )); + } + + if let Some(cursor) = params.cursor.as_deref() { + let valid_cursor = cursor + .strip_prefix("seq:") + .is_some_and(|rest| !rest.is_empty() && rest.chars().all(|ch| ch.is_ascii_digit())); + if !valid_cursor { + return Err(self.invalid_usage("Cursor must use the daemon `seq:` format.", None)); + } + } + + let target = match params.target { + LogTargetKind::Runtime => { + if params.registration_id.is_some() || params.domain.is_some() { + return Err(self.invalid_usage( + "Runtime log queries do not accept `registrationId` or `domain`.", + None, + )); + } + cadder_operator::LogsTarget::Runtime + } + LogTargetKind::Entrypoint => { + let Some(registration_id) = params.registration_id else { + return Err(self.invalid_usage("Entrypoint log queries require `registrationId`.", None)); + }; + if params.domain.is_some() { + return Err(self.invalid_usage("Entrypoint log queries do not accept `domain`.", None)); + } + cadder_operator::LogsTarget::Entrypoint { registration_id } + } + LogTargetKind::Domain => { + let Some(domain) = params.domain else { + return Err(self.invalid_usage("Domain log queries require `domain`.", None)); + }; + cadder_operator::LogsTarget::Domain(DomainSelector { + domain, + registration: params.registration_id, + }) + } + }; + + let stream = self + .operator + .resolve_logs_target("cadder_get_logs", target) + .await + .map_err(|error| self.map_error(error))?; + let view = self + .operator + .query_logs( + "cadder_get_logs", + "query retained logs", + stream, + limit, + params.cursor, + map_severity(params.minimum_severity), + ) + .await + .map_err(|error| self.map_error(error))?; + Ok(Json(self.build_logs(view))) + } + + #[tool( + name = "cadder_start_daemon", + description = "Explicitly start the local Cadder daemon for this runtime directory and return an updated overview." + )] + async fn cadder_start_daemon( + &self, + ) -> std::result::Result, CallToolResult> { + let already_running = self.operator.query_state_response().await.is_ok(); + self + .operator + .ensure_daemon_running("cadder_start_daemon") + .await + .map_err(|error| self.map_error(error))?; + let snapshot = self + .operator + .query_snapshot("cadder_start_daemon", "query daemon status after start") + .await + .map_err(|error| self.map_error(error))?; + Ok(Json(StartDaemonOutput { + started: !already_running, + message: if already_running { + "cadderd is already running.".to_string() + } else { + "cadderd started.".to_string() + }, + overview: self.build_overview(&snapshot), + })) + } + + #[tool( + name = "cadder_set_entrypoint_enabled", + description = "Enable or disable one Cadder entrypoint by registration ID." + )] + async fn cadder_set_entrypoint_enabled( + &self, + Parameters(params): Parameters, + ) -> std::result::Result, CallToolResult> { + let response = self + .operator + .set_entrypoint_enabled( + "cadder_set_entrypoint_enabled", + params.registration_id.clone(), + params.enabled, + ) + .await + .map_err(|error| self.map_error(error))?; + Ok(Json(ToggleEntrypointOutput { + registration_id: params.registration_id, + enabled: params.enabled, + message: self.redactor.redact_text(&response.message), + })) + } + + #[tool( + name = "cadder_set_domain_enabled", + description = "Enable or disable one Cadder domain with an optional registration filter for disambiguation." + )] + async fn cadder_set_domain_enabled( + &self, + Parameters(params): Parameters, + ) -> std::result::Result, CallToolResult> { + let response = self + .operator + .set_domain_enabled( + "cadder_set_domain_enabled", + &DomainSelector { + domain: params.domain.clone(), + registration: params.registration_id.clone(), + }, + params.enabled, + ) + .await + .map_err(|error| self.map_error(error))?; + Ok(Json(ToggleDomainOutput { + canonical_domain: cadder_protocol::canonicalize_domain(¶ms.domain), + registration_id: params.registration_id, + enabled: params.enabled, + message: self.redactor.redact_text(&response.message), + })) + } +} + +#[tool_handler] +impl ServerHandler for CadderMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::default() + .with_server_info( + Implementation::new("cadder-mcp", env!("CARGO_PKG_VERSION")) + .with_title("Cadder MCP") + .with_description("Local stdio MCP server for safe Cadder inspection and bounded management."), + ) + .with_instructions( + "Cadder MCP is local-only over stdio. It talks to the same per-user Cadder daemon model as cadderctl. Only `cadder_start_daemon` may launch `cadderd`; all other tools are attach-only and return typed unavailable-daemon errors when the backend is absent. Prefer cadderctl for streaming watch/tail workflows and cadder-tui for interactive browsing." + ) + } +} + +fn map_severity(value: Option) -> Option { + match value { + None => None, + Some(SeverityParam::Trace) => Some(cadder_protocol::LogSeverity::Trace), + Some(SeverityParam::Debug) => Some(cadder_protocol::LogSeverity::Debug), + Some(SeverityParam::Info) => Some(cadder_protocol::LogSeverity::Info), + Some(SeverityParam::Warn) => Some(cadder_protocol::LogSeverity::Warn), + Some(SeverityParam::Error) => Some(cadder_protocol::LogSeverity::Error), + Some(SeverityParam::Fatal) => Some(cadder_protocol::LogSeverity::Fatal), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::redaction::McpRedactor; + + #[test] + fn invalid_usage_response_marks_tool_error() { + let server = CadderMcpServer { + operator: OperatorContext::from_paths( + cadder_daemon::RuntimePaths::resolve(Some(PathBuf::from("D:/runtime"))).unwrap(), + DaemonLaunchOptions::default(), + ), + redactor: McpRedactor::new(None, PathBuf::from("D:/runtime")), + }; + + let error = server.invalid_usage("bad input", Some("fix it".to_string())); + + assert_eq!(error.is_error, Some(true)); + assert!(error.structured_content.is_some()); + } +} diff --git a/crates/cadder-mcp/src/main.rs b/crates/cadder-mcp/src/main.rs new file mode 100644 index 0000000..2a3557f --- /dev/null +++ b/crates/cadder-mcp/src/main.rs @@ -0,0 +1,7 @@ +use clap::Parser; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = cadder_mcp::Args::parse(); + cadder_mcp::CadderMcpServer::new(args)?.run_stdio().await +} diff --git a/crates/cadder-mcp/src/redaction.rs b/crates/cadder-mcp/src/redaction.rs new file mode 100644 index 0000000..7a66f66 --- /dev/null +++ b/crates/cadder-mcp/src/redaction.rs @@ -0,0 +1,179 @@ +use cadder_daemon::Redactor; +use std::path::{Path, PathBuf}; + +const MAX_FREE_TEXT_CHARS: usize = 400; + +#[derive(Debug, Clone)] +pub struct McpRedactor { + workspace_root: Option, + runtime_dir: PathBuf, +} + +impl McpRedactor { + pub fn new(workspace_root: Option, runtime_dir: PathBuf) -> Self { + Self { + workspace_root, + runtime_dir, + } + } + + pub fn redact_path(&self, raw: &str) -> String { + if raw.trim().is_empty() { + return raw.to_string(); + } + + let path = Path::new(raw); + if !path.is_absolute() { + return raw.replace('\\', "/"); + } + + if let Some(relative) = self.strip_prefix(path, self.workspace_root.as_deref()) { + return relative; + } + + if let Some(relative) = self.strip_prefix(path, Some(&self.runtime_dir)) { + return format!("/{}", relative); + } + + path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| format!("/{name}")) + .unwrap_or_else(|| "".to_string()) + } + + pub fn redact_endpoint(&self, endpoint: Option<&str>) -> Option { + endpoint.map(|endpoint| { + if endpoint.contains("127.0.0.1") || endpoint.contains("localhost") { + "loopback".to_string() + } else { + "".to_string() + } + }) + } + + pub fn redact_text(&self, text: &str) -> String { + let base = Redactor::redact(text); + let sanitized = base + .split_whitespace() + .map(|token| self.redact_token(token)) + .collect::>() + .join(" "); + truncate(&sanitized, MAX_FREE_TEXT_CHARS) + } + + fn redact_token(&self, token: &str) -> String { + let (prefix, core, suffix) = split_token(token); + if core.is_empty() { + return token.to_string(); + } + + let redacted = if looks_like_absolute_path(&core) { + self.redact_path(&core) + } else { + core.to_string() + }; + + format!("{prefix}{redacted}{suffix}") + } + + fn strip_prefix(&self, path: &Path, base: Option<&Path>) -> Option { + let base = base?; + let relative = path.strip_prefix(base).ok()?; + Some(relative.to_string_lossy().replace('\\', "/")) + } +} + +fn looks_like_absolute_path(token: &str) -> bool { + let path = Path::new(token); + path.is_absolute() + || token.starts_with("\\\\") + || token.starts_with('/') + || token + .as_bytes() + .get(1) + .is_some_and(|separator| *separator == b':') +} + +fn split_token(token: &str) -> (String, String, String) { + let bytes = token.as_bytes(); + let mut start = 0; + while start < bytes.len() && is_wrapper(bytes[start] as char) { + start += 1; + } + + let mut end = bytes.len(); + while end > start && is_wrapper(bytes[end - 1] as char) { + end -= 1; + } + + ( + token[..start].to_string(), + token[start..end].to_string(), + token[end..].to_string(), + ) +} + +fn is_wrapper(ch: char) -> bool { + matches!( + ch, + '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | '!' | '?' + ) +} + +pub fn truncate(text: &str, max_chars: usize) -> String { + let char_count = text.chars().count(); + if char_count <= max_chars { + return text.to_string(); + } + + let truncated = text + .chars() + .take(max_chars.saturating_sub(3)) + .collect::(); + format!("{truncated}...") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_paths_become_relative() { + let redactor = McpRedactor::new( + Some(PathBuf::from("D:/Projects/Personal/Cadder")), + PathBuf::from("D:/Users/test/runtime"), + ); + + assert_eq!( + redactor.redact_path("D:/Projects/Personal/Cadder/crates/cadderctl/src/app.rs"), + "crates/cadderctl/src/app.rs" + ); + } + + #[test] + fn runtime_paths_use_runtime_prefix() { + let redactor = McpRedactor::new( + Some(PathBuf::from("D:/Projects/Personal/Cadder")), + PathBuf::from("D:/Users/test/runtime"), + ); + + assert_eq!( + redactor.redact_path("D:/Users/test/runtime/daemon.json"), + "/daemon.json" + ); + } + + #[test] + fn free_text_redacts_secrets_and_paths() { + let redactor = McpRedactor::new( + Some(PathBuf::from("D:/Projects/Personal/Cadder")), + PathBuf::from("D:/Users/test/runtime"), + ); + + let text = redactor + .redact_text("Authorization: bearer token=abc D:/Projects/Personal/Cadder/Caddyfile failed"); + + assert_eq!(text, "[redacted] [redacted] [redacted] Caddyfile failed"); + } +} diff --git a/crates/cadder-mcp/tests/mcp.rs b/crates/cadder-mcp/tests/mcp.rs new file mode 100644 index 0000000..eb501cc --- /dev/null +++ b/crates/cadder-mcp/tests/mcp.rs @@ -0,0 +1,171 @@ +mod support; + +use rmcp::{ + ServiceExt, model::CallToolRequestParams, object, serde_json, transport::TokioChildProcess, +}; +use serde_json::Value; +use std::process::Stdio; +use support::Harness; +use tokio::process::Command; + +fn parse_structured(result: &rmcp::model::CallToolResult) -> Value { + result.structured_content.clone().unwrap() +} + +async fn spawn_client(runtime_dir: &str) -> rmcp::service::RunningService { + let mut command = Command::new(env!("CARGO_BIN_EXE_cadder-mcp")); + command + .arg("--runtime-dir") + .arg(runtime_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + let transport = TokioChildProcess::new(command).unwrap(); + ().serve(transport).await.unwrap() +} + +#[tokio::test] +async fn stdio_smoke_and_tool_discovery_are_stable() { + let harness = Harness::start().await; + harness.register_entrypoint("shim-1").await; + let runtime_dir = harness.paths.runtime_dir().display().to_string(); + + let client = spawn_client(&runtime_dir).await; + let tools = client.peer().list_all_tools().await.unwrap(); + let names = tools + .iter() + .map(|tool| tool.name.as_ref()) + .collect::>(); + + assert_eq!( + names, + vec![ + "cadder_get_logs", + "cadder_get_overview", + "cadder_list_domains", + "cadder_list_entrypoints", + "cadder_set_domain_enabled", + "cadder_set_entrypoint_enabled", + "cadder_show_diagnostics", + "cadder_start_daemon", + ] + ); + assert!(tools.iter().all(|tool| tool.description.is_some())); + + let overview = client + .peer() + .call_tool(CallToolRequestParams::new("cadder_get_overview")) + .await + .unwrap(); + assert_eq!(overview.is_error, Some(false)); + assert_eq!(parse_structured(&overview)["counts"]["entrypoints"], 1); + + let toggle = client + .peer() + .call_tool( + CallToolRequestParams::new("cadder_set_domain_enabled").with_arguments(object!({ + "domain": "app.smarketing.localhost", + "registrationId": "shim-1", + "enabled": false + })), + ) + .await + .unwrap(); + assert_eq!(toggle.is_error, Some(false)); + + let snapshot = harness.snapshot().await; + let domain = snapshot.registrations[0] + .registered_domains + .iter() + .find(|domain| domain.name.canonical == "app.smarketing.localhost") + .unwrap(); + assert_eq!(format!("{:?}", domain.activation_state), "Inactive"); + + client.cancel().await.unwrap(); + harness.shutdown().await; +} + +#[tokio::test] +async fn unavailable_daemon_returns_typed_tool_error() { + let runtime_dir = tempfile::tempdir().unwrap(); + let runtime_arg = runtime_dir.path().to_string_lossy().to_string(); + let client = spawn_client(&runtime_arg).await; + + let result = client + .peer() + .call_tool(CallToolRequestParams::new("cadder_list_domains").with_arguments(object!({}))) + .await + .unwrap(); + + let structured = parse_structured(&result); + assert_eq!(result.is_error, Some(true)); + assert_eq!(structured["error"]["kind"], "daemonUnavailable"); + assert!( + structured["error"]["guidance"] + .as_str() + .unwrap() + .contains("cadder_start_daemon") + ); + + client.cancel().await.unwrap(); +} + +#[tokio::test] +async fn log_results_are_redacted_and_bounded() { + let harness = Harness::start().await; + harness.register_entrypoint("shim-1").await; + harness.append_domain_log( + "app.smarketing.localhost", + cadder_protocol::LogSeverity::Error, + "Authorization: bearer token=abc D:/Projects/Personal/Cadder/Caddyfile exploded", + ); + let runtime_dir = harness.paths.runtime_dir().display().to_string(); + let client = spawn_client(&runtime_dir).await; + + let result = client + .peer() + .call_tool( + CallToolRequestParams::new("cadder_get_logs").with_arguments(object!({ + "target": "domain", + "domain": "app.smarketing.localhost", + "registrationId": "shim-1", + "limit": 10 + })), + ) + .await + .unwrap(); + + let structured = parse_structured(&result); + let message = structured["entries"][0]["message"].as_str().unwrap(); + assert_eq!(result.is_error, Some(false)); + assert!(!message.contains("token=abc")); + assert!(!message.contains("D:/Projects/Personal/Cadder")); + + client.cancel().await.unwrap(); + harness.shutdown().await; +} + +#[tokio::test] +async fn invalid_log_limit_returns_typed_error() { + let harness = Harness::start().await; + let runtime_dir = harness.paths.runtime_dir().display().to_string(); + let client = spawn_client(&runtime_dir).await; + + let result = client + .peer() + .call_tool( + CallToolRequestParams::new("cadder_get_logs").with_arguments(object!({ + "target": "runtime", + "limit": 0 + })), + ) + .await + .unwrap(); + + let structured = parse_structured(&result); + assert_eq!(result.is_error, Some(true)); + assert_eq!(structured["error"]["kind"], "invalidUsage"); + + client.cancel().await.unwrap(); + harness.shutdown().await; +} diff --git a/crates/cadder-mcp/tests/support/mod.rs b/crates/cadder-mcp/tests/support/mod.rs new file mode 100644 index 0000000..47824a0 --- /dev/null +++ b/crates/cadder-mcp/tests/support/mod.rs @@ -0,0 +1,227 @@ +use cadder_daemon::{ + CadderClient, CadderSession, CaddyConfigAdapter, CaddyConfigCoordinator, DaemonServer, + DaemonState, ProcessRuntime, RealCaddyResolver, RuntimePaths, +}; +use cadder_protocol::{ + ActivationState, EntrypointInstanceIdentity, EntrypointRegistration, LogAttributionKind, + LogSeverity, LogStreamIdentity, OwnerProcessIdentity, QueryStateRequest, QueryStateResponse, + RegisterEntrypointRequest, RegisterEntrypointResponse, ShimRunMetadata, SourcePath, + message_types, new_request_id, +}; +use chrono::Utc; +use std::{ + fs, + path::{Path, PathBuf}, + sync::Mutex, + time::Duration, +}; +use tokio::{sync::watch, time::sleep}; + +const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../cadder-daemon/tests/fixtures/SmarketingReverseProxy.Caddyfile" +)); + +pub struct Harness { + pub client: CadderClient, + pub state: DaemonState, + pub paths: RuntimePaths, + pub config_path: PathBuf, + sessions: Mutex>, + shutdown_tx: watch::Sender, + _temp: tempfile::TempDir, +} + +impl Harness { + pub async fn start() -> Self { + let temp = tempfile::tempdir().unwrap(); + let runtime_dir = temp.path().join("runtime"); + let paths = RuntimePaths::resolve(Some(runtime_dir)).unwrap(); + paths.ensure_dirs().unwrap(); + let config_path = temp.path().join("Caddyfile"); + fs::write(&config_path, FIXTURE).unwrap(); + let command_log_path = temp.path().join("fake-caddy-commands.log"); + let fake_caddy_path = write_fake_caddy(temp.path(), &command_log_path); + + let resolver = RealCaddyResolver::new(Some(fake_caddy_path.display().to_string())); + let adapter = CaddyConfigAdapter::new(resolver.clone()); + let runtime = ProcessRuntime::new(resolver, paths.clone()); + let state = DaemonState::new(CaddyConfigCoordinator::new(adapter, runtime)); + let server = DaemonServer::new(paths.clone(), state.clone()); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + tokio::spawn(async move { + let _ = server.run_until(shutdown_rx).await; + }); + + let client = CadderClient::new(paths.clone()); + for _ in 0..50 { + if client + .request::<_, QueryStateResponse>( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("wait"), + }, + ) + .await + .is_ok() + { + return Self { + client, + state, + paths, + config_path, + sessions: Mutex::new(Vec::new()), + shutdown_tx, + _temp: temp, + }; + } + sleep(Duration::from_millis(20)).await; + } + + panic!("daemon server did not become ready"); + } + + pub async fn register_entrypoint(&self, id: &str) { + let mut session = CadderSession::connect(&self.paths).await.unwrap(); + let response: RegisterEntrypointResponse = session + .request( + message_types::REGISTER_ENTRYPOINT_REQUEST, + message_types::REGISTER_ENTRYPOINT_RESPONSE, + &RegisterEntrypointRequest { + request_id: new_request_id("test-register"), + registration: registration(id, &format!("{id}-nonce"), &self.config_path), + }, + ) + .await + .unwrap(); + assert!(response.accepted, "{}", response.message); + self.sessions.lock().unwrap().push(session); + } + + pub async fn snapshot(&self) -> cadder_protocol::GuiStateSnapshot { + let response: QueryStateResponse = self + .client + .request( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("test-query"), + }, + ) + .await + .unwrap(); + response.snapshot.unwrap() + } + + pub fn append_domain_log(&self, domain_key: &str, severity: LogSeverity, message: &str) { + self.state.logs().append( + LogStreamIdentity::domain(domain_key), + severity, + message, + LogAttributionKind::Domain, + None, + ); + } + + pub async fn shutdown(self) { + let _ = self.shutdown_tx.send(true); + } +} + +fn registration(id: &str, nonce: &str, config_path: &Path) -> EntrypointRegistration { + let now = Utc::now(); + let identity = EntrypointInstanceIdentity { + instance_id: id.to_string(), + started_at_utc: now, + shim_session_nonce: nonce.to_string(), + }; + EntrypointRegistration { + registration_id: id.to_string(), + entrypoint_instance: identity.clone(), + source_working_directory: SourcePath::new(".", None), + source_config_path: SourcePath::new( + config_path.display().to_string(), + Some(config_path.display().to_string()), + ), + registered_domains: Vec::new(), + activation_state: ActivationState::Active, + owner_process: OwnerProcessIdentity { + process_id: 1, + process_start_time_utc: now, + shim_session_nonce: nonce.to_string(), + executable_path: None, + }, + log_stream: LogStreamIdentity::entrypoint(id), + shim_run: Some(ShimRunMetadata { + adapter: Some("caddyfile".to_string()), + raw_arguments: vec!["run".to_string()], + command_line: "run".to_string(), + }), + created_at_utc: now, + last_heartbeat_utc: now, + } +} + +fn write_fake_caddy(dir: &Path, command_log_path: &Path) -> PathBuf { + const ADAPTED_JSON: &str = r#"{"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["api.smarketing.localhost","app.smarketing.localhost","mailbox.smarketing.localhost","storage.smarketing.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}}"#; + + #[cfg(windows)] + { + let path = dir.join("fake-caddy.cmd"); + fs::write( + &path, + format!( + r#"@echo off +echo %*>> "{command_log}" +if "%1"=="adapt" ( + echo {adapted_json} + exit 0 +) +if "%1"=="reload" ( + exit 0 +) +if "%1"=="run" ( + echo fake runtime started + exit 0 +) +exit 0 +"#, + command_log = command_log_path.display(), + adapted_json = ADAPTED_JSON, + ), + ) + .unwrap(); + path + } + + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + let path = dir.join("fake-caddy"); + fs::write( + &path, + format!( + r#"#!/usr/bin/env sh +printf '%s\n' "$*" >> '{command_log}' +if [ "$1" = "adapt" ]; then + printf '%s\n' '{adapted_json}' + exit 0 +fi +if [ "$1" = "reload" ]; then + exit 0 +fi +if [ "$1" = "run" ]; then echo fake runtime started; exit 0; fi +exit 0 +"#, + command_log = command_log_path.display(), + adapted_json = ADAPTED_JSON, + ), + ) + .unwrap(); + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).unwrap(); + path + } +} diff --git a/crates/cadder-operator/Cargo.toml b/crates/cadder-operator/Cargo.toml new file mode 100644 index 0000000..40a3a88 --- /dev/null +++ b/crates/cadder-operator/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cadder-operator" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +anyhow = "=1.0.102" +cadder-daemon = { version = "0.8.0", path = "../cadder-daemon" } +cadder-protocol = { version = "0.8.0", path = "../cadder-protocol" } +chrono = { version = "=0.4.45", features = ["serde"] } +serde = { version = "=1.0.228", features = ["derive"] } diff --git a/crates/cadder-operator/src/error.rs b/crates/cadder-operator/src/error.rs new file mode 100644 index 0000000..ead0852 --- /dev/null +++ b/crates/cadder-operator/src/error.rs @@ -0,0 +1,330 @@ +use anyhow::Error; +use serde::Serialize; +use std::{ + fmt::{self, Display}, + path::Path, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum OperatorExitCode { + Success, + InvalidUsage, + DaemonUnavailable, + DaemonStartFailure, + TargetNotFound, + ConflictOrRejected, + PermissionOrElevation, + UnsupportedOperation, + IpcFailure, +} + +impl OperatorExitCode { + pub fn code(self) -> u8 { + match self { + Self::Success => 0, + Self::InvalidUsage => 2, + Self::DaemonUnavailable => 3, + Self::DaemonStartFailure => 4, + Self::TargetNotFound => 5, + Self::ConflictOrRejected => 6, + Self::PermissionOrElevation => 7, + Self::UnsupportedOperation => 8, + Self::IpcFailure => 9, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum OperatorErrorKind { + InvalidUsage, + DaemonUnavailable, + DaemonStartFailure, + TargetNotFound, + ConflictOrRejected, + PermissionOrElevation, + UnsupportedOperation, + IpcFailure, +} + +impl OperatorErrorKind { + pub fn exit_code(self) -> OperatorExitCode { + match self { + Self::InvalidUsage => OperatorExitCode::InvalidUsage, + Self::DaemonUnavailable => OperatorExitCode::DaemonUnavailable, + Self::DaemonStartFailure => OperatorExitCode::DaemonStartFailure, + Self::TargetNotFound => OperatorExitCode::TargetNotFound, + Self::ConflictOrRejected => OperatorExitCode::ConflictOrRejected, + Self::PermissionOrElevation => OperatorExitCode::PermissionOrElevation, + Self::UnsupportedOperation => OperatorExitCode::UnsupportedOperation, + Self::IpcFailure => OperatorExitCode::IpcFailure, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OperatorError { + pub kind: OperatorErrorKind, + pub message: String, + pub guidance: Option, + #[serde(skip)] + pub command: &'static str, +} + +impl OperatorError { + pub fn new( + command: &'static str, + kind: OperatorErrorKind, + message: impl Into, + guidance: Option, + ) -> Self { + Self { + kind, + message: message.into(), + guidance, + command, + } + } + + pub fn exit_code(&self) -> OperatorExitCode { + self.kind.exit_code() + } + + pub fn invalid_usage( + command: &'static str, + message: impl Into, + guidance: Option, + ) -> Self { + Self::new(command, OperatorErrorKind::InvalidUsage, message, guidance) + } + + pub fn target_not_found( + command: &'static str, + message: impl Into, + guidance: Option, + ) -> Self { + Self::new( + command, + OperatorErrorKind::TargetNotFound, + message, + guidance, + ) + } + + pub fn conflict_or_rejected( + command: &'static str, + message: impl Into, + guidance: Option, + ) -> Self { + Self::new( + command, + OperatorErrorKind::ConflictOrRejected, + message, + guidance, + ) + } + + pub fn unsupported( + command: &'static str, + message: impl Into, + guidance: Option, + ) -> Self { + Self::new( + command, + OperatorErrorKind::UnsupportedOperation, + message, + guidance, + ) + } + + pub fn daemon_request(command: &'static str, paths: &Path, action: &str, error: &Error) -> Self { + if error_indicates_permission(error) { + return Self::new( + command, + OperatorErrorKind::PermissionOrElevation, + format!("Could not {action}: {}.", format_error_chain(error)), + Some("Check file-system and local IPC permissions, then retry the command.".to_string()), + ); + } + + if daemon_error_indicates_unavailable(error) { + return Self::new( + command, + OperatorErrorKind::DaemonUnavailable, + format!( + "Cadder daemon is unavailable for runtime `{}`.", + paths.display() + ), + Some(start_guidance(paths)), + ); + } + + Self::new( + command, + OperatorErrorKind::IpcFailure, + format!("Could not {action}: {}.", format_error_chain(error)), + Some( + "The daemon responded unexpectedly or the IPC exchange failed before completion." + .to_string(), + ), + ) + } + + pub fn daemon_start(command: &'static str, paths: &Path, error: &Error) -> Self { + if error_indicates_permission(error) { + return Self::new( + command, + OperatorErrorKind::PermissionOrElevation, + format!("Could not start cadderd: {}.", format_error_chain(error)), + Some( + "Check the cadderd path, executable permissions, and runtime directory permissions before retrying." + .to_string(), + ), + ); + } + + Self::new( + command, + OperatorErrorKind::DaemonStartFailure, + format!("Could not start cadderd: {}.", format_error_chain(error)), + Some(format!( + "Retry `cadderctl daemon start --runtime-dir \"{}\"` after fixing the daemon path or startup problem.", + paths.display() + )), + ) + } +} + +impl Display for OperatorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for OperatorError {} + +pub fn format_error_chain(error: &Error) -> String { + let mut messages = error.chain().map(ToString::to_string).collect::>(); + messages.dedup(); + messages.join(": ") +} + +pub fn daemon_error_indicates_unavailable(error: &Error) -> bool { + error.chain().any(|cause| { + cause.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::UnexpectedEof + ) + }) + }) +} + +pub fn error_indicates_permission(error: &Error) -> bool { + error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|error| matches!(error.kind(), std::io::ErrorKind::PermissionDenied)) + }) || format_error_chain(error) + .to_ascii_lowercase() + .contains("permission denied") + || format_error_chain(error) + .to_ascii_lowercase() + .contains("access is denied") +} + +pub fn start_guidance(paths: &Path) -> String { + format!( + "Start `cadderd --runtime-dir \"{}\"` or run `cadderctl daemon start --runtime-dir \"{}\"`, then retry.", + paths.display(), + paths.display() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + + #[test] + fn exit_codes_remain_stable_for_all_error_kinds() { + assert_eq!(OperatorExitCode::Success.code(), 0); + assert_eq!(OperatorExitCode::InvalidUsage.code(), 2); + assert_eq!(OperatorExitCode::DaemonUnavailable.code(), 3); + assert_eq!(OperatorExitCode::DaemonStartFailure.code(), 4); + assert_eq!(OperatorExitCode::TargetNotFound.code(), 5); + assert_eq!(OperatorExitCode::ConflictOrRejected.code(), 6); + assert_eq!(OperatorExitCode::PermissionOrElevation.code(), 7); + assert_eq!(OperatorExitCode::UnsupportedOperation.code(), 8); + assert_eq!(OperatorExitCode::IpcFailure.code(), 9); + } + + #[test] + fn daemon_unavailable_maps_to_stable_exit_code() { + let error = Error::from(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "connection refused", + )); + + let mapped = OperatorError::daemon_request( + "domains list", + Path::new("runtime"), + "query domains", + &error, + ); + + assert_eq!(mapped.kind, OperatorErrorKind::DaemonUnavailable); + assert_eq!(mapped.exit_code().code(), 3); + assert!(mapped.guidance.unwrap().contains("cadderctl daemon start")); + } + + #[test] + fn permission_errors_map_to_permission_exit_code() { + let error = Error::from(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "permission denied", + )); + + let mapped = OperatorError::daemon_start("daemon start", Path::new("runtime"), &error); + + assert_eq!(mapped.kind, OperatorErrorKind::PermissionOrElevation); + assert_eq!(mapped.exit_code().code(), 7); + } + + #[test] + fn daemon_request_maps_unknown_errors_to_ipc_failure() { + let error = anyhow!("unexpected daemon envelope"); + + let mapped = OperatorError::daemon_request( + "logs show", + Path::new("runtime"), + "query retained logs", + &error, + ); + + assert_eq!(mapped.kind, OperatorErrorKind::IpcFailure); + assert!(mapped.message.contains("unexpected daemon envelope")); + assert!( + mapped + .guidance + .as_deref() + .is_some_and(|guidance| guidance.contains("IPC exchange failed")) + ); + } + + #[test] + fn daemon_start_maps_non_permission_errors_to_start_failure() { + let error = anyhow!("spawn failed"); + + let mapped = OperatorError::daemon_start("daemon start", Path::new("runtime"), &error); + + assert_eq!(mapped.kind, OperatorErrorKind::DaemonStartFailure); + assert_eq!(mapped.exit_code().code(), 4); + assert!(mapped.message.contains("spawn failed")); + } +} diff --git a/crates/cadder-operator/src/lib.rs b/crates/cadder-operator/src/lib.rs new file mode 100644 index 0000000..22055bd --- /dev/null +++ b/crates/cadder-operator/src/lib.rs @@ -0,0 +1,18 @@ +mod error; +mod service; +mod view; + +pub use error::{ + OperatorError, OperatorErrorKind, OperatorExitCode, daemon_error_indicates_unavailable, + error_indicates_permission, format_error_chain, start_guidance, +}; +pub use service::{ + DomainSelector, LogsTarget, OperatorContext, connected_status, connection_state_from_error, + unavailable_status, +}; +pub use view::{ + ActionResultView, ConfigView, ConnectionStateView, CountsView, DaemonStartView, DaemonStatusView, + DiagnosticsView, DomainListView, DomainResolveError, DomainView, EntrypointListView, + EntrypointView, LogsView, RuntimeView, SelectedDomain, counts, daemon_status_connected, + daemon_status_unavailable, diagnostics_view, domains_view, entrypoints_view, resolve_domain, +}; diff --git a/crates/cadder-operator/src/service.rs b/crates/cadder-operator/src/service.rs new file mode 100644 index 0000000..851ee5d --- /dev/null +++ b/crates/cadder-operator/src/service.rs @@ -0,0 +1,372 @@ +use crate::{ + OperatorError, daemon_status_connected, domains_view, format_error_chain, resolve_domain, + view::{ConnectionStateView, LogsView, SelectedDomain}, +}; +use anyhow::Result as AnyResult; +use cadder_daemon::{ + CadderClient, DaemonLaunchOptions, RuntimePaths, StateSubscription, + ensure_daemon_running_with_options, +}; +use cadder_protocol::{ + BasicResponse, GuiStateSnapshot, LogSeverity, LogStreamIdentity, QueryLogsRequest, + QueryStateRequest, QueryStateResponse, SetDomainEnabledRequest, SetEntrypointEnabledRequest, + message_types, new_request_id, +}; +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DomainSelector { + pub domain: String, + pub registration: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LogsTarget { + Runtime, + Entrypoint { registration_id: String }, + Domain(DomainSelector), +} + +#[derive(Debug, Clone)] +pub struct OperatorContext { + paths: RuntimePaths, + client: CadderClient, + launch_options: DaemonLaunchOptions, +} + +impl OperatorContext { + pub fn new( + command: &'static str, + runtime_dir: Option, + launch_options: DaemonLaunchOptions, + ) -> Result { + let paths = RuntimePaths::resolve(runtime_dir).map_err(|error| { + OperatorError::invalid_usage( + command, + format!("Could not resolve the Cadder runtime directory: {error}."), + None, + ) + })?; + Ok(Self::from_paths(paths, launch_options)) + } + + pub fn from_paths(paths: RuntimePaths, launch_options: DaemonLaunchOptions) -> Self { + Self { + client: CadderClient::new(paths.clone()), + paths, + launch_options, + } + } + + pub fn paths(&self) -> &RuntimePaths { + &self.paths + } + + pub fn client(&self) -> &CadderClient { + &self.client + } + + pub fn launch_options(&self) -> &DaemonLaunchOptions { + &self.launch_options + } + + pub async fn query_state_response(&self) -> AnyResult { + self + .client + .request( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("ctl-state"), + }, + ) + .await + } + + pub async fn query_snapshot( + &self, + command: &'static str, + action: &str, + ) -> Result { + let response = self.query_state_response().await.map_err(|error| { + OperatorError::daemon_request(command, self.paths.runtime_dir(), action, &error) + })?; + response.snapshot.ok_or_else(|| { + OperatorError::new( + command, + crate::OperatorErrorKind::IpcFailure, + "Cadder daemon returned no state snapshot.".to_string(), + Some( + "Retry the command after the daemon finishes its current state transition.".to_string(), + ), + ) + }) + } + + pub async fn request_basic( + &self, + command: &'static str, + action: &str, + message_type: &str, + response_type: &str, + request: &impl Serialize, + ) -> Result { + self + .client + .request(message_type, response_type, request) + .await + .map_err(|error| { + OperatorError::daemon_request(command, self.paths.runtime_dir(), action, &error) + }) + } + + pub async fn query_logs( + &self, + command: &'static str, + action: &str, + stream: LogStreamIdentity, + limit: usize, + cursor: Option, + minimum_severity: Option, + ) -> Result { + let response = self + .client + .request::<_, cadder_protocol::QueryLogsResponse>( + message_types::QUERY_LOGS_REQUEST, + message_types::QUERY_LOGS_RESPONSE, + &QueryLogsRequest { + request_id: new_request_id("ctl-logs"), + stream, + limit: Some(limit), + cursor, + minimum_severity, + }, + ) + .await + .map_err(|error| { + OperatorError::daemon_request(command, self.paths.runtime_dir(), action, &error) + })?; + Ok(LogsView::from(response)) + } + + pub async fn subscribe_state( + &self, + command: &'static str, + action: &str, + ) -> Result { + self + .client + .subscribe_state(new_request_id("ctl-watch")) + .await + .map_err(|error| { + OperatorError::daemon_request(command, self.paths.runtime_dir(), action, &error) + }) + } + + pub async fn ensure_daemon_running(&self, command: &'static str) -> Result<(), OperatorError> { + ensure_daemon_running_with_options(&self.paths, self.launch_options.clone()) + .await + .map_err(|error| OperatorError::daemon_start(command, self.paths.runtime_dir(), &error)) + } + + pub async fn resolve_logs_target( + &self, + command: &'static str, + target: LogsTarget, + ) -> Result { + match target { + LogsTarget::Runtime => Ok(LogStreamIdentity::runtime_control()), + LogsTarget::Entrypoint { registration_id } => { + let snapshot = self.query_snapshot(command, "query entrypoints").await?; + let entrypoint = snapshot + .registrations + .iter() + .find(|entrypoint| entrypoint.registration_id == registration_id) + .ok_or_else(|| { + OperatorError::target_not_found( + command, + format!("Entrypoint `{registration_id}` was not found."), + Some( + "Run `cadderctl entrypoints list` to inspect valid registration IDs.".to_string(), + ), + ) + })?; + Ok(entrypoint.log_stream.clone()) + } + LogsTarget::Domain(selector) => { + let snapshot = self.query_snapshot(command, "query domains").await?; + let selected = self.select_domain(command, &snapshot, &selector)?; + let domain = domains_view(&snapshot, Some(&selected.registration_id)) + .domains + .into_iter() + .find(|domain| domain.canonical_domain == selected.canonical_domain) + .ok_or_else(|| { + OperatorError::target_not_found( + command, + "The selected domain no longer exists in the current daemon snapshot.", + Some("Retry the command after refreshing the daemon state.".to_string()), + ) + })?; + Ok(domain.log_stream) + } + } + } + + pub async fn set_entrypoint_enabled( + &self, + command: &'static str, + registration_id: String, + enabled: bool, + ) -> Result { + let response = self + .request_basic( + command, + "toggle entrypoint activation", + message_types::SET_ENTRYPOINT_ENABLED_REQUEST, + message_types::SET_ENTRYPOINT_ENABLED_RESPONSE, + &SetEntrypointEnabledRequest { + request_id: new_request_id("ctl-entrypoint-toggle"), + registration_id: registration_id.clone(), + shim_session_nonce: None, + enabled, + }, + ) + .await?; + + if !response.accepted { + return Err(OperatorError::target_not_found( + command, + format!("Entrypoint `{registration_id}` was not found."), + Some("Run `cadderctl entrypoints list` to inspect valid registration IDs.".to_string()), + )); + } + + Ok(response) + } + + pub async fn set_domain_enabled( + &self, + command: &'static str, + selector: &DomainSelector, + enabled: bool, + ) -> Result { + let snapshot = self.query_snapshot(command, "query domains").await?; + let selected = self.select_domain(command, &snapshot, selector)?; + let response = self + .request_basic( + command, + "toggle domain activation", + message_types::SET_DOMAIN_ENABLED_REQUEST, + message_types::SET_DOMAIN_ENABLED_RESPONSE, + &SetDomainEnabledRequest { + request_id: new_request_id("ctl-domain-toggle"), + registration_id: selected.registration_id, + domain_key: selected.canonical_domain, + enabled, + }, + ) + .await?; + if !response.accepted { + return Err(OperatorError::conflict_or_rejected( + command, + response.message, + Some( + "Refresh the domain list and retry the command with an explicit `--registration` filter if needed.".to_string(), + ), + )); + } + Ok(response) + } + + pub fn select_domain( + &self, + command: &'static str, + snapshot: &GuiStateSnapshot, + selector: &DomainSelector, + ) -> Result { + resolve_domain(snapshot, &selector.domain, selector.registration.as_deref()).map_err(|error| { + match error { + crate::DomainResolveError::NotFound { + canonical_domain, + requested_registration, + } => OperatorError::target_not_found( + command, + match requested_registration { + Some(registration_id) => format!( + "Domain `{canonical_domain}` was not found under entrypoint `{registration_id}`." + ), + None => format!("Domain `{canonical_domain}` was not found."), + }, + Some( + "Run `cadderctl domains list` to inspect the current domain registrations.".to_string(), + ), + ), + crate::DomainResolveError::Ambiguous { + canonical_domain, + matches, + } => OperatorError::conflict_or_rejected( + command, + format!( + "Domain `{canonical_domain}` matches multiple entrypoints: {}.", + matches.join(", ") + ), + Some( + "Retry the command with `--registration ` to choose the exact entrypoint." + .to_string(), + ), + ), + } + }) + } +} + +pub fn connection_state_from_error(error: &anyhow::Error) -> ConnectionStateView { + if error.chain().any(|cause| { + cause.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionAborted + ) + }) + }) { + ConnectionStateView::NotRunning + } else { + ConnectionStateView::ConnectionFailed + } +} + +pub fn unavailable_status( + context: &OperatorContext, + error: &anyhow::Error, +) -> crate::DaemonStatusView { + let runtime_dir = context.paths.runtime_dir(); + let message = match connection_state_from_error(error) { + ConnectionStateView::NotRunning => format!( + "Cadder daemon is not running for runtime `{}`.", + runtime_dir.display() + ), + ConnectionStateView::ConnectionFailed => format!( + "Could not attach to Cadder daemon for runtime `{}`: {}.", + runtime_dir.display(), + format_error_chain(error) + ), + ConnectionStateView::Connected => "Attached to cadderd.".to_string(), + }; + + crate::daemon_status_unavailable( + runtime_dir, + connection_state_from_error(error), + message, + Some(crate::start_guidance(runtime_dir)), + ) +} + +pub fn connected_status( + context: &OperatorContext, + snapshot: &GuiStateSnapshot, +) -> crate::DaemonStatusView { + daemon_status_connected(context.paths.runtime_dir(), snapshot) +} diff --git a/crates/cadder-operator/src/view.rs b/crates/cadder-operator/src/view.rs new file mode 100644 index 0000000..29e0e99 --- /dev/null +++ b/crates/cadder-operator/src/view.rs @@ -0,0 +1,442 @@ +use cadder_protocol::{ + ActivationState, ConfigDiagnostic, ConfigState, EntrypointRegistration, GuiStateSnapshot, + LogEntry, LogStreamIdentity, LogStreamStatus, QueryLogsResponse, RuntimeDiagnostic, RuntimeState, + canonicalize_domain, +}; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ConnectionStateView { + Connected, + NotRunning, + ConnectionFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DaemonStatusView { + pub runtime_dir: String, + pub connection_state: ConnectionStateView, + pub message: String, + pub guidance: Option, + pub captured_at_utc: Option>, + pub runtime: Option, + pub config: Option, + pub counts: CountsView, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DaemonStartView { + pub started: bool, + pub message: String, + pub status: DaemonStatusView, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionResultView { + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CountsView { + pub entrypoints: usize, + pub domains: usize, + pub active_domains: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeView { + pub status: String, + pub binary_path: Option, + pub version: Option, + pub process_id: Option, + pub admin_endpoint: Option, + pub diagnostics: Vec, +} + +impl From<&RuntimeState> for RuntimeView { + fn from(value: &RuntimeState) -> Self { + Self { + status: format!("{:?}", value.status), + binary_path: value.binary_path.clone(), + version: value.version.clone(), + process_id: value.process_id, + admin_endpoint: value.admin_endpoint.clone(), + diagnostics: value.diagnostics.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigView { + pub status: String, + pub last_attempted_at_utc: Option>, + pub last_successful_reload_at_utc: Option>, + pub effective_config_hash: Option, + pub diagnostics: Vec, +} + +impl From<&ConfigState> for ConfigView { + fn from(value: &ConfigState) -> Self { + Self { + status: format!("{:?}", value.status), + last_attempted_at_utc: value.last_attempted_at_utc, + last_successful_reload_at_utc: value.last_successful_reload_at_utc, + effective_config_hash: value.effective_config_hash.clone(), + diagnostics: value.diagnostics.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EntrypointView { + pub registration_id: String, + pub activation_state: ActivationState, + pub working_directory: String, + pub config_path: String, + pub started_at_utc: DateTime, + pub last_heartbeat_utc: DateTime, + pub process_id: u32, + pub executable_path: Option, + pub domain_count: usize, + pub active_domain_count: usize, + pub adapter: Option, + pub command_line: Option, +} + +impl From<&EntrypointRegistration> for EntrypointView { + fn from(value: &EntrypointRegistration) -> Self { + Self { + registration_id: value.registration_id.clone(), + activation_state: value.activation_state, + working_directory: value.source_working_directory.raw.clone(), + config_path: value.source_config_path.raw.clone(), + started_at_utc: value.entrypoint_instance.started_at_utc, + last_heartbeat_utc: value.last_heartbeat_utc, + process_id: value.owner_process.process_id, + executable_path: value.owner_process.executable_path.clone(), + domain_count: value.registered_domains.len(), + active_domain_count: value + .registered_domains + .iter() + .filter(|domain| domain.activation_state.is_enabled()) + .count(), + adapter: value.shim_run.as_ref().and_then(|run| run.adapter.clone()), + command_line: value.shim_run.as_ref().map(|run| run.command_line.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EntrypointListView { + pub captured_at_utc: DateTime, + pub entrypoints: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DomainView { + pub registration_id: String, + pub domain: String, + pub canonical_domain: String, + pub activation_state: ActivationState, + pub entrypoint_activation_state: ActivationState, + pub working_directory: String, + pub config_path: String, + pub log_stream: LogStreamIdentity, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DomainListView { + pub captured_at_utc: DateTime, + pub domains: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiagnosticsView { + pub captured_at_utc: DateTime, + pub runtime: RuntimeView, + pub config: ConfigView, + pub runtime_diagnostics: Vec, + pub config_diagnostics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LogsView { + pub stream: LogStreamIdentity, + pub stream_status: LogStreamStatus, + pub entries: Vec, + pub next_cursor: Option, + pub has_gap: bool, + pub has_more_before: bool, + pub truncated_by_retention: bool, +} + +impl From for LogsView { + fn from(value: QueryLogsResponse) -> Self { + Self { + stream: value.stream, + stream_status: value.stream_status, + entries: value.entries, + next_cursor: value.next_cursor, + has_gap: value.has_gap, + has_more_before: value.has_more_before, + truncated_by_retention: value.truncated_by_retention, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectedDomain { + pub registration_id: String, + pub canonical_domain: String, +} + +pub fn daemon_status_connected( + runtime_dir: &Path, + snapshot: &GuiStateSnapshot, +) -> DaemonStatusView { + DaemonStatusView { + runtime_dir: runtime_dir.display().to_string(), + connection_state: ConnectionStateView::Connected, + message: "Attached to cadderd.".to_string(), + guidance: None, + captured_at_utc: Some(snapshot.captured_at_utc), + runtime: Some(RuntimeView::from(&snapshot.runtime)), + config: Some(ConfigView::from(&snapshot.config)), + counts: counts(snapshot), + } +} + +pub fn daemon_status_unavailable( + runtime_dir: &Path, + state: ConnectionStateView, + message: String, + guidance: Option, +) -> DaemonStatusView { + DaemonStatusView { + runtime_dir: runtime_dir.display().to_string(), + connection_state: state, + message, + guidance, + captured_at_utc: None, + runtime: None, + config: None, + counts: CountsView::default(), + } +} + +pub fn entrypoints_view(snapshot: &GuiStateSnapshot) -> EntrypointListView { + EntrypointListView { + captured_at_utc: snapshot.captured_at_utc, + entrypoints: snapshot + .registrations + .iter() + .map(EntrypointView::from) + .collect(), + } +} + +pub fn domains_view( + snapshot: &GuiStateSnapshot, + registration_filter: Option<&str>, +) -> DomainListView { + let domains = snapshot + .registrations + .iter() + .filter(|registration| { + registration_filter.is_none_or(|filter| registration.registration_id == filter) + }) + .flat_map(|registration| { + registration + .registered_domains + .iter() + .map(move |domain| DomainView { + registration_id: registration.registration_id.clone(), + domain: domain.name.raw.clone(), + canonical_domain: domain.name.canonical.clone(), + activation_state: domain.activation_state, + entrypoint_activation_state: registration.activation_state, + working_directory: registration.source_working_directory.raw.clone(), + config_path: registration.source_config_path.raw.clone(), + log_stream: domain.log_stream.clone(), + }) + }) + .collect(); + + DomainListView { + captured_at_utc: snapshot.captured_at_utc, + domains, + } +} + +pub fn diagnostics_view(snapshot: &GuiStateSnapshot) -> DiagnosticsView { + DiagnosticsView { + captured_at_utc: snapshot.captured_at_utc, + runtime: RuntimeView::from(&snapshot.runtime), + config: ConfigView::from(&snapshot.config), + runtime_diagnostics: snapshot.runtime.diagnostics.clone(), + config_diagnostics: snapshot.config.diagnostics.clone(), + } +} + +pub fn counts(snapshot: &GuiStateSnapshot) -> CountsView { + CountsView { + entrypoints: snapshot.registrations.len(), + domains: snapshot + .registrations + .iter() + .map(|registration| registration.registered_domains.len()) + .sum(), + active_domains: snapshot + .registrations + .iter() + .flat_map(|registration| registration.registered_domains.iter()) + .filter(|domain| domain.activation_state.is_enabled()) + .count(), + } +} + +pub fn resolve_domain( + snapshot: &GuiStateSnapshot, + domain: &str, + registration: Option<&str>, +) -> Result { + let canonical = canonicalize_domain(domain); + let mut matches = Vec::new(); + for entrypoint in &snapshot.registrations { + if registration.is_some_and(|registration_id| entrypoint.registration_id != registration_id) { + continue; + } + + if entrypoint + .registered_domains + .iter() + .any(|candidate| candidate.name.canonical == canonical) + { + matches.push(SelectedDomain { + registration_id: entrypoint.registration_id.clone(), + canonical_domain: canonical.clone(), + }); + } + } + + match matches.as_slice() { + [] => Err(DomainResolveError::NotFound { + canonical_domain: canonical, + requested_registration: registration.map(ToString::to_string), + }), + [selected] => Ok(selected.clone()), + _ => Err(DomainResolveError::Ambiguous { + canonical_domain: canonical, + matches: matches + .into_iter() + .map(|entry| entry.registration_id) + .collect(), + }), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DomainResolveError { + NotFound { + canonical_domain: String, + requested_registration: Option, + }, + Ambiguous { + canonical_domain: String, + matches: Vec, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use cadder_protocol::{ + DomainName, EntrypointInstanceIdentity, OwnerProcessIdentity, RegisteredDomain, SourcePath, + }; + + fn registration(id: &str, domains: &[&str]) -> EntrypointRegistration { + let now = Utc::now(); + let identity = EntrypointInstanceIdentity { + instance_id: id.to_string(), + started_at_utc: now, + shim_session_nonce: format!("{id}-nonce"), + }; + EntrypointRegistration { + registration_id: id.to_string(), + entrypoint_instance: identity.clone(), + source_working_directory: SourcePath::new("/work", None), + source_config_path: SourcePath::new("/work/Caddyfile", None), + registered_domains: domains + .iter() + .map(|domain| RegisteredDomain { + name: DomainName::parse(*domain), + activation_state: ActivationState::Active, + log_stream: LogStreamIdentity::domain(domain), + }) + .collect(), + activation_state: ActivationState::Active, + owner_process: OwnerProcessIdentity { + process_id: 1, + process_start_time_utc: now, + shim_session_nonce: identity.shim_session_nonce.clone(), + executable_path: None, + }, + log_stream: LogStreamIdentity::entrypoint(id), + shim_run: None, + created_at_utc: now, + last_heartbeat_utc: now, + } + } + + fn snapshot(registrations: Vec) -> GuiStateSnapshot { + GuiStateSnapshot { + captured_at_utc: Utc::now(), + registrations, + runtime: RuntimeState::idle(), + config: ConfigState::idle(), + } + } + + #[test] + fn resolve_domain_finds_unique_match() { + let snapshot = snapshot(vec![registration("shim-1", &["app.localhost"])]); + + let selected = resolve_domain(&snapshot, "APP.localhost", None).unwrap(); + + assert_eq!(selected.registration_id, "shim-1"); + assert_eq!(selected.canonical_domain, "app.localhost"); + } + + #[test] + fn resolve_domain_requires_registration_when_ambiguous() { + let snapshot = snapshot(vec![ + registration("shim-1", &["app.localhost"]), + registration("shim-2", &["app.localhost"]), + ]); + + let error = resolve_domain(&snapshot, "app.localhost", None).unwrap_err(); + + assert_eq!( + error, + DomainResolveError::Ambiguous { + canonical_domain: "app.localhost".to_string(), + matches: vec!["shim-1".to_string(), "shim-2".to_string()], + } + ); + } +} diff --git a/crates/cadderctl/Cargo.toml b/crates/cadderctl/Cargo.toml index 5a00a5b..ae7f959 100644 --- a/crates/cadderctl/Cargo.toml +++ b/crates/cadderctl/Cargo.toml @@ -9,6 +9,7 @@ description = "Cadder non-TUI CLI for daemon state, domains, logs, and automatio [dependencies] anyhow = "1.0.102" cadder-daemon = { version = "0.8.0", path = "../cadder-daemon" } +cadder-operator = { version = "0.8.0", path = "../cadder-operator" } cadder-protocol = { version = "0.8.0", path = "../cadder-protocol" } chrono = { version = "0.4.45", features = ["serde"] } clap = { version = "4.6.1", features = ["derive"] } diff --git a/crates/cadderctl/src/app.rs b/crates/cadderctl/src/app.rs index 25727fc..0fbc51e 100644 --- a/crates/cadderctl/src/app.rs +++ b/crates/cadderctl/src/app.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +use crate::view::{SelectedDomain, resolve_domain}; use crate::{ cli::{ CliArgs, Command, DaemonCommand, DiagnosticsCommand, DomainsCommand, LogsCommand, @@ -8,19 +10,15 @@ use crate::{ }, render, view::{ - ActionResultView, ConnectionStateView, DaemonStartView, LogsView, SelectedDomain, - daemon_status_connected, daemon_status_unavailable, diagnostics_view, domains_view, - entrypoints_view, map_severity, resolve_domain, + ActionResultView, ConnectionStateView, DaemonStartView, LogsView, daemon_status_connected, + daemon_status_unavailable, diagnostics_view, domains_view, entrypoints_view, map_severity, }, }; use anyhow::Result as AnyResult; -use cadder_daemon::{ - CadderClient, DaemonLaunchOptions, RuntimePaths, StateSubscription, - ensure_daemon_running_with_options, -}; +use cadder_daemon::{DaemonLaunchOptions, RuntimePaths, StateSubscription}; +use cadder_operator::{DomainSelector, OperatorContext}; use cadder_protocol::{ - BasicResponse, GuiStateSnapshot, LogStreamIdentity, QueryLogsRequest, QueryStateRequest, - QueryStateResponse, SetDomainEnabledRequest, SetEntrypointEnabledRequest, ShutdownDaemonRequest, + BasicResponse, GuiStateSnapshot, LogStreamIdentity, QueryStateResponse, ShutdownDaemonRequest, StateChangedEvent, message_types, new_request_id, }; use chrono::Utc; @@ -63,44 +61,32 @@ impl From for ExecuteError { struct AppContext { output: OutputMode, - paths: RuntimePaths, - client: CadderClient, - launch_options: DaemonLaunchOptions, + operator: OperatorContext, } impl AppContext { fn new(args: &CliArgs) -> Result { let command = args.command_label(); - let paths = RuntimePaths::resolve(args.runtime_dir.clone()).map_err(|error| { - CliError::invalid_usage( - command, - format!("Could not resolve the Cadder runtime directory: {error}."), - None, - ) - })?; Ok(Self { output: args.output, - client: CadderClient::new(paths.clone()), - paths, - launch_options: DaemonLaunchOptions { - explicit_daemon: args.daemon_path.clone(), - real_caddy_command: args.real_caddy_command.clone(), - shim_path: None, - }, + operator: OperatorContext::new( + command, + args.runtime_dir.clone(), + DaemonLaunchOptions { + explicit_daemon: args.daemon_path.clone(), + real_caddy_command: args.real_caddy_command.clone(), + shim_path: None, + }, + )?, }) } + fn paths(&self) -> &RuntimePaths { + self.operator.paths() + } + async fn query_state_response(&self) -> AnyResult { - self - .client - .request( - message_types::QUERY_STATE_REQUEST, - message_types::QUERY_STATE_RESPONSE, - &QueryStateRequest { - request_id: new_request_id("ctl-state"), - }, - ) - .await + self.operator.query_state_response().await } async fn query_snapshot( @@ -108,19 +94,7 @@ impl AppContext { command: &'static str, action: &str, ) -> Result { - let response = self.query_state_response().await.map_err(|error| { - CliError::daemon_request(command, self.paths.runtime_dir(), action, &error) - })?; - response.snapshot.ok_or_else(|| { - CliError::new( - command, - CliErrorKind::IpcFailure, - "Cadder daemon returned no state snapshot.".to_string(), - Some( - "Retry the command after the daemon finishes its current state transition.".to_string(), - ), - ) - }) + self.operator.query_snapshot(command, action).await } async fn request_basic( @@ -132,10 +106,9 @@ impl AppContext { request: &impl Serialize, ) -> Result { self - .client - .request(message_type, response_type, request) + .operator + .request_basic(command, action, message_type, response_type, request) .await - .map_err(|error| CliError::daemon_request(command, self.paths.runtime_dir(), action, &error)) } async fn query_logs( @@ -147,24 +120,10 @@ impl AppContext { cursor: Option, minimum_severity: Option, ) -> Result { - let response = self - .client - .request::<_, cadder_protocol::QueryLogsResponse>( - message_types::QUERY_LOGS_REQUEST, - message_types::QUERY_LOGS_RESPONSE, - &QueryLogsRequest { - request_id: new_request_id("ctl-logs"), - stream, - limit: Some(limit), - cursor, - minimum_severity, - }, - ) + self + .operator + .query_logs(command, action, stream, limit, cursor, minimum_severity) .await - .map_err(|error| { - CliError::daemon_request(command, self.paths.runtime_dir(), action, &error) - })?; - Ok(LogsView::from(response)) } async fn subscribe_state( @@ -172,11 +131,43 @@ impl AppContext { command: &'static str, action: &str, ) -> Result { + self.operator.subscribe_state(command, action).await + } + + async fn ensure_daemon_running(&self, command: &'static str) -> Result<(), CliError> { + self.operator.ensure_daemon_running(command).await + } + + async fn set_entrypoint_enabled( + &self, + command: &'static str, + registration_id: String, + enabled: bool, + ) -> Result { + self + .operator + .set_entrypoint_enabled(command, registration_id, enabled) + .await + } + + async fn set_domain_enabled( + &self, + command: &'static str, + selector: &DomainSelector, + enabled: bool, + ) -> Result { self - .client - .subscribe_state(new_request_id("ctl-watch")) + .operator + .set_domain_enabled(command, selector, enabled) .await - .map_err(|error| CliError::daemon_request(command, self.paths.runtime_dir(), action, &error)) + } + + async fn resolve_logs_target( + &self, + command: &'static str, + target: cadder_operator::LogsTarget, + ) -> Result { + self.operator.resolve_logs_target(command, target).await } } @@ -275,19 +266,19 @@ async fn handle_daemon( ) .into()); }; - daemon_status_connected(context.paths.runtime_dir(), &snapshot) + daemon_status_connected(context.paths().runtime_dir(), &snapshot) } Err(error) if daemon_error_indicates_unavailable(&error) => daemon_status_unavailable( - context.paths.runtime_dir(), + context.paths().runtime_dir(), connection_state_from_error(&error), - unavailable_message(context.paths.runtime_dir(), &error), - Some(start_guidance(context.paths.runtime_dir())), + unavailable_message(context.paths().runtime_dir(), &error), + Some(start_guidance(context.paths().runtime_dir())), ), Err(error) => { return Err( CliError::daemon_request( command.label(), - context.paths.runtime_dir(), + context.paths().runtime_dir(), "query daemon status", &error, ) @@ -311,7 +302,7 @@ async fn handle_daemon( return Err( CliError::daemon_request( command.label(), - context.paths.runtime_dir(), + context.paths().runtime_dir(), "check daemon status before start", &error, ) @@ -320,11 +311,7 @@ async fn handle_daemon( } }; - ensure_daemon_running_with_options(&context.paths, context.launch_options.clone()) - .await - .map_err(|error| { - CliError::daemon_start(command.label(), context.paths.runtime_dir(), &error) - })?; + context.ensure_daemon_running(command.label()).await?; let snapshot = context .query_snapshot(command.label(), "query daemon status after start") .await?; @@ -335,7 +322,7 @@ async fn handle_daemon( } else { "cadderd started.".to_string() }, - status: daemon_status_connected(context.paths.runtime_dir(), &snapshot), + status: daemon_status_connected(context.paths().runtime_dir(), &snapshot), }; write_one_shot( context.output, @@ -426,29 +413,8 @@ async fn toggle_entrypoint( stdout: &mut dyn Write, ) -> Result<(), ExecuteError> { let response = context - .request_basic( - command, - "toggle entrypoint activation", - message_types::SET_ENTRYPOINT_ENABLED_REQUEST, - message_types::SET_ENTRYPOINT_ENABLED_RESPONSE, - &SetEntrypointEnabledRequest { - request_id: new_request_id("ctl-entrypoint-toggle"), - registration_id: registration_id.clone(), - shim_session_nonce: None, - enabled, - }, - ) + .set_entrypoint_enabled(command, registration_id, enabled) .await?; - if !response.accepted { - return Err( - CliError::target_not_found( - command, - format!("Entrypoint `{registration_id}` was not found."), - Some("Run `cadderctl entrypoints list` to inspect valid registration IDs.".to_string()), - ) - .into(), - ); - } write_one_shot( context.output, @@ -498,32 +464,16 @@ async fn toggle_domain( enabled: bool, stdout: &mut dyn Write, ) -> Result<(), ExecuteError> { - let snapshot = context.query_snapshot(command, "query domains").await?; - let selected = select_domain(command, &snapshot, &selector)?; let response = context - .request_basic( + .set_domain_enabled( command, - "toggle domain activation", - message_types::SET_DOMAIN_ENABLED_REQUEST, - message_types::SET_DOMAIN_ENABLED_RESPONSE, - &SetDomainEnabledRequest { - request_id: new_request_id("ctl-domain-toggle"), - registration_id: selected.registration_id, - domain_key: selected.canonical_domain, - enabled, + &DomainSelector { + domain: selector.domain, + registration: selector.registration, }, + enabled, ) .await?; - if !response.accepted { - return Err( - CliError::conflict_or_rejected( - command, - response.message, - Some("Refresh the domain list and retry the command with an explicit `--registration` filter if needed.".to_string()), - ) - .into(), - ); - } write_one_shot( context.output, command, @@ -644,7 +594,7 @@ async fn handle_watch( let event = tokio::select! { _ = tokio::signal::ctrl_c() => return Ok(()), result = subscription.next_event() => { - result.map_err(|error| CliError::daemon_request(command.label(), context.paths.runtime_dir(), "stream daemon state", &error))? + result.map_err(|error| CliError::daemon_request(command.label(), context.paths().runtime_dir(), "stream daemon state", &error))? } }; @@ -656,57 +606,7 @@ async fn handle_watch( } } -async fn resolve_logs_target( - context: &AppContext, - command: &'static str, - target: LogsTargetCommand, -) -> Result { - match target { - LogsTargetCommand::Runtime => Ok(LogStreamIdentity::runtime_control()), - LogsTargetCommand::Entrypoint { registration_id } => { - let snapshot = context.query_snapshot(command, "query entrypoints").await?; - let entrypoint = snapshot - .registrations - .iter() - .find(|entrypoint| entrypoint.registration_id == registration_id) - .ok_or_else(|| { - CliError::target_not_found( - command, - format!("Entrypoint `{registration_id}` was not found."), - Some("Run `cadderctl entrypoints list` to inspect valid registration IDs.".to_string()), - ) - })?; - Ok(entrypoint.log_stream.clone()) - } - LogsTargetCommand::Domain { - domain, - registration, - } => { - let snapshot = context.query_snapshot(command, "query domains").await?; - let selected = select_domain( - command, - &snapshot, - &crate::cli::DomainSelectorArgs { - domain, - registration, - }, - )?; - let domain = domains_view(&snapshot, Some(&selected.registration_id)) - .domains - .into_iter() - .find(|domain| domain.canonical_domain == selected.canonical_domain) - .ok_or_else(|| { - CliError::target_not_found( - command, - "The selected domain no longer exists in the current daemon snapshot.", - Some("Retry the command after refreshing the daemon state.".to_string()), - ) - })?; - Ok(domain.log_stream) - } - } -} - +#[cfg(test)] fn select_domain( command: &'static str, snapshot: &GuiStateSnapshot, @@ -747,6 +647,42 @@ fn select_domain( }) } +async fn resolve_logs_target( + context: &AppContext, + command: &'static str, + target: LogsTargetCommand, +) -> Result { + match target { + LogsTargetCommand::Runtime => { + context + .resolve_logs_target(command, cadder_operator::LogsTarget::Runtime) + .await + } + LogsTargetCommand::Entrypoint { registration_id } => { + context + .resolve_logs_target( + command, + cadder_operator::LogsTarget::Entrypoint { registration_id }, + ) + .await + } + LogsTargetCommand::Domain { + domain, + registration, + } => { + context + .resolve_logs_target( + command, + cadder_operator::LogsTarget::Domain(DomainSelector { + domain, + registration, + }), + ) + .await + } + } +} + fn write_one_shot( output: OutputMode, command: &'static str, @@ -793,7 +729,7 @@ fn write_watch_event( ) -> io::Result<()> { match command { WatchCommand::Status => { - let status = daemon_status_connected(context.paths.runtime_dir(), &event.snapshot); + let status = daemon_status_connected(context.paths().runtime_dir(), &event.snapshot); match context.output { OutputMode::Human => { writeln!( @@ -1033,9 +969,7 @@ mod tests { let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); let context = AppContext { output, - client: CadderClient::new(paths.clone()), - paths, - launch_options: DaemonLaunchOptions::default(), + operator: OperatorContext::from_paths(paths, DaemonLaunchOptions::default()), }; (temp, context) } diff --git a/crates/cadderctl/src/error.rs b/crates/cadderctl/src/error.rs index 23a80fd..ed95e89 100644 --- a/crates/cadderctl/src/error.rs +++ b/crates/cadderctl/src/error.rs @@ -1,364 +1,5 @@ -use anyhow::Error; -use serde::Serialize; -use std::{ - fmt::{self, Display}, - path::Path, +pub use cadder_operator::{ + OperatorError as CliError, OperatorErrorKind as CliErrorKind, OperatorExitCode as CliExitCode, + daemon_error_indicates_unavailable, error_indicates_permission, format_error_chain, + start_guidance, }; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum CliExitCode { - Success, - InvalidUsage, - DaemonUnavailable, - DaemonStartFailure, - TargetNotFound, - ConflictOrRejected, - PermissionOrElevation, - UnsupportedOperation, - IpcFailure, -} - -impl CliExitCode { - pub fn code(self) -> u8 { - match self { - Self::Success => 0, - Self::InvalidUsage => 2, - Self::DaemonUnavailable => 3, - Self::DaemonStartFailure => 4, - Self::TargetNotFound => 5, - Self::ConflictOrRejected => 6, - Self::PermissionOrElevation => 7, - Self::UnsupportedOperation => 8, - Self::IpcFailure => 9, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum CliErrorKind { - InvalidUsage, - DaemonUnavailable, - DaemonStartFailure, - TargetNotFound, - ConflictOrRejected, - PermissionOrElevation, - UnsupportedOperation, - IpcFailure, -} - -impl CliErrorKind { - pub fn exit_code(self) -> CliExitCode { - match self { - Self::InvalidUsage => CliExitCode::InvalidUsage, - Self::DaemonUnavailable => CliExitCode::DaemonUnavailable, - Self::DaemonStartFailure => CliExitCode::DaemonStartFailure, - Self::TargetNotFound => CliExitCode::TargetNotFound, - Self::ConflictOrRejected => CliExitCode::ConflictOrRejected, - Self::PermissionOrElevation => CliExitCode::PermissionOrElevation, - Self::UnsupportedOperation => CliExitCode::UnsupportedOperation, - Self::IpcFailure => CliExitCode::IpcFailure, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct CliError { - pub kind: CliErrorKind, - pub message: String, - pub guidance: Option, - #[serde(skip)] - pub command: &'static str, -} - -impl CliError { - pub fn new( - command: &'static str, - kind: CliErrorKind, - message: impl Into, - guidance: Option, - ) -> Self { - Self { - kind, - message: message.into(), - guidance, - command, - } - } - - pub fn exit_code(&self) -> CliExitCode { - self.kind.exit_code() - } - - pub fn invalid_usage( - command: &'static str, - message: impl Into, - guidance: Option, - ) -> Self { - Self::new(command, CliErrorKind::InvalidUsage, message, guidance) - } - - pub fn target_not_found( - command: &'static str, - message: impl Into, - guidance: Option, - ) -> Self { - Self::new(command, CliErrorKind::TargetNotFound, message, guidance) - } - - pub fn conflict_or_rejected( - command: &'static str, - message: impl Into, - guidance: Option, - ) -> Self { - Self::new(command, CliErrorKind::ConflictOrRejected, message, guidance) - } - - pub fn unsupported( - command: &'static str, - message: impl Into, - guidance: Option, - ) -> Self { - Self::new( - command, - CliErrorKind::UnsupportedOperation, - message, - guidance, - ) - } - - pub fn daemon_request(command: &'static str, paths: &Path, action: &str, error: &Error) -> Self { - if error_indicates_permission(error) { - return Self::new( - command, - CliErrorKind::PermissionOrElevation, - format!("Could not {action}: {}.", format_error_chain(error)), - Some("Check file-system and local IPC permissions, then retry the command.".to_string()), - ); - } - - if daemon_error_indicates_unavailable(error) { - return Self::new( - command, - CliErrorKind::DaemonUnavailable, - format!( - "Cadder daemon is unavailable for runtime `{}`.", - paths.display() - ), - Some(start_guidance(paths)), - ); - } - - Self::new( - command, - CliErrorKind::IpcFailure, - format!("Could not {action}: {}.", format_error_chain(error)), - Some( - "The daemon responded unexpectedly or the IPC exchange failed before completion." - .to_string(), - ), - ) - } - - pub fn daemon_start(command: &'static str, paths: &Path, error: &Error) -> Self { - if error_indicates_permission(error) { - return Self::new( - command, - CliErrorKind::PermissionOrElevation, - format!("Could not start cadderd: {}.", format_error_chain(error)), - Some( - "Check the cadderd path, executable permissions, and runtime directory permissions before retrying." - .to_string(), - ), - ); - } - - Self::new( - command, - CliErrorKind::DaemonStartFailure, - format!("Could not start cadderd: {}.", format_error_chain(error)), - Some(format!( - "Retry `cadderctl daemon start --runtime-dir \"{}\"` after fixing the daemon path or startup problem.", - paths.display() - )), - ) - } -} - -impl Display for CliError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for CliError {} - -pub fn format_error_chain(error: &Error) -> String { - let mut messages = error.chain().map(ToString::to_string).collect::>(); - messages.dedup(); - messages.join(": ") -} - -pub fn daemon_error_indicates_unavailable(error: &Error) -> bool { - error.chain().any(|cause| { - cause.downcast_ref::().is_some_and(|error| { - matches!( - error.kind(), - std::io::ErrorKind::NotFound - | std::io::ErrorKind::ConnectionRefused - | std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::UnexpectedEof - ) - }) - }) -} - -pub fn error_indicates_permission(error: &Error) -> bool { - error.chain().any(|cause| { - cause - .downcast_ref::() - .is_some_and(|error| matches!(error.kind(), std::io::ErrorKind::PermissionDenied)) - }) || format_error_chain(error) - .to_ascii_lowercase() - .contains("permission denied") - || format_error_chain(error) - .to_ascii_lowercase() - .contains("access is denied") -} - -pub fn start_guidance(paths: &Path) -> String { - format!( - "Start `cadderd --runtime-dir \"{}\"` or run `cadderctl daemon start --runtime-dir \"{}\"`, then retry.", - paths.display(), - paths.display() - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use anyhow::anyhow; - - #[test] - fn exit_codes_remain_stable_for_all_error_kinds() { - assert_eq!(CliExitCode::Success.code(), 0); - assert_eq!(CliExitCode::InvalidUsage.code(), 2); - assert_eq!(CliExitCode::DaemonUnavailable.code(), 3); - assert_eq!(CliExitCode::DaemonStartFailure.code(), 4); - assert_eq!(CliExitCode::TargetNotFound.code(), 5); - assert_eq!(CliExitCode::ConflictOrRejected.code(), 6); - assert_eq!(CliExitCode::PermissionOrElevation.code(), 7); - assert_eq!(CliExitCode::UnsupportedOperation.code(), 8); - assert_eq!(CliExitCode::IpcFailure.code(), 9); - } - - #[test] - fn daemon_unavailable_maps_to_stable_exit_code() { - let error = Error::from(std::io::Error::new( - std::io::ErrorKind::ConnectionRefused, - "connection refused", - )); - - let mapped = CliError::daemon_request( - "domains list", - Path::new("runtime"), - "query domains", - &error, - ); - - assert_eq!(mapped.kind, CliErrorKind::DaemonUnavailable); - assert_eq!(mapped.exit_code().code(), 3); - assert!(mapped.guidance.unwrap().contains("cadderctl daemon start")); - } - - #[test] - fn permission_errors_map_to_permission_exit_code() { - let error = Error::from(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "permission denied", - )); - - let mapped = CliError::daemon_start("daemon start", Path::new("runtime"), &error); - - assert_eq!(mapped.kind, CliErrorKind::PermissionOrElevation); - assert_eq!(mapped.exit_code().code(), 7); - } - - #[test] - fn daemon_request_maps_unknown_errors_to_ipc_failure() { - let error = anyhow!("unexpected daemon envelope"); - - let mapped = CliError::daemon_request( - "logs show", - Path::new("runtime"), - "query retained logs", - &error, - ); - - assert_eq!(mapped.kind, CliErrorKind::IpcFailure); - assert!(mapped.message.contains("unexpected daemon envelope")); - assert!( - mapped - .guidance - .as_deref() - .is_some_and(|guidance| guidance.contains("IPC exchange failed")) - ); - } - - #[test] - fn daemon_start_maps_non_permission_errors_to_start_failure() { - let error = anyhow!("spawn failed"); - - let mapped = CliError::daemon_start("daemon start", Path::new("runtime"), &error); - - assert_eq!(mapped.kind, CliErrorKind::DaemonStartFailure); - assert_eq!(mapped.exit_code().code(), 4); - assert!(mapped.message.contains("spawn failed")); - } - - #[test] - fn helper_constructors_set_expected_error_kinds() { - assert_eq!( - CliError::invalid_usage("cmd", "bad", None).kind, - CliErrorKind::InvalidUsage - ); - assert_eq!( - CliError::target_not_found("cmd", "missing", None).kind, - CliErrorKind::TargetNotFound - ); - assert_eq!( - CliError::conflict_or_rejected("cmd", "busy", None).kind, - CliErrorKind::ConflictOrRejected - ); - assert_eq!( - CliError::unsupported("cmd", "unsupported", None).kind, - CliErrorKind::UnsupportedOperation - ); - } - - #[test] - fn permission_detection_accepts_access_denied_text() { - let error = anyhow!("Access is denied while opening the socket"); - - assert!(error_indicates_permission(&error)); - } - - #[test] - fn format_error_chain_deduplicates_repeated_messages() { - let error = anyhow!("broken").context("broken").context("top level"); - - assert_eq!(format_error_chain(&error), "top level: broken"); - } - - #[test] - fn daemon_unavailable_detection_accepts_connection_reset_variants() { - let error = Error::from(std::io::Error::new( - std::io::ErrorKind::ConnectionReset, - "connection reset", - )); - - assert!(daemon_error_indicates_unavailable(&error)); - } -} diff --git a/crates/cadderctl/src/view.rs b/crates/cadderctl/src/view.rs index 8a45a9d..80cc36b 100644 --- a/crates/cadderctl/src/view.rs +++ b/crates/cadderctl/src/view.rs @@ -1,355 +1,11 @@ use crate::cli::SeverityArg; -use cadder_protocol::{ - ActivationState, ConfigDiagnostic, ConfigState, EntrypointRegistration, GuiStateSnapshot, - LogEntry, LogSeverity, LogStreamIdentity, LogStreamStatus, QueryLogsResponse, RuntimeDiagnostic, - RuntimeState, canonicalize_domain, +pub use cadder_operator::{ + ActionResultView, ConfigView, ConnectionStateView, CountsView, DaemonStartView, DaemonStatusView, + DiagnosticsView, DomainListView, DomainResolveError, DomainView, EntrypointListView, + EntrypointView, LogsView, RuntimeView, SelectedDomain, counts, daemon_status_connected, + daemon_status_unavailable, diagnostics_view, domains_view, entrypoints_view, resolve_domain, }; -use chrono::{DateTime, Utc}; -use serde::Serialize; -use std::path::Path; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum ConnectionStateView { - Connected, - NotRunning, - ConnectionFailed, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DaemonStatusView { - pub runtime_dir: String, - pub connection_state: ConnectionStateView, - pub message: String, - pub guidance: Option, - pub captured_at_utc: Option>, - pub runtime: Option, - pub config: Option, - pub counts: CountsView, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DaemonStartView { - pub started: bool, - pub message: String, - pub status: DaemonStatusView, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ActionResultView { - pub message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct CountsView { - pub entrypoints: usize, - pub domains: usize, - pub active_domains: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeView { - pub status: String, - pub binary_path: Option, - pub version: Option, - pub process_id: Option, - pub admin_endpoint: Option, - pub diagnostics: Vec, -} - -impl From<&RuntimeState> for RuntimeView { - fn from(value: &RuntimeState) -> Self { - Self { - status: format!("{:?}", value.status), - binary_path: value.binary_path.clone(), - version: value.version.clone(), - process_id: value.process_id, - admin_endpoint: value.admin_endpoint.clone(), - diagnostics: value.diagnostics.clone(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ConfigView { - pub status: String, - pub last_attempted_at_utc: Option>, - pub last_successful_reload_at_utc: Option>, - pub effective_config_hash: Option, - pub diagnostics: Vec, -} - -impl From<&ConfigState> for ConfigView { - fn from(value: &ConfigState) -> Self { - Self { - status: format!("{:?}", value.status), - last_attempted_at_utc: value.last_attempted_at_utc, - last_successful_reload_at_utc: value.last_successful_reload_at_utc, - effective_config_hash: value.effective_config_hash.clone(), - diagnostics: value.diagnostics.clone(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct EntrypointView { - pub registration_id: String, - pub activation_state: ActivationState, - pub working_directory: String, - pub config_path: String, - pub started_at_utc: DateTime, - pub last_heartbeat_utc: DateTime, - pub process_id: u32, - pub executable_path: Option, - pub domain_count: usize, - pub active_domain_count: usize, - pub adapter: Option, - pub command_line: Option, -} - -impl From<&EntrypointRegistration> for EntrypointView { - fn from(value: &EntrypointRegistration) -> Self { - Self { - registration_id: value.registration_id.clone(), - activation_state: value.activation_state, - working_directory: value.source_working_directory.raw.clone(), - config_path: value.source_config_path.raw.clone(), - started_at_utc: value.entrypoint_instance.started_at_utc, - last_heartbeat_utc: value.last_heartbeat_utc, - process_id: value.owner_process.process_id, - executable_path: value.owner_process.executable_path.clone(), - domain_count: value.registered_domains.len(), - active_domain_count: value - .registered_domains - .iter() - .filter(|domain| domain.activation_state.is_enabled()) - .count(), - adapter: value.shim_run.as_ref().and_then(|run| run.adapter.clone()), - command_line: value.shim_run.as_ref().map(|run| run.command_line.clone()), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct EntrypointListView { - pub captured_at_utc: DateTime, - pub entrypoints: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DomainView { - pub registration_id: String, - pub domain: String, - pub canonical_domain: String, - pub activation_state: ActivationState, - pub entrypoint_activation_state: ActivationState, - pub working_directory: String, - pub config_path: String, - pub log_stream: LogStreamIdentity, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DomainListView { - pub captured_at_utc: DateTime, - pub domains: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DiagnosticsView { - pub captured_at_utc: DateTime, - pub runtime: RuntimeView, - pub config: ConfigView, - pub runtime_diagnostics: Vec, - pub config_diagnostics: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LogsView { - pub stream: LogStreamIdentity, - pub stream_status: LogStreamStatus, - pub entries: Vec, - pub next_cursor: Option, - pub has_gap: bool, - pub has_more_before: bool, - pub truncated_by_retention: bool, -} - -impl From for LogsView { - fn from(value: QueryLogsResponse) -> Self { - Self { - stream: value.stream, - stream_status: value.stream_status, - entries: value.entries, - next_cursor: value.next_cursor, - has_gap: value.has_gap, - has_more_before: value.has_more_before, - truncated_by_retention: value.truncated_by_retention, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SelectedDomain { - pub registration_id: String, - pub canonical_domain: String, -} - -pub fn daemon_status_connected( - runtime_dir: &Path, - snapshot: &GuiStateSnapshot, -) -> DaemonStatusView { - DaemonStatusView { - runtime_dir: runtime_dir.display().to_string(), - connection_state: ConnectionStateView::Connected, - message: "Attached to cadderd.".to_string(), - guidance: None, - captured_at_utc: Some(snapshot.captured_at_utc), - runtime: Some(RuntimeView::from(&snapshot.runtime)), - config: Some(ConfigView::from(&snapshot.config)), - counts: counts(snapshot), - } -} - -pub fn daemon_status_unavailable( - runtime_dir: &Path, - state: ConnectionStateView, - message: String, - guidance: Option, -) -> DaemonStatusView { - DaemonStatusView { - runtime_dir: runtime_dir.display().to_string(), - connection_state: state, - message, - guidance, - captured_at_utc: None, - runtime: None, - config: None, - counts: CountsView::default(), - } -} - -pub fn entrypoints_view(snapshot: &GuiStateSnapshot) -> EntrypointListView { - EntrypointListView { - captured_at_utc: snapshot.captured_at_utc, - entrypoints: snapshot - .registrations - .iter() - .map(EntrypointView::from) - .collect(), - } -} - -pub fn domains_view( - snapshot: &GuiStateSnapshot, - registration_filter: Option<&str>, -) -> DomainListView { - let domains = snapshot - .registrations - .iter() - .filter(|registration| { - registration_filter.is_none_or(|filter| registration.registration_id == filter) - }) - .flat_map(|registration| { - registration - .registered_domains - .iter() - .map(move |domain| DomainView { - registration_id: registration.registration_id.clone(), - domain: domain.name.raw.clone(), - canonical_domain: domain.name.canonical.clone(), - activation_state: domain.activation_state, - entrypoint_activation_state: registration.activation_state, - working_directory: registration.source_working_directory.raw.clone(), - config_path: registration.source_config_path.raw.clone(), - log_stream: domain.log_stream.clone(), - }) - }) - .collect(); - - DomainListView { - captured_at_utc: snapshot.captured_at_utc, - domains, - } -} - -pub fn diagnostics_view(snapshot: &GuiStateSnapshot) -> DiagnosticsView { - DiagnosticsView { - captured_at_utc: snapshot.captured_at_utc, - runtime: RuntimeView::from(&snapshot.runtime), - config: ConfigView::from(&snapshot.config), - runtime_diagnostics: snapshot.runtime.diagnostics.clone(), - config_diagnostics: snapshot.config.diagnostics.clone(), - } -} - -pub fn counts(snapshot: &GuiStateSnapshot) -> CountsView { - CountsView { - entrypoints: snapshot.registrations.len(), - domains: snapshot - .registrations - .iter() - .map(|registration| registration.registered_domains.len()) - .sum(), - active_domains: snapshot - .registrations - .iter() - .flat_map(|registration| registration.registered_domains.iter()) - .filter(|domain| domain.activation_state.is_enabled()) - .count(), - } -} - -pub fn resolve_domain( - snapshot: &GuiStateSnapshot, - domain: &str, - registration: Option<&str>, -) -> Result { - let canonical = canonicalize_domain(domain); - let mut matches = Vec::new(); - for entrypoint in &snapshot.registrations { - if registration.is_some_and(|registration_id| entrypoint.registration_id != registration_id) { - continue; - } - - if entrypoint - .registered_domains - .iter() - .any(|candidate| candidate.name.canonical == canonical) - { - matches.push(SelectedDomain { - registration_id: entrypoint.registration_id.clone(), - canonical_domain: canonical.clone(), - }); - } - } - - match matches.as_slice() { - [] => Err(DomainResolveError::NotFound { - canonical_domain: canonical, - requested_registration: registration.map(ToString::to_string), - }), - [selected] => Ok(selected.clone()), - _ => Err(DomainResolveError::Ambiguous { - canonical_domain: canonical, - matches: matches - .into_iter() - .map(|entry| entry.registration_id) - .collect(), - }), - } -} +use cadder_protocol::LogSeverity; pub fn map_severity(value: Option) -> Option { match value { @@ -362,94 +18,3 @@ pub fn map_severity(value: Option) -> Option { Some(SeverityArg::Fatal) => Some(LogSeverity::Fatal), } } - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DomainResolveError { - NotFound { - canonical_domain: String, - requested_registration: Option, - }, - Ambiguous { - canonical_domain: String, - matches: Vec, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - use cadder_protocol::{ - DomainName, EntrypointInstanceIdentity, OwnerProcessIdentity, RegisteredDomain, SourcePath, - }; - - fn registration(id: &str, domains: &[&str]) -> EntrypointRegistration { - let now = Utc::now(); - let identity = EntrypointInstanceIdentity { - instance_id: id.to_string(), - started_at_utc: now, - shim_session_nonce: format!("{id}-nonce"), - }; - EntrypointRegistration { - registration_id: id.to_string(), - entrypoint_instance: identity.clone(), - source_working_directory: SourcePath::new("/work", None), - source_config_path: SourcePath::new("/work/Caddyfile", None), - registered_domains: domains - .iter() - .map(|domain| RegisteredDomain { - name: DomainName::parse(*domain), - activation_state: ActivationState::Active, - log_stream: LogStreamIdentity::domain(domain), - }) - .collect(), - activation_state: ActivationState::Active, - owner_process: OwnerProcessIdentity { - process_id: 1, - process_start_time_utc: now, - shim_session_nonce: identity.shim_session_nonce.clone(), - executable_path: None, - }, - log_stream: LogStreamIdentity::entrypoint(id), - shim_run: None, - created_at_utc: now, - last_heartbeat_utc: now, - } - } - - fn snapshot(registrations: Vec) -> GuiStateSnapshot { - GuiStateSnapshot { - captured_at_utc: Utc::now(), - registrations, - runtime: RuntimeState::idle(), - config: ConfigState::idle(), - } - } - - #[test] - fn resolve_domain_finds_unique_match() { - let snapshot = snapshot(vec![registration("shim-1", &["app.localhost"])]); - - let selected = resolve_domain(&snapshot, "APP.localhost", None).unwrap(); - - assert_eq!(selected.registration_id, "shim-1"); - assert_eq!(selected.canonical_domain, "app.localhost"); - } - - #[test] - fn resolve_domain_reports_ambiguity_without_registration() { - let snapshot = snapshot(vec![ - registration("shim-1", &["app.localhost"]), - registration("shim-2", &["app.localhost"]), - ]); - - let error = resolve_domain(&snapshot, "app.localhost", None).unwrap_err(); - - assert_eq!( - error, - DomainResolveError::Ambiguous { - canonical_domain: "app.localhost".to_string(), - matches: vec!["shim-1".to_string(), "shim-2".to_string()], - } - ); - } -} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ec85d80..26b4a9e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,16 +1,17 @@ # Cadder Architecture -Cadder is a cross-platform Rust daemon, shim, non-TUI CLI, and terminal UI for routing project-local `caddy run` invocations into one persistent per-user Caddy runtime. +Cadder is a cross-platform Rust daemon, shim, non-TUI CLI, local MCP server, and terminal UI for routing project-local `caddy run` invocations into one persistent per-user Caddy runtime. ## Process Roles - `cadderd`: per-user daemon. It owns registrations, local IPC, Caddyfile adaptation, effective Caddy config composition, the Cadder-owned real Caddy process, runtime diagnostics, and bounded log storage. - `caddy`: PATH-facing shim. It intentionally shadows Caddy for managed `caddy run` commands, attaches to an already running `cadderd`, registers the caller's config, heartbeats while alive, and unregisters on exit. - `cadderctl`: non-TUI CLI. It attaches to the daemon for scriptable inspection, log queries, entrypoint and domain toggles, diagnostics, and explicit daemon lifecycle control. +- `cadder-mcp`: local stdio MCP server. It attaches to the daemon through the same operator layer as `cadderctl`, exposes typed tools for state, diagnostics, logs, and toggles, and keeps daemon launch explicit through `cadder_start_daemon`. - `cadder-tui`: Ratatui/Crossterm UI. It attaches to the daemon and shows overview, entrypoints, grouped domains, per-domain logs, diagnostics, search/filtering, activation toggles, and explicit backend start or shutdown actions. - Real Caddy runtime: resolved external binary. Cadder never embeds Caddy and must not recursively execute its own shim. -All four Cadder binaries expose `--help` and `--version` through their Clap command definitions. The release-facing command names are `cadderd`, `cadderctl`, `caddy`, and `cadder-tui`. +All five Cadder binaries expose `--help` and `--version` through their Clap command definitions. The release-facing command names are `cadderd`, `cadderctl`, `cadder-mcp`, `caddy`, and `cadder-tui`. ## Cross-Platform Runtime Model @@ -21,7 +22,7 @@ Cadder uses per-user runtime paths from `directories::ProjectDirs`, with `CADDER - an effective generated Caddy JSON config file; - daemon metadata and bounded in-memory state. -`cadderd` is a manually started per-user backend. The `caddy` shim is attach-only, while `cadder-tui` and `cadderctl` are attach-only except for their explicit daemon start actions. They connect to an existing daemon when it is available, surface explicit unavailable guidance when it is not, and never spawn it as an implicit side effect of opening a dashboard, querying state, or running `caddy run`. Explicit backend start is limited to direct `cadderd` execution or deliberate client actions such as pressing `s` in `cadder-tui` or running `cadderctl daemon start`. Explicit launch attempts still use a per-runtime launch lock before spawning `cadderd`, so concurrent start requests wait for the daemon socket instead of spawning another child, while the daemon's own runtime lock remains the final ownership guard. OS services are intentionally deferred; v1 remains a user daemon model. +`cadderd` is a manually started per-user backend. The `caddy` shim is attach-only, while `cadder-tui`, `cadderctl`, and `cadder-mcp` are attach-only except for their explicit daemon start actions. They connect to an existing daemon when it is available, surface explicit unavailable guidance when it is not, and never spawn it as an implicit side effect of opening a dashboard, querying state, running `caddy run`, or serving an MCP tool call. Explicit backend start is limited to direct `cadderd` execution or deliberate client actions such as pressing `s` in `cadder-tui`, running `cadderctl daemon start`, or invoking the MCP `cadder_start_daemon` tool. Explicit launch attempts still use a per-runtime launch lock before spawning `cadderd`, so concurrent start requests wait for the daemon socket instead of spawning another child, while the daemon's own runtime lock remains the final ownership guard. OS services are intentionally deferred; v1 remains a user daemon model. ## IPC Boundary @@ -98,6 +99,8 @@ The TUI attempts to attach without spawning the daemon, upgrades successful atta `cadderctl` reuses the same contracts for non-interactive workflows. One-shot inspection commands use `query-state-request` or `query-logs-request`, `watch` uses `subscribe-state-request`, domain and entrypoint toggles use the existing activation messages, and `logs tail` advances the daemon log cursor through repeated `query-logs-request` calls instead of introducing a new streaming backend protocol. +`cadder-mcp` reuses the same daemon IPC contracts through a shared operator layer, but serves them over local stdio for MCP-capable hosts. The server is intentionally local-only, returns structured tool payloads instead of terminal-oriented output, applies an extra redaction pass to paths and free-form text, and does not expose daemon event streaming in v1. + On Windows, `cadder-tui` also exposes an IIS Handoff view. The view lists site, protocol, IP address, port, host header, handoff state, and safety details. Pressing `Enter` refreshes IIS discovery; pressing `Space` hands off an available binding or restores a handed-off binding. Before dispatching a mixed-elevation action, the TUI states why administrator approval may be requested. After the daemon responds, the status line summarizes succeeded, approved, denied, failed, and follow-up operation steps. For wildcard or empty-host IIS rows, press `/`, type the route host or URL, press `Enter` to keep the value, then press `Space`. The view is absent on non-Windows platforms rather than shown as a disabled placeholder. Log refresh, late-availability probes, IIS discovery, activation toggles, IIS handoff actions, and shutdown requests are dispatched as short background IPC tasks. The TUI accepts at most one active state probe, one active state subscription stream, and one active log refresh for the current stream, and surfaces IPC failures as a read-error state rather than blocking terminal input. @@ -112,11 +115,12 @@ The log store reports stream status and retention metadata in `query-logs-respon - `cadderd` - `cadderctl` +- `cadder-mcp` - `cadder-tui` - `caddy` - `cadder.toml` -On Windows the binaries include the `.exe` suffix. `cargo run -p xtask -- verify-dist --dir ` checks the expected files, runs `caddy --cadder-shim-info`, and verifies `cadderctl --help` from the layout. `cargo run -p xtask -- package --out --platform --target ` builds the target-specific layout, wraps it in a versioned portable archive, and writes a neighboring `.sha256` checksum file. The package version defaults to the root `Cargo.toml` `[workspace.package]` version, while `--version ` remains available for explicit dry-run overrides. Windows artifacts are `.zip` archives; Linux and macOS artifacts are `.tar.gz` archives. +On Windows the binaries include the `.exe` suffix. `cargo run -p xtask -- verify-dist --dir ` checks the expected files, runs `caddy --cadder-shim-info`, and verifies both `cadderctl --help` and `cadder-mcp --help` from the layout. `cargo run -p xtask -- package --out --platform --target ` builds the target-specific layout, wraps it in a versioned portable archive, and writes a neighboring `.sha256` checksum file. The package version defaults to the root `Cargo.toml` `[workspace.package]` version, while `--version ` remains available for explicit dry-run overrides. Windows artifacts are `.zip` archives; Linux and macOS artifacts are `.tar.gz` archives. The packaging workflow does not modify PATH, shell profiles, package-manager shims, OS services, or other system state. @@ -128,7 +132,9 @@ The current runtime model is a single per-user daemon that owns the backend and - `crates/cadder-protocol`: shared DTOs, activation/runtime/log states, IPC envelopes, and request/response contracts. - `crates/cadder-daemon`: runtime paths, daemon lock, local IPC server/client, registration state, Caddy integration, process runtime, and log store. +- `crates/cadder-operator`: shared operator layer for daemon queries, daemon launch policy, state shaping, and view-building reused across CLIs and MCP surfaces. - `crates/cadderctl`: non-TUI CLI for daemon status, entrypoints, domains, diagnostics, and logs. +- `crates/cadder-mcp`: stdio MCP server exposing Cadder inspection and control tools for local agents. - `crates/cadderd`: daemon binary. - `crates/cadder-shim`: package containing the PATH-facing `caddy` binary. - `crates/cadder-tui`: terminal UI. diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 282cb97..a0dc747 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -28,6 +28,7 @@ export default defineConfig({ items: [ { label: 'How to use', slug: 'user-guide/how-to-use' }, { label: 'cadderctl', slug: 'user-guide/cadderctl' }, + { label: 'cadder-mcp', slug: 'user-guide/cadder-mcp' }, { label: 'cadder.toml', slug: 'user-guide/cadder-toml' }, { label: 'PATH and shim strategy', slug: 'user-guide/path-and-shim' }, { label: 'TUI and diagnostics', slug: 'user-guide/tui-diagnostics' }, diff --git a/docs/site/src/content/docs/index.mdx b/docs/site/src/content/docs/index.mdx index b2e8fc7..c7dc7a0 100644 --- a/docs/site/src/content/docs/index.mdx +++ b/docs/site/src/content/docs/index.mdx @@ -17,6 +17,7 @@ That makes local reverse-proxy setups easier to run side by side. Cadder can com - One local Caddy runtime can serve domains from many project Caddyfiles. - The `caddy` shim keeps `caddy run` usable for project workflows. - `cadderctl` gives scripts and agents a non-interactive way to inspect and operate Cadder. +- `cadder-mcp` exposes the same local daemon through MCP tools for agent hosts. - `cadder.toml` points Cadder at the real Caddy binary without replacing it. - `cadder-tui` shows entrypoints, domains, logs, diagnostics, and toggles. - Domains can be enabled or disabled without killing unrelated project sessions. @@ -27,6 +28,7 @@ That makes local reverse-proxy setups easier to run side by side. Cadder can com - `caddy` is the Caddy-compatible shim. For `caddy run`, it attaches to a running `cadderd`, registers the current project, and unregisters on exit. If the backend is unavailable, Cadder reports how to start it instead of spawning it implicitly. Other Caddy commands are delegated to the real Caddy binary. - `cadderd` is the daemon. It owns registrations, generated Caddy config, the real Caddy process it starts, diagnostics, and bounded logs. - `cadderctl` is the non-TUI CLI for state, domains, diagnostics, logs, and explicit daemon lifecycle commands. +- `cadder-mcp` is the local stdio MCP server for agents that need state, diagnostics, logs, and explicit toggles without scraping terminal output. - `cadder-tui` is the terminal UI for inspecting domains, entrypoints, diagnostics, logs, filters, toggles, daemon shutdown, and explicit backend start when needed. Each binary supports `--help` and `--version`. @@ -58,4 +60,4 @@ Each binary supports `--help` and `--version`. 2. Create `cadder.toml` with the path to the real Caddy binary. See [cadder.toml](user-guide/cadder-toml/) for the supported forms. 3. Start `cadderd`, then run Cadder's `caddy` shim instead of the real `caddy` command. -Use `cadderctl` for automation-friendly inspection and toggles, or open `cadder-tui` when you want the interactive dashboard. +Use `cadderctl` for automation-friendly inspection and toggles, `cadder-mcp` when an MCP-capable host should drive Cadder locally, or `cadder-tui` when you want the interactive dashboard. diff --git a/docs/site/src/content/docs/quick-start/getting-started.mdx b/docs/site/src/content/docs/quick-start/getting-started.mdx index 5f5d0b0..a26798c 100644 --- a/docs/site/src/content/docs/quick-start/getting-started.mdx +++ b/docs/site/src/content/docs/quick-start/getting-started.mdx @@ -24,6 +24,7 @@ You can also run Cadder by direct path. That is useful for testing or for setups ```sh cadderd cadderctl daemon status +cadder-mcp --help caddy run ``` @@ -35,7 +36,7 @@ cadderctl logs show --limit 20 domain app.localhost --registration shim-1 cadder-tui ``` -Use `cadderctl` for automation-friendly state, domain, and log commands. Use the TUI to inspect current state interactively, browse logs, and use explicit backend controls. If you prefer, you can also open `cadder-tui` first and press `s` to start the backend from inside the dashboard. +Use `cadderctl` for automation-friendly state, domain, and log commands. Use `cadder-mcp` when a local MCP host should inspect or control Cadder through tools. Use the TUI to inspect current state interactively, browse logs, and use explicit backend controls. If you prefer, you can also open `cadder-tui` first and press `s` to start the backend from inside the dashboard. ## OS cookbooks diff --git a/docs/site/src/content/docs/user-guide/cadder-mcp.mdx b/docs/site/src/content/docs/user-guide/cadder-mcp.mdx new file mode 100644 index 0000000..48dbb01 --- /dev/null +++ b/docs/site/src/content/docs/user-guide/cadder-mcp.mdx @@ -0,0 +1,54 @@ +--- +title: cadder-mcp +description: Run Cadder's local MCP server for agent-friendly inspection and explicit control. +--- + +`cadder-mcp` is Cadder's local stdio MCP server. It exposes structured tools for the same per-user backend used by `cadderctl` and `cadder-tui`, so an MCP-capable host can inspect state, read diagnostics, fetch logs, and toggle managed routes without scraping terminal output. + +## Start the server + +Run the server directly: + +```sh +cadder-mcp +``` + +You can also override the runtime directory, daemon binary, or real Caddy resolution inputs when your host needs an explicit launch context: + +```sh +cadder-mcp --runtime-dir /tmp/cadder-runtime +cadder-mcp --daemon-path /absolute/path/to/cadderd +cadder-mcp --real-caddy-command /absolute/path/to/caddy +``` + +## Available tools + +`cadder-mcp` exposes these operator-facing tools: + +- `cadder_get_overview` +- `cadder_list_entrypoints` +- `cadder_list_domains` +- `cadder_show_diagnostics` +- `cadder_get_logs` +- `cadder_start_daemon` +- `cadder_set_entrypoint_enabled` +- `cadder_set_domain_enabled` + +The server stays attach-only by default. Read-only tools and toggle tools connect to an existing daemon and return typed unavailable errors when `cadderd` is not running. Only `cadder_start_daemon` may launch the backend. + +## Trust boundary + +`cadder-mcp` is intentionally local-only: + +- it serves tools over stdio for the host process that launched it; +- it does not open a network listener; +- it applies an extra redaction pass to returned paths and free-form text; +- it truncates large log payloads instead of streaming the full daemon log store in v1. + +This makes the MCP surface appropriate for local agents and editors that already have workstation access, while keeping daemon ownership and startup policy aligned with the CLI and TUI. + +## When to use it + +- Use `cadder-mcp` when your host already speaks MCP and should call structured local tools. +- Use `cadderctl` when you want shell-friendly commands, JSON output, or scripts outside an MCP host. +- Use `cadder-tui` when you want an interactive dashboard, navigation, filters, and browsing. diff --git a/docs/site/src/content/docs/user-guide/cadderctl.mdx b/docs/site/src/content/docs/user-guide/cadderctl.mdx index 750bf8a..b3b59e8 100644 --- a/docs/site/src/content/docs/user-guide/cadderctl.mdx +++ b/docs/site/src/content/docs/user-guide/cadderctl.mdx @@ -76,9 +76,10 @@ cadderctl watch status --output jsonl ## Relation to the TUI -`cadderctl` and `cadder-tui` share the same daemon contracts and runtime model. +`cadderctl`, `cadder-mcp`, and `cadder-tui` share the same daemon contracts and runtime model. - Use `cadderctl` when you need predictable output, non-interactive toggles, or agent-friendly polling and watches. +- Use `cadder-mcp` when your agent host already speaks MCP and should call structured local tools instead of shelling out. - Use `cadder-tui` when you need interactive browsing, navigation, filters, and richer operator context. Neither tool changes daemon ownership semantics. They are clients of the same per-user backend. @@ -93,4 +94,4 @@ The command structure and output modes are intentionally stable so future Cadder - toggle managed routes explicitly; - map command failures to stable exit codes and typed machine-readable errors. -That keeps agent workflows aligned with the same backend used by the interactive TUI instead of creating a parallel automation API. +That keeps agent workflows aligned with the same backend used by the interactive TUI instead of creating a parallel automation API. If you want a local MCP surface instead of shell-oriented commands, see [cadder-mcp](../cadder-mcp/). diff --git a/docs/site/src/content/docs/user-guide/how-to-use.mdx b/docs/site/src/content/docs/user-guide/how-to-use.mdx index 339e789..10975c4 100644 --- a/docs/site/src/content/docs/user-guide/how-to-use.mdx +++ b/docs/site/src/content/docs/user-guide/how-to-use.mdx @@ -3,11 +3,12 @@ title: How to use description: Use Cadder releases, the caddy shim, and the TUI without changing Cadder itself. --- -The release contains five files you normally care about: +The release contains six files you normally care about: - `caddy` - `cadderd` - `cadderctl` +- `cadder-mcp` - `cadder-tui` - `cadder.toml` @@ -34,12 +35,13 @@ caddy run ```sh cadderctl daemon status cadderctl domains list --output json +cadder-mcp --help cadder-tui ``` `caddy run` attaches to the running backend. If `cadderd` is unavailable, Cadder reports how to start it instead of starting it implicitly. -Use `cadderctl` when you want stable machine-readable output, non-interactive toggles, or scriptable log queries. Use `cadder-tui` when you want an interactive dashboard with navigation and live browsing. +Use `cadderctl` when you want stable machine-readable output, non-interactive toggles, or scriptable log queries. Use `cadder-mcp` when an MCP-capable host should drive Cadder through local stdio tools. Use `cadder-tui` when you want an interactive dashboard with navigation and live browsing. ## Direct or PATH-based use diff --git a/xtask/src/main.rs b/xtask/src/main.rs index fe93548..d6362cc 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -127,6 +127,18 @@ fn verify_dist(options: &VerifyDistOptions) -> Result<()> { bail!("cadderctl --help failed with {}", ctl_output.status); } + let mcp = options + .dir + .join(exe_name("cadder-mcp", options.target.as_deref())); + let mcp_output = Command::new(&mcp) + .arg("--help") + .stdin(Stdio::null()) + .output() + .with_context(|| format!("run {}", mcp.display()))?; + if !mcp_output.status.success() { + bail!("cadder-mcp --help failed with {}", mcp_output.status); + } + Ok(()) } @@ -179,8 +191,8 @@ fn package_with_dist( Ok(()) } -fn portable_binaries() -> [&'static str; 4] { - ["cadderd", "cadderctl", "cadder-tui", "caddy"] +fn portable_binaries() -> [&'static str; 5] { + ["cadderd", "cadderctl", "cadder-mcp", "cadder-tui", "caddy"] } fn release_binary_path(name: &str, target: Option<&str>) -> PathBuf { @@ -212,6 +224,8 @@ fn build_release_binaries(target: Option<&str>) -> Result<()> { "-p", "cadderctl", "-p", + "cadder-mcp", + "-p", "cadder-tui", "-p", "cadder-shim", @@ -723,7 +737,7 @@ edition = "2024" fn portable_layout_includes_expected_binaries() { assert_eq!( portable_binaries(), - ["cadderd", "cadderctl", "cadder-tui", "caddy"] + ["cadderd", "cadderctl", "cadder-mcp", "cadder-tui", "caddy"] ); } @@ -864,6 +878,7 @@ edition = "2024" &[ "cadderd.exe", "cadderctl.exe", + "cadder-mcp.exe", "cadder-tui.exe", "caddy.exe", ], @@ -881,6 +896,7 @@ edition = "2024" &[ "cadder-1.2.3-windows-x64/", "cadder-1.2.3-windows-x64/cadderctl.exe", + "cadder-1.2.3-windows-x64/cadder-mcp.exe", "cadder-1.2.3-windows-x64/cadder-tui.exe", "cadder-1.2.3-windows-x64/cadder.toml", "cadder-1.2.3-windows-x64/cadderd.exe", @@ -916,7 +932,7 @@ edition = "2024" ); write_fake_portable_layout( &dist_options.out_dir, - &["cadderd", "cadderctl", "cadder-tui", "caddy"], + &["cadderd", "cadderctl", "cadder-mcp", "cadder-tui", "caddy"], ) }, ) @@ -931,6 +947,7 @@ edition = "2024" &[ "cadder-1.2.3-linux-x64/", "cadder-1.2.3-linux-x64/cadderctl", + "cadder-1.2.3-linux-x64/cadder-mcp", "cadder-1.2.3-linux-x64/cadder-tui", "cadder-1.2.3-linux-x64/cadder.toml", "cadder-1.2.3-linux-x64/cadderd", From a1859dcc5492853390306c6af8bd96834767b524 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Tue, 7 Jul 2026 14:59:54 +0200 Subject: [PATCH 05/29] docs: define OpenSpec architecture reset Add OpenSpec project rules and a reset-cadder-architecture change covering proposal, design, capability specs, and implementation tasks. This establishes the target daemon-owned runtime model before implementation cleanup. --- .../reset-cadder-architecture/.openspec.yaml | 4 + .../reset-cadder-architecture/README.md | 3 + .../reset-cadder-architecture/design.md | 166 ++++++++++++++++++ .../reset-cadder-architecture/proposal.md | 73 ++++++++ .../specs/caddy-shim-integration/spec.md | 65 +++++++ .../specs/daemon-control-plane/spec.md | 79 +++++++++ .../specs/observability-logs/spec.md | 53 ++++++ .../specs/operator-clients/spec.md | 66 +++++++ .../specs/quality-tooling/spec.md | 89 ++++++++++ .../specs/runtime-topology/spec.md | 54 ++++++ .../specs/windows-iis-handoff/spec.md | 52 ++++++ .../reset-cadder-architecture/tasks.md | 66 +++++++ openspec/config.yaml | 39 ++++ 13 files changed, 809 insertions(+) create mode 100644 openspec/changes/reset-cadder-architecture/.openspec.yaml create mode 100644 openspec/changes/reset-cadder-architecture/README.md create mode 100644 openspec/changes/reset-cadder-architecture/design.md create mode 100644 openspec/changes/reset-cadder-architecture/proposal.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/caddy-shim-integration/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/observability-logs/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/operator-clients/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/quality-tooling/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/runtime-topology/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/windows-iis-handoff/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/tasks.md create mode 100644 openspec/config.yaml diff --git a/openspec/changes/reset-cadder-architecture/.openspec.yaml b/openspec/changes/reset-cadder-architecture/.openspec.yaml new file mode 100644 index 0000000..ccd7490 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +created: 2026-07-07 +goal: Adopt OpenSpec as the source of truth and define the target architecture + before implementation. diff --git a/openspec/changes/reset-cadder-architecture/README.md b/openspec/changes/reset-cadder-architecture/README.md new file mode 100644 index 0000000..78888a3 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/README.md @@ -0,0 +1,3 @@ +# reset-cadder-architecture + +Rebaseline Cadder around daemon-owned runtime state, a Caddy-compatible shim, operator clients, explicit platform boundaries, and OpenSpec-based planning. diff --git a/openspec/changes/reset-cadder-architecture/design.md b/openspec/changes/reset-cadder-architecture/design.md new file mode 100644 index 0000000..6ad0acb --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/design.md @@ -0,0 +1,166 @@ +## Context + +Cadder coordinates Caddy-backed local development runtimes. The reset defines a +target architecture before implementation continues, so each part has one clear +reason to exist and one clear owner. + +The desired shape is a daemon-backed tool: + +- the daemon owns runtime state and external process coordination; +- compatibility shims translate familiar workflows into daemon requests; +- operator clients inspect and control the daemon through a stable local + protocol; +- optional platform integrations sit behind narrow providers. + +OpenSpec captures the product contract. Code, documentation, and release +artifacts should follow the accepted specs rather than becoming competing +sources of truth. + +## Goals / Non-Goals + +**Goals:** + +- Define a small runtime topology with explicit ownership. +- Keep runtime, integration, and presentation boundaries independently testable. +- Make daemon-offline behavior part of the normal operator contract. +- Keep platform-specific behavior isolated behind testable abstractions. +- Prefer mature libraries and tools over custom orchestration when they fit. +- Make future client surfaces reuse existing protocol and view-model contracts. + +**Non-Goals:** + +- This change does not implement the architecture. +- This change does not pick every crate/module name. +- This change does not make every future user interface part of the initial + product surface. +- This change does not require Cadder to manage external processes it did not + start. + +## Decisions + +### Runtime Ownership + +The daemon is the only Cadder component that owns durable runtime state, +generated Caddy configuration, Caddy process coordination, logs, and platform +integration state. Other components are clients or adapters. + +This keeps process ownership, recovery, and diagnostics in one place. It avoids +state drift between shims, operator clients, and real Caddy. + +### Runtime Identity + +Production mode uses one stable runtime identity per user or explicit system +installation. Multiple runtimes are a dev/debug feature and require isolated +runtime directories, visible labels, and separate locks. + +This gives normal users predictable behavior while preserving a deliberate path +for debugging and integration tests. + +### Local Control Plane + +Cadder uses a local daemon protocol with typed envelopes, requests, responses, +events, and errors. The protocol should tolerate additive compatible changes and +report unsupported capabilities explicitly. + +Transport is an edge concern. Core protocol types stay transport-neutral. +Platform-specific transport security belongs behind a narrow policy layer. + +### Privilege Boundaries + +Normal operation should run at user privilege. Operations that require higher +privilege must be explicit, scoped, and observable from user-level clients. + +The implementation must document how user-level clients discover and contact a +more privileged daemon or helper. The security policy must be testable without +requiring real OS mutation in every test. + +### Shim Boundary + +The Caddy-compatible shim is a routing layer, not a second daemon and not a +full Caddy clone. It must classify commands as managed, read-only inspection, +explicit passthrough, or unsupported. + +Managed commands go to the daemon. Passthrough is allowed only when it cannot +mutate Cadder-managed state silently. Unknown commands fail with useful +diagnostics. + +### Caddy Adapter + +Cadder owns the generated runtime model. Real Caddy is the execution target. +Caddy's Admin API and process commands are adapter mechanisms, not public +Cadder state. + +The adapter must prevent recursive shim execution, apply generated config +atomically where possible, and report drift when real Caddy no longer matches +Cadder's last applied model. + +### Platform Integrations + +Platform integrations, including IIS handoff, live behind provider traits or +equivalent boundaries. The daemon depends on provider contracts, not directly on +platform command scripts or OS APIs. + +This keeps policy decisions testable with fakes and reserves real OS smoke tests +for behavior that genuinely requires the platform. + +### Operator Clients + +The operator executable provides non-interactive CLI workflows and interactive +TUI workflows. Both render from daemon protocol data and shared view models. + +Clients must remain useful when the daemon is offline. Read-only flows can show +setup information, diagnostics, and last-known state where available. +State-changing flows must explain that the daemon is unavailable and offer a +start/recovery path. + +### Logs + +The daemon owns structured log capture, query, retention, and redaction +semantics. Clients request logs by dimensions such as runtime, project, domain, +source, and severity; presentation differences must not change query semantics. + +Per-domain logs must be real domain-filtered records, not a UI label over a +broader Caddy stream. + +### Tooling + +Custom tooling should remain small. Use mature tools for testing, coverage, +release, documentation, and command orchestration when they fit the project. +Custom Rust tooling is reserved for Cadder-specific checks that external tools +do not cover clearly. + +## Risks / Trade-offs + +- Local IPC security differs by platform -> keep transport policy explicit and + test policy separately from OS smoke tests. +- Future client surfaces can drift -> require shared protocol and view-model + contracts before adding them. +- Direct real-Caddy changes can create drift -> keep generated config ownership + in the daemon and surface drift as state. +- Domain-level logs may require extra labeling or mapping -> define that mapping + in daemon-owned log semantics. +- Tooling cleanup can break CI or releases -> replace custom steps only when an + equivalent verification path exists. + +## Migration Plan + +1. Adopt OpenSpec as the source for requirements, design decisions, and tasks. +2. Reconcile workspace crates against the accepted topology. +3. Split protocol, daemon, shim, operator, platform, and log code into testable + seams. +4. Rebuild behavior in independently verifiable slices. +5. Replace or shrink custom tooling after equivalent validation exists. +6. Update docs and release metadata after the runtime topology is implemented. + +## Open Questions + +- What is the exact CLI/TUI invocation model for the operator executable? +- What local IPC security model should be used for user-level clients talking to + a privileged daemon or helper? +- Should privileged platform operations use a narrow helper executable or a + privileged daemon mode? +- Which exact Caddy command paths belong in each shim policy category? +- What file-size/cohesion threshold should validation enforce, and which + generated files are exempt? +- Should the existing operator support crate remain separate or become part of + the final operator crate boundary? diff --git a/openspec/changes/reset-cadder-architecture/proposal.md b/openspec/changes/reset-cadder-architecture/proposal.md new file mode 100644 index 0000000..49e876f --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/proposal.md @@ -0,0 +1,73 @@ +## Why + +Cadder needs a smaller and more explicit target architecture before more code is +added. The current direction must make ownership obvious: one runtime service +owns external state, while compatibility shims and operator clients attach to it +through stable local contracts. + +The target should be familiar to users of daemon-backed developer tools. A +durable daemon manages the runtime. Thin clients inspect it, configure it, and +recover from offline states without becoming independent sources of truth. + +## What Changes + +- Define the product topology around: + - a daemon that owns runtime state and external process coordination; + - a Caddy-compatible shim that routes managed Caddy workflows into the daemon; + - an operator executable that exposes CLI and TUI workflows. +- Define the local control plane, runtime identity, privilege boundaries, and + daemon-unavailable behavior as product contracts. +- Define Caddy integration as an adapter boundary: Cadder owns the generated + runtime model, and real Caddy is the execution target. +- Define optional platform integrations behind narrow, testable providers. +- Treat logs as a first-class operator workflow across runtime, project, domain, + source, and severity dimensions. +- Require future operator surfaces to reuse the same daemon protocol and view + models instead of creating another state model. +- Replace ad hoc planning with OpenSpec requirements, design notes, and + implementation tasks. +- Reconcile workspace crates and tooling around small, independently testable + modules and mature external tools where they fit. + +## Capabilities + +### New Capabilities + +- `runtime-topology`: product surfaces, package boundaries, runtime ownership, + future-surface constraints, and production/dev runtime identity. +- `daemon-control-plane`: daemon lifecycle, local IPC, state ownership, + protocol compatibility, privilege boundaries, and offline handling. +- `caddy-shim-integration`: shim command policy, registration lifecycle, config + composition, real Caddy resolution, fallback policy, and drift detection. +- `windows-iis-handoff`: optional IIS discovery, handoff, restore, privilege + boundaries, provider abstraction, and system smoke coverage. +- `observability-logs`: structured log capture, retention, redaction, and query + semantics by runtime, project, domain, source, and severity. +- `operator-clients`: CLI/TUI contracts, offline UX, daemon start/setup flows, + reusable view models, and future client boundaries. +- `quality-tooling`: project workflow, code organization, dependency selection, + test strategy, coverage, CI, release, and documentation expectations. + +### Modified Capabilities + +- None. This repository has no existing OpenSpec main specs yet. + +## Impact + +- Planning: + - `openspec/` becomes the canonical place for requirements, design decisions, + and implementation plans. +- Runtime architecture: + - The daemon owns Cadder-managed runtime state. + - Shims and operator clients are protocol clients. + - Platform-specific behavior is explicit and isolated behind provider seams. +- Rust workspace: + - Product crates, shared protocol/API code, test support, and tooling must be + classified and simplified against the target topology. +- Testing: + - Daemon logic, shim policy, protocol contracts, operator rendering, platform + providers, log filtering, and system smoke behavior must be independently + testable. +- Tooling: + - Large custom orchestration should shrink or move to mature tools when those + tools cover the job clearly. diff --git a/openspec/changes/reset-cadder-architecture/specs/caddy-shim-integration/spec.md b/openspec/changes/reset-cadder-architecture/specs/caddy-shim-integration/spec.md new file mode 100644 index 0000000..ff3af86 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/caddy-shim-integration/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: Shim command policy is explicit +The `caddy` shim SHALL classify every supported Caddy command path as managed, +read-only inspection, explicit passthrough, or unsupported. + +#### Scenario: Managed registration command +- **WHEN** a command provides a Caddyfile, config, or project definition that Cadder manages +- **THEN** the shim SHALL send the definition to `cadderd` +- **AND** it SHALL NOT start a separate real Caddy process + +#### Scenario: Unsupported command +- **WHEN** a user invokes a Caddy command that has no policy entry +- **THEN** the shim SHALL return a clear unsupported-command diagnostic +- **AND** it SHALL NOT silently pass the command to real Caddy + +### Requirement: Shim handles daemon unavailable states +The shim SHALL give useful diagnostics when `cadderd` is unavailable and SHALL +avoid state drift during fallback behavior. + +#### Scenario: Managed command while daemon is down +- **WHEN** the shim receives a managed command and cannot contact `cadderd` +- **THEN** it SHALL explain that Cadder cannot update managed runtime state +- **AND** it SHALL suggest the supported `cadder daemon start` recovery path + +#### Scenario: Read-only command while daemon is down +- **WHEN** the shim receives a read-only inspection command and cannot contact `cadderd` +- **THEN** it MAY call real Caddy only if the command cannot mutate Cadder-managed state +- **AND** it SHALL label the result as real-Caddy inspection rather than Cadder runtime state + +### Requirement: Shim prevents recursive real-Caddy execution +The shim SHALL resolve real Caddy without recursively executing itself. + +#### Scenario: Real Caddy command configured +- **WHEN** `CADDER_CADDY_REAL_COMMAND` is set +- **THEN** the shim SHALL use that command as the first real-Caddy candidate +- **AND** it SHALL reject candidates that resolve to the shim binary + +#### Scenario: PATH contains shim before real Caddy +- **WHEN** PATH lookup finds the shim binary before real Caddy +- **THEN** the shim SHALL skip itself +- **AND** it SHALL continue searching or return a clear real-Caddy-not-found diagnostic + +### Requirement: Cadder owns effective Caddy config composition +`cadderd` SHALL compose the effective Caddy configuration from Cadder runtime +state and SHALL apply it atomically to real Caddy. + +#### Scenario: Multiple projects register definitions +- **WHEN** multiple projects register Caddy definitions +- **THEN** `cadderd` SHALL merge them into one effective runtime model +- **AND** it SHALL apply the resulting Caddy config as a single coherent update + +#### Scenario: Caddy rejects generated config +- **WHEN** real Caddy rejects a generated config update +- **THEN** `cadderd` SHALL keep or restore the last known good runtime state +- **AND** it SHALL report the validation error to clients and logs + +### Requirement: Direct Caddy Admin API drift is detectable +Cadder SHALL treat Caddy Admin API state as adapter state and SHALL detect or +repair drift when possible. + +#### Scenario: Caddy config changes outside Cadder +- **WHEN** real Caddy active config no longer matches Cadder's last applied config identity +- **THEN** `cadderd` SHALL mark runtime state as drifted +- **AND** it SHALL offer a controlled reapply or reconcile action diff --git a/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md b/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md new file mode 100644 index 0000000..0bf059c --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: Daemon owns external state +`cadderd` SHALL be the only Cadder component that owns durable runtime state, +generated Caddy config, Caddy process lifecycle, and OS integration state. + +#### Scenario: Applying project definitions +- **WHEN** a project definition changes through the shim or operator client +- **THEN** `cadderd` SHALL update Cadder state first +- **AND** `cadderd` SHALL regenerate and apply the effective Caddy config from that state + +#### Scenario: External Caddy process exists +- **WHEN** an unrelated Caddy process is already running +- **THEN** `cadderd` SHALL NOT enumerate, terminate, or mutate that process +- **AND** diagnostics SHALL distinguish unmanaged Caddy from Cadder-owned Caddy + +### Requirement: Local IPC is stable and version tolerant +The daemon control plane SHALL use a local protocol with typed envelopes, +request/response messages, events, and errors that tolerate additive compatible +changes. + +#### Scenario: Older compatible client sends a request +- **WHEN** an older compatible client sends a request with known required fields +- **THEN** `cadderd` SHALL process the request even if newer optional fields exist in the current schema +- **AND** the response SHALL include capability information needed by the client + +#### Scenario: Incompatible client sends a request +- **WHEN** a client uses an unsupported protocol or required capability +- **THEN** `cadderd` SHALL reject the request with a typed compatibility error +- **AND** the error SHALL include upgrade or downgrade guidance where possible + +### Requirement: Client access to privileged runtime is explicit +When the daemon or helper runs with higher privilege than the caller, user-level +clients SHALL discover and contact it through a documented local IPC security +policy without requiring the whole client process to run with elevated +privileges. + +#### Scenario: User client contacts privileged runtime +- **WHEN** a same-user non-elevated `cadder` client starts while the runtime endpoint has higher privileges +- **THEN** the client SHALL discover the supported endpoint +- **AND** the local IPC policy SHALL allow only the documented principals and operations + +#### Scenario: Unauthorized user contacts daemon +- **WHEN** a different unauthorized user tries to contact the daemon endpoint +- **THEN** the IPC layer SHALL deny access before state-changing commands are executed +- **AND** the daemon SHALL log the denied attempt without leaking secrets + +#### Scenario: IPC policy is tested without OS mutation +- **WHEN** the IPC security policy is tested +- **THEN** allowed and denied principals SHALL be covered without requiring real platform mutation in every test +- **AND** platform smoke tests SHALL cover the real transport behavior separately + +### Requirement: Daemon lifecycle is user friendly +CLI, TUI, and shim flows SHALL detect daemon-unavailable states and return +actionable recovery guidance instead of opaque connection failures. + +#### Scenario: State-changing command while daemon is down +- **WHEN** a user runs a state-changing `cadder` command and no daemon is reachable +- **THEN** the client SHALL explain that `cadderd` is not running +- **AND** it SHALL offer the supported start command or TUI action + +#### Scenario: Read-only command while daemon is down +- **WHEN** a user runs a read-only command and no daemon is reachable +- **THEN** the client SHALL show daemon-unavailable status +- **AND** it MAY show local setup information or last-known cached state when available + +### Requirement: Runtime locking has stale recovery +Production daemon startup SHALL use a lock mechanism with explicit ownership, +diagnostics, and stale-lock recovery. + +#### Scenario: Stale lock after crash +- **WHEN** a daemon lock exists but the recorded owner is no longer alive +- **THEN** `cadderd` SHALL recover or replace the stale lock through a documented process +- **AND** it SHALL record the recovery in daemon logs + +#### Scenario: Active incompatible runtime +- **WHEN** a lock is owned by an active incompatible runtime +- **THEN** `cadderd` SHALL refuse to start a competing production runtime +- **AND** it SHALL report the active runtime identity and recovery options diff --git a/openspec/changes/reset-cadder-architecture/specs/observability-logs/spec.md b/openspec/changes/reset-cadder-architecture/specs/observability-logs/spec.md new file mode 100644 index 0000000..42de307 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/observability-logs/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Logs are structured and labeled +`cadderd` SHALL capture daemon, shim, and real Caddy logs into a structured log +model with timestamp, source, severity, runtime, project, domain, and redaction +metadata where applicable. + +#### Scenario: Caddy emits an access log +- **WHEN** real Caddy emits an access log for a Cadder-managed route +- **THEN** `cadderd` SHALL store or stream the log with project and domain labels +- **AND** clients SHALL be able to identify it as Caddy-sourced + +#### Scenario: Shim emits a diagnostic +- **WHEN** the shim rejects a command or cannot contact the daemon +- **THEN** the diagnostic SHALL be available in the structured log stream when the daemon can receive it +- **AND** secrets SHALL be redacted before persistence + +### Requirement: Logs can be queried by operator dimensions +Operator clients SHALL be able to query and tail logs by all logs, runtime, +project, domain, source, and severity. + +#### Scenario: User tails all logs at debug level +- **WHEN** a user requests all logs with debug-level visibility +- **THEN** the client SHALL receive daemon, shim, and Caddy logs allowed by the retention policy +- **AND** the daemon SHALL apply one canonical severity filter + +#### Scenario: User tails logs for one domain +- **WHEN** a user requests logs for a specific domain +- **THEN** the daemon SHALL return only log records mapped to that domain +- **AND** the result SHALL be correct even when one Caddy site contains multiple domains + +### Requirement: Log filtering semantics are canonical +The daemon SHALL own severity and dimension filtering semantics so every client +surface displays equivalent results for the same query. + +#### Scenario: CLI and TUI run equivalent queries +- **WHEN** CLI and TUI request the same project logs at the same severity +- **THEN** both clients SHALL receive equivalent records in the same canonical order +- **AND** differences SHALL be limited to presentation + +#### Scenario: Invalid log level requested +- **WHEN** a client requests an unsupported log level +- **THEN** the daemon SHALL reject the query with a typed validation error +- **AND** the client SHALL display supported levels + +### Requirement: Log retention is bounded and configurable +Cadder SHALL bound local log retention and SHALL expose retention status to +operator clients. + +#### Scenario: Retention limit reached +- **WHEN** retained logs exceed the configured storage policy +- **THEN** `cadderd` SHALL prune or rotate logs according to the policy +- **AND** it SHALL preserve recent records needed for diagnostics diff --git a/openspec/changes/reset-cadder-architecture/specs/operator-clients/spec.md b/openspec/changes/reset-cadder-architecture/specs/operator-clients/spec.md new file mode 100644 index 0000000..a65a239 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/operator-clients/spec.md @@ -0,0 +1,66 @@ +## ADDED Requirements + +### Requirement: `cadder` exposes CLI and TUI contact surfaces +The `cadder` executable SHALL provide CLI and TUI workflows for inspecting, +starting, configuring, and managing Cadder through the daemon protocol. + +#### Scenario: CLI command reads daemon state +- **WHEN** a user runs a CLI status command +- **THEN** `cadder` SHALL request state through the daemon protocol +- **AND** it SHALL render a deterministic, script-friendly result when requested + +#### Scenario: TUI opens runtime overview +- **WHEN** a user opens the TUI +- **THEN** the TUI SHALL render runtime status, project/domain state, logs access, and available actions from a view model +- **AND** the TUI SHALL NOT depend on a real daemon for rendering tests + +### Requirement: Clients remain useful while daemon is down +CLI and TUI SHALL handle daemon-down states as a normal product state. + +#### Scenario: CLI state change while daemon is down +- **WHEN** a user runs a state-changing CLI command and no daemon is reachable +- **THEN** the command SHALL fail with an actionable daemon-unavailable message +- **AND** it SHALL include the supported start command + +#### Scenario: TUI opens while daemon is down +- **WHEN** a user opens the TUI and no daemon is reachable +- **THEN** the TUI SHALL show offline status and setup diagnostics +- **AND** it SHALL offer a visible start action instead of exiting immediately + +### Requirement: Client view models are mockable +Operator clients SHALL render from reusable view models supplied by a mockable +client service boundary. + +#### Scenario: TUI rendering test uses fixture state +- **WHEN** a TUI test supplies fixture runtime, domain, platform-integration, and log state +- **THEN** the TUI SHALL render the expected view through a test backend +- **AND** no real daemon, real Caddy, or real IIS dependency SHALL be required + +#### Scenario: CLI snapshot test uses fake daemon relation +- **WHEN** a CLI test supplies a fake daemon client result +- **THEN** the CLI SHALL render stable output suitable for snapshot or golden-file verification +- **AND** the test SHALL not open real IPC + +### Requirement: Operator clients expose log workflows +CLI and TUI SHALL support log inspection for all logs, one project, one domain, +and selectable severity levels. + +#### Scenario: CLI tails project logs +- **WHEN** a user tails logs for one project at info level +- **THEN** `cadder` SHALL request the corresponding daemon log stream +- **AND** it SHALL render only records matching the daemon's canonical filter + +#### Scenario: TUI filters domain logs +- **WHEN** a user selects a domain and changes severity in the TUI +- **THEN** the TUI SHALL update the log query through the client service +- **AND** it SHALL preserve the surrounding runtime context + +### Requirement: Client invocation model is documented +The project SHALL document whether TUI is launched through an explicit +subcommand, automatic TTY detection, or both before implementation depends on +that behavior. + +#### Scenario: User asks for interactive mode +- **WHEN** a user invokes the documented TUI entrypoint +- **THEN** `cadder` SHALL start the TUI or explain why the terminal does not support it +- **AND** non-interactive CLI behavior SHALL remain predictable for automation diff --git a/openspec/changes/reset-cadder-architecture/specs/quality-tooling/spec.md b/openspec/changes/reset-cadder-architecture/specs/quality-tooling/spec.md new file mode 100644 index 0000000..022c20f --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/quality-tooling/spec.md @@ -0,0 +1,89 @@ +## ADDED Requirements + +### Requirement: OpenSpec is the planning source of truth +The repository SHALL use OpenSpec for product requirements, design decisions, +and implementation planning. + +#### Scenario: New architecture work is proposed +- **WHEN** new architecture or product behavior work is proposed +- **THEN** the work SHALL be captured as an OpenSpec change +- **AND** the change SHALL define affected capabilities before implementation + +#### Scenario: Project documentation conflicts with OpenSpec +- **WHEN** project documentation conflicts with accepted OpenSpec requirements +- **THEN** OpenSpec SHALL be treated as authoritative +- **AND** the documentation SHALL be updated or explicitly marked stale + +### Requirement: Release surface follows accepted specs +Release artifacts, docs, and validation SHALL include only product surfaces +defined by accepted OpenSpec requirements or an active approved change. + +#### Scenario: Release artifact list generated +- **WHEN** release metadata is generated +- **THEN** every shipped artifact SHALL map to a documented product surface +- **AND** undocumented artifacts SHALL fail validation or require an explicit OpenSpec change + +#### Scenario: Documentation advertises a workflow +- **WHEN** user documentation advertises a workflow +- **THEN** that workflow SHALL map to an accepted requirement or active change +- **AND** stale workflows SHALL be removed or moved to historical notes + +### Requirement: Files stay small and cohesive +Production source files SHALL stay focused on one concern and SHOULD remain +under the project file-size threshold unless an explicit exception is documented. + +#### Scenario: File exceeds threshold +- **WHEN** a source file exceeds the configured line threshold +- **THEN** the change SHALL either split the file by concern or document a narrow exception +- **AND** validation SHALL report the exception visibly + +#### Scenario: Generated file exceeds threshold +- **WHEN** a generated file exceeds the threshold +- **THEN** the file SHALL be marked as generated or excluded by policy +- **AND** reviewers SHALL not treat it as a precedent for hand-written modules + +### Requirement: Mature tools are preferred over custom orchestration +The project SHALL prefer proven external tools for testing, coverage, release, +documentation, and command orchestration when they satisfy Cadder's needs. + +#### Scenario: Tooling task duplicates external tool +- **WHEN** a custom tooling feature duplicates a mature external tool +- **THEN** the change SHALL either replace it or justify why Cadder-specific logic is required +- **AND** the resulting implementation SHALL remain small and reviewable + +#### Scenario: Dependency proposed +- **WHEN** a new dependency is proposed for tooling or runtime code +- **THEN** the task SHALL state the job it performs, why it is preferable to custom code, and how it will be tested + +### Requirement: Test strategy covers independent seams +Cadder SHALL test domain logic, external adapters, protocol contracts, client +rendering, platform providers, and log filtering independently before relying on +system smoke tests. + +#### Scenario: Runtime logic tested without external process +- **WHEN** runtime registration, config composition, or lifecycle logic is tested +- **THEN** the test SHALL use fake adapters unless the test is explicitly marked as integration or smoke coverage +- **AND** failure paths SHALL be covered with typed errors + +#### Scenario: Client rendering tested without daemon +- **WHEN** client screens or output modes are tested +- **THEN** tests SHALL use mock view models or fake client responses +- **AND** no real IPC SHALL be required + +#### Scenario: System behavior needs OS integration +- **WHEN** a behavior depends on OS services, process installation, path resolution, or platform-specific permissions +- **THEN** a system smoke test SHALL cover that behavior +- **AND** unit tests SHALL still cover the decision logic separately + +### Requirement: Coverage and CI are explicit +The project SHALL keep automated coverage and CI expectations visible in +OpenSpec and project documentation. + +#### Scenario: Full validation runs in CI +- **WHEN** CI validates Cadder +- **THEN** it SHALL run the formatting, linting, test, documentation, release metadata, and coverage checks required by the current specs +- **AND** failures SHALL identify which project contract was violated where practical + +#### Scenario: Local verification is skipped +- **WHEN** a change cannot run part of the expected verification locally +- **THEN** the final change notes SHALL state what was skipped and why diff --git a/openspec/changes/reset-cadder-architecture/specs/runtime-topology/spec.md b/openspec/changes/reset-cadder-architecture/specs/runtime-topology/spec.md new file mode 100644 index 0000000..f767577 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/runtime-topology/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Daemon-centered product topology +Cadder SHALL be organized around `cadderd` as the runtime owner, `caddy` as the +PATH-facing shim, and `cadder` as the CLI/TUI operator client. + +#### Scenario: Normal runtime ownership +- **WHEN** a project invokes the `caddy` shim to register or update a Caddy definition +- **THEN** the shim SHALL send the request to `cadderd` instead of starting an unmanaged Caddy process +- **AND** `cadderd` SHALL own the resulting runtime state and real Caddy process coordination + +#### Scenario: Operator client inspection +- **WHEN** a user opens `cadder` CLI or TUI +- **THEN** the client SHALL read daemon state through the shared Cadder protocol +- **AND** the client SHALL NOT inspect daemon storage or Caddy Admin API state directly + +### Requirement: Future operator surfaces use the same client boundary +Future operator surfaces SHALL be clients of the same daemon protocol and +reusable view-model contracts used by the initial operator clients. + +#### Scenario: Adding a future operator surface +- **WHEN** a future change introduces another operator surface +- **THEN** it SHALL attach to `cadderd` through the supported local protocol +- **AND** it SHALL NOT create an independent runtime state model or Caddy control plane + +### Requirement: Workspace boundaries are minimal and explainable +The Rust workspace SHALL keep product crates and library crates aligned with the +target topology and SHALL require each crate to have a documented, testable +responsibility. + +#### Scenario: Contributor evaluates a crate +- **WHEN** a crate is reviewed during the reset +- **THEN** it SHALL be classified as daemon, shim, operator client, shared protocol/API, test support, documentation/tooling, or obsolete +- **AND** crates without a current responsibility SHALL be removed, merged, or explicitly justified + +#### Scenario: New crate proposed +- **WHEN** a new crate is proposed +- **THEN** the proposal SHALL explain why a module inside an existing crate is insufficient +- **AND** the crate SHALL have independently testable responsibilities + +### Requirement: Production runtime identity is singular +Production Cadder SHALL run one stable runtime identity per user or per explicit +system installation. Multiple Cadder runtimes SHALL be limited to dev/debug +profiles with separate runtime directories and visible labels. + +#### Scenario: Production daemon already running +- **WHEN** a production `cadderd` instance starts and a compatible production runtime already owns the lock +- **THEN** the new process SHALL attach, report the existing runtime, or exit with a clear diagnostic +- **AND** it SHALL NOT create a second production runtime silently + +#### Scenario: Developer starts isolated runtime +- **WHEN** a developer starts Cadder with an explicit dev/debug runtime profile +- **THEN** Cadder SHALL use an isolated runtime directory and lock identity +- **AND** clients and logs SHALL display the active runtime profile diff --git a/openspec/changes/reset-cadder-architecture/specs/windows-iis-handoff/spec.md b/openspec/changes/reset-cadder-architecture/specs/windows-iis-handoff/spec.md new file mode 100644 index 0000000..75e060c --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/windows-iis-handoff/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: IIS handoff is optional and explicit +Windows IIS handoff SHALL be disabled unless the user explicitly enables, +previews, or applies it. + +#### Scenario: Normal user-level setup +- **WHEN** a user installs or starts Cadder without enabling IIS handoff +- **THEN** Cadder SHALL operate without admin elevation +- **AND** it SHALL NOT mutate IIS bindings + +#### Scenario: User requests IIS handoff +- **WHEN** a user asks Cadder to enable IIS handoff +- **THEN** Cadder SHALL show the required changes and privilege requirement before applying them +- **AND** the user SHALL be able to cancel without mutating IIS + +### Requirement: IIS operations use a platform provider seam +IIS discovery, handoff, restore, and status checks SHALL be implemented behind a +mockable platform provider boundary. + +#### Scenario: Daemon unit test uses fake IIS provider +- **WHEN** daemon logic is tested with a fake IIS provider +- **THEN** tests SHALL cover discovery, apply, restore, and failure paths without real IIS +- **AND** the same daemon logic SHALL be used with the real provider in production + +#### Scenario: IIS provider command fails +- **WHEN** the platform provider fails to apply or restore IIS state +- **THEN** Cadder SHALL return a typed error with recoverable context +- **AND** it SHALL preserve enough state to retry or restore safely + +### Requirement: Elevation is scoped +Cadder SHALL require elevation only for operations that need it, such as IIS +binding mutation or an explicitly elevated daemon mode. + +#### Scenario: Non-admin user views IIS status +- **WHEN** a non-admin user views IIS handoff status +- **THEN** Cadder SHALL show what can be discovered at user privilege +- **AND** it SHALL identify actions that require elevation + +#### Scenario: Elevated operation requested +- **WHEN** an operation requires admin rights +- **THEN** Cadder SHALL use the documented elevated helper or elevated daemon flow +- **AND** it SHALL return control to the user-level client with a clear result + +### Requirement: Real IIS behavior has system smoke coverage +IIS handoff SHALL have focused system smoke coverage for behavior that cannot be +validated through fake providers. + +#### Scenario: Sandbox smoke checks IIS handoff +- **WHEN** the Windows Sandbox smoke harness runs an IIS handoff and restore cycle +- **THEN** Cadder SHALL apply the handoff, preserve restore metadata, restore the binding, and leave no stale handoff state +- **AND** unsupported IIS environments SHALL produce a clear unavailable result diff --git a/openspec/changes/reset-cadder-architecture/tasks.md b/openspec/changes/reset-cadder-architecture/tasks.md new file mode 100644 index 0000000..f7d160d --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/tasks.md @@ -0,0 +1,66 @@ +## 1. Planning Migration + +- [ ] 1.1 Keep `openspec/` initialized and document it as the repository planning source of truth. +- [ ] 1.2 Preserve relevant architecture rationale in OpenSpec proposal, design, and specs. +- [ ] 1.3 Remove obsolete planning artifacts and workflow references from project-facing docs. +- [ ] 1.4 Remove obsolete product-surface assumptions from repo instructions, docs, release metadata, and validation tasks. +- [ ] 1.5 Update `docs/ARCHITECTURE.md` so it follows this OpenSpec change or explicitly points to OpenSpec as authoritative. + +## 2. Workspace Topology + +- [ ] 2.1 Audit every workspace crate and classify it as daemon, shim, operator client, shared protocol/API, test support, docs/tooling, or obsolete. +- [ ] 2.2 Remove, merge, or justify crates that do not map to a current product, library, test, docs, or tooling responsibility. +- [ ] 2.3 Decide whether `cadder-operator` remains as an internal client library or is renamed/merged into the final `cadder` boundary. +- [ ] 2.4 Fix workspace membership and dependency warnings after topology cleanup. +- [ ] 2.5 Add validation that prevents reintroducing undocumented product crates. + +## 3. Protocol And Daemon + +- [ ] 3.1 Split protocol code into envelopes, commands, responses, events, errors, log queries, and client traits. +- [ ] 3.2 Replace hard protocol equality checks with additive compatibility and typed unsupported-capability errors. +- [ ] 3.3 Extract daemon state concerns into focused modules for registrations, config composition, process lifecycle, runtime status, logs, and storage. +- [ ] 3.4 Define and implement production runtime locking with stale-lock recovery. +- [ ] 3.5 Define local IPC discovery and security for user clients contacting privileged runtime endpoints. +- [ ] 3.6 Add daemon contract tests with fake Caddy, fake storage, fake IPC clients, and fake IIS providers. + +## 4. Shim And Caddy Integration + +- [ ] 4.1 Write the shim command policy table for managed, read-only, passthrough, and unsupported Caddy commands. +- [ ] 4.2 Implement managed shim commands as requests to `cadderd` without starting unmanaged Caddy. +- [ ] 4.3 Implement daemon-unavailable diagnostics and recovery guidance for shim commands. +- [ ] 4.4 Harden real Caddy resolution against recursive shim execution. +- [ ] 4.5 Implement config composition and atomic apply behavior with last-known-good recovery. +- [ ] 4.6 Add shim tests for command policy, no-daemon behavior, fallback behavior, and recursion prevention. + +## 5. Operator Clients + +- [ ] 5.1 Decide and document the `cadder` invocation model for CLI and TUI. +- [ ] 5.2 Build a mockable operator client service boundary and reusable view models. +- [ ] 5.3 Implement CLI unavailable-daemon behavior with actionable start guidance. +- [ ] 5.4 Implement TUI unavailable-daemon behavior with a visible start action. +- [ ] 5.5 Add CLI tests with fake daemon responses and stable output snapshots. +- [ ] 5.6 Add TUI rendering and interaction tests with Ratatui test backend fixtures. + +## 6. Logs + +- [ ] 6.1 Define the structured log record schema and redaction policy. +- [ ] 6.2 Implement daemon-side log query and tail semantics by all logs, runtime, project, domain, source, and severity. +- [ ] 6.3 Make per-domain log mapping accurate for multi-domain Caddy sites. +- [ ] 6.4 Expose equivalent log workflows in CLI and TUI. +- [ ] 6.5 Add tests proving severity filtering equivalence across CLI and TUI. + +## 7. Windows And IIS + +- [ ] 7.1 Extract IIS discovery, handoff, restore, and status into a mockable provider boundary. +- [ ] 7.2 Choose and implement the scoped elevation model for IIS operations. +- [ ] 7.3 Keep normal Cadder usage fully user-level when IIS handoff is not enabled. +- [ ] 7.4 Add local tests for IIS policy and fake-provider failure recovery. +- [ ] 7.5 Add system smoke coverage for IIS handoff, privileged runtime contact, autostart, and shim PATH behavior. + +## 8. Tooling, CI, And Release + +- [ ] 8.1 Decide which custom tooling responsibilities move to mature external tools and which remain project-specific. +- [ ] 8.2 Shrink `xtask` into small modules or a compatibility wrapper with tests. +- [ ] 8.3 Add or update CI jobs for formatting, linting, tests, coverage, docs, Windows Sandbox documentation, and release metadata. +- [ ] 8.4 Add file-size/cohesion validation with documented exceptions for generated files. +- [ ] 8.5 Verify the docs site builds and advertises only workflows covered by accepted specs or active changes. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..0a8f2c6 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,39 @@ +schema: spec-driven + +context: | + Cadder is a Rust workspace for coordinating Caddy-backed local development + runtimes. OpenSpec is the canonical source for product requirements, design + decisions, and implementation plans. + + The codebase should favor small modules, explicit ownership, independently + testable boundaries, and clear contracts between runtime, integration, and + presentation layers. Prefer proven Rust crates and established tooling over + custom infrastructure when they fit the job. + + Cadder is cross-platform by default. Platform-specific behavior must live + behind narrow abstractions and make permissions, lifecycle differences, and + unsupported states explicit. + + Project-facing text, docs, source comments, and commit messages must be in + English. + +rules: + proposal: + - Describe the problem, user value, scope, non-goals, and success criteria before proposing implementation details. + - Keep each proposal focused on one coherent change; split unrelated goals into separate changes. + - Distinguish confirmed facts from assumptions, and cite current specs or code evidence when scope depends on them. + design: + - Separate product surfaces from implementation crates and library modules. + - Record ownership, permissions, failure modes, and trust boundaries for every integration or privilege boundary. + - Prefer mature external tools and libraries over custom orchestration when they fit the job. + - Define test seams, fakes, and verification strategy before describing implementation details. + specs: + - Use the OpenSpec delta format correctly: ADDED, MODIFIED, REMOVED, or RENAMED Requirements as appropriate. + - For MODIFIED requirements, include the full updated requirement block, not a partial patch. + - Write normative SHALL/MUST requirements with observable behavior and at least one scenario using the required heading level. + - Keep specs focused on what the system must do; put implementation choices in design unless they are part of the user-facing contract. + tasks: + - Break work into independently verifiable slices. + - Keep cleanup and implementation tasks separate when cleanup has migration risk. + - Order tasks by dependency and keep each task small enough to complete and verify in one focused session. + - Name the expected verification level for each task without prescribing a specific tool unless the design or spec requires it. From 1e19b0240c2949d306e50967d1cbff45f6886aae Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Wed, 8 Jul 2026 10:15:40 +0200 Subject: [PATCH 06/29] feat: implement Cadder architecture reset slice Remove obsolete Backlog and unsupported product-surface artifacts in favor of OpenSpec-driven planning. Reshape the workspace around the daemon, shim, cadder operator client, shared protocol, docs, and xtask validation. Add runtime locking, IPC discovery and security policy, storage/history, autostart, IIS handoff, release docs validation, and focused contract tests. --- .cargo/config.toml | 2 + .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/pull_request_template.md | 2 +- .github/workflows/ci.yml | 15 +- .github/workflows/docs.yml | 18 + .github/workflows/release.yml | 154 +- .gitignore | 1 - AGENTS.md | 22 +- CONTRIBUTING.md | 79 +- Cargo.lock | 616 ++- Cargo.toml | 19 +- README.md | 67 +- SECURITY.md | 24 +- assets/logo-icon.png | Bin 0 -> 969 bytes assets/logo-white.png | Bin 0 -> 50553 bytes .../archive/milestones/m-0 - release-1.1.md | 8 - .../archive/milestones/m-1 - release-1.0.md | 8 - backlog/config.yml | 16 - backlog/milestones/m-2 - v1.0.md | 8 - backlog/milestones/m-3 - v2.0.md | 8 - backlog/milestones/m-4 - v1.1.md | 8 - backlog/milestones/m-5 - v.0.8.1.md | 8 - ...ild-Cadder-persistent-Caddy-tray-daemon.md | 120 - ...adder-architecture-and-scaffold-the-app.md | 153 - .../task-1.10 - Build-per-domain-logs-UI.md | 141 - ...TH-installation-and-caddy.exe-shadowing.md | 164 - ...d-to-end-lifecycle-and-GUI-verification.md | 146 - ....13 - Expand-project-README-with-banner.md | 112 - ...ogo-asset-for-tray-and-executable-icons.md | 166 - ...tHub-CI-CD-for-app-and-installer-builds.md | 177 - ...uild-Astro-Starlight-documentation-site.md | 191 - ...- Define-and-enforce-test-coverage-gate.md | 145 - ...d-backend-mode-with-multiple-dashboards.md | 142 - ...ntainers-end-to-end-coverage-before-1.0.md | 160 - ...lement-single-instance-daemon-lifecycle.md | 137 - ...-settings-and-visual-clarity-before-1.0.md | 152 - ...S-domain-handoff-controls-to-cadder-tui.md | 165 - ...mentation-for-user-first-portable-usage.md | 138 - ...-cadderd-when-the-daemon-is-unavailable.md | 148 - ...operations-with-minimal-admin-prompting.md | 154 - ...ory-slug-to-lowercase-cadder-before-1.0.md | 50 - ...-for-daemon-only-and-daemontray-startup.md | 47 - ...rable-runtime-state-and-history-storage.md | 45 - ...CLI-for-inspecting-and-operating-Cadder.md | 144 - ...Cadder-through-an-MCP-server-for-agents.md | 229 - ...nt-the-caddy.exe-shim-registration-flow.md | 157 - ...epo-local-agent-skills-for-using-Cadder.md | 46 - ...-IPC-and-owner-aware-registration-store.md | 156 - ...-reload-Caddy-config-from-registrations.md | 143 - ... - Manage-real-Caddy-runtime-and-health.md | 143 - ...lement-per-domain-log-capture-and-query.md | 163 - .../task-1.8 - Build-Cadder-tray-popup-UI.md | 172 - ...adder-panel-overview-and-registry-pages.md | 165 - ...ersal-for-local-and-remote-environments.md | 34 - ...e-remote-daemon-pairing-and-trust-model.md | 38 - ...ure-remote-Caddy-through-paired-daemons.md | 41 - ...pose-selected-ports-to-hosted-processes.md | 40 - ...- Register-Cadder-routes-in-Pi-hole-DNS.md | 40 - ...- Build-remote-web-management-dashboard.md | 39 - ...al-tray-and-egui-desktop-management-app.md | 55 - ...-entities-across-TUI-web-and-desktop-UI.md | 52 - ...ent-for-local-and-paired-remote-domains.md | 40 - ...documentation-and-fix-README-docs-links.md | 38 - ...equired-IIS-applications-during-handoff.md | 147 - ...-IIS-restore-batch-file-in-use-failures.md | 55 - crates/cadder-daemon/Cargo.toml | 8 + crates/cadder-daemon/src/autostart.rs | 806 +++ crates/cadder-daemon/src/caddy.rs | 1544 +++++- crates/cadder-daemon/src/config.rs | 87 +- crates/cadder-daemon/src/iis.rs | 419 +- crates/cadder-daemon/src/ipc.rs | 1234 ++++- crates/cadder-daemon/src/ipc_security.rs | 586 ++ crates/cadder-daemon/src/lib.rs | 416 +- crates/cadder-daemon/src/paths.rs | 329 +- crates/cadder-daemon/src/privilege.rs | 157 + crates/cadder-daemon/src/runtime.rs | 704 ++- crates/cadder-daemon/src/runtime_lock.rs | 512 ++ crates/cadder-daemon/src/state.rs | 2220 +------- .../src/state/autostart_control.rs | 43 + .../cadder-daemon/src/state/config_apply.rs | 42 + crates/cadder-daemon/src/state/history.rs | 19 + crates/cadder-daemon/src/state/iis_handoff.rs | 831 +++ crates/cadder-daemon/src/state/lifecycle.rs | 74 + crates/cadder-daemon/src/state/log_queries.rs | 47 + .../cadder-daemon/src/state/registrations.rs | 353 ++ .../cadder-daemon/src/state/runtime_status.rs | 61 + crates/cadder-daemon/src/state/shutdown.rs | 29 + crates/cadder-daemon/src/state/tests.rs | 2042 +++++++ crates/cadder-daemon/src/storage.rs | 823 +++ crates/cadder-daemon/tests/caddy_runtime.rs | 401 ++ crates/cadder-daemon/tests/ipc_lifecycle.rs | 1466 ++++- crates/cadder-mcp/Cargo.toml | 21 - crates/cadder-mcp/src/dto.rs | 260 - crates/cadder-mcp/src/lib.rs | 616 --- crates/cadder-mcp/src/main.rs | 7 - crates/cadder-mcp/src/redaction.rs | 179 - crates/cadder-mcp/tests/mcp.rs | 171 - crates/cadder-mcp/tests/support/mod.rs | 227 - crates/cadder-operator/Cargo.toml | 3 + crates/cadder-operator/src/error.rs | 94 +- crates/cadder-operator/src/service.rs | 257 +- crates/cadder-operator/src/view.rs | 4 + crates/cadder-protocol/src/autostart.rs | 25 + crates/cadder-protocol/src/client.rs | 9 + crates/cadder-protocol/src/commands.rs | 88 + crates/cadder-protocol/src/envelopes.rs | 58 + crates/cadder-protocol/src/errors.rs | 309 ++ crates/cadder-protocol/src/events.rs | 21 + crates/cadder-protocol/src/history.rs | 37 + crates/cadder-protocol/src/iis.rs | 195 + crates/cadder-protocol/src/lib.rs | 1611 +++--- crates/cadder-protocol/src/logs.rs | 119 + crates/cadder-protocol/src/message_types.rs | 29 + crates/cadder-protocol/src/responses.rs | 66 + crates/cadder-protocol/src/state.rs | 256 + crates/cadder-shim/Cargo.toml | 3 + crates/cadder-shim/src/main.rs | 523 +- crates/cadder-shim/tests/shim_binary.rs | 150 + crates/cadder-tui/Cargo.toml | 18 - crates/cadder-tui/src/main.rs | 2923 ---------- crates/cadder-tui/src/model.rs | 1230 ----- crates/cadder/Cargo.toml | 14 + crates/cadder/src/app.rs | 325 ++ crates/cadder/src/data.rs | 356 ++ crates/cadder/src/logs/mod.rs | 98 + crates/cadder/src/main.rs | 199 + crates/cadder/src/mock_data.yaml | 46 + crates/cadder/src/mock_settings.yaml | 3 + crates/cadder/src/widgets/details_overlay.rs | 95 + crates/cadder/src/widgets/dim_background.rs | 13 + crates/cadder/src/widgets/header.rs | 75 + crates/cadder/src/widgets/mod.rs | 14 + crates/cadder/src/widgets/shortcuts.rs | 66 + crates/cadder/src/widgets/tabs.rs | 30 + .../cadder/src/widgets/tabs_content/body.rs | 212 + .../src/widgets/tabs_content/domains.rs | 26 + .../cadder/src/widgets/tabs_content/logs.rs | 38 + crates/cadder/src/widgets/tabs_content/mod.rs | 10 + .../src/widgets/tabs_content/settings.rs | 26 + crates/cadder/src/widgets/theme.rs | 198 + crates/cadderctl/Cargo.toml | 21 - crates/cadderctl/src/app.rs | 1231 ----- crates/cadderctl/src/cli.rs | 438 -- crates/cadderctl/src/error.rs | 5 - crates/cadderctl/src/lib.rs | 5 - crates/cadderctl/src/main.rs | 25 - crates/cadderctl/src/render.rs | 617 --- crates/cadderctl/src/view.rs | 20 - crates/cadderctl/tests/cli.rs | 433 -- crates/cadderctl/tests/support/mod.rs | 227 - crates/cadderd/Cargo.toml | 3 + crates/cadderd/src/main.rs | 81 +- crates/cadderd/tests/cadderd_binary.rs | 113 + docs/ARCHITECTURE.md | 156 +- .../0001-rescope-v1-daemon-shim-cli-tui.md | 49 + docs/site/README.md | 20 +- docs/site/astro.config.mjs | 7 +- docs/site/public/cadder-downloads.js | 53 +- docs/site/public/site.webmanifest | 20 +- .../site/src/content/docs/cookbooks/linux.mdx | 10 +- .../site/src/content/docs/cookbooks/macos.mdx | 8 +- .../content/docs/cookbooks/windows/iis.mdx | 83 +- .../docs/cookbooks/windows/overview.mdx | 27 +- docs/site/src/content/docs/index.mdx | 59 +- .../docs/quick-start/getting-started.mdx | 35 +- .../docs/reference/real-caddy-resolution.mdx | 18 +- .../src/content/docs/reference/releases.mdx | 37 + .../docs/reference/runtime-configuration.mdx | 66 +- .../content/docs/user-guide/cadder-mcp.mdx | 54 - .../src/content/docs/user-guide/cadder.mdx | 76 + .../src/content/docs/user-guide/cadderctl.mdx | 97 - .../content/docs/user-guide/how-to-use.mdx | 31 +- .../content/docs/user-guide/path-and-shim.mdx | 8 +- .../docs/user-guide/tui-diagnostics.mdx | 41 - docs/verification/release-profile.md | 81 + docs/verification/tui-smoke.md | 64 - docs/verification/windows-sandbox-smoke.md | 66 + .../reset-cadder-architecture/design.md | 71 +- .../specs/daemon-control-plane/spec.md | 46 +- .../reset-cadder-architecture/tasks.md | 30 +- xtask/Cargo.toml | 5 + xtask/src/main.rs | 4857 +++++++++++++++-- xtask/tests/cli.rs | 818 +++ 183 files changed, 24462 insertions(+), 18431 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 assets/logo-icon.png create mode 100644 assets/logo-white.png delete mode 100644 backlog/archive/milestones/m-0 - release-1.1.md delete mode 100644 backlog/archive/milestones/m-1 - release-1.0.md delete mode 100644 backlog/config.yml delete mode 100644 backlog/milestones/m-2 - v1.0.md delete mode 100644 backlog/milestones/m-3 - v2.0.md delete mode 100644 backlog/milestones/m-4 - v1.1.md delete mode 100644 backlog/milestones/m-5 - v.0.8.1.md delete mode 100644 backlog/tasks/task-1 - Build-Cadder-persistent-Caddy-tray-daemon.md delete mode 100644 backlog/tasks/task-1.1 - Define-Cadder-architecture-and-scaffold-the-app.md delete mode 100644 backlog/tasks/task-1.10 - Build-per-domain-logs-UI.md delete mode 100644 backlog/tasks/task-1.11 - Package-PATH-installation-and-caddy.exe-shadowing.md delete mode 100644 backlog/tasks/task-1.12 - Add-end-to-end-lifecycle-and-GUI-verification.md delete mode 100644 backlog/tasks/task-1.13 - Expand-project-README-with-banner.md delete mode 100644 backlog/tasks/task-1.14 - Use-logo-asset-for-tray-and-executable-icons.md delete mode 100644 backlog/tasks/task-1.15 - Add-GitHub-CI-CD-for-app-and-installer-builds.md delete mode 100644 backlog/tasks/task-1.16 - Build-Astro-Starlight-documentation-site.md delete mode 100644 backlog/tasks/task-1.17 - Define-and-enforce-test-coverage-gate.md delete mode 100644 backlog/tasks/task-1.18 - Support-detached-backend-mode-with-multiple-dashboards.md delete mode 100644 backlog/tasks/task-1.19 - Add-Testcontainers-end-to-end-coverage-before-1.0.md delete mode 100644 backlog/tasks/task-1.2 - Implement-single-instance-daemon-lifecycle.md delete mode 100644 backlog/tasks/task-1.20 - Polish-cadder-tui-navigation-refresh-settings-and-visual-clarity-before-1.0.md delete mode 100644 backlog/tasks/task-1.21 - Add-Windows-IIS-domain-handoff-controls-to-cadder-tui.md delete mode 100644 backlog/tasks/task-1.22 - Polish-Cadder-documentation-for-user-first-portable-usage.md delete mode 100644 backlog/tasks/task-1.23 - Let-cadder-tui-recover-and-start-cadderd-when-the-daemon-is-unavailable.md delete mode 100644 backlog/tasks/task-1.24 - Support-mixed-elevation-operations-with-minimal-admin-prompting.md delete mode 100644 backlog/tasks/task-1.25 - Rename-repository-slug-to-lowercase-cadder-before-1.0.md delete mode 100644 backlog/tasks/task-1.26 - Add-OS-native-autostart-modes-for-daemon-only-and-daemontray-startup.md delete mode 100644 backlog/tasks/task-1.27 - Introduce-durable-runtime-state-and-history-storage.md delete mode 100644 backlog/tasks/task-1.28 - Add-non-TUI-CLI-for-inspecting-and-operating-Cadder.md delete mode 100644 backlog/tasks/task-1.29 - Expose-Cadder-through-an-MCP-server-for-agents.md delete mode 100644 backlog/tasks/task-1.3 - Implement-the-caddy.exe-shim-registration-flow.md delete mode 100644 backlog/tasks/task-1.30 - Add-repo-local-agent-skills-for-using-Cadder.md delete mode 100644 backlog/tasks/task-1.4 - Build-IPC-and-owner-aware-registration-store.md delete mode 100644 backlog/tasks/task-1.5 - Compose-and-reload-Caddy-config-from-registrations.md delete mode 100644 backlog/tasks/task-1.6 - Manage-real-Caddy-runtime-and-health.md delete mode 100644 backlog/tasks/task-1.7 - Implement-per-domain-log-capture-and-query.md delete mode 100644 backlog/tasks/task-1.8 - Build-Cadder-tray-popup-UI.md delete mode 100644 backlog/tasks/task-1.9 - Build-Cadder-panel-overview-and-registry-pages.md delete mode 100644 backlog/tasks/task-2 - Make-Cadder-universal-for-local-and-remote-environments.md delete mode 100644 backlog/tasks/task-2.1 - Define-remote-daemon-pairing-and-trust-model.md delete mode 100644 backlog/tasks/task-2.2 - Configure-remote-Caddy-through-paired-daemons.md delete mode 100644 backlog/tasks/task-2.3 - Negotiate-backend-ports-and-expose-selected-ports-to-hosted-processes.md delete mode 100644 backlog/tasks/task-2.4 - Register-Cadder-routes-in-Pi-hole-DNS.md delete mode 100644 backlog/tasks/task-2.5 - Build-remote-web-management-dashboard.md delete mode 100644 backlog/tasks/task-2.6 - Build-local-tray-and-egui-desktop-management-app.md delete mode 100644 backlog/tasks/task-2.7 - Aggregate-local-and-paired-remote-entities-across-TUI-web-and-desktop-UI.md delete mode 100644 backlog/tasks/task-2.8 - Add-quick-certificate-management-for-local-and-paired-remote-domains.md delete mode 100644 backlog/tasks/task-3 - Add-repository-link-to-documentation-and-fix-README-docs-links.md delete mode 100644 backlog/tasks/task-4 - Support-HTTPS-required-IIS-applications-during-handoff.md delete mode 100644 backlog/tasks/task-5 - Handle-transient-IIS-restore-batch-file-in-use-failures.md create mode 100644 crates/cadder-daemon/src/autostart.rs create mode 100644 crates/cadder-daemon/src/ipc_security.rs create mode 100644 crates/cadder-daemon/src/privilege.rs create mode 100644 crates/cadder-daemon/src/runtime_lock.rs create mode 100644 crates/cadder-daemon/src/state/autostart_control.rs create mode 100644 crates/cadder-daemon/src/state/config_apply.rs create mode 100644 crates/cadder-daemon/src/state/history.rs create mode 100644 crates/cadder-daemon/src/state/iis_handoff.rs create mode 100644 crates/cadder-daemon/src/state/lifecycle.rs create mode 100644 crates/cadder-daemon/src/state/log_queries.rs create mode 100644 crates/cadder-daemon/src/state/registrations.rs create mode 100644 crates/cadder-daemon/src/state/runtime_status.rs create mode 100644 crates/cadder-daemon/src/state/shutdown.rs create mode 100644 crates/cadder-daemon/src/state/tests.rs create mode 100644 crates/cadder-daemon/src/storage.rs create mode 100644 crates/cadder-daemon/tests/caddy_runtime.rs delete mode 100644 crates/cadder-mcp/Cargo.toml delete mode 100644 crates/cadder-mcp/src/dto.rs delete mode 100644 crates/cadder-mcp/src/lib.rs delete mode 100644 crates/cadder-mcp/src/main.rs delete mode 100644 crates/cadder-mcp/src/redaction.rs delete mode 100644 crates/cadder-mcp/tests/mcp.rs delete mode 100644 crates/cadder-mcp/tests/support/mod.rs create mode 100644 crates/cadder-protocol/src/autostart.rs create mode 100644 crates/cadder-protocol/src/client.rs create mode 100644 crates/cadder-protocol/src/commands.rs create mode 100644 crates/cadder-protocol/src/envelopes.rs create mode 100644 crates/cadder-protocol/src/errors.rs create mode 100644 crates/cadder-protocol/src/events.rs create mode 100644 crates/cadder-protocol/src/history.rs create mode 100644 crates/cadder-protocol/src/iis.rs create mode 100644 crates/cadder-protocol/src/logs.rs create mode 100644 crates/cadder-protocol/src/message_types.rs create mode 100644 crates/cadder-protocol/src/responses.rs create mode 100644 crates/cadder-protocol/src/state.rs create mode 100644 crates/cadder-shim/tests/shim_binary.rs delete mode 100644 crates/cadder-tui/Cargo.toml delete mode 100644 crates/cadder-tui/src/main.rs delete mode 100644 crates/cadder-tui/src/model.rs create mode 100644 crates/cadder/Cargo.toml create mode 100644 crates/cadder/src/app.rs create mode 100644 crates/cadder/src/data.rs create mode 100644 crates/cadder/src/logs/mod.rs create mode 100644 crates/cadder/src/main.rs create mode 100644 crates/cadder/src/mock_data.yaml create mode 100644 crates/cadder/src/mock_settings.yaml create mode 100644 crates/cadder/src/widgets/details_overlay.rs create mode 100644 crates/cadder/src/widgets/dim_background.rs create mode 100644 crates/cadder/src/widgets/header.rs create mode 100644 crates/cadder/src/widgets/mod.rs create mode 100644 crates/cadder/src/widgets/shortcuts.rs create mode 100644 crates/cadder/src/widgets/tabs.rs create mode 100644 crates/cadder/src/widgets/tabs_content/body.rs create mode 100644 crates/cadder/src/widgets/tabs_content/domains.rs create mode 100644 crates/cadder/src/widgets/tabs_content/logs.rs create mode 100644 crates/cadder/src/widgets/tabs_content/mod.rs create mode 100644 crates/cadder/src/widgets/tabs_content/settings.rs create mode 100644 crates/cadder/src/widgets/theme.rs delete mode 100644 crates/cadderctl/Cargo.toml delete mode 100644 crates/cadderctl/src/app.rs delete mode 100644 crates/cadderctl/src/cli.rs delete mode 100644 crates/cadderctl/src/error.rs delete mode 100644 crates/cadderctl/src/lib.rs delete mode 100644 crates/cadderctl/src/main.rs delete mode 100644 crates/cadderctl/src/render.rs delete mode 100644 crates/cadderctl/src/view.rs delete mode 100644 crates/cadderctl/tests/cli.rs delete mode 100644 crates/cadderctl/tests/support/mod.rs create mode 100644 crates/cadderd/tests/cadderd_binary.rs create mode 100644 docs/decisions/0001-rescope-v1-daemon-shim-cli-tui.md create mode 100644 docs/site/src/content/docs/reference/releases.mdx delete mode 100644 docs/site/src/content/docs/user-guide/cadder-mcp.mdx create mode 100644 docs/site/src/content/docs/user-guide/cadder.mdx delete mode 100644 docs/site/src/content/docs/user-guide/cadderctl.mdx delete mode 100644 docs/site/src/content/docs/user-guide/tui-diagnostics.mdx create mode 100644 docs/verification/release-profile.md delete mode 100644 docs/verification/tui-smoke.md create mode 100644 docs/verification/windows-sandbox-smoke.md create mode 100644 xtask/tests/cli.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5592118 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run -p xtask --" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index ec2fedb..20911a3 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Security reports - url: https://github.com/MrMaxie/Cadder/security + url: https://github.com/MrMaxie/cadder/security about: Please report suspected vulnerabilities privately. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 813ba53..3b0082e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,7 +7,7 @@ - [ ] `cargo fmt --check` - [ ] `cargo clippy --workspace --all-targets -- -D warnings` - [ ] `cargo test --workspace` -- [ ] `cargo run -p xtask -- check` +- [ ] `cargo xtask check` ## Notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02a5cb3..494478b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,11 @@ jobs: - name: Checkout uses: actions/checkout@v6.0.3 + - name: Set up Bun + uses: oven-sh/setup-bun@v2.2.0 + with: + bun-version: 1.3.8 + - name: Cache Cargo registry and target uses: actions/cache@v5.0.5 with: @@ -70,7 +75,7 @@ jobs: cargo-${{ runner.os }}- - name: Run project check - run: cargo run -p xtask -- check + run: cargo xtask check coverage: name: Coverage (Windows MSVC) @@ -99,14 +104,14 @@ jobs: run: cargo +stable-x86_64-pc-windows-msvc install cargo-llvm-cov --version 0.8.7 --locked - name: Run coverage gate - run: cargo +stable-x86_64-pc-windows-msvc run -p xtask -- coverage + run: cargo +stable-x86_64-pc-windows-msvc xtask coverage - - name: Upload coverage summary + - name: Upload coverage report if: always() uses: actions/upload-artifact@v7.0.1 with: - name: coverage-summary-windows-msvc - path: target/llvm-cov/coverage-summary.json + name: coverage-lcov-windows-msvc + path: target/llvm-cov/coverage-summary.lcov if-no-files-found: ignore retention-days: 14 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 108cf63..d7eaa96 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -44,6 +44,24 @@ jobs: restore-keys: | bun-${{ runner.os }}- + - name: Cache Cargo registry and target + uses: actions/cache@v5.0.5 + with: + path: | + ~/.cargo/git + ~/.cargo/registry + target + key: cargo-${{ runner.os }}-docs-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}-docs- + cargo-${{ runner.os }}- + + - name: Verify documentation assets + run: cargo xtask verify-assets + + - name: Verify release metadata + run: cargo xtask verify-release-identity + - name: Install dependencies working-directory: docs/site run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f268c06..21f41ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,7 +87,7 @@ jobs: run: cargo test -p cadder-daemon --features docker-e2e --test testcontainers_e2e -- --ignored --test-threads=1 package: - name: Package ${{ matrix.platform }} + name: Package portable ${{ matrix.platform }} needs: - release-gate - docker-e2e @@ -113,6 +113,11 @@ jobs: - name: Checkout uses: actions/checkout@v6.0.3 + - name: Set up Bun + uses: oven-sh/setup-bun@v2.2.0 + with: + bun-version: 1.3.8 + - name: Install Rust target run: rustup target add ${{ matrix.target }} @@ -128,12 +133,18 @@ jobs: cargo-${{ runner.os }}-${{ matrix.target }}- cargo-${{ runner.os }}- - - name: Package portable archive + - name: Verify release metadata policy + run: | + cargo xtask verify-release-profile + cargo xtask verify-assets + cargo xtask verify-release-identity + + - name: Package portable archives shell: bash env: RELEASE_VERSION: ${{ needs.release-gate.outputs.version }} run: | - cargo run -p xtask -- package \ + cargo xtask package \ --out artifacts \ --version "$RELEASE_VERSION" \ --platform "${{ matrix.platform }}" \ @@ -142,25 +153,154 @@ jobs: - name: Upload workflow artifact uses: actions/upload-artifact@v7.0.1 with: - name: cadder-${{ matrix.platform }} + name: cadder-portable-${{ matrix.platform }} path: | artifacts/*.zip artifacts/*.tar.gz artifacts/*.sha256 if-no-files-found: error + runtime-installer: + name: Package runtime installer ${{ matrix.platform }} + needs: + - release-gate + - docker-e2e + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + platform: windows-x64 + target: x86_64-pc-windows-msvc + - os: ubuntu-latest + platform: linux-x64 + target: x86_64-unknown-linux-gnu + - os: macos-15-intel + platform: macos-x64 + target: x86_64-apple-darwin + - os: macos-15 + platform: macos-arm64 + target: aarch64-apple-darwin + + steps: + - name: Checkout + uses: actions/checkout@v6.0.3 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2.2.0 + with: + bun-version: 1.3.8 + + - name: Install Rust target + run: rustup target add ${{ matrix.target }} + + - name: Install Windows installer tools + if: runner.os == 'Windows' + shell: bash + run: | + dotnet tool update --global wix --version 6.0.2 || dotnet tool install --global wix --version 6.0.2 + echo "$USERPROFILE\\.dotnet\\tools" >> "$GITHUB_PATH" + + - name: Install Linux installer tools + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y dpkg-dev rpm + + - name: Cache Cargo registry and target + uses: actions/cache@v5.0.5 + with: + path: | + ~/.cargo/git + ~/.cargo/registry + target + key: cargo-${{ runner.os }}-runtime-installer-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}-runtime-installer-${{ matrix.target }}- + cargo-${{ runner.os }}- + + - name: Verify release metadata policy + run: | + cargo xtask verify-release-profile + cargo xtask verify-assets + cargo xtask verify-release-identity + + - name: Build runtime installer artifacts + shell: bash + env: + RELEASE_VERSION: ${{ needs.release-gate.outputs.version }} + PUBLISH_RELEASE: ${{ needs.release-gate.outputs.publish }} + CADDER_WINDOWS_SIGNTOOL_CERT_PATH: ${{ secrets.CADDER_WINDOWS_SIGNTOOL_CERT_PATH }} + CADDER_WINDOWS_SIGNTOOL_CERT_PASSWORD: ${{ secrets.CADDER_WINDOWS_SIGNTOOL_CERT_PASSWORD }} + CADDER_WINDOWS_SIGNTOOL_TIMESTAMP_URL: ${{ secrets.CADDER_WINDOWS_SIGNTOOL_TIMESTAMP_URL }} + CADDER_MACOS_INSTALLER_SIGNING_IDENTITY: ${{ secrets.CADDER_MACOS_INSTALLER_SIGNING_IDENTITY }} + run: | + sign_args=() + if [[ "$PUBLISH_RELEASE" == "true" ]]; then + sign_args+=(--sign) + fi + cargo xtask runtime-installer \ + --out runtime-installer-artifacts \ + --version "$RELEASE_VERSION" \ + --platform "${{ matrix.platform }}" \ + --target "${{ matrix.target }}" \ + "${sign_args[@]}" + + - name: Upload runtime installer artifact + uses: actions/upload-artifact@v7.0.1 + with: + name: cadder-runtime-installer-${{ matrix.platform }} + path: runtime-installer-artifacts/* + if-no-files-found: error + + verify-release-assets: + name: Verify combined release assets + needs: + - release-gate + - package + - runtime-installer + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6.0.3 + + - name: Download packaged artifacts + uses: actions/download-artifact@v8.0.1 + with: + path: release-assets + merge-multiple: true + + - name: Verify release assets + shell: bash + env: + RELEASE_VERSION: ${{ needs.release-gate.outputs.version }} + PUBLISH_RELEASE: ${{ needs.release-gate.outputs.publish }} + CADDER_WINDOWS_SIGNING_READY: ${{ secrets.CADDER_WINDOWS_SIGNING_READY }} + CADDER_MACOS_NOTARIZATION_READY: ${{ secrets.CADDER_MACOS_NOTARIZATION_READY }} + run: | + mode="dry-run" + if [[ "$PUBLISH_RELEASE" == "true" ]]; then + mode="publish" + fi + cargo xtask verify-release-assets \ + --dir release-assets \ + --version "$RELEASE_VERSION" \ + --mode "$mode" + publish: name: Publish GitHub Release needs: - release-gate - - package + - verify-release-assets if: needs.release-gate.outputs.publish == 'true' runs-on: ubuntu-latest permissions: contents: write steps: - - name: Download packaged artifacts + - name: Download verified artifacts uses: actions/download-artifact@v8.0.1 with: path: release-assets @@ -174,5 +314,5 @@ jobs: gh release create "$GITHUB_REF_NAME" \ --repo "$GITHUB_REPOSITORY" \ --title "$GITHUB_REF_NAME" \ - --notes "Portable Cadder release $GITHUB_REF_NAME" + --notes "Cadder release $GITHUB_REF_NAME" gh release upload "$GITHUB_REF_NAME" release-assets/* --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.gitignore b/.gitignore index 3bced7c..c7cd270 100644 --- a/.gitignore +++ b/.gitignore @@ -43,5 +43,4 @@ $RECYCLE.BIN/ # Local operational workspace .agents/ -.local/ skills-lock.json diff --git a/AGENTS.md b/AGENTS.md index 41d69b1..c725deb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,13 +2,15 @@ ## Project -Cadder is a cross-platform Rust Caddy coordinator. It provides a per-user daemon (`cadderd`), a PATH-facing `caddy` shim, and a minimal Ratatui terminal UI (`cadder-tui`). Treat `docs/ARCHITECTURE.md` and Backlog.md tasks as the source of truth for process boundaries, task scope, and planned work. +Cadder is a cross-platform Rust Caddy coordinator. It provides a daemon (`cadderd`), a PATH-facing `caddy` shim, and the `cadder` operator executable for CLI and TUI workflows. Future Web and Tauri GUI surfaces must attach through the same daemon protocol and view-model contracts. Treat `openspec/` as the source of truth for target behavior, process boundaries, task scope, and planned work. ## Local Workspace Rules - If `.local` exists, keep `.local` listed in `.git/info/exclude`; do not add it to `.gitignore` unless explicitly requested. +- Keep `.agents` content local-only unless a task explicitly makes it part of the repository contract. - Read this file, applicable nested `AGENTS.md` files, and relevant `.local` workflow files before editing. -- Do not edit Backlog.md task markdown directly. Use Backlog MCP tools when available. +- Use OpenSpec change artifacts for architecture, requirements, design, and implementation plans. +- Do not reintroduce Backlog.md, Backlog task folders, or MCP workflow surfaces unless the user explicitly requests a new OpenSpec change for that reversal. - Keep project-facing text, source comments, docs, commits, and task notes in English. - Keep chat with the user in Polish unless they ask otherwise. @@ -18,9 +20,10 @@ Cadder is a cross-platform Rust Caddy coordinator. It provides a per-user daemon - `crates/cadder-daemon`: daemon state, local IPC, lockfiles, Caddy integration, runtime process management, and log storage. - `crates/cadderd`: daemon binary. - `crates/cadder-shim`: package that builds the PATH-facing `caddy` shim binary. -- `crates/cadder-tui`: Ratatui/Crossterm terminal UI. -- `xtask`: validation task runner. -- `docs/ARCHITECTURE.md`: durable architecture notes. +- `crates/cadder`: package that builds the `cadder` operator executable for CLI and TUI workflows. +- `openspec/`: canonical requirements, design, and task planning. +- `xtask`: current validation task runner; prefer shrinking it or replacing custom logic with mature tools as OpenSpec changes require. +- `docs/ARCHITECTURE.md`: architecture notes that must follow OpenSpec, not override it. ## Build And Validation @@ -30,15 +33,17 @@ Use Cargo from the repository root. cargo fmt --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace -cargo run -p xtask -- check +cargo xtask check ``` +- `cargo xtask` is the current task-runner entrypoint from `.cargo/config.toml`; use `cargo xtask --help` to discover existing validation, docs, release, verification, and dev commands until OpenSpec-driven tooling changes replace or shrink it. - Run focused tests after narrow edits, then run the full relevant validation before closeout or commit. - Use `cargo add`, `cargo remove`, or another Cargo command for dependency changes; do not hand-edit dependency entries. - Keep automated tests independent of a locally installed real Caddy unless explicitly marked ignored. Prefer fake Caddy fixtures for lifecycle/config/runtime tests. - Application have to be well tested (at least 85% coverage) and well documented (docs/ with Astro + Starlight theme, that allows to create nice documentation pages). - Application have to be building on CI/CD pipeline (GitHub Actions) and release artifacts have to be published to GitHub Releases. - Documentation have to be written in English, and be regenerated and published via Github Actions as well. +- `cargo xtask check` validates the daemon, shim, operator CLI/TUI, docs, release metadata, and asset contract. ## Engineering Notes @@ -47,11 +52,13 @@ cargo run -p xtask -- check - IPC is versioned newline-delimited JSON over a per-user local socket via `interprocess`. - The `caddy` shim must never recursively execute itself when Cadder needs real Caddy. Real Caddy resolution checks `CADDER_CADDY_REAL_COMMAND` before PATH. - The daemon owns only the real Caddy process it starts. It must not enumerate or kill unrelated Caddy processes. +- Normal operation must work at user privilege. Elevated behavior is limited to explicit IIS handoff or an explicitly elevated daemon, and user-level clients must retain controlled access to that daemon through documented local IPC policy. ## Skill Routing - Use `$rust-pro` for Rust production code, async, process management, ownership-heavy design, contracts, and runtime boundaries. -- Use `$backlogmd-task-*` skills for Backlog.md intake, execution, review, and closeout. +- Use OpenSpec workflows for intake, execution planning, review, and closeout. +- Use the `cadder` CLI for attach-first Cadder shell automation, explicit daemon lifecycle control, logs tail, watches, diagnostics, TUI launch, and exact CLI recovery flows. - Use `$commit-work` when staging or committing changes. - Use `$agents-md-maintainer` when updating agent instructions. - Use `$are-you-sure` after making any changes to code, for performing fresh-eyes self-review. @@ -59,7 +66,6 @@ cargo run -p xtask -- check - Use `$code-simplifier` after making any changes to code, to simplify the code for readability or performance. - Use `$handoff` when you are asked about handoff to other agents. - Use `$humanizer` when you are creating or updating user-facing text. -- Use `$ratatui-tui` for Rust production code in this application. - Use `$rust-async-patterns` for Rust production code in this application that uses async patterns. - Use `$rust-best-practices` for Rust production code in this application that follows best practices. - Use `$rust-profiling` for Rust production code in this application to make application more performant. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1600d7b..87efcba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,64 +1,95 @@ # Contributing to Cadder -Thanks for taking the time to improve Cadder. - Cadder is a Rust workspace for a per-user Caddy coordinator. The main parts are: - `crates/cadder-protocol`: shared request and response contracts. -- `crates/cadder-daemon`: daemon state, IPC, Caddy process ownership, and runtime storage. +- `crates/cadder-daemon`: daemon state, IPC, Caddy process ownership, durable history, and runtime storage. +- `crates/cadder-operator`: current shared operator layer used by operator clients; OpenSpec will decide whether it remains separate or is merged into `cadder`. +- `crates/cadder`: package that builds the `cadder` operator executable. - `crates/cadderd`: daemon binary. - `crates/cadder-shim`: PATH-facing `caddy` shim. -- `crates/cadder-tui`: terminal UI. -- `xtask`: validation, coverage, distribution, and packaging tasks. +- `xtask`: current validation, coverage, distribution, and packaging tasks; OpenSpec-driven cleanup should shrink or replace custom orchestration with mature tools where practical. -## Development setup +## Development Setup -Use Cargo from the repository root. +Use Cargo from the repository root. `.cargo/config.toml` defines the conventional `cargo xtask` alias. ```sh cargo fmt --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace -cargo run -p xtask -- check +cargo xtask check ``` Documentation lives in `docs/site` and uses Bun: ```sh -cd docs/site -bun install --frozen-lockfile -bun run check -bun run build +cargo xtask docs-check +cargo xtask docs-build ``` -## Pull requests +## Local Workspace -Keep pull requests focused. Include the reason for the change, the behavior that changed, and the commands you ran. +`.local` is private operational workspace. Keep it in `.git/info/exclude`, not in `.gitignore`. -Automated tests should not depend on a globally installed real Caddy binary. Use repository fixtures or explicitly ignored integration tests when a real runtime is required. +Repo-local agent skill verification is not part of the v1.0 product contract. CLI commands remain the stable automation surface for people and agents. + +## Planning + +OpenSpec is the source of truth for product requirements, design decisions, architecture changes, and implementation plans. New product or architecture work should start as an OpenSpec change, and project documentation should follow accepted specs or active changes. ## Releases -Cadder does not publish GitHub Releases before 1.0.0. The release workflow rejects `v0.*` tags before publishing. +The release workflow publishes only tag events whose `v*` version exactly matches the root `[workspace.package]` version. Manual workflow runs build package artifacts without publishing a GitHub Release. -To build a local release layout, run: +Release binaries use the workspace root release profile. Verify profile and release identity drift before packaging: ```sh -cargo run -p xtask -- dist --out target/cadder-dist +cargo xtask verify-release-profile +cargo xtask verify-release-identity ``` -The layout contains `cadderd`, `cadder-tui`, the `caddy` shim, and `cadder.toml`. On Windows the binaries use the `.exe` suffix. +Build a local release layout: -Verify an existing layout with: +```sh +cargo xtask dist --out target/cadder-dist +cargo xtask verify-dist --dir target/cadder-dist +``` + +The v1.0 portable runtime layout contains: + +- `cadderd` +- `cadder` +- `caddy` +- `cadder.toml` + +Build a versioned portable archive and checksum: ```sh -cargo run -p xtask -- verify-dist --dir target/cadder-dist +cargo xtask package --out target/cadder-packages --version 1.0.0 --platform windows-x64 --target x86_64-pc-windows-msvc ``` -Build a versioned portable archive and checksum with: +Build daemon-first native runtime installers: ```sh -cargo run -p xtask -- package --out target/cadder-packages --version 1.0.0 --platform windows-x64 --target x86_64-pc-windows-msvc +cargo xtask runtime-installer --out target/cadder-runtime-installers --version 1.0.0 --platform windows-x64 --target x86_64-pc-windows-msvc +cargo xtask verify-runtime-installer-dist --dir target/cadder-runtime-installers --version 1.0.0 --platform windows-x64 ``` -The package command writes a platform archive next to a `.sha256` checksum file. Windows packages are `.zip`; Linux and macOS packages are `.tar.gz`. +Runtime installers are named `cadder-runtime--` and include only `cadderd`, `cadder`, `caddy`, and `cadder.toml`. Windows uses WiX, Linux uses `dpkg-deb` and `rpmbuild`, and macOS uses `pkgbuild`. + +Before a public release upload, verify the combined artifact set: + +```sh +cargo xtask verify-release-assets --dir target/release-assets --version 1.0.0 --mode dry-run +``` + +`verify-release-assets` checks portable archive contents, runtime installer manifests, checksums, and the complete platform matrix. + +## Pull Requests + +Keep pull requests focused. Include the reason for the change, the behavior that changed, and the commands you ran. + +Automated tests should not depend on a globally installed real Caddy binary. Use repository fixtures or explicitly ignored integration tests when a real runtime is required. + +See `docs/verification/release-profile.md` for the measurement protocol and release artifact inspection checklist. diff --git a/Cargo.lock b/Cargo.lock index 6beb77a..135d475 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -97,6 +106,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "astral-tokio-tar" version = "0.6.2" @@ -210,6 +225,21 @@ dependencies = [ "tower-service", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "base64" version = "0.22.1" @@ -295,7 +325,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_urlencoded", - "thiserror 2.0.18", + "thiserror 2.0.17", "time", "tokio", "tokio-stream", @@ -368,6 +398,18 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cadder" +version = "0.8.0" +dependencies = [ + "color-eyre", + "crossterm", + "ratatui", + "serde", + "tui-term", + "yaml_serde", +] + [[package]] name = "cadder-daemon" version = "0.8.0" @@ -380,28 +422,16 @@ dependencies = [ "fs4", "hex", "interprocess", + "is_elevated", + "libc", + "rusqlite", "serde", "serde_json", "sha2 0.11.0", "tempfile", "testcontainers", "tokio", -] - -[[package]] -name = "cadder-mcp" -version = "0.8.0" -dependencies = [ - "anyhow", - "cadder-daemon", - "cadder-operator", - "cadder-protocol", - "chrono", - "clap", - "rmcp", - "serde", - "tempfile", - "tokio", + "windows-sys 0.61.2", ] [[package]] @@ -413,6 +443,7 @@ dependencies = [ "cadder-protocol", "chrono", "serde", + "tempfile", ] [[package]] @@ -435,36 +466,6 @@ dependencies = [ "chrono", "clap", "serde_json", - "tokio", -] - -[[package]] -name = "cadder-tui" -version = "0.8.0" -dependencies = [ - "anyhow", - "cadder-daemon", - "cadder-protocol", - "chrono", - "clap", - "crossterm", - "ratatui", - "tokio", - "tracing-subscriber", -] - -[[package]] -name = "cadderctl" -version = "0.8.0" -dependencies = [ - "anyhow", - "cadder-daemon", - "cadder-operator", - "cadder-protocol", - "chrono", - "clap", - "serde", - "serde_json", "tempfile", "tokio", ] @@ -475,6 +476,7 @@ version = "0.8.0" dependencies = [ "anyhow", "cadder-daemon", + "cadder-protocol", "clap", "tokio", "tracing-subscriber", @@ -576,6 +578,33 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -613,9 +642,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.10.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" dependencies = [ "core-foundation-sys", "libc", @@ -914,6 +943,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -956,7 +1007,7 @@ dependencies = [ "atomic", "pear", "serde", - "toml", + "toml 0.8.23", "uncased", "version_check", ] @@ -1182,6 +1233,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "h2" version = "0.4.14" @@ -1238,6 +1295,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -1552,6 +1618,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "1.9.3" @@ -1618,6 +1690,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "is_elevated" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5299060ff5db63e788015dcb9525ad9b84f4fd9717ed2cbdeba5018cbf42f9b5" +dependencies = [ + "winapi", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1658,7 +1739,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -1700,6 +1781,23 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + [[package]] name = "line-clipping" version = "0.3.7" @@ -1757,7 +1855,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix 0.29.0", + "nix", "winapi", ] @@ -1835,18 +1933,6 @@ dependencies = [ "memoffset", ] -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nom" version = "7.1.3" @@ -1965,6 +2051,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1998,6 +2093,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "palette" version = "0.7.6" @@ -2070,12 +2171,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "pastey" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" - [[package]] name = "pear" version = "0.2.9" @@ -2107,9 +2202,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" dependencies = [ "memchr", "ucd-trie", @@ -2117,9 +2212,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" dependencies = [ "pest", "pest_generator", @@ -2127,9 +2222,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" dependencies = [ "pest", "pest_meta", @@ -2140,12 +2235,11 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" dependencies = [ "pest", - "sha2 0.10.9", ] [[package]] @@ -2226,6 +2320,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2288,20 +2388,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "process-wrap" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" -dependencies = [ - "futures", - "indexmap 2.14.0", - "nix 0.31.3", - "tokio", - "tracing", - "windows", -] - [[package]] name = "prost" version = "0.14.4" @@ -2418,14 +2504,15 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "ratatui" -version = "0.30.1" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termina", "ratatui-termwiz", "ratatui-widgets", "serde", @@ -2433,22 +2520,21 @@ dependencies = [ [[package]] name = "ratatui-core" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ "bitflags 2.13.0", "compact_str", "critical-section", "hashbrown 0.17.1", - "indoc", "itertools", "kasuari", "lru", "palette", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.17", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -2456,9 +2542,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm", @@ -2468,19 +2554,30 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ "ratatui-core", "ratatui-widgets", ] +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + [[package]] name = "ratatui-termwiz" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -2488,9 +2585,9 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ "bitflags 2.13.0", "hashbrown 0.17.1", @@ -2529,7 +2626,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -2596,43 +2693,36 @@ dependencies = [ ] [[package]] -name = "rmcp" -version = "1.7.0" +name = "rsqlite-vfs" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ - "async-trait", - "base64", - "chrono", - "futures", - "pastey", - "pin-project-lite", - "process-wrap", - "rmcp-macros", - "schemars 1.2.1", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "which", + "hashbrown 0.16.1", + "thiserror 2.0.17", ] [[package]] -name = "rmcp-macros" -version = "1.7.0" +name = "rusqlite" +version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" dependencies = [ - "darling", - "proc-macro2", - "quote", - "serde_json", - "syn 2.0.117", + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2747,26 +2837,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ - "chrono", "dyn-clone", "ref-cast", - "schemars_derive", "serde", "serde_json", ] -[[package]] -name = "schemars_derive" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.117", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -2832,17 +2908,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "serde_json" version = "1.0.150" @@ -2876,6 +2941,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3022,6 +3096,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3153,6 +3239,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.0", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -3192,7 +3291,7 @@ dependencies = [ "libc", "log", "memmem", - "nix 0.29.0", + "nix", "num-derive", "num-traits", "ordered-float", @@ -3240,7 +3339,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tokio-stream", "tokio-util", @@ -3258,11 +3357,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.17", ] [[package]] @@ -3278,9 +3377,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -3423,11 +3522,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_edit", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -3437,6 +3551,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3445,10 +3568,19 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.14.0", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", ] [[package]] @@ -3457,6 +3589,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tonic" version = "0.14.6" @@ -3560,6 +3698,16 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -3591,6 +3739,17 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-term" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338ded85dbe7f9ea2298321d126244f54e531e2b2006b97abdab8e47d6f3c88" +dependencies = [ + "ratatui-core", + "ratatui-widgets", + "vt100", +] + [[package]] name = "typed-path" version = "0.12.3" @@ -3736,12 +3895,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "vtparse" version = "0.6.2" @@ -3945,15 +4131,6 @@ dependencies = [ "wezterm-dynamic", ] -[[package]] -name = "which" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" -dependencies = [ - "libc", -] - [[package]] name = "widestring" version = "1.2.1" @@ -3982,27 +4159,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -4016,17 +4172,6 @@ dependencies = [ "windows-strings", ] -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -4055,16 +4200,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -4117,15 +4252,6 @@ dependencies = [ "windows_x86_64_msvc", ] -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4183,6 +4309,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4298,13 +4430,31 @@ name = "xtask" version = "0.8.0" dependencies = [ "anyhow", + "clap", "flate2", "hex", + "serde_json", "sha2 0.11.0", "tar", + "tempfile", + "tokio", + "toml 1.1.2+spec-1.1.0", "zip", ] +[[package]] +name = "yaml_serde" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c7c1b1a6a7c8a6b2741a6c21a4f8918e51899b111cfa08d1288202656e3975" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 2293a5b..31fd0e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,10 @@ [workspace] members = [ - "crates/cadder-mcp", "crates/cadder-operator", - "crates/cadderctl", + "crates/cadder", "crates/cadder-daemon", "crates/cadder-protocol", "crates/cadder-shim", - "crates/cadder-tui", "crates/cadderd", "xtask", ] @@ -16,4 +14,17 @@ resolver = "3" version = "0.8.0" edition = "2024" license = "MIT" -repository = "https://github.com/MrMaxie/Cadder" +repository = "https://github.com/MrMaxie/cadder" + +[profile.release] +opt-level = "s" +lto = "thin" +codegen-units = 1 +debug = false +strip = "symbols" +panic = "unwind" + +[profile.profiling] +inherits = "release" +debug = true +strip = "none" diff --git a/README.md b/README.md index 6f3d6b5..1048705 100644 --- a/README.md +++ b/README.md @@ -2,66 +2,63 @@ # Cadder -Cadder coordinates local Caddy reverse proxies that would otherwise fight for the same HTTP and HTTPS ports. Projects can keep using a `caddy run` workflow, while Cadder registers them with one per-user daemon and applies the active configs through one real Caddy process. +Cadder coordinates local Caddy reverse proxies that would otherwise fight for the same HTTP and HTTPS ports. Projects can keep using a `caddy run` workflow, while Cadder registers them with one per-user daemon and applies active configs through one real Caddy process. -Published documentation: +Published documentation: -## What's included +## What's Included -Cadder ships five executables: +Cadder v1.0 ships three runtime-facing binaries: -- `caddy` is the Caddy-compatible shim. For `caddy run`, it attaches to a running `cadderd`, registers the current project's Caddyfile, keeps that registration alive while the shim process runs, and unregisters on exit. If the backend is unavailable, Cadder fails with guidance instead of starting it implicitly. Other Caddy commands are delegated to the safely resolved real Caddy binary. -- `cadderd` is the per-user daemon. It owns local IPC, entrypoint registrations, adapted Caddy config composition, the generated effective runtime config, the real Caddy process it starts, diagnostics, and bounded log storage. -- `cadderctl` is the non-TUI operator CLI. It attaches to the daemon for one-shot state queries, logs, and toggles, and provides an explicit `daemon start` path for scripting and automation. -- `cadder-mcp` is the local stdio MCP server. It attaches to the same daemon, exposes entrypoints, domains, diagnostics, and logs as MCP tools, and keeps daemon startup explicit through a dedicated `cadder_start_daemon` tool. -- `cadder-tui` is the terminal UI. It attaches to the daemon, shows overview state, entrypoints, domains, per-domain logs, diagnostics, filters, toggles, log export, and daemon shutdown, and exposes an explicit `s` action when the backend needs to be started. +- `cadderd`: the per-user daemon. It owns local IPC, entrypoint registrations, adapted Caddy config composition, the generated effective runtime config, the real Caddy process it starts, diagnostics, durable history, and bounded log storage. +- `caddy`: the Caddy-compatible shim. For `caddy run`, it attaches to `cadderd`, registers the current project's Caddyfile, keeps that registration alive while the shim process runs, and unregisters on exit. Other Caddy commands are delegated to the safely resolved real Caddy binary. +- `cadder`: the operator executable. It provides CLI and TUI workflows for scripts, agents, and operators. -Each executable supports `--help` and `--version`. +Native runtime installers are named `cadder-runtime--`. Portable runtime archives are named `cadder--`. Both contain only `cadderd`, `cadder`, `caddy`, and `cadder.toml`. -## Quick use +The target v1 surface is intentionally limited to the runtime daemon, PATH shim, operator CLI, and TUI. Web and Tauri GUI surfaces are deferred until they can reuse the same daemon protocol and view-model contracts. -1. Download the latest Cadder release for your operating system from [GitHub Releases](https://github.com/MrMaxie/Cadder/releases). -2. Create `cadder.toml` next to Cadder with the path to the real Caddy binary. -3. Start `cadderd`, then run a project through Cadder's `caddy` shim. -4. Use `cadderctl` for automation-friendly state and log queries, start `cadder-mcp` from an MCP-capable host when you want agent tools, or open `cadder-tui` for the interactive dashboard. +OpenSpec is the planning source of truth for architecture, requirements, design decisions, and implementation tasks. Project documentation follows accepted OpenSpec specs or active changes. -Full setup docs: +## Quick Use -- [Getting started](docs/site/src/content/docs/quick-start/getting-started.mdx) -- [cadder.toml](docs/site/src/content/docs/user-guide/cadder-toml.mdx) -- [How to use](docs/site/src/content/docs/user-guide/how-to-use.mdx) -- [Real Caddy resolution](docs/site/src/content/docs/reference/real-caddy-resolution.mdx) +1. Download the runtime installer or portable archive for your OS from [GitHub Releases](https://github.com/MrMaxie/cadder/releases). +2. Create `cadder.toml` next to Cadder with the path to the real Caddy binary. +3. Start `cadderd`, then run a project through Cadder's `caddy` shim. +4. Use `cadder` for automation-friendly state, log, diagnostics, IIS, autostart, daemon lifecycle, and TUI workflows. ## Commands ```sh cadderd -cadderctl daemon status -cadder-mcp --help +cadder daemon status +cadder entrypoints list --output json +cadder domains disable app.localhost --registration shim-1 +cadder logs show --limit 20 domain app.localhost --registration shim-1 --output json +cadder history show --limit 20 --output json +cadder autostart status +cadder tui caddy run -cadder-tui ``` -`caddy run` now requires a running Cadder backend. Start `cadderd` directly or open `cadder-tui` and press `s` before retrying the shim command. +`caddy run` requires a running Cadder backend. Start `cadderd` directly or run `cadder daemon start` before retrying the shim command. -`cadderctl` gives you non-interactive inspection and control commands such as: +For a local checkout, run the project checks with: ```sh -cadderctl daemon status -cadderctl entrypoints list --output json -cadderctl domains disable app.localhost --registration shim-1 -cadderctl logs show --limit 20 domain app.localhost --registration shim-1 --output json +cargo fmt --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +cargo xtask check ``` -An MCP-capable host can also start `cadder-mcp` and interact with Cadder through local stdio tools. The server is intentionally local-only, applies extra redaction to returned text, and only launches the backend through the explicit `cadder_start_daemon` tool. +The repository defines `cargo xtask` in `.cargo/config.toml`; run `cargo xtask --help` to list validation, docs, release, verification, and dev commands. -For a local checkout, run the project checks with: +Use the isolated dev runtime profile for CLI checks with: ```sh -cargo fmt --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace -cargo run -p xtask -- check +cargo xtask dev-env --format powershell +cargo xtask dev-run -- cadder daemon status ``` ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 34846f6..83d9b7a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,18 +2,28 @@ ## Supported versions -Cadder has not reached 1.0.0 yet. Security fixes are made on `master` until the project starts publishing stable release lines. +Security fixes are provided for the current stable release line. + +| Version | Supported | +| --- | --- | +| 1.0.x | Yes | +| < 1.0 | No | ## Reporting a vulnerability Please do not open a public issue for a suspected vulnerability. -Use GitHub private vulnerability reporting when it is available for this repository. If that is not available, contact the repository owner through GitHub and include: +Use GitHub private vulnerability reporting for this repository. If that is not available, contact the repository owner through GitHub. + +Include enough information to reproduce and assess the issue privately: -- a short description of the issue; -- affected platforms; +- affected Cadder version; +- affected platform; +- affected component, if known; - steps to reproduce; -- expected impact; -- any logs or proof-of-concept material that are safe to share privately. +- expected security impact; +- logs or proof-of-concept material that are safe to share privately. + +Do not include secrets, credentials, or private infrastructure details unless they are required to demonstrate the issue. Redact anything that is not needed for reproduction. -We will acknowledge valid reports as soon as possible and coordinate a fix before public disclosure. +Security reports are handled privately until a fix or mitigation is available. diff --git a/assets/logo-icon.png b/assets/logo-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3cc02954354b66e46c4210842a7c5978782390f8 GIT binary patch literal 969 zcmV;)12+7LP)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy32;bRa{vGf6951U69E94oEQKA12RcOK~z}7wUi;CTL>?8sx^pk^*}e6)uX1 zFhV3HEgLhfZ1mT{GxTwpxifc0|C>ACng4lw=bky|jEF}7Yk+IOTi`3u1H1#S1I02{ z02Ts|h>T}I_GmjV1ilkJegYdu%Xt>y$2)!jdGR_=0A3P1-UCVT1W-==*dI>-jiV62 z3pXFe#rY%==@pSg7yS+q=@OAl5lMF8r5a;;z21JY%dMUQ`4%?`*ba1r;pfDX{+uwz z9s=W)Ga1MLe0t6cqmvg)0P{l_`U}j{?+*e4DsKahXw`>8R;)$nY(9-D&-YM-KY*~c z=Chow1Nv0qfQsuJ{8oLg_i>V+_DPpx3oa0!wwc3<23duH!bf%B%2@fdXKGzCJ&& zEK=I~bag>@>y%Ecu{= zcUVOBXiED;WVVPDi^#-KI>wk`;ERZ)D=%yY+N~Sx&}q2IdM#ei50q+`EVt^T-7SC% zj6Evd0!Y!7%83?7b|Rq-cpXCi97u7UhFW|TBC=X*q)DgU(O`Xu$gD7T=oXPQ{ah&` zEh18+=Oz)UcGQ*#Faf9nJ^;-?C9p-C&^D_t1ZTl%>-@%1A18o8>)Zg8sjbcGrx|Ev zWV43w;blN7Pz2lt{y2ya^cJPSu*w3!B4Dk9 zyBk;n+_Si-%^ICK(}1l&j{s*vZ9~8co$qNtqK;cTy~i#7B}+a8oKqV!)JCqeajD)Z z2`&>6_JUWIgMjVa+MZ8pjRf%#SRTQ)FVbw70(??~y}%W>gda$cXDhC- zCf#%oda1x;)jj0K^+m81{8Mq(81pUK0E{ty5ee2-Af61Q0-u4)K!OK7pT3GtAT8z$ r=uL8EWdC2~A@&08iuuNvZa3mT1NEzEaYxh)00000NkvXXu0mjfquiUc literal 0 HcmV?d00001 diff --git a/assets/logo-white.png b/assets/logo-white.png new file mode 100644 index 0000000000000000000000000000000000000000..76476b0b22a734279aac04a1be383075215f5cec GIT binary patch literal 50553 zcmZ6zcRZEv|2TdhJ9Jc1%2CoWGfv5jXxJ;`kd;#^Au>Wn91%sSj&*Dja_sEciI7cZ zR?5sQLLz;ir}yXm{r*0`KYF~J>%Q*mdhUI{9$(T^-_3fM6+w{Qni>}k5rkoy_K$@L zRvvEWiNb$SZiedTQJoRv25EatWn{!%iNvbQDTqSEsgA>Y4WiuVcP8~O-rQE?aDI`#ukTi4`xC_< zNy}%f=>Q}IV>Pu8L4IaOzR`H2^M?D!+eU?ERXpC|tpP%GVgXusIO~2W)pL!V6q9AX z(~3<#op0m*VzOu2-J(trAzIM3$lNv<^^m^4b+*}GdnF9JJo^1@tWJaFYGrO!o#H!Q z1n2NT9wC_IG2C$+jC}gGD!bA#Ikcjp@_h zy>{XW8-7r=?pf3l|HY2K0G^-6F(Xfgb{`vY#NSy|{yTy7Zyx4yW52*Nl)G~CUYw=% zjm$bU!p-{*gWwLI338^4WlY#txwzhs8g+LptW!V;T?}prb$sMwsA$lH;6O>kmEH3U z?g%vnkRV1>2|lXI;=;2uN#q5)Q|1ujxxH;DM7>OPj}S49rbtn~A;dGgrV+xlal<=<5o2AUSM%k%&HLa~hS5vBo=2`?*hg<_cz)WR!IP{7 zHjhze5rW~E%Pxd)xR_FhElwW5R>ZtI?Z4qiN*36(5mDvhMRbBfZATnf%>0N)+E zz>0A4!JT_QQdo;T-QUb-W?5Weyo=!YL2kJB_PJ1{hn$_b@US9ETakm6Xya}a^#d@D zkYe0~VlFpV6-uEeqG)gtUx(8xWVCZ3Ogcb4`Vb2L$ZtLQx+B>BcpRv+3xkFbas=UfBIpr%DvJuOpS7cpS*6qR-$;ePrjx zNIbp=el@|bAeLDqMIB(8eWZdW<50ZFPZhY6dEW<=6hFX&rm;hxPM*-na2M%9!I29Z ze9YfNiD(2(!&rRRB;tgHjY@#YIGRqLn7a$;#|$Ty2dJP4cjy$5!3V&myzU^^XC%ci zShJ2tjrL$AgWQnsaqvXOGK=IBt&qU^0fo7lIH@}4tha_iP6KLt9J3Milo~E155UDa zz~O0}T_}|cIA-c?d3E&os|00c>H%NLCE3&Vo|Ci7oniIj1H#_a0u%2 zG2;#oS)j;V@GN2)BTqE=IL1m91Bl5vPcJPM$*H3Ww`tqGXxp2dnTeX+u;DuGWOmzT zluq2b2_mQt*J9t|3p6;3zSToT&kZw`D-qE9(P znL)TQfUHoNfk2stmB@zb9I<0#KVoI1#7rgJ8%K@5%1aO!2zC>lLS`f80EBaZT=VVX zJipkWn7p(H4R(p^351OBf^(ERpkbhhRm;a9Fal5USANZBu#*?_!PoN)110S5+1tN`(F(bo!J5bI- z!0$5A^aP>JQ_T}BQwZUpquj>nX3^${L^M%1SQ&9nGi=en1_WEk&Q}%ki zc>9I=Q|^2lV&<1ow-O@RhA_R0><2JTeCA&ezOy;YgAyA2V_vuKXSf z+8bet8ZYnHOcL@UO;%ATq1tpf{pE57WK}Sdo|tCwDo@Y~;az^m6+ znI3oC+zfS7O_&)8ERxS!i^nbtRv4dS#@&A)Pdv1@1w|#4d>Q%Y2i6`sqX|QxmQu$6 zkaxgb9jlWYx^t`wUog}lkb|2la7tRyr!mn4db034Sd(M@q;Wgh#(kc+&Obo6u}Twl~VwK=LX<)NO0i;xRBFGOJ0BF2C0em zLL}{lX&NqfPxrabv_HFqMlzLTO$rE>hMCt_D%Om97 z0Odtsx*)-3yuRplG(AzC1`zQJ2Xfq&^#sxu7qAC84wisBOYpfxhLhF(0{}x?6u|Hb zU^tGa!JtZF&Z1YKt;fK6P{K)O+j!vb`*QEC>wDvZWe_MzEfyk#p;uMP4RdaO(ggmZwx{h%qz}I zYD!^7#y#R~yE7tPuW~a`aiEdhI51k&r2ozC@4#+uVF19Hs5;lYsCj=Zfg(>to!Ukb zh(WH13NxiwouH__@&U&}`~n1F?=d5BkQAQ)w zAoXmI@+z{HM+hM(#bfttdpvA+scpq^P@6cZr4ALpwx; z^MiOqLZ*C*{CRmKMH!Tfwb`2!QQ+>rVQ=>K?ufXpi45&Z9$w ziZyCP?p@KUxFpT&>NXHzM%Y{V z*Dg^w3^}ssQq*a>`KQWI!1IVL09e0vsyX?Z9GA*6oYmwK?LD(;FsGS=AQ$@bEF`o< zvatt)&mliAoH#*s8T4X88eiuA1k>R%hJjeZ4d{8`O@}=SX?*|6c5In~%pb+vNbywsk#DQHL^+yn57LJ`RMF`aR_f^|N2${8>5Ag;xQ!cdgyT?}E z?=urasK&+hd_!{;coJI-<2Viw3YRlQBV?2n3gP|O!rGO1WA4(@pWb%~AQR-c(rZhp z9wx+kAh5zEtt>-kmsW6LstED1{TYU{+AHOet`aHqgUJN+xJvuApMKyvk<|iu_OD^R zJQ2vkI{=iq{EMYLz~&$;7%>S}+W_A;bOSZ`G*q5gpCGV_bC=7!23JLh=64R>f0fQf z@C2Zgq{BLWG;2efuOn1N$0T{71o}Y(NJbWxf)ALm86bqbe|FIj-`6sQ42DZhBOb+j zTkiK)vrZz!3Wv-dNi7jP9^W8rZ1OeN1D6r=u--(5jgn%z+YodVRK;lS z?4JQ{-8L88e7Sit>j;4j=;zV3uyYoSn&)&Vf>giJa8+LOax*tam4cxjJP&ehRA#Ff zRs_f88BMus&sY8zt@5O-9K=F6T`Xc-qIoOpU>t}(aXvS~61yqWte#O!H_`?q8TN_= zLEYewq({nihd=)2YpyuA2ayGnH3Dv3I6WcL$?6ZTBZ?jwREzq}{Tv+ct{EgRGm2~c z05}o^{e~8kH)&pxyMrFr1#j=vwXiUzz2aV6K!+^UU-ThbkXu-ti*ArQjgp^TJGKic zNCrfEtLX|L7;!FAom=nQ>i*4v6yz5{N^vkf62w6~7m&Yo3vOx#0>Rt_*P9ea>E<4aCT=T4x1cTucXcYw)}M3+_Hw z1i3G-t;FzkRj%_OS)R*{x?pwaS4u7&l42c(-7rd)FN1&#kLQYEBp(OEG;d`=e-uH& z(^}@GX8O}30Szj~?gnF59V_C@+KRP4t!!={U}kudxh?vD31o8i7UmI?mVFqUdXzTO zh8LMqTthDg_M#HOH4rB(guId_HSzcpG4y0kxLi!S&vnBn#C5|ajSd+%9+F@R z5_tI+3Soc8-&}*WZfF$29e*HCaOyBzeqWockkBKFLU8Q1nrC%ZtVE_B?gB(^-C(?# z_Rp@(PvgO-;xJ)Yi1f7~^pj)eCMyDj3B|TCijQch9kUNn**>kQC2~-7hA3hL7@sei z_-#LAY7FL?z3)`B|KoFesO&7$#AXNc%gZxsv93e>2ogO1bV5C@1VhDo$uSD%N50Wn zsp9^HAh-kb*n^t2x+?}Waxjqj;j&kUXb-gR+*RaeK~w8;KxKnx+rE za{vN~0J(LdX$0-I5ZqK`I)`wkUqh4McpTf{I{+NE`Pt9Mf_I}v0^0?%(O(neYo7eb8BjmR+!aeJFf znX3~(1w(|3yT{Y#YAu@&mI-lsX^-aK4Xhrg5rd#klhvJs$vfE;^9n3=4+Ka|x+NeS zK9{i-$&;Cy9S~|=03qatP!S4Sgv3cNk`VCq7{~|vXlrl8Wjts}LAEOCM$Yko0LUvZ zct%ggK=g<#*uR)mTFjyD0cO0jKcGL8)yZ7=oDLxzmGKUE2Rj3M|K;D>qkd2`+q&m| zIusani`^e#%A$oGt=MO2z!>5mK#1g(_*|=fJxlf*~zbuAAx@t`0 zslh791@LUalVa||8#o&KX0Q0%ZppVf&drU$#Vo;R5yY9HKY^OUy@RknOyvL)UKmdQ zL9l45+jZ1yB=wJjR--a=xh%~MIo;}ju19s^rI9aEusxg0NTtH;N$~Z z=ItbaAWg*(3a+v`-+drIemzd6UzOyq=lTsixAXO|-o>05+6x3Y>=aEE{z^CNr$N9$ z?72*{^^XDkLU|js+~b?-D~j&Ip2{+`dN279h=11Yq#4L?!0t;J9nF2y)xnC1C!poH zXby+_K~XPHV{+3Tmhlh8Z|Kwq7B1Evgjg2IEYrAO+@jE*NqqrS(fcCSEE#8tAaW(5 zy6PbwCgWt#91%;w&8BHCu)Ty;_UptnD8Z=rEvP7109dJodW_*6huo-z;6GkWo?B3( zLvUhHIQ<`{qEqgOP7`e`u=4u%sd=LsaL6RwF(Gz+Tmz2owv25wuK=^o-qwo!+h0xh zQxC_CiLe6#vVZy)?4M5Ll4E*@an{Kwgp;p_VL6OK++-pG#z4H&a8E(=UKGT#W1eQ^ zPz%9R>ykEX4ucldmui65u_Sk}{?u&1IEzU}_(n}$0fk0zmNX`9Vu#EElHiOnX~!In z2w(0n9std;dwbihxQI>(Vw=FT$Z6&t$Ki=;8q=*wI~#2tg-Wv-Xx>!ZY@t zjo+D5O#eZ49^8FIjtwu{PcS_{7sb}zRXnpP+lvr?yk4nHbibi+qKi2a6H@H-eMC zXJ^akeVk11-CbOmB(Ujkq+djb5Kn#tEWrc)1UF+wdK;?B4kA&l3JZ3zld=Zu90;|% zWTx$tGm`?OsJ~b4liuFhxs9SnsAPFt^uOyZiz0qqVE|L36X-Tgyiz@}4^g@-NUI_Z z;u)a)Ipom~@o8{8D)G`u=AR9Dz%iqjiCnXg7SGn-Ao<^h?2Ev9Tai8yD3@s85#vLi z48+>f>?LBUk7K^O_)k|opjAMQUQdvpw@l%DAfMtu!;>sJ1Mh%gO0@GpCsv^#=r>!Y zOZ09MKome#AayMaqn%R*w(oAc1nuhS1o;#xcwf6C2CV5^f)w*z!2j;cPbYoJ-u&~i zC%}4N{I&`Ic{5I!ITuNFYfuvh)0p`|^)B*+!AudAjR7nf5 z2~L2{zhXI9XO|9)GuYCCvYWPD6^G%(s5KmI!I|Yu5Dg|M_56SSgq4L(goSYD0k|Aw zFnPj?SOXx{aBss;P0TVK1}eG>tsQ*u^Z)lJtRO;QQ~{EDavSRcNDr)pxa^!NI0*Mk zahjo!@!kDR)GMI1XBSxL!1x8wFjE@b%fOxIP70e|+q1NWAT-rM*ayxY8&)Oh#}8as zFy|GAO4HyAs1S`8JF$IU-}MQq02GSC>uY(DZ?smh!_1Clj9%x@5YR)-IoTJM-%))b z5-7+vYzf5vB~1ooqY6sm>B@)e%Jj~c+LYVix^z<$sc16k|E1wXNAOM_*Z5CRP4d{j zII)haan_DzZ2tW_#N2H`SB`*xY2;Rg4BQR%CRDUkAsy~7~XQ3e-vSLqziUKC;kJVeUJ zZgLAfI`e`zP~jM_v%O#u-IMOea$Gj8 zQIMA&3Y{BizyilOg#Q!|2aOv;>P{uwnWh_2W{GGql9;y0iO;H3E72IpJ1d<0nJYy4WIqlAsNJN*n|Ev8#tXP48FsThSY@f{-Ey3t~o=!e|DNa-Y#p}$o zxJ>N_I7`KFM5BElTwJ04pYpGJ_JP5bJ)DR}W@%MJZvFsFAoM#BG}tC2_#$7^EHomB z_aTKRrh?CVF=@x(`s6umAB=+SawYAe6`7h>SSGoPc<3r2)l`*(wi1vE$$aS;rCGJF z>wbbl0#vt=YKR?{jJ3?(Uts7Gcg${6!{g@-Tf%PLc_?p&H3QFF``#Y&7Ks%Ms z#kqA{1I1;OB5rilgS-3DSih0ow z*9Zdh3)phAo%x}*OYo*KzqB~74!PyXNc={l;c!9r@r)^E;MXjt}2KJ>r!YV|hPB=+4Y+tmMq~bARpT zLblmbG3-zvoHx`O9`a5C_oQA}$dq|Kkf%H%RDN9Izk3+1;1Q=^XMqVGS zwxs2d?Tr3U&#O4d6}Pt|k)G&6gYWSrw4m+r1fP%FuYuqJfHt){wwjf5dIWlDGJsv% zfJNWw^YUcDnGO^c2SP+h(z)_57HZVg@4yj)UxE;`lmL*7Ryd?qvrG}DgWMod1&c}M zNX0zA2-0+c)-?2js@)wZv{Jv(4*4DAN-cqn&gQg@GN~Bn?5EC4b|f}a1|%Fj0n#H7 zNniSL_xY~<&Q$4O_vCFH0nc| zjT{HBMs(qXM2@OKgSI-zRmB>@80uac%GDBv9NvJ9l@vmUgwuL@6UC5%zS;NRCFr6d z6FsE`XP??1fKZXvH+vH12K9D~aT1*;2(@Qn3i0awBM)6wmbFB}u&r1&Q~LGk9JO&h z2w`bFWIr!IPL9?*Yj-8!PwS^EdHs@Cgao%%xKg_hfyZj1k?f0W9>$rKNg1KOpy5J>f+)tyNZfOb z)+!(-)j*&^9hWl=uW|1UF4uECwm(S#GA8GXKs@dv;yIQMtzEfcgel-cdgSnft6xS> zf3}8uPrGy$Fy(#5FB{uSy$=ucIJqH*8$o|r)ejl#dDs|4`P{EDn8V^>zm%zSuW@-{Ok4>7Z>QgSL1^w z_DJax>o=3VU2B_Xr58}Yd&XKv-V!Y!4sa}HV+%QdVIx^wJmX6a5_DC zKwSF0kz0y~K00kR=0B?)3-k)m9mZXuQ83A!bWk%%tz=}JMOJOOlDK3iGSy&QFCUeN z;Qan=2!s0h^}q*(h61u8(0#Ves;_9qcY1ZwQBQ+_jms4mpr4CYXgUODYnGIGch(8# z7~N^ytYxM}j-`DPxQ+=FvVL{Eo_GtD@U*#@w&nG&4(3t_2566Kb^hRx+xjIVwCCvt zWwhtBIZ{2}x~NNFI8DH1#u!FU*843aaJ*Ne{xB;>I=cJT`_Gwn+F5`RhUiJ@)ic&YsE^UZ(CL z(dYEUP0&~sf8*A~NBwvY=Qr>6(8(h-t^adL+>_tj^ixmYT!sD?>nwvS&ThV!$F+7V zAQ$3bt75jNtM~2^t6oQ+LFHy+ZWVbuNu@X(eY1i7CR10wj^|_UncO-$2&QdNwW{ma zskM3rXQe(@A->483#Rmy9yXNS-`2Z>UE;qaYzXUgo;!Ay1|Goya(h_{6EXgixbmq~ zjFKIF<>`D!jk4vx;#V=qcgXFKyV@lqFLq7Ik=mc5krYm4*Ih`85oIb|1B@G$3>GcL zq9a;M-ABQr$aY8JfYMROn3eJlpdSGiT6@QHYWFz;W7LM&PuG z_3}m;Lhwwj&P9?I{36cWY(Ma%+o_=|{_(qKPj~z2K621VCsPeg7r6c7F0B*VUWGPdqwlN4x$HXp@PdMiAg3U#auTPNSFc2KfOWxgoZDJn8b~g^~r9tQF|5(&}90A+E$MMW( zYfILfziP{nWoUIEjXGRP|H)72`N;EIV|3N@VExDl{^!37#@LC33^8g|5mZ2E#ebX; z9CC?C1vz6R4t@-NI5Vl>ggRp8i2)oiTH}s##zBT742N;fw7S<*nUM@?B_any5pt1? zyWGC|R?ypBWh9)>Vf%0LwO3%fEnvf2mfaUpnP6aUX_~@M6Y)Zl z5Ht^ZkKmO>sJD)@Dl2(}(6Pl97-w?zKs(U3KAUN`mir)Af)@qiSEj#wM5h0x`9UCfA}mVi>1 zJP1}7Gz*}FtmU2JM%!{gjJOJi@JIbrnSl#3;~r{Re7&P^!Y0G&1_xBsa0dO1Dw6Qz zXuqb3W^Gsl+eG?rH)Xhk9QV)aHdZ3Dz?j*3Rg!f&T3W~UMow||0xRk$vjK2PL)5n%oXmqMUOO8`!pjcBqn&R&YB61)Fhcl$lNIgsywpqF z+XZK6#khhrPfNP>?yEQ6`)K;Tn0y;$So`)%@(-fo70132FXL`%FBHEBp~l_MdUsJa zmCD{g&g)tECAc4d<2}E-*RP3@k(GNeDQMU-9g`YACRfZBD+y&NXf6mQol*N5H`8xp zy6WR}L-K+A*qf={x$~nTK4sgUGg*O&?eU-b;*)sB5Inxb$xq!+5_c7f7KE3f+PHD+ zj?vECrYp(!{=G@6b~ie+wtJDHI+j;j>D@D2{iWj5?l2D%@>!@!;G`E1dvL|K1%9cB z5}{Z=@ER^JOq^NMb$k-V5znb2+Zo_o#M9`r;8I+4>d61gTyC$)`#+zwCMh~gL%BZ; zt8bLQ%OpuiZzdYo;dUY-<(gBl6C7ka;;0c{#Tj3<&jYKcbLPbH$>+AnxL9*%#I|QA zN0V$J&Z)<$GdKI*%|zaJBTsZ*D=bMkdDN#RMs?R%j~<6gs)Jf*PV*XU5f+Q(&CTJI z?Vc;Y@0i5-!)|p>&9i#r3q&!}`E~Na3&U7{D|}y>L_>e_oy9psr(Lu;0^6x1`Exl= z#c8QCXK@}z!m61wbsI*49D|N^o?1){b}TuwueG{@=UB?TSx?7rU6S(cZ5Y(S{X#+a zCigA_T}7E??#nH)W~0uW9zk+^$_d#rTZ7cQeSO3w=pQ^YY~j$97+vso`=?$6h3VRF z^wgiXO~kwD14c}S&hw}Wa0-#*_sEtdijzj}>}>n3u6oi{UMUH~ENeqyu!Zk+(!HCb zu`fx7_O$7~k1EB>mM!AjOMY9CjzZO6S$l;eeSgjNXr;zV_VnSu5<_DUNDG1~<@~_A zU~ll)wjTyMo+gy`=@kz+Co15)W!H4|CH+I<+@}3B{s^q((JEGmoz3ENl{}+(Re@ze zFUe+g%1om^PWBpQQenW)4(q+S1v5m?vOK+(Dlj-FQ$c@v>i%0ci;rgduG~Ibl%&2k zf@PUj(})@|u#|nbmK8sGHI7oTY{R@Sqwb;!lJV*y&-|Bz-K%qjmbpnjhFY6>6w23+ zPr?XJ)kmq4B*jNoA+?T7ch&HoX^tFn8)G-9%lgU7ix6iMM2DoQqWvfzBLPoih)RxN zVRk8~^|(<5!^Fpt*&~^GUn`eA?-|Ei_v=TzPCB!#4(xi-RjO14@cVie5d(sp&8ge7ZwW;5vQ%TQCY2)=~VnN_^ z>p9hZ7jA^?MW$O72pl|}S=KVj=PM>0lJQ61ug=W^y4NOdZkUbC>N&1@{($!WQgE5n z7x3K|3Z4A?k|x`x2Cs;uC+`0(w#G--)R!>E^C`f#?+a(!Q{pA3=VL=$uJyys*(-A} zn}f*Z!WhZtZOcl`uw9b>QfA&q1^pFH(;K)Tx8wX|9=X(!l#;2s6N7VD(c-w>TA?wV4Y5IV_ddvsZFlY`cv zNqBJF#Dnmme)e{fQjB5fOfO7&?tUIWDlBfayuT*yfsxip#u*Q2fRih~eoumGBy|@Q z;O~?2jq)cd-bGyvn#oEIP|QSIZ{<|N+vh&EDCj`vrN{SPkn042wIe)EGH}_(b05Iv zCEMId+UCnC{}knv*K~67s?LyYpStGRCn%x7<7A$U7|mKOE7i%HvS-mlBT4-z=niKs zesx~@u^Ur(`cS?rQ|Ie?Ks^KElqXo9wjj4*=pot_?}lE5ufQ79j}6L{Q@P6d3K3dQ zXIi}<$M>Kjz|P5cCaX!W>f6(J5joFW2UHvzEMW!%k%NNWvSD(SKx9?$B`4PdSyd~w z;gpJVd)sz*!sOFQCvQ=i&ZMg4Y0q5uImT|!LSg7(%Sjk{I&F(q%8v0z#^;pUFDa|` zjX=jHI5Y9XT%#=S;{F85wA?j?gINbJzI|2%lS*>oMObD1H~o#j1ye^}iN_`ThuY@9 z+tapN!;OA_i>|(f^oCz?No~3r%b(}ohx@B&HEJa(;sVq8ici&l6SL*`U@#`|u1LCN`Jvj6diCDQYZVZqbj7sxjvlsF=b1loUS1AD$@-VqoPHOVTuIv{{Px+g zJura2JPJFM2Zi%@O^rk6kv{dQyUb8&f0JG9&mgyaZaNf{jrwV`%N#C^CJ@yo#1Wk2 zhGhCTEJMxiO|hZNRfybDD}m9LU=^IKSp1@|j`fznFR>1;(+p7duQj_< z>lpjn>!L8T5HJbdcYd|Ev@|I@PWw$^;31SWwAY|uzI_L%_G zy(R(fNRQhaUXh%gnpH+?e6-HJl(lem94YFzcD*e`TNiSTJVka*k|GWL_Vu~CZL?Qv ztejr!QhK}%vPLBgm06%+RbwB(iu8Ou zaBf?4O{!lk%J2ZRz$AVn?Y0!~&1kN?vNsoa33U;OW|w9`j3 z@T5EO$@m}-qi&P-_8m4u3t6FzbSQ<+JYAb=qkjw{tZB7B<${Cv@@ji^N;|vXhxtoO z4^^D*6~93bsKOM{mDK=y?R@3-dETVeUfJFa*FUFWJw#h}_{TlG;n~&beh>B4bE`*` zH~PmlMr5IWHWdCOKkfL$4N^X7Te5KMP=pM}FYURPiMnCE0rRq1XO?A7oc~PwjKKfV zC8q+Qd7m#qBF=;IERJLN-=_)lH#MjV`9|OM^L-I$2#Yo^$8p$gefAp1)OuXsY)X$b z8&izm_@zqg%wTR7nJ-z-V02D*M$;Nr%zrulC{=mA!f$Qwcxu+R4mVkia@RuJjn-H; z6qdCFRdhJ3nC-Ard-`32db@QECC~nR)rO(}=C*%UeU*N^Ka8|d^R#L#?jATd+9hTf z69s;3vhwp^o$9zat_ysG*Fz7}0@G03l(62JmizIsCatu=$Y6FfzA8!Uif8@($brrP z=-Y`jTW6xD{gNlQC={*Lt(ud*4s3pwU$26h9pp{58fGmY{FP#wwbha={dZPbaFg#r z29%@wD!M_E79T&02RLyz>xgIU=HA4gbBym)*6 zxLrBwE%EA1jR)U*@9>#+>BSt=5S4q%b>GisW`xk=j2&khI*pil-F8bRoRCe}0A6|; zB{Oe?VErQo&W&t%{mJp`4!$F+G%UsYpdz;7cWUjvUh$RRZI%9dRuaQw<%znUzOB81 z9JTH7>1}QwkMwT&(VC0}@t-*EJLio!`*CFje)%l4X-4wb;O$Vlv$Ms98u8sVebZ^R z{_V+I{8<;u{bLkTwCA)cS6ODm?Osg!#c37FmljjvpcSNFbo6&ug3=f|+AVM7$7g>> z_mLZ`$);HszWTS%ua5gUXYFbG(IS0pw5NK^%$o#*13TIzpYCbzVr%W2V;|!N&LloF zk`$#uah&lzVGe{S_&eKA+DQzD+L>3X(@QZc!9_v&IVWfRa1O~Q4nENkrIRoHc|3;b2$6FhW$q<~a&)CqdFQXr z{Rx-)Z<{Nb=vPBo6>e@XCQY+Av=gV^)nNJ(lpVJG&MLS23XP(0I&M3D<{4)n%*uAV zxaMpO-7!)=noptpPW5tHf6@l!`-SY9&F#-wo{T2#pDQ+hW-2)>RexfPfBH1gKsH20 zB{^9-Dz@tW+0nl!?$CCb{<`T9Kwj@yhVv>+2j3jiQrD(yZ;tq0d0oxx-JK+`;X?X*LN^ua{_9guQ-E=sOF^l(guC#- zSjq_bch3lNrG-e?Pu%r^3c6blf~ns?p>O1YrtV`pX}?XN#M@&A0FHVPjgUDBep?6{(J&w2i%5V!4WORyMy} z)sObN2VWFG*Rea>f$97$$3+;rg+U{?H2d({LkP^Zmg%kNlKej?4g~#YJOv`=6{WtU z_#G@jBS=p3G-W!^&D5Psi-lb8G~ywcuhC|;ax-aN0=;p*ov(2Brn$JMWb;6KcKiGK z8iUI)#;GN&{+YoIqv|HLCDB}FAU%){{>8~}+FW|0=`}g+Xovl$M7L=~t`r)hvB5%a z{s(63ZwFq{Q^z%RpRVVVIXV=Y?ONawvyk1}=jP_4pBv$u?0s(f4@3Pg9SRKMU!LBn zS*7?0%IvH9ZhtTJQn<~&HW%ae-A+-Ju1i(-?#s!_A(pM-yC)BCV6-_6|4m_>!7qP}};z^&?wNL!< ztz{<;{*lT`&*qh= zypFv0OK!$5$kBHtuVV`So}7rThmbY$&=K37z|x2AQIn8(!~98j#I26_o(F@870)!^ z>a2LSKcmS1dznUC_M(yT&$n#;@C{w&SX1lPSWRG67a&hW7V~-7f*~ZWw&7G@+A45c zN5K|NJew1roNQa08Y$DibnEKWB%t&Jm%gQ~uRcf2lY4n7evhq;Q{TH{Cjeud1z`x0#f) zM^n0AYP>KW{p~jA7Xec>vmZAxSg-qE=iAJxo;zBa4w;$4KpGKmd8kv@H+3iNbZg%>PLOrL)P;gKVc1TR@0{;T>;ri% zx1`CU)3Q2}ce1w>n&001IUf}{k_?o3k@UMH_bm`7jsU)ASadd9AsaS9ZvEEItJ!Ui#2a)o(*mGW(Bi(F+|fZZaP5hqFEyHML;U= zcBn_cH1q}u=Ng^z4W-MHoY-AOXVal!rFAzcsuPK*9kkoX0bSO+8660S-``|Yg ze7{`E$zhYRb`utUN(ld2%<;MB^=m>=TlE_e;9wvHz9n*oo;zvd<<#?b1)Y`E%YpH9 zyQYQv91Ip4n6lz*0*fE#FN?JcD(hQ?AwTa{-8>5;j~7cu1ZLkoC2YB*xE5E}$iD%wiYioNW!Ic%C z2GjnP9i-~yn}HHGr%J!@HP2UKgrTzEsQYb8dgxPA>!(jF^p~IN^2vBA+1oLalN8#y z7qHD)_VT5ZxMsUW2{$U3^+jW;S`NBbBV(hnWW%JOi|p_O6etINlk%A=A&#?7vmO;6N_+ z+1JEdyrR>>afEP^*B7f~=T#5N^yL_j{@ve7iv9M$w=CpO->;rjw>w`^M%&7)dBu}m zP+7G}74qe)igvsjGBQl9jX&84DBTjrM##`Hzb21!I_hGrzOY zl8!LN_rV0N5FDv*GaWsZ6*sIn-;R3HV;1K=bvE?mfMey;5k=;!?`a2(>aOS&7r#-= z738^D1D{?}dPX|+NePtbsy;K)GaEZ*ZWvAxkh<_b&kUNYgACW|gWQlbm;gS#c#07? zjc#rqnn~F>#tqeL+BUsUEDxmZY)8l3?WEuXq51xw_m!7s7NUs}FF~B55Am38tO%9%@w?X8GPJY(x6=`tmmK!jU@=WKz#)w?E7G@}6Y za@uYUC{|dK<<27qGVPsw2GW5l!Og)O3l?RX0}lW8804&{_e+Ea=Bt(m+iNy!E*}UwMLwN=`LidBaIdH4mG>#pbjJ8KN2`HRwLLB4 zX2!CJuWY-vLi}49$+^mVQ?pB?t z>&ID4uI5(o#+tb;x#X-i*jK77y2K%iqu6KJ6z?nezUE3v*U_PLJYFgJk5oebli9<+ z1>3JrTZIHZz+gA8?)SgM2a@$$q1hT#HE2w2kA8&j;UcP7$F=%1)q2AYKS#03c5m4} zni>Dy{zg|30h!6qq!WLL9h)s(i9(wfwr=EHv=a>>(s-t2VX$&DF0|r;dJtWEX>lD4 zlP`AZs2cjKPYrc{VKNn!C_D7Q%CjS5*KB2|exDorn!<0v=0NP{TU@VH`eV~HXbvbc zqxx4!ccWG;s0}OL&8JI3!H<-YyVkqbg89?m$3a?f98HZ4)nCkI|6%SOchmQ$0mD(A zZoS!>Bg?Ast`*lw&4yb1{p3(gAI#!mt~O;POiekt-j0qoi3XA9 zm@eMMI-{{ZSXo);xe0jG5vit{AA$ISi1QF7WVe#He&Q%9cP6k!_pGkwc8T@I+=nv`Ll zmz`j9pV^M$cEwFtCYx6{miML^WqRFz^opI;lY@yOB6;raPu)ya)J5j1*{k(S3Y;FI z$?bDPX8v3ad=RoQPY5y$=C!UFe`S2!@H%c-ic2Hnw1t4F<@_A$Odc`ZFfX@f36n!< zSsJjYT(@bT)diOyqq)TW%z*A4M)mc4W#}He6CwS_29vHZ2J(Km9PDFdHjqD8;lgt} zYD9M?Z*4~SVwusOEra%k0*v6M?pC^ecnxP;q35DlLk;%Mt1RqEhr&ZpIYG_tVIt=yt@*^i_@w94)D@$)0XcP_=_RpHNij87jaQ(o6OJXYc! zJ$>kx%hsRU3j>2~n_I%o79Ccl;FLZKZrZaBTt*FS0B6M;$?s2wn?lhVHoqu%f)ZMcy5Db(-1v#uJtj`ik+cB%H#n>e|%?Q_WWY@ z;Po%?#V$gaO|DAnWLM5|Tv*Bg(9?d-%?%?~)U zcb+&Cttaq2-gBpMDR*Jo+lQQC*l~Sw9W=@)=SKDJ(aQ@-h1^?MvQ7RF&$vsR^z;b3 zY_@}gi>2p`Si9Pu<%UW>v1wt);aJpFGX_i`?zj7-iIOMY|BdQs_|Hz`mc^yGj=m0d znHk~cqY>LOp%(|?Q!JmJ+93O>Yp1>GGiOfTyLNIZ@S$~&8yy-oy3_7pQDQRDK9fJE zFXO&=KVmWw>&Y9QsGwcsl=1j;`jrs2A(q_mIGz?uuGGoqRoZ#C@vVm-a# zLB<*7vZ{iqjY32=?3;{(mzbsS&QR&O_d7bSHX-5(RWc!jzWIEItL}@7JBRZ~)h{Nk zKi-yUyxF-}beeO1+3U`=qsDE8`sU(L`ucqo-Q-k4-)C!Qu9U-E$6s8Go*XhcSLOzD zpQ}1||DAXAqQi;SdGpK3mFw$YONx28w+MF$R zi=UFK;HcWiF-M&;)7@2^SZ0e0?{wU*n$zchKK!8~UNDlgqi|vE^+Wf!3vHEF_@S43 zIbSWzGXy2tYZor7^32O9>96ivjT(Kk?vdpAWtB&BN2Fa0Goq$FUy#4CR3$UeXJCYriV>TL4h?(lB9&sC+ z`WW`HS!TvK9AKMT@A&b#HVylid^4v1bA$Ql!XrX>qFwe><>KqVf=vDvN+alVhlUO& zI~{+NXT{?5(UIZDIje#G$C&RW$z$&|@=1B0mlV>U=N)}7P*-l)VZ^C1@@n||m)Q9t zmOr-4!`=SYH6T*~Zpl`A+6uKn?a3K8>gv*m-Cn#p;V#>16#NPN(akEhjgfS2CkAmU+lx2xxUKm z@pAP0m#5gjAN@R+9Q8NqHeiRSTVOR_!qTAtOwY%hFsA*s2ej>n(3E7fbjSjci}VJ-@n6v(i2v+#pV zSmD^n`!*)`=8~&co_-6iHK|@GOa369zRiK8J2HXfJPD$*rt7iD6CL2co!%BHS-F1| z>i*b*W|Vt=7*%Ncsm4V+os%y6#uWVQTFg{)+`)bUStgHR zeqwyE1Q9PSt2?84R*O@Og}-Inw^qaGyK~c*K~bJ_1t4do6i1u;Qo14qyN{OU51TFK zAHg)d*~`%S@nehS0Ha62l2_kS@7xtBC14E)X*+vHw{T#_&bVYrYhZItu{|y(L;V|-Z{ZwoFI>uT68v3*)t_hxX|NpA9^S=4bgXnzI@h=e38G!2h=OFx zPQtYK3FIq-jI`tm@izW@EPQ?^neq$Gqakulb^i0s+-Ei_>)(-5*0 zQd!d2cUg*YoP&!R*U*xguHuMF1&xN8xH`)b7 zr8&Q`CeHz`D0eUiZ#;ls==~>17#u%O>1FA4(-I@8%QLKmaUN^p{`I-sS_3GoGBev*ucJQq?Jg8+Bp}AUJSwhB0jU9ezAKn^*bi$eCS888RBCP$U>p zFhSeS z)_zJ&!!Nu@1*&9WAzy}Ba99_B(OHMxp6nHDaHa_*I}TTuuSLia?3d+U?V2k5p~!0)Uf(qcQ@s25Ukks&mr?mP-Zbwqlk*AQ4bGizKgZ1n;sBiB2T&`M!DwZG zJr-FYVymmxx)N@UfK3|HD;R;`YQGAimb&VSm=1Gvgl1;v&v)~qpU;oh`@HZy=hz^P z(~y~Zo;-&snR2lMO?Q7UI9V1anh?MMZ~zQ}OET=h!L;DaB1@@CbldsfDnqeyc&G0y zD$}{q{*mtxs)`R%!lrkakohAd`!^dZ_H?grlY<(2Y{Fuu}_~N8f{(EX)%@v{WBm(fI}( ze*A!)BiAj&E(RqsJ=qc|>lz0#;@Nyw;Jd13)L(HCj1>`k6EF2y@#47oF6DA}nr2YK z-1ix~^I!zLus@xQ`c^vdeBkftEEyi42PnQJhV zMEbp zjO6-$12mgsL}_3H$w^ZLsSX?RFGEPNz(@2ol&6C}S|n_|uN-X?*xu$rWgAmKr{ znHCOXYgCQTo?!f?iB{}1=>ua@It5uXFzQXXtw=q&vS1D#mvUKSBhT&j^8V`5W6zXL99t7g54Eq% zBnf6*^Hc49AdN^@NV^?>cZ3&uwr}Iq4b0byC7QtTv!mxJ__o{TdWg-F$qd!CJlI#& z8YZ}r>Vu2O^#H{5bKBErO;eH5vZQVhOb>lokx9z?ZyIw3U-TWWz7@;?Ld}AwF|JJH z`kYdVXur;^2))-eeZH@a$UC6TV?#zRMoPDi4YBjDR}>#Sy%6piq$tNrw5EAMo!WP1|ga`U4Tv{wB*Q|APQo@r%2jlt3)|A1Km z*0?&4E0f#bR@Bpo5asggwCBG0d;b9|z})E9cSB=izO;x0pNyi^*;8%2ni4MNlEVjF z-xGc+^*fXd^!eZHXiWpcBu%>$x47@D|f3k>N^MU7=Vh$?7 z)#~KkP@#mZUgxmL=pEAWoRUERARhWnQMc-9aoQbj%$3*3uq%V(rz~X)9|C<)pmpKb zt}{5#rjuo~ipiIMShe)a!%<1q?-lkfr61UAWteFJ`JtY4{Vy`|o)-1B6nA%HWd@$? zhcR?kf-p_*qTyFOT#AWbh1jjYlSrOR(wvG?v$@V(TAzR-izF|@Mj^)D;xm`~X%7G% zMjXUj?*YX8{zKb$fN^-$&0ZSZvr*B49ZS)i*$`Msxd-7BV;ME# zIHHC?az_DMK2}`X^hOso^Ox%x%P_N7qAfHO-5SR(7kE2t6*Didw>hB68WmdbKnkh@fd3SsbmU-Chd*2Ex&G6O7}c&% zW)E0JfFxoQ%Rk%nD1AR zRS&ER77ke3s{f3e1#@e@(8L3<2GI^VG--(w?4(Bgsito z2DHzZ-`tK-u77hN5cSmn4Y+`Mn>MK%Y``BeEweOwdpdvYL&D7;tW;tO54Sd5Tv44G zx4SMg&W&=oW27V|tL|NfO25J~4R%^!0E0$nQ)-;uEb|?#z&rEwqDTzrdARe`EXdIe zRD2l(CKv_xd0bQQLt)-}O;*$-joS~-(0ZS{!y^`@w~G&YM6B8EcnheTT1m|Eu*2 z8}j)R*rxBg<%ttg(zw9He33MFpY&Yo&`s$am7G?Z*l7bkhTHb6LvEvcy|NrpJ~o$N zZ6w;hzN3qFiCreUkO`z=TCphZElw!P5FB~^?pz=5U*%=3 z7h;F_hs!$*iWlx!99_OtOUA`YocXbuP$Xa94r80$7La2~r)?wcp(HQtqK#M?AmSzb zL)Tv6GH}=4P*nKaXd#Zn#;X$$2aq)$#wOQPQnxxYF4t7?T)(K`vLc^*#nXQ~X~;NI9~O4%PjyT!I^J=c@xrYP{Hv>e6Fp zX#Fwv(@B69dDWCygPQLFnUYVX6ppIP7|-Z{m*JcJAT$Myuqx%0a~z}_e#a@Ct*EX} zWo8ID;6`R#u5dC>#Z<`HUvDRhjY;3D@}65${W)VO?$=H>W=U53qbS7bHJ>G1`6}+% zR`LR*Jbisg>EOI)RI(j{1a|r8xuC}Cq&{)RVvw%u74%RDwvz>lXN5XC--e?G{~)z1 z#GJL$T=|Ipy|Y4dqi5Sl^{CK;0L|*^#9q({z}+p^_I)+iI;FGy+OTWa7=N!3XIlB^-M49NUR_SO(P0PuG|s~MznO~ z(5iG?zMTPSBfY6{)qe6pJS###OQx3zQY}GDckf~-XLRg-8E8_vzHzp%?FdDo<7*hJ zqH1B|sn^tU5$d`RfjG;ofo{G01n{___D+&ZABSaqs5mdSQ3K(<{oLgCSb_!r}*V zn#5-*z-l6h0V4mT@)p-cbmu59YfM&WUN9O}}{AK*kImk~rTd?CM8QkiM z>JU_Y|NX){f#lFiD~T14%U{!xv(VL~wsR0qK1cde$10deENJT;>wJaq08R)B392E* z?KFJ90IA0(c!hS`pnEs+YUZQzJ*}w$BfW&u3Z-&345);UCs$r|EyyNHihcOHeJ{%CUL_Uip ze5kEFL$>xSn22dG2cwK7$8Zq2OLI!YThbXy6*7-9M%os9PIYit#+8n5{oLl#yYm4& z0Xbu&%0aGRLI)KDqfztYUJXan?vVD*srhVu(9^`c5c{edC&%~VM430#5CV!D{{wYM z6XE#izr1_OBLB`Ux77{c$*8uEizR2KZS2)#ZCGkv`U*J(8I8)7b&O;{Sz8J@QTu~_ z9nIsVty*xO)Pc3YyyC;nu2G2H!>rw0*_m{zbC7bT$uH9j2Fkb2C*J2KZU>xOr_a5X z9J)ES93*g}fU^8iQ$6**|EqdQ-At$y15iIfgKF+dor(ZVha)bT^w~!J8gSTzX})QO zWI7K&kIIRTxsxK$t%)Aq$>OdRV>9DXi_(&eKL|YOZ^G%OC&_IOLIddCWmSUfHMSu7 z6M1%RN$wRFKLsd+8=j4kpS1fb`QN~FRm^lGTjlu`ntIASdWHP}pdkUB8q(X-&yD&* zqN7k?i789ZC<-7g%;-vIy%OhUMe4ar0CYPGyg;mpiOD=EYcB)NT*3b|2lBMhTtxe> zV0Qv+Q9x0`i)F3|c-!La*fUDt(xvBK7&-lih9<0HO z%v}d7n9#VHFp!&d{3y}f$u4!Bm}EOKdKCGV;PuvP{&#iU_JhH81@-?9f;z0;t!Yq` zk(h)mZ<%=@-VpOo4t?v12mzrOh}z`4L(MMI(QAP>!wKD;=Z_hR(m$z}^ArIAs=wQ9 zVMv-G9@C(yAa2W;IN!fa?0wOe;CfQ;N?RC{Fbp3jo10zLIjE_o`5lcL6^Gx|7wPG`o-Z+u`5 za2$^K>((8%B7hZe^n^KjcU&^^eoR{bdI4!IfD8TQFesU?_j##>OhU%9RDc%0VA&4in}OslOL!9D8r} z8TEoYnlMk*$Jao;Mak+w<1ucK9JO}E-L!!E#5R(OLMUqHaK)d`g*M9)vR+3Dy4=BT zfNQe~*s3*~tNwRcl9;}QU0=tY=*b=J?!KYsvTc9PMD8rJ4E~9VaPZDL$mLR6O;_W9 zNZo12D&K5Q|AX?K^XE}*Bgp~+$Qb~Yb+7zf(zPZcmMx8l+W`FrD>mljc$LGA`3X7R z36+W za*+n0NzL&K)oySZ7d}t3b2L}uhiIn-=gnS~daMMYv_7Se%R6t{$<>e5KaaGV!O4HojpDU z1zolXI|a8Rsc^AzdUmTaOQSzv=?UtwWR^C)#;Faf7Nl@r@TP$@f~=lWKdv)a!*G8) zVyBB52OH-z4NjZWJeUl)I9c;YwwqxiNRCOAER;P7`E{SP=x0oPfuov-z6X8IcHQnw0w13URgF!k!L=?efQf5u_9>3ZKCeiB z<0D7jGP@T~L5(1Rkvd99slo3>emyYV`(KR!9nkSkSHy`WsVC3*XUa_y1^)^2rh&0t zqPqQ2&6a$3gR$|Lz7w@kpUygU9iEp6(~!~E5K?IH)45E{`uc1V0ufiIv$iqM-Rsyn zu(v6_=JKLjtT6hn)D2N`fwz;TOU&2)w8?)WI=3e6)#XD{WkUCzjJ^Tu%%MEd#ZsoZ za~kE}P!56Ew`clvG!8tUE9z(!+r>=$VbEKzQ$2#Ck5kVuXFC&ipUd>?nsqe=3&#Q% zo5rz_wG7z?tcsR5l;)D-lvMM%Zxx^71c6=)utMutyJVj@Wh8=6ab);?bM2PHpK&|W z)`I)TL#)}$xJ!MIBB*PMY-k&F7(>}eYQ!~n@$my|1Czem`YD~^n&0qLY4QVkzC?lhm>%BjE^|1PC&)MPzFN|khUSYk_<1%aIPdDPF_{eZdrs-d6a#uWf6a8( zywhUCuC;X;w@Bbn=5DYqM`siFh!?OCk}ETub%A##2kUS=dNzw|ik9)JyJ{E=B=)(EcLrGIWV3TknHRpwvKL0yup^|Ot0y( zniU{f#u~2z51#a6PJ}=nB0o5bC&@b2fRiL>Zu+hc1d@*SvX7@%?4tav*>^P$)@`d> zbSFfM6*M<(&uRUt1;@fIFd|s$@gs5SO>5x&e1_IR)uGNYy7s zQ)Y8*F?Yun^l08CtxpZ%stnRkYUEuww6>vlwodjM=XVr39W7JFvR%sUc%os2mU)4yWD1 zWjWUZLSVypwq@rV*3!_e1IC{~N$#5AMK0+4R4(C;;czx3y#0KVcbV6?+$F*1wwONg zV;$IU$zxYMc{cpOe$dYmH1)6~X^k5_R|io|Ee<^G4?~ z_}gycT({?E5Ehq|QbGkEfL+_B9(>&5yb z=VjtMn8atGoPdOsz%)1O;E8Ajj=m3qU`q>{Lu!jCj}An;!Pm3q?wy>3gklhz%J#F| z^=1PM7S8~5+yA<#8J?36h*bsei1$m{@-Y*Mq5xX1m!i!23J zmG`Kak&NVIRAzyLE}(@%Aech25I^B=Kq@kq3;y}8s7PWNmsygk==u1hE4cMWZ=6Vw zJ?nX7AHXhh1zU8hZeIhxl&6dJ-Z!_6@iXe%cn5TDC#i6BE}(aUdvW@dIBOzGPA;Bo z-v1x^e)Z&j%;KNIE29 zHvp6@CnD78iIbB#U`v92K87dM7_m|ngZ&-@TrPA^Nyie% zD}kU`GqpWo0q)~q+}QpIP$|%0mgyjA6y5@IE97MBY#!zJ(sTsA#v-h#8}5w(Fu4ML zNF4Dw|9tWYi+y z*Chd^fw6O5+6q*X>!;TXxVQZIot;4DYUnl68s5T9oqfFNojP}{?Y&qIk+NfeXF46r z*tEY?59TCr+`wS&N_7cY?fmFkxuR{Z8h?V z1rs^Il~=D;{GOg~zIZ53NDIB5GRa8Z7UVDZoJSW@A^&jeMS!x-NP32ay~Xm5ziM&O zvSmc54&a(dueyPcNe_0IE3ft3A7Vm4>Rf}YG2H_Y1-91zDJCyl1s-wlw%4Pq9YmeR zPEw*iiOz*ZzK*ffj=tC8T|4%Pzyr^&5m$uGm5nn1>6I2lpR1{JEqF%2zY7K2xHVJ1 zda*pRYb1wzcaLyhfN-S+jgVYdxAf#0W=C2&`7KRjPO{eL>90#YMc&2wL^!7b^8z1J zp^s4k_CIj_moiVyjb3QuwH%(R2Of&z-Po(txt`YfRyh_xQC^?^=BaRg7*b02u>X?P zup8h=yEmlGJy`X%8%3O&KYP*_UzxT#(}JCm+fFMUPzY+bWX~f9e!Zr@>*FXwG+_q2 z#^iU{fhJH{!mIH|hdwIp;`qwT5Eg1-P~+^iTOC@qT~f+gOPq zTJIzx;31Y;)`G0N?jL&L_l}b+T<^l~z<}|A_7635-B1`3Db`tBgnQ)2F>5O_BKB2K zbbb`@>=m61thl0_?qGRrA42TD;P@=o`%U_{&5BR;_$C5?gRB*P#L}A`l;V_|K%%dD$Q1Qrde_mpx9+k8{4C-O&u9sI_Urq~k_kOi6i#>-V){=2!0kAzqAf zEUGgPsxX*M3EhDKW2*A>UYCWN#>lriSL|q3ntQUr=YhSCxBVf3b$*sQ<9j4S=0#J^4lNLps<3~CBN8ZrWv)g8C zHWQf(WAo&!89{yOc^mmP zPIImxw-_Ce%)+Vx;1LKOcI0A+1@vU!ozTvPl3DL=ylhp#?ycWNgNny21gYZ5Fx;ijLy3${uj6#%!Nz?JF^ zk@7fXBBF>UMRRCV`u$RV$Y}bJM2V|v!^H6s_*_@XCOj`%;A-%5v;((WnDfRUh3ULX zR2$Q5)d*kV_yvOAkWmlT2Vz%MyU7csccvyrvs^lHX3Nd1&N?&xH{@|a_;&Pjbx5n+ zi@3R9!F@?%;|%vrs2sg>SS{f?b^c!F-BvE}!W=A}+>l?5dYqc3z-$1$>{oXt=xjQ+|6OV9lI#}J_@L74n z3~6{I4AU`WI)0w^F;w#@ea`7w+&cdQiYUj-d}Q}h__N(%BgRSO4gJ^*Jo^qkrR9Cf zGstLu31aaFR7Nu}eeOM=J;0(|R1aEM@xz#=y&#)fo6~d2Q2-y&)&c7kWt~;4`eG^w z>iSJ(75VzN$hakNyZ#&J$o~V?RO4BC7^pWMohgA5Q1KlY7IGg1#m}jw`c@5} zeBEDPH@NoY@t8^Goj;Z)k{wvgwcO;gzgXA19YqRV`Y zZ^&|dKXgPx9hm!ewv*f|Vosczl4zf;e7EsUO|8P`fH%Zw8HgqGnHKRovJz7C&J#Yh zSnd-KPiL<5^85Gu*a@h>bfMUWbrOAuzzS+eLYm&0vf#5DD7gTQ2lyi~Sw_C1NvT-0 z;Bm0CH8x;_7ZL)>>p1t)n zAg$a`p<$-t6MJ-pK?|VsXUC~}EaC0gS=rO%AU?}Jq_J1zxCOxa=p8-M?)D>fZo4q| zHEz|a9vNR@I1L)0{X^sa3*>u89WqT_7$V1!f%ly z62;{gu25s1I7jZQ7BDGc)S$}u2UvIRV5hrUxq(TvK=*<% zd6!eYf|(7c-&(}9Ne6v3>FS56zIW6)vC?jaE1;;D=mtWP?50LB@0#7S}^pMp8Y@XX+Xs}K@ODU-op0OSs%FUMRhu9xuU_m zSVkXLLbZ)1q#WcIChWRerGTg4=*=sW*?$-WfJQ8F4ggIWn*uZ`+~SM*wcbrxt=K^0S>0SS19Ol&r{kslx;zQ81^g}5sXW#)_s=bww&%adbH^kom?Y_26XZ*i8>Ofn3iSxge1u7T?DukG=SX@BaC{%7v~*d~8d>=Tg-uDb{yal0)13 z`vfl)Fp+_&lkLQMnG3G!&6ZE}@*hh>NilM#9OO*TV8aDnkQ8pw0;Z9YAlR+lqNXn^ zP7bj8FPnX$g<^o;Q6TQgMoXkO=z276i4-l8GZ+rpCAiI97^8s6CcU$+KD0sI+65Du zaP)Kbe(Ge-k&vWsG;Wh3bW`Je&qoW^&eeyI;iHCJ*;UN(IZ8haTEKzt2jEOhaGV!| zPh^;9Qj0D-L&r}l)~ix3*=Q?OGff*-GqfrH+F{l-A%y%ZQFRyBV!FGox_0=_=_@Io zp8pQaxm0+>TPoW1&Nx=xTlowy{y zUA{|P2@9G*0KeLr+w}8H7YbXi+mlv~V_qPeOO-dci;o}f7Dok?o&I@YU#9HfmT8a& zI1?xgCiDqvr%6^KnI^xq`r?*9p%c7DYT>*Cv+>He0JwA2j?$UFHhF6%@;K#CUi2}X zaRDJE)7<1P?mziRILlfQ48~l+su*|5pW&tF+I+qIU z*3{7a^7rC|(SW#AHy`Lf0|pFAtj4Talu9;3)W$eUYXc{R$m z?lg9R2CjOHX#5RO-*)VI?p^AJP2~tkw%Z+8DTwolfWKSC)Uz@$S*^|@b=Lj)Z&@Ar zg;ZheMSRmp%vFd8a3amN1IG^b{{r8+XS%n-@tZPSkb)w zr_HLXy*psT(ZW-ul)q-T8T3I|a$5~#FrN611}K7o9C6?Kt&46T{!0b9)t+Jso~i>R zH#@8hSteZJ%F#tIo8@-wz=Y%o5<&-pzjHaJ*L-1~G5RsZAmRV zu}1bINoU~o31T5XV9WsbS#D0>X#SGmJ{h1tcBE#l z;R65R?O>>m@hbGp_bpLy0q1}dak&d2&QZ-pb4qX10P!j8Gi5vmBeizW+J*IF!EVX! zzP0VLK=y$mNFu;-&`u8{+=Z4P#^6dVN&~U}lYX?h&1~tDM$!{UBsjWkh$LgwFa2KU z3gi^<-5sT&bXVj>R<8ov$Pws2T&`en)?5U>5T4G)O1D+L`}TejC0Q=*4&)q26_q=J z>!6Elw&TxZnmC3GjFzv7wbPd=@+Rso$>y+C-gzpBbOWE~SmsaLT#2;Ml z9;PdrM}LXI3@fP8J{x`(D->^8ov|$|_tI`3+1JSqQ3U3Xqi0KsF-w(BULW*J1L1$| zFO=okkJ{Wr37&oeeZ@XkDPB?nQQQoS$mU(`Kz!u>4s=BL3z!0kkFT=ts(P20&4l*e zxt`74o!*+lbl14cPwcs^(+(Ci*Y2ZkL@rraz8}(-QUPfr!70UT5!X3JKEHNQ`k)7= z5AvPK^J08>^b0HEc1{&pLTU79JBarRQ}GgYl9S~C4!M3S!t>9<>jkLR34qCBlv#J& z^Y~2fqUk^e*a&6%Cx!G|T(z^j^wx?PqWAFeESI+jo)dD1eyIfb!sPRTXx}&`MCgSqEOKV!>@Fk61GhWDhM?$V zc^KWRI%AsJXrsP)vXZ^c>&r5u>}@1xNeo3I4<#9|gPdt2uhWg2jD>+ZL&J@pS+%Zr zZ}?w2y4)QqtJYs}7xobN*8y?E!m3LU82GIey*(R%Aavh6wznqwf*P@c$Lth?%D?+W zW1nDNDn6?U^)_8NwylxU9Gub1EO$?g;?Fs{5^lU1E*sD`Ef=!xJY$Rf?flTn`gy(Z zClx!Hzs8I%TCcrNl(a`Wc;CTs1>-WOZRv$c!F9p?0%czk)XXG!6}DQUJ$~y7N&l3O z33lxO(aZdp4s4fh_t+CvaQg@+dW{Kyq(nC-#?!bUb<{BkT zhIzsl?nAbA!T^4DZnBX>AdnDwUsy@yLF!7aU;0iQU=Di*`kM@@*p{sRY-Lw$^E_j$ z9Do1Y=o$+(Om0;`XS+vHv)S#~4~XUpTqeI}as%ix_-Tt|KHvKckoy+U2`@EyLb%lc z`_e_N(3uV0(Mvs8wYOeGMi`Z_1pbBSpz$n4eZ>@%Iyl? zZoLnARQl4h2p4%;#%_`S`>1$)Nk%-y5q8jvdjl)9UqvzaYSd2$bJ2PQjLsyO-Gi+K6?G&ZW9|DWR@!-N7)?LPeArTGwHUcp!5e*Zv8=k}};Um37jRS6y<-OUu- ze_tH-cXdBkG~GI*c-(R?C{cu_>&b0m5qZbiQAO zin(L-CqzcY0qfEyYtFvUscEG^?A~{{3!qN(1!xiFuPz$AIQet(6IKnlI|xj_)cwl1 z0&heA-Xb@;u?^|C{utXxa3}Hqb$wX>p#1ct1m;AI62Mjd3XnLpt2&l(6yHgmDzQIM z_?lkkOSg`dzeqxogZry`O_G>;%f!|bF*2=pu2Yw3tyge)0c}I|;I zTl^xJ{cu9OW!+)lPvk{s;g94@R@Pxb_qj{oV-j@1cTukjX1e<#%sRbecRV;Kw!nKU z5(L|hyn3!}8HUl}oq<_g+lYbOQ)GX|VdZz6YoD$2_}t5^d_CB|8N31zRfZstLp8>X zwOyZ8_9zVq%Eo)>p2-`xnA`qMWXIv6XghRGoa9asl;DFP$!IJ#VOdOT)$i z%F)+xhpHS-W7+(G8P|l0ZL^Y$ugW}?R{ti>6#78}V_>?u?co#sacTXFKiRW9?7mzO z`;hm41h+VyD8r(nA_@*jpkDqQd#Jg9jYV9h^;(%Z`fWmwgMG<%ZoypZiZRbEZFHSZ zI-Uu$3FOBBH2|SAu#O43S7+2Hmqj&j5ABytq6TQa z&iUvq3VmTPP=+Sk0akOk)lVoLDygLtVt19;P$b{!;CQ-f0IQCnx)l^ji&S7TTeLC{ z*M!gme&E{XLewzS+cp_`iSvuWuO>To*8!HCgprWAJEUrL>n;mavF)t$C|9n>Ff*gB z+zU4OhaBG~-|u|wb|umV00mlOdSjSQhVAr@1#JoR!4krvsPpD6F*B(!N=(y+LO#IN zYZg>Qur)P{cDhW;G}zIDFjS;1aWZE}65W_tY^-`|g?VyCd{T~(fXw*rn>^>T>RSNM zrSzRRDBNulLZli2k8-R~h*p><^RdeJ^M2cN`R(I(d&`A+YqEWv$OU`^Fs zg)UtXunnmxz4?LXZg52BSjs1f`UBV0+7FaVuLHORNm984=)v(Hf$l8yzsjakTP+zz zdCH+bQNa=0%wK43x>j9O3MWT zD**AYPWJS+BD4TLo7Ue48whLF9t5RbFbQ($W>RJi-?ll%Bcu8iC$mPf1haNks2v+) z8hj^R#(q5yG`~iVw2{yD8t(AjA-XL)gIbnA$6auEl(UQUQ4dcW(bTOvO!}p$d2~0G zN!%SXTk9s}E8N|px5&#h@go#Ou@=l3iW3Nz8ciO^ zT|yRZ6Di$`e}=UCQ2yc_P$C=?5UUEdgO7XJw#wrEb91NSpM`!#7=Z zO7fJ6nFUOcY4_tXKZe%WXU0g6yzY-jhG^)-i>HMbv_>VMLN;Jp4qA8!2WwPcEM zci>de$u*!OWfjX2sHzC=akE0lj8@+ydub;Sr}=_fc9X`$DV@#S(dV=wKuBnZjk9^M zP}+{oU_i>06nk{BVj#>~!S4CHm7x&ett&WCksx;lew-F3+Xmcb;0hXB4jWEBoO4-) zpOXW~jWl*Y|MKwit^Q&o!KF-zsF9=h%D@N?^1AT!yoyHE>`ZCVe04=-)JceDITMe% z0rOZZ&)D9aN*3b1@SRG^XtI;NtKoUfns~D7vCG;uEWa`EwbwiTiX>Woc6H1k_|>jm z^a2h?I@RZO$M7x(t3OV)ezUre;I5cHp(zEeDj*4u4FXFvxyK$K)8z==V_z}t*4wLs=@U<-pd3BshO8kUEfmGg?;r3^ZVRkM0Y@Qpfx}wI%128O zHMeTGDW^~Pd`&kGg1km>xCWIhi*y4=NcZXr1!AIO1`N$7X`>7jT;M>|Dxri9>AX+H z^>QtL)1F|t^Ym~k@wk%Bp^5?W+GYBq zRb>R<`LP37-BRLGV6DAjVv|Y#H6YPcw-=-IyDKGAYNmLF_g@aMguEOJ5Fx5)*}m6D zJd_*j#Pv_d*^3G+a^k6^sCjHX5V>tuW#K5X)4!-%6BG2WC&_Bv{wI+5DtR1RH<%GT zF-^%kJR9N}JGUKJQ-x1Y0_S+`mfL&JO+1+rev)TPn`f}oc*i*JmMJ@i^su~9SVUet z$Fa8yrk@9f2EtQW0h|TvqFankiW>m?R&UL%p?_&RT3<=BZa0)%2b?HdHcpNx~nP!6b+ov$Q09IgHwW*E{0A^KoR|yFC;grzvS?MaaR;g!_ zq3;bfz_5G+o5_|{-+>=X>5&tNTO;^~g%ADGdO<$f6|c??VICa+`qY&U@Jgw;vP&PX zP3{zjJ;dhu#U9HQ4HP{f%DbWIFr;3R8Fu(q1xKA?Gklpjchr&+mspzzdEv|{s* z#psZr@Bmk=LX&<@H3;nsPR$pgw^6c+BZ6-}0@V!J)_aC&YA z?Xo;KFl=2{;j?4#`N5cp1)B>I0A9@B9t{wj#QxW{1Jp0MLk2azuw>I-v4a~m716-b zd#NsmBiXK6(Pz7zR<-10{qN9vL{-G22@^@b0tM5mSvi7Va8MYsQT7c;E8a|3gts(r z4^ruW3ePI(HR?+lW5_lN{hafJzwc|=(if9~k-!Ltaq|=x4adV$g}<8P)^%5(&}S>1 zR<`6k^QNB_}~O7=!gx6}K+71_Lwk3-XkD{nJX(>j3_goZbvZc2E~R zS+!Oy$~?{a9Iy5SIRZr=#84u{asbfDjjc(3Ba{AbZTH>Yc3<2Zsd@t}=hSZl8Z_ zb7|;%gcCIG->1Ov<^_h+jx6|jd9gpoc{^Ege5YDb^0zvDOghtVnm}qxTw{;Lw`Z1u zp5D%lTIDfl?->g4|MoJZL(ukqC$Hqi!-dfy{Wr%P`6ZxcX-{i9M-Zg6ZL?#TK{GQ- z+ORY~Ea5S2oZiH(TK>D2&D>92D|w(Z%qy$2CEk@^l6z5rwp9?bBI9e(neGOI)P8<^ zA3JCk>djjJgvD>Mqq2>G9cyAJ0$*(*t{1YtetwY(4ygroG*(9pgDQ@^0iE{dg<$?x zqit3G21sYsk4FS)Wb6H#Id-PQvJdj(CE~tihB_Uh2@R_UkK@O8|HkbcEV3G0sx5;U z{;u5X=>2uN@G!{hZLy^vi!I8U@VCGg^`T)1Q~alDQI5GTPPS0|LL@C$E@Js`UqVI|MzlUuHR|5mcRf{M~(or*UG=rbw{3 z&i;97+!@OJ*Gkdn3{sDK4NV_Ff7++KWD{>{iBS176J{eccqnMAPJc#n`BIOHNPZNS z;mK~MWPUuN>+D3%j-R9nlD%;03{|+Xmch!~yFT-LU z{Bb;^Iw$Y^3;C?3bbz#FBHaJ3C9mm6|7e56-07F~hdcedP>|-ATqHD8+4e3+VZ|~; zGtY_#9!oF`&){ac%iZwpeC68+4XM!l-TfytoU56?CcmZ5KFeiM%9=fFNdh^Kqxd;a zukX}u4^&yb#JYiRg2rauYD+`b#dNW;JCHSjM29ohw>L_DjCQP#2OpOo-g00c83vAL zsbqMiQIEpbO~M+>AMviXx~u{O=2(p8^p_hHe28a0#hcn&oq%HGgH)nxh*aCNS~Xvjh7zxP=UX$jXm08r6u_R?vK4+O z`M_U3dx^^sJkGV|wLW*?^XC%{!L`!d`)#u~=0%Gy$Lp?tD(kNzE6HX~Hw`V4YA8FG z{eoH#z5W}4i9Tvwc|os_{9cr1F3R`{KxA@nrc2xm ztO{I}8(gpR_-DW{i$B!#BB)aM!6@rF60*7~Kr>7>$+Hqo^4UHST zNAzTJSL#n&e~N3OpMJvG9ZY^+`R+1b`NQC?!O17PX$98}|2gZ}JB4dHv2jzk!E_Kk zJKU6y6a4FdGUqQyCAqvF(Llwj+7rd`%BCRczVFprQoHiQY~T56rWd9qUHVYMHij?i z7jr77tEd0*3taXs3bMuRJo}yY!;U0S!}>z^gWJy+f14D5Kfd4c|7-2L|C&sm_@5Ap zC{pwgkO)XeTId9%9g2pkLFpw*J9>$LfIyHt)RRLDp;wi1kt)3t4;4ahf^?Kl3@Ac! zfWUWi-`D5$`u+i5ek0F5`|Qrn&dk1NcBa_)MiMG>%yXLe!~p3yLYvap6rjf}GLnTb zxUsIsAt@vCT4cFVXY0q}f8tA)MKFr)Ycx)ba za-lw#kD~dJf5rE9ej>$7f-2u7=leOLGMTe&!lPe>suA>|d5w{$*2KR;n!i*^~}5V5zLpvI#i9VIjX-i zQTx^L$2sBeVvA90yhLHjKa}rFuVqM5U0NYC3ZtCjT-OZD0_ZfDshq`z@z^7kuWqp`QRjSLX}!3OkNdHaVANM!7#h*Pq;1IHAq_h4yp&?$GKw8M2=#fk&)EJlH-Sut z`5XtFqq($Ar~DP>I+9wwS(ql3ND6#hF!Q)v@Z2!lAAwgh<((car~EU?GrYd*+LrP> z|DQK}PsdWGuBpjWH!X|j(c8hSWf7<-dJ45fzGLcXw6fEqQF^PZmx*zIvHMb6ffcbj zDOjcH?XypR-r>__hP>6t;{Nb=NNqMaxTqs9lE*4)=b}~9lSZwz#|>gc-2)=!gScxD z?7<)Q*Lx}hy4nTm{Ub6axAVf!fi8;~llw*{I=|da>=b2PmhP!K@#*z#Sd-POH~xgP zjQ8P$)=k2+d%L)3=*gh7~J5m?#@I?;4{m9wf_U`($Zq=-YdM`Rlq`d=IG^NON>hc6;53)&9C}TPYLeVwb-R`g#~#qf&kN;PfvIvEN>Btjv%VD=Ih- zT#`y8Hs2+%4UAY#Z(W}^_@e-^>SSFr2>MW~UTOU5P~Bh?NKhi;`KbgQ2^gCi-d%i1!NA<7jO>~T(MdPkGK zW_WJP3AV+6<@*_(QQ*SFw*S{~N+UOK7jH%l=StZh_1TLgo8!!-RTYJYdmEZ^*GR&| zuFTJ4-A~UD7g?xg^*Otp`xB`PGrbcT(1lxvE|lY(r4m|)_33|1-GtUOcgQmg5>J}u z+nl;j1Lx1Z$zB}&MOd)dC!(O!wIokuE--6_yo_>|D?*>%?EUw%2jZ8!_9S9$2>(Oe zs;k#jGm`3&ED$9Xo-a_nXm?nQ(l>mN3={Qh)fu8rD0epJr+C-}{d#cm!H`m`4DDdW zWMl0!C>SBL*Cl8$SbI5kzQJHqx=i2lw7HCiU6))W4=NlJIHoVG?c1g(4i7CV_D%vN zXzAxU&jj5(EbHYlm3xRQDXWouis>=hxENQjWIp?PF8JwK+oq_vZW8V{hO8CFK#PHa z-;3}4ChGHH#)$CjCD&844ZWZe=X;0We^jpt*2FaVOc-ody8W}^p)bK68~Fh{p5vGI zqg>7&XXiWZ8h%)>ZwNP_Z5wN2dM`^egBkueX_SGYKdQ;go0k6ok#X$-+ zEO}{;S8XLH?G2xN^_sq~YihIVJ?BXufuI!rYj>4r;!3;|jx=63yS@2rs+rMW@8>d!~FdVCh(6BW_- zJ5n9S_wAEvd@kEnp-UeIS1*pY)H4W|&D#d;2XnkEE)})>jHFs!juGq>Yy7G6)$1?a zLWjYr+Ch(L>c40gt`D<`Sj5C{hQ_7rw|gRq4%dz>xgIQ>uiI*icOm(9T)Wocb?xqN zthN$@4N`5}A3IC_bhW*WvleSXY4WBiHjdTW9$eD8OC6)4MT9Xno zT#fQ2s>xV#aTu;&^0n)-!bE-UPIF8N+qY*--Y?H~ zkv0wr(Dh|pHfnw+625`ZRtufTETlV4xNVvcKp>^n#J5NGlAyxSGew``rNq1oPb~UA zO%`(D+T@&eTQ{Ogc6_1Tf34zT9i4AOLUA}qw-#sIo`Jx6bbabA&>9!>q zG0Xq?jPDHjQGdlSHK3~R-b6s`_N%W=RQu_Y3s+?5jc)eF^u|8SmYU!6VUX2VJy+)= zoO9_tCs)*(7kPH}X#pbLam&!i49kp3=?^yP@B~(6@I}RMnM=SML_YLeKt`%~DXq$*XRh zdE4x7KUW~BDd*;>71Mr0F>U`H=f(Cp?^q|w zNjuD9xlD!ycZeAmt=@kTdN!&4-{Xfj9h74I4b*K?Puydry?)BNkarv@ObnJSQl~}j z-P!0LO#sE)Xt=o3H%EHdLxnP<(0Njm@ z!08?oVcXrij5?b6xi!}Qt#{(RzCcBRcNV5++(fChihfX3)@W>+>x@m_G^PiS zs?SFWlRa}Wnn7t#*|+OIw`MA8N{>eKbmZAN+}o%ConqoH5PF!FDHp znqVpG`cG$U8^m2(6(R6~*Dm5(y3=*c(`r%X)F6#s1|7u!Ft z1?9S=&HPAAIMMS)WWVRM9RSn(2S3Y@4u(22UC(Hi##Zh~ zKR)(;nZFhKzDISu5FACkgqO>pWKH8cS{iPjExGPv4|h_9pH+moDXV%;CLQVheNfhD zYdRU2Rou7V1NGg|&G7N~=6BjA?Txf@TD5exC=|RQ%8rj%^Pue3@CS6oYg3CVXCCiZ zzBT}dE5B!70mOL6L&&#U+^ym--k9m5VnQULcEI4VwJ%-XtT!JC?kW2)osp>n>A>~p9XJBNvc>-7k-c^$0D5gky-zC4HCZkF#% z(B?wOy+u{rOxLrW5}ijAFgUub;(?l%LX6MQ_^6-wpnaf@M5&QEqx> z4G)RpBf8;;RY^J3HogfWFa8$`P;sD`vG}z~Ii=OvwCSelZj;m=`5_Q!tOy^q&Cpcdd-F@PV~;OFXG2YtQIqU`e-_NV ztMw>aDJ1Lu0^s3(1 z+BNc2)<2#^7&Wa&>c5jPb!ZZqRqJ!{iJ->TR#vY<#U8mhz9hoe5)n|2F%~87RuOC3 zBnqlD4h&H#Rq0t5+5{Nf^9M#1;pGM-iC0fG1GG`v*xPl;;&6;n!3_Mp% z&jB&+xAdUs60YzY^*y0OwgCxWErbeU=>K*U`>)?HZL&65ZgR)>XkLO4ov?vn7KKMJ{2GcF15Yd&!xH=B@itGY}^2groL;r(~nx`)|WaL39&-^8O zf^mKzG*Livm zXGL0Rw((>CRnq$-bTU=6JyP$J zQ~Hv3EqS3jP*QJIuw>I{>j$`-w`V~(EcX7>3|_cYT6x88)e=Z|^tJ_E zUPO$S44*dmIY{_CvfC%t-W1SzCygsN~>)@JV}uC zslil^HBC)DUi|Kv47lJ$J%XT|5sJO^&?#Drl1>upM%w86!c3O<(;@6-Q#sutV%ylG zYeuLK@8ZSd$J=EDXcLTz6B6SFtx*lc>IwEdn4p*t4w4w6N;RY}b<%H7UUIuA+a;?| zcj6nW_fu$5xmC)HS@pcSMX)`Ta215*FI<)d2@69K|BUA?rl(m}=bz7TEId8Fr@$`A z4WecwEp;`1ca(_U@Wp*{#wn7oCjG;>?CkWeN7&fNt zBO3+42w(LQTJraKWp(3+fx86QUGqna;?*mHl>y2(nQF1x+Vr^|2NF_Uf!IAs_v)f} zcMQAjWSu=Q=5Iak0#^uEA{jw8RM^#sNv@C6{t{i7NtoZi=z{@VJhb=H^HBy2^yvZd@N9EmTyR`0SD5CukC9o@|W3Yec-4g6#6?pM5J2 zx2xXU-TP}pv?xtR&nL_C^Tku+uYk-d_U4}YEC((wfFcJPyaBl?CbKcCv$dV&Q+m9R zfTSBY*23J?qtKo5_8cY8?O_szCu~!iX$v+=LaU6|W>8Ql;AC^#hhuG*j2|j`PfQvC zG^F_}s%=%UMk#93@}ND83=e_Kzb?cvbM@) zXZzurv4WXV$)~{2P$#7GFa#-PHi@&s$N*R4M9{?9GanZAevh;V1;b>($a37(kmAmh z8vWw&%}AqS60yiysvu8etP{8MLFtotZeWo^?~^hyT`uh#Sio@0D~-|-^xT#p4M zi~xULKK#thcGtN0E$lMjeIVZ&CR4Auy^3?PmSNgANd^&2cedSyn@q zVX<+m>lr2r19H65^Brq_e|iVzD!)8NbdIOGXOEo>ItiFmd6@d+RSCj)^z?l@rpWzx zja%A})ViBaI$VuOn~Jr3B5(ZBV_^1K6hE00u$C7u(GA~>bz)o+<4~(rPhPX}c|whE zv{i9B>?}OEIdwAm3YL4}7Lc$v3>Ft6f(GJG54CvdT|YenIOgx2T=*K0`gTS)l3?3n z@EnqsB(N!sSM%71fcUioYT6JXg)$GRiN3uL&*{a`ozhV<@kuXGplTV#+4@}^>TCw+ zf#?PS2%Q;;`=4vl+BUVEWp$)CZyMcSaG(&1eOKPtpn`ZR2`=%3#KKohvD{)kv4Xz? zs9cvutpPjS4&MvT=%llbvAIjZ8)fycSgU4BY$#K&Jqt_GQ8q% za3(fIUEjXxW4Ia2x+a*U^a@u=-1Y~@;TB2qOm_y{6CC@}mwLzW-PDQ`j^f7LpI+0& zHf%uqbf$wFsH9NtqDG~|CyP&in}+&;SVcw^5kYXS1m%Klydp37$ZcgqhVwx)P#N9l zSN8!8m>Ku{aRI5Nx`MY?{Tio86?rH?D$;S4X%d2=u!b|jLihkvs@vfTz6r=2PZCX@fvkIQ z+Xl)W;pvV((}u(I*{S?Oa;0y?P^8zjIQo*nFwNT9LH${;i`3EGXDyTMC%a*<#(MKw za&^SMfZGJAw4p%m41T>8{G|Pcyo_v@&OIK!1EvFDksfNm5<)&vm+$zr{&+@<1Z(Od z!J1tqBLFb`De>UED*s=xPu75Bj9kiwCxRTq0bv(s z%?(p;%mHOxzU%fq9?sd)qhPY1qQ`Kk5H1R>-y4jzOiq$->&Hz0I%Bcj-F7ZR987`Y zc^ErCer@iBN8mtb+vJ9H_Uw~7zHe?D1s|MDHlQ@ zYjHR3jQA~#=*bua4?L&BwRqp(+?K7eD(0NC?(pubv4d}mg6;2?L!+96i-;E(Zdg+w z=TbaQ%CC-jbm@VbWIi@CPZCieN0jZvT>89an@o5nu2*jZ%O5X>rw~*)%qPpo&YYJj zQ=oFpfm|mM1O`Uy=Yhp2kv^e^BSHU3drVKtn8}eY$=WG4EsJxvckOD|dKkv+#WTWJ z;N_lcJC7Y>UBRP$td;`{(*|Vk6paoh9jI#vG9r9}0M#dhHTQvz^AuAi6cVdJcCMtY z31{VIEzH*R{&4UYTFN4&<66-#iqd%Wjh>U{7&#ARkPa~XuA4!4iAPs-*7*YK*C&Ji ziiAt-OO;2F&N{aoG(@4$Z6(SWc$26QE6hAD5rqvgl{-B9aXR>fitQ>T>1;B*(ep5l z51m)X_|D8-`%`~6@5r9mj5*$H;W7i0M}Mn2X>J<0(3v}y*raFck(NZb{N$@P{^o|q z@P*>JiP+pl%*6C_BQ39LFgGBJq>KJrJHdaEGS0WOlP|~Wvg?m* z%ooZ=zq?AYAayyf

vn8pt2c z2m4~XK>-nuSw{;e5WtcJ!%SAxis+pT20WOxl>aIm9Bg;@d*>`^MvRiNc%4r1;&R=x z18ItZxcEO-|XFoErywU zVY4R-{^9xu1d^?Kx%h>=ZQEqK5+H|FWL%?{(pkvMdrQdKV8pA4D7`E4qwx^4qN7-S zG43W1-X`UG{9C3rF^KHy;iKEd`d1F6>zsB=$F^^K$U#o%QS~B4+@Y*YuTw1Fs1t~p z1H1kcB~7?e>D7x~ zzx{7~Oj#aV;@LNOg~zrdc4h)4vL*)dY1dKN*6HGgS~mahMs|BaWMwH4B z4F4Q3v8gU9uUX!gTky0Hjw~y*kG$@@xLgtIG+}LN$c?{A^%!act~3nXumOx*eHQk4 zRFaWZY~LtJ9%8O@$t8PC&Nt^!C^@7g(JzRso}1~W!7t?qEf3Ag-9ciOenF~yV>~fXCa83gWEp! zjeXxfgQ|@OlDf1zO5_4L4>pscSuCxHo_3(!V^?WrdugOCh7hR}UEs%$C;2iY8Za_( z?YTOSP3bd8SuZw|t8(&4%?83xH<0%}aku%N5FBi};A=oDV@;-_=KeD-(FL)l{cEwh z4{;M{lsxhhlVR>Ha~@myMK+Y@OEp~AdomYj76oA|nzzTd2oHoOamGO}b3T5)h;#>S zf7_9MIl1K0xjeX`fC5wSw4l$3fe6AoQz7Hsd2n`fEVp3F?3neM?yzHkqdNsg-rKEN zrKeoJPWy|B4Q?UUu)Mm6sdIgNdOYkw{-Qz?Fr4kzhFPH1QT6_tx_mpqgaZ;x_mSf?PM$KD()w{z0>+L8 zo9rC$^_VHTY5Wyh(>!`~b&7-29wcKzffLj`JufON@>%L(#|2%I{hvG-HG$MRmUu}p zAPSQuC0gVT@iQ_sYMcTJYOJo%xFSHbo@&1Euzhs=7#5cLY^+H#i~ZjLm2Uqg#XHBECbLV~{l zrnT$NnofLGTS9*bK6`s(>bqb zVdj;rKU;=W911(MlRlksR%W*vq>DL-d;4pO`*mrUb%z$t=pE9=e6u$+Lx- zNaYeUi0e5RqPujp3vEh~pun7WD_1;}p$X{JZSLNN5WWv(@OFwp#b$u1t3@$zGtGik z5IP2#r`+L%&H}FCn)D?Zi|(ZB8bpJ)AMRwsGo+bshow9aJZnE_<_h*W@^A$zzQ*h! zw>v{uSvVBRfH8k!f*I$DRV?HOYg!ygK5$l8Ecu-u^1OnUC*K3Jioa!g^Fw3|2U`Eh zUnrz4PJpyG4=CVwE#~iGgjT*H_7aq-o=^jf z19AsNpurlGwI}g#2%YEEAIcH-o*$JOZ-EVB1qG(NByAw9eS^WLXXH_DgdYoYNs;=R zsSvq3UUI8slaFiA!6Md$*%4FXf_-((htsBXO zO=k9UspzZZq=nvYuI?d78T~`&dPwDA$ltb%n;XP4(7EQ;T<@tZt4(cbhGCqm%Y}v1&)cg z3mnkGueGcZ-yO8SvN2ap5Ri+Ja+&fV9s@E9#Tu9@Ly(d&T?}al=8LV1=RVT?!EFlg zeBkh6H-I1~U|t8l+J*#W!J@6L%yBH)5ST&}8ZeVj?D^dRJH#Y-_JR9&?%4LJ!#jXl z+Sj+ytgi@~zLvW%G~-ai0pwV64_Mxf)^0{+t!%{+nrU6x>WQa+kNmO1T`jCsny1YS3#!o3ubpp9;bnu=USfIdf~#EeX!iD-eFP_TYD5j9mU9x1O`u&YN)pv>{HV46Vw%%CxF%;(0X)KGqD} z$kUnEPH}-n_?x*1nhm4FxnuqHi70469XGAqod!2y(!V^@vtQ1O4YKiJaNu;`Ma_lV z)gzDqy(53>rx9uO4g<6A%M1mm%p*^*BKFd;4BpHOC z(viI|39tvuDL+U~nK9!z7kME=0V2qY9N*{W3hofb5V|ZzW$&<=0EW*XbdIHr9;Y^Y zXR88dAHI-%Q3SY0i`!+^rqUI%iudMR^^k1fW9|yOS&r@^_Bb9wtD-gs^h2!^U54{< zWa;}INLD%j%BG5UpV7q^a;T=ZF~Vpl- z@_ZpcJn&yp`F4@SQ4z~0RYqoIp83Xrl{TvW6aa++AY2GaoZ*TOxkiGwtyJg3J=boh zLm7>z+$y@60}n9*A}?JYOdh~yGJjut21*>E# zr$zXA+h@pUBYR3oY|m(eRjs5C6hBY{u#P0-7S0&0x?4+O#m7yJ0Nv@*qx;3c7P3+?S!2=N>7)Zf6YH`m z;c(2j6L8Rjty!qAzZ96NUf|ABD&`zIum0FaYjlnaTye=bPo#3JLwH4Bo)ax?T`mV2 z4ci|}(9HQcf%%PDd~TBr$Hdz-sLg`l{YBSi(R56Eoe(>owvk$)-yIwj5H3A`bZWVD zj*sjFPAA<@&JX}-(@gRjkWvo#lgGNG;KC(-t}b>6V$DKdF}fAVhVKV~4YVTm<&`lj zC6(lp3#<~<0u*owDCUUkJx9`87UArH0Sxbc#Vi7>8V9MQ z5ts4jcxwHobc7nEhOSZcOZ-I$d)j7)(2^3tmFqCR+vxns4Y~y|a+|f?=uG3Fipt)C z|GC5PW_CRvj*&7^lzv5POHO$*&@WKQZ(5^U(tE0*#(FZPEcYUZU}Q4|k1kp<`x0Gs z5bJd92KT}*pq&@Mm5YFLy#KP+Xs_N{%uIXC^kGiF285enUkoS`xl1diCr|f2s9DrG zjBh{SBf{^;kr8AePjH{(t880h6N2%$DZTk~J3q*V+1LFib_U4J{ASzx84>hks$<^K zCXxrs1_+XFcXFUDb*W|SuYaB5@1Zf6nzhZMfY#V7hwV$(ImnWVY+quopKjfa56)k{ zABbF4Xu7g~iQEL%EXVkNPP^3{qjW@i$vuOZj_AW_aJz0#CvpUagaJ&UE3WZaxvr?w zg>}&e46+ddpTuqC5@XYW*{s@gOXpB*ww$?5(MK<%>r^W+~&b-eSdOrXup#JZ;zCCq$1uH{jIAc?`jb_60IO+Y zuhM58@fDaQsqq~V0fY}F^xg`^D>B_v$zea5Ws4A3@D;d>rBXUSl&}K)^Tq5%NveZU zb=9}=ikP00RZ?c7j;n$Nx~3Nr?}*Do@#;U&R4|MRCOOjj)eR?7J0I}MY(ZR3m9}34 z)o2S&0*O=?wk4<@9I+R&`XmiY5bXGy4(C~ebw?w^90ro2L~?NX?4OjtI{4O$8&0sT zeh?`fRX>lvDPQzeDk5XavN+f^?E(i933wYmd}=7P(v53GF>v350z*2S?GA9|PfP)i z#h8!PtHp9H44@Rv>aSr84?G_@iwQQWJ-kQR)%)es4jicqzpPE2f2OtnDYO>|HxrKk2S*IDwk5EG?;y(uUcNI_!ax` F{{XTH;V}RJ literal 0 HcmV?d00001 diff --git a/backlog/archive/milestones/m-0 - release-1.1.md b/backlog/archive/milestones/m-0 - release-1.1.md deleted file mode 100644 index af2373a..0000000 --- a/backlog/archive/milestones/m-0 - release-1.1.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -id: m-0 -title: "Release 1.1" ---- - -## Description - -Milestone: Release 1.1 diff --git a/backlog/archive/milestones/m-1 - release-1.0.md b/backlog/archive/milestones/m-1 - release-1.0.md deleted file mode 100644 index 176425f..0000000 --- a/backlog/archive/milestones/m-1 - release-1.0.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -id: m-1 -title: "Release 1.0" ---- - -## Description - -Milestone: Release 1.0 diff --git a/backlog/config.yml b/backlog/config.yml deleted file mode 100644 index 89c3cae..0000000 --- a/backlog/config.yml +++ /dev/null @@ -1,16 +0,0 @@ -project_name: "Cadder" -default_status: "To Do" -statuses: ["To Do", "In Progress", "Done"] -labels: [] -definition_of_done: ["Tests or explicit verification were run for the changed behavior", "Coverage was measured and remains at or above the project threshold"] -date_format: yyyy-mm-dd -max_column_width: 20 -auto_open_browser: true -default_port: 6420 -remote_operations: true -auto_commit: false -filesystem_only: false -bypass_git_hooks: false -check_active_branches: true -active_branch_days: 30 -task_prefix: "task" diff --git a/backlog/milestones/m-2 - v1.0.md b/backlog/milestones/m-2 - v1.0.md deleted file mode 100644 index 0227496..0000000 --- a/backlog/milestones/m-2 - v1.0.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -id: m-2 -title: "v1.0" ---- - -## Description - -Milestone: v1.0 diff --git a/backlog/milestones/m-3 - v2.0.md b/backlog/milestones/m-3 - v2.0.md deleted file mode 100644 index 8155eb1..0000000 --- a/backlog/milestones/m-3 - v2.0.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -id: m-3 -title: "v2.0" ---- - -## Description - -Milestone: v2.0 diff --git a/backlog/milestones/m-4 - v1.1.md b/backlog/milestones/m-4 - v1.1.md deleted file mode 100644 index 98f7190..0000000 --- a/backlog/milestones/m-4 - v1.1.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -id: m-4 -title: "v1.1" ---- - -## Description - -Post-v1.0 work that is useful but not required for the initial 1.0 release. diff --git a/backlog/milestones/m-5 - v.0.8.1.md b/backlog/milestones/m-5 - v.0.8.1.md deleted file mode 100644 index c2e1901..0000000 --- a/backlog/milestones/m-5 - v.0.8.1.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -id: m-5 -title: "v.0.8.1" ---- - -## Description - -Patch release for high-priority IIS handoff fixes. diff --git a/backlog/tasks/task-1 - Build-Cadder-persistent-Caddy-tray-daemon.md b/backlog/tasks/task-1 - Build-Cadder-persistent-Caddy-tray-daemon.md deleted file mode 100644 index 74f9d31..0000000 --- a/backlog/tasks/task-1 - Build-Cadder-persistent-Caddy-tray-daemon.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -id: TASK-1 -title: Build Cadder cross-platform Rust Caddy coordinator -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 11:39' -updated_date: '2026-06-10 11:14' -labels: [] -dependencies: [] -references: - - 'https://docs.rs/ratatui/latest/ratatui/' - - 'https://github.com/ratatui/awesome-ratatui#-widgets' - - 'https://docs.rs/crossterm/latest/crossterm/' - - 'https://docs.rs/interprocess/latest/interprocess/' - - 'https://docs.rs/directories/latest/directories/struct.ProjectDirs.html' - - 'https://caddyserver.com/docs/command-line' - - 'https://caddyserver.com/docs/api' -modified_files: - - Cargo.toml - - Cargo.lock - - crates/cadder-protocol/src/lib.rs - - crates/cadder-daemon/src/lib.rs - - crates/cadder-daemon/src/caddy.rs - - crates/cadder-daemon/src/ipc.rs - - crates/cadder-daemon/src/logs.rs - - crates/cadder-daemon/src/paths.rs - - crates/cadder-daemon/src/runtime.rs - - crates/cadder-daemon/src/state.rs - - crates/cadder-daemon/tests/ipc_lifecycle.rs - - crates/cadderd/src/main.rs - - crates/cadder-shim/src/main.rs - - crates/cadder-tui/src/main.rs - - crates/cadder-tui/src/model.rs - - xtask/src/main.rs - - README.md - - docs/ARCHITECTURE.md - - AGENTS.md - - .gitignore - - .gitattributes - - .editorconfig -priority: high -ordinal: 1000 ---- - -## Description - - -Build Cadder as a cross-platform Rust Caddy coordinator. Cadder owns a persistent per-user daemon (`cadderd`) that stays alive until explicit shutdown. A PATH-installed `caddy` shim starts or connects to the daemon, registers the invoking project's Caddy configuration while the shim process is alive, and removes that registration when the shim exits. A minimal Ratatui terminal UI (`cadder-tui`) replaces the previous Windows tray and panel surfaces. The final implementation must not retain WinUI, Windows App SDK, .NET project files, or Windows-only daemon assumptions. - - -## Acceptance Criteria - -- [x] #1 Cadder can support zero, one, or many concurrently registered project entrypoints without closing the per-user daemon. -- [x] #2 The `caddy` shim lifecycle controls only the registrations owned by that shim process; the daemon remains alive after shim exit. -- [x] #3 The daemon can resolve, start, reload, observe, and stop the real Caddy runtime without requiring each project to own a separate Caddy process. -- [x] #4 The Rust Ratatui TUI shows registered entrypoints, grouped domains, activation state, diagnostics, and per-domain logs without any Windows-only GUI dependency. -- [x] #5 The task tree captures Rust daemon/runtime, shim/IPC, Caddy config composition, TUI, diagnostics, packaging, and cross-platform verification work. - - -## Implementation Plan - - -# Rust Cross-Platform Rewrite Plan - -## Summary -- Replace the current .NET/WinUI scaffold with a fresh Rust Cargo workspace. -- Deliver separate binaries: `cadderd`, `caddy`, and `cadder-tui`. -- Keep existing .NET code only as behavioral reference during implementation; remove it from the final build path. -- Use cross-platform abstractions for daemon lockfiles, per-user runtime paths, IPC, process lifecycle, and terminal UI. - -## Implementation Steps -1. Create the Rust workspace skeleton and shared crates: protocol/domain types, daemon runtime, shim, TUI, and `xtask`. -2. Port the core contracts and tests: registrations, domains, activation state, runtime/config/log DTOs, IPC envelopes, canonicalization, and serialization. -3. Implement cross-platform runtime paths, singleton lockfile behavior, local-socket JSON IPC, daemon state store, registration lifecycle, heartbeat cleanup, and state/log query endpoints. -4. Implement Caddy integration: real-binary resolution, Caddyfile adaptation through real Caddy, host inspection, active config composition, conflict diagnostics, owned process start/reload/idle/stop, and bounded redacted log storage. -5. Implement the PATH-facing `caddy` shim: parse supported `caddy run`, start/connect to daemon, register/heartbeat/unregister, and recursion-safe delegation for unsupported commands. -6. Implement `cadder-tui` with Ratatui/Crossterm: overview, entrypoints, domains, logs, diagnostics, search/filter, toggles, pause/resume log tail, and graceful quit. -7. Replace project docs/build instructions with Cargo/xtask validation and remove Windows-only/.NET files from the final tree. - -## Validation -- Run `cargo fmt --check`. -- Run `cargo clippy --workspace --all-targets -- -D warnings`. -- Run `cargo test --workspace`. -- Run `cargo run -p xtask -- check`. -- Run focused integration tests with fake Caddy fixtures for registration, config adaptation, runtime reload, log capture, and shim lifecycle. - -## Notes -- Rust 1.95.0 and Cargo 1.95.0 are available locally. -- Local `caddy` and `caddy-real` currently report Caddy 2.10.0, but automated tests should rely on fake fixtures unless explicitly marked ignored for real Caddy. -- Project-facing text remains English; chat remains Polish. - - -## Implementation Notes - - -Implemented the Rust cross-platform rewrite skeleton and removed the previous .NET/WinUI build path. Added Cargo workspace crates for protocol contracts, daemon/runtime/IPC, `cadderd`, PATH-facing `caddy` shim, Ratatui `cadder-tui`, and `xtask` validation. Implemented local-socket newline-delimited JSON IPC, persistent shim sessions with pipe-disconnect cleanup, per-user runtime paths, fs4 lockfile singleton, Caddy adapt/host extraction/config composition, fake-Caddy lifecycle tests, bounded redacted logs, daemon shutdown, and minimal TUI views/actions for overview, entrypoints, domains, logs, diagnostics, filtering, toggles, log severity filter/export, and quit/shutdown flows. Removed `src/`, `tests/`, `.slnx`, .NET SDK/NuGet/MSBuild files, WinUI assets, and PowerShell-only build script. Final validation passed: `cargo run -p xtask -- check`. CLI smoke passed for `cadderd --help`, `cadder-tui --help`, and `caddy --cadder-shim-info`. - - -## Comments - - -author: @agent -created: 2026-06-10 10:43 ---- -Rebaseline approved by user: replace the prior Windows tray/WinUI direction with a fresh Rust cross-platform rewrite. Existing .NET/WinUI code is behavioral reference only and must not remain in the final architecture. Target binaries are `cadderd`, `caddy`, and `cadder-tui`; per-user daemon starts on demand through local IPC and a lockfile; UI is minimal Ratatui/Crossterm. ---- - - -## Final Summary - - -Implemented the Cadder cross-platform Rust rewrite. The repository now builds as a Cargo workspace with shared protocol contracts, daemon/runtime logic, a per-user `cadderd` daemon, a PATH-facing `caddy` shim, a Ratatui/Crossterm `cadder-tui`, and an `xtask` validation runner. - -The implementation replaces the previous .NET/WinUI build path. It removes the old C# source tree, WinUI assets, `.slnx`, MSBuild/NuGet/.NET SDK files, and PowerShell-only build script. The new daemon uses per-user runtime paths, an `fs4` lockfile, `interprocess` local-socket newline-delimited JSON IPC, persistent shim-owned sessions with disconnect cleanup, real Caddy resolution, Caddyfile adaptation, active config composition, conflict diagnostics, bounded redacted log storage, and process runtime start/reload/idle/stop behavior. The TUI exposes overview, entrypoints, domains, logs, diagnostics, search/filtering, toggles, log severity filtering/export, pause/resume tailing, quit, and daemon shutdown. - -Validation passed with `cargo run -p xtask -- check`, covering `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, and `cargo test --workspace`. Additional CLI smoke checks passed for `cadderd --help`, `cadder-tui --help`, and `caddy --cadder-shim-info`. - -Follow-ups remain tracked separately in Backlog for deeper logs TUI work, packaging/install UX, broader lifecycle/TUI verification, README expansion, release metadata, and CI/CD. - diff --git a/backlog/tasks/task-1.1 - Define-Cadder-architecture-and-scaffold-the-app.md b/backlog/tasks/task-1.1 - Define-Cadder-architecture-and-scaffold-the-app.md deleted file mode 100644 index 79381e0..0000000 --- a/backlog/tasks/task-1.1 - Define-Cadder-architecture-and-scaffold-the-app.md +++ /dev/null @@ -1,153 +0,0 @@ ---- -id: TASK-1.1 -title: Define Cadder architecture and scaffold the app -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 11:41' -updated_date: '2026-06-09 12:31' -labels: [] -dependencies: [] -references: - - .local/examples/gui/openclaw-windows-node.slnx - - .local/examples/gui/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj -modified_files: - - .editorconfig - - .gitattributes - - .gitignore - - Cadder.slnx - - Directory.Build.props - - NuGet.Config - - README.md - - build.ps1 - - docs/ARCHITECTURE.md - - global.json - - src/Cadder.CaddyShim/Cadder.CaddyShim.csproj - - src/Cadder.CaddyShim/Program.cs - - src/Cadder.CaddyShim/ShimEntrypoint.cs - - src/Cadder.Contracts/Cadder.Contracts.csproj - - src/Cadder.Contracts/CadderRoles.cs - - src/Cadder.Contracts/DomainContracts.cs - - src/Cadder.Contracts/IpcContracts.cs - - src/Cadder.Daemon/Cadder.Daemon.csproj - - src/Cadder.Daemon/DaemonBoundaries.cs - - src/Cadder.Tray.WinUI/.gitignore - - src/Cadder.Tray.WinUI/App.xaml - - src/Cadder.Tray.WinUI/App.xaml.cs - - src/Cadder.Tray.WinUI/Assets/AppIcon.ico - - src/Cadder.Tray.WinUI/Assets/LockScreenLogo.scale-200.png - - src/Cadder.Tray.WinUI/Assets/SplashScreen.scale-200.png - - src/Cadder.Tray.WinUI/Assets/Square150x150Logo.scale-200.png - - src/Cadder.Tray.WinUI/Assets/Square44x44Logo.scale-200.png - - >- - src/Cadder.Tray.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png - - >- - src/Cadder.Tray.WinUI/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png - - src/Cadder.Tray.WinUI/Assets/StoreLogo.png - - src/Cadder.Tray.WinUI/Assets/Wide310x150Logo.scale-200.png - - src/Cadder.Tray.WinUI/Cadder.Tray.WinUI.csproj - - src/Cadder.Tray.WinUI/MainPage.xaml - - src/Cadder.Tray.WinUI/MainPage.xaml.cs - - src/Cadder.Tray.WinUI/MainWindow.xaml - - src/Cadder.Tray.WinUI/MainWindow.xaml.cs - - src/Cadder.Tray.WinUI/Package.appxmanifest - - src/Cadder.Tray.WinUI/Properties/launchSettings.json - - src/Cadder.Tray.WinUI/app.manifest - - tests/Cadder.Contracts.Tests/Cadder.Contracts.Tests.csproj - - tests/Cadder.Contracts.Tests/ContractShapeTests.cs - - tests/Cadder.Daemon.Tests/Cadder.Daemon.Tests.csproj - - tests/Cadder.Daemon.Tests/DaemonBoundaryTests.cs -parent_task_id: TASK-1 -priority: high -ordinal: 2000 ---- - -## Description - - -Create the initial Cadder codebase structure and settle the process boundaries before feature work begins. The implementation should name the roles explicitly: tray/daemon singleton, caddy.exe shim entrypoint, real Caddy runtime adapter, IPC contract, registration store, and GUI state projection. - - -## Acceptance Criteria - -- [x] #1 The repository contains a buildable initial application scaffold with separate daemon/tray, shim, shared contracts, and test projects or modules. -- [x] #2 The scaffold documents how the PATH-installed caddy.exe shim differs from the real Caddy binary and how Cadder resolves the real binary. -- [x] #3 The initial domain model includes entrypoint instance, source working directory, source config path, registered domains, activation state, owner process identity, and log stream identity. -- [x] #4 The app has a single command to build all scaffolded projects from a clean checkout. - - -## Implementation Plan - - -## Goal -Create the initial buildable Cadder scaffold and make the process boundaries explicit before feature implementation begins. - -## Scope -- Build a .NET 10 solution with separate contracts, daemon/core, tray/daemon host, caddy.exe shim, and test projects. -- Establish contract types for entrypoint registrations, domain state, owner identity, and log stream identity. -- Document the role split between Cadder's PATH-facing shim and the real Caddy runtime. -- Provide one clean-checkout build command. - -## Key Files And Modules -- `Cadder.slnx` -- `global.json` -- `Directory.Build.props` -- `NuGet.Config` -- `build.ps1` -- `src/Cadder.Contracts/` -- `src/Cadder.Daemon/` -- `src/Cadder.Tray.WinUI/` -- `src/Cadder.CaddyShim/` -- `tests/Cadder.Contracts.Tests/` -- `tests/Cadder.Daemon.Tests/` -- `docs/ARCHITECTURE.md` - -## Implementation Steps -1. Add repository-level .NET scaffolding: `Cadder.slnx`, pinned `global.json` for SDK `10.0.204`, root build properties, NuGet source config, and standard editor/git hygiene files. -2. Create the contracts, daemon/core, shim, WinUI tray host, and test projects using the appropriate .NET CLI/project tooling. Use the installed Microsoft WinUI template for `Cadder.Tray.WinUI` and check in the generated project so clean-checkout builds do not require the template. -3. Configure deterministic Windows build settings for the WinUI project and solution: x64 default, `RuntimeIdentifier=win-x64`, explicit Windows target/minimum versions, and package versions pinned to known-good values where needed (`Microsoft.WindowsAppSDK 2.1.3`, `Microsoft.Windows.SDK.BuildTools 10.0.28000.1839`). -4. Configure `Cadder.CaddyShim` as a console project whose output assembly/executable name is `caddy`, while keeping the project name distinct from the real Caddy binary. -5. Add `Cadder.Contracts` domain/IPC DTOs with explicit JSON-friendly shapes. Include entrypoint instance identity, raw and canonical source working directory, raw and canonical source config path, registered domains, activation state, owner process identity, and log stream identity. -6. Define `OwnerProcessIdentity` as more than a PID: include PID plus process start identity and/or a shim session nonce so future owner cleanup cannot confuse PID reuse with the original owner. -7. Define path and domain value contracts so later tasks can distinguish raw user input from canonical values. Do not implement full Windows path resolution, symlink handling, Caddyfile parsing, or domain normalization behavior in this task. -8. Add minimal daemon/core boundary types only where they clarify future process boundaries: daemon host boundary, registration store contract, real Caddy runtime adapter boundary, IPC request/response contract, and GUI state snapshot/read model. Avoid broad fake abstractions that would lock in TASK-1.2 through TASK-1.9 implementation details. -9. Add `docs/ARCHITECTURE.md` describing the named roles: tray/daemon singleton, PATH-facing `caddy.exe` shim, real Caddy runtime adapter, IPC contract, registration store, and GUI state projection. -10. In the architecture doc, document the safety rule for real Caddy resolution: the shim intentionally shadows `caddy.exe`, but Cadder must resolve a real Caddy binary without recursively invoking its own shim. Do not finalize source precedence in this task; defer exact resolver policy to TASK-1.6/TASK-1.11. Mention that future resolver logic must exclude self/shim identity by normalized path and, where possible, file identity rather than a single string path comparison. -11. Add focused tests proving the scaffold contract shape: models can represent the required fields, owner identity is not PID-only, raw/canonical source fields exist, domain activation and log stream identity are first-class, daemon snapshot/read-model shape exists, and shim project metadata produces `caddy.exe`. -12. Add `build.ps1` as the single clean-checkout build entrypoint. It should check Windows and .NET 10 prerequisites, run restore/build with `Platform=x64` and `RuntimeIdentifier=win-x64`, and avoid OpenClaw-specific Node/npm/GitVersion requirements. - -## Validation -- Run `./build.ps1` from the repository root. -- Run `dotnet test Cadder.slnx -p:Platform=x64 -r win-x64`. -- Run `git status --short` before finishing and remove only temporary artifacts created for the implementation. - -## Scope Boundaries -- Do not implement real single-instance enforcement. -- Do not implement daemon process spawning or quit behavior. -- Do not implement live IPC transport. -- Do not implement shim registration lifecycle. -- Do not parse or compose Caddyfiles. -- Do not start, reload, observe, or stop the real Caddy runtime. -- Do not build the tray popup, main panel, domain toggles, or log UI beyond a minimal buildable host. - -## Risks And Notes -- WinUI builds require deterministic x64/RID restore; `build.ps1` must restore with `RuntimeIdentifier=win-x64` before building. -- The WinUI template is installed in the current environment, but normal clean-checkout builds should rely on checked-in project files, not on template availability. -- Real Caddy resolver precedence is intentionally not finalized in this task to avoid preempting TASK-1.6 and TASK-1.11. - - -## Implementation Notes - - -Plan approved by user and recorded before implementation. WinUI template Microsoft.WindowsAppSDK.WinUI.CSharp.Templates@0.0.6-alpha was installed locally and validated with a temporary x64/win-x64 build probe. - -Implemented the .NET 10 scaffold with Cadder.slnx, contracts, daemon boundaries, WinUI tray host, caddy.exe shim, architecture documentation, and focused xUnit tests. build.ps1 restores the solution and builds each scaffolded project with Platform=x64 and RuntimeIdentifier=win-x64 because dotnet build rejects solution-level RuntimeIdentifier with NETSDK1134. Validation passed: .\build.ps1; dotnet test Cadder.slnx -p:Platform=x64 -r win-x64; dotnet format Cadder.slnx --verify-no-changes. - -Addressed final review risks: Cadder.CaddyShim now sets UseAppHost=true, build.ps1 verifies that the shim build emits caddy.exe, and build.ps1 fails if its project list drifts from Cadder.slnx. - - -## Final Summary - - -Implemented the initial Cadder .NET 10 scaffold: solution/build configuration, contracts, daemon boundaries, WinUI tray host, PATH-facing caddy.exe shim, architecture documentation, and focused xUnit tests. The domain contracts now model entrypoint identity, raw/canonical source paths, registered domains, activation state, owner process identity beyond PID-only matching, and log stream identity. build.ps1 is the clean-checkout build entrypoint; it restores the solution, checks that its project list matches Cadder.slnx, builds each project with Platform=x64 and RuntimeIdentifier=win-x64, and verifies that the shim emits caddy.exe. Validation passed with .\build.ps1, dotnet test Cadder.slnx -p:Platform=x64 -r win-x64, and dotnet format Cadder.slnx --verify-no-changes. build.ps1 uses a project-level build loop for RuntimeIdentifier=win-x64 because dotnet build rejects solution-level RID for .slnx. - diff --git a/backlog/tasks/task-1.10 - Build-per-domain-logs-UI.md b/backlog/tasks/task-1.10 - Build-per-domain-logs-UI.md deleted file mode 100644 index a61af82..0000000 --- a/backlog/tasks/task-1.10 - Build-per-domain-logs-UI.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -id: TASK-1.10 -title: Build per-domain logs TUI -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 11:44' -updated_date: '2026-06-10 12:48' -labels: [] -dependencies: - - TASK-1.7 -references: - - 'https://docs.rs/ratatui/latest/ratatui/' - - 'https://github.com/ratatui/awesome-ratatui#-widgets' -modified_files: - - crates/cadder-tui/src/model.rs - - crates/cadder-tui/src/main.rs - - crates/cadder-daemon/src/logs.rs - - crates/cadder-daemon/src/state.rs - - docs/ARCHITECTURE.md -parent_task_id: TASK-1 -priority: medium -ordinal: 11000 ---- - -## Description - - -Add a log-focused Ratatui surface that lets users inspect domain-scoped Caddy logs from `cadder-tui` without requiring a Windows GUI. The surface should use the daemon's lazy log query IPC and remain responsive while registrations change. - - -## Acceptance Criteria - -- [x] #1 Each domain row in `cadder-tui` exposes a View logs action or shortcut that opens a domain-scoped log view. -- [x] #2 The log view lazy-loads lines, tails new entries by default, and lets the user pause auto-scroll. -- [x] #3 Users can filter by severity and copy or export a redacted diagnostic excerpt through a cross-platform terminal-friendly path. -- [x] #4 The log view shows clear empty, loading, paused, stale, removed, and read-error states. -- [x] #5 The TUI remains responsive when logs update while domains are toggled or registrations are removed. - - -## Implementation Plan - - -## Goal -Build a domain-scoped logs surface in `cadder-tui` that opens from a selected domain row, uses the daemon's lazy log query IPC, tails new entries by default, supports pause/severity/export workflows, and remains responsive while registrations and domains change. - -## Scope -- Implement the Ratatui log UX for domain streams in `crates/cadder-tui`. -- Use the existing `QueryLogsRequest` / `QueryLogsResponse` IPC contracts and `LogStreamIdentity` values already attached to registered domains. -- Store only redacted log excerpts in exported diagnostics by using `LogEntry.raw_message` returned from the daemon. -- Add focused daemon-side metadata fixes only where needed for the TUI to show correct status, cursor, or retention state. -- Do not broaden Caddy log parsing or durable log persistence unless implementation proves the current contract cannot satisfy the accepted UI behavior. - -## Key Files And Modules -- `crates/cadder-tui/src/model.rs` -- `crates/cadder-tui/src/main.rs` -- `crates/cadder-protocol/src/lib.rs` -- `crates/cadder-daemon/src/logs.rs` -- `crates/cadder-daemon/src/state.rs` -- `crates/cadder-daemon/src/ipc.rs` -- `docs/ARCHITECTURE.md` - -## Implementation Steps -1. Add a dedicated log view model in `cadder-tui` for the active domain log target, stream identity, loaded entries, next cursor, loading/read-error state, stream status, paused/follow-tail mode, severity filter, and display/export metadata. -2. Add an explicit domain-row action in the Domains view, such as `Enter` or `l`, that selects the highlighted domain's `LogStreamIdentity`, resets log state for that target, switches to the Logs view, and performs the initial lazy query. -3. Change log refresh behavior to use the daemon cursor. The initial query loads a bounded page of recent entries; follow-up tail queries pass `next_cursor` and append only new entries. When paused, stop auto-scroll and avoid automatic tail refresh until resumed or manually refreshed. -4. Keep the TUI event loop responsive by moving IPC calls for state, toggles, and log refreshes into small background tasks with a response channel. Allow at most one in-flight log refresh for the current stream and surface timeout/read failures as log view state instead of blocking key handling. -5. Render clear log states in the Logs view: loading, empty, tailing, paused, stale, removed, read-error, retention gap, and truncated-by-retention. Include the active domain, severity filter, and tail/pause state in the title/status area. -6. Implement severity controls against the selected log stream. Changing severity should reset the log cursor and reload the current domain stream so old entries with the previous filter are not mixed with the new filter. -7. Implement a terminal-friendly diagnostic export path. Write a timestamped `cadder-logs--.txt` excerpt in the current working directory containing timestamp, severity, domain/source metadata, and redacted raw message text. Treat file export as the accepted cross-platform copy/export path for this task. -8. Harden daemon log query metadata where needed. Add or correct `has_gap`, `has_more_before`, and `truncated_by_retention` behavior in `CaddyLogStore::query`, and verify `DaemonState::stream_is_active` returns statuses that let the TUI distinguish active, stale, removed, and empty streams. -9. Add focused tests for TUI model transitions, selected domain-to-log stream behavior, pause/tail cursor behavior, severity reset, export formatting, log store cursor/retention metadata, and daemon query status after domain removal. -10. Update `docs/ARCHITECTURE.md` with the per-domain logs TUI behavior, cursor-based tailing, pause/export semantics, and the boundary that diagnostic excerpts use daemon-redacted log messages. - -## Validation -- Run `cargo fmt --check`. -- Run `cargo clippy --workspace --all-targets -- -D warnings`. -- Run `cargo test -p cadder-tui` after TUI model/render changes. -- Run `cargo test -p cadder-daemon` after log query/status changes. -- Run `cargo test --workspace`. -- Run `cargo run -p xtask -- check` before closeout. -- Manually smoke test `cadder-tui`: open Logs from a selected domain row, verify tailing, pause/resume, severity filtering, export, empty/stale/removed/read-error rendering, and responsiveness while toggling or unregistering domains. - -## Assumptions -- File export satisfies the acceptance criterion for a cross-platform terminal-friendly copy/export path; no system clipboard dependency is required. -- The current protocol shape is sufficient for forward tailing. Backward pagination is not required unless the existing UI behavior cannot satisfy lazy loading without it. -- The task does not require new Caddy JSON log attribution heuristics beyond the domain streams already exposed by the daemon. -- Exported excerpts must use already-redacted daemon log messages and must not reintroduce raw process arguments or secrets. - -## Risks And Boundaries -- Awaiting IPC in the key handling loop can make the TUI feel stuck during daemon churn; background requests and single-flight log refreshes are required for acceptance criterion #5. -- Cursor and retention metadata are partially implemented today, so UI state may be misleading unless daemon query tests cover gap/truncation paths. -- A selected domain may disappear while the Logs view is active. The TUI should retain the last selected stream label, show a stale or removed state, and avoid silently falling back to an entrypoint or unrelated stream. -- Broad runtime log parsing, durable log persistence, and clipboard integration should be treated as follow-up work unless the accepted criteria cannot be met without them. - - -## Implementation Notes - - -Plan approved by the user and recorded before implementation. Task prepared for execution with status `In Progress` and assignee `@agent`. - -Implemented the per-domain logs TUI surface. Domain rows open logs with Enter or l; the log view keeps a domain target, cursor, status, severity filter, pause state, retention flags, and redacted entries in the TUI model. Log refreshes, state refreshes, activation toggles, and shutdown requests now run as background IPC tasks with channel responses; stale log responses are ignored by request serial so an old response cannot clear the current request state. Export writes a timestamped cadder-logs--.txt file in the current working directory using daemon-redacted LogEntry.raw_message content. Daemon log queries now report gap, more-before, and truncated-by-retention metadata, and inactive domain streams are reported stale when retained entries exist. Validation run: cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings; cargo test -p cadder-tui; cargo test -p cadder-daemon; cargo test --workspace; cargo run -p xtask -- check. Manual interactive cadder-tui smoke testing was not run in this environment. - -Fresh-eyes review before commit found and fixed a log status regression: cursor tail queries with no new entries now preserve Active/Stale status when matching retained entries exist, instead of reporting Empty for a quiet poll. Added regression coverage in `logs::tests::active_tail_query_stays_active_when_no_new_entries_arrive`. Re-ran validation after the fix: cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings; cargo test -p cadder-daemon logs::tests::active_tail_query_stays_active_when_no_new_entries_arrive; cargo test -p cadder-daemon; cargo test --workspace; cargo run -p xtask -- check. - - -## Comments - - -author: @agent -created: 2026-06-10 10:44 ---- -Rebaselined from WinUI panel logs to Ratatui logs as part of the Rust cross-platform rewrite. ---- - - -## Final Summary - - -Implemented the per-domain logs TUI for `cadder-tui`. - -Changes: -- Added a stateful domain log model with selected domain target, cursor, loading/read-error status, pause/tail mode, severity filter, retention flags, and retained log entries. -- Added `Enter` / `l` domain-row shortcuts to open domain-scoped logs, cursor-based tail refresh, pause/resume, severity reset, and timestamped redacted text export. -- Moved TUI state refreshes, activation toggles, log refreshes, and shutdown IPC into background tasks with response channels so terminal input stays responsive; stale log responses are ignored by request serial. -- Improved daemon log query metadata for retention gaps, more-before, truncated-by-retention, and stale inactive domain streams. -- Documented the per-domain logs behavior and daemon-redacted export boundary in `docs/ARCHITECTURE.md`. - -Validation: -- `cargo fmt --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test -p cadder-tui` -- `cargo test -p cadder-daemon` -- `cargo test --workspace` -- `cargo run -p xtask -- check` - -Not run: -- Manual interactive `cadder-tui` smoke test; the environment was non-interactive for terminal UI verification. - -Fresh-eyes follow-up before commit fixed a daemon log status regression for quiet cursor tail polls and added regression coverage. Full validation was re-run after that fix. - diff --git a/backlog/tasks/task-1.11 - Package-PATH-installation-and-caddy.exe-shadowing.md b/backlog/tasks/task-1.11 - Package-PATH-installation-and-caddy.exe-shadowing.md deleted file mode 100644 index 91c65ea..0000000 --- a/backlog/tasks/task-1.11 - Package-PATH-installation-and-caddy.exe-shadowing.md +++ /dev/null @@ -1,164 +0,0 @@ ---- -id: TASK-1.11 -title: >- - Package portable cross-platform binaries and configurable Caddy runtime - resolution -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 11:44' -updated_date: '2026-06-10 12:28' -labels: [] -dependencies: - - TASK-1.2 - - TASK-1.3 - - TASK-1.6 -references: - - 'D:\Projects\Selleo\smarketing\apps\reverse-proxy\readme.md' -documentation: - - docs/ARCHITECTURE.md - - 'https://docs.rs/figment/latest/figment/' -modified_files: - - Cargo.lock - - README.md - - crates/cadder-daemon/Cargo.toml - - crates/cadder-daemon/src/config.rs - - crates/cadder-daemon/src/caddy.rs - - crates/cadder-daemon/src/ipc.rs - - crates/cadder-daemon/src/lib.rs - - crates/cadder-shim/src/main.rs - - crates/cadder-tui/src/main.rs - - docs/ARCHITECTURE.md - - xtask/src/main.rs -parent_task_id: TASK-1 -priority: medium -ordinal: 12000 ---- - -## Description - - -Package Cadder as portable cross-platform Rust executables and add configurable real Caddy runtime resolution. Cadder must not modify system PATH, shell profiles, package-manager shims, or other system state. Users may choose to place the shim on PATH under any name, but the application should only provide binaries, documentation, and verification tooling. Real Caddy resolution must be configurable through layered sources so Cadder can be used either as a direct coordinator or through an optional Caddy-compatible shim. - - -## Acceptance Criteria - -- [x] #1 Portable outputs include `cadderd`, `cadder-tui`, the current `caddy` shim binary, and a sample `cadder.toml` for the current platform without modifying PATH or other system state. -- [x] #2 Real Caddy command/path resolution uses layered configuration with precedence: CLI override, `cadder.toml` in the current working directory, `cadder.toml` next to the executable, environment variables, then `caddy` available on PATH as the final fallback. -- [x] #3 The shim can start or connect to the per-user daemon and register `caddy run` invocations from arbitrary project directories while honoring project-local layered configuration. -- [x] #4 Unsupported Caddy commands are delegated to the safely resolved real Caddy binary only after recursion-safe resolution; otherwise they fail with a clear Cadder-owned message explaining how to configure the real Caddy command. -- [x] #5 The portable build and verification workflow runs through Cargo/xtask on supported platforms and is not PowerShell-only. - - -## Implementation Plan - - -## Goal -Package Cadder as portable OS-specific Rust executables and make real Caddy runtime resolution configurable without modifying user PATH or system state. - -## Scope -- Produce a portable distribution layout per supported OS containing `cadderd`, `cadder-tui`, the current `caddy` shim binary, and a sample `cadder.toml`. -- Do not implement installers, package-manager integration, shell profile edits, PATH mutation, Scoop/Homebrew/Apt setup, or automatic global shim creation. -- Let users decide whether to place a shim on PATH and under which name. The `caddy-real` convention is a local setup example, not the promoted product model. -- Add typed layered configuration for real Caddy resolution so Cadder can be used directly as Cadder or optionally through a Caddy-compatible shim. -- Keep the current default runtime model as `0-N entrypoints <-> single instance dashboard+backend`. Detached backend with multiple dashboards is future work. - -## Key Files And Modules -- `crates/cadder-daemon/src/caddy.rs` -- `crates/cadder-daemon/src/ipc.rs` -- `crates/cadder-daemon/src/lib.rs` -- A new config module in `crates/cadder-daemon/src/` if that is the cleanest boundary -- `crates/cadder-daemon/Cargo.toml` -- `crates/cadderd/src/main.rs` -- `crates/cadder-shim/src/main.rs` -- `crates/cadder-tui/src/main.rs` -- `xtask/src/main.rs` -- `README.md` -- `docs/ARCHITECTURE.md` - -## Implementation Steps -1. Add a typed Cadder runtime configuration model for real Caddy resolution. Prefer `figment` with TOML and ENV features because it supports layered providers, typed extraction, and precise configuration-source diagnostics. -2. Define the effective precedence as CLI override, `cadder.toml` in the current working directory, `cadder.toml` next to the executable, environment variables, then `caddy` on PATH as the final fallback. -3. Preserve compatibility with existing environment names where useful, including `CADDER_CADDY_REAL_COMMAND`, but document them as configuration inputs rather than as the recommended primary path. -4. Refactor `RealCaddyResolver` to consume the effective configuration. Remove `caddy-real` as a built-in default fallback; users can still configure `caddy-real` explicitly through CLI, TOML, or ENV. -5. Keep recursion safety in the resolver by excluding the current executable and any known shim path from fallback PATH resolution. If resolution would point back to Cadder's shim, fail with a clear diagnostic. -6. Update `cadderd`, `cadder-tui`, and the shim to load the same configuration model at their respective startup boundaries. The shim should load project-local config from the invoking working directory so entrypoints can override real Caddy per project. -7. Update unsupported-command delegation so it only delegates after recursion-safe real Caddy resolution. If no safe real Caddy command can be resolved, print a Cadder-owned message that explains the CLI/TOML/ENV options. -8. Extend `xtask` with a portable distribution command, such as `cargo run -p xtask -- dist --out

`, that builds release binaries and copies the OS-appropriate executable names plus a sample `cadder.toml` into the output directory. -9. Add an `xtask` verification path for the portable layout that checks the expected binaries, runs `caddy --cadder-shim-info`, and remains cross-platform. -10. Update README and architecture docs with the portable model, layered configuration precedence, per-system usage notes, optional user-managed PATH/shim setup, and the two runtime architecture modes: current default dashboard+backend and future detached backend with multiple dashboards. - -## Validation -- `cargo fmt --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- Focused tests for configuration precedence, TOML next to CWD and executable, environment fallback, PATH fallback, recursion-safe shim exclusion, unsupported-command messaging, and portable dist verification. -- A local portable-output smoke check under `.local/verification/task-1.11/` or another private scratch directory, with generated artifacts cleaned up unless intentionally retained under `.local`. - -## Scope Boundaries -- Do not build an installer or modify system PATH. -- Do not add package-manager recipes or OS service registration. -- Do not promote `caddy-real` as the official resolution model; treat it only as a user-configurable example. -- Do not implement detached backend with multiple dashboards in this task. -- Do not require a globally installed real Caddy in automated tests; use fake Caddy fixtures for deterministic validation. -- Do not expand CI release publishing here beyond any local `xtask` shape needed by this task; TASK-1.15 owns GitHub release artifacts. - -## Risks And Notes -- PATH fallback can accidentally find the Cadder shim if the user names it `caddy`; recursion-safe exclusions and clear diagnostics are mandatory. -- Config precedence must be documented exactly because users may run Cadder directly, through a renamed shim, or through a `caddy` shim. -- CWD config enables project-local behavior but can be surprising for unsupported delegated commands, so diagnostics should include the selected source when practical. -- Portable artifacts should remain simple enough for CI to archive later without changing their structure. - - -## Implementation Notes - - -Plan approved by the user and recorded before implementation. Scope was corrected from installer/PATH ownership to portable OS-specific executables, user-managed optional PATH/shim setup, and layered real Caddy configuration. The user's `caddy-real` setup is local context only, not a promoted product workflow. - -Implemented TASK-1.11 portable packaging and layered real Caddy resolution. Added a Figment-backed `cadder.toml` configuration model with precedence CLI override -> current working directory `cadder.toml` -> executable-adjacent `cadder.toml` -> environment variables including `CADDER_CADDY_REAL_COMMAND` -> safe `caddy` on PATH. Removed the built-in `caddy-real` fallback; it remains supported only when explicitly configured. The shim now passes its path to daemon startup for recursion-safe resolution, can pass a hidden real-Caddy CLI override, and unsupported command delegation now prints a Cadder-owned configuration message when resolution fails. `ensure_daemon_running_with_options` now passes the selected runtime dir and launch options to `cadderd`. Added `xtask dist --out ` and `xtask verify-dist --dir ` to build/copy `cadderd`, `cadder-tui`, the `caddy` shim, and sample `cadder.toml` without modifying PATH or system state. Updated README and architecture docs with portable usage, config precedence, and current/future runtime model boundaries. - -Validation passed: `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo run -p xtask -- check`; `cargo run -p xtask -- dist --out .local/verification/task-1.11/dist`. The `.local/verification/task-1.11` smoke artifacts were removed after verification. - -Closeout fresh-eyes review completed before marking Done. Re-read the changed resolver/config/shim/xtask/docs diff, verified acceptance criteria against the implementation, checked whitespace with `git diff --check`, and reran the focused project-local config test: `cargo test -p cadder-daemon adapter_resolves_real_caddy_from_registration_working_directory_config`. No substantive issue remained after the prior local fixes. - - -## Comments - - -author: @agent -created: 2026-06-09 16:31 ---- -Future packaging context from user approval of TASK-1.5: the user's real global Caddy command is `caddy-real`/`caddy-real.exe`. Cadder's PATH-facing shim should be installed globally with Scoop using a command shape like `scoop shim add caddy "path_to_cadder_caddy.exe"`, while keeping the real Caddy command configurable and distinguishable from the shim. ---- - -author: @agent -created: 2026-06-10 10:44 ---- -Rebaselined from Windows packaging/PATH work to cross-platform Rust binary installation and shim shadowing. ---- - - -## Final Summary - - -## Summary -- Added a typed `cadder.toml` configuration model for real Caddy resolution with the required precedence: CLI override, project working-directory config, executable-adjacent config, environment variables, then safe `caddy` on PATH. -- Removed `caddy-real` as an implicit fallback while preserving it as an explicitly configurable command. -- Updated shim, daemon launch, and TUI startup wiring so the shim can start/connect to the per-user daemon, pass recursion-safety context, and honor project-local config for `caddy run` registrations. -- Added Cadder-owned unsupported-command delegation diagnostics when no safe real Caddy binary can be resolved. -- Added `xtask dist --out ` and `xtask verify-dist --dir ` for portable layouts containing `cadderd`, `cadder-tui`, `caddy`, and sample `cadder.toml` without modifying PATH or system state. -- Updated README and architecture documentation for the portable model and runtime-resolution contract. - -## Validation -- `cargo fmt --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- dist --out .local/verification/task-1.11/dist` -- `git diff --check` -- Fresh focused check: `cargo test -p cadder-daemon adapter_resolves_real_caddy_from_registration_working_directory_config` - -## Risk / Follow-up -- No installer, PATH mutation, OS service registration, or CI release publishing was added; those remain outside TASK-1.11 scope. - diff --git a/backlog/tasks/task-1.12 - Add-end-to-end-lifecycle-and-GUI-verification.md b/backlog/tasks/task-1.12 - Add-end-to-end-lifecycle-and-GUI-verification.md deleted file mode 100644 index 99d022b..0000000 --- a/backlog/tasks/task-1.12 - Add-end-to-end-lifecycle-and-GUI-verification.md +++ /dev/null @@ -1,146 +0,0 @@ ---- -id: TASK-1.12 -title: Add end-to-end lifecycle and TUI verification -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 11:44' -updated_date: '2026-06-10 13:12' -labels: [] -dependencies: - - TASK-1.3 - - TASK-1.4 - - TASK-1.5 - - TASK-1.7 - - TASK-1.11 -references: - - 'https://docs.rs/ratatui/latest/ratatui/' - - 'https://docs.rs/crossterm/latest/crossterm/' -documentation: - - docs/verification/tui-smoke.md -modified_files: - - crates/cadder-daemon/tests/ipc_lifecycle.rs - - crates/cadder-tui/src/model.rs - - docs/verification/tui-smoke.md -parent_task_id: TASK-1 -priority: medium -ordinal: 13000 ---- - -## Description - - -Add automated and manual verification that proves the Rust Cadder implementation handles the intended cross-platform lifecycle: zero projects, one project, many projects, shim death, domain toggles, logs, daemon state, and Ratatui status views. - - -## Acceptance Criteria - -- [x] #1 Automated tests cover zero, one, and at least ten concurrent shim registrations. -- [x] #2 Tests prove killing one shim removes only that shim-owned registration and preserves other active registrations. -- [x] #3 Tests cover Caddyfile parsing/adaptation and effective config reload behavior using fake Caddy fixtures, with optional ignored tests for a local real Caddy binary. -- [x] #4 Tests cover domain toggles, conflict reporting, runtime failure reporting, and per-domain log queries. -- [x] #5 A TUI smoke checklist or automation verifies overview, entrypoint grouping, domain toggles, log opening, diagnostics, and daemon shutdown flow on supported terminal backends. - - -## Implementation Plan - - -## Goal -Add automated and manual verification that proves the Rust Cadder implementation handles the intended cross-platform lifecycle: zero projects, one project, many projects, shim death, domain toggles, logs, daemon state, and Ratatui status views. - -## Scope -- Add verification only; do not change daemon, shim, or TUI behavior except for small testability refactors required by the tests. -- Use fake Caddy fixtures for deterministic automated tests. Optional real-Caddy checks may be added as ignored tests only. -- Cover daemon/shim lifecycle through IPC/session integration tests and TUI behavior through model/render tests plus a smoke checklist or lightweight automation. -- Keep coverage gates, CI release publishing, installer behavior, and documentation-site work outside this task. - -## Key Files And Modules -- `crates/cadder-daemon/tests/ipc_lifecycle.rs` -- `crates/cadder-daemon/tests/fixtures/SmarketingReverseProxy.Caddyfile` -- `crates/cadder-daemon/src/ipc.rs` -- `crates/cadder-daemon/src/state.rs` -- `crates/cadder-daemon/src/caddy.rs` -- `crates/cadder-daemon/src/runtime.rs` -- `crates/cadder-daemon/src/logs.rs` -- `crates/cadder-protocol/src/lib.rs` -- `crates/cadder-shim/src/main.rs` -- `crates/cadder-tui/src/model.rs` -- `crates/cadder-tui/src/main.rs` -- `docs/ARCHITECTURE.md` -- New verification documentation such as `docs/verification/tui-smoke.md` if a manual TUI checklist is used. - -## Implementation Steps -1. Refactor the IPC lifecycle test harness in `crates/cadder-daemon/tests/ipc_lifecycle.rs` only as much as needed to create reusable scenarios for zero, one, many registrations, separate IPC sessions, fake Caddy behavior, and controlled session shutdown. -2. Add dedicated lifecycle tests proving the daemon starts with an empty snapshot, a single shim registration can adapt/apply config, at least ten concurrent registrations stay stable, and closing one IPC session removes only the registration owned by that session while preserving other active registrations. -3. Extend the fake Caddy test double to support deterministic `adapt`, `run`, `reload`, and failure modes. Capture enough side effects to assert effective config reload behavior without requiring a locally installed real Caddy. -4. Add config/runtime tests for Caddyfile adaptation, effective config updates after domain toggles, successful reload, reload/runtime failure diagnostics (`runtime-apply-failed`), and runtime-control log entries. -5. Add tests for domain toggles, active-domain conflict reporting with source paths, per-domain log query status and cursor behavior, and retained/stale/removed stream handling where relevant. -6. Extend TUI verification around `crates/cadder-tui/src/model.rs` for overview counts, entrypoint/domain grouping, domain log target opening, severity filter reset, diagnostics state visibility, and daemon shutdown request state. -7. If practical with a small refactor, extract pure render or command-building helpers from `crates/cadder-tui/src/main.rs` into testable functions/modules. If full terminal automation would add too much complexity, add a manual TUI smoke checklist instead. -8. Add a TUI smoke checklist or automation covering overview, entrypoint grouping, domain toggles, log opening, diagnostics, and daemon shutdown flow on supported terminal backends. -9. Keep optional real-Caddy verification as `#[ignore]` tests or documented manual checks, never as required workspace tests. -10. Run focused checks after each slice, then full workspace validation before closeout. - -## Validation -- `cargo fmt --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- Focused: `cargo test -p cadder-daemon --test ipc_lifecycle` -- Focused: `cargo test -p cadder-tui` -- Optional packaging smoke if useful: `cargo run -p xtask -- dist --out .local/verification/task-1.12/dist`, with artifacts removed afterward unless intentionally retained under `.local`. - -## Assumptions -- Acceptance criterion #5 can be satisfied by a clear manual smoke checklist unless lightweight terminal automation proves straightforward. -- Automated tests should not require a real local Caddy binary. -- Runtime failure reporting means asserting the daemon exposes config diagnostics and runtime-control logs for start/reload/apply failures; deeper runtime health polling is outside this task unless already present. - -## Risks And Boundaries -- The current shim process waits for Ctrl+C, so process-level shim death tests may be heavier than IPC-session tests. Prefer direct `CadderSession` integration tests unless a process harness is clearly worth the added complexity. -- Avoid broad TUI restructuring. Only extract testable units when it directly supports the smoke/verification acceptance criteria. -- Do not silently expand scope into coverage gates, CI/CD, docs site generation, packaging installers, or real Caddy dependency requirements. - - -## Implementation Notes - - -Plan approved by user and recorded before implementation. Task moved to In Progress and assigned to @agent. - -Implemented IPC lifecycle coverage for zero, one, and ten concurrent shim registrations; session-disconnect cleanup for a single shim; fake Caddy adapt/run/reload side effects; domain toggles; conflict diagnostics; runtime reload failure diagnostics/control logs; and per-domain log query cursor/status behavior. Extended TUI model tests for overview/domain association/log metadata/diagnostics and added docs/verification/tui-smoke.md for manual terminal smoke verification. Validation passed: cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace; cargo run -p xtask -- check. - -Closeout review completed after final focused checks: cargo test -p cadder-daemon --test ipc_lifecycle; cargo test -p cadder-tui; git diff --check. - - -## Comments - - -author: @agent -created: 2026-06-10 10:44 ---- -Rebaselined from GUI verification to Rust daemon/shim/TUI verification as part of the cross-platform rewrite. ---- - - -## Final Summary - - -What changed: -- Added IPC lifecycle verification for empty state, single registration, ten concurrent registrations, session-disconnect cleanup, fake Caddy adapt/run/reload behavior, domain toggles, conflicts, runtime reload failures, and per-domain log queries. -- Extended TUI model coverage for overview counts, entrypoint/domain association, log response metadata, severity cursor resets, and diagnostics visibility. -- Added docs/verification/tui-smoke.md as the manual terminal smoke checklist for overview, entrypoint grouping, domain toggles, log opening, diagnostics, and daemon shutdown. - -Why: -- The task required automated and manual verification that Cadder's Rust daemon/shim/TUI lifecycle behaves correctly across the intended cross-platform workflows without depending on a locally installed real Caddy. - -Validation: -- cargo fmt --check -- cargo clippy --workspace --all-targets -- -D warnings -- cargo test --workspace -- cargo run -p xtask -- check -- cargo test -p cadder-daemon --test ipc_lifecycle -- cargo test -p cadder-tui -- git diff --check - -Risks / follow-ups: -- Real interactive terminal smoke remains manual by checklist; automated terminal driving was intentionally left out of scope. - diff --git a/backlog/tasks/task-1.13 - Expand-project-README-with-banner.md b/backlog/tasks/task-1.13 - Expand-project-README-with-banner.md deleted file mode 100644 index 748dd6c..0000000 --- a/backlog/tasks/task-1.13 - Expand-project-README-with-banner.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -id: TASK-1.13 -title: Expand project README for Rust portable cross-platform usage -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 16:10' -updated_date: '2026-06-10 13:24' -labels: [] -dependencies: - - TASK-1.11 -references: - - README.md - - assets/banner.png -documentation: - - docs/ARCHITECTURE.md -modified_files: - - README.md -parent_task_id: TASK-1 -priority: medium -ordinal: 13000 ---- - -## Description - - -Expand the README so a new user can understand and validate the Rust Cadder workspace, run or package the portable binaries, configure real Caddy resolution through layered configuration, and understand the daemon/shim/TUI workflow without assuming Cadder modifies PATH or installs global shims. - - -## Acceptance Criteria - -- [x] #1 README explains the `cadderd`, `caddy`, and `cadder-tui` binaries and their responsibilities. -- [x] #2 README documents Cargo validation commands, the `xtask` check workflow, and the portable dist/verification workflow introduced by TASK-1.11. -- [x] #3 README documents real Caddy resolution precedence: CLI, `cadder.toml` in CWD, `cadder.toml` next to the executable, environment variables, then `caddy` on PATH. -- [x] #4 README explains that PATH placement and shim naming are user-managed and optional, with per-platform examples that do not imply Cadder modifies system state. -- [x] #5 README avoids Windows-only tray, WinUI, MSIX, or .NET build instructions. - - -## Implementation Plan - - -## Goal -Expand README.md so a new user can understand Cadder's cross-platform Rust workspace, binary roles, validation commands, portable distribution workflow, real Caddy resolution, and optional user-managed PATH setup. - -## Scope -- Update only README.md. -- Keep the README aligned with TASK-1.11 and docs/ARCHITECTURE.md. -- Document current portable binary behavior; do not introduce installer, service, package-manager, WinUI, MSIX, tray, or .NET instructions. - -## Implementation Steps -1. Review the existing README structure and keep the current banner/project introduction if present. -2. Expand the binary roles section for `cadderd`, `caddy`, and `cadder-tui`, including daemon ownership, shim registration/delegation, and TUI inspection/control responsibilities. -3. Expand build and validation docs with `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, and `cargo run -p xtask -- check`. -4. Add a portable distribution section that documents `cargo run -p xtask -- dist --out `, output files (`cadderd`, `cadder-tui`, `caddy`, and `cadder.toml`), Windows `.exe` suffix behavior, `cargo run -p xtask -- verify-dist --dir `, and the fact that dist/verify do not modify PATH, shell profiles, services, package-manager shims, or system state. -5. Expand real Caddy resolution documentation with the exact precedence: CLI override, `cadder.toml` in the current working directory, `cadder.toml` next to the executable, environment variables including `CADDER_CADDY_REAL_COMMAND`, then safe real `caddy` on PATH. -6. Document `cadder.toml` examples and path behavior: absolute path, relative path with a path separator resolved relative to that config file, plain command resolved via PATH, and `caddy-real` supported only when explicitly configured. -7. Add optional user-managed PATH/shim examples per platform: Windows PowerShell example using a user-controlled directory, and macOS/Linux shell example using a user-controlled directory or symlink/copy. Make clear Cadder does not perform these changes. -8. Add a short first-run/user workflow: build or unpack portable layout, configure real Caddy if needed, optionally place shim on PATH, run `caddy run` from a project, and inspect with `cadder-tui`. -9. Re-read README against all TASK-1.13 acceptance criteria and remove any text implying Windows-only tray, WinUI, MSIX, .NET, automatic PATH mutation, or global install ownership. - -## Validation -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- dist --out .local/verification/task-1.13/dist` if a fresh portable layout is needed for verification -- `cargo run -p xtask -- verify-dist --dir .local/verification/task-1.13/dist` when a fresh layout is created -- `git diff --check` -- `git status --short` - -## Scope Boundaries -- Do not edit files other than README.md unless new evidence requires user approval. -- Do not add installers, PATH mutation, OS services, package-manager recipes, WinUI, MSIX, tray, or .NET instructions. -- Do not change acceptance criteria while implementing without user approval. - -## Risks And Notes -- README already contains a compact TASK-1.11 summary, so the implementation should expand and organize it without duplicating the same facts in multiple places. -- PATH examples must read as optional user-managed setup, not as Cadder-owned installation behavior. -- Real Caddy precedence must match code and architecture docs exactly. - - -## Implementation Notes - - -Plan approved by the user and recorded before implementation. Task prepared for execution with README.md as the intended write scope. - -Implemented the README-only expansion for TASK-1.13. README.md now includes the banner asset, binary role descriptions for cadderd/caddy/cadder-tui, Cargo and xtask validation commands, portable dist and verify-dist workflow, layered real Caddy resolution precedence, cadder.toml path behavior, optional user-managed PATH/shim examples for Windows PowerShell and macOS/Linux shells, and a first-run workflow. Verified that the text avoids Windows-only tray, WinUI, MSIX, and .NET build instructions and does not imply Cadder modifies PATH or system state. Validation passed: git diff --check; cargo run -p xtask -- check; cargo run -p xtask -- dist --out .local/verification/task-1.13/dist; cargo run -p xtask -- verify-dist --dir .local/verification/task-1.13/dist. Temporary .local verification artifacts created for the dist check were removed after verification. - -Closeout fresh-eyes review found one wording issue in the optional PATH examples: headings said "current shell only" even though the copy/symlink persists in the chosen user directory. Updated those headings to "user directory plus current shell PATH" and reran `git diff --check`, which passed. - - -## Comments - - -author: @agent -created: 2026-06-10 11:06 ---- -Rebaselined from README banner work to Rust cross-platform usage documentation after the project pivot away from WinUI. ---- - -created: 2026-06-10 12:04 ---- -TASK-1.11 planning rebaselined usage documentation from PATH installation to portable binaries, user-managed optional PATH/shim setup, and layered real Caddy configuration. README scope should follow that model. ---- - - -## Final Summary - - -README.md was expanded into a cross-platform Rust usage guide for Cadder. It now shows the banner asset, explains the `cadderd`, `caddy`, and `cadder-tui` binaries, documents Cargo and `xtask` validation, describes portable `dist` and `verify-dist` workflows, and captures the layered real Caddy resolution order with `cadder.toml` path behavior. - -The README also clarifies that PATH placement and shim naming are optional user-managed choices. It includes Windows PowerShell and macOS/Linux examples that operate in user-controlled directories and do not imply Cadder edits PATH, shell profiles, services, package-manager shims, or system state. Windows-only tray, WinUI, MSIX, and .NET build instructions were not added. - -Validation passed: `git diff --check`, `cargo run -p xtask -- check`, `cargo run -p xtask -- dist --out .local/verification/task-1.13/dist`, and `cargo run -p xtask -- verify-dist --dir .local/verification/task-1.13/dist`. Temporary `.local` verification artifacts created for the dist check were removed after verification. After the final wording clarification, `git diff --check` was rerun and passed. - diff --git a/backlog/tasks/task-1.14 - Use-logo-asset-for-tray-and-executable-icons.md b/backlog/tasks/task-1.14 - Use-logo-asset-for-tray-and-executable-icons.md deleted file mode 100644 index 187448e..0000000 --- a/backlog/tasks/task-1.14 - Use-logo-asset-for-tray-and-executable-icons.md +++ /dev/null @@ -1,166 +0,0 @@ ---- -id: TASK-1.14 -title: Add cross-platform release metadata and terminal branding -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 16:10' -updated_date: '2026-06-10 13:51' -labels: [] -dependencies: - - TASK-1.11 -references: - - assets/logo.png - - assets/banner.png -documentation: - - README.md - - docs/ARCHITECTURE.md -modified_files: - - crates/cadderd/Cargo.toml - - crates/cadderd/src/main.rs - - crates/cadder-shim/Cargo.toml - - crates/cadder-shim/src/main.rs - - crates/cadder-tui/Cargo.toml - - crates/cadder-tui/src/main.rs - - README.md - - docs/ARCHITECTURE.md -parent_task_id: TASK-1 -priority: medium -ordinal: 14000 ---- - -## Description - - -Replace the previous Windows tray/executable icon scope with cross-platform release metadata and lightweight terminal branding. Cadder should not depend on Windows tray icons or Windows App SDK assets in the final Rust implementation. - - -## Acceptance Criteria - -- [x] #1 Rust binaries expose consistent names, versions, descriptions, and help text through `--help` and `--version`. -- [x] #2 README and architecture docs describe the `cadderd`, `caddy`, and `cadder-tui` binaries and the cross-platform install model. -- [x] #3 Any retained image or logo assets are optional documentation/release assets and are not required for runtime behavior. -- [x] #4 No Windows tray icon, WinUI asset, or Windows App SDK packaging requirement remains in the final build path. - - -## Implementation Plan - - -## Goal -Add consistent cross-platform release metadata and lightweight terminal branding for the three Rust binaries while keeping image assets optional and removing any Windows tray/WinUI packaging assumptions from the final build path. - -## Scope -- Cover `cadderd`, the PATH-facing `caddy` shim, and `cadder-tui`. -- Improve CLI metadata exposed through `--help` and `--version`. -- Keep the runtime model portable and terminal-first. -- Clarify documentation around optional visual assets and the cross-platform install model. -- Do not reintroduce Windows tray icons, WinUI assets, Windows App SDK packaging, installers, PATH mutation, or release publishing in this task. - -## Current Findings -- `cadderd --version`, `caddy --version`, and `cadder-tui --version` all return `0.1.0` with the expected binary names. -- All three `--help` commands work, but public options have little or no descriptive help text. -- The `caddy` shim help currently exposes only raw trailing `[CADDY_ARGS]...` without explaining managed `caddy run` behavior versus delegation to the real Caddy binary. -- README and architecture docs already describe the three binaries and portable installation model, but they should explicitly state that retained `assets/logo.png` and `assets/banner.png` are optional documentation/release assets, not runtime requirements. -- The old `src/Cadder.Tray.WinUI` tree is absent, and no WinUI/App SDK references were found outside Backlog task history. - -## Key Files And Modules -- `Cargo.toml` -- `crates/cadderd/Cargo.toml` -- `crates/cadderd/src/main.rs` -- `crates/cadder-shim/Cargo.toml` -- `crates/cadder-shim/src/main.rs` -- `crates/cadder-tui/Cargo.toml` -- `crates/cadder-tui/src/main.rs` -- `README.md` -- `docs/ARCHITECTURE.md` -- `assets/logo.png` -- `assets/banner.png` - -## Implementation Steps -1. Normalize release metadata in Cargo manifests. Prefer workspace package metadata where it fits, and add clear package descriptions for the daemon, shim, and TUI packages. -2. Update the `clap` command metadata for `cadderd`, `caddy`, and `cadder-tui` so each binary has a consistent name, version, short description, and help text style. -3. Add descriptive help strings for public daemon and TUI options: `--runtime-dir`, `--real-caddy-command`, `--daemon-path`, and `--no-start`. -4. Improve the `caddy` shim help text for trailing Caddy arguments so users can understand that `caddy run` is managed by Cadder while other commands are delegated to the safely resolved real Caddy binary. -5. Keep hidden Cadder-internal options hidden, but verify that public help still explains the supported operating model clearly enough without exposing internal flags. -6. Add focused CLI metadata tests without introducing a heavy test framework. Prefer `clap::CommandFactory`-style tests inside the binary crates to assert names, versions, descriptions, and public help content. -7. Update README and architecture documentation only where needed to clarify that retained logo/banner assets are optional documentation or release assets and are not required by runtime behavior. -8. Verify that no final build path references Windows tray icons, WinUI assets, or Windows App SDK packaging. - -## Validation -- `cargo fmt --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- Manual CLI smoke checks: - - `cargo run -q -p cadderd -- --help` - - `cargo run -q -p cadderd -- --version` - - `cargo run -q -p cadder-shim --bin caddy -- --help` - - `cargo run -q -p cadder-shim --bin caddy -- --version` - - `cargo run -q -p cadder-tui -- --help` - - `cargo run -q -p cadder-tui -- --version` -- Search verification that WinUI/App SDK/tray-icon references are absent from implementation and packaging paths. -- `git status --short` before closeout. - -## Risks And Notes -- The shim binary intentionally reports the command name `caddy`; its description and help text should make clear that this is the Cadder shim without breaking expected `caddy` command ergonomics. -- Help text should remain concise and terminal-friendly; avoid long marketing copy in CLI output. -- Documentation changes should stay scoped because README and architecture already cover most of the portable install model from TASK-1.11. -- Adding snapshot-style tests could create brittle formatting churn; focused assertions against command metadata and selected help substrings should be more stable. - -## Scope Boundaries -- Do not implement release artifact publishing or CI release workflows; those remain outside this task. -- Do not add GUI, tray, WinUI, Windows App SDK, or platform-specific icon requirements. -- Do not require logo/banner assets at runtime. -- Do not modify PATH, shell profiles, package-manager shims, or OS service registration. - - -## Implementation Notes - - -Plan approved by the user and recorded before implementation. Task was executed under the recorded plan. - -Implemented cross-platform release metadata for the Rust binaries. `cadderd`, the PATH-facing `caddy` shim, and `cadder-tui` now inherit workspace package release fields, declare package descriptions, expose consistent Clap command names and versions, and include public help text for user-facing options or trailing Caddy arguments. Added focused command metadata/help tests in each binary crate. - -Fresh-eyes review found that the help-output tests did not explicitly distinguish short `-h` help from full `--help` output. Fixed this by adding short-help package-description assertions and long-help behavior assertions for all three binaries. - -Updated README and architecture documentation to describe `--help`/`--version`, the three binaries, the portable install/runtime model, and that `assets/logo.png`/`assets/banner.png` are documentation or release artwork only, not runtime requirements. - -Validation passed after the final changes: `cargo fmt --check`; `cargo test -p cadderd -p cadder-shim -p cadder-tui`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo run -p xtask -- check`; manual `--help` and `--version` smoke checks for all three binaries; `cargo run -p xtask -- dist --out .local/verification/task-1.14/dist`; `cargo run -p xtask -- verify-dist --dir .local/verification/task-1.14/dist`; `git diff --check`. Search verification found no active WinUI, Windows App SDK, AppIcon, Package.appxmanifest, Cadder.Tray, or tray-icon references in README, docs, Cargo manifests, crates, xtask, or assets. The temporary `.local/verification/task-1.14` dist directory was removed. - - -## Comments - - -author: @agent -created: 2026-06-10 10:44 ---- -Rebaselined from Windows tray/executable icon work to cross-platform release metadata because the user explicitly redirected the project to Rust + Ratatui and no Windows-only GUI. ---- - - -## Final Summary - - -Implemented cross-platform release metadata and terminal-oriented CLI branding for the Rust Cadder binaries. - -What changed: -- `cadderd`, `caddy`, and `cadder-tui` now use workspace release metadata where appropriate and define package descriptions for release identity. -- Clap command definitions now expose stable command names, versions, descriptions, long help, and clearer public option/argument help text. -- Added focused tests for command metadata, short `-h` package descriptions, and long `--help` behavior across all three binaries. -- README and architecture docs now clarify the three binaries, `--help`/`--version`, the portable runtime/install model, and that retained logo/banner assets are documentation or release artwork only. - -Validation: -- `cargo fmt --check` -- `cargo test -p cadderd -p cadder-shim -p cadder-tui` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- Manual `--help` and `--version` smoke checks for `cadderd`, `caddy`, and `cadder-tui` -- `cargo run -p xtask -- dist --out .local/verification/task-1.14/dist` -- `cargo run -p xtask -- verify-dist --dir .local/verification/task-1.14/dist` -- `git diff --check` -- Search verification for removed Windows tray/WinUI/App SDK build-path references - -Risks or follow-ups: -- No known follow-up for this task. Retained image assets remain optional documentation/release assets and are not used by runtime commands. - diff --git a/backlog/tasks/task-1.15 - Add-GitHub-CI-CD-for-app-and-installer-builds.md b/backlog/tasks/task-1.15 - Add-GitHub-CI-CD-for-app-and-installer-builds.md deleted file mode 100644 index bd259eb..0000000 --- a/backlog/tasks/task-1.15 - Add-GitHub-CI-CD-for-app-and-installer-builds.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -id: TASK-1.15 -title: Add GitHub CI/CD for Rust portable binaries and release artifacts -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 18:45' -updated_date: '2026-06-11 16:18' -labels: [] -milestone: v1.0 -dependencies: - - TASK-1.11 - - TASK-1.16 -references: - - AGENTS.md - - .github/workflows - - Cargo.toml - - xtask -documentation: - - docs/ARCHITECTURE.md -modified_files: - - .github/workflows/ci.yml - - .github/workflows/release.yml - - .github/workflows/docs.yml - - xtask/src/main.rs - - xtask/Cargo.toml - - Cargo.lock - - docs/site/src/content/docs/guides/release-process.mdx - - docs/ARCHITECTURE.md - - README.md -parent_task_id: TASK-1 -priority: medium -ordinal: 15000 ---- - -## Description - - -Add CI/CD for the Rust workspace and cross-platform portable binary artifacts. The pipeline should validate formatting, Clippy, tests, and release builds for `cadderd`, `caddy`, and `cadder-tui` on supported platforms, then publish portable archives without relying on OS installers or system PATH mutation. - - -## Acceptance Criteria - -- [x] #1 CI runs `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, and `cargo test --workspace`. -- [x] #2 CI builds portable release artifacts for `cadderd`, `caddy`, and `cadder-tui` on Windows, Linux, and macOS or documents any platform intentionally deferred. -- [x] #3 Release packaging does not depend on WinUI, Windows App SDK, MSIX, NuGet, .NET SDK, shell profile edits, package-manager shims, or installer-specific state changes. -- [x] #4 CI uses fake Caddy fixtures for automated tests and does not require a machine-global real Caddy install. -- [x] #5 Release workflow publishes versioned portable archives to GitHub Releases for supported platforms or documents any platform intentionally deferred. -- [x] #6 GitHub Actions builds and publishes the generated documentation after the Astro Starlight documentation site exists. - - -## Implementation Plan - - -## Goal -Add GitHub CI/CD for the Rust workspace, portable release archives, GitHub Releases publishing, and generated documentation publishing without introducing installers or machine-global state changes. - -## Scope -- Add GitHub Actions workflows for workspace validation, release artifact publishing, and documentation publishing. -- Build and publish portable artifacts for `cadderd`, `caddy`, and `cadder-tui` on Windows, Linux, and macOS. -- Build macOS release artifacts for both processor families: `x86_64-apple-darwin` and `aarch64-apple-darwin`. -- Keep release packaging aligned with the existing portable model from TASK-1.11: no PATH mutation, shell profile edits, services, package-manager shims, installers, MSIX, NuGet, Windows App SDK, WinUI, or .NET SDK dependency. -- Use the existing Astro Starlight docs site from TASK-1.16 and publish generated output from source. -- Leave the full 85% coverage gate to TASK-1.17, while keeping this CI structure ready for that workflow to integrate later. - -## Key Files -- `.github/workflows/ci.yml` -- `.github/workflows/release.yml` -- `.github/workflows/docs.yml` -- `xtask/src/main.rs` -- `Cargo.toml` -- `docs/site/package.json` -- `docs/site/bun.lock` -- `docs/site/src/content/docs/guides/release-process.mdx` -- `README.md` if workflow-facing contributor commands need a short update - -## Implementation Steps -1. Add a CI workflow for `push` and `pull_request` with a runner matrix covering `ubuntu-latest`, `windows-latest`, and `macos-latest`. -2. In CI, run the required validation commands explicitly: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, and `cargo test --workspace`. Keep `cargo run -p xtask -- check` as an optional aggregate confirmation if it does not add excessive duplicate runtime. -3. Ensure automated tests continue to use repository fake Caddy fixtures and do not require a machine-global real Caddy installation. -4. Add or extend `xtask` release packaging support so CI can create versioned portable archives from the existing `xtask dist --out ` layout instead of duplicating package layout rules in YAML. -5. Define stable artifact names, for example `cadder--windows-x64.zip`, `cadder--linux-x64.tar.gz`, `cadder--macos-x64.tar.gz`, and `cadder--macos-arm64.tar.gz`, plus checksum files. -6. Add a release workflow triggered by version tags such as `v*`. It should build release layouts, run `xtask verify-dist`, archive each layout, generate checksums, upload workflow artifacts, and publish the archives to GitHub Releases. -7. Configure the release matrix so Windows and Linux build their native x64 artifacts, while macOS builds both `x86_64-apple-darwin` and `aarch64-apple-darwin`. Install Rust targets as needed with `rustup target add` and document any platform limitation if cross-target packaging cannot be completed cleanly. -8. Prefer first-party GitHub Actions and `gh release` for release publishing. Use minimal permissions: `contents: read` by default and `contents: write` only for the release publishing job. -9. Add a docs workflow for `push` to `main` that installs Bun, runs `bun install --frozen-lockfile`, `bun run check`, and `bun run build` in `docs/site`, then publishes `docs/site/dist` through GitHub Pages or uploads it as the Pages artifact. -10. Use minimal Pages permissions for docs publishing: `pages: write` and `id-token: write` for the deploy job, with `contents: read` for source checkout. -11. Update release-process documentation only where needed so it names the real workflow commands and artifact conventions without claiming installer behavior. -12. Keep generated docs output, release archives, checksums, and temporary packaging directories out of git unless a source file explicitly belongs in the repository. - -## Validation -- `cargo fmt --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- dist --out .local/verification/task-1.15/dist` -- `cargo run -p xtask -- verify-dist --dir .local/verification/task-1.15/dist` -- Local archive/checksum command or `xtask` package command added for TASK-1.15 -- `cd docs/site && bun install --frozen-lockfile` -- `cd docs/site && bun run check` -- `cd docs/site && bun run build` -- Local YAML validation where available -- `git status --short` to confirm only intended source/workflow/task changes remain - -## Risks And Boundaries -- macOS arm64 packaging may need explicit Rust target installation and verification on a macOS runner. If a target cannot be built or verified in GitHub-hosted Actions, document the reason in the workflow/docs rather than silently omitting it. -- Cross-compiling from non-macOS runners is out of scope unless native GitHub-hosted macOS runners cannot satisfy the release matrix. -- Release artifacts must remain portable archives, not installers. -- GitHub Pages publishing may require repository Pages settings to use GitHub Actions as the source; document this as an operator prerequisite if needed. -- Coverage enforcement is intentionally left to TASK-1.17, which depends on this CI foundation. - - -## Implementation Notes - - -Plan approved by the user and recorded before implementation. User requested one adjustment: macOS release artifacts must be built for both x64 and arm64 processor families. - -Implemented GitHub Actions CI, release, and documentation publishing for TASK-1.15. CI runs the required Rust format, Clippy, and workspace test commands on Windows, Linux, and macOS without installing a real machine-global Caddy. Release workflow builds native portable archives for windows-x64, linux-x64, macos-x64, and macos-arm64, uploads workflow artifacts, and publishes archives plus .sha256 files to GitHub Releases with gh release. Docs workflow builds the existing Astro Starlight site with Bun and deploys docs/site/dist through GitHub Pages. Extended xtask with target-aware dist/verify-dist and package support, including ZIP/TAR.GZ archive creation and checksums. Updated release documentation, architecture notes, and README. Validation passed: cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace; cargo run -p xtask -- check; cargo run -p xtask -- dist --out .local/verification/task-1.15/dist; cargo run -p xtask -- verify-dist --dir .local/verification/task-1.15/dist; rustup target add x86_64-pc-windows-msvc; cargo run -p xtask -- package --out .local/verification/task-1.15-package/artifacts --version 0.1.0 --platform windows-x64 --target x86_64-pc-windows-msvc; cd docs/site && bun install --frozen-lockfile; bun run check; bun run build; Ruby YAML parse for all workflow files; cargo +stable-x86_64-pc-windows-msvc llvm-cov --workspace --json --summary-only --fail-under-lines 85 --output-path .local/coverage-summary.json passed with 85.46% line coverage. Local generated docs output and verification artifacts were removed after validation. - -Fresh-eyes closeout review found and fixed a duplicate `required_path_option_reads_value_after_option` unit test in `xtask/src/main.rs` that would have caused a duplicate symbol compile failure. Re-ran validation after the fix: `cargo fmt --check`; `cargo test -p xtask`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo run -p xtask -- check`; `cargo run -p xtask -- dist --out .local/verification/task-1.15-closeout/dist`; `cargo run -p xtask -- verify-dist --dir .local/verification/task-1.15-closeout/dist`; `cargo run -p xtask -- package --out .local/verification/task-1.15-closeout/artifacts --version 0.1.0 --platform windows-x64 --target x86_64-pc-windows-msvc`; Ruby YAML parse for `.github/workflows/*.yml`; `cd docs/site && bun install --frozen-lockfile`; `bun run check`; `bun run build`. The docs build emitted the existing sitemap warning because `site` is not configured in `astro.config.mjs`. I attempted to rerun the 85% coverage gate with `cargo +stable-x86_64-pc-windows-msvc llvm-cov ...`, but `cargo-llvm-cov` is not installed for the available toolchains in this session. Temporary verification artifacts, `docs/site/dist`, `docs/site/.astro`, and `docs/site/node_modules` were removed after validation. - - -## Comments - - -author: @agent -created: 2026-06-10 11:06 ---- -Rebaselined from app/installer CI to Rust binary CI/CD after the project pivot away from .NET/WinUI. ---- - -author: agent -created: 2026-06-10 11:29 ---- -Expanded from base Rust CI/CD to include the new AGENTS.md requirements for GitHub Releases artifact publishing and generated documentation publishing. Added TASK-1.16 as a dependency because documentation publishing needs the Astro Starlight site first. ---- - -created: 2026-06-10 12:04 ---- -TASK-1.11 planning clarified that Cadder should publish portable executables/archives, not installers or package-manager-specific PATH changes. CI/CD release artifacts should preserve that boundary. ---- - - -## Final Summary - - -Implemented GitHub Actions CI/CD for Cadder's Rust portable binary release model. - -What changed: -- Added CI, release, and documentation publishing workflows under `.github/workflows`. -- Extended `xtask` with target-aware `dist`, `verify-dist`, and `package` support, including ZIP/TAR.GZ archives and SHA-256 checksum files. -- Documented release artifact naming, GitHub Release publishing, Pages deployment, and the no-installer/no-global-state packaging boundary in README, architecture docs, and the Starlight release process guide. -- Fixed a duplicate `xtask` unit test found during closeout review. - -Impact: -- CI validates formatting, Clippy, and workspace tests on Windows, Linux, and macOS. -- Release tags can publish portable archives for windows-x64, linux-x64, macos-x64, and macos-arm64 without WinUI, MSIX, NuGet, .NET SDK, PATH mutation, shell profile edits, or installer state. -- Docs are built from the Astro Starlight source and deployed via GitHub Pages. - -Validation: -- `cargo fmt --check` -- `cargo test -p xtask` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- dist --out .local/verification/task-1.15-closeout/dist` -- `cargo run -p xtask -- verify-dist --dir .local/verification/task-1.15-closeout/dist` -- `cargo run -p xtask -- package --out .local/verification/task-1.15-closeout/artifacts --version 0.1.0 --platform windows-x64 --target x86_64-pc-windows-msvc` -- Ruby YAML parse for `.github/workflows/*.yml` -- `cd docs/site && bun install --frozen-lockfile` -- `cd docs/site && bun run check` -- `cd docs/site && bun run build` - -Risks and follow-ups: -- Local closeout could not rerun the coverage gate because `cargo-llvm-cov` is not installed for the available toolchains in this session. Earlier task validation recorded an 85.46% line coverage pass. -- The docs build currently warns that sitemap generation is skipped because `astro.config.mjs` has no `site` option; this does not block TASK-1.15 but should be handled when production docs URL configuration is finalized. - diff --git a/backlog/tasks/task-1.16 - Build-Astro-Starlight-documentation-site.md b/backlog/tasks/task-1.16 - Build-Astro-Starlight-documentation-site.md deleted file mode 100644 index b6dfba7..0000000 --- a/backlog/tasks/task-1.16 - Build-Astro-Starlight-documentation-site.md +++ /dev/null @@ -1,191 +0,0 @@ ---- -id: TASK-1.16 -title: Build Astro Starlight documentation site -status: Done -assignee: - - '@agent' -created_date: '2026-06-10 11:29' -updated_date: '2026-06-11 16:18' -labels: [] -milestone: v1.0 -dependencies: [] -references: - - AGENTS.md -documentation: - - docs/ARCHITECTURE.md -modified_files: - - README.md - - docs/site/.gitignore - - docs/site/astro.config.mjs - - docs/site/bun.lock - - docs/site/package.json - - docs/site/README.md - - docs/site/tsconfig.json - - docs/site/public/favicon.svg - - docs/site/src/content.config.ts - - docs/site/src/env.d.ts - - docs/site/src/content/docs/index.mdx - - docs/site/src/content/docs/guides/getting-started.mdx - - docs/site/src/content/docs/guides/linux.mdx - - docs/site/src/content/docs/guides/macos.mdx - - docs/site/src/content/docs/guides/path-and-shim.mdx - - docs/site/src/content/docs/guides/portable-binaries.mdx - - docs/site/src/content/docs/guides/release-process.mdx - - docs/site/src/content/docs/guides/tui-diagnostics.mdx - - docs/site/src/content/docs/guides/validation.mdx - - docs/site/src/content/docs/guides/windows.mdx - - docs/site/src/content/docs/reference/architecture.mdx - - docs/site/src/content/docs/reference/real-caddy-resolution.mdx - - docs/site/src/content/docs/reference/runtime-configuration.mdx - - crates/cadder-daemon/src/config.rs - - crates/cadder-daemon/src/ipc.rs - - crates/cadder-daemon/src/paths.rs - - crates/cadder-daemon/src/state.rs - - crates/cadder-daemon/tests/ipc_lifecycle.rs - - crates/cadder-shim/src/main.rs - - crates/cadder-tui/src/main.rs - - crates/cadder-tui/src/model.rs - - xtask/src/main.rs -parent_task_id: TASK-1 -priority: medium -ordinal: 16000 ---- - -## Description - - -Create a durable English documentation site for Cadder using Astro and the Starlight theme. The site should make the cross-platform Rust daemon, shim, TUI, portable binary model, layered runtime configuration, per-system usage guidance, validation workflow, and release process understandable without relying on chat context or stale WinUI-era documents. - - -## Acceptance Criteria - -- [x] #1 The repository contains an Astro Starlight documentation site under `docs/` or a clearly documented docs workspace location. -- [x] #2 The documentation covers Cadder architecture, portable binaries, optional user-managed PATH/shim setup, the `cadderd`, `caddy`, and `cadder-tui` binaries, runtime directories, and supported configuration sources. -- [x] #3 The documentation explains real Caddy resolution precedence: CLI, `cadder.toml` in CWD, `cadder.toml` next to the executable, environment variables, then `caddy` on PATH. -- [x] #4 The documentation includes per-system usage guidance for Windows, macOS, and Linux that explains how to use Cadder well without implying the application mutates system PATH. -- [x] #5 Existing durable architecture content from `docs/ARCHITECTURE.md` is preserved, migrated, or linked so no current architecture detail is lost. -- [x] #6 The docs build can be run reproducibly from the repository using the project package manager and is documented for contributors. -- [x] #7 The documentation avoids stale WinUI, MSIX, NuGet, or .NET build instructions unless explicitly marked as historical context. - - -## Implementation Plan - - -## Goal -Add a durable Astro Starlight documentation site for Cadder. The repository should contain only documentation source files and reproducible build configuration. Generated site output must not be committed; GitHub Actions should build generated documentation from source on `main`. - -## Scope -- Create an Astro Starlight docs workspace under `docs/site/`. -- Use Bun as the package manager. -- Commit source/config files and `bun.lock`. -- Do not commit generated output such as `dist/`, `.astro/`, build cache, or generated static HTML. -- Preserve or link current durable architecture content from `docs/ARCHITECTURE.md`. -- Do not add stale WinUI, MSIX, NuGet, .NET, installer, or automatic PATH mutation guidance. -- Treat GitHub Actions publishing as the CI contract for TASK-1.15 unless the user explicitly expands TASK-1.16 to add the workflow now. - -## Key Files -- `docs/site/package.json` -- `docs/site/bun.lock` -- `docs/site/astro.config.mjs` -- `docs/site/src/content.config.ts` -- `docs/site/src/content/docs/**` -- `docs/site/README.md` -- `docs/ARCHITECTURE.md` -- `README.md` -- `.gitignore` if extra generated docs paths need exclusion - -## Implementation Steps -1. Scaffold `docs/site` as an Astro Starlight site using Bun. -2. Add Bun scripts: - - `bun run dev` - - `bun run check` - - `bun run build` - - `bun run preview` -3. Ensure generated output stays out of git. Verify `docs/site/dist/`, `docs/site/.astro/`, and cache/build artifacts are ignored. -4. Build Starlight content around: - - Overview - - Getting Started - - Architecture - - Portable Binaries - - Real Caddy Resolution - - PATH and Shim Strategy - - Windows Usage - - macOS Usage - - Linux Usage - - Runtime Directories and Configuration - - TUI and Diagnostics - - Validation - - Release Process -5. Document real Caddy resolution exactly: CLI override, `cadder.toml` in CWD, `cadder.toml` next to executable, environment variables, then safe `caddy` on PATH. -6. Document that Cadder does not mutate PATH, shell profiles, package-manager shims, services, or installer state. -7. Document contributor workflow with Bun from `docs/site`. -8. Add a CI handoff section: GitHub Actions should build docs on `push` to `main` from source using Bun, publish/upload generated output, and never write generated docs back to the repository. -9. Cross-check docs against `README.md`, `docs/ARCHITECTURE.md`, `xtask`, and the Rust CLI/config source files. - -## GitHub Actions Contract -When implemented in TASK-1.15, docs publishing should: -- run on `push` to `main`; -- install Bun; -- run `bun install --frozen-lockfile` in `docs/site`; -- run `bun run check`; -- run `bun run build`; -- publish or upload `docs/site/dist`; -- never commit generated output. - -## Validation -- `cd docs/site && bun install --frozen-lockfile` -- `cd docs/site && bun run check` -- `cd docs/site && bun run build` -- `cargo run -p xtask -- check` -- `git status --short` to confirm no generated output is tracked - -## Risks -- Starlight source can drift from Rust CLI behavior if examples duplicate too much detail. -- Release-process docs must avoid claiming CI publishing exists before TASK-1.15 implements it. -- Generated docs output must stay out of the repo, including local preview/build artifacts. - -## Coverage DoD blocker fix plan -- Re-run workspace coverage locally with the same `cargo-llvm-cov` path used during closeout to get actionable per-file/module data instead of relying only on the previous 62.48% total. -- Identify narrow, high-impact uncovered code paths already within TASK-1.16's validation/DoD scope, preferring tests over production refactors. -- Add focused tests in the existing Rust crates without expanding TASK-1.16's documentation scope. -- Run targeted tests first, then re-run coverage and the relevant workspace validation needed to prove DoD #2 is satisfied or document any remaining blocker. - - -## Implementation Notes - - -Plan approved by the user and recorded before implementation. User requested Bun instead of npm and clarified that generated documentation output must not be committed; GitHub Actions should build generated docs from source on main. - -Implemented the Astro Starlight documentation site under `docs/site` using Bun with exact dependency versions and source-only commits. Added pages for overview, getting started, architecture, portable binaries, real Caddy resolution, PATH/shim strategy, Windows/macOS/Linux usage, runtime configuration, TUI diagnostics, validation, and release-process CI handoff. Added `docs/site/.gitignore` entries for generated `dist`, `.astro`, and `node_modules`, updated root `README.md` with the docs workspace commands, and removed generated local artifacts after validation. Verification run: `cd docs/site && bun install --frozen-lockfile`, `bun run check`, `bun run build`; Playwright smoke check against `http://127.0.0.1:4321/` verified key pages and crawled 13 local links with HTTP 200; `cargo run -p xtask -- check` passed. Coverage was not measured because the repository coverage gate is tracked separately by TASK-1.17, so DoD coverage remains unchecked. - -Closeout attempt: coverage was measured with local `cargo-llvm-cov v0.8.7` installed under `.local/cargo-tools` and run via `cargo +stable-x86_64-pc-windows-msvc llvm-cov --workspace --summary-only`. The GNU toolchain attempt failed with `can't find crate for profiler_builtins`; the MSVC run succeeded but reported total line coverage `62.48%`, below the project threshold of 85%. TASK-1.16 remains In Progress because Definition of Done #2 is not satisfied. - -Coverage DoD blocker fix started. The previous measured result was total line coverage 62.48%, below the 85% project threshold, so the next step is to produce per-file coverage detail and add focused tests rather than changing task scope. - -Resolved the coverage DoD blocker by adding focused tests for existing Rust behavior rather than changing production behavior: TUI rendering/model/async response paths, daemon state owner and log query branches, IPC unsupported/unexpected response handling and envelope serialization, runtime path/config helpers, shim registration/delegation parsing, and xtask path/dist validation helpers. The prior blocker was total line coverage 62.48%; the final enforced run `cargo +stable-x86_64-pc-windows-msvc llvm-cov --workspace --json --summary-only --fail-under-lines 85 --output-path .local\coverage-summary.json` passed with total line coverage 85.28% (3621/4246 lines). Validation also passed: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, and `cargo run -p xtask -- check`. Fresh-eyes/code simplification pass fixed two local test-quality issues: unique temporary directories for xtask tests and draining the TUI async log refresh response in the domain-log-opening test. - - -## Definition of Done - -- [x] #1 Tests or explicit verification were run for the changed behavior -- [x] #2 Coverage was measured and remains at or above the project threshold - - -## Comments - - -created: 2026-06-10 12:04 ---- -TASK-1.11 planning added the requirement for per-system usage docs around portable binaries, optional user-managed PATH/shim setup, and layered real Caddy configuration. The Starlight site should cover that durable guidance. ---- - - -## Final Summary - - -Completed the Astro Starlight documentation site and resolved the task-level coverage blocker by adding focused Rust tests for existing behavior. The docs workspace lives under `docs/site` with Bun/Starlight source, contributor commands, platform guidance, architecture/runtime references, validation guidance, and release-process handoff notes; generated output remains untracked. - -Validation passed on the final tree: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo run -p xtask -- check`, and `cargo +stable-x86_64-pc-windows-msvc llvm-cov --workspace --json --summary-only --fail-under-lines 85 --output-path .local\coverage-summary.json`. Coverage passed at 85.28% line coverage (3621/4246 lines). No substantive fresh-eyes issues remained during final review. - -Risks/follow-ups: docs publishing belongs to TASK-1.15, and the reusable coverage workflow/gate remains tracked by TASK-1.17. - diff --git a/backlog/tasks/task-1.17 - Define-and-enforce-test-coverage-gate.md b/backlog/tasks/task-1.17 - Define-and-enforce-test-coverage-gate.md deleted file mode 100644 index 0d730a3..0000000 --- a/backlog/tasks/task-1.17 - Define-and-enforce-test-coverage-gate.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -id: TASK-1.17 -title: Define and enforce test coverage gate -status: Done -assignee: - - '@agent' -created_date: '2026-06-10 11:29' -updated_date: '2026-06-11 16:18' -labels: [] -milestone: v1.0 -dependencies: - - TASK-1.15 -references: - - AGENTS.md - - backlog/config.yml -documentation: - - docs/ARCHITECTURE.md -modified_files: - - xtask/src/main.rs - - .github/workflows/ci.yml - - README.md - - docs/ARCHITECTURE.md - - docs/site/src/content/docs/guides/validation.mdx - - docs/site/src/content/docs/guides/release-process.mdx -parent_task_id: TASK-1 -priority: medium -ordinal: 17000 ---- - -## Description - - -Turn the project coverage expectation into an executable workflow. Cadder should have an explicit 85% coverage target, a documented way to measure it, and automation that keeps future changes honest without requiring a real machine-global Caddy installation. - - -## Acceptance Criteria - -- [x] #1 The project documents the coverage tool and command used to measure Rust workspace coverage. -- [x] #2 The documented workflow enforces at least 85% coverage or clearly fails when coverage cannot be measured. -- [x] #3 Coverage exclusions are limited to generated, platform-gated, or intentionally untestable code and are documented where the coverage command is defined. -- [x] #4 GitHub Actions runs the coverage workflow or records a clearly named follow-up dependency if the full CI workflow is not yet available. -- [x] #5 Backlog.md project Definition of Done defaults require tests or explicit verification and a coverage check for changed work. - - -## Implementation Plan - - -## Goal -Define a durable, executable Rust workspace coverage gate for Cadder with an explicit 85% line coverage threshold, local documentation, and GitHub Actions enforcement that does not require a machine-global real Caddy installation. - -## Scope -- Use `cargo-llvm-cov` as the canonical Rust coverage tool. -- Pin the documented/CI coverage tool version to `cargo-llvm-cov 0.8.7`, matching the existing local coverage baseline artifact. -- Add a project-owned `xtask coverage` command so contributors and CI use the same coverage gate. -- Enforce total line coverage with `--fail-under-lines 85` and write a JSON summary report. -- Prefer the Windows MSVC toolchain for the canonical gate because prior GNU coverage attempts hit a `profiler_builtins` issue while MSVC coverage passed. -- Keep coverage exclusions empty initially; future exclusions must be limited to generated, platform-gated, or intentionally untestable code and documented beside the command. -- Extend GitHub Actions CI with a dedicated coverage job that installs the coverage tool and runs the gate without installing real Caddy. -- Update contributor and architecture documentation so the coverage workflow is discoverable. -- Verify Backlog.md Definition of Done defaults already require tests or explicit verification and coverage for changed work. - -## Key Files -- `xtask/src/main.rs` -- `xtask/Cargo.toml` if implementation requires test-support dependency changes -- `.github/workflows/ci.yml` -- `README.md` -- `docs/ARCHITECTURE.md` -- `docs/site/src/content/docs/guides/validation.mdx` -- `docs/site/src/content/docs/guides/release-process.mdx` -- `backlog/config.yml` for verification only unless the defaults no longer satisfy the acceptance criteria - -## Implementation Steps -1. Add a `coverage` subcommand to `xtask` with constants for the 85% threshold and default report path, for example `target/llvm-cov/coverage-summary.json`. -2. Have `xtask coverage` run `cargo llvm-cov --workspace --json --summary-only --fail-under-lines 85 --output-path `, and optionally expose `--output ` only if it keeps the command simple and testable. -3. Make the failure mode clear when `cargo-llvm-cov` is missing by preserving the underlying command error and documenting the installation path. -4. Add focused `xtask` unit tests for coverage option parsing/command construction so adding the gate does not erode the current narrow coverage margin. -5. Update `.github/workflows/ci.yml` with a separate coverage job on `windows-latest` using `stable-x86_64-pc-windows-msvc`, installing `cargo-llvm-cov 0.8.7`, and running `cargo run -p xtask -- coverage`. -6. Keep the existing Rust matrix for format, Clippy, and workspace tests unchanged unless the coverage job needs shared setup. -7. Update `README.md`, `docs/ARCHITECTURE.md`, and the Starlight validation/release-process docs with the tool, version, threshold, command, output path, CI behavior, and exclusion policy. -8. Replace the TASK-1.17 placeholder language in the validation docs with the real workflow. -9. Confirm `backlog/config.yml` already contains both required DoD defaults; if it does, do not edit it. -10. Run validation, then update task notes/acceptance criteria during execution through Backlog MCP only after each criterion is actually satisfied. - -## Validation -- `cargo fmt --check` -- `cargo test -p xtask` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- coverage` -- YAML parse for `.github/workflows/*.yml` -- `cd docs/site && bun install --frozen-lockfile` -- `cd docs/site && bun run check` -- `cd docs/site && bun run build` -- `git status --short` - -## Risks And Boundaries -- The current recorded line coverage baseline is close to the threshold: `.local/coverage-summary.json` reports 85.46% line coverage with `cargo-llvm-cov 0.8.7`, so even small untested additions can fail the gate. -- Do not add artificial coverage exclusions to make the threshold pass. Prefer tests for existing behavior. -- Do not require or install a real machine-global Caddy in local coverage or CI; existing tests should continue to use fake Caddy fixtures. -- Do not fold coverage into `xtask check` unless explicitly approved later; keep coverage as a dedicated gate because it requires an extra tool and is slower. -- Do not change Backlog acceptance criteria or task scope while recording this plan. - - -## Implementation Notes - - -Plan approved by the user and recorded before implementation. - -Implemented the coverage gate with `cargo-llvm-cov 0.8.7`, an 85% total line threshold, and `target/llvm-cov/coverage-summary.json` as the default JSON summary output. `xtask coverage` now creates the report directory, supports `--output `, and uses `stable-x86_64-pc-windows-msvc` on Windows to avoid the known GNU `profiler_builtins` coverage failure. Updated CI to run a dedicated Windows MSVC coverage job and updated README, architecture, and Starlight validation/release docs with the tool version, command, threshold, output path, failure mode, and exclusion policy. Verified `backlog/config.yml` already contains the required DoD defaults. Validation passed: `cargo fmt --check`; `cargo test -p xtask`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo run -p xtask -- check`; `cargo run -p xtask -- coverage` with 4064/4757 covered lines (85.43%); YAML parse for `.github/workflows/*.yml`; `bun install --frozen-lockfile`; `bun run check`; `bun run build`. - -Fresh closeout review on 2026-06-11 found no substantive issues in the coverage gate implementation. Re-ran validation after review: `cargo fmt --check`; `cargo test -p xtask`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo run -p xtask -- check`; `cargo run -p xtask -- coverage` with 4064/4757 covered lines (85.43%); Python YAML parse for `.github/workflows/*.yml`; `bun install --frozen-lockfile`; `bun run check`; `bun run build`. `bun run build` still emits the existing Astro sitemap warning because `site` is not configured, but the build exits successfully. - - -## Final Summary - - -## Summary -- Added `xtask coverage` as the project-owned Rust workspace coverage gate using `cargo-llvm-cov 0.8.7`, an 85% total line threshold, and `target/llvm-cov/coverage-summary.json` as the default JSON summary output. -- Added focused `xtask` tests for coverage option parsing, command construction, and report directory creation. -- Added a dedicated GitHub Actions coverage job on Windows MSVC and documented the local/CI workflow, threshold, output path, failure behavior, and zero-exclusion policy in README, architecture, and Starlight docs. -- Confirmed Backlog.md Definition of Done defaults already require tests or explicit verification and a coverage check for changed work. - -## Validation -- `cargo fmt --check` -- `cargo test -p xtask` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- coverage` with 4064/4757 covered lines (85.43%) -- Python YAML parse for `.github/workflows/*.yml` -- `bun install --frozen-lockfile` -- `bun run check` -- `bun run build` - -## Risks And Follow-ups -- Coverage currently passes with a narrow margin, so future behavior changes should add tests before the gate regresses. -- TASK-1.19 tracks Docker/Testcontainers end-to-end coverage before the 1.0 release. - - -## Definition of Done - -- [x] #1 Tests or explicit verification were run for the changed behavior -- [x] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.18 - Support-detached-backend-mode-with-multiple-dashboards.md b/backlog/tasks/task-1.18 - Support-detached-backend-mode-with-multiple-dashboards.md deleted file mode 100644 index fd3ada6..0000000 --- a/backlog/tasks/task-1.18 - Support-detached-backend-mode-with-multiple-dashboards.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -id: TASK-1.18 -title: Support detached backend mode with multiple dashboards -status: Done -assignee: - - '@agent' -created_date: '2026-06-10 12:04' -updated_date: '2026-06-16 16:35' -labels: [] -milestone: m-2 -dependencies: - - TASK-1.11 - - TASK-1.12 -references: - - docs/ARCHITECTURE.md - - 'https://docs.rs/whirlwind/latest/whirlwind/' -modified_files: - - README.md - - crates/cadder-daemon/src/ipc.rs - - crates/cadder-daemon/src/lib.rs - - crates/cadder-daemon/src/state.rs - - crates/cadder-daemon/tests/ipc_lifecycle.rs - - crates/cadder-shim/src/main.rs - - crates/cadder-tui/src/main.rs - - crates/cadder-tui/src/model.rs - - docs/ARCHITECTURE.md - - docs/site/src/content/docs/index.mdx - - docs/site/src/content/docs/quick-start/getting-started.mdx - - docs/site/src/content/docs/reference/runtime-configuration.mdx - - docs/site/src/content/docs/user-guide/how-to-use.mdx - - docs/site/src/content/docs/user-guide/path-and-shim.mdx - - docs/site/src/content/docs/user-guide/tui-diagnostics.mdx - - docs/verification/testcontainers-e2e.md - - docs/verification/tui-smoke.md -parent_task_id: TASK-1 -priority: medium -ordinal: 18000 ---- - -## Description - - -Rebaseline Cadder's future runtime architecture around a single per-user `cadderd` operational backend and zero-to-many attach-only clients. `cadderd` is the only process that owns the backend and the real Caddy runtime. `cadder-tui` and the PATH-facing `caddy` shim never auto-start the daemon; they attach when it is available, surface offline or unavailable state when it is not, and keep working across daemon restarts. `cadder-tui` may expose an explicit user-triggered action to start `cadderd`, but backend startup is never an automatic side effect of opening a dashboard or running a shim command. This task should establish that detached backend foundation for multiple dashboards, while leaving the future desktop `cadder` app to a later task. - - -## Acceptance Criteria - -- [x] #1 Cadder supports a backend-only `cadderd` mode that runs independently of any interactive dashboard lifecycle and remains the only owner of the real Caddy runtime. -- [x] #2 Multiple dashboard/TUI clients can connect to the same backend concurrently, query state, subscribe to changes, disconnect independently, and reconnect without stopping the backend or removing shim registrations. -- [x] #3 `cadder-tui` and the `caddy` shim never auto-start `cadderd`; when the daemon is unavailable they surface explicit offline or unavailable guidance instead of starting it implicitly. -- [x] #4 `cadder-tui` can offer a user-triggered backend start action, but that action is explicit and distinct from ordinary attach, reconnect, and refresh behavior. -- [x] #5 CLI and UX clearly distinguish backend operation, dashboard attach, explicit backend start, and backend shutdown; `caddy run` requires a running backend and fails with a clear Cadder-owned message when `cadderd` is unavailable. -- [x] #6 Tests cover zero-to-many entrypoints with zero-to-many dashboard clients, including late backend availability, backend loss and reconnect, dashboard disconnects, and shim behavior when no backend is running. -- [x] #7 Architecture and user documentation explain the manual-daemon-first attach model, process-role boundaries, and when to use explicit backend start from the dashboard. - - -## Implementation Plan - - -## Goal -Establish Cadder's detached runtime foundation around a single manual-start per-user `cadderd` backend and zero-to-many attach-only clients, so multiple dashboards can observe the same backend without owning its lifecycle. - -## Scope -- Rebaseline runtime ownership so `cadderd` is the only backend process and the only owner of the real Caddy runtime. -- Keep `cadder-tui` and the `caddy` shim as attach-only clients that never auto-start the daemon. -- Allow `cadder-tui` to expose an explicit user-triggered backend start action, but keep that distinct from normal attach and reconnect behavior. -- Keep future desktop `cadder` semantics aligned with this model, but do not implement that application in this task. -- Cover reconnect and offline behavior, many dashboards, and shim behavior when the daemon is absent. - -## Key Files And Modules -- `crates/cadder-daemon/src/ipc.rs` -- `crates/cadder-daemon/src/lib.rs` -- `crates/cadder-daemon/src/state.rs` -- `crates/cadderd/src/main.rs` -- `crates/cadder-shim/src/main.rs` -- `crates/cadder-tui/src/main.rs` -- `crates/cadder-tui/src/model.rs` -- `crates/cadder-daemon/tests/ipc_lifecycle.rs` -- `docs/ARCHITECTURE.md` -- `README.md` -- `docs/site/src/content/docs/user-guide/tui-diagnostics.mdx` -- `docs/site/src/content/docs/reference/runtime-configuration.mdx` - -## Implementation Steps -1. Rebaseline the daemon-launch contract so `ensure_daemon_running_with_options` is no longer used implicitly by attach-only clients. Replace automatic startup paths with explicit connection attempts and clear unavailable-daemon diagnostics. -2. Update `cadder-shim` so `caddy run` only attaches to an already running daemon. If `cadderd` is unavailable, fail with a Cadder-owned operational message that explains how to start the backend manually. -3. Update `cadder-tui` startup so opening the dashboard never starts `cadderd` automatically. The TUI should boot into an offline or attach state, keep the UI responsive, and continue trying to detect and attach to the daemon in the background. -4. Add a deliberate user-triggered backend start path in the TUI. The control and messaging should clearly distinguish explicit backend start from ordinary reconnect and refresh behavior. -5. Extend the IPC client layer with a durable state-subscription path built on `subscribe-state-request`, so dashboards can observe state changes continuously after they attach rather than relying only on periodic snapshot polling. Keep polling or reconnect probes only as a fallback for daemon detection and recovery. -6. Fix daemon shutdown semantics so `ShutdownDaemonRequest` can terminate the actual `cadderd` process after replying to the client, not only stop the owned Caddy runtime. Ensure connected dashboards surface the loss of backend cleanly and can reattach after a later manual restart. -7. Preserve the existing ownership boundaries for shim registrations: dashboard disconnects must not remove registrations or stop the backend, while shim disconnect cleanup continues to remove only the registration owned by that shim session. -8. Expand automated coverage for zero-to-many dashboards and zero-to-many shims, including attach before backend startup, attach after backend startup, backend loss while dashboards are open, dashboard reconnect after backend restart, explicit backend start from the TUI, and shim failure behavior when no backend is running. -9. Update architecture and user documentation to describe the manual-daemon-first attach model, the process-role boundaries (`cadderd`, `cadder-tui`, `caddy`), and the explicit backend start and shutdown operations available to users. - -## Validation -- `cargo fmt --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- Focused: `cargo test -p cadder-daemon --test ipc_lifecycle` -- Focused: `cargo test -p cadder-tui` - -## Assumptions -- `cadder-tui` is the only dashboard delivered in this task; the future desktop `cadder` application remains out of scope but should follow the same attach-first contract later. -- `--no-start` may be retained temporarily as a compatibility alias if needed, but the resulting behavior must still be "never auto-start the daemon". -- Background daemon detection in the TUI means a lightweight reconnect or detection loop, not blocking startup and not busy-looping. -- Optional library evaluation: `whirlwind` may be considered only if subscriber or client bookkeeping becomes simpler with its concurrent sharded map or set types than with the existing Tokio and standard synchronization primitives. It is not required for this task. - -## Risks And Boundaries -- This task changes the current runtime contract from auto-start to manual-daemon-first; docs, help text, and user-facing errors must be updated consistently to avoid operator confusion. -- Do not introduce OS service managers, autostart agents, or network-remote dashboards in this task. -- Do not broaden scope into the future `cadder` desktop application implementation. -- Keep the backend single-instance per runtime directory; multiple dashboards do not imply multiple backend owners. - - -## Implementation Notes - - -Plan approved and recorded before implementation. Task rebaselined to the manual-daemon-first attach model per user approval, and the description plus acceptance criteria were updated to remove automatic dashboard or shim daemon startup. - -Implemented the detached manual-daemon-first contract across daemon, shim, TUI, tests, and docs. `caddy run` now requires an already running `cadderd` and returns a Cadder-owned guidance message instead of auto-starting the backend. `cadder-tui` now launches in attach-only mode, keeps `s` as an explicit backend start action, retries attach with `r`, upgrades successful attaches into a durable `subscribe-state-request` stream, and surfaces backend loss cleanly before reattaching after later availability. `ShutdownDaemonRequest` now stops the owned real Caddy runtime and terminates the actual daemon process after replying. Added IPC lifecycle coverage for multiple dashboard subscribers and daemon self-shutdown, plus shim/TUI tests for attach-only and reconnect flows. Validation run: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo run -p xtask -- check`, `cargo run -p xtask -- coverage`. Coverage gate passed at 87.37% lines (8051/9215). - -Fresh-eyes review found a shutdown-signal race in `ShutdownSignal::wait()` that could let `cadderd` miss a successful `ShutdownDaemonRequest` notification and continue accepting IPC clients. Fixed by registering the `Notify` waiter before checking the atomic shutdown flag, then reran focused IPC verification plus full `xtask` validation and coverage. Final coverage is 87.28% lines on the updated state. - -Attempted an independent Claude audit twice via `claude --print --dangerously-skip-permissions --permission-mode bypassPermissions --effort max --output-format text`. The CLI answered a trivial heartbeat prompt but both substantive audit runs stalled without producing output, so no Claude findings were available for this closeout. - - -## Final Summary - - -Rebased Cadder onto a manual-daemon-first runtime contract where `cadderd` is the only backend owner and both `cadder-tui` and the `caddy` shim are attach-only clients by default. The shim now fails `caddy run` with Cadder-owned startup guidance when the backend is unavailable, while the TUI starts in offline attach mode, offers an explicit `s` backend start action, upgrades successful attaches into durable `subscribe-state-request` updates, and handles backend loss plus later reattach without taking ownership of daemon lifecycle. - -Extended the daemon IPC layer with zero-to-many state subscriptions and daemon self-shutdown semantics, including stopping the real Caddy runtime and ending the daemon accept loop after `ShutdownDaemonRequest`. Added coverage for multi-dashboard subscriptions, backend shutdown behavior, attach-only shim/TUI flows, and updated architecture, user docs, and verification guides to explain the detached backend model and explicit operator actions. - -Validation on the final state: `cargo fmt --check`, `cargo test -p cadder-daemon --test ipc_lifecycle shutdown_daemon_request_stops_server_and_rejects_new_clients`, `cargo run -p xtask -- check`, `cargo run -p xtask -- coverage`. Coverage passed at 87.28% lines. Residual risk is low; the main follow-up is future live/manual smoke validation against a real backend environment if release confidence needs to go beyond the automated coverage already added. - - -## Definition of Done - -- [x] #1 Tests or explicit verification were run for the changed behavior -- [x] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.19 - Add-Testcontainers-end-to-end-coverage-before-1.0.md b/backlog/tasks/task-1.19 - Add-Testcontainers-end-to-end-coverage-before-1.0.md deleted file mode 100644 index cd7415f..0000000 --- a/backlog/tasks/task-1.19 - Add-Testcontainers-end-to-end-coverage-before-1.0.md +++ /dev/null @@ -1,160 +0,0 @@ ---- -id: TASK-1.19 -title: Add Testcontainers end-to-end coverage before 1.0 -status: Done -assignee: - - '@agent' -created_date: '2026-06-11 04:47' -updated_date: '2026-06-11 07:13' -labels: - - testing - - e2e - - pre-1.0 -milestone: v1.0 -dependencies: - - TASK-1.12 -references: - - 'https://rust.testcontainers.org/' - - 'https://docs.rs/crate/testcontainers/latest' -documentation: - - docs/ARCHITECTURE.md - - docs/verification/tui-smoke.md - - 'https://rust.testcontainers.org/quickstart/testcontainers/' - - 'https://rust.testcontainers.org/features/files/' - - 'https://rust.testcontainers.org/features/networking/' - - docs/verification/testcontainers-e2e.md - - docs/site/src/content/docs/guides/validation.mdx - - docs/site/src/content/docs/guides/release-process.mdx -modified_files: - - .github/workflows/ci.yml - - .github/workflows/release.yml - - Cargo.lock - - crates/cadder-daemon/Cargo.toml - - crates/cadder-daemon/src/caddy.rs - - crates/cadder-daemon/src/runtime.rs - - crates/cadder-daemon/tests/ipc_lifecycle.rs - - crates/cadder-daemon/tests/testcontainers_e2e.rs - - docs/ARCHITECTURE.md - - docs/site/src/content/docs/guides/release-process.mdx - - docs/site/src/content/docs/guides/validation.mdx - - docs/verification/testcontainers-e2e.md -parent_task_id: TASK-1 -priority: high -ordinal: 19000 ---- - -## Description - - -Cadder already has deterministic lifecycle coverage with fake Caddy fixtures, but the 1.0 release should also prove the daemon, PATH-facing shim, real Caddy integration, and observable state work in a disposable container-backed environment. Add Rust Testcontainers-based end-to-end tests that exercise compiled Cadder binaries with an isolated CADDER_RUNTIME_DIR and no dependency on a host-global Caddy installation. The suite should complement TASK-1.12 rather than replace the fast fake-Caddy tests. - - -## Acceptance Criteria - -- [x] #1 A dedicated end-to-end test suite uses Testcontainers for Rust to provision disposable container resources and exercises compiled Cadder binaries with an isolated CADDER_RUNTIME_DIR, without relying on a host-global Caddy installation. -- [x] #2 The suite verifies a happy path where at least two project `caddy run` shim sessions register with one daemon, real Caddy serves their configured HTTP routes through exposed container ports, and daemon/TUI-facing state reports the expected entrypoints, domains, diagnostics, and log availability. -- [x] #3 The suite verifies lifecycle behavior for shim exit or unregister, domain enable/disable, daemon shutdown, and conflict or invalid-config failure reporting in the container-backed environment. -- [x] #4 Testcontainers resources are cleaned up automatically; tests use unique runtime directories and avoid fixed host ports or names so repeated and parallel runs do not leak processes or containers. -- [x] #5 CI includes a Docker-enabled end-to-end job that gates release readiness before artifacts are accepted, while local validation clearly separates Docker-required e2e checks from unit and fake-Caddy checks when Docker is unavailable. -- [x] #6 Project documentation explains prerequisites and commands for running the Testcontainers e2e suite locally and in CI, including how this suite differs from the fake-Caddy lifecycle tests. - - -## Implementation Plan - - -## Goal -Add a dedicated Docker/Testcontainers end-to-end suite that runs compiled Cadder binaries against real Caddy in a disposable container-backed environment, while keeping the existing fake-Caddy lifecycle tests as the fast deterministic default. - -## Scope -- Add a Docker-required E2E suite that is separate from normal `cargo test --workspace` and existing fake-Caddy tests. -- Exercise compiled host `cadderd` and PATH-facing `caddy` shim binaries with a unique `CADDER_RUNTIME_DIR` per test run. -- Use Testcontainers to provision real Caddy inside a disposable container, with dynamic host port mapping and automatic cleanup. -- Avoid reliance on any host-global Caddy installation. -- Add a Docker-enabled CI gate before release artifacts are accepted, and document local/CI commands and prerequisites. -- Do not replace or weaken TASK-1.12 fake-Caddy tests. - -## Key Files And Modules -- `Cargo.toml` -- `Cargo.lock` -- `crates/cadder-daemon/Cargo.toml` -- `crates/cadder-daemon/tests/testcontainers_e2e.rs` -- `crates/cadder-daemon/tests/ipc_lifecycle.rs` for comparison only; keep fake-Caddy coverage separate -- `.github/workflows/ci.yml` -- `.github/workflows/release.yml` if tag publishing can bypass the Docker E2E gate -- `docs/verification` -- `docs/site/src/content/docs/guides/validation.mdx` -- `docs/site/src/content/docs/guides/release-process.mdx` - -## Docker And CI Assumptions Verified During Planning -- Local Docker is available through Docker Desktop 4.77.0 with Docker Engine 29.5.3 and the `desktop-linux` context. -- Testcontainers for Rust is appropriate for provisioning disposable Docker resources, copying/mounting files, executing commands in containers, and using dynamically mapped host ports. -- GitHub-hosted Ubuntu runner images include Docker tooling, making an Ubuntu Docker E2E job the most practical CI target. -- The existing CI workflow currently has a cross-platform Rust matrix and a Windows coverage job, but no Docker-enabled E2E job. - -## Implementation Steps -1. Add required test-only dependencies through Cargo CLI, likely `testcontainers`, `reqwest`, and a small process-control helper only if the final harness needs one. -2. Add a dedicated `crates/cadder-daemon/tests/testcontainers_e2e.rs` integration test target. Gate it so Docker-required tests do not run as part of the default `cargo test --workspace` path, for example with an explicit feature and/or ignored tests. -3. Build or locate compiled `target/debug` Cadder binaries for the harness: `cadderd` and the `caddy` shim. Allow explicit overrides through environment variables for CI or nonstandard target directories. -4. Start an official Caddy container through Testcontainers with container port `80` exposed to a random host port. Do not bind fixed host ports or stable container names. -5. Create unique temporary project directories and runtime directories per test. Generate at least two Caddyfiles with distinct `.localhost` hostnames and deterministic response bodies. -6. Provide a test-only real-Caddy proxy command used as `CADDER_CADDY_REAL_COMMAND`. The proxy should translate host paths to the container-mounted workspace path and delegate `adapt`, `run`, and `reload` operations to real Caddy inside the Testcontainers container, so Cadder itself still runs as host processes and remains inspectable through local IPC. -7. Spawn two long-lived host shim sessions using `caddy run --config --adapter caddyfile`, sharing one `CADDER_RUNTIME_DIR` and one daemon. Wait for daemon readiness and assert state through `CadderClient`/IPC. -8. Verify the happy path: two registrations, expected entrypoints/domains, applied config, running runtime state, no unexpected diagnostics, runtime-control log availability, and real HTTP responses through the mapped container port with matching `Host` headers. -9. Verify lifecycle behavior in the container-backed environment: one shim exit or unregister removes only that registration, domain disable/enable changes served routes and state, daemon shutdown stops the real Caddy runtime, duplicate domains report conflict diagnostics, and an invalid Caddyfile reports adapt/config failure. -10. Ensure cleanup is RAII-driven: child processes are terminated, daemon shutdown is requested, temp directories are unique, Testcontainers owns container lifecycle, and tests can run repeatedly without leaked ports/processes/containers. -11. Add a Docker E2E CI job on Ubuntu. Keep it separate from the cross-platform fake-Caddy matrix and make release readiness depend on it. If tag releases can bypass PR/main CI, update `release.yml` so package/publish waits for or reruns the Docker E2E gate before publishing artifacts. -12. Update contributor documentation to describe Docker prerequisites, local commands, CI behavior, expected skip/failure behavior when Docker is unavailable, and the distinction between fast fake-Caddy lifecycle tests and Docker/Testcontainers real-Caddy E2E tests. - -## Validation -- `cargo fmt --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- Focused fake-Caddy regression: `cargo test -p cadder-daemon --test ipc_lifecycle` -- Docker E2E command, for example `cargo test -p cadder-daemon --features docker-e2e --test testcontainers_e2e -- --ignored` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- coverage` -- Documentation checks from `docs/site`: `bun install --frozen-lockfile`, `bun run check`, `bun run build` -- `git status --short` before closeout - -## Risks And Boundaries -- Path translation between host temp directories and container-mounted paths is the highest-risk part of the harness. Keep it explicit and covered by harness assertions. -- Current generated effective config listens on `:80` and `:443`; real Caddy should stay inside the container to avoid host privilege and port-collision issues. -- Host process signal behavior differs by platform. Keep Docker E2E CI on Ubuntu unless cross-platform Docker semantics are deliberately added later. -- Do not make Docker E2E part of default `cargo test --workspace`; local validation must clearly separate Docker-required checks from unit and fake-Caddy checks. -- Do not weaken coverage requirements or exclude broad code paths to compensate for Docker E2E cost. -- Do not introduce machine-global Caddy installation requirements in CI or local docs. - - -## Implementation Notes - - -Plan approved by user and recorded before implementation. Task moved to In Progress and assigned to @agent. - -Implemented a feature-gated `docker-e2e` integration test target for `crates/cadder-daemon` using `testcontainers 0.27.3` and the official `caddy:2.10.0-alpine` image. The suite starts compiled host `cadderd` and `caddy` shim binaries against a disposable real-Caddy container, uses a unique temporary `CADDER_RUNTIME_DIR`, dynamic host port mapping, and a generated proxy command that translates host temp paths to the container workspace for `adapt`, `run`, `reload`, and `stop`. - -The Docker E2E scenario verifies two shim sessions, real HTTP routing for two `.localhost` hosts, daemon-facing registration/runtime/diagnostic state, runtime-control logs, domain disable/enable with stable negative HTTP assertions, Unix SIGINT unregister cleanup for the beta shim in CI, fallback process-exit cleanup, duplicate-domain conflict diagnostics, invalid Caddyfile `adapt-failed` diagnostics, and daemon runtime shutdown stopping real Caddy. Production fixes made while implementing the suite: persisted per-registration adapt diagnostics so they survive the follow-up apply pass, filtered persisted diagnostics out when an entrypoint is disabled, and requested graceful `caddy stop --address localhost:2019` before killing the managed runtime child. - -Added a Docker E2E job to CI and made the release packaging job depend on a Docker E2E preflight. Updated architecture and Starlight validation/release docs, plus `docs/verification/testcontainers-e2e.md`, with prerequisites, commands, CI behavior, and the distinction from fake-Caddy lifecycle tests. - -Verification run locally on Windows with Docker Desktop: `cargo add -p cadder-daemon --dev testcontainers@0.27.3`; `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo test -p cadder-daemon --test ipc_lifecycle`; `cargo build -p cadderd -p cadder-shim`; `cargo test -p cadder-daemon --features docker-e2e --test testcontainers_e2e -- --ignored --test-threads=1`; `cargo run -p xtask -- check`; `cargo run -p xtask -- coverage`; `bun install --frozen-lockfile`; `bun run check`; `bun run build`. Final coverage report: 85.6905503634476% line coverage, above the 85% threshold. - -Closeout verification on 2026-06-11: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo build -p cadderd -p cadder-shim`, `cargo test -p cadder-daemon --features docker-e2e --test testcontainers_e2e -- --ignored --test-threads=1`, `cargo run -p xtask -- check`, `cargo run -p xtask -- coverage`, `bun install --frozen-lockfile`, `bun run check`, `bun run build`, and `git diff --check` all passed. Coverage report remains above threshold at 85.6905503634476% line coverage. - - -## Final Summary - - -Implemented Docker/Testcontainers end-to-end coverage for the pre-1.0 release gate. The new `docker-e2e` test target runs compiled host `cadderd` and PATH-facing `caddy` shim binaries against real Caddy in an official disposable container with a unique `CADDER_RUNTIME_DIR`, dynamic mapped ports, and no host-global Caddy dependency. - -The suite verifies two shim registrations, real HTTP routing, daemon-facing state, runtime-control logs, domain disable/enable, shim unregister cleanup, duplicate-domain conflict diagnostics, invalid Caddyfile adapt failures, and daemon runtime shutdown. Supporting daemon changes keep per-registration adapt diagnostics stable across apply passes, hide disabled-entrypoint diagnostics from effective config, and request a graceful `caddy stop` before killing the managed runtime child. - -CI now has a Docker-enabled Ubuntu E2E job, and release packaging depends on a Docker E2E preflight before artifacts can be published. Architecture, validation, release, and verification docs describe local prerequisites, commands, CI behavior, and how this suite complements the fast fake-Caddy lifecycle tests. - -Validation passed locally on Windows with Docker Desktop: `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo build -p cadderd -p cadder-shim`; `cargo test -p cadder-daemon --features docker-e2e --test testcontainers_e2e -- --ignored --test-threads=1`; `cargo run -p xtask -- check`; `cargo run -p xtask -- coverage`; `bun install --frozen-lockfile`; `bun run check`; `bun run build`; `git diff --check`. Final line coverage is 85.6905503634476%, above the 85% project threshold. Residual risk is limited to CI Docker environment differences, which are covered by the new Ubuntu Docker E2E gate. - - -## Definition of Done - -- [x] #1 Tests or explicit verification were run for the changed behavior -- [x] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.2 - Implement-single-instance-daemon-lifecycle.md b/backlog/tasks/task-1.2 - Implement-single-instance-daemon-lifecycle.md deleted file mode 100644 index 376a8fc..0000000 --- a/backlog/tasks/task-1.2 - Implement-single-instance-daemon-lifecycle.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -id: TASK-1.2 -title: Implement single-instance daemon lifecycle -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 11:41' -updated_date: '2026-06-09 13:18' -labels: [] -dependencies: - - TASK-1.1 -modified_files: - - src/Cadder.Daemon/DaemonLifecycle.cs - - src/Cadder.Daemon/DaemonSingleton.cs - - src/Cadder.Daemon/Properties/AssemblyInfo.cs - - tests/Cadder.Daemon.Tests/DaemonLifecycleTests.cs - - src/Cadder.Tray.WinUI/Cadder.Tray.WinUI.csproj - - src/Cadder.Tray.WinUI/Program.cs - - src/Cadder.Tray.WinUI/App.xaml.cs - - src/Cadder.Tray.WinUI/MainWindow.xaml.cs - - src/Cadder.Tray.WinUI/MainPage.xaml - - src/Cadder.Tray.WinUI/MainPage.xaml.cs - - src/Cadder.Tray.WinUI/DaemonTrayPresence.cs - - docs/ARCHITECTURE.md -parent_task_id: TASK-1 -priority: high -ordinal: 3000 ---- - -## Description - - -Implement the Cadder singleton daemon lifecycle. The daemon is the long-lived process behind the tray icon and must remain alive with zero registered projects until the user explicitly quits it. - - -## Acceptance Criteria - -- [x] #1 Starting Cadder when no daemon is running creates exactly one daemon process and tray presence. -- [x] #2 Starting Cadder again while the daemon is running forwards intent to the existing daemon instead of creating a second daemon. -- [x] #3 The daemon remains alive and visible in the tray when the registration count drops to zero. -- [x] #4 The daemon exposes an explicit quit path that shuts down IPC, removes transient registrations, and stops any Cadder-owned Caddy runtime cleanly. -- [x] #5 Single-instance protection handles stale locks or abandoned mutex state without permanently blocking startup. - - -## Implementation Plan - - -## Goal -Implement the Cadder singleton daemon lifecycle so the tray-backed daemon is a long-lived per-user process, survives zero registrations, forwards repeat launches to the existing instance, and exits only through an explicit quit path. - -## Scope -- Add daemon lifecycle coordination and single-instance protection for the tray/daemon process. -- Use a per-user named mutex for deterministic single-instance ownership and abandoned/stale lock recovery. -- Use Windows App SDK app lifecycle redirection for repeat WinUI activations before creating a second window. -- Add a minimal intent-forwarding path for repeat Cadder launches without implementing the full shim registration flow. -- Add an explicit daemon quit path that shuts down IPC, clears transient registrations, asks the Cadder-owned Caddy runtime boundary to stop, releases the singleton lock, and exits the app. -- Keep zero registrations as a normal active daemon state. - -## Key Files And Modules -- `src/Cadder.Daemon/DaemonBoundaries.cs` -- `src/Cadder.Daemon/` new lifecycle/single-instance classes as needed -- `src/Cadder.Tray.WinUI/Cadder.Tray.WinUI.csproj` -- `src/Cadder.Tray.WinUI/Program.cs` -- `src/Cadder.Tray.WinUI/App.xaml.cs` -- `src/Cadder.Tray.WinUI/MainWindow.xaml.cs` -- `src/Cadder.CaddyShim/ShimEntrypoint.cs` only if a minimal launch/forwarding probe is needed -- `src/Cadder.Contracts/IpcContracts.cs` only for minimal lifecycle intent/quit DTOs if required -- `tests/Cadder.Daemon.Tests/` -- `docs/ARCHITECTURE.md` - -## Implementation Steps -1. Add daemon lifecycle types in `Cadder.Daemon` that model singleton acquisition results, lifecycle state, and shutdown sequencing. Keep the API small and testable without WinUI. -2. Implement per-user named mutex acquisition with immediate ownership when available, a clear `already running` result when owned elsewhere, and `AbandonedMutexException` handling that treats the lock as recoverable after verifying local lifecycle state can be rebuilt. -3. Add tests for first acquisition, second acquisition forwarding result, abandoned mutex recovery, lock release on quit, and no shutdown when the registration list is empty. -4. Extend daemon boundaries only where the lifecycle needs explicit hooks: start IPC, stop IPC, clear transient registrations, and stop Cadder-owned runtime. Use no-op/fake implementations in tests; do not implement real Caddy runtime behavior. -5. Add a custom WinUI `Program.cs` and set `DISABLE_XAML_GENERATED_MAIN` in `Cadder.Tray.WinUI.csproj` so single-instance/redirection logic runs before any window is created. -6. In the WinUI startup path, call `AppInstance.FindOrRegisterForKey` with a stable Cadder per-user key. If the current process is not the registered instance, redirect activation with `RedirectActivationToAsync`, signal/forward the launch intent as available, and exit before creating `MainWindow`. -7. Update `App.xaml.cs` so the primary instance creates the daemon lifecycle host, keeps the tray/window state alive with zero registrations, and routes explicit quit through the lifecycle shutdown path. -8. Add a visible but minimal explicit quit command if the current scaffold lacks one. It may be a simple UI command or lifecycle method wired from the main window; full tray popup design remains TASK-1.8. -9. Keep `Cadder.CaddyShim` changes minimal. If needed, let it detect an existing daemon/start intent boundary, but leave real registration, argument parsing, owner cleanup, and process-lifetime registration semantics to TASK-1.3 and TASK-1.4. -10. Update `docs/ARCHITECTURE.md` with the singleton lifecycle decision: the tray host is the daemon, the singleton is per-user, zero registrations do not terminate the process, repeat starts forward intent, and stale/abandoned locks are recoverable. - -## Validation -- Run `./build.ps1` from the repository root. -- Run `dotnet test Cadder.slnx -p:Platform=x64 -r win-x64`. -- Run `dotnet format Cadder.slnx --verify-no-changes`. -- Manually smoke test on Windows: launch Cadder twice and confirm only one daemon/tray presence; request explicit quit and confirm the process exits and a later launch succeeds. -- Run `git status --short` before finishing and remove only temporary artifacts created by the implementation. - -## Scope Boundaries -- Do not implement full caddy.exe shim registration semantics. -- Do not implement owner heartbeat, process watcher cleanup, or durable registration store behavior beyond transient shutdown clearing hooks. -- Do not parse, compose, or reload Caddy configs. -- Do not resolve, start, health-check, reload, or manage the real Caddy runtime beyond calling the lifecycle boundary used later by TASK-1.6. -- Do not build the full tray popup, panel overview, toggles, domain logs, or packaging/PATH installation. -- Do not add end-to-end lifecycle automation beyond focused unit tests and manual smoke instructions. - -## Risks And Notes -- WinUI apps are multi-instance by default, so single-instance logic must run before `Application.Start` creates windows. -- App lifecycle redirection is useful for WinUI activation, but a named mutex keeps singleton ownership deterministic and testable for the daemon lifecycle. -- Abandoned mutex acquisition means the previous owner exited without releasing the lock; recovery must rebuild transient in-memory state rather than treating the daemon as still running. -- Concurrent launches are the main race risk. The implementation should make lock acquisition atomic and keep forwarding behavior idempotent. -- Explicit quit must be distinct from zero registrations. A daemon with no registered projects is still an active daemon. - - -## Implementation Notes - - -Plan approved by user and recorded before implementation. - -Implemented singleton daemon lifecycle in small increments. Added a testable DaemonLifecycleHost, per-user named mutex coordinator with abandoned-mutex recovery handling, explicit shutdown sequencing, zero-registration running state, and forwarded activation recording. Integrated WinUI startup through a custom Program.cs using AppInstance redirection before window creation, added minimal Shell_NotifyIcon tray presence, hid the main window on close so close is not quit, and routed the visible Quit daemon command through lifecycle shutdown. Addressed review findings by rolling lifecycle state back after failed IPC start, recording activation kind/payload metadata for forwarded launches, and adding a retry test. - -Validation: dotnet restore Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64; dotnet test Cadder.slnx --no-restore -p:Platform=x64 -p:RuntimeIdentifier=win-x64 (13 passed); .\build.ps1; dotnet format Cadder.slnx --verify-no-changes. Runtime smoke: launching the tray app kept one process alive; launching a second instance with --smoke-forward exited with code 0 while one daemon process remained; closing the main window did not exit the daemon. UI Automation could not reliably click the Quit daemon button in this session, so explicit quit is verified by unit shutdown sequencing and compiled UI wiring rather than automated UI click. - - -## Final Summary - - -Implemented the Cadder single-instance daemon lifecycle. - -What changed: -- Added daemon lifecycle and singleton mutex primitives in Cadder.Daemon, including abandoned mutex recovery and explicit shutdown sequencing. -- Added focused daemon tests for first/second acquisition, abandoned recovery, shutdown cleanup, zero-registration persistence, forwarded launch recording, and failed-start retry. -- Added a custom WinUI Program.cs that performs AppInstance redirection and singleton acquisition before creating the app/window. -- Wired App.xaml.cs to own the lifecycle host, record redirected activations, expose explicit quit, and keep the daemon alive when the window is closed. -- Added minimal tray presence via Shell_NotifyIcon and documented the singleton lifecycle architecture. - -Validation: -- dotnet restore Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64 -- dotnet test Cadder.slnx --no-restore -p:Platform=x64 -p:RuntimeIdentifier=win-x64 (13 passed) -- .\build.ps1 -- dotnet format Cadder.slnx --verify-no-changes -- Runtime smoke confirmed a second launch exits with code 0 while one daemon process remains, and closing the window does not quit the daemon. - -Risk/follow-up: -- Full tray popup/menu behavior remains deferred to TASK-1.8; the current tray presence is intentionally minimal. - diff --git a/backlog/tasks/task-1.20 - Polish-cadder-tui-navigation-refresh-settings-and-visual-clarity-before-1.0.md b/backlog/tasks/task-1.20 - Polish-cadder-tui-navigation-refresh-settings-and-visual-clarity-before-1.0.md deleted file mode 100644 index 7e2d3f9..0000000 --- a/backlog/tasks/task-1.20 - Polish-cadder-tui-navigation-refresh-settings-and-visual-clarity-before-1.0.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -id: TASK-1.20 -title: 'Polish cadder-tui navigation, refresh, settings, and visual clarity before 1.0' -status: Done -assignee: - - '@agent' -created_date: '2026-06-11 07:43' -updated_date: '2026-06-11 08:22' -labels: - - tui - - usability - - pre-1.0 -milestone: v1.0 -dependencies: - - TASK-1.12 -references: - - crates/cadder-tui/src/main.rs - - crates/cadder-tui/src/model.rs -documentation: - - docs/ARCHITECTURE.md - - docs/verification/tui-smoke.md - - 'https://docs.rs/ratatui/latest/ratatui/widgets/struct.Table.html' - - 'https://ratatui.rs/examples/style/colors/' - - 'https://ratatui.rs/highlights/v026/' - - >- - https://raw.githubusercontent.com/jesseduffield/lazygit/master/docs/Config.md - - 'https://textual.textualize.io/guide/CSS/' -modified_files: - - crates/cadder-tui/src/main.rs - - crates/cadder-tui/src/model.rs - - docs/ARCHITECTURE.md - - docs/site/src/content/docs/guides/tui-diagnostics.mdx - - docs/verification/tui-smoke.md -parent_task_id: TASK-1 -priority: high -ordinal: 20000 ---- - -## Description - - -Improve the current Ratatui terminal UI so it is suitable for normal pre-1.0 usage rather than only smoke verification. The TUI should keep daemon state fresh without requiring manual refresh for routine changes, support both Tab-based and left/right view navigation, make the selected Domains row and activation state obvious, move log severity selection into a Settings view, and use a more intentional color system inspired by established terminal UI patterns. Keep the work scoped to `cadder-tui` usability unless a small protocol/model change is required to support the UI cleanly. - - -## Acceptance Criteria - -- [x] #1 The TUI automatically refreshes daemon state snapshots on a reasonable interval while preserving explicit `r` as a manual refresh command and avoiding overlapping state requests. -- [x] #2 Top-level view navigation works with both `Tab`/`Shift+Tab` and left/right arrow keys, with tests covering wraparound and selection preservation. -- [x] #3 The Domains view makes the selected row visibly distinct using Ratatui table selection styling or an equivalent high-contrast marker, and enabled versus disabled domains are distinguishable by both color and text markers such as `[x]` and `[ ]`. -- [x] #4 The log severity filter is controlled from a Settings view where the user can choose the level with up/down navigation and apply it without relying on the `i`/`w`/`e`/`0` footer shortcut model as the primary interaction. -- [x] #5 The TUI uses a cohesive, higher-contrast color palette across tabs, status summaries, entrypoints, domains, logs, diagnostics, selected rows, disabled states, warnings, and errors while remaining legible on common Windows Terminal, macOS, and Linux terminal themes. -- [x] #6 The footer/help text reflects the new navigation and Settings interactions without advertising obsolete severity shortcuts as the main control path. -- [x] #7 Automated tests cover the new refresh timing, keyboard navigation, Settings severity selection, Domains selection state, and enabled/disabled styling decisions where they can be asserted without a live terminal. -- [x] #8 The manual TUI smoke checklist and user-facing documentation are updated to describe auto-refresh, left/right navigation, Settings severity selection, and the visual state conventions. - - -## Implementation Plan - - -## Goal -Polish `cadder-tui` so it is usable for normal pre-1.0 operation: daemon state refreshes automatically, top-level navigation supports both tab and left/right flows, Domains selection and activation state are visually obvious, log severity is controlled from Settings, and the UI uses one cohesive high-contrast terminal color system. - -## Scope -- Keep the work focused on `crates/cadder-tui` usability and documentation. -- Avoid daemon/protocol changes unless a small model contract change is clearly required to support the UI cleanly. -- Preserve existing manual commands where useful, especially `r` for explicit state refresh and log controls that are not being replaced. -- Do not expand into CI, packaging, release, or unrelated daemon behavior. - -## Key Files And Modules -- `crates/cadder-tui/src/model.rs` -- `crates/cadder-tui/src/main.rs` -- `crates/cadder-tui/Cargo.toml` only if a justified test/helper dependency is needed through Cargo CLI -- `docs/verification/tui-smoke.md` -- `docs/site/src/content/docs/guides/tui-diagnostics.mdx` -- `docs/ARCHITECTURE.md` only if durable architecture notes need a small update - -## Implementation Steps -1. Extend the TUI model with a `Settings` top-level view and a small settings model for log severity choices (`All`, `Info`, `Warn`, `Error`). Keep the existing log severity state as the source of truth for log queries. -2. Rework top-level navigation so `Tab`/`Shift+Tab` and `Left`/`Right` share the same wraparound behavior. Preserve per-view selection where it matters instead of resetting row selection on every view change, and clamp selection after snapshot/filter changes. -3. Add state auto-refresh timing to the app loop with a reasonable interval, while keeping manual `r` refresh. Reuse `state_request_in_flight` to prevent overlapping state requests and make the timing logic small enough to unit-test. -4. Implement the Settings view as the primary severity UI: use up/down to choose the severity row and `Enter` or `Space` to apply it through the existing `set_log_severity` path so log cursor and entries reset consistently. -5. Improve Domains rendering by using Ratatui table selection styling or an equivalent high-contrast row marker, plus explicit activation text markers such as `[x] Active` and `[ ] Disabled`. Style enabled and disabled domains differently, but do not rely on color alone. -6. Introduce a cohesive color/style helper for tabs, blocks, summaries, entrypoints, domains, log severities, diagnostics, selected rows, disabled states, warnings, and errors. Prefer named terminal colors plus bold/reverse modifiers over subtle RGB-only styling so common Windows Terminal, macOS, and Linux themes stay legible. -7. Update the footer/help text to show `Tab`/`Shift+Tab`/`Left`/`Right`, manual refresh, Settings severity selection, domain log opening, log pause/export, search, shutdown, and quit. Do not advertise `i`/`w`/`e`/`0` as the primary severity interaction. -8. Add focused automated tests for refresh timing/no-overlap behavior, keyboard navigation wraparound and selection preservation, Settings severity selection/application, Domains selected-row behavior, enabled/disabled markers, style decisions that can be asserted through `TestBackend`, and updated footer/help text. -9. Update the manual TUI smoke checklist and user-facing TUI diagnostics documentation to describe auto-refresh, left/right navigation, Settings-based severity selection, and visual state conventions. - -## Validation -- Focused while iterating: `cargo test -p cadder-tui` -- Formatting: `cargo fmt --check` -- Lints: `cargo clippy --workspace --all-targets -- -D warnings` -- Full tests: `cargo test --workspace` -- Repository validation: `cargo run -p xtask -- check` -- Coverage gate: `cargo run -p xtask -- coverage` -- Manual smoke checklist: `docs/verification/tui-smoke.md` on an available terminal backend after implementation - -## Assumptions -- TASK-1.12 is complete, so its TUI verification baseline is available. -- The current daemon/protocol state query and log query contracts are sufficient for this task. -- `Settings` should be a top-level tab rather than a popup because the acceptance criteria call for a Settings view. -- Legacy severity shortcuts may remain as secondary compatibility behavior if they do not confuse the footer/help model, but they are not the primary interaction. - -## Risks And Boundaries -- Stateful table selection can require additional app/model state; keep it narrow and avoid a broad TUI architecture rewrite unless tests prove the current shape is too brittle. -- Auto-refresh must not starve keyboard input or create IPC request storms; the in-flight guard and a fixed interval are the boundary. -- Style tests should assert durable choices such as markers, modifiers, and semantic style helper output rather than fragile full-screen snapshots. -- Do not silently broaden the task into daemon behavior, protocol redesign, CI/release work, or real-Caddy-dependent automated tests. - - -## Implementation Notes - - -Plan approved by user and recorded before implementation. - -Implemented the planned cadder-tui polish: added auto state refresh with no overlapping requests and pending refresh handoff for toggle/manual races; added Settings as a top-level severity control; preserved per-view selection across Tab/Shift+Tab/Left/Right navigation; made Domains rows stateful with high-contrast selected-row styling and explicit `[x]`/`[ ]` activation markers; centralized named terminal color styles for tabs, summaries, tables, logs, diagnostics, disabled rows, warnings, and errors. Legacy log severity shortcuts remain as secondary compatibility behavior, but the footer and docs present Settings as the primary interaction. - -Addressed independent review findings before closeout: queued one pending state refresh when refresh is requested during an in-flight state poll, prevented Settings severity changes from overlapping log IPC requests by waiting for the current response and then dispatching a pending refresh, and clarified the manual smoke checklist so Settings severity verification starts from an open Logs stream and returns to Logs for confirmation. - -Validation after final changes: `cargo fmt --check`; `cargo test -p cadder-tui` (41 passed); `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo run -p xtask -- check`; `cargo run -p xtask -- coverage` (total line coverage 86.93%, above the 85% gate). Manual live terminal smoke was not run in this session; `docs/verification/tui-smoke.md` was updated for the new flow. - -Additional documentation validation after closeout update: `bun run build` in `docs/site` completed successfully and did not leave extra tracked working-tree changes. - - -## Final Summary - - -## Summary -- Added automatic daemon state refresh in `cadder-tui` with in-flight guards and pending refresh handoff so manual refreshes and successful toggles do not overlap or leave stale state waiting for the next interval. -- Added a top-level Settings view for log severity selection, preserving the existing log query state while making `Up`/`Down` plus `Enter`/`Space` the primary filter flow. -- Reworked top-level navigation so `Tab`/`Shift+Tab` and `Left`/`Right` share wraparound behavior while preserving per-view row selection. -- Improved Domains, status, logs, diagnostics, and footer rendering with named high-contrast terminal styles, stateful selected rows, and explicit `[x]`/`[ ]` activation markers. -- Updated architecture notes, the TUI diagnostics guide, and the manual smoke checklist for auto-refresh, Settings severity control, left/right navigation, and visual state conventions. - -## Tests -- `cargo fmt --check` -- `cargo test -p cadder-tui` (41 passed) -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- coverage` (total line coverage 86.93%) - -## Notes -- Manual live terminal smoke was not run in this session; the checklist was updated so it can be run on Windows Terminal, macOS, and Linux terminal backends. - -Additional validation: `bun run build` in `docs/site` completed successfully for the updated Starlight documentation page. - - -## Definition of Done - -- [x] #1 Tests or explicit verification were run for the changed behavior -- [x] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.21 - Add-Windows-IIS-domain-handoff-controls-to-cadder-tui.md b/backlog/tasks/task-1.21 - Add-Windows-IIS-domain-handoff-controls-to-cadder-tui.md deleted file mode 100644 index 0224bd0..0000000 --- a/backlog/tasks/task-1.21 - Add-Windows-IIS-domain-handoff-controls-to-cadder-tui.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -id: TASK-1.21 -title: Add Windows IIS domain handoff controls to cadder-tui -status: Done -assignee: - - Codex -created_date: '2026-06-11 07:47' -updated_date: '2026-06-11 14:43' -labels: - - windows - - iis - - tui - - pre-1.0 -milestone: v1.0 -dependencies: - - TASK-1.20 -references: - - crates/cadder-protocol/src/lib.rs - - crates/cadder-daemon/src/state.rs - - crates/cadder-daemon/src/ipc.rs - - crates/cadder-daemon/src/caddy.rs - - crates/cadder-tui/src/model.rs - - crates/cadder-tui/src/main.rs -documentation: - - docs/ARCHITECTURE.md - - docs/verification/tui-smoke.md - - docs/site/src/content/docs/guides/windows.mdx - - docs/site/src/content/docs/cookbooks/windows/iis.mdx - - >- - https://learn.microsoft.com/en-us/iis/configuration/system.applicationhost/sites/site/bindings/binding - - >- - https://learn.microsoft.com/en-us/iis/configuration/system.applicationhost/sites/site/bindings/ - - >- - https://learn.microsoft.com/en-us/powershell/module/webadministration/get-webbinding?view=windowsserver2025-ps - - >- - https://learn.microsoft.com/en-us/powershell/module/webadministration/new-webbinding?view=windowsserver2025-ps - - >- - https://learn.microsoft.com/en-us/powershell/module/webadministration/remove-webbinding?view=windowsserver2025-ps - - >- - https://learn.microsoft.com/en-us/iis/get-started/getting-started-with-iis/getting-started-with-appcmdexe - - >- - https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/netsh-http -modified_files: - - crates/cadder-protocol/src/lib.rs - - crates/cadder-daemon/src/iis.rs - - crates/cadder-daemon/src/ipc.rs - - crates/cadder-daemon/src/lib.rs - - crates/cadder-daemon/src/state.rs - - crates/cadder-daemon/src/caddy.rs - - crates/cadder-tui/src/model.rs - - crates/cadder-tui/src/main.rs - - docs/ARCHITECTURE.md - - docs/verification/tui-smoke.md - - docs/site/astro.config.mjs - - docs/site/src/content/docs/guides/windows.mdx - - docs/site/src/content/docs/cookbooks/windows/iis.mdx -parent_task_id: TASK-1 -priority: high -ordinal: 21000 ---- - -## Description - - -Add a Windows-only IIS handoff surface that lets a user move selected IIS host bindings under Cadder control from the TUI and restore them back to IIS. The feature must be absent on non-Windows platforms rather than shown as a disabled placeholder. The implementation should preserve original IIS binding metadata, expose privilege and safety errors clearly, and avoid requiring real IIS in default automated tests by using a platform abstraction and fake provider coverage. - - -## Acceptance Criteria - -- [x] #1 On Windows, `cadder-tui` exposes an IIS handoff view that lists discoverable IIS site bindings with site name, protocol, IP address, port, host header/domain, and current handoff state. -- [x] #2 On non-Windows platforms, the IIS handoff view and related commands are not present in the TUI, and the workspace continues to build and test without Windows-only dependencies leaking into other targets. -- [x] #3 The daemon/protocol exposes platform-gated IIS handoff state and commands through a small abstraction that can report IIS unavailable, insufficient privileges, unsupported binding shape, conflicts, and successful handoff/restore outcomes without panics. -- [x] #4 Turning a supported IIS domain `on` for Cadder preserves enough original IIS binding metadata to restore it later, releases or disables the IIS-owned binding safely, and creates or activates the Cadder-owned route for the same domain only when a safe handoff plan exists. -- [x] #5 Turning a handed-off domain `off` removes or deactivates the Cadder-owned route and restores the original IIS binding exactly enough for IIS to own the domain again, with rollback or clear failure reporting when any step fails. -- [x] #6 The UI prevents destructive ambiguity: duplicate host bindings, HTTPS certificate bindings, unsupported ports, missing upstream/route information, or privilege limitations are shown inline and cannot be silently overwritten. -- [x] #7 Automated tests cover IIS provider parsing/state mapping, handoff and restore success/failure flows, non-Windows absence behavior, and TUI model/navigation behavior using fake IIS data rather than requiring a machine-local IIS installation. -- [x] #8 Windows documentation and the TUI smoke checklist explain the IIS handoff workflow, required privileges, supported binding types, rollback expectations, and the fact that the feature is Windows-only. - - -## Implementation Plan - - -## Goal -Add a Windows-only IIS handoff surface that lets users move supported local IIS host bindings behind Cadder from `cadder-tui` and restore them back to IIS. Keep the feature absent on non-Windows targets, preserve enough IIS metadata for restore/rollback, and cover behavior with fake-provider tests so default automation does not require a local IIS installation. - -## Scope -- Add a dedicated IIS protocol, daemon, and TUI path instead of reusing generic domain activation toggles. -- Keep Windows-specific code behind narrow `cfg(windows)` boundaries. -- Use fake IIS provider coverage for default automated tests. -- Preserve HTTPS certificate metadata when HTTPS bindings are discovered. -- Do not broaden into OS service installation, CI/release pipeline changes, or mixed-elevation prompting. - -## Key Files And Modules -- `crates/cadder-protocol/src/lib.rs` -- `crates/cadder-daemon/src/iis.rs` -- `crates/cadder-daemon/src/state.rs` -- `crates/cadder-daemon/src/ipc.rs` -- `crates/cadder-daemon/src/caddy.rs` -- `crates/cadder-daemon/src/lib.rs` -- `crates/cadder-tui/src/model.rs` -- `crates/cadder-tui/src/main.rs` -- `docs/ARCHITECTURE.md` -- `docs/verification/tui-smoke.md` -- `docs/site/src/content/docs/guides/windows.mdx` -- `docs/site/src/content/docs/cookbooks/windows/iis.mdx` - -## Implementation Steps -1. Add protocol DTOs and message types for IIS discovery and handoff actions, including typed unavailable, privilege, unsupported shape, conflict, rollback, restore, and busy outcomes. -2. Add an `IisProvider` abstraction with system and fake implementations. Keep PowerShell/WebAdministration usage inside the provider module. -3. Discover IIS bindings explicitly through `query-iis-bindings` rather than during normal `query-state` refresh. -4. Persist restore metadata before mutating IIS. The record must include the original binding identity, protocol, IP address, port, host header, selected route host, loopback backend binding, and HTTPS certificate metadata when available. -5. Enable handoff as a transaction: preflight shape and conflicts, persist metadata, create a loopback IIS backend binding, remove the public IIS binding, add the Caddy reverse-proxy route, apply Caddy, and compensate on failure. -6. Restore handoff as the inverse transaction: remove the Caddy IIS route, restore the original IIS binding and certificate metadata, remove the loopback backend binding, and clear metadata only after cleanup succeeds. -7. Serialize IIS handoff mutations in the daemon so concurrent clients cannot interleave IIS/store/Caddy changes. -8. Add the Windows-only TUI view with explicit IIS route-host input for wildcard or empty-host bindings. Keep route-host input separate from the global search/filter state. -9. Update Caddy composition so generated config has both an HTTP front door on `:80` and an HTTPS front door on `:443` with TLS policy for active Cadder and IIS hosts. -10. Update architecture notes, smoke verification, and a dedicated Windows IIS cookbook page. - -## Validation -- `cargo fmt --check` -- `cargo test -p cadder-protocol` -- `cargo test -p cadder-daemon` -- `cargo test -p cadder-tui` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- coverage` - -## Assumptions -- Default automated tests must not depend on machine-local IIS or elevated Windows privileges. -- Real IIS smoke uses disposable local bindings only, and private hostnames or application paths must stay out of tracked task notes. -- Mixed-elevation prompting is follow-up scope. - - -## Implementation Notes - - -Implemented the Windows IIS handoff protocol, daemon provider/store, IPC handling, Caddy route composition, and Windows-only TUI view. The daemon discovers IIS bindings outside `query-state`, persists restore metadata before mutation, serializes IIS handoff mutations, keeps rollback metadata when recovery fails, and restores HTTPS certificate metadata when the provider reports it. - -Review follow-up fixed several issues before commit: generated Caddy config now exposes separate `:80` and `:443` front doors instead of HTTPS-only output; IIS route-host input no longer reuses the global TUI search/filter field; restore no longer reports success if loopback backend cleanup fails; concurrent IIS mutations return a busy issue; and private real-smoke hostnames, application paths, site names, and artifact names were removed from tracked task text. - -Documentation was moved into a dedicated Windows IIS cookbook page and the general Windows guide now links to it. The architecture notes and TUI smoke checklist describe the proxy model, elevated same-context requirement, route-host input, rollback expectations, and restore constraints. - -Validation completed before commit: `cargo fmt --check`, `cargo test -p cadder-protocol`, focused IIS daemon/TUI tests, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test -p cadder-daemon`, `cargo test -p cadder-tui`, `cargo test --workspace`, `cargo run -p xtask -- check`, `bun run check`, `bun run build`, and `cargo run -p xtask -- coverage`. Coverage remained above the project threshold at 85.49698400209807% lines covered (6520/7626). - -CI regression fix: GitHub Actions on Linux/macOS failed after the IIS handoff work because cadder-tui imported IIS-only protocol types unconditionally while using them only behind cfg(windows). Moved the IIS request/response imports in crates/cadder-tui/src/model.rs and crates/cadder-tui/src/main.rs behind cfg(windows). Validation passed locally: cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings; cargo clippy -p cadder-tui --target x86_64-unknown-linux-gnu --all-targets -- -D warnings; cargo test --workspace; cargo run -p xtask -- check. - - -## Final Summary - - -Implemented and reviewed the Windows IIS handoff model for `cadder-tui`. - -Cadder can discover supported local IIS bindings, move a selected binding behind Cadder's Caddy front door, preserve restore metadata, proxy to a deterministic loopback IIS backend, hydrate persisted IIS routes on daemon startup, and restore the original binding later. HTTPS restore metadata now includes certificate details when the Windows provider can discover them. IIS handoff operations are serialized in the daemon and report typed safety, busy, rollback, and restore failures instead of silently interleaving or losing recovery state. - -The TUI exposes the IIS Handoff view only on Windows. Wildcard or empty-host IIS rows use a dedicated route-host input instead of the global search filter. Documentation now lives under Cookbooks > Windows > IIS, with the general Windows guide linking to it. - -Final validation passed: `cargo fmt --check`, `cargo test -p cadder-protocol`, focused IIS daemon/TUI tests, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test -p cadder-daemon`, `cargo test -p cadder-tui`, `cargo test --workspace`, `cargo run -p xtask -- check`, `bun run check`, `bun run build`, and `cargo run -p xtask -- coverage`. Coverage remained above threshold at 85.49698400209807% lines covered (6520/7626). - - -## Definition of Done - -- [x] #1 Tests or explicit verification were run for the changed behavior -- [x] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.22 - Polish-Cadder-documentation-for-user-first-portable-usage.md b/backlog/tasks/task-1.22 - Polish-Cadder-documentation-for-user-first-portable-usage.md deleted file mode 100644 index 8c88f53..0000000 --- a/backlog/tasks/task-1.22 - Polish-Cadder-documentation-for-user-first-portable-usage.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -id: TASK-1.22 -title: Polish Cadder documentation for user-first portable usage -status: Done -assignee: - - '@agent' -created_date: '2026-06-11 10:04' -updated_date: '2026-06-11 11:23' -labels: [] -dependencies: [] -references: - - TASK-1.16 - - assets/banner.webp - - assets/logo.png - - README.md - - docs/site/astro.config.mjs - - docs/site/src/content/docs/index.mdx -documentation: - - docs/site/README.md - - docs/ARCHITECTURE.md - - 'https://diataxis.fr/' - - 'https://starlight.astro.build/guides/customization/' - - 'https://starlight.astro.build/guides/css-and-tailwind/' - - 'https://developers.google.com/style/code-syntax' - - 'https://clig.dev/' - - 'https://caddyserver.com/docs/' -modified_files: - - README.md - - assets/banner.webp - - assets/logo.png - - docs/site/astro.config.mjs - - docs/site/public/cadder-downloads.js - - docs/site/public/favicon.ico - - docs/site/src/assets/banner.webp - - docs/site/src/assets/logo.png - - docs/site/src/styles/cadder.css - - docs/site/src/content/docs/index.mdx - - docs/site/src/content/docs/guides/cadder-toml.mdx - - docs/site/src/content/docs/guides/getting-started.mdx - - docs/site/src/content/docs/guides/how-to-use.mdx - - docs/site/src/content/docs/guides/path-and-shim.mdx - - docs/site/src/content/docs/guides/tui-diagnostics.mdx - - docs/site/src/content/docs/guides/windows.mdx - - docs/site/src/content/docs/guides/macos.mdx - - docs/site/src/content/docs/guides/linux.mdx - - docs/site/src/content/docs/reference/real-caddy-resolution.mdx - - docs/site/src/content/docs/reference/runtime-configuration.mdx - - docs/site/src/content/docs/guides/portable-binaries.mdx - - docs/site/src/content/docs/guides/release-process.mdx - - docs/site/src/content/docs/guides/validation.mdx - - docs/site/src/content/docs/reference/architecture.mdx -parent_task_id: TASK-1 -priority: medium -ordinal: 22000 ---- - -## Description - - -Rework the Cadder documentation site and README so public documentation leads with using released portable binaries, Cadder branding, OS-specific setup cookbooks, and practical user workflows. Developer build, validation, and release material should remain available but be clearly separated from the main user path. The implementation must preserve the actual real Caddy resolver behavior while promoting `cadder.toml` next to the Cadder binary as the recommended user-facing configuration path. - - -## Acceptance Criteria - -- [x] #1 Docs header shows the Cadder logo and the Starlight theme uses colors consistent with `assets/banner.webp`. -- [x] #2 The first documentation page explains what Cadder is, what binaries it includes, and points users to the release artifact workflow before developer build workflows. -- [x] #3 Public user documentation and README examples do not use `.local` as an example path. -- [x] #4 User documentation is separated from developer documentation in navigation and page content. -- [x] #5 Windows, macOS, and Linux cookbooks cover portable binary use, optional user-managed PATH shim setup, recommended `cadder.toml`, TUI diagnostics, and common failure recovery. -- [x] #6 The recommended configuration path promotes `cadder.toml` next to the binary first, per-project `cadder.toml` second, and CLI/env/PATH methods as advanced alternatives while the resolver reference preserves the actual precedence order. -- [x] #7 Both environment aliases, `CADDER_CADDY_REAL_COMMAND` and `CADDER_CADDY__REAL_COMMAND`, are documented in the reference or advanced configuration material. -- [x] #8 Documentation validation passes with Bun check/build, and the docs are inspected in a browser on desktop and mobile viewports. - - -## Implementation Plan - - -## Implementation plan - -1. Keep the existing Rust behavior and configuration format unchanged. Treat `cadder.toml` as the only documented config file name. -2. Add Cadder branding to the Starlight site by placing docs-site-owned copies of the existing logo/banner assets under `docs/site/src/assets` or `docs/site/public`, configuring `logo` in `astro.config.mjs`, and adding a custom CSS file for the dark teal/cyan theme. -3. Reorganize the Starlight sidebar into `Start here`, `User guide`, `Cookbooks`, `Reference`, and `Developer`, keeping existing public slugs where practical and moving validation/release material into the Developer group. -4. Rewrite user-facing docs and README so the primary path is: download a GitHub Release archive, unpack it, configure `cadder.toml` next to the binary, run the shim, then inspect with `cadder-tui`. -5. Replace `.local` examples in public docs with neutral user paths such as `$HOME/Tools/Cadder`, `$HOME/bin`, `C:\Tools\Cadder`, or `dist/cadder` for contributor-only build output. -6. Convert the existing Windows, macOS, and Linux guides into cookbook-style setup pages that cover direct portable use, optional user-managed PATH shim setup, recommended `cadder.toml`, TUI diagnostics, and recovery for common real-Caddy resolution failures. -7. Update real Caddy configuration/reference docs to distinguish recommended documentation order from actual resolver precedence, and document both `CADDER_CADDY_REAL_COMMAND` and `CADDER_CADDY__REAL_COMMAND` as advanced aliases. -8. Validate with `bun install --frozen-lockfile`, `bun run check`, `bun run build`, `rg -n "\.local" README.md docs/site/src/content/docs`, browser inspection on desktop and mobile, and any relevant project checks if changes go beyond docs/README. - -## Review feedback fix plan - -1. Preserve the user-adjusted color palette in `docs/site/src/styles/cadder.css`; only add structural styles if needed for download buttons or layout. -2. Rename the sidebar group `Start here` to `Quick Start` and remove the Developer sidebar section. -3. Generate favicon files from `docs/site/src/assets/logo.png` using the locally available image tooling and configure Starlight/Astro to use the logo-derived favicon. -4. Rewrite Overview and Getting Started to remove over-explained archive/folder/Rust guidance, explain the port/reverse-proxy motivation accurately, add a concise feature list, add OS download buttons linked to latest release assets, and remove `Good first reads`. -5. Rename the user guide page from `Portable binaries` to a less pretentious `How to use` page while preserving the existing slug where practical. -6. Add a dedicated `cadder.toml` user page and replace repeated config snippets in user-facing pages with links to that page. -7. Remove implementation-library and internal-repo details from user-facing docs, including Ratatui/Crossterm, manual smoke test references, `directories::ProjectDirs`, and architecture migration wording. -8. Simplify README to project overview, license/contributing, quick use, and documentation links instead of duplicating most docs content. -9. Validate with docs check/build, grep for unwanted public wording, browser inspection where useful, and update task notes/final summary. - - -## Implementation Notes - - -Implemented a user-first documentation polish for Cadder. The Starlight site now uses docs-site-owned copies of the Cadder logo and banner, a custom dark teal/cyan theme, and navigation split into Start here, User guide, Cookbooks, Reference, and Developer. README and user docs now lead with GitHub Release archives, portable binary use, `cadder.toml` next to the binaries, running the shim, and inspecting with `cadder-tui`. Build-from-source guidance was moved to a separate Developer page. Windows, macOS, and Linux cookbook pages now cover direct portable use, optional PATH setup, recommended `cadder.toml`, TUI diagnostics, and common recovery. Public docs no longer use `.local` as an example path. Real Caddy reference preserves actual resolver precedence and documents both `CADDER_CADDY_REAL_COMMAND` and `CADDER_CADDY__REAL_COMMAND`. - -Validation: `bun install --frozen-lockfile`, `bun run check`, and `bun run build` passed in `docs/site`. `rg -n "\.local" README.md docs/site/src/content/docs` returned no matches. Browser inspection covered the docs homepage and key pages on desktop/mobile-style viewports; logo, banner, sidebar groups, cookbook content, and reference env aliases were present with no console errors, and a Lighthouse mobile navigation audit reported Accessibility 100, Best Practices 100, SEO 92, Agentic Browsing 94. `cargo run -p xtask -- check` passed. `cargo run -p xtask -- coverage` passed and wrote `target/llvm-cov/coverage-summary.json`; total line coverage is 86.93%, above the 85% threshold. - -Addressed review feedback on the documentation polish. Preserved the existing color variables and only added layout CSS for download cards. Renamed `Start here` to `Quick Start`, removed the Developer/validation/release/architecture pages from the public Starlight navigation/content, replaced `Portable binaries` with `How to use`, added a dedicated `cadder.toml` page, and removed repeated TOML snippets from user-facing pages. The Overview now explains HTTP/HTTPS port contention and local reverse-proxy coordination, includes a feature list, and uses OS-specific download buttons. The buttons fall back to GitHub Releases and use a small public script to rewrite links to matching latest release assets when a release exists. Generated `favicon.ico` from the Cadder logo with ImageMagick and configured Starlight to use it. README was reduced to project overview, quick use, documentation links, commands, contribution/security/architecture links, and license. - -Review cleanup removed wording called out in feedback, including `Start here`, `Portable binaries`, `.local`, `config.toml`, `Ratatui/Crossterm`, `directories::ProjectDirs`, manual smoke-test references, forced unpack/folder instructions, and Windows `.exe` dashboard examples from public README/docs source. Current GitHub Releases has no published release yet, so direct asset URLs cannot be resolved at authoring time; the docs use `releases?per_page=1` at runtime to avoid 404 console errors and keep OS buttons ready for the first published release. - -Validation after fixes: `bun run check` passed, `bun run build` passed with 12 pages, grep for review-banned public wording returned no matches, `git diff --check` passed aside from Git LF/CRLF warnings, ImageMagick identified multi-size `docs/site/public/favicon.ico`, and browser checks on desktop/mobile verified the logo, favicon, download cards, mobile layout, no visible banned wording, and 0 console warnings/errors after the final reload. - -Follow-up layout fix: changed the download button layout to explicit CSS grid areas: `windows macos-arm linux` and `windows macos-intel linux`, with Windows and Linux spanning both rows and a one-column mobile fallback. Verified with `bun run check`, `bun run build`, and Playwright computed styles on desktop (`gridTemplateAreas` matched the requested layout). - -Closeout pre-commit check: root `assets/banner.webp` and `assets/logo.png` match the docs-site asset copies byte-for-byte by SHA-256, so the commit includes both root assets and Starlight-local copies to keep README and docs branding consistent. - - -## Final Summary - - -Reworked the documentation feedback pass around a stricter user-first public site. The Starlight sidebar now starts with `Quick Start`, has no Developer section, and no longer publishes the previous validation, release-process, or architecture pages as public docs content. `Portable binaries` was replaced with `How to use`, and a dedicated `cadder.toml` page now owns the real-Caddy configuration examples. - -The Overview now describes the real problem Cadder solves: local Caddy projects competing for HTTP/HTTPS ports while serving reverse proxies. It includes a feature list, the existing binary list, and OS-specific download buttons with platform icons. Because release assets are versioned and no GitHub Release exists yet, the buttons fall back to `releases/latest` and a small public script rewrites them to matching latest release asset URLs when available. The docs favicon is generated from the Cadder logo and Starlight is configured to use it. - -README was trimmed to overview, quick use, documentation links, commands, contributing/security/architecture links, and license. User-facing pages no longer repeat `cadder.toml` snippets, no longer prescribe unpack folders, no longer include `.local`, `config.toml`, Ratatui/Crossterm, `directories::ProjectDirs`, manual smoke tests, or Windows `.exe` dashboard examples. - -Checks run: `bun run check`, `bun run build`, grep for review-banned public wording, `git diff --check`, ImageMagick favicon identification, and browser verification on desktop/mobile with 0 console warnings/errors after the final reload. - -Follow-up: adjusted the OS download buttons to use explicit grid areas so desktop renders as `Windows | macOS Apple Silicon | Linux` on the first row and `Windows | macOS Intel | Linux` on the second row, with Windows and Linux spanning both rows. Mobile keeps a single-column layout. - - -## Definition of Done - -- [x] #1 Tests or explicit verification were run for the changed behavior -- [x] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.23 - Let-cadder-tui-recover-and-start-cadderd-when-the-daemon-is-unavailable.md b/backlog/tasks/task-1.23 - Let-cadder-tui-recover-and-start-cadderd-when-the-daemon-is-unavailable.md deleted file mode 100644 index 1566287..0000000 --- a/backlog/tasks/task-1.23 - Let-cadder-tui-recover-and-start-cadderd-when-the-daemon-is-unavailable.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -id: TASK-1.23 -title: Let cadder-tui recover and start cadderd when the daemon is unavailable -status: Done -assignee: - - '@agent' -created_date: '2026-06-11 13:59' -updated_date: '2026-06-11 16:13' -labels: - - tui - - daemon - - pre-1.0 -milestone: v1.0 -dependencies: - - TASK-1.20 -references: - - crates/cadder-tui/src/main.rs - - crates/cadder-tui/src/model.rs - - crates/cadderd/src/main.rs -documentation: - - docs/ARCHITECTURE.md - - docs/verification/tui-smoke.md -modified_files: - - crates/cadder-tui/src/main.rs - - crates/cadder-tui/src/model.rs - - crates/cadder-daemon/src/ipc.rs - - docs/ARCHITECTURE.md - - docs/verification/tui-smoke.md -parent_task_id: TASK-1 -priority: high -ordinal: 23000 ---- - -## Description - - -Make `cadder-tui` usable when `cadderd` is not running or cannot be reached. The TUI should start normally, show a clear daemon-unavailable state instead of exiting or behaving like the app is broken, and provide an in-TUI action to start the per-user daemon and reconnect. This is pre-1.0 polish for first-run and recovery workflows. - - -## Acceptance Criteria - -- [x] #1 Starting `cadder-tui` without a running daemon opens the TUI successfully and shows an explicit daemon-unavailable state with no panic, terminal corruption, or immediate process exit. -- [x] #2 The daemon-unavailable state distinguishes at least not-running, connection-failed, and start-failed outcomes when the underlying error can be determined safely. -- [x] #3 The TUI exposes a clear action to start `cadderd` from inside the TUI, reports progress while the start/reconnect attempt is in flight, and refreshes normal state after a successful connection. -- [x] #4 Daemon start uses the same per-user runtime/path assumptions as the shim and does not spawn recursive or duplicate daemon instances when one becomes available during the attempt. -- [x] #5 If daemon start fails, the TUI remains usable and shows an actionable error while keeping retry and quit flows available. -- [x] #6 Automated tests cover the TUI model/state transitions for unavailable, starting, connected, and failed daemon states without requiring a real daemon process. -- [x] #7 Documentation or smoke verification notes describe the no-daemon startup path and the in-TUI daemon start flow. - - -## Implementation Plan - - -## Goal -Make `cadder-tui` resilient when `cadderd` is not running or cannot be reached. The TUI should enter normally, show a clear daemon-unavailable state, allow the user to start `cadderd` from inside the TUI, and reconnect to normal state after a successful start. - -## Scope -- Keep the work focused on `cadder-tui` startup/recovery behavior, daemon launch wiring, model state, tests, and verification docs. -- Reuse the existing Cadder daemon launch path instead of inventing a separate launcher. -- Preserve current per-user runtime behavior through `RuntimePaths`, `CADDER_RUNTIME_DIR`, and existing launch options. -- Do not expand into OS service installation, protocol redesign, packaging, release workflow, or real-Caddy-dependent automated tests. - -## Key Files And Modules -- `crates/cadder-tui/src/main.rs` -- `crates/cadder-tui/src/model.rs` -- `crates/cadder-daemon/src/ipc.rs` only if the existing daemon launcher needs a narrow safety/testability improvement -- `crates/cadderd/src/main.rs` only if CLI/help behavior must be adjusted for the TUI start flow -- `docs/verification/tui-smoke.md` -- `docs/site/src/content/docs/guides/tui-diagnostics.mdx` if user-facing TUI guidance needs an update -- `docs/ARCHITECTURE.md` if durable daemon/TUI startup notes need a small update - -## Implementation Steps -1. Rework `cadder-tui` bootstrap so terminal initialization and the app loop do not depend on a successful daemon start or initial `query-state`. The first connection attempt should set model state instead of returning an error that exits the process. -2. Add an explicit daemon connectivity model in `crates/cadder-tui/src/model.rs`, covering at least connected, not-running, connection-failed, starting, and start-failed. Classify errors conservatively so unknown errors do not get mislabeled. -3. Render the daemon-unavailable state prominently in Overview and status/footer text while keeping navigation, retry, and quit responsive. Use the existing style system for warning/error/progress states. -4. Add a clear in-TUI start/reconnect action, expected to be a keyboard command advertised in the footer. While it is in flight, mark the daemon as starting and prevent overlapping start attempts from the same TUI instance. -5. Implement the start action by calling `ensure_daemon_running_with_options` with the same runtime path and launch inputs already accepted by `cadder-tui` (`--runtime-dir`, `--daemon-path`, `--real-caddy-command`). After success, immediately issue a state refresh and clear the unavailable state only after a valid response. -6. Gate daemon-dependent actions while unavailable or starting. State refresh/reconnect and quit should remain available; toggles, log refreshes, IIS discovery/handoff, and shutdown should report actionable guidance instead of silently spawning doomed IPC requests. -7. Review `crates/cadder-daemon/src/ipc.rs` for duplicate/recursive start risk. If needed, make a narrow improvement around connect-before-spawn, post-spawn retry, or typed launch-result reporting, while preserving daemon lock ownership as the final duplicate-instance guard. -8. Add automated tests for TUI model/state transitions and app response handling: no-daemon startup state, connection failure, in-flight start, successful reconnect, start failure, retry availability, and no overlapping start attempts. Tests must not require a real daemon process. -9. Update smoke verification and user-facing docs to describe starting `cadder-tui` with no daemon, the daemon-unavailable screen, the in-TUI start/reconnect command, progress/error behavior, retry, and quit. - -## Validation -- Focused tests while iterating: `cargo test -p cadder-tui` -- If daemon launcher code changes: `cargo test -p cadder-daemon ipc` -- Formatting: `cargo fmt --check` -- Lints: `cargo clippy --workspace --all-targets -- -D warnings` -- Full tests: `cargo test --workspace` -- Repository validation: `cargo run -p xtask -- check` -- Coverage gate: `cargo run -p xtask -- coverage` -- Manual smoke: follow `docs/verification/tui-smoke.md` for the no-daemon startup path and successful in-TUI start/reconnect flow on an available terminal backend. - -## Assumptions -- `--no-start` should still prevent automatic daemon start during initial TUI launch, but the explicit in-TUI start action remains available because it is user-initiated. -- The existing `ensure_daemon_running_with_options` API is the preferred daemon launch path unless implementation reveals a narrow testability or safety gap. -- Distinguishing not-running versus connection-failed should be best-effort and based on safely available error details. -- The default behavior may still attempt startup automatically, but startup failure must degrade into the TUI recovery state instead of exiting. - -## Risks And Boundaries -- Error classification can become brittle if it depends on platform-specific string matching; keep labels conservative and tests focused on controlled errors. -- Concurrent daemon starts from separate processes may still race before the daemon lock rejects duplicates; avoid adding broad global coordination unless the existing guard proves insufficient. -- Startup progress must not block terminal input or redraws; run launch/reconnect as a background app response, matching existing TUI request patterns. -- Do not mark acceptance criteria complete until implementation and verification prove each behavior. - -## Execution Adjustment -- The daemon launcher now serializes startup attempts with a per-runtime `cadder-launch.lock` while waiting for the daemon socket. This replaced the earlier narrower connect-before-spawn-only guard and better satisfies the duplicate-start boundary while leaving the daemon runtime lock as the final ownership guard. - - -## Implementation Notes - - -Plan approved by user and recorded before implementation. - -Implemented the daemon recovery slice: cadder-tui now models connected, not-running, connection-failed, starting, and start-failed daemon states; daemon start/query-state runs in background responses instead of blocking terminal startup; daemon-dependent actions are gated while unavailable; the daemon launcher now rechecks connectivity immediately before spawning to reduce duplicate-start races. Focused validation so far: `cargo test -p cadder-tui` passed with 50 tests. - -Addressed code-review findings: `Start failed` now remains visible until a manual retry/start action instead of being overwritten by automatic polling; Windows IIS discovery starts on the first successful daemon connection to preserve the previous eager-load behavior; daemon launch attempts now use a per-runtime launch lock so concurrent callers wait for readiness instead of spawning duplicate daemon children. Added focused tests for these cases. - -Final validation passed after code-review fixes: `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test -p cadder-tui` (51 tests); `cargo test -p cadder-daemon ipc` (8 focused tests); `cargo test --workspace`; `cargo run -p xtask -- check`; `cargo run -p xtask -- coverage` with line coverage 86.69747689233076% (6941/8006), above the 85% gate. Manual PTY smoke ran `cargo run -p cadder-tui -- --runtime-dir --no-start` with no daemon; the TUI opened Overview showing `Daemon: Not running` and recovery guidance, then quit cleanly with `q` and exit code 0. - - -## Final Summary - - -## Summary -- Reworked `cadder-tui` startup so terminal initialization no longer depends on a successful daemon start or first `query-state`; unavailable daemon states are now rendered in the TUI instead of exiting. -- Added explicit daemon connectivity states for connected, not-running, connection-failed, starting, and start-failed, plus an in-TUI `s` start/reconnect action that uses the existing `ensure_daemon_running_with_options` path and refreshes state after a valid connection. -- Gated daemon-dependent actions while unavailable or starting, preserved responsive navigation/retry/quit flows, restored eager Windows IIS discovery on first successful connection, and added a per-runtime daemon launch lock to prevent duplicate spawn attempts across concurrent callers. -- Updated architecture and TUI smoke verification docs for no-daemon startup and recovery flows. - -## Validation -- `cargo fmt --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test -p cadder-tui` (51 tests) -- `cargo test -p cadder-daemon ipc` (8 focused tests) -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- coverage` (line coverage 86.69747689233076%, 6941/8006) -- Manual PTY smoke: `cargo run -p cadder-tui -- --runtime-dir --no-start` opened Overview with `Daemon: Not running` and recovery guidance, then quit cleanly with `q` and exit code 0. - -## Risks / Follow-ups -- No real Windows IIS environment smoke was run; IIS eager discovery is covered by Windows unit tests in this workspace. - - -## Definition of Done - -- [x] #1 Tests or explicit verification were run for the changed behavior -- [x] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.24 - Support-mixed-elevation-operations-with-minimal-admin-prompting.md b/backlog/tasks/task-1.24 - Support-mixed-elevation-operations-with-minimal-admin-prompting.md deleted file mode 100644 index dd4dae3..0000000 --- a/backlog/tasks/task-1.24 - Support-mixed-elevation-operations-with-minimal-admin-prompting.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -id: TASK-1.24 -title: Support mixed-elevation operations with minimal admin prompting -status: Done -assignee: - - '@Codex' -created_date: '2026-06-11 13:59' -updated_date: '2026-06-11 17:19' -labels: - - windows - - iis - - elevation - - pre-1.0 -milestone: v1.0 -dependencies: - - TASK-1.21 -references: - - crates/cadder-protocol/src/lib.rs - - crates/cadder-daemon/src/iis.rs - - crates/cadder-daemon/src/ipc.rs - - crates/cadder-daemon/src/state.rs - - crates/cadder-tui/src/main.rs - - crates/cadder-tui/src/model.rs -documentation: - - docs/ARCHITECTURE.md - - docs/verification/tui-smoke.md - - docs/site/src/content/docs/guides/windows.mdx -modified_files: - - crates/cadder-protocol/src/lib.rs - - crates/cadder-daemon/src/iis.rs - - crates/cadder-daemon/src/state.rs - - crates/cadder-tui/src/main.rs - - docs/ARCHITECTURE.md - - docs/verification/tui-smoke.md - - docs/site/src/content/docs/guides/windows.mdx - - docs/site/src/content/docs/cookbooks/windows/iis.mdx -parent_task_id: TASK-1 -priority: high -ordinal: 24000 ---- - -## Description - - -Allow Cadder to handle workflows where only part of an operation requires administrator privileges, especially Windows IIS handoff changes. Non-privileged daemon, TUI, and registration work should continue in the normal user context, while privileged sub-operations are isolated, batched where safe, and executed through an OS-appropriate admin prompt only when needed. - - -## Acceptance Criteria - -- [x] #1 Cadder can represent operation steps that require different privilege levels and can execute the non-admin portion without forcing the whole workflow to run elevated. -- [x] #2 Windows IIS operations that require administrator privileges request elevation through an OS-appropriate prompt for the smallest practical privileged subset, while non-IIS or read-only/user-level work remains unelevated. -- [x] #3 Mixed-elevation workflows report which steps succeeded, which steps require elevation, which privileged steps were approved or denied, and which follow-up rollback or retry actions are available. -- [x] #4 Privileged operations are batched only when doing so reduces prompts without broadening the set of actions performed as administrator beyond what the user requested. -- [x] #5 The implementation prevents silent privilege escalation: the TUI and daemon surface the reason for admin access before requesting it, and denial leaves existing user-level operations usable. -- [x] #6 Cross-platform behavior is explicit: non-Windows builds compile and test without Windows-only elevation dependencies, and unsupported elevation flows return typed unsupported responses rather than panicking. -- [x] #7 Automated tests cover privilege classification, partial success/failure reporting, admin-denied handling, and non-Windows fallback behavior using fakes instead of requiring elevated CI. -- [x] #8 Windows documentation and the TUI smoke checklist explain mixed-elevation behavior, expected prompts, denial behavior, and how IIS handoff is applied in privileged and non-privileged batches. - - -## Implementation Plan - - -## Goal -Add a low-risk mixed-elevation model for Windows IIS handoff so Cadder can keep daemon, TUI, registration, Caddy config planning, and non-privileged work in the normal user context while isolating only IIS mutation steps behind an explicit Windows elevation prompt. Non-Windows behavior must remain typed and testable without Windows-only dependencies. - -## Scope -- Extend the existing IIS handoff flow rather than replacing it. -- Keep the public TUI flow synchronous from the user's perspective, but make operation results step-aware. -- Keep privileged execution limited to IIS binding add/remove/restore operations. -- Do not require elevated CI or real IIS for automated tests. -- Do not introduce OS service installation, long-running privileged daemons, or broad privilege escalation. - -## Key Files -- `crates/cadder-protocol/src/lib.rs` -- `crates/cadder-daemon/src/iis.rs` -- `crates/cadder-daemon/src/state.rs` -- `crates/cadder-daemon/src/ipc.rs` -- `crates/cadder-tui/src/main.rs` -- `crates/cadder-tui/src/model.rs` -- `docs/ARCHITECTURE.md` -- `docs/verification/tui-smoke.md` -- `docs/site/src/content/docs/guides/windows.mdx` -- `docs/site/src/content/docs/cookbooks/windows/iis.mdx` - -## Implementation Steps -1. Extend protocol DTOs with typed mixed-elevation concepts: operation step id, privilege level, step status, approval outcome, rollback/retry action, and `ElevationRequired` / `ElevationDenied` style issue kinds. -2. Refactor `DaemonState::set_iis_handoff` into a small planner plus executor: preflight/discovery/conflict checks stay unelevated; IIS add/remove/restore steps are marked privileged; Caddy route updates and metadata handling remain normal user operations. -3. Add an `ElevationBroker` abstraction inside the daemon/IIS layer. Provide fake implementations for tests, direct unsupported behavior for non-Windows, and a Windows implementation that launches the smallest privileged batch through an OS-appropriate prompt. -4. Batch only adjacent IIS mutations that belong to the selected handoff/restore request, such as creating the loopback binding and removing the public binding. Do not include Caddy config apply, registration changes, discovery, or unrelated IIS rows in the elevated batch. -5. Preserve current rollback guarantees: write restore metadata before privileged mutation, report partial success, keep metadata when rollback/restore fails, and expose retry/rollback availability in the response. -6. Update TUI model/rendering to show why admin access is requested before dispatch, then show approved/denied/partial results without treating denial as a fatal daemon state. -7. Keep non-Windows explicit: daemon IPC returns typed unsupported/elevation-unavailable responses; non-Windows TUI still omits the IIS view; cross-target builds must not pull Windows-only elevation APIs. -8. Expand fake-provider tests for privilege classification, admin approval, admin denial, partial success, rollback/retry reporting, and non-Windows fallback. -9. Update architecture, Windows guide/cookbook, and TUI smoke checklist to describe normal-user operation, expected prompts, denial behavior, batching boundaries, and IIS handoff recovery. - -## Validation -- `cargo fmt --check` -- `cargo test -p cadder-protocol` -- focused `cadder-daemon` IIS/elevation tests -- focused `cadder-tui` IIS view/model tests -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- coverage` - -## Assumptions -- Windows elevation can be implemented as a short-lived helper/prompt path owned by daemon/IIS code, not as an always-elevated daemon. -- User denial is a normal outcome, not an error that should disable TUI or daemon operations. -- Existing fake IIS provider should be expanded rather than replaced. - -## Risks And Boundaries -- Highest risk is preserving ordering across metadata, IIS mutation, Caddy apply, and rollback. Keep the transaction shape close to the existing `enable_iis_handoff` / `disable_iis_handoff` flow. -- Avoid changing shim registration, Caddy runtime ownership, or daemon startup semantics. -- Avoid string-only status parsing; all user-visible mixed-elevation outcomes should come from typed protocol data. - - -## Implementation Notes - - -Plan approved by the user and recorded before implementation. - -Implemented mixed-elevation IIS handoff support with typed protocol operation steps, privilege levels, approval outcomes, step statuses, and follow-up actions. IIS discovery, metadata, Caddy route updates, registration state, and TUI operation remain user-level; only IIS add/remove/restore binding mutations are sent through the privileged batch path. Added Windows UAC-based batch execution for system IIS mutations, fake-provider approval/denial/unsupported behavior for tests, TUI pre-dispatch reason text, and response summaries for approved/denied/failed steps. Updated architecture, Windows guide/cookbook, and TUI smoke checklist for normal-user operation, prompt boundaries, denial behavior, batching, rollback, and retry guidance. Validation passed: cargo fmt --check; cargo test -p cadder-protocol; focused cadder-daemon IIS/elevation tests; focused cadder-tui tests; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace; cargo check --workspace --target x86_64-unknown-linux-gnu; cargo run -p xtask -- check; cargo run -p xtask -- coverage. Final line coverage: 86.35%, above the 85% threshold. - - -## Final Summary - - -## Summary -- Added typed mixed-elevation IIS operation reporting to the protocol: step ids, privilege levels, statuses, approval outcomes, issues, and follow-up actions. -- Routed IIS binding add/remove/restore mutations through a privileged batch path while keeping discovery, restore metadata, Caddy route application, registration state, daemon, and TUI behavior in the normal user context. -- Added fake-provider coverage for admin approval, admin denial, unsupported elevation, rollback/retry reporting, and partial restore failure paths without requiring real IIS or elevated CI. -- Updated the TUI to explain why administrator approval may be requested before dispatch and to summarize approved, denied, failed, and follow-up operation steps after the daemon response. -- Updated architecture, Windows guide/cookbook, and TUI smoke checklist to document mixed-elevation behavior, expected prompts, denial handling, batching boundaries, and recovery. - -## Validation -- `cargo fmt --check` -- `cargo test -p cadder-protocol` -- focused `cargo test -p cadder-daemon iis_` -- focused `cargo test -p cadder-tui` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo check --workspace --target x86_64-unknown-linux-gnu` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- coverage` with 86.35% line coverage - -## Risk -- Real Windows UAC/IIS mutation behavior still needs the documented manual smoke on a disposable IIS binding; automated tests use fake providers by design. - - -## Definition of Done - -- [x] #1 Tests or explicit verification were run for the changed behavior -- [x] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.25 - Rename-repository-slug-to-lowercase-cadder-before-1.0.md b/backlog/tasks/task-1.25 - Rename-repository-slug-to-lowercase-cadder-before-1.0.md deleted file mode 100644 index 19a1a5c..0000000 --- a/backlog/tasks/task-1.25 - Rename-repository-slug-to-lowercase-cadder-before-1.0.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -id: TASK-1.25 -title: Rename repository slug to lowercase cadder before 1.0 -status: To Do -assignee: [] -created_date: '2026-06-11 16:02' -labels: - - repository - - docs - - release - - pre-1.0 -milestone: v1.0 -dependencies: [] -references: - - Cargo.toml - - .github/workflows/ci.yml - - .github/workflows/release.yml - - .github/ISSUE_TEMPLATE/config.yml - - docs/site/public/cadder-downloads.js -documentation: - - README.md - - docs/site/astro.config.mjs - - docs/site/README.md - - docs/site/src/content/docs/index.mdx -parent_task_id: TASK-1 -priority: medium -ordinal: 25000 ---- - -## Description - - -Prepare and execute the repository slug rename from `MrMaxie/Cadder` to `MrMaxie/cadder` so the public documentation path can become `https://maxie.dev/cadder/`. Keep `Cadder` as the product name unless a separate branding decision changes it. Minimize disruption to GitHub links, releases, docs publishing, local clone remotes, and CI/CD. - - -## Acceptance Criteria - -- [ ] #1 Audit affected repository and docs URLs, including README links, Cargo metadata, docs base path, release links, download scripts, issue/security links, and workflows. -- [ ] #2 Record a low-risk rename plan with required GitHub permissions, change ordering, expected old-URL redirects, and rollback or remediation notes. -- [ ] #3 Rename the GitHub repository slug to lowercase `cadder` and update project-owned repository references to `MrMaxie/cadder` or the matching SSH remote form. -- [ ] #4 Publish docs under `https://maxie.dev/cadder/`; update project-owned `/Cadder` references or document any compatibility leftovers. -- [ ] #5 Verify CI, docs publishing, releases, and release download links still work after the rename. -- [ ] #6 Run targeted searches for `MrMaxie/Cadder`, `maxie.dev/Cadder`, and `/Cadder`; leave only documented compatibility references. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.26 - Add-OS-native-autostart-modes-for-daemon-only-and-daemontray-startup.md b/backlog/tasks/task-1.26 - Add-OS-native-autostart-modes-for-daemon-only-and-daemontray-startup.md deleted file mode 100644 index 96389bf..0000000 --- a/backlog/tasks/task-1.26 - Add-OS-native-autostart-modes-for-daemon-only-and-daemontray-startup.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -id: TASK-1.26 -title: Add OS-native autostart modes for daemon-only and daemon+tray startup -status: To Do -assignee: [] -created_date: '2026-06-16 15:24' -labels: - - startup - - runtime - - desktop -milestone: m-2 -dependencies: - - TASK-1.18 - - TASK-2.6 -references: - - crates/cadder-daemon/src/runtime.rs - - crates/cadderd/src/main.rs - - crates/cadder-tui/src/main.rs -documentation: - - docs/ARCHITECTURE.md - - docs/site/src/content/docs/reference/runtime-configuration.mdx -parent_task_id: TASK-1 -priority: medium -ordinal: 25100 ---- - -## Description - - -Add operator-configurable autostart that uses native per-user startup mechanisms on supported platforms. Cadder should support starting only `cadderd` for background use and starting `cadderd` together with the tray/GUI app for users who want resident controls, while keeping setup, removal, and degraded-platform behavior explicit. - - -## Acceptance Criteria - -- [ ] #1 Users can inspect the current autostart mode and enable, disable, or change it without manually editing startup files or registry entries. -- [ ] #2 Cadder supports two explicit per-user autostart modes: daemon-only and daemon plus tray/GUI. -- [ ] #3 Autostart setup uses each platform's native startup mechanism where supported and cleans up stale entries when the mode changes or is disabled. -- [ ] #4 Unsupported or partially supported platform behavior returns clear diagnostics and never silently reports success. -- [ ] #5 Automated tests cover configuration or state transitions and startup-registration generation; manual smoke verification is documented for platform-specific startup behavior. -- [ ] #6 Architecture and user documentation explain the available modes, platform differences, and how autostart interacts with detached daemon mode. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.27 - Introduce-durable-runtime-state-and-history-storage.md b/backlog/tasks/task-1.27 - Introduce-durable-runtime-state-and-history-storage.md deleted file mode 100644 index 4109146..0000000 --- a/backlog/tasks/task-1.27 - Introduce-durable-runtime-state-and-history-storage.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -id: TASK-1.27 -title: Introduce durable runtime state and history storage -status: To Do -assignee: [] -created_date: '2026-06-16 15:25' -labels: - - runtime - - persistence - - storage -milestone: m-2 -dependencies: [] -references: - - crates/cadder-daemon/src/state.rs - - crates/cadder-daemon/src/logs.rs - - crates/cadder-daemon/src/paths.rs - - crates/cadder-protocol/src/lib.rs -documentation: - - docs/ARCHITECTURE.md -parent_task_id: TASK-1 -priority: medium -ordinal: 25200 ---- - -## Description - - -Improve how Cadder stores time-based state so long-lived daemon sessions, diagnostics, logs, and future automation surfaces can retain useful history across restarts. Evaluate SQLite as the default per-user backing store and adopt it if it is the safest fit; otherwise document and implement the chosen alternative with clear migration and retention rules. - - -## Acceptance Criteria - -- [ ] #1 Cadder persists core runtime state and selected history across daemon restarts with explicit retention and redaction rules. -- [ ] #2 The storage backend decision is recorded for future contributors; if SQLite is adopted, schema and migration rules are documented, and if not, the chosen alternative and rationale are documented. -- [ ] #3 Daemon startup and upgrades handle missing, existing, and incompatible on-disk state safely with actionable recovery guidance. -- [ ] #4 Query surfaces can retrieve recent historical registrations, diagnostics, or logs without depending on the original shim process still being alive. -- [ ] #5 Automated tests cover cold start, restart recovery, retention cleanup, migration or compatibility behavior, and secret redaction. -- [ ] #6 Architecture and user documentation explain storage location, lifecycle, retention, backup or reset behavior, and platform considerations. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.28 - Add-non-TUI-CLI-for-inspecting-and-operating-Cadder.md b/backlog/tasks/task-1.28 - Add-non-TUI-CLI-for-inspecting-and-operating-Cadder.md deleted file mode 100644 index 7ff2ef1..0000000 --- a/backlog/tasks/task-1.28 - Add-non-TUI-CLI-for-inspecting-and-operating-Cadder.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -id: TASK-1.28 -title: Add non-TUI CLI for inspecting and operating Cadder -status: Done -assignee: - - '@agent' -created_date: '2026-06-16 15:25' -updated_date: '2026-06-16 18:42' -labels: - - cli - - automation -milestone: m-2 -dependencies: [] -references: - - Cargo.toml - - crates/cadder-protocol/src/lib.rs - - crates/cadderd/src/main.rs - - crates/cadder-tui/src/main.rs -documentation: - - docs/ARCHITECTURE.md - - docs/site/src/content/docs/reference/runtime-configuration.mdx -modified_files: - - Cargo.lock - - Cargo.toml - - README.md - - docs/ARCHITECTURE.md - - docs/site/astro.config.mjs - - docs/site/src/content/docs/index.mdx - - docs/site/src/content/docs/quick-start/getting-started.mdx - - docs/site/src/content/docs/reference/runtime-configuration.mdx - - docs/site/src/content/docs/user-guide/how-to-use.mdx - - docs/site/src/content/docs/user-guide/cadderctl.mdx - - xtask/src/main.rs - - crates/cadderctl/Cargo.toml - - crates/cadderctl/src/lib.rs - - crates/cadderctl/src/main.rs - - crates/cadderctl/src/cli.rs - - crates/cadderctl/src/error.rs - - crates/cadderctl/src/render.rs - - crates/cadderctl/src/view.rs - - crates/cadderctl/src/app.rs - - crates/cadderctl/tests/cli.rs - - crates/cadderctl/tests/support/mod.rs -parent_task_id: TASK-1 -priority: high -ordinal: 25300 ---- - -## Description - - -Add a command-line management interface so operators and automations can query Cadder state and execute common actions without opening `cadder-tui`. The CLI should reuse daemon contracts, provide stable exit codes and output modes, and cover the core inspection and control workflows needed for scripting. - - -## Acceptance Criteria - -- [x] #1 The CLI can inspect daemon status, entrypoints, domains, diagnostics, and recent logs without launching the TUI. -- [x] #2 The CLI supports common management actions such as starting or reconnecting to the daemon, refreshing state, and enabling or disabling managed domains using non-interactive commands. -- [x] #3 The CLI offers both human-readable output and a machine-readable mode suitable for scripts and agents. -- [x] #4 Permission-sensitive or unavailable-daemon operations return typed errors, stable exit codes, and actionable guidance instead of interactive UI prompts. -- [x] #5 Automated tests cover core commands, output modes, exit codes, and unavailable-daemon behavior. -- [x] #6 User documentation explains command structure, scripting patterns, and how this CLI relates to the TUI and future agent integrations. - - -## Implementation Plan - - -1. Add a new operator-facing CLI crate and workspace binary, recommended as `cadderctl`, instead of overloading `cadder`, because the architecture notes reserve `cadder` for a future desktop surface. Wire Clap command metadata, shared runtime arguments, and release identity consistently with the existing Cadder binaries. -2. Keep the CLI attach-only by default. Reuse `RuntimePaths`, `CadderClient`, `CadderSession`, and `ensure_daemon_running_with_options` from `crates/cadder-daemon` so only an explicit `daemon start` path may launch `cadderd`, while inspect and mutate commands only attach to an already running daemon. -3. Define the v1 command surface around the existing daemon contracts: `daemon status`, `daemon start`, `daemon shutdown`; `entrypoints list|enable|disable`; `domains list|enable|disable`; `diagnostics show`; `logs show`; and continuous `watch` or `tail` flows for the core inspection paths. Reuse current IPC request and response types instead of introducing a parallel backend contract. -4. Implement a small typed CLI application layer that resolves selectors, normalizes target lookup, and maps daemon/protocol failures into explicit operator-facing error kinds. Cover unavailable daemon, start timeout, target not found, rejected operation, unsupported platform or operation, permission or elevation issues, invalid usage, and serialization or IPC failures. -5. Define stable exit codes and keep them centralized and testable: success, invalid usage, daemon unavailable, daemon start failure or timeout, target not found, conflict or rejected operation, permission or elevation issue, unsupported platform or operation, and IPC or serialization failure. Ensure `Ctrl+C` exits streaming commands cleanly with success when the user intentionally stops them. -6. Implement stable output modes. Use human-readable output by default for one-shot commands, `json` for machine-readable one-shot results, and `jsonl` plus human streaming output for `watch` and `tail` commands. Keep machine-readable envelopes stable, explicit, and free of interactive prompts. -7. Build `watch` and `tail` on the current transport model instead of adding new daemon features to this task. Use `subscribe_state` for state-oriented watches, and implement log tailing through repeated `query_logs` calls with cursor progression, including explicit handling for stream status, gaps, retention truncation, and daemon loss while streaming. -8. Keep scope aligned with the milestone by focusing on core scripting and operator workflows. Do not require full TUI parity. Include state, entrypoints, domains, diagnostics, recent logs, start and shutdown, and enable or disable actions; only include IIS-specific CLI control if the existing contracts and time budget make it clearly part of the same bounded surface. -9. Update packaging and documentation alongside the binary. Extend workspace membership, portable dist and package verification, README usage, architecture wording where needed, and user docs so the shipped command set, output modes, scripting patterns, and the relationship between CLI, TUI, and future agent integrations stay aligned. -10. Validate with focused CLI unit and integration coverage plus the normal repository gates. Add tests for Clap help and version metadata, selector parsing, output rendering, exit-code mapping, unavailable-daemon behavior, state watch subscription handling, log tail cursor behavior, retention and gap reporting, and packaging expectations. Then run `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo run -p xtask -- check`, and `cargo run -p xtask -- coverage`. - - -## Implementation Notes - - -Plan approved by the user and recorded before implementation. Task moved to In Progress and assigned to @agent. Approved scope includes `watch` and `tail` support in the non-TUI CLI surface. - -Confirmed that the current daemon IPC does not expose a dedicated refresh or reconnect mutation. The CLI implementation will stay within existing contracts by using one-shot `query_state` requests for explicit refreshes, `subscribe_state` for `watch`, and repeated `query_logs` cursor polling for `tail` instead of adding new daemon messages in this task. - -Implemented a new `cadderctl` workspace crate for non-interactive Cadder inspection and control. The CLI now covers daemon status/start/shutdown, entrypoint and domain listing/toggles, diagnostics, retained log queries, log tailing, and state watches with `human`, `json`, and `jsonl` output modes. - -The implementation stayed within existing daemon contracts: one-shot refreshes use `query_state`, `watch` uses `subscribe_state`, and `logs tail` advances by repeated `query_logs` cursor polling rather than adding new IPC messages. Packaging and docs were updated to ship and document `cadderctl` alongside the existing binaries. - -Fresh-eyes review found and fixed two contract issues before closeout: top-level CLI flags are now global so documented command examples work even when `--output` or `--runtime-dir` appear after subcommands, and `cadderctl --help` / `--version` now exit with status 0 instead of invalid-usage status 2. - -Verification run: -- `cargo fmt --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- coverage` - -Workspace line coverage now passes the project threshold at 87.42%. - -Attempted an additional external Claude audit per repo guidance, but the local `claude` CLI invocation produced no output for an extended period and had to be aborted, so there are no external review findings to record from that pass. - - -## Final Summary - - -Added a new operator-facing `cadderctl` workspace crate so Cadder can be inspected and controlled from scripts and non-interactive terminal workflows without opening `cadder-tui`. - -What changed: -- Implemented `daemon status|start|shutdown`, `entrypoints list|enable|disable`, `domains list|enable|disable`, `diagnostics show`, `logs show`, `logs tail`, and `watch status|entrypoints|domains|diagnostics`. -- Kept the CLI attach-only by default and reused existing daemon contracts instead of introducing new IPC messages. One-shot refreshes use fresh `query_state` calls, `watch` uses `subscribe_state`, and `logs tail` advances via repeated `query_logs` cursor polling. -- Added stable `human`, `json`, and `jsonl` output modes plus centralized typed error handling and stable exit codes for unavailable daemon, invalid usage, target-not-found, rejected/conflict, permission/elevation, start failure, unsupported operations, and IPC failures. -- Extended workspace packaging and verification so `cadderctl` ships with releases and is covered by dist validation. -- Updated README, architecture docs, and docs site content with command structure, scripting patterns, runtime override examples, and the relationship between `cadderctl` and the TUI. - -Why it changed: -- Operators, automations, and future agents need a stable non-TUI surface for querying daemon state, toggling routes, and retrieving logs without depending on an interactive dashboard. - -Impact: -- Cadder now exposes a scriptable CLI surface suitable for shell usage, CI checks, and agent integration while preserving the existing daemon ownership and runtime model. - -Tests and verification: -- `cargo fmt --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- coverage` -- Workspace line coverage after the change: 87.42% - -Key closeout fixes found during review: -- Made top-level CLI flags global so documented forms like `cadderctl logs show ... --output json` work after subcommands. -- Fixed `cadderctl --help` and `cadderctl --version` to exit with status `0` instead of invalid-usage status `2`. - -Risks / follow-ups: -- No dedicated reconnect or refresh daemon mutation was added in this task by design; the CLI intentionally relies on fresh state queries, state subscriptions, and log cursor polling over the existing protocol. -- An additional external Claude audit was attempted but the local `claude` CLI produced no output and had to be aborted, so no external findings were available from that pass. - - -## Definition of Done - -- [x] #1 Tests or explicit verification were run for the changed behavior -- [x] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.29 - Expose-Cadder-through-an-MCP-server-for-agents.md b/backlog/tasks/task-1.29 - Expose-Cadder-through-an-MCP-server-for-agents.md deleted file mode 100644 index 8770a87..0000000 --- a/backlog/tasks/task-1.29 - Expose-Cadder-through-an-MCP-server-for-agents.md +++ /dev/null @@ -1,229 +0,0 @@ ---- -id: TASK-1.29 -title: Expose Cadder through an MCP server for agents -status: Done -assignee: - - '@agent' -created_date: '2026-06-16 15:25' -updated_date: '2026-06-16 19:39' -labels: - - mcp - - agent - - automation -milestone: m-2 -dependencies: [] -references: - - crates/cadder-daemon/src/ipc.rs - - crates/cadder-protocol/src/lib.rs - - crates/cadderd/src/main.rs -documentation: - - docs/ARCHITECTURE.md -modified_files: - - Cargo.toml - - Cargo.lock - - crates/cadder-operator/src/lib.rs - - crates/cadder-operator/src/error.rs - - crates/cadder-operator/src/view.rs - - crates/cadder-operator/src/service.rs - - crates/cadder-mcp/Cargo.toml - - crates/cadder-mcp/src/lib.rs - - crates/cadder-mcp/src/main.rs - - crates/cadder-mcp/src/dto.rs - - crates/cadder-mcp/src/redaction.rs - - crates/cadder-mcp/tests/support/mod.rs - - crates/cadder-mcp/tests/mcp.rs - - crates/cadderctl/Cargo.toml - - crates/cadderctl/src/app.rs - - crates/cadderctl/src/error.rs - - crates/cadderctl/src/view.rs - - xtask/src/main.rs - - README.md - - docs/ARCHITECTURE.md - - docs/site/astro.config.mjs - - docs/site/src/content/docs/index.mdx - - docs/site/src/content/docs/quick-start/getting-started.mdx - - docs/site/src/content/docs/user-guide/how-to-use.mdx - - docs/site/src/content/docs/user-guide/cadderctl.mdx - - docs/site/src/content/docs/user-guide/cadder-mcp.mdx -parent_task_id: TASK-1 -priority: high -ordinal: 25400 ---- - -## Description - - -Provide an MCP server so AI agents can inspect Cadder state and perform safe management actions through a stable tool surface instead of screen-driving the TUI. The MCP surface should expose daemon status, entrypoints, domains, diagnostics, logs, and carefully bounded control operations with redacted outputs and clear failure states. - - -## Acceptance Criteria - -- [x] #1 MCP tools can inspect daemon health, registrations, domains, diagnostics, recent logs, and configuration summaries without requiring the TUI. -- [x] #2 Safe management actions are exposed for the core non-interactive workflows, with typed results, redacted failures, and bounded output sizes. -- [x] #3 The MCP surface works against the local daemon model by default and documents any trust, authentication, or exposure boundaries required for agent use. -- [x] #4 Secrets and sensitive paths are redacted consistently from tool results, logs, and error messages. -- [x] #5 Automated tests cover tool contracts, unavailable-daemon behavior, redaction, and action routing. -- [x] #6 Agent-facing documentation explains how to start the MCP server, what tools it exposes, and when to prefer CLI or TUI instead. - - -## Implementation Plan - - -# MCP v1 Plan - -## Goal -Expose Cadder to local AI agents through a minimal, stable MCP server that reuses the existing `cadderctl` and daemon IPC model instead of introducing a parallel backend contract or screen-driving the TUI. - -## Milestone Fit -- Keep this task within milestone `m-2` by building on the already shipped `cadderctl` command model and current daemon IPC contracts. -- Preserve the local per-user daemon trust model. MCP v1 must remain a local `stdio` server that talks to the same daemon runtime directory model as `cadderctl`. -- Do not expand this task into remote access, authentication systems, full TUI parity, or a new daemon-side streaming protocol. - -## Scope Boundaries -Included in v1: -- Local MCP server binary and workspace wiring. -- Agent-facing inspect tools for daemon overview, entrypoints, domains, diagnostics, and recent logs. -- Bounded management tools for explicit daemon start plus entrypoint and domain activation toggles. -- Shared redaction rules for tool results, errors, and sensitive path exposure. -- Contract and integration tests for tool schemas, unavailable-daemon behavior, redaction, and action routing. -- Agent-facing documentation for startup, trust boundaries, tool usage, and when to prefer CLI or TUI. - -Explicitly out of scope for this task: -- Remote TCP or HTTP MCP transport. -- New daemon IPC messages unless implementation proves an existing contract is insufficient. -- IIS handoff MCP control. -- `watch` and `logs tail` streaming MCP tools. -- Daemon shutdown through MCP v1. -- Full TUI parity or unrestricted proxying to the real Caddy admin API. - -## Key Modules And Files -- `Cargo.toml` -- `crates/cadder-daemon/src/ipc.rs` -- `crates/cadder-daemon/src/lib.rs` -- `crates/cadder-daemon/src/logs.rs` -- `crates/cadder-protocol/src/lib.rs` -- `crates/cadderctl/src/app.rs` -- `crates/cadderctl/src/view.rs` -- `crates/cadderctl/src/error.rs` -- New `crates/cadder-mcp/` crate for the MCP server -- `README.md` -- `docs/ARCHITECTURE.md` -- Docs site pages under `docs/site/src/content/docs/` -- `xtask/src/main.rs` if packaging or verification expectations need updating - -## Implementation Steps -1. Extract a shared Cadder operator service layer from `cadderctl` so MCP and CLI can reuse the same attach, query, toggle, log-target resolution, daemon start, and typed error-mapping behavior without shelling out to `cadderctl`. -2. Keep the existing `CadderClient`, `CadderSession`, `StateSubscription`, and daemon IPC request and response types as the primary transport boundary. Reuse existing `query-state`, `query-logs`, `set-entrypoint-enabled`, `set-domain-enabled`, and explicit daemon start behavior rather than designing a second internal control path. -3. Create a new local-only `cadder-mcp` crate and binary that serves MCP over `stdio`. Accept runtime configuration inputs equivalent to the CLI runtime selection model, but do not expose any network listener in v1. -4. Define a minimal safe MCP tool surface using agent-facing DTOs rather than returning the raw daemon snapshot shape. The initial tool set should be: - - `cadder_get_overview` - - `cadder_list_entrypoints` - - `cadder_list_domains` - - `cadder_show_diagnostics` - - `cadder_get_logs` - - `cadder_start_daemon` - - `cadder_set_entrypoint_enabled` - - `cadder_set_domain_enabled` -5. Keep inspect tools attach-only. `cadder_start_daemon` is the only MCP tool that may launch `cadderd`. All other tools must return typed unavailable-daemon failures with guidance instead of causing side effects. -6. Reuse or adapt `cadderctl` view-building logic so MCP returns compact, stable, task-oriented payloads for overview, entrypoints, domains, diagnostics, and logs. Avoid exposing raw internal fields that are unnecessary for agent workflows. -7. Add bounded inputs and outputs for management and inspect tools. At minimum, validate log limits, selector ambiguity, and runtime path handling, and cap log result sizes so tool responses remain predictable for agents. -8. Implement MCP-specific redaction on top of the existing daemon log redaction. Preserve the current token-like log scrubbing, then additionally redact or normalize sensitive path and process detail fields before they are emitted through MCP. -9. Define path-redaction behavior explicitly. Prefer workspace-relative paths when the target is inside the repo or selected runtime context; otherwise return a reduced or redacted representation that remains useful for diagnostics without leaking unnecessary host-specific details. -10. Apply the same redaction discipline to tool success payloads, diagnostics, and typed failures. Error messages, guidance, configuration path summaries, and command metadata must not leak raw secrets or unnecessarily sensitive absolute paths. -11. Document the MCP trust boundary clearly: local `stdio` MCP server, same-user daemon assumptions, no remote exposure in v1, explicit daemon start semantics, and the intended division of responsibilities between MCP, `cadderctl`, and `cadder-tui`. -12. Update release and operator documentation so contributors and agents can discover the MCP binary, understand the tool names, and know when to use MCP versus CLI or TUI. - -## Redaction Requirements -- Reuse daemon-side redaction for retained log messages from `crates/cadder-daemon/src/logs.rs`. -- Redact token-like values, credentials, authorization fragments, and similar sensitive substrings from any MCP-visible free-text field. -- Redact or normalize absolute paths, executable paths, command lines, admin endpoints, and similar host-sensitive fields unless they are strictly necessary for safe agent operation. -- Ensure tool errors and guidance go through the same redaction path as normal tool results. -- Add regression tests that prove the same secret or path pattern is not exposed through logs, diagnostics, or failure payloads. - -## Contract Test Requirements -Add automated coverage at three levels: -1. Unit tests for shared operator service behavior: -- selector resolution -- bounded log option validation -- typed unavailable-daemon and rejected-operation mapping -- redaction helpers for strings and paths - -2. MCP contract tests: -- stable tool names and descriptions -- stable input validation behavior -- stable result envelope shapes for inspect and action tools -- typed error payloads for daemon unavailable, ambiguous selectors, unsupported operations, and rejected actions - -3. Integration tests using a local harness similar to `cadderctl` tests: -- inspect tools return expected data from the existing daemon IPC model -- management tools route to the correct daemon actions -- unavailable-daemon cases remain non-interactive and typed -- redaction is preserved in logs, diagnostics, and failures -- one `stdio` MCP smoke path covers initialize, tool discovery, and at least one inspect plus one action call - -## Validation -Run focused tests while implementing, then the normal repository gates before closeout: -- `cargo fmt --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- coverage` - -## Risks And Watchpoints -- Avoid duplicating `cadderctl` logic and drifting tool semantics between CLI and MCP. -- Avoid leaking host-specific or secret-bearing fields through apparently harmless diagnostics. -- Keep v1 tool scope intentionally small so tool names and schemas can stay stable. -- If implementation reveals that a current IPC response shape is too raw for safe MCP exposure, adapt through shared view or service DTOs first before proposing new daemon messages. -- If a necessary workflow would require broader actions such as shutdown or IIS mutation, stop and request an explicit scope decision instead of silently expanding the task. - - -## Implementation Notes - - -Plan approved and recorded before implementation. MCP v1 is intentionally limited to inspect plus bounded management actions on top of existing `cadderctl` and daemon IPC contracts; daemon shutdown, streaming tools, and IIS control remain out of scope unless explicitly approved later. - -Extracted a shared `cadder-operator` layer from `cadderctl` so CLI and MCP reuse the same daemon attach/query/toggle/log-target resolution logic and typed error mapping instead of drifting through separate implementations. - -Implemented `cadder-mcp` as a local stdio MCP server with bounded inspect and management tools, MCP-specific path/text redaction, and explicit daemon-start semantics limited to `cadder_start_daemon`. - -Updated release packaging and documentation to include `cadder-mcp`, plus validation for the portable layout and agent-facing docs about trust boundaries and when to prefer MCP versus CLI or TUI. - -Validation run after the final code changes: `cargo test -p cadder-operator`, `cargo test -p cadder-mcp`, `cargo test -p cadderctl --lib`, `cargo test -p xtask`, `cargo run -p xtask -- check`, `cargo run -p xtask -- coverage`, `bun run check`, and `bun run build` in `docs/site`. Workspace line coverage remained above the gate at 85.65% in `target/llvm-cov/coverage-summary.json`. - - -## Final Summary - - -## Summary -Added a new local stdio `cadder-mcp` server so agent hosts can inspect Cadder state and perform bounded management actions through a stable MCP tool surface instead of shelling out to `cadderctl` or screen-driving the TUI. - -## What Changed -- extracted a shared `cadder-operator` crate from `cadderctl` for daemon attach/query/toggle/log-target resolution, daemon start policy, typed error mapping, and shared state/view shaping -- refactored `cadderctl` to reuse the shared operator layer instead of keeping its own copies of the same logic -- added the `cadder-mcp` crate and binary with overview, entrypoint, domain, diagnostics, logs, daemon-start, and activation-toggle tools over local stdio -- added MCP-specific redaction for paths, free-form text, endpoints, and bounded log responses -- updated workspace packaging and `xtask` verification so portable layouts include `cadder-mcp` -- documented the MCP trust boundary, startup flow, tool surface, and when to use MCP versus CLI or TUI in the README, architecture notes, and docs site - -## Why -This gives AI agents a stable, structured local control surface for Cadder while preserving the existing per-user daemon model, keeping daemon startup explicit, and avoiding duplicate business logic between CLI and MCP clients. - -## Validation -- `cargo test -p cadder-operator` -- `cargo test -p cadder-mcp` -- `cargo test -p cadderctl --lib` -- `cargo test -p xtask` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- coverage` -- `bun run check` in `docs/site` -- `bun run build` in `docs/site` - -## Risks / Follow-ups -MCP v1 intentionally excludes streaming watch/tail tools, daemon shutdown, and IIS handoff control. Those should remain separate follow-up scope so the initial tool names and schemas stay stable. - - -## Definition of Done - -- [x] #1 Tests or explicit verification were run for the changed behavior -- [x] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.3 - Implement-the-caddy.exe-shim-registration-flow.md b/backlog/tasks/task-1.3 - Implement-the-caddy.exe-shim-registration-flow.md deleted file mode 100644 index 004e4a1..0000000 --- a/backlog/tasks/task-1.3 - Implement-the-caddy.exe-shim-registration-flow.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -id: TASK-1.3 -title: Implement the caddy.exe shim registration flow -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 11:42' -updated_date: '2026-06-09 14:04' -labels: [] -dependencies: - - TASK-1.1 - - TASK-1.2 -references: - - 'D:\Projects\Selleo\smarketing\apps\reverse-proxy\package.json' - - 'D:\Projects\Selleo\smarketing\apps\reverse-proxy\Caddyfile' -modified_files: - - docs/ARCHITECTURE.md - - src/Cadder.CaddyShim/Program.cs - - src/Cadder.CaddyShim/ShimEntrypoint.cs - - src/Cadder.CaddyShim/ShimRunCommand.cs - - src/Cadder.CaddyShim/ShimRegistration.cs - - src/Cadder.CaddyShim/ShimDaemonConnection.cs - - src/Cadder.CaddyShim/ShimDaemonStartup.cs - - src/Cadder.CaddyShim/ShimRuntime.cs - - src/Cadder.Contracts/DomainContracts.cs - - src/Cadder.Contracts/IpcContracts.cs - - src/Cadder.Contracts/IpcPipeProtocol.cs - - src/Cadder.Daemon/CadderIpcEndpoint.cs - - src/Cadder.Daemon/DaemonBoundaries.cs - - src/Cadder.Daemon/DaemonLifecycle.cs - - src/Cadder.Daemon/DaemonRegistrationStore.cs - - src/Cadder.Daemon/NamedPipeDaemonIpcServer.cs - - src/Cadder.Tray.WinUI/App.xaml.cs - - tests/Cadder.Contracts.Tests/ContractShapeTests.cs - - tests/Cadder.Daemon.Tests/Cadder.Daemon.Tests.csproj - - tests/Cadder.Daemon.Tests/DaemonLifecycleTests.cs - - tests/Cadder.Daemon.Tests/NamedPipeDaemonIpcServerTests.cs - - tests/Cadder.Daemon.Tests/ShimCommandParserTests.cs - - tests/Cadder.Daemon.Tests/ShimEntrypointTests.cs -parent_task_id: TASK-1 -priority: high -ordinal: 4000 ---- - -## Description - - -Implement the PATH-facing caddy.exe shim that project scripts invoke. The shim should start or connect to the singleton daemon, register the invoking Caddy configuration, and keep that registration alive only for the lifetime of the shim process. - - -## Acceptance Criteria - -- [x] #1 Running caddy.exe run from a project directory registers that directory and its Caddyfile with the daemon. -- [x] #2 The shim supports explicit Caddy config and adapter flags needed by caddy run, including --config and --adapter. -- [x] #3 If the daemon is not running, the shim starts it and waits until IPC is ready before registering. -- [x] #4 The shim keeps the registration alive until normal exit, Ctrl+C, parent terminal close, or process termination is detected by the daemon. -- [x] #5 Unsupported Caddy commands either delegate to the real Caddy binary or fail with a clear message that names the supported Cadder command set. - - -## Implementation Plan - - -## Goal -Implement the PATH-facing `caddy.exe run` shim flow so project scripts can start or connect to the singleton Cadder daemon, register the invoking Caddy configuration, and keep that registration alive only while the shim process is alive. - -## Scope -- Support `caddy.exe run` from a project working directory, including the default `Caddyfile` lookup. -- Support the explicit `caddy run` flags required by the task: `--config` and `--adapter`, in both separated and equals forms where applicable. -- Add the minimal daemon IPC needed for shim registration, keepalive/session lifetime, and unregister/disconnect behavior. -- Start the daemon when IPC is not available, then wait for IPC readiness with a bounded timeout before registering. -- Keep the full owner-aware registry, broad IPC API, GUI subscriptions, domain extraction, config composition, real Caddy runtime management, and packaging/PATH installer behavior deferred to later tasks. -- For unsupported Caddy commands in this task, prefer a clear Cadder error message that names the supported command set instead of delegating to the real Caddy binary, because real Caddy resolution is deferred to TASK-1.6 and TASK-1.11. - -## Key Files And Modules -- `src/Cadder.CaddyShim/ShimEntrypoint.cs` -- `src/Cadder.Contracts/IpcContracts.cs` -- `src/Cadder.Contracts/DomainContracts.cs` -- `src/Cadder.Daemon/DaemonLifecycle.cs` -- `src/Cadder.Daemon/DaemonBoundaries.cs` -- `src/Cadder.Tray.WinUI/App.xaml.cs` -- `docs/ARCHITECTURE.md` -- `tests/Cadder.Daemon.Tests/` -- New or updated shim-focused tests, in an existing or new test project depending on the smallest clean fit. - -## Implementation Steps -1. Add a small, testable shim argument parser for `run`. It should recognize `--config `, `--config=`, `--adapter `, and `--adapter=`, preserve raw arguments, reject missing option values, and resolve the default config as `Caddyfile` under the current working directory when `--config` is omitted. -2. Add a shim run options/session model that captures the source working directory, raw and canonical config path, adapter name, raw command line arguments, started time, process identity, and a generated shim session nonce. -3. Extend the shared IPC contracts only as much as this task needs: request/response shapes for registering a shim-owned entrypoint, maintaining a live shim session, and ending the registration on normal disconnect/unregister. -4. Implement a minimal per-user named-pipe IPC transport shared by the daemon and shim. Keep it intentionally narrow and JSON-friendly so TASK-1.4 can expand it into the full register/update/list/toggle/unregister API without replacing the task's core behavior. -5. Replace the WinUI daemon's `NoopDaemonIpcServer` with the minimal real IPC server. It should accept shim registration requests, update the daemon lifecycle registration count, and remove the registration when the client disconnects or unregisters. -6. Implement shim daemon discovery/startup: attempt IPC connection first; if unavailable, start the Cadder tray daemon executable through a testable resolver, then poll IPC readiness with cancellation and timeout. -7. Keep daemon executable resolution separate from real Caddy binary resolution. Provide a test/environment override for local tests and use the current build/package layout as the default local resolver path. -8. After registration succeeds, keep the shim process alive until normal exit, Ctrl+C, terminal close, cancellation, or hard process termination. On graceful exit, send unregister/disconnect; on hard termination, rely on daemon-side pipe/session loss detection for this task. -9. Return clear exit codes and diagnostics: successful registration lifecycle returns `0`, parse/unsupported-command failures return non-zero, daemon startup/readiness failures name the timeout/IPC problem, and unsupported commands name the supported Cadder command set (`caddy run` plus `--cadder-shim-info`). -10. Update `docs/ARCHITECTURE.md` with the shim registration flow, the minimal IPC boundary added in this task, and the explicit handoff boundaries to TASK-1.4, TASK-1.6, and TASK-1.11. - -## Validation -- Run `./build.ps1` from the repository root. -- Run `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet format Cadder.slnx --verify-no-changes`. -- Add focused unit tests for shim argument parsing, config path resolution, unsupported command messaging, daemon startup fallback, and registration/unregister lifecycle behavior through fakes. -- Smoke test from `D:\Projects\Selleo\smarketing\apps\reverse-proxy`: run the built shim as `caddy.exe run` and confirm the working directory and `Caddyfile` are registered with the daemon. -- Smoke test `caddy.exe run --config Caddyfile --adapter caddyfile`. -- Smoke test an unsupported command such as `caddy.exe version` and confirm the message clearly names the supported Cadder command set. -- Run `git status --short` before finishing and remove only temporary artifacts created by the implementation. - -## Scope Boundaries -- Do not implement full owner-aware registration store behavior from TASK-1.4 beyond the minimum transient session tracking needed for this shim lifecycle. -- Do not implement register/update/list/toggle API breadth, GUI state subscriptions, or at-least-ten-concurrent-registration stress behavior; those are TASK-1.4. -- Do not parse domains from Caddyfiles, compose effective Caddy config, reload Caddy, or validate composed config; those are TASK-1.5. -- Do not resolve, inspect, start, reload, or delegate to the real Caddy binary; those are TASK-1.6 and TASK-1.11. -- Do not implement installer/PATH shadowing validation; that is TASK-1.11. -- Do not build tray/panel UI beyond any minimal diagnostics needed to validate daemon registration state. - -## Risks And Notes -- Detecting parent terminal close may surface as process cancellation or named-pipe/session loss rather than a distinct terminal-close signal. That is acceptable for this task if the daemon removes only the affected shim registration. -- The daemon executable resolver must be testable and must not be confused with the future real Caddy resolver. -- The IPC implementation should stay simple enough to avoid preempting TASK-1.4 while still making TASK-1.3 behavior real and testable. -- Concurrent shim sessions should avoid shared mutable state races, but broad concurrency guarantees remain part of TASK-1.4. - - -## Implementation Notes - - -Plan approved by user and recorded before implementation. - -Implemented the TASK-1.3 shim registration flow in narrow vertical slices: parser/session metadata, minimal JSON named-pipe IPC, daemon-side transient registration cleanup, shim daemon startup/readiness fallback, and docs/tests. Validation passed: .\build.ps1; dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64; dotnet format Cadder.slnx --verify-no-changes; smoke-tested built caddy.exe --cadder-shim-info and unsupported caddy.exe version. Full external caddy run smoke against the referenced smarketing folder was not run to avoid launching the real tray UI; equivalent registration and disconnect behavior is covered by NamedPipeDaemonIpcServerTests. - -Addressed adversarial review findings after initial closeout: moved daemon shutdown boundary calls outside the lifecycle gate to avoid cleanup deadlock, made shim register/unregister IPC failures controlled, rejected option flags as missing separated option values, assigned server-owned registration IDs per pipe session, and handled malformed IPC JSON before it can fault StopAsync. Added regression tests for each path. Validation after these fixes passed: dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64; .\build.ps1; dotnet format Cadder.slnx --verify-no-changes; caddy.exe --cadder-shim-info; caddy.exe version unsupported-command smoke. - - -## Final Summary - - -Implemented the PATH-facing caddy.exe run shim flow for TASK-1.3. - -What changed: -- Added a testable caddy run parser supporting default Caddyfile resolution plus --config/--adapter in separated and equals forms, including missing-value validation. -- Added shim session metadata, registration construction, daemon startup/readiness fallback, graceful unregister, controlled IPC failure diagnostics, and clear unsupported-command diagnostics. -- Added a minimal per-user JSON named-pipe IPC protocol, daemon endpoint, transient in-memory registration store, server-owned per-pipe registration IDs, and disconnect cleanup for shim-owned sessions. -- Replaced the WinUI daemon Noop IPC server with the minimal real IPC server and documented the TASK-1.3 flow and handoff boundaries. -- Adjusted daemon shutdown so boundary calls do not hold the lifecycle gate while client cleanup updates registration count. -- Added focused contract, shim, lifecycle, parser, and named-pipe regression tests. - -Validation: -- .\build.ps1 -- dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64 -- dotnet format Cadder.slnx --verify-no-changes -- Built caddy.exe --cadder-shim-info smoke test -- Built caddy.exe version unsupported-command smoke test returned exit code 2 with the supported Cadder command set - -Review follow-up: -- A code-review subagent found shutdown, IPC failure, parser, ownership, and malformed-message edge cases. Each was fixed and covered by regression tests. - -Notes: -- Real Caddy delegation/resolution, full owner-aware registration APIs, GUI subscriptions, domain extraction, and PATH installer behavior remain deferred to TASK-1.4, TASK-1.5, TASK-1.6, and TASK-1.11 as planned. - diff --git a/backlog/tasks/task-1.30 - Add-repo-local-agent-skills-for-using-Cadder.md b/backlog/tasks/task-1.30 - Add-repo-local-agent-skills-for-using-Cadder.md deleted file mode 100644 index 6ee9f80..0000000 --- a/backlog/tasks/task-1.30 - Add-repo-local-agent-skills-for-using-Cadder.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -id: TASK-1.30 -title: Add repo-local agent skills for using Cadder -status: To Do -assignee: [] -created_date: '2026-06-16 15:25' -labels: - - agent - - documentation - - skills -milestone: m-2 -dependencies: - - TASK-1.28 - - TASK-1.29 -references: - - .agents/skills - - AGENTS.md -documentation: - - docs/ARCHITECTURE.md - - AGENTS.md -parent_task_id: TASK-1 -priority: medium -ordinal: 25500 ---- - -## Description - - -Add repo-local agent skills that teach coding agents how to inspect, operate, and debug Cadder through the supported interfaces instead of guessing from repository structure or driving the TUI blindly. The skills should cover the preferred CLI and MCP workflows, safety boundaries, verification steps, and fallbacks when an interface is unavailable. - - -## Acceptance Criteria - -- [ ] #1 Repo-local skills are added under `.agents/skills` for the supported Cadder operator workflows, including state inspection, lifecycle control, logs and diagnostics, and common recovery actions. -- [ ] #2 The skills prefer documented CLI and MCP surfaces where available and explain fallback behavior when an interface is unavailable. -- [ ] #3 Skill instructions cover safety constraints, environment or setup expectations, and verification commands for the workflows they describe. -- [ ] #4 Example prompts or usage snippets are included so future agents can discover and invoke the skills consistently. -- [ ] #5 A documented check or automated test keeps the skill instructions aligned with shipped commands and tool names. -- [ ] #6 Agent-facing documentation explains where the skills live and how contributors should maintain them. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-1.4 - Build-IPC-and-owner-aware-registration-store.md b/backlog/tasks/task-1.4 - Build-IPC-and-owner-aware-registration-store.md deleted file mode 100644 index 824615c..0000000 --- a/backlog/tasks/task-1.4 - Build-IPC-and-owner-aware-registration-store.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -id: TASK-1.4 -title: Build IPC and owner-aware registration store -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 11:42' -updated_date: '2026-06-09 15:59' -labels: [] -dependencies: - - TASK-1.2 - - TASK-1.3 -modified_files: - - docs/ARCHITECTURE.md - - src/Cadder.Contracts/DomainContracts.cs - - src/Cadder.Contracts/IpcContracts.cs - - src/Cadder.Contracts/IpcPipeProtocol.cs - - src/Cadder.Daemon/DaemonBoundaries.cs - - src/Cadder.Daemon/DaemonRegistrationStore.cs - - src/Cadder.Daemon/CadderIpcEndpoint.cs - - src/Cadder.Daemon/GuiStateBroadcaster.cs - - src/Cadder.Daemon/RegistrationOwnerWatcher.cs - - src/Cadder.Daemon/NamedPipeDaemonIpcServer.cs - - src/Cadder.Daemon/DaemonLifecycle.cs - - src/Cadder.CaddyShim/ShimDaemonConnection.cs - - src/Cadder.CaddyShim/ShimEntrypoint.cs - - src/Cadder.CaddyShim/ShimRegistration.cs - - src/Cadder.CaddyShim/ShimRuntime.cs - - src/Cadder.Tray.WinUI/App.xaml.cs - - tests/Cadder.Contracts.Tests/ContractShapeTests.cs - - tests/Cadder.Daemon.Tests/CadderIpcEndpointTests.cs - - tests/Cadder.Daemon.Tests/DaemonRegistrationStoreTests.cs - - tests/Cadder.Daemon.Tests/NamedPipeDaemonIpcServerTests.cs - - tests/Cadder.Daemon.Tests/RegistrationOwnerWatcherTests.cs - - tests/Cadder.Daemon.Tests/ShimEntrypointTests.cs -parent_task_id: TASK-1 -priority: high -ordinal: 5000 ---- - -## Description - - -Build the daemon-side IPC and owner-aware registration store used by the shim and GUI. Registrations are grouped by entrypoint process and removed automatically when their owning shim dies. - - -## Acceptance Criteria - -- [x] #1 The IPC API can register, update, list, toggle, and unregister entrypoint-owned Caddy configs. -- [x] #2 Each registration records owner process ID, process start identity, executable path, working directory, config path, command line, created time, and last heartbeat. -- [x] #3 A daemon-side watcher removes only the registrations owned by a dead or disconnected shim. -- [x] #4 The registry supports zero, one, and at least ten simultaneous registrations without state corruption. -- [x] #5 The GUI can subscribe to state changes without polling tight loops. - - -## Implementation Plan - - -## Goal -Expand the minimal IPC and transient registration path from TASK-1.3 into the owner-aware daemon registration layer used by shim sessions and GUI state consumers. - -## Scope -- Keep the registration store transient and in-memory for this task. -- Add the full IPC API required by TASK-1.4: register, update, list, toggle, unregister, heartbeat, and GUI state subscription. -- Record complete owner-aware metadata for each registration: owner PID, owner process start identity, executable path, working directory, config path, command line, created time, and last heartbeat. -- Add daemon-side owner cleanup for dead or disconnected shim sessions, ensuring cleanup removes only registrations owned by the affected owner/session. -- Add state-change notifications so GUI code can subscribe without tight polling loops. -- Preserve Caddyfile parsing, domain extraction, conflict detection, composed config reload, real Caddy runtime management, and full GUI pages for later tasks. - -## Key Files And Modules -- `src/Cadder.Contracts/DomainContracts.cs` -- `src/Cadder.Contracts/IpcContracts.cs` -- `src/Cadder.Contracts/IpcPipeProtocol.cs` -- `src/Cadder.Daemon/DaemonBoundaries.cs` -- `src/Cadder.Daemon/DaemonRegistrationStore.cs` -- `src/Cadder.Daemon/CadderIpcEndpoint.cs` -- `src/Cadder.Daemon/NamedPipeDaemonIpcServer.cs` -- `src/Cadder.CaddyShim/ShimDaemonConnection.cs` -- `src/Cadder.CaddyShim/ShimEntrypoint.cs` -- `src/Cadder.CaddyShim/ShimRegistration.cs` -- `src/Cadder.Tray.WinUI/App.xaml.cs` -- `tests/Cadder.Contracts.Tests/ContractShapeTests.cs` -- `tests/Cadder.Daemon.Tests/NamedPipeDaemonIpcServerTests.cs` -- New focused daemon tests as needed for store, endpoint, owner watcher, heartbeat, and subscription behavior. -- `docs/ARCHITECTURE.md` - -## Implementation Steps -1. Extend the shared domain model with explicit registration lifecycle metadata. Add created time and last heartbeat fields, and keep owner identity as more than a PID by preserving process start time and shim session nonce. -2. Extend IPC contracts and message type constants for update, list, toggle, heartbeat, and subscribe operations. Keep DTOs JSON-friendly and preserve existing register/unregister/query messages where practical. -3. Replace the TASK-1.3 store's basic upsert/remove API with owner-aware operations. Keep the implementation in-memory, but make register/update/toggle/unregister/list operations safe for concurrent access and deterministic under at least ten simultaneous registrations. -4. Separate stable registration identity from pipe connection identity. The daemon may still track connection-owned registrations internally, but GUI/list/update/toggle flows should use stable registration IDs rather than IDs that accidentally encode transport sessions. -5. Extend `CadderIpcEndpoint` so all mutation operations update the store, refresh heartbeat or activation state as appropriate, publish registration count changes, and emit state-change notifications. -6. Add a daemon-side owner watcher behind a testable process abstraction. It should check owner liveness using PID plus recorded process start time, tolerate process lookup/access failures conservatively, and remove only registrations owned by the dead owner. -7. Extend `NamedPipeDaemonIpcServer` to dispatch the new request types and retain the existing safety behavior for malformed JSON, unknown message types, and disconnect cleanup. Disconnect cleanup should remain scoped to registrations owned by that pipe session. -8. Add GUI subscription support over IPC. Prefer a long-lived subscription stream that sends an initial snapshot followed by state-change events, so GUI consumers do not need tight polling loops. -9. Update shim-side IPC connection and shim session flow to send heartbeats while the shim remains alive, use the server-returned registration ID for later operations, and preserve graceful unregister on normal shutdown. -10. Update `App.xaml.cs` wiring if needed so the daemon host owns the expanded store, endpoint, owner watcher, and state broadcaster lifecycle. -11. Update `docs/ARCHITECTURE.md` with the TASK-1.4 IPC surface, owner identity rules, heartbeat semantics, process watcher cleanup, and GUI subscription model. - -## Validation -- Run `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `./build.ps1` from the repository root. -- Run `dotnet format Cadder.slnx --verify-no-changes`. -- Add contract tests for new DTO shape and serialization. -- Add store/endpoint tests for zero, one, and at least ten simultaneous registrations. -- Add concurrency tests for parallel register, update, toggle, unregister, and list operations. -- Add owner cleanup tests for dead owner, PID reuse, disconnected pipe session, and cleanup isolation when multiple owners share similar metadata. -- Add named-pipe integration tests for all new IPC message types, malformed messages after registration, and subscription snapshot plus delta delivery. -- Smoke test two shim sessions concurrently and confirm closing one removes only that shim's registration while the daemon remains alive. - -## Scope Boundaries -- Do not parse domains from Caddyfiles or populate real domain lists beyond existing DTO placeholders; TASK-1.5 owns parsing and config composition. -- Do not reload or manage the real Caddy runtime; TASK-1.6 owns runtime management. -- Do not build full tray popup or panel pages; TASK-1.8 and TASK-1.9 own UI surfaces. This task only provides subscribable state plumbing. -- Do not implement durable persistence unless explicitly requested later. -- Do not add or change acceptance criteria without user approval. - -## Risks And Notes -- PID reuse is the main owner-cleanup risk; watcher logic must compare process start identity in addition to PID. -- Heartbeat semantics should be diagnostic and freshness-oriented, not the only cleanup mechanism, because pipe disconnect and process death are stronger ownership signals. -- Subscription support introduces server-push ordering concerns. The implementation should define initial snapshot plus subsequent event ordering clearly enough for GUI consumers. -- Existing TASK-1.3 behavior around disconnect cleanup must remain intact while expanding API breadth. -- `RegistrationId` stability matters for GUI and future runtime tasks; avoid leaking transport-specific connection IDs into user-facing identity unless the plan is revised deliberately. - - -## Implementation Notes - - -Plan approved by user and recorded before implementation. Baseline observed before recording: `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed, and `git status --short` was clean. - -Implemented owner-aware in-memory registration storage with stable registration IDs, full IPC operations, heartbeat refresh, GUI state broadcasting, pipe-session cleanup, and PID/start-time owner cleanup. Kept Caddyfile parsing, real Caddy runtime reloads, durable persistence, and full GUI pages out of scope per plan. Validation passed: `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64`, `./build.ps1`, and `dotnet format Cadder.slnx --verify-no-changes`. Two-session disconnect behavior is covered by named-pipe integration tests rather than a manual GUI smoke. - -Independent review found blocking issues before final handoff: subscription ordering could replay pre-initial deltas after the initial snapshot, malformed register payloads could violate owner/session invariants and evade disconnect cleanup, owner probe process-exit races were treated as Unknown instead of Dead, and lifecycle startup did not stop IPC if owner watcher start failed. Reopened task for fixes. - -Addressed independent review findings: serialized GUI broadcaster subscription setup with publish delivery so deltas cannot queue ahead of initial snapshot creation; added register invariant validation for stable registration ID and matching shim owner nonce; classified process disappearance during StartTime reads as Dead while keeping access failures Unknown; and added lifecycle rollback so IPC stops if owner watcher startup fails. Added regression tests for all four cases. Final validation passed: daemon targeted tests, full solution tests, `./build.ps1`, `dotnet format Cadder.slnx --verify-no-changes`, and `git diff --check`. - - -## Final Summary - - -Summary: -- Expanded shared IPC/domain contracts with update, list, toggle, heartbeat, GUI subscription messages, lifecycle timestamps, and preserved shim command line metadata. -- Replaced the basic transient store with owner-aware in-memory operations keyed by stable registration IDs, guarded by validated shim session nonce and owner process identity. -- Added daemon-side GUI state broadcasting with ordered initial snapshots, process-owner watcher cleanup, stable named-pipe session ownership cleanup, lifecycle rollback on watcher startup failure, and shim heartbeat flow. -- Wired the tray daemon host to own the store, endpoint, broadcaster, and owner watcher lifecycle, and updated architecture documentation for TASK-1.4 semantics. - -Validation: -- `dotnet test tests/Cadder.Daemon.Tests/Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed: 42 tests. -- `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed: 47 tests. -- `./build.ps1` passed with zero warnings/errors. -- `dotnet format Cadder.slnx --verify-no-changes` passed. -- `git diff --check` passed. - -Risks / follow-ups: -- Domain parsing, composed Caddy config reloads, durable persistence, real runtime management, and full GUI surfaces remain intentionally deferred to later tasks. - diff --git a/backlog/tasks/task-1.5 - Compose-and-reload-Caddy-config-from-registrations.md b/backlog/tasks/task-1.5 - Compose-and-reload-Caddy-config-from-registrations.md deleted file mode 100644 index 98fde9d..0000000 --- a/backlog/tasks/task-1.5 - Compose-and-reload-Caddy-config-from-registrations.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -id: TASK-1.5 -title: Compose and reload Caddy config from registrations -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 11:42' -updated_date: '2026-06-09 16:56' -labels: [] -dependencies: - - TASK-1.4 -references: - - 'D:\Projects\Selleo\smarketing\apps\reverse-proxy\Caddyfile' -modified_files: - - src/Cadder.Contracts/DomainContracts.cs - - src/Cadder.Daemon/DaemonBoundaries.cs - - src/Cadder.Daemon/CadderIpcEndpoint.cs - - src/Cadder.Daemon/CaddyAdaptedConfig.cs - - src/Cadder.Daemon/CaddyConfigCoordinator.cs - - src/Cadder.Daemon/CaddyJsonConfigComposer.cs - - src/Cadder.Daemon/CaddyJsonConfigInspector.cs - - src/Cadder.Daemon/ProcessCaddyfileConfigAdapter.cs - - src/Cadder.Daemon/ProcessRealCaddyRuntimeAdapter.cs - - src/Cadder.Tray.WinUI/App.xaml.cs - - tests/Cadder.Contracts.Tests/ContractShapeTests.cs - - tests/Cadder.Daemon.Tests/CaddyConfigCoordinatorTests.cs - - tests/Cadder.Daemon.Tests/CaddyfileConfigAdapterTests.cs - - docs/ARCHITECTURE.md -parent_task_id: TASK-1 -priority: high -ordinal: 6000 ---- - -## Description - - -Convert active registrations into one effective Caddy configuration and apply it to the real Caddy runtime. The composition must preserve each source instance as a group while producing a valid runtime config. - - -## Acceptance Criteria - -- [x] #1 Cadder extracts hostnames/domains from registered Caddyfiles and associates every domain with its source entrypoint instance. -- [x] #2 The smarketing reverse-proxy Caddyfile registers api.smarketing.localhost, app.smarketing.localhost, mailbox.smarketing.localhost, and storage.smarketing.localhost as domains from one instance. -- [x] #3 Disabling a domain removes or neutralizes only that domain from the effective config while preserving the rest of the instance. -- [x] #4 Conflicting domains across instances are detected and reported with source paths before reload. -- [x] #5 Invalid composed config does not replace the last known good running Caddy config. -- [x] #6 Successful changes reload the real Caddy runtime without restarting unrelated shim processes. - - -## Implementation Plan - - -## Goal -Build the daemon-side pipeline that turns active entrypoint registrations into one effective Caddy JSON config, validates it, detects conflicts, preserves source grouping, and reloads the configured real Caddy runtime boundary only after validation succeeds. - -## Scope -- Use Caddy's own adapter command shape, `caddy adapt --config --adapter caddyfile`, as the source of truth for interpreting registered Caddyfiles. -- Extract domains from adapted Caddy JSON host matchers and associate each domain with the source entrypoint registration. -- Support the referenced smarketing reverse-proxy Caddyfile and register api.smarketing.localhost, app.smarketing.localhost, mailbox.smarketing.localhost, and storage.smarketing.localhost as domains from one instance. -- Compose deterministic effective Caddy JSON from active registrations while preserving each source instance's routes as a contiguous group in registration-id order. -- Allow a single domain to be disabled by filtering only that host matcher while preserving the rest of the source instance. -- Detect conflicting active domains across registrations before runtime validation or reload and report the domain plus source config paths. -- Keep the last known good runtime config state when adaptation, composition, validation, or reload fails. -- Trigger successful runtime reload through a narrow process runtime boundary that calls the configured real Caddy command, currently `caddy-real`, without restarting or touching shim processes. -- Keep full real Caddy binary resolution, managed process lifecycle, health tracking, and packaging/PATH installation behavior in TASK-1.6 and TASK-1.11. - -## Key Files And Modules -- `src/Cadder.Contracts/DomainContracts.cs` -- `src/Cadder.Daemon/DaemonBoundaries.cs` -- `src/Cadder.Daemon/CadderIpcEndpoint.cs` -- `src/Cadder.Daemon/CaddyAdaptedConfig.cs` -- `src/Cadder.Daemon/CaddyConfigCoordinator.cs` -- `src/Cadder.Daemon/CaddyJsonConfigComposer.cs` -- `src/Cadder.Daemon/CaddyJsonConfigInspector.cs` -- `src/Cadder.Daemon/ProcessCaddyfileConfigAdapter.cs` -- `src/Cadder.Daemon/ProcessRealCaddyRuntimeAdapter.cs` -- `src/Cadder.Tray.WinUI/App.xaml.cs` -- `tests/Cadder.Contracts.Tests/ContractShapeTests.cs` -- `tests/Cadder.Daemon.Tests/CaddyConfigCoordinatorTests.cs` -- `tests/Cadder.Daemon.Tests/CaddyfileConfigAdapterTests.cs` -- `docs/ARCHITECTURE.md` - -## Implementation Steps -1. Add JSON-friendly config state and diagnostics DTOs for GUI snapshots. -2. Extend the real runtime boundary with validate and reload operations that accept composed JSON. -3. Add a Caddyfile adapter boundary that runs `caddy-real adapt --config --adapter caddyfile` and returns structured JSON or diagnostics. -4. Extract registered domains from adapted Caddy JSON host matchers, preserving raw and canonical lowercase host names. -5. Wire registration and update flows so the daemon populates `RegisteredDomains` from adapted source config metadata. -6. Compose effective Caddy JSON from active registrations, grouping each registration's routes contiguously and filtering inactive domain host matchers. -7. Detect active-domain conflicts before validation/reload and report source config paths. -8. Maintain last-known-good config hash and successful reload time across failed adaptation, composition, validation, or reload attempts. -9. Use a process runtime adapter for `caddy-real validate --config ` and `caddy-real reload --config ` without owning process lifecycle. -10. Surface config apply status and diagnostics in GUI state snapshots. -11. Update architecture documentation with TASK-1.5 semantics and the TASK-1.6/TASK-1.11 scope boundary. - -## Validation -- Run focused daemon tests for adapt-based extraction, smarketing source support, composition, conflicts, disabled domains, invalid-config protection, and process validation. -- Run contract serialization tests for new shared DTOs. -- Run `dotnet test tests/Cadder.Daemon.Tests/Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet test tests/Cadder.Contracts.Tests/Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `./build.ps1` from the repository root. -- Run `dotnet format Cadder.slnx --verify-no-changes`. - -## Scope Boundaries -- Do not implement full real Caddy executable resolution, runtime process start/stop ownership, health polling, or configured runtime settings UI here; TASK-1.6 owns that. -- Do not implement global PATH/Scoop packaging or `caddy.exe` shim installation here; TASK-1.11 owns that. -- Do not build tray popup, registry pages, logs UI, or durable persistence in this task. -- Do not silently change acceptance criteria. If advanced Caddy JSON merging beyond host-routed HTTP servers becomes necessary, pause and ask whether to expand scope or create a follow-up. - -## Runtime And Future Task Context -The user's local operating model is that the real global Caddy executable has been renamed to `caddy-real.exe` and is currently invoked as `caddy-real`. Future runtime/configuration work should let Cadder settings point at the real Caddy path or command. Future packaging work should install Cadder's PATH-facing shim globally with a command such as `scoop shim add caddy "path_to_cadder_caddy.exe"`. - -## Risks And Notes -- Caddyfile interpretation is delegated to Caddy itself through `adapt`; Cadder only inspects and filters the adapted JSON. -- The supported merge scope is host-routed HTTP servers. More advanced Caddy JSON merging should be handled as a follow-up if needed. -- Validation/reload must remain atomic from Cadder's perspective: invalid new config must not replace the last known good running config state. -- Conflict detection must happen before reload so one bad registration cannot disrupt unrelated registrations. -- Reload sequencing must not depend on shim process restarts; registration IPC sessions remain untouched. -- The configured real Caddy command/path must not resolve back to Cadder's own shim; full resolver safety remains TASK-1.6/TASK-1.11. - - -## Implementation Notes - - -Plan approved by user and recorded before implementation. Additional user-provided runtime context: the real global Caddy command has been renamed to `caddy-real.exe`/`caddy-real`; future runtime settings should point to the real Caddy path or command, and future packaging should install Cadder's shim globally with `scoop shim add caddy "path_to_cadder_caddy.exe"`. - -Implementation direction updated after user feedback: do not hand-parse Caddyfile syntax as the source of truth. Use the real Caddy adapter command shape caddy adapt --config --adapter caddyfile to convert each source Caddyfile into structured JSON, then extract host matchers and compose/filter the effective runtime JSON from that adapted representation. Keep TASK-1.6 real runtime ownership separate; TASK-1.5 may use a narrow adapter-command boundary with caddy-real as the current local default when available. - -Implemented TASK-1.5 using Caddy's own adapter path instead of hand-parsing Caddyfiles. Added daemon-side ProcessCaddyfileConfigAdapter for caddy-real adapt --config --adapter caddyfile, JSON host extraction, deterministic JSON composition, per-domain host filtering, conflict diagnostics, last-known-good config state, and process-based real Caddy validate/reload calls. Wired the coordinator into IPC registration/update/toggle/unregister flows and WinUI daemon startup. Updated architecture documentation. Validation run: dotnet test tests/Cadder.Daemon.Tests/Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64, dotnet test tests/Cadder.Contracts.Tests/Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64, dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64, ./build.ps1, and dotnet format Cadder.slnx --verify-no-changes all passed. - -Closeout validation on 2026-06-09: all acceptance criteria were already checked and re-verified against the implemented daemon config pipeline. Fresh checks passed: `dotnet test tests/Cadder.Daemon.Tests/Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` (49/49), `dotnet test tests/Cadder.Contracts.Tests/Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` (6/6), `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64` (55/55), `./build.ps1` (0 warnings, 0 errors), and `dotnet format Cadder.slnx --verify-no-changes`. No task-specific Definition of Done items are defined for this task. - - -## Final Summary - - -Implemented the daemon-side Caddy config composition pipeline for active registrations. Cadder now adapts registered Caddyfiles through the real Caddy adapter command path, extracts host domains with source registration context, detects active-domain conflicts before reload, composes deterministic effective JSON while preserving grouped source routes, supports per-domain disabling without dropping the rest of an instance, and preserves last-known-good runtime state when adaptation, validation, composition, or reload fails. - -The implementation also wires config apply state into IPC/tray snapshots, adds process-backed real Caddy validate/reload boundaries without owning or restarting unrelated shim processes, covers the referenced smarketing reverse-proxy domains from one registration, and updates architecture documentation with the TASK-1.5 behavior and boundaries left for TASK-1.6/TASK-1.11. - -Validation run on 2026-06-09: `dotnet test tests/Cadder.Daemon.Tests/Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed 49/49; `dotnet test tests/Cadder.Contracts.Tests/Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed 6/6; `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed 55/55; `./build.ps1` passed with 0 warnings and 0 errors; `dotnet format Cadder.slnx --verify-no-changes` passed. No additional risks were found during closeout; full runtime ownership and PATH/shim packaging remain intentionally scoped to TASK-1.6 and TASK-1.11. - diff --git a/backlog/tasks/task-1.6 - Manage-real-Caddy-runtime-and-health.md b/backlog/tasks/task-1.6 - Manage-real-Caddy-runtime-and-health.md deleted file mode 100644 index 1ff8bcb..0000000 --- a/backlog/tasks/task-1.6 - Manage-real-Caddy-runtime-and-health.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -id: TASK-1.6 -title: Manage real Caddy runtime and health -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 11:42' -updated_date: '2026-06-09 17:31' -labels: [] -dependencies: - - TASK-1.5 -modified_files: - - docs/ARCHITECTURE.md - - src/Cadder.Contracts/DomainContracts.cs - - src/Cadder.Daemon/CaddyConfigCoordinator.cs - - src/Cadder.Daemon/DaemonBoundaries.cs - - src/Cadder.Daemon/ProcessRealCaddyRuntimeAdapter.cs - - src/Cadder.Tray.WinUI/App.xaml.cs - - src/Cadder.Tray.WinUI/MainPage.xaml - - src/Cadder.Tray.WinUI/MainPage.xaml.cs - - tests/Cadder.Contracts.Tests/ContractShapeTests.cs - - tests/Cadder.Daemon.Tests/CaddyConfigCoordinatorTests.cs - - tests/Cadder.Daemon.Tests/RealCaddyRuntimeAdapterTests.cs -parent_task_id: TASK-1 -priority: high -ordinal: 7000 ---- - -## Description - - -Own the real Caddy runtime behind Cadder without confusing it with the caddy.exe shim. Cadder should be able to start, reload, observe, and stop its managed Caddy process or connect to a configured runtime path safely. - - -## Acceptance Criteria - -- [x] #1 Cadder resolves the real Caddy executable without recursively invoking its own caddy.exe shim. -- [x] #2 The daemon starts the real Caddy runtime when needed and tracks its process ID, admin endpoint, version, and health. -- [x] #3 With zero active domains, Cadder reaches a defined idle runtime state that the tray and panel can display. -- [x] #4 Runtime failures surface structured errors to the registry and GUI without crashing the daemon. -- [x] #5 Quitting the daemon stops only Cadder-owned Caddy runtime processes and does not kill unrelated user Caddy processes. - - -## Implementation Plan - - -## Goal -Turn the current thin `caddy-real validate/reload/version` boundary into a safe daemon-owned real Caddy runtime manager. Cadder must resolve a real Caddy executable without selecting its own PATH-facing `caddy.exe` shim, start and observe only the runtime it owns, expose runtime health to GUI snapshots, enter a defined idle state when there are no active domains, and stop only Cadder-owned runtime processes on daemon quit. - -## Scope -- Build runtime ownership in the daemon, not in the shim. -- Preserve the TASK-1.5 config composition pipeline and keep `validate`/`reload` semantics atomic from Cadder's perspective. -- Replace the local `caddy-real` command default with a resolver-backed runtime identity while keeping `caddy-real` as the current development fallback. -- Track process metadata for Cadder-owned runtime instances: PID, process start time when available, admin endpoint, resolved binary identity, version, health, and structured diagnostics. -- Define and surface an idle runtime state for zero active domains that the tray/panel can render. -- Report runtime lifecycle/health failures through contracts and GUI state without crashing the daemon. -- Stop only the process Cadder started and still identifies as its owned runtime. Do not enumerate and kill unrelated `caddy.exe` or `caddy-real.exe` processes. - -## Key Files And Modules -- `src/Cadder.Contracts/DomainContracts.cs` -- `src/Cadder.Daemon/DaemonBoundaries.cs` -- `src/Cadder.Daemon/ProcessRealCaddyRuntimeAdapter.cs` -- `src/Cadder.Daemon/CaddyConfigCoordinator.cs` -- `src/Cadder.Daemon/CadderIpcEndpoint.cs` -- `src/Cadder.Daemon/DaemonLifecycle.cs` -- `src/Cadder.Daemon/RegistrationOwnerWatcher.cs` -- `src/Cadder.Tray.WinUI/App.xaml.cs` -- `src/Cadder.Tray.WinUI/MainPage.xaml` -- `src/Cadder.Tray.WinUI/MainPage.xaml.cs` -- `tests/Cadder.Contracts.Tests/ContractShapeTests.cs` -- `tests/Cadder.Daemon.Tests/CaddyConfigCoordinatorTests.cs` -- `tests/Cadder.Daemon.Tests/CaddyfileConfigAdapterTests.cs` -- `tests/Cadder.Daemon.Tests/DaemonLifecycleTests.cs` -- `tests/Cadder.Daemon.Tests/DaemonBoundaryTests.cs` -- `docs/ARCHITECTURE.md` - -## Implementation Steps -1. Extend shared runtime contracts with JSON-friendly fields for idle/running/unhealthy state, owned process ID, admin endpoint, runtime diagnostics, and enough binary/process identity for GUI and tests. -2. Add a real Caddy executable resolver in `Cadder.Daemon` that accepts a configured path/command, supports the current `caddy-real` development command, normalizes candidate paths, records file identity where practical, and rejects candidates that resolve to Cadder's own `caddy.exe` shim. -3. Refactor `ProcessRealCaddyRuntimeAdapter` into a resolver-backed process runtime boundary that can inspect the resolved binary, run version/validate/reload, and surface structured operation failures instead of returning only `NotResolved`. -4. Add a daemon-owned runtime manager implementing the existing shutdown boundary and the runtime adapter boundary. It should idempotently start Caddy when active domains need a runtime, remember only the process it started, probe/admin-observe that runtime, and expose a stable state snapshot. -5. Update `CaddyConfigCoordinator.ApplyAsync` so active composed configs ensure the owned runtime is running before validation/reload, while zero active domains produce the defined idle runtime/config state instead of treating absence of domains as an error. -6. Wire the owned runtime manager into `App.xaml.cs` so `DaemonLifecycleHost.ShutdownAsync` calls the real owned-runtime stop path instead of `NoopCadderOwnedRuntime`. -7. Route runtime lifecycle and health diagnostics through `CadderIpcEndpoint`/GUI snapshots, keeping registration and config diagnostics distinct but visible in the same snapshot model. -8. Update the minimal WinUI panel to display runtime status, PID, admin endpoint, version, idle state, and the latest runtime diagnostic using the existing quiet dashboard style. -9. Update architecture documentation with resolver precedence, shim exclusion, owned process lifecycle, idle behavior, health reporting, and the boundary left for TASK-1.11 packaging/PATH installation. - -## Validation -- Add focused daemon unit tests with fakes/spies for resolver selection, shim rejection, owned runtime start, PID/admin/version/health tracking, idle transition with zero active domains, structured runtime failure reporting, and shutdown stopping only the owned process handle. -- Add contract serialization tests for new runtime state and diagnostics fields. -- Keep real `caddy-real` integration tests optional/skipped when the command is unavailable; the current environment has `caddy-real.exe` available through Scoop, but tests must remain deterministic without it. -- Run `dotnet test tests/Cadder.Daemon.Tests/Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet test tests/Cadder.Contracts.Tests/Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `./build.ps1` from the repository root. -- Run `dotnet format Cadder.slnx --verify-no-changes`. -- For UI-facing changes, run the tray app when practical and inspect the panel/browser automation path available for the environment; do not claim live UI verification if it cannot be completed. - -## Assumptions -- TASK-1.5 remains the source of truth for Caddyfile adaptation, domain extraction, composed JSON validation, and reload ordering. -- The local development real Caddy command is `caddy-real`/`caddy-real.exe`. -- `caddy.exe` may later be installed as Cadder's PATH-facing shim by TASK-1.11, so TASK-1.6 resolver tests must simulate shim and real-binary candidates even when the current PATH does not contain `caddy.exe`. -- The idle state for zero active domains is a normal daemon runtime state, not a daemon shutdown trigger. - -## Risks And Scope Boundaries -- Do not implement global PATH/Scoop installation or `caddy.exe` shim packaging here; TASK-1.11 owns that. -- Do not build full logs capture or logs UI; TASK-1.7 and TASK-1.10 own those surfaces. -- Do not kill processes by name. Runtime shutdown must use stored Cadder-owned process identity/handle only. -- Avoid broad UI redesign; only add the runtime state needed for TASK-1.6 visibility. -- If Caddy admin endpoint probing requires config shape changes beyond the current composed JSON scope, pause and ask whether to expand TASK-1.6 or create a follow-up task. - - -## Implementation Notes - - -Plan approved by the user and recorded before implementation. Local prerequisite check during planning found .NET SDK 10.0.204 and `caddy-real.exe` available at `D:\Projects\Internal\scoop\shims\caddy-real.exe`; `caddy.exe` is not currently on PATH, so shim-recursion safety should be covered with deterministic resolver tests. - -Implemented TASK-1.6 runtime ownership path. Added JSON-friendly runtime contract fields for idle state, owned process identity, admin endpoint, version, and structured runtime diagnostics. Added resolver-backed ProcessRealCaddyRuntimeAdapter that resolves caddy-real/CADDER_CADDY_REAL_COMMAND, rejects known Cadder shim paths, starts a Cadder-owned Caddy process for active config, tracks its handle/metadata, and stops only that stored owned process on idle or daemon shutdown. Updated CaddyConfigCoordinator so empty active composition enters Idle instead of reloading `{}` and so runtime startup failures become structured config/runtime diagnostics. Wired WinUI daemon shutdown to the real runtime adapter and added a minimal runtime status panel. Updated architecture docs. Validation run: dotnet test tests/Cadder.Contracts.Tests/Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64; dotnet test tests/Cadder.Daemon.Tests/Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64; dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64; ./build.ps1; dotnet format Cadder.slnx --verify-no-changes. Live WinUI launch was partially verified: PowerShell and Win32 automation confirmed a visible Cadder top-level window from the built exe, but UIA child inspection was unstable with pywinauto COM errors, so panel child text was not fully machine-verified. The temporary verification process was stopped. - -Follow-up UI verification after adding the `winui-ui-testing` skill: added explicit AutomationProperties.AutomationId values to the runtime panel fields and Quit button in MainPage.xaml. Rebuilt the WinUI app successfully and reran dotnet format verification plus full solution tests. `winapp ui screenshot` captured the Cadder window and visually confirmed the runtime panel renders `Resolved`, admin endpoint, version, config status, registration count, diagnostic field, and Quit daemon button without visible overlap or clipping. `winapp ui inspect` / `wait-for` still reported only a disabled top-level pane or stale_element for this WinUI window, so child-element assertions through winapp could not be completed in this environment. - - -## Comments - - -author: @agent -created: 2026-06-09 16:31 ---- -Future runtime context from user approval of TASK-1.5: the real global Caddy executable has been renamed to `caddy-real.exe` and is currently expected to be invoked as `caddy-real`. Runtime configuration should allow Cadder settings to point at the real Caddy path or command, and must not resolve back to Cadder's own PATH-facing shim. ---- - - -## Final Summary - - -Implemented real Caddy runtime ownership for Cadder. The daemon now resolves the real Caddy executable through a resolver-backed runtime boundary, rejects known Cadder `caddy.exe` shim paths, validates composed JSON, starts and tracks only the Cadder-owned runtime process when active domains need it, exposes PID/admin endpoint/version/runtime diagnostics in GUI snapshots, and stops only the stored owned process on idle or daemon shutdown. - -The config coordinator now treats zero active domains as an explicit idle config/runtime state instead of reloading an empty `{}` config. Runtime start/version/resolve failures are surfaced as structured diagnostics without crashing daemon flows. The WinUI tray host is wired to the real owned-runtime stop path and the panel displays runtime status, process, admin endpoint, version, config state, registration count, and latest diagnostics. Architecture documentation and focused contract/daemon tests were updated, including deterministic fakes for shim rejection, owned-process stop behavior, idle transition, and startup failure reporting. - -Additional follow-up from closeout: added root `AGENTS.md` guidance for future agents, including required routing to `winui-*`, `dotnet-*`, Windows desktop, Backlog.md, commit, and `winui-ui-testing` skills. Added explicit AutomationId values to the runtime panel controls and retained the user-provided WinApp SDK BuildTools connector package change so future WinUI test/debug flows can use `winapp ui`. - -Validation run: `dotnet test tests/Cadder.Contracts.Tests/Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`, `dotnet test tests/Cadder.Daemon.Tests/Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`, `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64`, `./build.ps1`, `dotnet build src/Cadder.Tray.WinUI/Cadder.Tray.WinUI.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`, and `dotnet format Cadder.slnx --verify-no-changes` passed. `winapp ui screenshot` visually confirmed the Cadder runtime panel renders correctly without visible overlap or clipping. `winapp ui inspect`/`wait-for` still saw only a disabled top-level pane or stale element for this WinUI window in this environment, so child-level WinApp assertions were not claimed as passing. - diff --git a/backlog/tasks/task-1.7 - Implement-per-domain-log-capture-and-query.md b/backlog/tasks/task-1.7 - Implement-per-domain-log-capture-and-query.md deleted file mode 100644 index c4a9907..0000000 --- a/backlog/tasks/task-1.7 - Implement-per-domain-log-capture-and-query.md +++ /dev/null @@ -1,163 +0,0 @@ ---- -id: TASK-1.7 -title: Implement per-domain log capture and query -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 11:43' -updated_date: '2026-06-09 21:06' -labels: [] -dependencies: - - TASK-1.5 - - TASK-1.6 -modified_files: - - src/Cadder.Contracts/DomainContracts.cs - - src/Cadder.Contracts/IpcContracts.cs - - src/Cadder.Contracts/IpcPipeProtocol.cs - - src/Cadder.Daemon/DaemonBoundaries.cs - - src/Cadder.Daemon/BoundedCaddyLogSink.cs - - src/Cadder.Daemon/CaddyLogRedactor.cs - - src/Cadder.Daemon/CaddyRuntimeLogParser.cs - - src/Cadder.Daemon/InMemoryCaddyLogStore.cs - - src/Cadder.Daemon/CadderIpcEndpoint.cs - - src/Cadder.Daemon/NamedPipeDaemonIpcServer.cs - - src/Cadder.Daemon/ProcessRealCaddyRuntimeAdapter.cs - - src/Cadder.Daemon/CaddyConfigCoordinator.cs - - src/Cadder.Daemon/CaddyJsonConfigComposer.cs - - src/Cadder.Tray.WinUI/App.xaml.cs - - tests/Cadder.Contracts.Tests/ContractShapeTests.cs - - tests/Cadder.Daemon.Tests/CaddyLogStoreTests.cs - - tests/Cadder.Daemon.Tests/CadderIpcEndpointTests.cs - - tests/Cadder.Daemon.Tests/NamedPipeDaemonIpcServerTests.cs - - tests/Cadder.Daemon.Tests/RealCaddyRuntimeAdapterTests.cs - - tests/Cadder.Daemon.Tests/CaddyConfigCoordinatorTests.cs - - docs/ARCHITECTURE.md -parent_task_id: TASK-1 -priority: medium -ordinal: 8000 ---- - -## Description - - -Capture and expose logs in a way the GUI can filter per domain. Logs should help the user understand which source instance and hostname produced a request or runtime error. - - -## Acceptance Criteria - -- [x] #1 Cadder stores or streams recent Caddy runtime logs with timestamps, severity, domain, source instance, and raw message fields. -- [x] #2 Each registered domain can query its own recent log lines without loading all logs into the GUI upfront. -- [x] #3 Log capture survives config reloads and clearly marks reload, validation, and runtime errors. -- [x] #4 Sensitive values such as tokens, environment secrets, and full command arguments are redacted before logs are exposed in diagnostics or GUI views. -- [x] #5 A bounded retention policy prevents unbounded disk or memory growth. - - -## Implementation Plan - - -## Goal -Implement daemon-side per-domain Caddy log capture and query so future GUI surfaces can request recent log lines lazily without embedding log payloads in `GuiStateSnapshot`. - -## Scope -- Capture recent Cadder-owned Caddy runtime output and runtime-control operation output with structured timestamps, severity, stream identity, domain/source attribution when known, and redacted raw message text. -- Expose recent logs through explicit IPC query contracts keyed by the existing `LogStreamIdentity`, not by GUI snapshot payloads. -- Keep log storage bounded in memory with clear retention and cursor gap metadata. -- Preserve the TASK-1.10 boundary: implement backend capture/query contracts and minimal wiring only, not the full logs UI. - -## Key Files And Modules -- `src/Cadder.Contracts/DomainContracts.cs` -- `src/Cadder.Contracts/IpcContracts.cs` -- `src/Cadder.Contracts/IpcPipeProtocol.cs` -- `src/Cadder.Daemon/DaemonBoundaries.cs` -- `src/Cadder.Daemon/CadderIpcEndpoint.cs` -- `src/Cadder.Daemon/NamedPipeDaemonIpcServer.cs` -- `src/Cadder.Daemon/ProcessRealCaddyRuntimeAdapter.cs` -- `src/Cadder.Daemon/CaddyConfigCoordinator.cs` -- `src/Cadder.Daemon/CaddyJsonConfigComposer.cs` -- `src/Cadder.Tray.WinUI/App.xaml.cs` -- `tests/Cadder.Contracts.Tests/ContractShapeTests.cs` -- `tests/Cadder.Daemon.Tests/CadderIpcEndpointTests.cs` -- `tests/Cadder.Daemon.Tests/NamedPipeDaemonIpcServerTests.cs` -- `tests/Cadder.Daemon.Tests/RealCaddyRuntimeAdapterTests.cs` -- `tests/Cadder.Daemon.Tests/CaddyConfigCoordinatorTests.cs` -- `docs/ARCHITECTURE.md` - -## Implementation Steps -1. Extend shared contracts with log DTOs and query DTOs: `CaddyLogEntry`, severity, attribution kind, stream status, opaque cursor metadata, and `QueryCaddyLogsRequest` / `QueryCaddyLogsResponse`. Use the existing `LogStreamIdentity` as the canonical selector and attribution object to avoid duplicating stream identity fields. -2. Add daemon logging boundaries in `DaemonBoundaries.cs`: `ICaddyLogSink`, `ICaddyLogStore`, and a shared redaction boundary. Implement a bounded in-memory ring buffer with `TimeProvider`, stable monotonic sequence numbers, per-stream/global caps, age pruning, and response metadata such as `NextCursor`, `HasGap`, `HasMoreBefore`, and `TruncatedByRetention`. -3. Add a central `CaddyLogRedactor` and route both log entries and runtime/config diagnostics through it before they are stored or exposed. Full command arguments must not be returned in log or diagnostic surfaces; expose only safe summaries or placeholders. -4. Update `ProcessRealCaddyRuntimeAdapter` so the Cadder-owned long-lived `caddy run` process redirects stdout/stderr and drains both streams through background reader tasks. Give each owned runtime session a stable session identity, cancellation token source, and awaited cleanup/drain path on stop, restart, or process exit. -5. Feed process output into a bounded ingestion path so slow parsing/redaction/storage cannot block Caddy's stdout/stderr pipes. Record explicit dropped/overflow markers when retention or ingestion limits discard entries. -6. Capture validate/reload/version/start/stop operation stdout/stderr as structured runtime-control log entries, and keep existing structured diagnostics consistent with the same redacted messages. -7. Parse Caddy JSON log lines when possible to derive timestamp, severity, raw message, request host/domain, and operation context. For global runtime or unparsable lines, store entries with explicit unknown/null domain attribution rather than guessing. -8. Update the Cadder-owned composed Caddy JSON to enable controlled runtime/access logging to stdout/stderr in a shape Cadder can parse. Keep this limited to Cadder's generated output and avoid broad advanced Caddy JSON merging beyond the current TASK-1.5 composer scope. -9. Add `QueryCaddyLogsAsync` to `CadderIpcEndpoint` and dispatch it through `NamedPipeDaemonIpcServer`. The query should validate requested limits, filter by `LogStreamIdentity`, optional severity/time/cursor filters, and return stream lifecycle status so callers can distinguish empty, stale/removed, read-error, and retention-gap states. -10. Wire one shared log store/sink/redactor through the daemon composition root in `App.xaml.cs`, keeping `App.xaml.cs` as wiring only and daemon abstractions testable in isolation. -11. Update architecture documentation with the log capture pipeline, retention policy, redaction policy, domain attribution limits, IPC query contract, and TASK-1.10 UI boundary. - -## Validation -- Add contract shape tests for new log DTOs, query DTOs, cursor metadata, stream status, and IPC message type constants. -- Add daemon unit tests for bounded retention, per-stream filtering, cursor gaps after wrap/prune, severity/time filters, and redaction of token-like values and full command arguments. -- Add runtime adapter tests with fake managed process stdout/stderr streams to verify reader lifecycle, session separation across restarts, stop/drain behavior, overflow markers, and validate/reload error capture. -- Add IPC endpoint and named-pipe tests for log query dispatch, empty result, unknown/stale stream status, cursor paging, and no logs embedded in `GuiStateSnapshot`. -- Add composer/coordinator tests that verify controlled logging config is present in Cadder-owned output and that `LogStreamIdentity` stays stable for canonical domains. -- Run `dotnet test tests\Cadder.Contracts.Tests\Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet test tests\Cadder.Daemon.Tests\Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `./build.ps1`. -- Run `dotnet format Cadder.slnx --verify-no-changes`. - -## Assumptions -- TASK-1.7 uses bounded in-memory retention only; durable log persistence across daemon restart is out of scope unless explicitly added later. -- Runtime-global, validation, reload, and unparsable log lines may have null/unknown domain attribution, but must still carry stream/source/operation context where available. -- Domain-scoped queries are per current user daemon context. If stronger authorization or cross-user isolation becomes relevant, handle it as a follow-up because the current IPC pipe is already per-user. -- TASK-1.10 owns the full log UI, lazy tailing UX, pause/auto-scroll behavior, copy actions, and severity filter controls. - -## Risks And Boundaries -- Redirecting long-lived Caddy stdout/stderr can deadlock if readers block; use bounded asynchronous ingestion and cheap reader loops. -- Heuristic domain attribution from log lines is imperfect; the implementation must be explicit about unknown attribution instead of inventing a domain. -- Redaction must be shared with diagnostics, not only log query responses, or the same sensitive value could leak through GUI snapshots. -- Avoid broad Caddy JSON merge work. If controlled logging requires composer behavior beyond the current generated `apps.http.servers.srv0` scope, pause and ask whether to expand the task or create a follow-up. -- Do not implement the final logs page UI in this task beyond any tiny contract/wiring needed to keep the existing placeholder honest. - - -## Implementation Notes - - -Plan approved by the user and recorded before implementation. Planning review incorporated code-mapper findings and adversarial review corrections: use `LogStreamIdentity` as the canonical query selector, include cursor/retention gap metadata, centralize redaction across logs and diagnostics, and model runtime reader lifecycle with bounded asynchronous ingestion. - -Started implementation. Verified repository instructions, `.local` exclusion, Backlog MCP availability, task status/assignee, approved plan, architecture notes, and current contract/runtime/IPC/test structure before code edits. Subagent execution was skipped because the available multi-agent tool requires an explicit user request to spawn agents. - -Implemented log contracts, IPC query dispatch, bounded in-memory log store, shared redaction, runtime stdout/stderr capture, runtime-control operation logging, controlled generated Caddy JSON logging, WinUI composition wiring, and architecture documentation. Focused validation currently passes: contracts tests, daemon tests, and WinUI project build. - -Acceptance criteria verified through implementation and tests. Validation passed: `dotnet test tests\Cadder.Contracts.Tests\Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`, `dotnet test tests\Cadder.Daemon.Tests\Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`, `dotnet build src\Cadder.Tray.WinUI\Cadder.Tray.WinUI.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`, `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64`, `./build.ps1`, and `dotnet format Cadder.slnx --verify-no-changes`. `git diff --check` is clean. - -Closeout verified: all acceptance criteria are checked, there are no task-specific Definition of Done items, the recorded implementation plan still reflects the delivered solution, documentation was updated, and validation was run after the final code/format changes. - - -## Final Summary - - -## Summary -- Added shared Caddy log contracts and lazy IPC query support keyed by `LogStreamIdentity`, including cursor, severity/time filters, stream status, and retention gap metadata. -- Implemented daemon-side bounded log capture with shared redaction, in-memory retention caps, runtime stdout/stderr parsing, runtime-control operation logs, and generated Caddy JSON logging to stdout in JSON format. -- Wired the shared log store/sink/redactor through the WinUI daemon composition root and updated architecture documentation for the capture/query pipeline and TASK-1.10 UI boundary. - -## Impact -- GUI and future diagnostics can query per-domain logs without loading logs into `GuiStateSnapshot`. -- Runtime, validation, reload, start/stop, and unparsable log lines are preserved with explicit attribution and redacted before exposure. -- Log retention is bounded by memory limits and reports cursor gaps when older entries have been pruned. - -## Validation -- `dotnet test tests\Cadder.Contracts.Tests\Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` -- `dotnet test tests\Cadder.Daemon.Tests\Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` -- `dotnet build src\Cadder.Tray.WinUI\Cadder.Tray.WinUI.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` -- `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64` -- `./build.ps1` -- `dotnet format Cadder.slnx --verify-no-changes` -- `git diff --check` - -## Risks And Follow-ups -- Domain attribution depends on Caddy JSON log host fields; unknown/unparsable lines intentionally stay on runtime streams instead of guessing a domain. -- Full logs UI behavior remains scoped to TASK-1.10. - diff --git a/backlog/tasks/task-1.8 - Build-Cadder-tray-popup-UI.md b/backlog/tasks/task-1.8 - Build-Cadder-tray-popup-UI.md deleted file mode 100644 index 5376897..0000000 --- a/backlog/tasks/task-1.8 - Build-Cadder-tray-popup-UI.md +++ /dev/null @@ -1,172 +0,0 @@ ---- -id: TASK-1.8 -title: Build Cadder tray popup UI -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 11:43' -updated_date: '2026-06-10 11:06' -labels: [] -dependencies: - - TASK-1.4 - - TASK-1.6 -references: - - .local/examples/gui/docs/images/openclawwindows1.png - - .local/examples/gui/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml - - .local/examples/gui/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs -modified_files: - - Cadder.slnx - - build.ps1 - - src/Cadder.Tray.WinUI/App.xaml.cs - - src/Cadder.Tray.WinUI/DaemonTrayPresence.cs - - src/Cadder.Tray.WinUI/Properties/AssemblyInfo.cs - - src/Cadder.Tray.WinUI/TrayPopupPositioner.cs - - src/Cadder.Tray.WinUI/TrayPopupState.cs - - src/Cadder.Tray.WinUI/TrayPopupWindow.xaml - - src/Cadder.Tray.WinUI/TrayPopupWindow.xaml.cs - - tests/Cadder.Tray.WinUI.Tests/Cadder.Tray.WinUI.Tests.csproj - - tests/Cadder.Tray.WinUI.Tests/TrayPopupPositionerTests.cs - - tests/Cadder.Tray.WinUI.Tests/TrayPopupStateBuilderTests.cs -parent_task_id: TASK-1 -priority: high -ordinal: 9000 ---- - -## Description - - -Build the compact tray popup for Cadder using only the interaction patterns from the OpenClaw reference: brand header, state rows, grouped entities, right-aligned toggles, flyouts, and direct actions. - - -## Acceptance Criteria - -- [x] #1 The tray popup opens from the tray icon and displays daemon state, Caddy runtime state, active entrypoint count, and active domain count. -- [x] #2 The popup groups visible domains under their entrypoint instance and shows source path or project name for each group. -- [x] #3 Domain toggles are visible in the popup and call the daemon toggle API without closing the daemon. -- [x] #4 The popup exposes Open panel, reload or refresh, and Quit daemon actions. -- [x] #5 Keyboard navigation works with Up, Down, Enter, Space, Escape, and focus-loss dismissal. - - -## Implementation Plan - - -## Goal -Build Cadder's compact tray popup UI from the tray icon using only the interaction patterns from the OpenClaw reference: brand header, state rows, grouped entities, right-aligned toggles, flyouts, and direct actions. - -## Scope -- Add a transient tray popup surface for the existing WinUI tray/daemon host. -- Keep `MainWindow` as the panel surface; implement the tray popup as a separate lightweight popup window. -- Open the popup from the existing tray icon path. -- Render daemon state, real Caddy runtime state, active entrypoint count, and active domain count from `GuiStateSnapshot`. -- Group visible domains under their entrypoint registration and show a source path or derived project name for each group. -- Show right-aligned domain toggles and update daemon state without closing the daemon. -- Expose `Open panel`, `Refresh` or `Reload`, and `Quit daemon` actions. -- Support keyboard navigation with Up, Down, Enter, Space, Escape, and focus-loss dismissal. -- Preserve light/dark/high-contrast-safe WinUI styling and stable UI automation identifiers. - -## Key Files And Modules -- `src/Cadder.Tray.WinUI/DaemonTrayPresence.cs` -- `src/Cadder.Tray.WinUI/App.xaml.cs` -- `src/Cadder.Tray.WinUI/MainWindow.xaml` -- `src/Cadder.Tray.WinUI/MainWindow.xaml.cs` -- New popup files under `src/Cadder.Tray.WinUI`, likely `TrayPopupWindow.xaml`, `TrayPopupWindow.xaml.cs`, and a tray popup state/builder service. -- `src/Cadder.Contracts/DomainContracts.cs` -- `src/Cadder.Contracts/IpcContracts.cs` -- `src/Cadder.Daemon/CadderIpcEndpoint.cs` -- `src/Cadder.Daemon/CaddyConfigCoordinator.cs` -- Existing tests under `tests/Cadder.Contracts.Tests` and `tests/Cadder.Daemon.Tests`; add focused tests where logic is testable without UI automation. -- OpenClaw references: `.local/examples/gui/docs/images/openclawwindows1.png`, `.local/examples/gui/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml`, `.local/examples/gui/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs`, `.local/examples/gui/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs`. - -## Implementation Steps -1. Add tray icon click plumbing in `DaemonTrayPresence`. Extend the existing `Shell_NotifyIcon` integration with a callback message, window message hook, and a raised event for left/right tray activation. Keep icon lifetime and disposal behavior intact. -2. Add a `TrayPopupWindow` as a separate transient WinUI window. Use OpenClaw's popup window pattern as the interaction reference: captionless/tool-window styling, work-area-aware placement near the cursor/tray, measured height with a vertical `ScrollViewer`, rounded popup region, focus-loss dismissal, and Escape dismissal. -3. Add pure popup positioning/sizing helpers or adapt the OpenClaw helper shape locally so monitor work-area and DPI math can be unit-tested without launching WinUI. -4. Add a tray popup state projection/builder layer. Map `GuiStateSnapshot` into compact rows for daemon state, runtime state, counts, entrypoint groups, domain rows, source path/project label, and diagnostics/flyout details. Keep this layer independent enough to unit-test with sample snapshots. -5. Compose popup UI using standard WinUI controls and theme resources. Use a compact brand header, status rows, grouped entrypoint/domain rows, right-aligned `ToggleSwitch` controls, separators, and flyout/detail rows matching the OpenClaw interaction model rather than inventing a new menu style. -6. Implement domain toggle behavior. Because there is no dedicated `ToggleDomainRequest`, use the existing daemon update path by sending an `UpdateEntrypointRequest` with the selected domain's `RegisteredDomain.ActivationState` changed while preserving other domains. Treat `ToggleEntrypointRequest` as entrypoint-level only unless the task scope is explicitly revised. -7. Wire direct actions: `Open panel` should show/activate `MainWindow`; `Refresh` should query the latest GUI state and rebuild the popup in place; `Reload` may reuse the existing update/apply path only if a clear daemon API exists, otherwise label it `Refresh`; `Quit daemon` should call the existing `App.QuitDaemonAsync` path. -8. Add keyboard behavior. Use a single key handler on the popup content root/list to move among enabled interactive rows with Up/Down, invoke focused buttons with Enter/Space, toggle focused switches with Space, and dismiss with Escape. Ensure focus-loss dismissal does not race with flyout interactions. -9. Add accessibility and automation IDs. Set `AutomationProperties.AutomationId` on popup root, action buttons, entrypoint rows, domain toggles, and status fields. Set accessible names for icon-only or compact controls and keep focus order logical. -10. Replace tight popup polling with an initial query plus explicit refresh. If feasible within scope, subscribe to GUI state changes while the popup is open and rebuild visible state on dispatcher updates; otherwise keep subscription as a follow-up candidate and ensure the visible refresh action works. -11. Keep `MainPage` and existing panel behavior stable. Do not redesign the full panel; TASK-1.9 owns panel overview and registry pages. - -## Validation -- Add focused unit tests for popup state projection from `GuiStateSnapshot`, including zero registrations, one entrypoint with domains, multiple entrypoints, inactive domains, runtime idle/running/unhealthy states, and diagnostics. -- Add unit tests for popup positioning/sizing helpers if introduced. -- Add or extend daemon/config tests for per-domain activation via `UpdateEntrypointRequest` if existing coverage is insufficient for the exact tray toggle behavior. -- Run `dotnet test tests\Cadder.Contracts.Tests\Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet test tests\Cadder.Daemon.Tests\Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet build src\Cadder.Tray.WinUI\Cadder.Tray.WinUI.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `./build.ps1` from the repository root. -- Run `dotnet format Cadder.slnx --verify-no-changes`. -- Launch the WinUI app and verify the popup visually with `winapp ui screenshot`. Use `winapp ui inspect`, `wait-for`, `invoke`, and keyboard interactions where the environment exposes child controls. If child-level UIA remains limited, record that limitation and keep screenshot evidence. -- Confirm the final working tree with `git status --short`. - -## Risks And Notes -- Tray icon activation is the highest-risk platform boundary because the current `DaemonTrayPresence` only adds an icon and tooltip; it does not yet handle callback messages. -- Domain toggles are not backed by a dedicated domain-toggle contract. The planned path is to use `UpdateEntrypointRequest` with an updated domain array. If this is considered insufficient for acceptance criterion #3, pause and ask whether to add a new daemon contract. -- `winapp ui inspect` had child-level limitations on this project in prior WinUI verification. Do not claim child-level automation passed unless it actually works in this task. -- Popup focus-loss dismissal can race with flyout windows; use a short delayed foreground-window check similar to the OpenClaw reference. -- Avoid broad panel redesign, durable persistence, logs UI, installer/PATH work, or new Backlog scope. Those belong to other tasks. - -## Assumptions -- `TASK-1.4` and `TASK-1.6` are complete and provide the IPC, GUI state, runtime state, and daemon lifecycle boundaries this popup consumes. -- The local machine can build and run the WinUI app with .NET 10, Windows App SDK, `winapp.exe`, and the existing x64/win-x64 project settings. -- Project-facing code, comments, task notes, and commit messages remain English; chat with the user remains Polish. - -## Closeout Fix Plan -- Guard the domain toggle rollback path so programmatic `ToggleSwitch.IsOn` restoration does not re-enter `DomainToggle_Toggled` and send an inverse update request. -- Keep the change scoped to `TrayPopupWindow.xaml.cs`; do not alter daemon IPC contracts or popup layout. -- Re-run focused WinUI tray tests, WinUI build, and formatting verification after the fix. Re-run broader checks if the edit touches shared behavior. - - -## Implementation Notes - - -Plan approved by user and recorded before implementation. Planning context: `.local` exists and is listed in `.git/info/exclude`; TASK-1.4 and TASK-1.6 are Done; .NET 10.0.204, `winapp.exe`, and `caddy-real.exe` were observed as available. No code changes were made during planning. - -Implemented tray icon callback plumbing via Shell_NotifyIcon callback messages and a Win32 window subclass. Added a reusable transient TrayPopupWindow that renders a compact Cadder tray surface with brand header, daemon/runtime/config summary, grouped entrypoint/domain rows, right-aligned domain ToggleSwitch controls, flyout detail rows, Open panel/Refresh/Quit daemon actions, keyboard navigation, Escape dismissal, and delayed focus-loss dismissal. - -Added TrayPopupStateBuilder and TrayPopupPositioner as testable non-visual helpers. Added Cadder.Tray.WinUI.Tests with focused coverage for empty state, multiple entrypoint/domain grouping, inactive domain state, failed diagnostics, and work-area-aware popup positioning. Synchronized Cadder.slnx and build.ps1 with the new test project. - -Domain toggles use the existing UpdateEntrypointRequest path by preserving the registration and replacing only the selected RegisteredDomain ActivationState. No new daemon IPC contract was added. - -Validation run: dotnet test tests\Cadder.Tray.WinUI.Tests\Cadder.Tray.WinUI.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64 passed 5/5 after formatting. dotnet test tests\Cadder.Contracts.Tests\Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64 passed 6/6. dotnet build src\Cadder.Tray.WinUI\Cadder.Tray.WinUI.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64 passed with 0 warnings. .\build.ps1 passed. dotnet format Cadder.slnx --verify-no-changes passed after applying dotnet format. - -Validation limitation: dotnet test tests\Cadder.Daemon.Tests\Cadder.Daemon.Tests.csproj and dotnet test Cadder.slnx both failed only in CaddyfileConfigAdapterTests.ProcessAdapter_WithSmarketingReverseProxy_UsesCaddyAdaptAndExtractsExpectedDomains because the local external smarketing Caddyfile now adapts an extra crm.smarketing.localhost host. This failure is outside TASK-1.8 changes and depends on D:\Projects\Selleo\smarketing\apps\reverse-proxy\Caddyfile. - -WinUI smoke verification: launched Cadder.Tray.WinUI, triggered the tray callback message, confirmed a foreground Cadder tray popup window, captured .local/verification/task-1.8-tray-popup.png, verified focus-loss dismissal keeps the process alive, verified Down moves focus to Open panel via .local/verification/task-1.8-keyboard-down.png, verified Enter invokes Open panel, and verified Escape dismisses the popup while leaving the daemon process alive. winapp ui child-level inspect/wait-for returned stale_element for the transient popup, so child-level UIA assertions were not claimed. - -Closeout validation rerun on 2026-06-09: `dotnet test tests\Cadder.Tray.WinUI.Tests\Cadder.Tray.WinUI.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed 5/5; `dotnet test tests\Cadder.Contracts.Tests\Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed 6/6; `dotnet build src\Cadder.Tray.WinUI\Cadder.Tray.WinUI.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed with 0 warnings; `.\build.ps1` passed; `dotnet format Cadder.slnx --verify-no-changes` exited 0 with a workspace-load warning. `dotnet test tests\Cadder.Daemon.Tests\Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` still has the known single external-fixture failure: `CaddyfileConfigAdapterTests.ProcessAdapter_WithSmarketingReverseProxy_UsesCaddyAdaptAndExtractsExpectedDomains` now sees `crm.smarketing.localhost` from `D:\Projects\Selleo\smarketing\apps\reverse-proxy\Caddyfile`. No TASK-1.8 code path appears involved in that failure. - -Closeout review found a blocker after the task was briefly marked Done: the domain toggle failure path programmatically reverted `ToggleSwitch.IsOn`, which can re-enter `DomainToggle_Toggled` and send a second inverse update request. Reopened TASK-1.8 to fix and revalidate before final Done status. - -Closeout blocker fixed: `TrayPopupWindow` now restores a rejected/failed domain toggle state by temporarily detaching `DomainToggle_Toggled` before setting `ToggleSwitch.IsOn`, preventing a reentrant inverse daemon update request. Post-fix validation: `dotnet test tests\Cadder.Tray.WinUI.Tests\Cadder.Tray.WinUI.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed 5/5; `dotnet build src\Cadder.Tray.WinUI\Cadder.Tray.WinUI.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed with 0 warnings; `dotnet format Cadder.slnx --verify-no-changes` exited 0 with a workspace-load warning; `.\build.ps1` passed. The known daemon external-fixture failure was not rerun after this WinUI-only fix; it remains recorded from closeout validation. - -Post-closeout test fixture correction: the daemon Caddyfile adapter test no longer reads `D:\Projects\Selleo\smarketing\apps\reverse-proxy\Caddyfile`. It now uses the repository-local fixture `tests\Cadder.Daemon.Tests\Fixtures\SmarketingReverseProxy.Caddyfile`, copied into the test output by `Cadder.Daemon.Tests.csproj`. Validation after the correction: `dotnet test tests\Cadder.Daemon.Tests\Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed 54/54; `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed 65/65; `dotnet format Cadder.slnx --verify-no-changes` exited 0 with a workspace-load warning. - - -## Comments - - -author: @agent -created: 2026-06-10 11:06 ---- -Superseded by the Rust cross-platform rewrite on 2026-06-10. The WinUI tray popup implementation was removed from the final codebase; future UI work lives in `cadder-tui` and related TUI tasks. ---- - - -## Final Summary - - -Implemented the Cadder tray popup as a transient WinUI window opened from the existing tray icon callback path. The popup renders daemon/runtime/config summary state, active entrypoint and domain counts, grouped entrypoint/domain rows with source labels, right-aligned domain toggles, detail flyouts, and direct Open panel, Refresh, and Quit daemon actions. - -Domain toggles use the existing `UpdateEntrypointRequest` path, preserving the registration and replacing only the selected domain activation state, so no new daemon IPC contract was introduced. A closeout review found that rejected/failed toggle rollback could re-enter the `Toggled` handler and send an inverse update request; that blocker was fixed by restoring `ToggleSwitch.IsOn` with the handler temporarily detached. Added testable popup state and positioning helpers plus a new `Cadder.Tray.WinUI.Tests` project, and synchronized `Cadder.slnx` and `build.ps1` with that project. - -Validation run for closeout: `dotnet test tests\Cadder.Tray.WinUI.Tests\Cadder.Tray.WinUI.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed 5/5 after the final fix; `dotnet test tests\Cadder.Contracts.Tests\Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed 6/6 before the WinUI-only fix; `dotnet build src\Cadder.Tray.WinUI\Cadder.Tray.WinUI.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed with 0 warnings after the final fix; `.\build.ps1` passed after the final fix; `dotnet format Cadder.slnx --verify-no-changes` exited 0 with a workspace-load warning after the final fix. WinUI smoke verification artifacts are stored under `.local/verification` and cover popup display, focus movement, Open panel invocation, Escape dismissal, and process survival. - -Known remaining validation limitation: `dotnet test tests\Cadder.Daemon.Tests\Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` and therefore the full solution test remain blocked by the existing external `smarketing` Caddyfile expectation mismatch, where the external file now adapts `crm.smarketing.localhost` in addition to the previously expected hosts. This is outside the TASK-1.8 tray popup scope. - -Post-closeout correction: the previous daemon/full-solution validation limitation was resolved by moving the smarketing-style Caddyfile adapter test to a repository-local fixture instead of depending on `D:\Projects\Selleo\smarketing\apps\reverse-proxy\Caddyfile`. After this correction, `dotnet test tests\Cadder.Daemon.Tests\Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed 54/54 and `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed 65/65. - diff --git a/backlog/tasks/task-1.9 - Build-Cadder-panel-overview-and-registry-pages.md b/backlog/tasks/task-1.9 - Build-Cadder-panel-overview-and-registry-pages.md deleted file mode 100644 index 6aa5cfa..0000000 --- a/backlog/tasks/task-1.9 - Build-Cadder-panel-overview-and-registry-pages.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -id: TASK-1.9 -title: Build Cadder panel overview and registry pages -status: Done -assignee: - - '@agent' -created_date: '2026-06-09 11:43' -updated_date: '2026-06-10 11:06' -labels: [] -dependencies: - - TASK-1.4 - - TASK-1.8 -references: - - .local/examples/gui/docs/images/openclawwindows2.png - - .local/examples/gui/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml - - .local/examples/gui/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml -modified_files: - - src/Cadder.Tray.WinUI/MainWindow.xaml - - src/Cadder.Tray.WinUI/MainWindow.xaml.cs - - src/Cadder.Tray.WinUI/PanelState.cs - - src/Cadder.Tray.WinUI/PanelStateStore.cs - - src/Cadder.Tray.WinUI/Pages/PanelPageBase.cs - - src/Cadder.Tray.WinUI/Pages/OverviewPage.cs - - src/Cadder.Tray.WinUI/Pages/InstancesPage.cs - - src/Cadder.Tray.WinUI/Pages/DomainsPage.cs - - src/Cadder.Tray.WinUI/Pages/LogsPage.cs - - src/Cadder.Tray.WinUI/Pages/SettingsPage.cs - - src/Cadder.Tray.WinUI/Pages/DiagnosticsPage.cs - - tests/Cadder.Tray.WinUI.Tests/PanelStateBuilderTests.cs - - tests/Cadder.Tray.WinUI.Tests/PanelStateStoreTests.cs - - src/Cadder.Tray.WinUI/MainPage.xaml - - src/Cadder.Tray.WinUI/MainPage.xaml.cs -parent_task_id: TASK-1 -priority: high -ordinal: 10000 ---- - -## Description - - -Build the main Cadder panel that shows the full daemon state. The panel should follow the OpenClaw-style Windows companion shell: searchable title bar, left navigation, overview card, and stacked cards with inline status. - - -## Acceptance Criteria - -- [x] #1 Opening the panel from the tray shows a single window with a top daemon overview card and navigation for Overview, Instances, Domains, Logs, Settings, and Diagnostics. -- [x] #2 The Instances view renders one card per caddy.exe entrypoint process with project path, config path, process status, age, domain count, and activation summary. -- [x] #3 Domains are grouped by entrypoint instance and can be expanded to reveal hostname, upstream target, enabled state, conflict state, and last error. -- [x] #4 Search or filtering can find domains and source paths across all registered instances. -- [x] #5 Empty, loading, disconnected, stale owner, config conflict, and runtime error states are represented inline without modal interruptions. - - -## Implementation Plan - - -## Goal -Build the main Cadder panel as the durable full-state companion window for the tray daemon. The panel should follow the OpenClaw Windows companion interaction pattern: searchable title bar, left navigation, overview card, stacked cards with inline status, and non-modal error/state representation. - -## Scope -- Replace the current minimal `MainWindow` + `MainPage` panel with a single WinUI shell window that owns title-bar search, navigation, and shared panel state. -- Keep the existing tray popup integration point: `TrayPopupWindow` continues to use `App.ActivateMainWindow()` for `Open panel`; the popup itself is not redesigned in this task. -- Add navigation destinations for Overview, Instances, Domains, Logs, Settings, and Diagnostics. -- Render state from `GuiStateSnapshot` through a shared panel read model instead of page-local control mutation. -- Show full daemon/runtime/config/registration state inline, including empty, loading, disconnected, stale owner, config conflict, and runtime error states. -- Add local search/filtering for domains, source working directories, config paths, and page commands. -- Add minimal upstream-target data/projection support needed by the Domains acceptance criteria; if a target cannot be extracted reliably from the adapted Caddy config, show a clear inline fallback such as `Not detected` instead of inventing data. -- Keep full per-domain log capture/tailing out of scope because TASK-1.7 and TASK-1.10 own that backend and UI surface. TASK-1.9 only adds the Logs navigation destination and an inline unavailable/coming state. -- Keep durable settings, installer/PATH behavior, and packaging out of scope. - -## Key Files And Modules -- `src/Cadder.Tray.WinUI/App.xaml.cs` -- `src/Cadder.Tray.WinUI/MainWindow.xaml` -- `src/Cadder.Tray.WinUI/MainWindow.xaml.cs` -- `src/Cadder.Tray.WinUI/MainPage.xaml` -- `src/Cadder.Tray.WinUI/MainPage.xaml.cs` -- New panel pages under `src/Cadder.Tray.WinUI/Pages`, likely `OverviewPage`, `InstancesPage`, `DomainsPage`, `LogsPage`, `SettingsPage`, and `DiagnosticsPage`. -- New shared panel state/projection code under `src/Cadder.Tray.WinUI`, likely `PanelState`, `PanelStateBuilder`, and a shell-level state store/service. -- `src/Cadder.Tray.WinUI/TrayPopupWindow.xaml.cs` only if the Open panel activation needs a tiny adjustment; avoid popup redesign. -- `src/Cadder.Contracts/DomainContracts.cs` and daemon config projection code if upstream target data must be exposed in `GuiStateSnapshot`. -- `src/Cadder.Daemon/CaddyJsonConfigComposer.cs`, `src/Cadder.Daemon/CaddyConfigCoordinator.cs`, and related tests if target extraction or domain diagnostics need contract support. -- `tests/Cadder.Tray.WinUI.Tests` for panel read-model tests, search/filter tests, and any non-visual helpers. -- `tests/Cadder.Contracts.Tests` and `tests/Cadder.Daemon.Tests` if shared DTOs or daemon projection behavior changes. -- OpenClaw references: `.local/examples/gui/docs/images/openclawwindows2.png`, `.local/examples/gui/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml`, `.local/examples/gui/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml`. - -## Implementation Steps -1. Baseline the current shell and references. Keep `MainWindow` as the single durable panel window, preserve Mica/system backdrop and hide-on-close behavior, and use OpenClaw only for interaction patterns rather than product logic or dependencies. -2. Introduce a panel state read model. Build a pure `PanelStateBuilder` from `GuiStateSnapshot` that computes daemon summary, runtime/config summary, instance rows, domain rows, diagnostics, search records, active counts, age labels, stale-owner heuristics, and conflict/error badges. -3. Define the stale owner heuristic in the panel layer using `LastHeartbeatUtc` age relative to a deterministic clock/time provider. Do not add arbitrary daemon state unless the implementation discovers that a contract change is necessary. -4. Address upstream target data for domain rows. First inspect the existing adapted Caddy JSON/config pipeline; if target extraction is available or can be added cleanly, expose it through the read model/contract with focused tests. If not reliably extractable for every route, surface `Not detected` inline for those rows while still satisfying the visible field requirement. -5. Replace the shell in `MainWindow.xaml` with a title bar/search/navigation layout modeled after OpenClaw: a pane toggle button, Cadder title/icon, `AutoSuggestBox` search, right-side status indicators, `NavigationView` with Overview, Instances, Domains, Logs, Settings, and Diagnostics, and a `Frame` content host. -6. Move shell-level navigation and command search into `MainWindow.xaml.cs`. Keep route tags stable, synchronize `NavigationView.SelectedItem`, support keyboard search focus with Ctrl+E/Ctrl+K/Ctrl+F, and ensure search results can navigate to the correct page and apply a filter or selection. -7. Add shared panel state refresh/subscription. Prefer consuming the existing GUI state subscription model so Overview, Instances, Domains, and Diagnostics update from one shared store. If the direct in-process route cannot stream cleanly inside the WinUI host, use an initial query plus explicit refresh as a scoped fallback and record the limitation before execution continues. -8. Build `OverviewPage` with a top daemon overview card and stacked runtime/config/diagnostic cards. Use theme resources, status stripes/dots, compact metadata rows, and inline `InfoBar`/status rows for unavailable, config conflict, and runtime error states. -9. Build `InstancesPage` with one card per entrypoint registration. Each card should show project/source path, config path, owner process status/PID, age, domain count, activation summary, and inline diagnostics related to that registration. Keep card actions minimal and non-modal. -10. Build `DomainsPage` grouped by entrypoint instance. Each group can expand to domain rows showing hostname, upstream target, enabled/activation state, conflict state, and last error. Use conflict diagnostics matched by `DomainKey` and source paths; show runtime/global errors separately when they are not domain-scoped. -11. Build lightweight `LogsPage`, `SettingsPage`, and `DiagnosticsPage`. `LogsPage` should explain through inline state that domain log capture/query is not available until TASK-1.7/TASK-1.10. `SettingsPage` should avoid durable settings scope. `DiagnosticsPage` should summarize runtime/config diagnostics and provide copyable identifiers or messages only when already available from current state. -12. Add automation IDs and accessibility names for shell navigation, search, overview card, instance cards, domain group expanders, domain rows, inline state banners, and key actions. Ensure keyboard navigation works across nav, search, pages, expanders, and buttons. -13. Make responsive behavior explicit. Wide layout uses left navigation and centered content up to a readable max width; medium/narrow layout should reclaim content width, reduce padding, and avoid squeezed cards or overlapping title-bar controls. -14. Remove or retire the old `MainPage` only after replacement pages are wired. Avoid leaving duplicate polling UI paths or dead routes. -15. Keep implementation notes updated if the plan changes, especially around upstream target extraction or GUI state subscription fallback. - -## Validation -- Add focused unit tests for `PanelStateBuilder` covering empty state, one instance, multiple instances, inactive domains, config conflict diagnostics, runtime errors, stale heartbeat owner heuristic, unknown/disconnected state, upstream target fallback, and search/filter matches for domains/source paths/config paths. -- Add contract and daemon tests if upstream target data is added to shared DTOs or daemon projection behavior. -- Run `dotnet test tests\Cadder.Tray.WinUI.Tests\Cadder.Tray.WinUI.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet test tests\Cadder.Contracts.Tests\Cadder.Contracts.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet test tests\Cadder.Daemon.Tests\Cadder.Daemon.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet build src\Cadder.Tray.WinUI\Cadder.Tray.WinUI.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. -- Run `./build.ps1` from the repository root. -- Run `dotnet format Cadder.slnx --verify-no-changes`. -- Launch the WinUI app and verify the panel with `winapp ui`: Open panel from tray popup, navigate every page, use title-bar search, expand domain groups, verify keyboard focus/order, and confirm inline states are visible without modal interruptions. -- Capture screenshots with `winapp ui screenshot` for Overview, Instances, Domains, Logs placeholder, Diagnostics, and at least one narrow window layout. Inspect screenshots for clipping, overlap, unintended scrollbars, unreadable text, and broken light/dark/high-contrast behavior where feasible. -- Run `git status --short` before closeout and keep unrelated files out of the task. - -## Risks And Boundaries -- Upstream target is the main contract risk. Current `RegisteredDomain` does not carry target data, so execution must either add a small tested contract/projection extension or display a truthful fallback for routes where extraction is unsupported. -- Per-domain last error is only reliable when diagnostics are domain-scoped. Runtime/global diagnostics should not be falsely attached to individual domains. -- Stale owner is not an explicit daemon state today. Use a documented heartbeat-age heuristic unless a stronger contract becomes necessary and is approved. -- The existing panel polls every two seconds; a shared subscription-driven state store is preferable, but it must not destabilize daemon lifecycle or tray popup behavior. -- Do not expand Logs into TASK-1.10, Settings into durable configuration, or tray popup behavior into a redesign. -- OpenClaw references are interaction references only. Do not import OpenClaw product logic, dependencies, localization, or unrelated settings pages. - -## Assumptions -- TASK-1.4 and TASK-1.8 remain complete and provide the IPC, GUI snapshot, owner metadata, tray popup, and Open panel integration this task builds on. -- TASK-1.7 and TASK-1.10 are still future work, so full per-domain log capture and tailing are outside this task. -- The local machine can build and run the WinUI project with .NET 10, Windows App SDK, x64/win-x64, and `winapp.exe`. -- Project-facing text, source comments, task notes, and commit messages remain English; chat with the user remains Polish. - -Execution note: The final shell keeps the approved searchable NavigationView layout, but uses a ContentControl page host instead of Frame navigation because Frame navigation to the new WinUI page types produced native XAML crashes during local validation. The shared panel store currently uses initial query plus periodic refresh rather than a streaming subscription to keep the tray host lifecycle stable. UIA-based winapp traversal of the dynamic content tree also crashes inside Microsoft.UI.Xaml on this machine; visual verification used Win32 PrintWindow screenshots as a scoped fallback. - - -## Implementation Notes - - -Plan approved by user and recorded before implementation. Planning context: `.local` exists and is listed in `.git/info/exclude`; TASK-1.4 and TASK-1.8 are Done; .NET 10.0.204 and winapp.exe were observed as available. No code changes were made during planning. A UI subagent reviewed the plan and highlighted contract risks around upstream target, stale owner, and per-domain last error; those risks are captured in the plan. - -Implemented and tested the first execution slice: added a pure panel read model (`PanelStateBuilder`) for overview, instance rows, domain groups, diagnostics, stale-owner detection, and search records. Upstream targets remain a truthful UI fallback (`Not detected`) because the current shared domain contract does not expose route targets. Focused validation passed: `dotnet test tests\Cadder.Tray.WinUI.Tests\Cadder.Tray.WinUI.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` (11 passed). - -Implemented the TASK-1.9 panel shell and registry pages: replaced the minimal MainPage with a searchable NavigationView shell, added Overview/Instances/Domains/Logs/Settings/Diagnostics pages, shared PanelStateStore, pure PanelStateBuilder read model, domain grouping/filter/search records, inline empty/loading/disconnected/stale/config/runtime states, and focused panel read-model tests. Upstream target remains a truthful `Not detected` fallback because the current shared domain contract does not expose route targets. Validation passed after formatting: `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64` (71 passed), `./build.ps1` (0 warnings, 0 errors), `dotnet format Cadder.slnx --verify-no-changes`, and `dotnet build src\Cadder.Tray.WinUI\Cadder.Tray.WinUI.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64`. The app was launched with `winapp run --detach`; final visual fallback screenshot saved at `.local/verification/TASK-1.9/08-final-after-xaml-crlf-printwindow.png`. `winapp ui` element traversal remains blocked by a native WinUI crash during accessibility tree inspection, so the task should be reviewed/closed out with that limitation explicit. - -Follow-up validation after investigating the UIA crash: reduced unnecessary page rerenders in `PanelStateStore` by suppressing non-material refresh notifications, added `PanelStateStoreTests`, and simplified panel pages to pure programmatic `UserControl` classes without empty XAML stubs. Focused WinUI tests now pass with 14 tests, and full solution validation passes with 74 tests. `winapp ui inspect -a --depth 3 --json` still returns `stale_element` and the WinUI process exits, so UIA traversal remains the only blocked validation path. Final visual fallback screenshot after these changes is `.local/verification/TASK-1.9/09-final-programmatic-pages-printwindow.png`. - -Closeout validation on 2026-06-09: `dotnet test tests\Cadder.Tray.WinUI.Tests\Cadder.Tray.WinUI.Tests.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed (14/14), `dotnet test Cadder.slnx -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed (74/74), `dotnet build src\Cadder.Tray.WinUI\Cadder.Tray.WinUI.csproj -p:Platform=x64 -p:RuntimeIdentifier=win-x64` passed with 0 warnings and 0 errors, `./build.ps1` passed, and `dotnet format Cadder.slnx --verify-no-changes` exited 0 with a generic workspace-loading warning. Visual/UIA limitation remains as previously recorded: `winapp ui inspect` destabilizes the WinUI process on this machine, so final UI validation relies on PrintWindow screenshots under `.local/verification/TASK-1.9/`, especially `09-final-programmatic-pages-printwindow.png`. - - -## Comments - - -author: @agent -created: 2026-06-10 11:06 ---- -Superseded by the Rust cross-platform rewrite on 2026-06-10. The WinUI panel implementation was removed from the final codebase; future overview/registry UI work lives in `cadder-tui`. ---- - - -## Final Summary - - -Implemented the main Cadder panel as a searchable WinUI NavigationView shell with Overview, Instances, Domains, Logs, Settings, and Diagnostics destinations. The old minimal MainPage was replaced by a shared panel read model and store that project daemon snapshots into overview cards, per-entrypoint cards, grouped domain rows, search records, inline diagnostics, and loading/disconnected/empty/stale/conflict/runtime states. - -Instances now show project/source paths, config paths, owner process status, age/heartbeat labels, domain counts, activation summary, and scoped diagnostics. Domains are grouped per entrypoint and render hostname, upstream target fallback (`Not detected` when the shared contract has no route target), enabled state, conflict state, and last error. Logs and Settings intentionally remain scoped placeholders because log capture/tailing and durable settings belong to later tasks. - -Validation passed on 2026-06-09: focused WinUI tests (14/14), full solution tests (74/74), explicit WinUI build (0 warnings, 0 errors), `./build.ps1`, and `dotnet format Cadder.slnx --verify-no-changes` (exit 0; generic workspace-loading warning only). Visual fallback screenshots exist under `.local/verification/TASK-1.9/`; UIA traversal with `winapp ui inspect` remains blocked by a native WinUI/process crash on this machine, so that limitation is documented rather than hidden. - diff --git a/backlog/tasks/task-2 - Make-Cadder-universal-for-local-and-remote-environments.md b/backlog/tasks/task-2 - Make-Cadder-universal-for-local-and-remote-environments.md deleted file mode 100644 index 9d09997..0000000 --- a/backlog/tasks/task-2 - Make-Cadder-universal-for-local-and-remote-environments.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -id: TASK-2 -title: Make Cadder universal for local and remote environments -status: To Do -assignee: [] -created_date: '2026-06-11 16:34' -labels: [] -milestone: m-3 -dependencies: [] -documentation: - - docs/ARCHITECTURE.md -priority: high -ordinal: 26000 ---- - -## Description - - -Make Cadder suitable for both local development and homelab-style remote environments by introducing paired local and remote `cadderd` instances as the primary remote-management model. This v2.0 initiative tracks remote daemon pairing, remote Caddy coordination, port negotiation, optional DNS registration, web and desktop management surfaces, multi-daemon aggregation, and quick certificate management while preserving the existing local-first workflows. - - -## Acceptance Criteria - -- [ ] #1 Cadder can model paired local and remote `cadderd` environments as the primary v2.0 remote-management topology. -- [ ] #2 Remote and local entities can be represented with clear ownership and provenance so management clients can route actions to the correct daemon. -- [ ] #3 Remote Caddy configuration, negotiated backend ports, DNS registration, UI aggregation, and certificate workflows are tracked as independent implementation tasks under this initiative. -- [ ] #4 Architecture and user documentation explain the local-only, paired remote, and mixed local/remote operating modes for v2.0 users. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-2.1 - Define-remote-daemon-pairing-and-trust-model.md b/backlog/tasks/task-2.1 - Define-remote-daemon-pairing-and-trust-model.md deleted file mode 100644 index d9c09ec..0000000 --- a/backlog/tasks/task-2.1 - Define-remote-daemon-pairing-and-trust-model.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -id: TASK-2.1 -title: Define remote daemon pairing and trust model -status: To Do -assignee: [] -created_date: '2026-06-11 16:34' -labels: [] -milestone: m-3 -dependencies: - - TASK-1.18 -documentation: - - docs/ARCHITECTURE.md -parent_task_id: TASK-2 -priority: high -ordinal: 26100 ---- - -## Description - - -Define the v2.0 foundation for paired local and remote `cadderd` instances. Cadder needs durable profiles for remote daemon endpoints, trust material, daemon identity, capabilities, health, and provenance so later remote Caddy, DNS, certificate, and UI work can share one safe remote-management model. - - -## Acceptance Criteria - -- [ ] #1 Users can create, list, inspect, and remove paired remote daemon profiles without exposing secret material in state snapshots, logs, or diagnostics. -- [ ] #2 The shared protocol can represent local and remote daemon identity, endpoint, trust state, capabilities, health, and last-seen status with clear provenance. -- [ ] #3 Management clients receive typed outcomes for reachable, unreachable, unauthorized, untrusted, version-incompatible, and unsupported-capability remote daemons. -- [ ] #4 Remote pairing is explicit and opt-in; existing local-only daemon and TUI workflows continue to work without remote configuration. -- [ ] #5 Automated tests cover profile persistence, secret redaction, trust-state transitions, capability negotiation, unreachable and unauthorized remotes, and version mismatch behavior using fakes. -- [ ] #6 Architecture and user documentation explain the paired `cadderd` model, trust boundary, local-only fallback, and operator setup flow. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-2.2 - Configure-remote-Caddy-through-paired-daemons.md b/backlog/tasks/task-2.2 - Configure-remote-Caddy-through-paired-daemons.md deleted file mode 100644 index d9247f9..0000000 --- a/backlog/tasks/task-2.2 - Configure-remote-Caddy-through-paired-daemons.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -id: TASK-2.2 -title: Configure remote Caddy through paired daemons -status: To Do -assignee: [] -created_date: '2026-06-11 16:34' -labels: [] -milestone: m-3 -dependencies: - - TASK-2.1 - - TASK-1.5 - - TASK-1.6 - - TASK-1.11 -documentation: - - docs/ARCHITECTURE.md -parent_task_id: TASK-2 -priority: high -ordinal: 26200 ---- - -## Description - - -Allow Cadder to configure Caddy on a remote host through the remote host's paired `cadderd`, not by directly driving a remote Caddy Admin API from the local machine. This enables homelab setups where Caddy and hosted backend servers can live on different machines while preserving daemon ownership, diagnostics, and rollback boundaries. - - -## Acceptance Criteria - -- [ ] #1 A local Cadder client can register or update a route that is owned and applied by a paired remote `cadderd` with explicit remote provenance in state snapshots. -- [ ] #2 Remote route configuration supports homelab upstream targets where the reverse proxy and backend service are on different machines. -- [ ] #3 Remote Caddy apply failures are reported with typed diagnostics and preserve the remote daemon's last-known-good Caddy state. -- [ ] #4 Local-only Caddy configuration behavior remains unchanged when no remote target is selected. -- [ ] #5 Automated tests cover successful remote registration, remote apply failure, unreachable remote daemon, unsupported remote capability, and last-known-good preservation using fake daemons and fake Caddy boundaries. -- [ ] #6 Architecture and user documentation describe remote Caddy ownership, supported homelab routing shapes, diagnostics, and the boundary that direct remote Caddy Admin API control is not the primary v2.0 path. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-2.3 - Negotiate-backend-ports-and-expose-selected-ports-to-hosted-processes.md b/backlog/tasks/task-2.3 - Negotiate-backend-ports-and-expose-selected-ports-to-hosted-processes.md deleted file mode 100644 index 656a4c7..0000000 --- a/backlog/tasks/task-2.3 - Negotiate-backend-ports-and-expose-selected-ports-to-hosted-processes.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: TASK-2.3 -title: Negotiate backend ports and expose selected ports to hosted processes -status: To Do -assignee: [] -created_date: '2026-06-11 16:35' -updated_date: '2026-06-16 15:23' -labels: [] -milestone: m-2 -dependencies: - - TASK-2.1 -documentation: - - docs/ARCHITECTURE.md -parent_task_id: TASK-2 -priority: high -ordinal: 26300 ---- - -## Description - - -Add dynamic backend port negotiation so multiple projects that prefer the same backend port can still be hosted by Cadder. Projects should negotiate an available port before hosting, Cadder should route Caddy to the negotiated backend without requiring Caddyfile rewrites, and hosted processes should receive the selected values through documented `CADDER_*` environment variables. - - -## Acceptance Criteria - -- [ ] #1 Projects can request a preferred backend port and receive either that port or a deterministic available fallback when the preferred port is already reserved. -- [ ] #2 Cadder records active port reservations with ownership, route, daemon provenance, and lifecycle state so reservations are released when the owning registration ends. -- [ ] #3 Generated Caddy routing uses the negotiated backend endpoint without requiring users to rewrite their source Caddyfile after negotiation. -- [ ] #4 Hosted processes can read documented `CADDER_*` environment variables for the negotiated port and related route metadata. -- [ ] #5 Conflicts, exhausted ranges, stale reservations, and unsupported remote capabilities return typed diagnostics instead of silently choosing unsafe ports. -- [ ] #6 Automated tests cover preferred-port success, same-port conflict fallback, reservation release and reuse, stale owner cleanup, Caddy routing to negotiated ports, and environment variable contents. -- [ ] #7 Architecture and user documentation explain the negotiation flow, environment variables, local and remote behavior, and migration path for projects that currently hardcode backend ports. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-2.4 - Register-Cadder-routes-in-Pi-hole-DNS.md b/backlog/tasks/task-2.4 - Register-Cadder-routes-in-Pi-hole-DNS.md deleted file mode 100644 index 0f551e0..0000000 --- a/backlog/tasks/task-2.4 - Register-Cadder-routes-in-Pi-hole-DNS.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: TASK-2.4 -title: Register Cadder routes in Pi-hole DNS -status: To Do -assignee: [] -created_date: '2026-06-11 16:35' -labels: [] -milestone: m-3 -dependencies: - - TASK-2.1 - - TASK-2.2 -documentation: - - docs/ARCHITECTURE.md -parent_task_id: TASK-2 -priority: medium -ordinal: 26400 ---- - -## Description - - -Add an optional Pi-hole registrar so Cadder-managed reverse-proxy routes can be reflected at the DNS layer. The registrar should map Cadder route hosts to the correct local or remote Caddy front door, clean up records when routes disappear, and report DNS-side issues without leaking credentials. - - -## Acceptance Criteria - -- [ ] #1 Users can configure an optional Pi-hole registrar for local or paired remote environments without requiring Pi-hole for normal Cadder operation. -- [ ] #2 Cadder can create or update DNS records for active managed route hosts so they resolve to the owning Caddy front door. -- [ ] #3 Cadder removes or disables DNS records when the corresponding managed route is removed or no longer active, without touching unrelated Pi-hole records. -- [ ] #4 DNS conflicts, authentication failures, unreachable Pi-hole instances, and unsupported registrar capabilities produce typed, redacted diagnostics visible to management clients. -- [ ] #5 Remote route DNS registration uses the route's owning daemon/provenance so local and remote front-door addresses are not mixed. -- [ ] #6 Automated tests cover record creation, update, cleanup, conflict handling, auth failure, unreachable registrar, redacted diagnostics, and local-vs-remote front-door selection using a fake registrar. -- [ ] #7 Architecture and user documentation describe Pi-hole setup, required credentials, DNS ownership boundaries, cleanup behavior, and safe failure modes. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-2.5 - Build-remote-web-management-dashboard.md b/backlog/tasks/task-2.5 - Build-remote-web-management-dashboard.md deleted file mode 100644 index 77bd99b..0000000 --- a/backlog/tasks/task-2.5 - Build-remote-web-management-dashboard.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -id: TASK-2.5 -title: Build remote web management dashboard -status: To Do -assignee: [] -created_date: '2026-06-11 16:35' -labels: [] -milestone: m-3 -dependencies: - - TASK-2.1 - - TASK-1.18 -documentation: - - docs/ARCHITECTURE.md -parent_task_id: TASK-2 -priority: medium -ordinal: 26500 ---- - -## Description - - -Build a browser-based management dashboard equivalent to the TUI for remote-friendly Cadder operation. The web surface should connect to `cadderd`, expose the core management workflows, and be safe by default through loopback restriction or authentication unless remote access is explicitly enabled. - - -## Acceptance Criteria - -- [ ] #1 Cadder provides a web management dashboard that can display daemon status, entrypoints, domains, diagnostics, logs, and activation controls comparable to the TUI's core workflows. -- [ ] #2 The dashboard is loopback-restricted or authenticated by default, and any remotely reachable mode requires explicit user configuration. -- [ ] #3 The web UI receives state updates from the daemon without requiring a full page reload for normal management workflows. -- [ ] #4 Remote daemon pairing status and connection failures are represented clearly enough for operators to diagnose unreachable or unauthorized remotes. -- [ ] #5 Browser-based tests cover initial load, state refresh/update behavior, activation controls, diagnostics display, restricted/unauthorized access, and remote-enable configuration behavior. -- [ ] #6 Architecture, security, and user documentation explain dashboard startup, default exposure, remote enablement, authentication or loopback restrictions, and operational risks. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-2.6 - Build-local-tray-and-egui-desktop-management-app.md b/backlog/tasks/task-2.6 - Build-local-tray-and-egui-desktop-management-app.md deleted file mode 100644 index eef52b3..0000000 --- a/backlog/tasks/task-2.6 - Build-local-tray-and-egui-desktop-management-app.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -id: TASK-2.6 -title: Build local tray and GPUI desktop management app -status: To Do -assignee: [] -created_date: '2026-06-11 16:35' -updated_date: '2026-06-16 15:43' -labels: [] -milestone: m-2 -dependencies: - - TASK-1.18 -documentation: - - docs/ARCHITECTURE.md -parent_task_id: TASK-2 -priority: medium -ordinal: 26600 ---- - -## Description - - -Build a local desktop management surface as a separate GPUI-based binary with a system tray presence and a management window that covers the same core local workflows as the TUI. For v1.0, this task is intentionally scoped to the existing local daemon model and should not assume remote daemon pairing, remote aggregation, or other v2.0-only capabilities. The desktop app should remain cross-platform, degrade cleanly where tray APIs are unavailable, and use the same daemon contracts as other Cadder clients. It should build on the detached-backend and multi-client runtime foundation from TASK-1.18 instead of introducing a separate ownership model. Remote-aware expansion of the desktop surface belongs to TASK-2.7 and later v2.0 work. - - -## Acceptance Criteria - -- [ ] #1 Cadder provides a separate GPUI desktop management binary with tray status/actions and a management window for local daemon status, entrypoints, domains, diagnostics, logs, and activation controls comparable to the TUI's core local workflows. -- [ ] #2 The GPUI app uses the existing daemon/client contracts instead of introducing a separate source of truth for Cadder state. -- [ ] #3 Unsupported or limited tray behavior on a platform is represented explicitly without breaking the management window or local-only workflows. -- [ ] #4 The v1.0 GPUI app remains fully usable without any remote daemon pairing model or remote profile support; remote-aware expansion is deferred to follow-up v2.0 tasks. -- [ ] #5 Automated tests cover desktop model state transitions, action routing, unsupported tray fallback behavior, and core local UI state rendering; manual smoke verification is documented for platform-specific tray behavior. -- [ ] #6 Architecture and user documentation explain GPUI app startup, platform behavior, tray actions, local management workflows, and how it relates to the TUI, while clearly deferring remote aggregation to later work. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - - -## Comments - - -author: Codex -created: 2026-06-16 15:41 ---- -Scope corrected during backlog intake: GPUI was moved into the v1.0 track, so this task now covers only the local desktop app surface. Remote-profile and paired-daemon UI behavior is intentionally deferred to TASK-2.7 and later v2.0 work. ---- - -author: Codex -created: 2026-06-16 15:43 ---- -Replaced the stale TASK-2.1 dependency with TASK-1.18 so the blocker matches the local multi-client runtime foundation rather than remote pairing work. ---- - diff --git a/backlog/tasks/task-2.7 - Aggregate-local-and-paired-remote-entities-across-TUI-web-and-desktop-UI.md b/backlog/tasks/task-2.7 - Aggregate-local-and-paired-remote-entities-across-TUI-web-and-desktop-UI.md deleted file mode 100644 index f8ebb77..0000000 --- a/backlog/tasks/task-2.7 - Aggregate-local-and-paired-remote-entities-across-TUI-web-and-desktop-UI.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -id: TASK-2.7 -title: 'Aggregate local and paired remote entities across TUI, web, and desktop UI' -status: To Do -assignee: [] -created_date: '2026-06-11 16:35' -updated_date: '2026-06-16 15:41' -labels: [] -milestone: m-3 -dependencies: - - TASK-2.1 - - TASK-2.5 - - TASK-2.6 -documentation: - - docs/ARCHITECTURE.md -parent_task_id: TASK-2 -priority: high -ordinal: 26700 ---- - -## Description - - -Extend Cadder management clients, including the v1.0 local GPUI desktop app, to resolve and display both local and paired remote entities in one coherent model. TUI, web, and desktop surfaces should show local and remote daemons, entrypoints, domains, logs, diagnostics, DNS and certificate status with clear provenance, and route actions to the daemon that owns each entity. - - -## Acceptance Criteria - -- [ ] #1 TUI, web, and desktop management surfaces can display local and paired remote daemons, entrypoints, domains, logs, diagnostics, and relevant DNS/certificate status in a combined view. -- [ ] #2 Every entity shown in a management client includes provenance that distinguishes local state from each paired remote daemon. -- [ ] #3 Actions such as toggling a domain, refreshing logs, viewing diagnostics, or managing DNS/cert state are routed to the daemon that owns the selected entity. -- [ ] #4 Unreachable, unauthorized, stale, and version-incompatible remotes remain visible with clear degraded states instead of disappearing silently. -- [ ] #5 Duplicate or conflicting route hosts across local and remote daemons are surfaced clearly enough for operators to understand which daemon owns each route. -- [ ] #6 Shared UI model tests cover aggregation, provenance labels, action routing, stale/unreachable remotes, duplicate-domain clarity, and local-only behavior with no remote profiles. -- [ ] #7 Architecture and user documentation describe dynamic local/remote resolving, supported management surfaces, provenance rules, and degraded remote states. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - - -## Comments - - -author: Codex -created: 2026-06-16 15:41 ---- -Clarified relationship during backlog intake: TASK-2.6 now delivers the local-only GPUI surface for v1.0, while this task remains the follow-up for remote-aware aggregation and paired-daemon UI behavior in v2.0. ---- - diff --git a/backlog/tasks/task-2.8 - Add-quick-certificate-management-for-local-and-paired-remote-domains.md b/backlog/tasks/task-2.8 - Add-quick-certificate-management-for-local-and-paired-remote-domains.md deleted file mode 100644 index 89e779f..0000000 --- a/backlog/tasks/task-2.8 - Add-quick-certificate-management-for-local-and-paired-remote-domains.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: TASK-2.8 -title: Add quick certificate management for local and paired remote domains -status: To Do -assignee: [] -created_date: '2026-06-11 16:35' -labels: [] -milestone: m-3 -dependencies: - - TASK-2.1 - - TASK-2.2 -documentation: - - docs/ARCHITECTURE.md -parent_task_id: TASK-2 -priority: medium -ordinal: 26800 ---- - -## Description - - -Add quick certificate management for Cadder-managed local and paired remote domains. Operators should be able to inspect certificate readiness, expiry, issuer/source, and errors, then trigger safe certificate-related actions through the daemon that owns the domain without copying remote private keys to the local machine. - - -## Acceptance Criteria - -- [ ] #1 Management clients can show certificate readiness, expiry, issuer/source, renewal or provisioning status, and recent certificate errors for local and paired remote domains. -- [ ] #2 Certificate actions are routed through the daemon that owns the selected domain, and remote private keys or secret challenge material are not exposed to the local daemon or UI state. -- [ ] #3 Quick certificate actions support both local domains and paired remote domains when the owning daemon advertises the required capability. -- [ ] #4 Unsupported certificate capabilities, authorization failures, ACME/DNS challenge failures, and unreachable owning daemons produce typed diagnostics visible to operators. -- [ ] #5 Certificate status integrates with aggregated local/remote entity views without hiding the domain's owning daemon provenance. -- [ ] #6 Automated tests cover local status inspection, remote status inspection through a fake paired daemon, safe action routing, secret redaction, unsupported capability, failed authorization, and provider failure diagnostics. -- [ ] #7 Architecture and user documentation describe certificate status fields, supported quick actions, local versus remote ownership boundaries, and safe operational expectations. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-3 - Add-repository-link-to-documentation-and-fix-README-docs-links.md b/backlog/tasks/task-3 - Add-repository-link-to-documentation-and-fix-README-docs-links.md deleted file mode 100644 index 24bb28c..0000000 --- a/backlog/tasks/task-3 - Add-repository-link-to-documentation-and-fix-README-docs-links.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -id: TASK-3 -title: Add repository link to documentation and fix README docs links -status: To Do -assignee: [] -created_date: '2026-06-11 16:45' -labels: - - documentation -milestone: m-2 -dependencies: [] -references: - - README.md - - docs/site/src/content/docs/index.mdx -documentation: - - docs/site/README.md -priority: medium -ordinal: 27800 ---- - -## Description - - -Cadder's public documentation should make the GitHub repository easy to reach, and the root README should link readers to the published documentation site instead of Starlight source files. This keeps the GitHub-rendered README useful for users while keeping the documentation site as the canonical public guide surface. - - -## Acceptance Criteria - -- [ ] #1 The published documentation source includes a clear link to the Cadder GitHub repository at https://github.com/MrMaxie/Cadder. -- [ ] #2 The root README documentation links point to the published documentation pages under https://maxie.dev/Cadder/ instead of docs/site/src/content/docs source files. -- [ ] #3 README links remain useful when rendered on GitHub and do not require browsing repository source paths to read user docs. -- [ ] #4 Relevant documentation validation is run from docs/site, including at least the existing docs check or build command. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-4 - Support-HTTPS-required-IIS-applications-during-handoff.md b/backlog/tasks/task-4 - Support-HTTPS-required-IIS-applications-during-handoff.md deleted file mode 100644 index fb382e1..0000000 --- a/backlog/tasks/task-4 - Support-HTTPS-required-IIS-applications-during-handoff.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -id: TASK-4 -title: Support HTTPS-required IIS applications during handoff -status: Done -assignee: - - '@agent' -created_date: '2026-06-12 10:32' -updated_date: '2026-06-15 14:34' -labels: - - bug - - iis - - ssl -milestone: m-5 -dependencies: [] -references: - - crates/cadder-daemon/src/caddy.rs - - crates/cadder-daemon/src/iis.rs - - crates/cadder-daemon/src/state.rs - - docs/verification/tui-smoke.md -documentation: - - docs/ARCHITECTURE.md - - docs/site/src/content/docs/cookbooks/windows/iis.mdx -modified_files: - - crates/cadder-protocol/src/lib.rs - - crates/cadder-daemon/src/iis.rs - - crates/cadder-daemon/src/state.rs - - crates/cadder-daemon/src/caddy.rs - - docs/ARCHITECTURE.md - - docs/site/src/content/docs/cookbooks/windows/iis.mdx - - docs/verification/tui-smoke.md -priority: high -ordinal: 28800 ---- - -## Description - - -Read-only verification on 2026-06-12 found an active IIS handoff route for `default.local-dev.thinksmart.com` on both Caddy `:80` and `:443`, with the upstream configured as plain `127.0.0.1:41956` reverse proxy traffic. Public HTTPS through Caddy reached IIS (`Server: Microsoft-IIS/10.0`, `Via: 1.1 Caddy`), direct HTTP to the loopback backend with the route host succeeded, and direct HTTPS to the same backend port failed the TLS handshake. Reading IIS bindings through `WebAdministration` without elevation was denied, so the live IIS binding details were not inspected. The current architecture and cookbook explicitly describe IIS handoff as creating a loopback HTTP backend binding. IIS applications or modules that require HTTPS can therefore see a non-TLS backend request and show `SSL is required` even when the browser used HTTPS to reach Caddy. Fix Cadder so HTTPS IIS handoff preserves HTTPS semantics for backend applications, or rejects/diagnoses unsupported SSL-required cases clearly before leaving a broken route. - - -## Acceptance Criteria - -- [x] #1 A handed-off IIS `https:443` binding whose application requires HTTPS can be reached through Caddy without failing solely because Cadder converted the IIS backend leg to plaintext HTTP. -- [x] #2 Cadder preserves or communicates the original HTTPS request semantics to IIS applications in a documented, testable way for both concrete host bindings and wildcard or empty-host bindings with an explicit route host. -- [x] #3 If Cadder cannot safely satisfy HTTPS-required IIS semantics for a binding, the daemon/TUI reports a typed issue with recovery guidance instead of silently producing a route that returns `SSL is required`. -- [x] #4 Existing IIS `http:80` handoff behavior, restore metadata, rollback behavior, and non-IIS Caddy route behavior remain unchanged. -- [x] #5 Automated tests cover the HTTPS IIS handoff path with fake IIS/Caddy boundaries, including an SSL-required application simulation or equivalent assertion, and the IIS handoff documentation/cookbook describes verification steps and limitations. - - -## Implementation Plan - - -## Goal -Support IIS HTTPS handoff without converting HTTPS-required applications to a plaintext backend leg. For HTTPS IIS bindings, Cadder should either preserve TLS semantics end to end between Caddy and IIS or reject the handoff with a typed, actionable issue before mutating IIS. - -## Scope -- Preserve existing `http:80` IIS handoff behavior. -- Fix `https:443` IIS handoff for concrete host bindings and wildcard or empty-host bindings that receive an explicit route host. -- Keep restore metadata, rollback, daemon restart hydration, and non-IIS Caddy routes compatible with the current behavior. -- Update automated tests and IIS handoff documentation. - -## Key Files -- `crates/cadder-daemon/src/iis.rs`: IIS binding model, backend binding construction, provider mutation scripts, restore metadata persistence. -- `crates/cadder-daemon/src/state.rs`: IIS handoff orchestration, route host handling, failure reporting, rollback and restore flow. -- `crates/cadder-daemon/src/caddy.rs`: generated Caddy reverse-proxy routes for IIS handoffs. -- `crates/cadder-protocol/src/lib.rs`: only if an existing issue kind is not expressive enough for the unsupported HTTPS case. -- `crates/cadder-tui/src/main.rs` and `crates/cadder-tui/src/model.rs`: only if a new issue kind or message path requires UI coverage. -- `docs/ARCHITECTURE.md`, `docs/site/src/content/docs/cookbooks/windows/iis.mdx`, `docs/verification/tui-smoke.md`: operator behavior and verification docs. - -## Implementation Steps -1. Replace the HTTP-only backend helper with a backend binding planner that derives the loopback binding from the original IIS protocol. -2. For `http:80`, keep the existing `127.0.0.1:` HTTP backend binding and Caddy plaintext reverse proxy behavior unchanged. -3. For `https:443`, create a loopback HTTPS backend binding on the same IIS site using the selected route host and copy the discovered TLS certificate metadata from the original binding. -4. Before writing restore metadata or mutating IIS, reject HTTPS handoff when the original binding does not expose usable TLS certificate metadata. Return a typed `IisIssue` with recovery guidance instead of producing a route that can fail as `SSL is required`. -5. Extend the IIS restore metadata and daemon restart hydration path to preserve and reuse the planned backend binding protocol, port, host, and TLS certificate data. -6. Extend `CaddyConfigCoordinator::set_iis_proxy_route` so IIS routes can carry backend protocol information. Generate a TLS-enabled HTTP transport for HTTPS IIS backends, with `server_name` set to the route host. Use loopback-appropriate TLS verification behavior and document the security tradeoff. -7. Keep rollback and restore batches operating from the stored backend binding rather than reconstructing an HTTP-only binding. -8. Update TUI-facing messages only if the new typed issue or success text needs clearer operator guidance. -9. Update architecture, cookbook, and smoke verification docs to describe HTTPS backend handoff, concrete host and wildcard route-host behavior, direct backend verification, and known limitations. - -## Validation Steps -- Add focused unit tests in `iis.rs` for HTTP backend compatibility, HTTPS backend certificate preservation, wildcard or empty-host route host handling, and missing certificate rejection. -- Add focused tests in `caddy.rs` asserting that HTTP IIS routes remain plaintext and HTTPS IIS routes include TLS transport configuration. -- Add daemon orchestration tests in `state.rs` covering HTTPS concrete host handoff, HTTPS wildcard or empty-host handoff with explicit route host, typed rejection before mutation, daemon restart hydration, and restore or rollback behavior. -- Add or update protocol/TUI tests only if the response contract or rendered safety messages change. -- Run `cargo fmt --check`. -- Run focused Cargo tests for `cadder-daemon`, `cadder-protocol`, and `cadder-tui` as applicable. -- Run `cargo clippy --workspace --all-targets -- -D warnings`. -- Run `cargo test --workspace`. -- Run `cargo run -p xtask -- check`. -- Measure coverage with the project coverage workflow and confirm it remains at or above the 85% threshold. - -## Risks And Checks -- Reusing IIS TLS certificate metadata must work for SNI and wildcard or empty-host bindings; otherwise the handoff must fail before mutation. -- Caddy proxies HTTPS upstreams over TLS only when its reverse proxy transport is configured for TLS. Plain `127.0.0.1:` must not be used for HTTPS-required IIS backends. -- Loopback HTTPS certificates may not validate against `127.0.0.1`; the generated Caddy route should use the route host as TLS server name and document any local trust or insecure-verification choice. -- Restore and rollback must not lose metadata if IIS or Caddy apply fails mid-handoff. - -## Out Of Scope -- No broad IIS management UI changes. -- No support for unsupported IIS protocols or non-80/non-443 ports. -- No changes to general Caddyfile adaptation or non-IIS route behavior unless required by the IIS route data model. - - -## Implementation Notes - - -Implementation plan approved by the user and recorded before code changes. - -Started implementation for TASK-4 after confirming the task is In Progress, assigned to @agent, and has an approved recorded plan. Initial code review found that IIS certificate discovery already exists, but backend planning and Caddy route generation still force IIS handoff backends to plaintext HTTP. - -Implemented HTTPS-aware IIS handoff. HTTP handoff still uses plaintext loopback backends. HTTPS handoff now requires usable IIS TLS certificate metadata, creates an HTTPS loopback backend binding with the copied certificate metadata, persists that backend binding for restore/restart hydration, and configures Caddy's IIS proxy route with TLS transport using the route host as SNI and loopback-only insecure certificate verification. Missing HTTPS certificate metadata is reported as the typed issue `missingTlsCertificate` before metadata writes or IIS mutation. - -Validation completed: `cargo fmt --check`; `cargo test -p cadder-daemon iis`; `cargo test -p cadder-protocol serializes_iis_handoff_contracts`; `cargo test -p cadder-daemon`; `cargo test -p cadder-protocol`; `cargo test -p cadder-tui`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo run -p xtask -- check`; `cargo run -p xtask -- coverage`. Coverage summary reports total line coverage 86.83187020780086%, above the 85% threshold. - -Closeout validation on 2026-06-15 completed before commit. Fresh-eyes review found no substantive local issues to patch. Verified with `git diff --check`, `cargo fmt --check`, `cargo test -p cadder-daemon iis`, `cargo test -p cadder-protocol serializes_iis_handoff_contracts`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo run -p xtask -- check`, and `cargo run -p xtask -- coverage`. Coverage summary reports total line coverage 86.83187020780086%, above the 85% threshold. - - -## Final Summary - - -## Summary -- Added HTTPS-aware IIS handoff so Cadder creates a TLS loopback IIS backend for `https:443` bindings and configures Caddy to use TLS to that backend with the route host as SNI. -- Preserved existing HTTP IIS handoff and non-IIS Caddy route behavior while keeping restore metadata, rollback, restart hydration, and restore flows tied to the stored backend binding. -- Added typed `missingTlsCertificate` handling so HTTPS handoff is rejected before metadata writes or IIS mutation when usable TLS certificate metadata is unavailable. -- Updated architecture, IIS cookbook, and TUI smoke verification docs with HTTPS backend behavior, loopback verification, certificate requirements, and limitations. - -## Validation -- `git diff --check` -- `cargo fmt --check` -- `cargo test -p cadder-daemon iis` -- `cargo test -p cadder-protocol serializes_iis_handoff_contracts` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo run -p xtask -- check` -- `cargo run -p xtask -- coverage` reports total line coverage 86.83187020780086%, above the 85% threshold. - -## Risks And Follow-ups -- HTTPS upstream certificate verification is intentionally skipped only for the local loopback hop because IIS certificates commonly do not validate for `127.0.0.1`; this is documented as a local development tradeoff. -- No follow-up task is required for TASK-4 closeout. - - -## Definition of Done - -- [x] #1 Tests or explicit verification were run for the changed behavior -- [x] #2 Coverage was measured and remains at or above the project threshold - diff --git a/backlog/tasks/task-5 - Handle-transient-IIS-restore-batch-file-in-use-failures.md b/backlog/tasks/task-5 - Handle-transient-IIS-restore-batch-file-in-use-failures.md deleted file mode 100644 index e591499..0000000 --- a/backlog/tasks/task-5 - Handle-transient-IIS-restore-batch-file-in-use-failures.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -id: TASK-5 -title: Handle transient IIS restore batch file-in-use failures -status: To Do -assignee: [] -created_date: '2026-06-15 18:16' -labels: - - bug - - iis - - windows - - elevation -milestone: m-5 -dependencies: - - TASK-1.24 - - TASK-4 -references: - - crates/cadder-daemon/src/state.rs - - crates/cadder-daemon/src/iis.rs - - crates/cadder-daemon/src/caddy.rs - - crates/cadder-protocol/src/lib.rs - - crates/cadder-tui/src/main.rs -documentation: - - docs/ARCHITECTURE.md - - docs/site/src/content/docs/cookbooks/windows/iis.mdx - - docs/verification/tui-smoke.md - - >- - backlog/tasks/task-1.24 - - Support-mixed-elevation-operations-with-minimal-admin-prompting.md - - >- - backlog/tasks/task-4 - - Support-HTTPS-required-IIS-applications-during-handoff.md -priority: high -ordinal: 29800 ---- - -## Description - - -A Windows IIS HTTPS handoff restore can fail after administrator approval when the privileged IIS restore batch hits a transient file-in-use or port-owner condition such as HRESULT 0x80070020. In the observed failure, Cadder restored the Caddy proxy route and exposed RetryRestore/RetryElevation follow-ups, but recovering the machine still required manual operator cleanup: free the front-door HTTPS port, recreate the original IIS public binding, remove the loopback backend binding, and clear Cadder handoff metadata. Improve the restore failure and retry path so Cadder remains recoverable and can complete the restore after the external lock is cleared without manual metadata edits. Related completed context: TASK-1.24 provides mixed-elevation step reporting and TASK-4 provides the HTTPS IIS backend/restore metadata model. - - -## Acceptance Criteria - -- [ ] #1 A privileged IIS restore batch failure caused by file-in-use diagnostics such as HRESULT 0x80070020 leaves the handoff recoverable: restore metadata is retained until successful restore, the binding remains reported as handed off or recoverable, and the Caddy IIS proxy route is restored if Cadder still owns the front door. -- [ ] #2 The daemon response reports accurate operation steps for this failure: restore metadata read and proxy-route removal are represented separately from failed privileged IIS mutation steps, approved admin status is preserved, metadata cleanup is skipped, and follow-ups include retry restore and retry elevation when applicable. -- [ ] #3 Retrying restore after the transient file lock or port owner is cleared succeeds without manual runtime-state edits: the public IIS binding is restored, the loopback backend binding is removed, the proxy route is removed, and restore metadata is cleared only after the restore completes. -- [ ] #4 The TUI status message and IIS row state accurately summarize the restore failure and available recovery actions without implying that restore completed successfully. -- [ ] #5 Automated tests cover restore-binding failure and loopback-cleanup failure inside the privileged restore batch using fake IIS/Caddy boundaries; real IIS verification, if performed, is documented as a disposable Windows smoke scenario. - - -## Definition of Done - -- [ ] #1 Tests or explicit verification were run for the changed behavior -- [ ] #2 Coverage was measured and remains at or above the project threshold - diff --git a/crates/cadder-daemon/Cargo.toml b/crates/cadder-daemon/Cargo.toml index 0c28f5b..ca2754a 100644 --- a/crates/cadder-daemon/Cargo.toml +++ b/crates/cadder-daemon/Cargo.toml @@ -22,6 +22,7 @@ figment = { version = "0.10.19", features = ["toml", "env"] } fs4 = "1.1.0" hex = "0.4.3" interprocess = { version = "2.4.2", features = ["tokio"] } +rusqlite = { version = "=0.40.1", features = ["bundled"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.11.0" @@ -30,3 +31,10 @@ tokio = { version = "1.52.3", features = ["full"] } [dev-dependencies] tempfile = "3.27.0" testcontainers = "0.27.3" + +[target."cfg(windows)".dependencies] +is_elevated = "=0.1.2" +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading"] } + +[target."cfg(unix)".dependencies] +libc = "0.2.186" diff --git a/crates/cadder-daemon/src/autostart.rs b/crates/cadder-daemon/src/autostart.rs new file mode 100644 index 0000000..70fcc84 --- /dev/null +++ b/crates/cadder-daemon/src/autostart.rs @@ -0,0 +1,806 @@ +use crate::paths::RuntimePaths; +use cadder_protocol::{AutostartDiagnostic, AutostartMode, AutostartStatus}; +#[cfg(unix)] +use directories::BaseDirs; +#[cfg(all(unix, not(target_os = "macos")))] +use std::os::unix::fs as unix_fs; +#[cfg(windows)] +use std::os::windows::process::CommandExt; +#[cfg(windows)] +use std::process::Command; +use std::{env, io, path::PathBuf}; +#[cfg(unix)] +use std::{fs, path::Path}; + +#[cfg(target_os = "macos")] +const APP_ID: &str = "dev.cadder"; +#[cfg(windows)] +const RUN_VALUE_NAME: &str = "Cadder"; +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +#[derive(Debug, Clone)] +pub struct AutostartManager { + runtime_dir: PathBuf, + daemon_path: Option, + #[cfg(unix)] + base_dirs: Option, + #[cfg(all(unix, not(target_os = "macos")))] + config_dir_override: Option, +} + +#[derive(Debug, Clone)] +pub struct AutostartView { + pub mode: AutostartMode, + pub status: AutostartStatus, + pub target: Option, + pub diagnostics: Vec, +} + +impl AutostartManager { + pub fn disabled() -> Self { + Self { + runtime_dir: PathBuf::new(), + daemon_path: None, + #[cfg(unix)] + base_dirs: None, + #[cfg(all(unix, not(target_os = "macos")))] + config_dir_override: None, + } + } + + pub fn new(paths: &RuntimePaths) -> Self { + let daemon_path = env::current_exe().ok(); + Self { + runtime_dir: paths.runtime_dir().to_path_buf(), + daemon_path, + #[cfg(unix)] + base_dirs: BaseDirs::new(), + #[cfg(all(unix, not(target_os = "macos")))] + config_dir_override: None, + } + } + + pub fn query(&self) -> AutostartView { + platform_query(self) + } + + pub fn set(&self, mode: AutostartMode) -> AutostartView { + let result = match mode { + AutostartMode::Disabled => platform_disable(self), + AutostartMode::Daemon => platform_set_daemon(self), + }; + match result { + Ok(()) => self.query(), + Err(error) => AutostartView { + mode, + status: AutostartStatus::Unsupported, + target: self.target_for_mode(mode), + diagnostics: vec![AutostartDiagnostic { + code: "autostart-update-failed".to_string(), + message: error.to_string(), + }], + }, + } + } + + fn target_for_mode(&self, mode: AutostartMode) -> Option { + match mode { + AutostartMode::Disabled => None, + AutostartMode::Daemon => self.daemon_command().ok(), + } + } + + fn daemon_command(&self) -> io::Result { + let daemon = self.daemon_path.as_ref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "could not resolve cadderd executable for autostart", + ) + })?; + let background_arg = daemon_background_arg(); + Ok(format!( + "\"{}\"{background_arg} --runtime-dir \"{}\"", + daemon.display(), + self.runtime_dir.display() + )) + } + + #[cfg(all(unix, not(target_os = "macos")))] + fn config_dir(&self) -> io::Result { + if let Some(config_dir) = &self.config_dir_override { + return Ok(config_dir.clone()); + } + self + .base_dirs + .as_ref() + .map(|dirs| dirs.config_dir().to_path_buf()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "could not resolve config directory", + ) + }) + } + + #[cfg(target_os = "macos")] + fn home_dir(&self) -> io::Result { + self + .base_dirs + .as_ref() + .map(|dirs| dirs.home_dir().to_path_buf()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "could not resolve home directory", + ) + }) + } +} + +#[cfg(windows)] +fn platform_query(manager: &AutostartManager) -> AutostartView { + let output = hidden_windows_command("reg") + .args([ + "query", + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", + "/v", + RUN_VALUE_NAME, + ]) + .output(); + match output { + Ok(output) if output.status.success() => AutostartView { + mode: AutostartMode::Daemon, + status: AutostartStatus::Enabled, + target: manager.target_for_mode(AutostartMode::Daemon), + diagnostics: Vec::new(), + }, + _ => AutostartView { + mode: AutostartMode::Disabled, + status: AutostartStatus::Disabled, + target: None, + diagnostics: Vec::new(), + }, + } +} + +#[cfg(windows)] +fn platform_disable(_manager: &AutostartManager) -> io::Result<()> { + let _ = hidden_windows_command("reg") + .args([ + "delete", + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", + "/v", + RUN_VALUE_NAME, + "/f", + ]) + .status(); + Ok(()) +} + +#[cfg(windows)] +fn platform_set_daemon(manager: &AutostartManager) -> io::Result<()> { + write_windows_run_value(&manager.daemon_command()?) +} + +#[cfg(windows)] +fn write_windows_run_value(command: &str) -> io::Result<()> { + let status = hidden_windows_command("reg") + .args([ + "add", + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", + "/v", + RUN_VALUE_NAME, + "/t", + "REG_SZ", + "/d", + command, + "/f", + ]) + .status()?; + if status.success() { + Ok(()) + } else { + Err(io::Error::other("reg.exe rejected the autostart update")) + } +} + +#[cfg(windows)] +fn hidden_windows_command(program: &str) -> Command { + let mut command = Command::new(program); + command.creation_flags(CREATE_NO_WINDOW); + command +} + +#[cfg(target_os = "macos")] +fn platform_query(manager: &AutostartManager) -> AutostartView { + let daemon = macos_daemon_plist(manager); + if daemon.is_file() { + autostart_enabled(manager, AutostartMode::Daemon) + } else { + autostart_disabled() + } +} + +#[cfg(target_os = "macos")] +fn platform_disable(manager: &AutostartManager) -> io::Result<()> { + remove_file_if_exists(macos_daemon_plist(manager)) +} + +#[cfg(target_os = "macos")] +fn platform_set_daemon(manager: &AutostartManager) -> io::Result<()> { + platform_disable(manager)?; + let path = macos_daemon_plist(manager); + write_parented(&path, macos_plist(APP_ID, &manager.daemon_command()?)) +} + +#[cfg(target_os = "macos")] +fn macos_daemon_plist(manager: &AutostartManager) -> PathBuf { + manager + .home_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("Library") + .join("LaunchAgents") + .join(format!("{APP_ID}.daemon.plist")) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_query(manager: &AutostartManager) -> AutostartView { + match linux_autostart_paths(manager) { + Ok(paths) => query_linux_autostart(manager, &paths), + Err(error) => AutostartView { + mode: AutostartMode::Disabled, + status: AutostartStatus::Unsupported, + target: None, + diagnostics: vec![AutostartDiagnostic { + code: "autostart-config-dir-unavailable".to_string(), + message: error.to_string(), + }], + }, + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn query_linux_autostart(manager: &AutostartManager, paths: &LinuxAutostartPaths) -> AutostartView { + let daemon_unit_exists = paths.daemon_service.is_file(); + let enablement = linux_enablement_state(paths); + + if !daemon_unit_exists && !enablement.exists() { + autostart_disabled() + } else { + let mode = AutostartMode::Daemon; + let target = manager.target_for_mode(mode); + let mut diagnostics = Vec::new(); + + if !daemon_unit_exists { + diagnostics.push(autostart_diagnostic( + "autostart-daemon-unit-missing", + "Cadder found startup enablement without the generated cadderd.service unit.", + )); + } + + match enablement { + LinuxEnablementState::Valid => {} + LinuxEnablementState::Missing if daemon_unit_exists => { + diagnostics.push(autostart_diagnostic( + "autostart-daemon-not-enabled", + "Cadder found cadderd.service, but it is not enabled under default.target.", + )) + } + LinuxEnablementState::Missing => {} + LinuxEnablementState::Invalid { code, message } => { + diagnostics.push(autostart_diagnostic(code, message)); + } + } + + if target.is_none() { + diagnostics.push(autostart_diagnostic( + "autostart-target-missing", + "Cadder could not resolve the configured autostart target.", + )); + } + + AutostartView { + mode, + status: if diagnostics.is_empty() { + AutostartStatus::Enabled + } else { + AutostartStatus::Misconfigured + }, + target, + diagnostics, + } + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_disable(manager: &AutostartManager) -> io::Result<()> { + let paths = linux_autostart_paths(manager)?; + remove_linux_autostart_files(&paths) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn remove_linux_autostart_files(paths: &LinuxAutostartPaths) -> io::Result<()> { + remove_file_if_exists(paths.daemon_enablement_link.clone())?; + remove_file_if_exists(paths.daemon_service.clone()) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_set_daemon(manager: &AutostartManager) -> io::Result<()> { + let daemon_command = manager.daemon_command()?; + let paths = linux_autostart_paths(manager)?; + remove_linux_autostart_files(&paths)?; + write_linux_daemon_autostart(&paths, &daemon_command) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn write_linux_daemon_autostart(paths: &LinuxAutostartPaths, command: &str) -> io::Result<()> { + write_parented(&paths.daemon_service, systemd_user_service(command))?; + write_parented_symlink( + linux_daemon_enablement_target(), + &paths.daemon_enablement_link, + ) +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[derive(Debug, Clone)] +struct LinuxAutostartPaths { + daemon_service: PathBuf, + daemon_enablement_link: PathBuf, +} + +#[cfg(all(unix, not(target_os = "macos")))] +impl LinuxAutostartPaths { + fn from_config_dir(config_dir: PathBuf) -> Self { + let systemd_user_dir = config_dir.join("systemd").join("user"); + Self { + daemon_service: systemd_user_dir.join("cadderd.service"), + daemon_enablement_link: systemd_user_dir + .join("default.target.wants") + .join("cadderd.service"), + } + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn linux_autostart_paths(manager: &AutostartManager) -> io::Result { + manager + .config_dir() + .map(LinuxAutostartPaths::from_config_dir) +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[derive(Debug, Clone, Copy)] +enum LinuxEnablementState { + Missing, + Valid, + Invalid { + code: &'static str, + message: &'static str, + }, +} + +#[cfg(all(unix, not(target_os = "macos")))] +impl LinuxEnablementState { + fn exists(self) -> bool { + !matches!(self, Self::Missing) + } + + fn is_valid(self) -> bool { + matches!(self, Self::Valid) + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn linux_enablement_state(paths: &LinuxAutostartPaths) -> LinuxEnablementState { + match fs::symlink_metadata(&paths.daemon_enablement_link) { + Ok(metadata) if metadata.file_type().is_symlink() => { + match fs::read_link(&paths.daemon_enablement_link) { + Ok(target) + if target == linux_daemon_enablement_target() || target == paths.daemon_service => + { + LinuxEnablementState::Valid + } + Ok(_) => LinuxEnablementState::Invalid { + code: "autostart-daemon-enablement-stale", + message: "Cadder found a default.target enablement link that does not point at the generated cadderd.service unit.", + }, + Err(_) => LinuxEnablementState::Invalid { + code: "autostart-daemon-enablement-unreadable", + message: "Cadder could not read the cadderd.service enablement link.", + }, + } + } + Ok(_) => LinuxEnablementState::Invalid { + code: "autostart-daemon-enablement-not-symlink", + message: "Cadder found a default.target cadderd.service entry that is not a symlink.", + }, + Err(error) if error.kind() == io::ErrorKind::NotFound => LinuxEnablementState::Missing, + Err(_) => LinuxEnablementState::Invalid { + code: "autostart-daemon-enablement-unreadable", + message: "Cadder could not inspect the cadderd.service enablement link.", + }, + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn linux_daemon_enablement_target() -> PathBuf { + PathBuf::from("..").join("cadderd.service") +} + +#[cfg(not(any(windows, unix)))] +fn platform_query(_manager: &AutostartManager) -> AutostartView { + AutostartView { + mode: AutostartMode::Disabled, + status: AutostartStatus::Unsupported, + target: None, + diagnostics: vec![AutostartDiagnostic { + code: "autostart-unsupported".to_string(), + message: "Autostart is not supported on this platform.".to_string(), + }], + } +} + +#[cfg(not(any(windows, unix)))] +fn platform_disable(_manager: &AutostartManager) -> io::Result<()> { + Ok(()) +} + +#[cfg(not(any(windows, unix)))] +fn platform_set_daemon(_manager: &AutostartManager) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "autostart is not supported on this platform", + )) +} + +#[cfg(target_os = "macos")] +fn autostart_enabled(manager: &AutostartManager, mode: AutostartMode) -> AutostartView { + let target = manager.target_for_mode(mode); + let mut diagnostics = Vec::new(); + if target.is_none() { + diagnostics.push(AutostartDiagnostic { + code: "autostart-target-missing".to_string(), + message: "Cadder could not resolve the configured autostart target.".to_string(), + }); + } + AutostartView { + mode, + status: if diagnostics.is_empty() { + AutostartStatus::Enabled + } else { + AutostartStatus::Misconfigured + }, + target, + diagnostics, + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn autostart_diagnostic(code: &str, message: &str) -> AutostartDiagnostic { + AutostartDiagnostic { + code: code.to_string(), + message: message.to_string(), + } +} + +#[cfg(unix)] +fn autostart_disabled() -> AutostartView { + AutostartView { + mode: AutostartMode::Disabled, + status: AutostartStatus::Disabled, + target: None, + diagnostics: Vec::new(), + } +} + +#[cfg(unix)] +fn write_parented(path: &Path, contents: String) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, contents) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn write_parented_symlink(target: PathBuf, link: &Path) -> io::Result<()> { + if let Some(parent) = link.parent() { + fs::create_dir_all(parent)?; + } + match fs::remove_file(link) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + unix_fs::symlink(target, link) +} + +#[cfg(unix)] +fn remove_file_if_exists(path: PathBuf) -> io::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn systemd_user_service(command: &str) -> String { + format!( + "[Unit]\nDescription=Cadder daemon\n\n[Service]\nExecStart={command}\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n" + ) +} + +#[cfg(target_os = "macos")] +fn macos_plist(label: &str, command: &str) -> String { + format!( + r#" + + + + Label{label} + ProgramArguments + /bin/sh-lc{command} + RunAtLoad + + +"# + ) +} + +#[cfg(windows)] +fn daemon_background_arg() -> &'static str { + " --background" +} + +#[cfg(not(windows))] +fn daemon_background_arg() -> &'static str { + "" +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn manager_with_targets() -> AutostartManager { + AutostartManager { + runtime_dir: PathBuf::from("D:/runtime"), + daemon_path: Some(PathBuf::from("D:/bin/cadderd.exe")), + #[cfg(unix)] + base_dirs: None, + #[cfg(all(unix, not(target_os = "macos")))] + config_dir_override: None, + } + } + + #[cfg(all(unix, not(target_os = "macos")))] + fn linux_manager(config_dir: PathBuf) -> AutostartManager { + AutostartManager { + runtime_dir: PathBuf::from("/tmp/cadder-runtime"), + daemon_path: Some(PathBuf::from("/opt/cadder/bin/cadderd")), + base_dirs: None, + config_dir_override: Some(config_dir), + } + } + + #[cfg(all(unix, not(target_os = "macos")))] + fn diagnostic_codes(view: &AutostartView) -> Vec<&str> { + view + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code.as_str()) + .collect() + } + + #[cfg(all(unix, not(target_os = "macos")))] + fn linux_manager_without_daemon(config_dir: PathBuf) -> AutostartManager { + AutostartManager { + runtime_dir: PathBuf::from("/tmp/cadder-runtime"), + daemon_path: None, + base_dirs: None, + config_dir_override: Some(config_dir), + } + } + + #[test] + fn command_targets_include_runtime_dir_and_executable() { + let manager = manager_with_targets(); + let expected_daemon = if cfg!(windows) { + "\"D:/bin/cadderd.exe\" --background --runtime-dir \"D:/runtime\"" + } else { + "\"D:/bin/cadderd.exe\" --runtime-dir \"D:/runtime\"" + }; + + assert_eq!(manager.daemon_command().unwrap(), expected_daemon); + assert_eq!( + manager.target_for_mode(AutostartMode::Daemon).as_deref(), + Some(expected_daemon) + ); + assert_eq!(manager.target_for_mode(AutostartMode::Disabled), None); + } + + #[test] + fn missing_targets_report_not_found_before_platform_write() { + let manager = AutostartManager::disabled(); + + assert_eq!( + manager.daemon_command().unwrap_err().kind(), + io::ErrorKind::NotFound + ); + } + + #[test] + fn missing_targets_make_set_fail_before_platform_write() { + let manager = AutostartManager::disabled(); + + let daemon = manager.set(AutostartMode::Daemon); + + assert_eq!(daemon.mode, AutostartMode::Daemon); + assert_eq!(daemon.status, AutostartStatus::Unsupported); + assert_eq!(daemon.target, None); + assert_eq!(daemon.diagnostics[0].code, "autostart-update-failed"); + assert!( + daemon.diagnostics[0] + .message + .contains("could not resolve cadderd executable") + ); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn systemd_unit_contains_daemon_command() { + let unit = systemd_user_service("\"/bin/cadderd\" --runtime-dir \"/tmp/cadder\""); + + assert!(unit.contains("ExecStart=\"/bin/cadderd\" --runtime-dir \"/tmp/cadder\"")); + assert!(unit.contains("Restart=on-failure")); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn linux_set_daemon_creates_unit_and_enablement_symlink() { + let temp = tempfile::tempdir().unwrap(); + let manager = linux_manager(temp.path().join("config")); + let paths = linux_autostart_paths(&manager).unwrap(); + + let view = manager.set(AutostartMode::Daemon); + + assert_eq!(view.mode, AutostartMode::Daemon); + assert_eq!(view.status, AutostartStatus::Enabled); + assert!(view.diagnostics.is_empty()); + assert!(paths.daemon_service.is_file()); + assert_eq!( + fs::read_link(&paths.daemon_enablement_link).unwrap(), + linux_daemon_enablement_target() + ); + let unit = fs::read_to_string(&paths.daemon_service).unwrap(); + assert!(unit.contains("WantedBy=default.target")); + assert!( + unit.contains("ExecStart=\"/opt/cadder/bin/cadderd\" --runtime-dir \"/tmp/cadder-runtime\"") + ); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn linux_query_reports_unavailable_config_dir_as_unsupported() { + let view = AutostartManager::disabled().query(); + + assert_eq!(view.mode, AutostartMode::Disabled); + assert_eq!(view.status, AutostartStatus::Unsupported); + assert_eq!( + diagnostic_codes(&view), + vec!["autostart-config-dir-unavailable"] + ); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn linux_set_daemon_validates_target_before_cleanup() { + let temp = tempfile::tempdir().unwrap(); + let manager = linux_manager_without_daemon(temp.path().join("config")); + let paths = linux_autostart_paths(&manager).unwrap(); + write_parented( + &paths.daemon_service, + systemd_user_service("\"/opt/cadder/bin/cadderd\" --runtime-dir \"/tmp/cadder-runtime\""), + ) + .unwrap(); + write_parented_symlink( + linux_daemon_enablement_target(), + &paths.daemon_enablement_link, + ) + .unwrap(); + + let view = manager.set(AutostartMode::Daemon); + + assert_eq!(view.mode, AutostartMode::Daemon); + assert_eq!(view.status, AutostartStatus::Unsupported); + assert_eq!(view.diagnostics[0].code, "autostart-update-failed"); + assert!(paths.daemon_service.is_file()); + assert!(paths.daemon_enablement_link.exists()); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn linux_query_reports_generated_unit_without_enablement_as_misconfigured() { + let temp = tempfile::tempdir().unwrap(); + let manager = linux_manager(temp.path().join("config")); + let paths = linux_autostart_paths(&manager).unwrap(); + write_parented( + &paths.daemon_service, + systemd_user_service("\"/opt/cadder/bin/cadderd\" --runtime-dir \"/tmp/cadder-runtime\""), + ) + .unwrap(); + + let view = manager.query(); + + assert_eq!(view.mode, AutostartMode::Daemon); + assert_eq!(view.status, AutostartStatus::Misconfigured); + assert_eq!( + diagnostic_codes(&view), + vec!["autostart-daemon-not-enabled"] + ); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn linux_query_reports_stale_enablement_link_as_misconfigured() { + let temp = tempfile::tempdir().unwrap(); + let manager = linux_manager(temp.path().join("config")); + let paths = linux_autostart_paths(&manager).unwrap(); + write_parented( + &paths.daemon_service, + systemd_user_service("\"/opt/cadder/bin/cadderd\" --runtime-dir \"/tmp/cadder-runtime\""), + ) + .unwrap(); + write_parented_symlink( + PathBuf::from("..").join("other.service"), + &paths.daemon_enablement_link, + ) + .unwrap(); + + let view = manager.query(); + + assert_eq!(view.mode, AutostartMode::Daemon); + assert_eq!(view.status, AutostartStatus::Misconfigured); + assert_eq!( + diagnostic_codes(&view), + vec!["autostart-daemon-enablement-stale"] + ); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn linux_query_accepts_absolute_enablement_link_to_generated_unit() { + let temp = tempfile::tempdir().unwrap(); + let manager = linux_manager(temp.path().join("config")); + let paths = linux_autostart_paths(&manager).unwrap(); + write_parented( + &paths.daemon_service, + systemd_user_service("\"/opt/cadder/bin/cadderd\" --runtime-dir \"/tmp/cadder-runtime\""), + ) + .unwrap(); + write_parented_symlink(paths.daemon_service.clone(), &paths.daemon_enablement_link).unwrap(); + + let view = manager.query(); + + assert_eq!(view.mode, AutostartMode::Daemon); + assert_eq!(view.status, AutostartStatus::Enabled); + assert!(view.diagnostics.is_empty()); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn linux_disable_removes_daemon_enablement_and_unit() { + let temp = tempfile::tempdir().unwrap(); + let manager = linux_manager(temp.path().join("config")); + let paths = linux_autostart_paths(&manager).unwrap(); + + manager.set(AutostartMode::Daemon); + let view = manager.set(AutostartMode::Disabled); + + assert_eq!(view.mode, AutostartMode::Disabled); + assert_eq!(view.status, AutostartStatus::Disabled); + assert!(!paths.daemon_service.exists()); + assert!(!paths.daemon_enablement_link.exists()); + } +} diff --git a/crates/cadder-daemon/src/caddy.rs b/crates/cadder-daemon/src/caddy.rs index 1b226f7..64108b4 100644 --- a/crates/cadder-daemon/src/caddy.rs +++ b/crates/cadder-daemon/src/caddy.rs @@ -1,7 +1,8 @@ use crate::{ config::{CONFIG_FILE_NAME, CadderConfig, configured_real_caddy_command}, logs::CaddyLogStore, - runtime::ProcessRuntime, + paths::RuntimePaths, + runtime::{CaddyRuntime, ProcessRuntime}, }; use anyhow::{Context, Result, anyhow}; use cadder_protocol::{ @@ -13,11 +14,78 @@ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, BTreeSet}, - env, + env, fmt, path::{Path, PathBuf}, process::Stdio, + str::FromStr, + time::Duration, }; -use tokio::process::Command; +use tokio::{process::Command, time::timeout}; + +pub const CADDER_CADDY_BACKEND_ENV: &str = "CADDER_CADDY_BACKEND"; + +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +fn hidden_command(binary: PathBuf) -> Command { + let mut command = Command::new(binary); + configure_hidden_child(&mut command); + command +} + +#[cfg(windows)] +fn configure_hidden_child(command: &mut Command) { + command.creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(windows))] +fn configure_hidden_child(_command: &mut Command) {} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum CaddyBackendMode { + #[default] + Real, + Mock, +} + +impl CaddyBackendMode { + pub fn from_env() -> Result { + env::var(CADDER_CADDY_BACKEND_ENV) + .ok() + .map_or(Ok(Self::Real), |value| value.parse()) + } + + pub fn parse_cli(value: &str) -> std::result::Result { + value.parse::().map_err(|error| error.to_string()) + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Real => "real", + Self::Mock => "mock", + } + } +} + +impl fmt::Display for CaddyBackendMode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for CaddyBackendMode { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "" | "real" | "default" => Ok(Self::Real), + "mock" | "dev" => Ok(Self::Mock), + other => Err(anyhow!( + "unknown Cadder Caddy backend `{other}`; expected `real` or `mock`" + )), + } + } +} #[derive(Debug, Clone)] pub struct RealCaddyResolver { @@ -73,7 +141,7 @@ impl RealCaddyResolver { "Cadder could not resolve a safe real Caddy binary.\n\n\ Cause: {error}\n\n\ Configure the real Caddy command with one of these options, in precedence order:\n\ - - CLI override: --real-caddy-command for cadderd/cadder-tui or --cadder-real-caddy-command for the shim\n\ + - CLI override: --real-caddy-command for cadderd/cadder or --cadder-real-caddy-command for the shim\n\ - [caddy].real_command in cadder.toml in the project working directory\n\ - [caddy].real_command in cadder.toml next to the Cadder executable\n\ - CADDER_CADDY_REAL_COMMAND environment variable\n\ @@ -235,6 +303,7 @@ fn executable_candidates(dir: &Path, command: &str) -> Vec { #[derive(Debug, Clone)] pub struct CaddyConfigAdapter { resolver: RealCaddyResolver, + command_timeout: Duration, } #[derive(Debug, Clone)] @@ -246,16 +315,26 @@ pub struct PreparedRegistration { impl CaddyConfigAdapter { pub fn new(resolver: RealCaddyResolver) -> Self { - Self { resolver } + Self { + resolver, + command_timeout: Duration::from_secs(30), + } + } + + pub fn with_command_timeout(resolver: RealCaddyResolver, command_timeout: Duration) -> Self { + Self { + resolver, + command_timeout, + } } pub async fn prepare(&self, registration: EntrypointRegistration) -> PreparedRegistration { match self.adapt(®istration).await { Ok(adapted) => { - let hosts = extract_hosts(&adapted); + let domains = extract_registered_domains(&adapted); let mut prepared = registration; - if !hosts.is_empty() { - prepared.registered_domains = hosts.into_iter().map(RegisteredDomain::active).collect(); + if !domains.is_empty() { + prepared.registered_domains = domains; } let routes = extract_http_routes(&adapted); PreparedRegistration { @@ -297,7 +376,8 @@ impl CaddyConfigAdapter { .and_then(|run| run.adapter.as_deref()) .unwrap_or("caddyfile"); - let output = Command::new(binary) + let mut command = hidden_command(binary); + command .arg("adapt") .arg("--config") .arg(config_path) @@ -305,8 +385,16 @@ impl CaddyConfigAdapter { .arg(adapter) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .output() + .kill_on_drop(true); + let child = command.spawn().context("start caddy adapt")?; + let output = timeout(self.command_timeout, child.wait_with_output()) .await + .with_context(|| { + format!( + "caddy adapt timed out after {} seconds", + self.command_timeout.as_secs() + ) + })? .context("run caddy adapt")?; if !output.status.success() { @@ -320,16 +408,86 @@ impl CaddyConfigAdapter { } } +#[derive(Debug, Clone)] +pub enum CaddyRegistrationAdapter { + Real(CaddyConfigAdapter), + Mock(MockCaddyConfigAdapter), +} + +impl CaddyRegistrationAdapter { + pub async fn prepare(&self, registration: EntrypointRegistration) -> PreparedRegistration { + match self { + Self::Real(adapter) => adapter.prepare(registration).await, + Self::Mock(adapter) => adapter.prepare(registration).await, + } + } +} + +impl From for CaddyRegistrationAdapter { + fn from(adapter: CaddyConfigAdapter) -> Self { + Self::Real(adapter) + } +} + +#[derive(Debug, Clone, Default)] +pub struct MockCaddyConfigAdapter; + +impl MockCaddyConfigAdapter { + pub async fn prepare(&self, mut registration: EntrypointRegistration) -> PreparedRegistration { + if registration.registered_domains.is_empty() { + let config_path = registration + .source_config_path + .canonical + .as_deref() + .unwrap_or(®istration.source_config_path.raw); + if let Ok(config) = tokio::fs::read_to_string(config_path).await { + let domains = mock_caddyfile_hosts(&config) + .into_iter() + .map(RegisteredDomain::active) + .collect::>(); + if !domains.is_empty() { + registration.registered_domains = domains; + } + } + } + + let routes = registration + .registered_domains + .iter() + .map(|domain| mock_route_for_domain(&domain.name.canonical)) + .collect(); + PreparedRegistration { + registration, + routes, + diagnostics: Vec::new(), + } + } +} + #[derive(Debug)] pub struct CaddyConfigCoordinator { - adapter: CaddyConfigAdapter, - runtime: ProcessRuntime, + adapter: CaddyRegistrationAdapter, + runtime: CaddyRuntime, routes: BTreeMap>, iis_routes: BTreeMap, registration_diagnostics: BTreeMap>, current: ConfigState, } +#[derive(Debug)] +pub enum CaddyApplyAction { + Current(ConfigState), + Stop { + attempted: chrono::DateTime, + }, + Apply { + attempted: chrono::DateTime, + rendered: Vec, + hash: String, + source_config_paths: Vec, + }, +} + #[derive(Debug, Clone)] struct IisProxyRoute { domain_key: String, @@ -354,6 +512,17 @@ impl IisProxyBackendProtocol { impl CaddyConfigCoordinator { pub fn new(adapter: CaddyConfigAdapter, runtime: ProcessRuntime) -> Self { + Self::with_backend(adapter.into(), runtime.into()) + } + + pub fn new_mock(paths: RuntimePaths) -> Self { + Self::with_backend( + CaddyRegistrationAdapter::Mock(MockCaddyConfigAdapter), + CaddyRuntime::mock(paths), + ) + } + + pub fn with_backend(adapter: CaddyRegistrationAdapter, runtime: CaddyRuntime) -> Self { Self { adapter, runtime, @@ -368,6 +537,14 @@ impl CaddyConfigCoordinator { self.current.clone() } + pub fn adapter(&self) -> CaddyRegistrationAdapter { + self.adapter.clone() + } + + pub fn runtime(&self) -> CaddyRuntime { + self.runtime.clone() + } + pub async fn runtime_state(&self) -> cadder_protocol::RuntimeState { self.runtime.inspect().await } @@ -405,6 +582,14 @@ impl CaddyConfigCoordinator { ) -> EntrypointRegistration { let source_path = registration.source_config_path.raw.clone(); let prepared = self.adapter.prepare(registration).await; + self.commit_prepared_registration(source_path, prepared) + } + + pub fn commit_prepared_registration( + &mut self, + source_path: String, + prepared: PreparedRegistration, + ) -> EntrypointRegistration { if prepared.diagnostics.is_empty() { self .registration_diagnostics @@ -440,11 +625,7 @@ impl CaddyConfigCoordinator { prepared.registration } - pub async fn apply( - &mut self, - registrations: &[EntrypointRegistration], - logs: &CaddyLogStore, - ) -> ConfigState { + pub fn begin_apply(&mut self, registrations: &[EntrypointRegistration]) -> CaddyApplyAction { let active_registration_ids = registrations .iter() .map(|registration| registration.registration_id.clone()) @@ -478,7 +659,7 @@ impl CaddyConfigCoordinator { effective_config_hash: self.current.effective_config_hash.clone(), diagnostics, }; - return self.current.clone(); + return CaddyApplyAction::Current(self.current.clone()); } let active: Vec<_> = registrations @@ -491,30 +672,44 @@ impl CaddyConfigCoordinator { .iter() .all(|registration| active_domains(registration).is_empty()) { - if let Err(error) = self.runtime.stop().await { - logs.append( - cadder_protocol::LogStreamIdentity::runtime_control(), - LogSeverity::Error, - error.to_string(), - LogAttributionKind::RuntimeControl, - Some("idle-stop".to_string()), - ); - } - self.current = ConfigState { - status: ConfigApplyStatus::Idle, - last_attempted_at_utc: Some(attempted), - last_successful_reload_at_utc: self.current.last_successful_reload_at_utc, - effective_config_hash: None, - diagnostics: Vec::new(), - }; - return self.current.clone(); + return CaddyApplyAction::Stop { attempted }; } let config = compose_config(&active, &self.routes, &self.iis_routes); let rendered = serde_json::to_vec_pretty(&config).expect("config serialization"); let hash = hex::encode(Sha256::digest(&rendered)); + let source_config_paths = active + .iter() + .map(|registration| registration.source_config_path.raw.clone()) + .collect(); + + CaddyApplyAction::Apply { + attempted, + rendered, + hash, + source_config_paths, + } + } + + pub fn finish_idle(&mut self, attempted: chrono::DateTime) -> ConfigState { + self.current = ConfigState { + status: ConfigApplyStatus::Idle, + last_attempted_at_utc: Some(attempted), + last_successful_reload_at_utc: self.current.last_successful_reload_at_utc, + effective_config_hash: None, + diagnostics: Vec::new(), + }; + self.current.clone() + } - match self.runtime.apply_config(&rendered, logs).await { + pub fn finish_runtime_apply( + &mut self, + attempted: chrono::DateTime, + hash: String, + source_config_paths: Vec, + result: Result<()>, + ) -> ConfigState { + match result { Ok(()) => { self.current = ConfigState { status: ConfigApplyStatus::Applied, @@ -534,10 +729,7 @@ impl CaddyConfigCoordinator { code: "runtime-apply-failed".to_string(), message: error.to_string(), domain_key: None, - source_config_paths: active - .iter() - .map(|registration| registration.source_config_path.raw.clone()) - .collect(), + source_config_paths, }], }; } @@ -545,6 +737,37 @@ impl CaddyConfigCoordinator { self.current.clone() } + pub async fn apply( + &mut self, + registrations: &[EntrypointRegistration], + logs: &CaddyLogStore, + ) -> ConfigState { + match self.begin_apply(registrations) { + CaddyApplyAction::Current(state) => state, + CaddyApplyAction::Stop { attempted } => { + if let Err(error) = self.runtime.stop().await { + logs.append( + cadder_protocol::LogStreamIdentity::runtime_control(), + LogSeverity::Error, + error.to_string(), + LogAttributionKind::RuntimeControl, + Some("idle-stop".to_string()), + ); + } + self.finish_idle(attempted) + } + CaddyApplyAction::Apply { + attempted, + rendered, + hash, + source_config_paths, + } => { + let result = self.runtime.apply_config(&rendered, logs).await; + self.finish_runtime_apply(attempted, hash, source_config_paths, result) + } + } + } + pub async fn shutdown(&mut self) -> Result<()> { self.runtime.stop().await } @@ -640,6 +863,62 @@ fn compose_config( }) } +fn mock_route_for_domain(domain: &str) -> Value { + json!({ + "match": [{ "host": [domain] }], + "handle": [{ "handler": "static_response", "body": "Cadder mock Caddy route" }], + "terminal": true + }) +} + +fn mock_caddyfile_hosts(config: &str) -> BTreeSet { + let mut hosts = BTreeSet::new(); + for line in config.lines() { + let line = line.split('#').next().unwrap_or_default().trim(); + let Some((site_labels, _)) = line.split_once('{') else { + continue; + }; + for token in site_labels.split([',', ' ', '\t']) { + if let Some(host) = mock_host_from_site_token(token) { + hosts.insert(cadder_protocol::canonicalize_domain(&host)); + } + } + } + hosts +} + +fn mock_host_from_site_token(token: &str) -> Option { + let token = token + .trim() + .trim_matches('"') + .trim_matches('\'') + .trim_end_matches(','); + if token.is_empty() || token.starts_with('@') || token.starts_with(':') { + return None; + } + + let without_scheme = token + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(token); + let without_path = without_scheme + .split_once('/') + .map(|(host, _)| host) + .unwrap_or(without_scheme); + let host = without_path + .rsplit_once(':') + .filter(|(_, port)| port.chars().all(|ch| ch.is_ascii_digit())) + .map(|(host, _)| host) + .unwrap_or(without_path) + .trim_matches('.'); + + if host.eq_ignore_ascii_case("localhost") || host.contains('.') { + Some(host.to_string()) + } else { + None + } +} + fn route_id_fragment(value: &str) -> String { value .chars() @@ -706,6 +985,54 @@ fn extract_hosts(value: &Value) -> BTreeSet { hosts } +fn extract_registered_domains(value: &Value) -> Vec { + let upstreams = extract_upstreams_by_host(value); + extract_hosts(value) + .into_iter() + .map(|host| { + let mut domain = RegisteredDomain::active(&host); + domain.upstream = upstreams.get(&host).cloned(); + domain + }) + .collect() +} + +fn extract_upstreams_by_host(value: &Value) -> BTreeMap { + let mut upstreams = BTreeMap::new(); + for route in extract_http_routes(value) { + let Some(upstream) = first_reverse_proxy_dial(&route) else { + continue; + }; + for host in extract_hosts(&route) { + upstreams.entry(host).or_insert_with(|| upstream.clone()); + } + } + upstreams +} + +fn first_reverse_proxy_dial(value: &Value) -> Option { + match value { + Value::Object(map) => { + if map.get("handler").and_then(Value::as_str) == Some("reverse_proxy") + && let Some(dial) = map + .get("upstreams") + .and_then(Value::as_array) + .and_then(|upstreams| { + upstreams + .iter() + .filter_map(|upstream| upstream.get("dial").and_then(Value::as_str)) + .find(|dial| !dial.trim().is_empty()) + }) + { + return Some(dial.to_string()); + } + map.values().find_map(first_reverse_proxy_dial) + } + Value::Array(items) => items.iter().find_map(first_reverse_proxy_dial), + _ => None, + } +} + fn collect_hosts(value: &Value, hosts: &mut BTreeSet) { match value { Value::Object(map) => { @@ -810,12 +1137,50 @@ fn detect_iis_route_conflicts( #[cfg(test)] mod tests { use super::*; + use crate::{paths::RuntimePaths, runtime::RuntimeTimeouts}; use cadder_protocol::{ ActivationState, EntrypointInstanceIdentity, LogStreamIdentity, OwnerProcessIdentity, SourcePath, }; use chrono::Utc; - use std::fs; + use std::{ffi::OsString, fs}; + + const ENV_KEYS: [&str; 2] = ["CADDER_CADDY__REAL_COMMAND", "CADDER_CADDY_REAL_COMMAND"]; + + struct EnvSnapshot { + values: Vec<(&'static str, Option)>, + } + + impl EnvSnapshot { + fn capture(keys: &[&'static str]) -> Self { + Self { + values: keys + .iter() + .copied() + .map(|key| (key, env::var_os(key))) + .collect(), + } + } + } + + impl Drop for EnvSnapshot { + fn drop(&mut self) { + for (key, value) in &self.values { + unsafe { + match value { + Some(value) => env::set_var(key, value), + None => env::remove_var(key), + } + } + } + } + } + + fn lock_env() -> std::sync::MutexGuard<'static, ()> { + crate::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } fn registration(id: &str, hosts: &[&str]) -> EntrypointRegistration { let now = Utc::now(); @@ -855,6 +1220,86 @@ mod tests { path.canonicalize().unwrap() } + #[test] + fn resolve_command_reports_missing_path_variable() { + let _guard = lock_env(); + let _env = EnvSnapshot::capture(&["PATH"]); + unsafe { + env::remove_var("PATH"); + } + + let error = resolve_command("caddy", &BTreeSet::new()).unwrap_err(); + + assert!(error.to_string().contains("PATH is not set")); + } + + #[test] + fn host_collection_and_filtering_walk_nested_values() { + let config = json!({ + "apps": { + "http": { + "servers": { + "srv0": { + "routes": [ + { + "match": [{ "host": ["App.Localhost", 7, "api.localhost"] }], + "handle": [{ + "routes": [{ + "match": [{ "host": ["nested.localhost"] }] + }] + }] + }, + { "match": [{ "path": ["/health"] }] } + ] + } + } + } + } + }); + + let hosts = extract_hosts(&config); + + assert_eq!( + hosts, + BTreeSet::from([ + "app.localhost".to_string(), + "api.localhost".to_string(), + "nested.localhost".to_string(), + ]) + ); + + let mut route = json!({ + "match": [{ "host": ["App.Localhost", "disabled.localhost", 7] }], + "handle": [{ + "routes": [{ + "match": [{ "host": ["api.localhost"] }] + }] + }] + }); + let mut retained_any = false; + filter_hosts_recursive( + &mut route, + &BTreeSet::from(["app.localhost".to_string()]), + &mut retained_any, + ); + + assert!(retained_any); + assert_eq!( + route + .pointer("/match/0/host") + .and_then(Value::as_array) + .unwrap(), + &[json!("App.Localhost")] + ); + assert!( + route + .pointer("/handle/0/routes/0/match/0/host") + .and_then(Value::as_array) + .unwrap() + .is_empty() + ); + } + fn write_fake_caddy(path: &Path) { #[cfg(windows)] fs::write( @@ -889,52 +1334,182 @@ exit 1 } } - #[test] - fn resolver_prefers_cli_override_over_working_directory_config() { - let dir = tempfile::tempdir().unwrap(); - let cli_caddy = dir.path().join("cli-caddy"); - let cwd_caddy = dir.path().join("cwd-caddy"); - write_file(&cli_caddy); - write_file(&cwd_caddy); + fn write_fake_caddy_with_adapt(path: &Path, adapt_body: &str, exit_code: i32) { + #[cfg(windows)] fs::write( - dir.path().join(CONFIG_FILE_NAME), + path, format!( - "[caddy]\nreal_command = \"{}\"\n", - cwd_caddy.display().to_string().replace('\\', "\\\\") + r#"@echo off +if "%1"=="adapt" ( + echo {adapt_body} + exit /b {exit_code} +) +exit /b 0 +"# ), ) .unwrap(); - let resolver = RealCaddyResolver::with_executable_path( - Some(cli_caddy.display().to_string()), - Some(dir.path().join("cadderd")), - ); - - assert_eq!( - resolver.resolve_for_working_directory(dir.path()).unwrap(), - canonical(&cli_caddy) - ); + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + fs::write( + path, + format!( + r#"#!/usr/bin/env sh +if [ "$1" = "adapt" ]; then + printf '%s\n' '{adapt_body}' + exit {exit_code} +fi +exit 0 +"# + ), + ) + .unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); + } } - #[test] - fn resolver_prefers_working_directory_config_over_executable_config() { - let dir = tempfile::tempdir().unwrap(); - let cwd = dir.path().join("project"); - let bin = dir.path().join("bin"); - fs::create_dir_all(&cwd).unwrap(); - fs::create_dir_all(&bin).unwrap(); - let cwd_caddy = cwd.join("cwd-caddy"); - let exe_caddy = bin.join("exe-caddy"); - write_file(&cwd_caddy); - write_file(&exe_caddy); - fs::write( - cwd.join(CONFIG_FILE_NAME), - "[caddy]\nreal_command = \"./cwd-caddy\"\n", - ) - .unwrap(); + fn write_slow_fake_caddy(path: &Path) { + #[cfg(windows)] fs::write( - bin.join(CONFIG_FILE_NAME), - "[caddy]\nreal_command = \"./exe-caddy\"\n", + path, + r#"@echo off +if "%1"=="adapt" ( + ping -n 6 127.0.0.1 >nul + echo {"apps":{}} + exit /b 0 +) +exit /b 0 +"#, + ) + .unwrap(); + + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + fs::write( + path, + r#"#!/usr/bin/env sh +if [ "$1" = "adapt" ]; then + sleep 1 + printf '%s\n' '{"apps":{}}' + exit 0 +fi +exit 0 +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); + } + } + + fn write_runtime_fake_caddy(path: &Path) { + #[cfg(windows)] + fs::write( + path, + r#"@echo off +if "%1"=="adapt" ( + echo {"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["project.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}} + exit /b 0 +) +if "%1"=="reload" exit /b 0 +if "%1"=="stop" ( + ping -n 3 127.0.0.1 >nul + exit /b 0 +) +if "%1"=="run" ( + :run_loop + ping -n 2 127.0.0.1 >nul + goto run_loop +) +exit /b 1 +"#, + ) + .unwrap(); + + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + fs::write( + path, + r#"#!/usr/bin/env sh +case "$1" in + adapt) + printf '%s\n' '{"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["project.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}}' + exit 0 + ;; + reload) + exit 0 + ;; + stop) + sleep 1 + exit 0 + ;; + run) + while true; do sleep 1; done + ;; +esac +exit 1 +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); + } + } + + #[test] + fn resolver_prefers_cli_override_over_working_directory_config() { + let dir = tempfile::tempdir().unwrap(); + let cli_caddy = dir.path().join("cli-caddy"); + let cwd_caddy = dir.path().join("cwd-caddy"); + write_file(&cli_caddy); + write_file(&cwd_caddy); + fs::write( + dir.path().join(CONFIG_FILE_NAME), + format!( + "[caddy]\nreal_command = \"{}\"\n", + cwd_caddy.display().to_string().replace('\\', "\\\\") + ), + ) + .unwrap(); + + let resolver = RealCaddyResolver::with_executable_path( + Some(cli_caddy.display().to_string()), + Some(dir.path().join("cadderd")), + ); + + assert_eq!( + resolver.resolve_for_working_directory(dir.path()).unwrap(), + canonical(&cli_caddy) + ); + } + + #[test] + fn resolver_prefers_working_directory_config_over_executable_config() { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + let bin = dir.path().join("bin"); + fs::create_dir_all(&cwd).unwrap(); + fs::create_dir_all(&bin).unwrap(); + let cwd_caddy = cwd.join("cwd-caddy"); + let exe_caddy = bin.join("exe-caddy"); + write_file(&cwd_caddy); + write_file(&exe_caddy); + fs::write( + cwd.join(CONFIG_FILE_NAME), + "[caddy]\nreal_command = \"./cwd-caddy\"\n", + ) + .unwrap(); + fs::write( + bin.join(CONFIG_FILE_NAME), + "[caddy]\nreal_command = \"./exe-caddy\"\n", ) .unwrap(); @@ -971,6 +1546,32 @@ exit 1 ); } + #[test] + fn resolver_uses_environment_real_command_when_no_file_config_exists() { + let _guard = lock_env(); + let _snapshot = EnvSnapshot::capture(&ENV_KEYS); + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + let bin = dir.path().join("bin"); + fs::create_dir_all(&cwd).unwrap(); + fs::create_dir_all(&bin).unwrap(); + let env_caddy = bin.join("env-caddy"); + write_file(&env_caddy); + unsafe { + env::remove_var("CADDER_CADDY__REAL_COMMAND"); + env::set_var("CADDER_CADDY_REAL_COMMAND", env_caddy.display().to_string()); + } + let resolver = RealCaddyResolver::with_executable_path( + None, + Some(dir.path().join(exe_name_for_test("cadderd"))), + ); + + assert_eq!( + resolver.resolve_for_working_directory(&cwd).unwrap(), + canonical(&env_caddy) + ); + } + #[tokio::test] async fn adapter_resolves_real_caddy_from_registration_working_directory_config() { let dir = tempfile::tempdir().unwrap(); @@ -1018,6 +1619,280 @@ exit 1 ); } + #[tokio::test] + async fn mock_adapter_prepares_domains_without_running_caddy_adapt() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("Caddyfile"); + fs::write( + &config_path, + r#" +app.localhost, http://api.localhost:8080 { + respond ok +} +"#, + ) + .unwrap(); + let mut registration = registration("project", &[]); + registration.source_config_path = SourcePath::new(config_path.display().to_string(), None); + + let prepared = MockCaddyConfigAdapter.prepare(registration).await; + + assert!(prepared.diagnostics.is_empty(), "{prepared:?}"); + assert_eq!( + prepared + .registration + .registered_domains + .iter() + .map(|domain| domain.name.canonical.as_str()) + .collect::>(), + vec!["api.localhost", "app.localhost"] + ); + assert_eq!(prepared.routes.len(), 2); + } + + #[tokio::test] + async fn mock_coordinator_applies_effective_config_without_real_caddy_process() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let mut coordinator = CaddyConfigCoordinator::new_mock(paths.clone()); + let logs = CaddyLogStore::new(20, 20); + let registration = registration("project", &["app.localhost"]); + let prepared = PreparedRegistration { + registration: registration.clone(), + routes: vec![mock_route_for_domain("app.localhost")], + diagnostics: Vec::new(), + }; + coordinator.commit_prepared_registration("Caddyfile".to_string(), prepared); + + let state = coordinator.apply(&[registration], &logs).await; + let runtime = coordinator.runtime_state().await; + + assert_eq!(state.status, ConfigApplyStatus::Applied); + assert_eq!(runtime.status, cadder_protocol::RuntimeStatus::Running); + assert_eq!(runtime.binary_path.as_deref(), Some("mock-caddy")); + assert!(paths.effective_config_path().is_file()); + } + + #[tokio::test] + async fn adapter_uses_canonical_config_path_without_shim_metadata() { + let dir = tempfile::tempdir().unwrap(); + let fake_caddy = dir.path().join(fake_caddy_name_for_test()); + write_fake_caddy(&fake_caddy); + let project_cwd = dir.path().join("project"); + fs::create_dir_all(&project_cwd).unwrap(); + let config_path = project_cwd.join("Caddyfile"); + fs::write(&config_path, "project.localhost { respond ok }").unwrap(); + let mut registration = registration("project", &[]); + registration.source_working_directory = SourcePath::new( + project_cwd.display().to_string(), + Some(project_cwd.canonicalize().unwrap().display().to_string()), + ); + registration.source_config_path = SourcePath::new( + config_path.display().to_string(), + Some(config_path.canonicalize().unwrap().display().to_string()), + ); + + let adapter = CaddyConfigAdapter::new(RealCaddyResolver::new(Some( + fake_caddy.display().to_string(), + ))); + let prepared = adapter.prepare(registration).await; + + assert!(prepared.diagnostics.is_empty(), "{prepared:?}"); + assert!(prepared.registration.shim_run.is_none()); + assert_eq!(prepared.routes.len(), 1); + assert_eq!( + prepared.registration.registered_domains[0].name.canonical, + "project.localhost" + ); + } + + #[tokio::test] + async fn prepare_registration_commits_routes_on_success() { + let dir = tempfile::tempdir().unwrap(); + let fake_caddy = dir.path().join(fake_caddy_name_for_test()); + write_fake_caddy(&fake_caddy); + let config_path = dir.path().join("Caddyfile"); + fs::write(&config_path, "project.localhost { respond ok }").unwrap(); + let resolver = RealCaddyResolver::with_executable_path( + Some(fake_caddy.display().to_string()), + Some(dir.path().join(exe_name_for_test("cadderd"))), + ); + let adapter = CaddyConfigAdapter::new(resolver.clone()); + let paths = RuntimePaths::resolve(Some(dir.path().join("run"))).unwrap(); + paths.ensure_dirs().unwrap(); + let runtime = ProcessRuntime::new(resolver, paths); + let mut coordinator = CaddyConfigCoordinator::new(adapter, runtime); + let mut registration = registration("project", &[]); + registration.source_config_path = SourcePath::new( + config_path.display().to_string(), + Some(config_path.display().to_string()), + ); + + let prepared = coordinator.prepare_registration(registration).await; + + assert_eq!(prepared.registered_domains.len(), 1); + assert_eq!( + prepared.registered_domains[0].name.canonical, + "project.localhost" + ); + assert_eq!(coordinator.routes["project"].len(), 1); + assert!(!coordinator.registration_diagnostics.contains_key("project")); + } + + #[tokio::test] + async fn adapter_reports_missing_real_caddy_command() { + let dir = tempfile::tempdir().unwrap(); + let mut registration = registration("project", &[]); + registration.source_config_path = SourcePath::new( + dir.path().join("missing.Caddyfile").display().to_string(), + None, + ); + let adapter = CaddyConfigAdapter::new(RealCaddyResolver::with_executable_path( + Some("definitely-missing-caddy".to_string()), + Some(dir.path().join(exe_name_for_test("cadderd"))), + )); + + let prepared = adapter.prepare(registration).await; + + assert_eq!(prepared.diagnostics[0].code, "adapt-failed"); + assert!( + prepared.diagnostics[0] + .message + .contains("resolve real Caddy command") + ); + assert!(prepared.routes.is_empty()); + } + + #[tokio::test] + async fn adapter_reports_caddy_adapt_failure_and_invalid_json() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("Caddyfile"); + fs::write(&config_path, "app.localhost { respond ok }").unwrap(); + let failing_caddy = dir.path().join(fake_caddy_name_for_test()); + write_fake_caddy_with_adapt(&failing_caddy, "adapt failed", 7); + let adapter = CaddyConfigAdapter::new(RealCaddyResolver::with_executable_path( + Some(failing_caddy.display().to_string()), + Some(dir.path().join(exe_name_for_test("cadderd"))), + )); + let mut registration = registration("project", &[]); + registration.source_config_path = SourcePath::new( + config_path.display().to_string(), + Some(config_path.display().to_string()), + ); + + let failed = adapter.prepare(registration.clone()).await; + assert_eq!(failed.diagnostics[0].code, "adapt-failed"); + assert!(failed.diagnostics[0].message.contains("adapt failed")); + + write_fake_caddy_with_adapt(&failing_caddy, "not-json", 0); + let invalid = adapter.prepare(registration).await; + assert_eq!(invalid.diagnostics[0].code, "adapt-failed"); + assert!(invalid.diagnostics[0].message.contains("parse adapted")); + } + + #[tokio::test] + async fn adapter_reports_adapt_timeout() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("Caddyfile"); + fs::write(&config_path, "app.localhost { respond ok }").unwrap(); + let slow_caddy = dir.path().join(fake_caddy_name_for_test()); + write_slow_fake_caddy(&slow_caddy); + let adapter = CaddyConfigAdapter::with_command_timeout( + RealCaddyResolver::with_executable_path( + Some(slow_caddy.display().to_string()), + Some(dir.path().join(exe_name_for_test("cadderd"))), + ), + Duration::from_millis(250), + ); + let mut registration = registration("project", &[]); + registration.source_config_path = SourcePath::new( + config_path.display().to_string(), + Some(config_path.display().to_string()), + ); + + let prepared = adapter.prepare(registration).await; + + assert_eq!(prepared.diagnostics[0].code, "adapt-failed"); + assert!(prepared.diagnostics[0].message.contains("timed out")); + } + + #[test] + fn resolver_resolution_help_and_path_edges_are_stable() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-caddy"); + let error = resolve_command(&missing.display().to_string(), &BTreeSet::new()).unwrap_err(); + let help = RealCaddyResolver::resolution_help(&error); + + assert!(help.contains("Cadder could not resolve")); + assert_eq!(anchor_configured_command("caddy", dir.path()), "caddy"); + assert!(anchor_configured_command("./bin/caddy", dir.path()).contains("bin")); + assert_eq!(trimmed(Some(" caddy ")).as_deref(), Some("caddy")); + assert_eq!(trimmed(Some(" ")), None); + } + + #[test] + fn resolver_reports_no_selected_command_when_no_source_is_configured() { + let _guard = lock_env(); + let _snapshot = EnvSnapshot::capture(&ENV_KEYS); + unsafe { + env::remove_var("CADDER_CADDY__REAL_COMMAND"); + env::remove_var("CADDER_CADDY_REAL_COMMAND"); + } + let dir = tempfile::tempdir().unwrap(); + let resolver = RealCaddyResolver::with_executable_path(None, None); + + let selected = resolver.selected_command(dir.path()).unwrap(); + let explicit = SelectedCaddyCommand::new("caddy".to_string(), "test".to_string()); + + assert!(selected.is_none()); + assert_eq!(resolver.executable_config_path(), None); + assert_eq!(explicit.command, "caddy"); + assert_eq!(explicit.source, "test"); + assert!(format!("{explicit:?}").contains("SelectedCaddyCommand")); + } + + #[tokio::test] + async fn coordinator_accessors_iis_routes_and_idle_shutdown_are_stable() { + let mut coordinator = coordinator_for_test(); + + assert_eq!(coordinator.current_state().status, ConfigApplyStatus::Idle); + assert_eq!( + coordinator.runtime_state().await.status, + cadder_protocol::RuntimeStatus::Idle + ); + assert!(!coordinator.has_iis_proxy_routes_except("app.localhost")); + + coordinator.set_iis_proxy_route( + "Default Web Site|http|*:80:app.localhost", + "app.localhost", + "127.0.0.1:53000", + IisProxyBackendProtocol::Http, + ); + + assert!(!coordinator.has_iis_proxy_routes_except("app.localhost")); + assert!(coordinator.has_iis_proxy_routes_except("other.localhost")); + let CaddyApplyAction::Apply { + rendered, + source_config_paths, + .. + } = coordinator.begin_apply(&[]) + else { + panic!("expected IIS route-only apply action"); + }; + let config: Value = serde_json::from_slice(&rendered).unwrap(); + assert!(source_config_paths.is_empty()); + assert_eq!( + config + .pointer("/apps/http/servers/cadder_https/routes/0/match/0/host/0") + .and_then(Value::as_str), + Some("app.localhost") + ); + + coordinator.remove_iis_proxy_route("app.localhost"); + assert!(!coordinator.has_iis_proxy_routes_except("app.localhost")); + coordinator.shutdown().await.unwrap(); + } + #[test] fn resolve_command_rejects_configured_shim_path() { let dir = tempfile::tempdir().unwrap(); @@ -1042,6 +1917,25 @@ exit 1 assert!(error.to_string().contains("command `caddy` not found")); } + #[test] + fn path_resolution_skips_excluded_shim_and_uses_next_caddy_candidate() { + let dir = tempfile::tempdir().unwrap(); + let shim_dir = dir.path().join("shim"); + let real_dir = dir.path().join("real"); + fs::create_dir_all(&shim_dir).unwrap(); + fs::create_dir_all(&real_dir).unwrap(); + let shim = shim_dir.join(exe_name_for_test("caddy")); + let real = real_dir.join(exe_name_for_test("caddy")); + write_file(&shim); + write_file(&real); + let path_var = env::join_paths([shim_dir.as_path(), real_dir.as_path()]).unwrap(); + + let resolved = + resolve_command_with_path("caddy", &BTreeSet::from([canonical(&shim)]), &path_var).unwrap(); + + assert_eq!(resolved, canonical(&real)); + } + #[cfg(windows)] fn exe_name_for_test(name: &str) -> String { format!("{name}.exe") @@ -1062,6 +1956,416 @@ exit 1 "fake-caddy" } + struct CoordinatorFixture { + coordinator: CaddyConfigCoordinator, + _temp: tempfile::TempDir, + } + + impl std::ops::Deref for CoordinatorFixture { + type Target = CaddyConfigCoordinator; + + fn deref(&self) -> &Self::Target { + &self.coordinator + } + } + + impl std::ops::DerefMut for CoordinatorFixture { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.coordinator + } + } + + fn coordinator_for_test() -> CoordinatorFixture { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("run"))).unwrap(); + paths.ensure_dirs().unwrap(); + let resolver = RealCaddyResolver::with_executable_path( + Some("missing-caddy".to_string()), + Some(temp.path().join(exe_name_for_test("cadderd"))), + ); + let adapter = CaddyConfigAdapter::new(resolver.clone()); + let runtime = ProcessRuntime::new(resolver, paths); + CoordinatorFixture { + coordinator: CaddyConfigCoordinator::new(adapter, runtime), + _temp: temp, + } + } + + fn diagnostic(code: &str) -> ConfigDiagnostic { + ConfigDiagnostic { + code: code.to_string(), + message: format!("{code} message"), + domain_key: None, + source_config_paths: Vec::new(), + } + } + + #[test] + fn commit_prepared_registration_records_diagnostics_and_removes_routes() { + let mut coordinator = coordinator_for_test(); + coordinator.routes.insert( + "shim".to_string(), + vec![json!({ "match": [{ "host": ["app.localhost"] }] })], + ); + let prepared = PreparedRegistration { + registration: registration("shim", &["app.localhost"]), + routes: vec![json!({ "match": [{ "host": ["app.localhost"] }] })], + diagnostics: vec![diagnostic("adapt-failed")], + }; + + coordinator.commit_prepared_registration("shim.Caddyfile".to_string(), prepared); + + assert!(!coordinator.routes.contains_key("shim")); + let diagnostics = &coordinator.registration_diagnostics["shim"]; + assert_eq!(diagnostics[0].source_config_paths, ["shim.Caddyfile"]); + assert_eq!(coordinator.current.status, ConfigApplyStatus::Failed); + } + + #[test] + fn begin_apply_returns_current_failed_state_for_enabled_diagnostics() { + let mut coordinator = coordinator_for_test(); + coordinator.registration_diagnostics.insert( + "shim".to_string(), + vec![ConfigDiagnostic { + source_config_paths: vec!["shim.Caddyfile".to_string()], + ..diagnostic("adapt-failed") + }], + ); + + let action = coordinator.begin_apply(&[registration("shim", &["app.localhost"])]); + + let CaddyApplyAction::Current(state) = action else { + panic!("expected current failed state"); + }; + assert_eq!(state.status, ConfigApplyStatus::Failed); + assert_eq!(state.diagnostics[0].code, "adapt-failed"); + } + + #[test] + fn begin_apply_returns_stop_when_no_routes_are_active() { + let mut coordinator = coordinator_for_test(); + + let action = coordinator.begin_apply(&[registration("shim", &[])]); + + assert!(matches!(action, CaddyApplyAction::Stop { .. })); + } + + #[test] + fn begin_apply_builds_rendered_config_and_prunes_stale_state() { + let mut coordinator = coordinator_for_test(); + coordinator.routes.insert( + "stale".to_string(), + vec![json!({ "match": [{ "host": ["stale.localhost"] }] })], + ); + coordinator + .registration_diagnostics + .insert("stale".to_string(), vec![diagnostic("adapt-failed")]); + + let action = coordinator.begin_apply(&[registration("shim", &["app.localhost"])]); + + let CaddyApplyAction::Apply { + rendered, + hash, + source_config_paths, + .. + } = action + else { + panic!("expected apply action"); + }; + let config: Value = serde_json::from_slice(&rendered).unwrap(); + assert!(!hash.is_empty()); + assert_eq!(source_config_paths, ["shim.Caddyfile"]); + assert_eq!( + config + .pointer("/apps/http/servers/cadder_https/routes/0/match/0/host/0") + .and_then(Value::as_str), + Some("app.localhost") + ); + assert!(coordinator.routes.is_empty()); + assert!(coordinator.registration_diagnostics.is_empty()); + } + + #[test] + fn compose_config_uses_placeholder_route_when_adapted_routes_are_missing() { + let registrations = vec![registration("shim", &["app.localhost"])]; + let config = compose_config(®istrations, &BTreeMap::new(), &BTreeMap::new()); + + assert_eq!( + config + .pointer("/apps/http/servers/cadder_https/routes/0/handle/0/body") + .and_then(Value::as_str), + Some("Cadder route placeholder") + ); + assert_eq!( + config + .pointer("/apps/tls/automation/policies/0/subjects/0") + .and_then(Value::as_str), + Some("app.localhost") + ); + } + + #[test] + fn compose_config_drops_routes_for_disabled_domains() { + let mut registration = registration("shim", &["app.localhost", "api.localhost"]); + registration.registered_domains[1].activation_state = ActivationState::Inactive; + let routes_by_registration = BTreeMap::from([( + "shim".to_string(), + vec![json!({ + "match": [{ "host": ["app.localhost", "api.localhost"] }], + "handle": [{ "handler": "static_response", "body": "mixed" }], + "terminal": true + })], + )]); + + let config = compose_config(&[registration], &routes_by_registration, &BTreeMap::new()); + let hosts = config + .pointer("/apps/http/servers/cadder_https/routes/0/match/0/host") + .and_then(Value::as_array) + .unwrap(); + let tls_subjects = config + .pointer("/apps/tls/automation/policies/0/subjects") + .and_then(Value::as_array) + .unwrap(); + + assert_eq!(hosts, &[json!("app.localhost")]); + assert_eq!(tls_subjects, &[json!("app.localhost")]); + } + + #[test] + fn compose_config_drops_inactive_domain_routes_but_keeps_iis_subjects() { + let mut registration = registration("shim", &["app.localhost"]); + registration.registered_domains[0].activation_state = ActivationState::Inactive; + let routes_by_registration = BTreeMap::from([( + "shim".to_string(), + vec![json!({ + "match": [{ "host": ["app.localhost"] }], + "handle": [{ "handler": "static_response", "body": "app" }], + "terminal": true + })], + )]); + let iis_routes = BTreeMap::from([( + "iis.localhost".to_string(), + IisProxyRoute { + domain_key: "iis.localhost".to_string(), + route: iis_proxy_route( + "Default Web Site|http|*:80:iis.localhost", + "iis.localhost", + "127.0.0.1:41080", + IisProxyBackendProtocol::Http, + ), + }, + )]); + + let config = compose_config(&[registration], &routes_by_registration, &iis_routes); + let routes = config + .pointer("/apps/http/servers/cadder_https/routes") + .and_then(Value::as_array) + .unwrap(); + let tls_subjects = config + .pointer("/apps/tls/automation/policies/0/subjects") + .and_then(Value::as_array) + .unwrap(); + + assert_eq!(routes.len(), 1); + assert_eq!( + routes[0].pointer("/match/0/host/0").and_then(Value::as_str), + Some("iis.localhost") + ); + assert_eq!(tls_subjects, &[json!("iis.localhost")]); + } + + #[test] + fn finish_apply_state_updates_success_failure_and_idle() { + let mut coordinator = coordinator_for_test(); + let attempted = Utc::now(); + + let applied = coordinator.finish_runtime_apply(attempted, "hash-1".to_string(), vec![], Ok(())); + assert_eq!(applied.status, ConfigApplyStatus::Applied); + assert_eq!(applied.effective_config_hash.as_deref(), Some("hash-1")); + + let failed = coordinator.finish_runtime_apply( + attempted, + "hash-2".to_string(), + vec!["shim.Caddyfile".to_string()], + Err(anyhow::anyhow!("runtime exploded")), + ); + assert_eq!(failed.status, ConfigApplyStatus::Failed); + assert_eq!(failed.effective_config_hash.as_deref(), Some("hash-1")); + assert_eq!(failed.diagnostics[0].code, "runtime-apply-failed"); + assert_eq!( + failed.diagnostics[0].source_config_paths, + ["shim.Caddyfile"] + ); + + let idle = coordinator.finish_idle(attempted); + assert_eq!(idle.status, ConfigApplyStatus::Idle); + assert_eq!(idle.effective_config_hash, None); + assert!(idle.diagnostics.is_empty()); + } + + #[tokio::test] + async fn public_control_types_keep_clone_debug_and_accessor_contracts() { + let resolver = RealCaddyResolver::new(None); + let adapter = CaddyConfigAdapter::new(resolver.clone()); + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().to_path_buf())).unwrap(); + let runtime = ProcessRuntime::new(resolver.clone(), paths); + let coordinator = CaddyConfigCoordinator::new(adapter.clone(), runtime.clone()); + let attempted = Utc::now(); + let apply_action = CaddyApplyAction::Apply { + attempted, + rendered: br#"{"apps":{}}"#.to_vec(), + hash: "hash".to_string(), + source_config_paths: vec!["Caddyfile".to_string()], + }; + let stop_action = CaddyApplyAction::Stop { attempted }; + let current_action = CaddyApplyAction::Current(ConfigState::idle()); + let proxy_route = IisProxyRoute { + domain_key: "app.localhost".to_string(), + route: iis_proxy_route( + "Default Web Site|http|*:80:app.localhost", + "app.localhost", + "127.0.0.1:41000", + IisProxyBackendProtocol::Http, + ), + }; + let prepared = PreparedRegistration { + registration: registration("shim", &["app.localhost"]), + routes: vec![json!({ "match": [{ "host": ["app.localhost"] }] })], + diagnostics: Vec::new(), + }; + + assert!(format!("{:?}", resolver.clone()).contains("RealCaddyResolver")); + assert!(format!("{:?}", adapter.clone()).contains("CaddyConfigAdapter")); + assert!(format!("{:?}", prepared.clone()).contains("PreparedRegistration")); + assert!(format!("{:?}", apply_action).contains("Apply")); + assert!(format!("{:?}", stop_action).contains("Stop")); + assert!(format!("{:?}", current_action).contains("Current")); + assert!(format!("{:?}", proxy_route.clone()).contains("IisProxyRoute")); + assert_eq!(coordinator.current_state().status, ConfigApplyStatus::Idle); + assert_eq!( + coordinator.runtime_state().await.status, + cadder_protocol::RuntimeStatus::Idle + ); + let _ = coordinator.adapter(); + let _ = coordinator.runtime(); + } + + #[tokio::test] + async fn apply_wrapper_handles_current_stop_and_runtime_failure_actions() { + let logs = CaddyLogStore::default(); + let mut current = coordinator_for_test(); + current.registration_diagnostics.insert( + "shim".to_string(), + vec![ConfigDiagnostic { + source_config_paths: vec!["shim.Caddyfile".to_string()], + ..diagnostic("adapt-failed") + }], + ); + + let current_state = current + .apply(&[registration("shim", &["app.localhost"])], &logs) + .await; + let mut idle = coordinator_for_test(); + let idle_state = idle.apply(&[registration("shim", &[])], &logs).await; + let mut failed = coordinator_for_test(); + let failed_state = failed + .apply(&[registration("shim", &["app.localhost"])], &logs) + .await; + let shutdown = failed.shutdown().await; + + assert_eq!(current_state.status, ConfigApplyStatus::Failed); + assert_eq!(idle_state.status, ConfigApplyStatus::Idle); + assert_eq!(failed_state.status, ConfigApplyStatus::Failed); + assert_eq!(failed_state.diagnostics[0].code, "runtime-apply-failed"); + assert!(shutdown.is_ok()); + } + + #[tokio::test] + async fn apply_wrapper_logs_stop_error_when_idling_running_runtime() { + let dir = tempfile::tempdir().unwrap(); + let fake_caddy = dir.path().join(fake_caddy_name_for_test()); + write_runtime_fake_caddy(&fake_caddy); + let resolver = RealCaddyResolver::with_executable_path( + Some(fake_caddy.display().to_string()), + Some(dir.path().join(exe_name_for_test("cadderd"))), + ); + let adapter = CaddyConfigAdapter::new(resolver.clone()); + let paths = RuntimePaths::resolve(Some(dir.path().join("run"))).unwrap(); + paths.ensure_dirs().unwrap(); + let runtime = ProcessRuntime::with_timeouts( + resolver, + paths, + RuntimeTimeouts { + start_check: Duration::from_millis(150), + reload: Duration::from_millis(500), + graceful_stop: Duration::from_millis(100), + stop_wait: Duration::from_millis(200), + kill_wait: Duration::from_secs(1), + }, + ); + let logs = CaddyLogStore::default(); + let mut coordinator = CaddyConfigCoordinator::new(adapter, runtime); + let active = registration("shim", &["project.localhost"]); + let started = coordinator + .apply(std::slice::from_ref(&active), &logs) + .await; + let idle = coordinator.apply(&[registration("shim", &[])], &logs).await; + let runtime_logs = logs.query( + crate::logs::LogQuery { + stream: cadder_protocol::LogStreamIdentity::runtime_control(), + limit: 20, + after_sequence: None, + minimum_severity: None, + }, + true, + ); + + assert_eq!(started.status, ConfigApplyStatus::Applied); + assert_eq!(idle.status, ConfigApplyStatus::Idle); + assert!(runtime_logs.entries.iter().any(|entry| { + entry.operation.as_deref() == Some("idle-stop") + && entry.raw_message.contains("caddy stop timed out") + })); + } + + #[test] + fn iis_proxy_route_helpers_track_case_insensitive_exceptions() { + let mut coordinator = coordinator_for_test(); + coordinator.set_iis_proxy_route( + "Default Web Site|http|*:80:app.localhost", + "app.localhost", + "127.0.0.1:41043", + IisProxyBackendProtocol::Http, + ); + coordinator.set_iis_proxy_route( + "Default Web Site|http|*:80:api.localhost", + "api.localhost", + "127.0.0.1:41044", + IisProxyBackendProtocol::Http, + ); + + assert!(coordinator.has_iis_proxy_routes_except("APP.localhost")); + coordinator.remove_iis_proxy_route("api.localhost"); + assert!(!coordinator.has_iis_proxy_routes_except("APP.localhost")); + } + + #[test] + fn iis_protocol_and_route_id_helpers_are_stable() { + assert_eq!( + IisProxyBackendProtocol::from_iis_protocol("HTTPS"), + IisProxyBackendProtocol::Https + ); + assert_eq!( + IisProxyBackendProtocol::from_iis_protocol("http"), + IisProxyBackendProtocol::Http + ); + assert_eq!( + route_id_fragment("Default Web Site|HTTPS|*:443:App.Localhost"), + "default_web_site_https___443_app_localhost" + ); + } + #[test] fn extracts_hosts_from_adapted_json() { let adapted = json!({ @@ -1083,6 +2387,40 @@ exit 1 assert!(hosts.contains("api.localhost")); } + #[test] + fn extracts_reverse_proxy_upstream_for_registered_domain() { + let adapted = json!({ + "apps": { + "http": { + "servers": { + "srv0": { + "routes": [{ + "match": [{ "host": ["App.Localhost"] }], + "handle": [{ + "handler": "reverse_proxy", + "upstreams": [{ "dial": "127.0.0.1:19087" }] + }], + "terminal": true + }] + } + } + } + } + }); + + let domains = extract_registered_domains(&adapted); + + assert_eq!(domains.len(), 1); + assert_eq!(domains[0].name.canonical, "app.localhost"); + assert_eq!(domains[0].upstream.as_deref(), Some("127.0.0.1:19087")); + } + + #[test] + fn extraction_and_filtering_helpers_handle_empty_shapes() { + assert!(extract_http_routes(&json!({ "apps": { "tls": {} } })).is_empty()); + assert!(filter_route_hosts(json!({ "handle": [] }), &BTreeSet::new()).is_none()); + } + #[test] fn detects_active_domain_conflicts() { let left = registration("left", &["app.localhost"]); @@ -1094,6 +2432,19 @@ exit 1 assert_eq!(diagnostics[0].domain_key.as_deref(), Some("app.localhost")); } + #[test] + fn conflict_detection_ignores_inactive_registrations_and_domains() { + let active = registration("active", &["app.localhost"]); + let mut inactive_registration = registration("inactive-registration", &["app.localhost"]); + inactive_registration.activation_state = ActivationState::Inactive; + let mut inactive_domain = registration("inactive-domain", &["app.localhost"]); + inactive_domain.registered_domains[0].activation_state = ActivationState::Inactive; + + let diagnostics = detect_conflicts(&[active, inactive_registration, inactive_domain]); + + assert!(diagnostics.is_empty()); + } + #[test] fn filters_routes_to_enabled_hosts() { let route = json!({ @@ -1185,6 +2536,43 @@ exit 1 ); } + #[test] + fn compose_config_includes_iis_only_routes_in_http_and_https_servers() { + let iis_routes = BTreeMap::from([( + "secure.localhost".to_string(), + IisProxyRoute { + domain_key: "secure.localhost".to_string(), + route: iis_proxy_route( + "Default Web Site|https|*:443:secure.localhost", + "secure.localhost", + "127.0.0.1:41443", + IisProxyBackendProtocol::Https, + ), + }, + )]); + + let config = compose_config(&[], &BTreeMap::new(), &iis_routes); + let http_route = config + .pointer("/apps/http/servers/cadder_http/routes/0") + .unwrap(); + let https_route = config + .pointer("/apps/http/servers/cadder_https/routes/0") + .unwrap(); + let tls_subjects = config + .pointer("/apps/tls/automation/policies/0/subjects") + .and_then(Value::as_array) + .unwrap(); + + assert_eq!(http_route, https_route); + assert_eq!( + https_route + .pointer("/handle/0/transport/tls/server_name") + .and_then(Value::as_str), + Some("secure.localhost") + ); + assert_eq!(tls_subjects, &[json!("secure.localhost")]); + } + #[test] fn iis_proxy_route_leaves_http_backend_plaintext() { let route = iis_proxy_route( diff --git a/crates/cadder-daemon/src/config.rs b/crates/cadder-daemon/src/config.rs index 1615ad2..4c604ae 100644 --- a/crates/cadder-daemon/src/config.rs +++ b/crates/cadder-daemon/src/config.rs @@ -65,7 +65,44 @@ fn env_value(key: &str) -> Option { #[cfg(test)] mod tests { use super::*; - use std::fs; + use std::{ffi::OsString, fs}; + + const ENV_KEYS: [&str; 2] = ["CADDER_CADDY__REAL_COMMAND", "CADDER_CADDY_REAL_COMMAND"]; + + struct EnvSnapshot { + values: Vec<(&'static str, Option)>, + } + + impl EnvSnapshot { + fn capture(keys: &[&'static str]) -> Self { + Self { + values: keys + .iter() + .copied() + .map(|key| (key, env::var_os(key))) + .collect(), + } + } + } + + impl Drop for EnvSnapshot { + fn drop(&mut self) { + for (key, value) in &self.values { + unsafe { + match value { + Some(value) => env::set_var(key, value), + None => env::remove_var(key), + } + } + } + } + } + + fn lock_env() -> std::sync::MutexGuard<'static, ()> { + crate::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } #[test] fn reads_real_command_from_toml_file() { @@ -106,6 +143,54 @@ mod tests { ); } + #[test] + fn from_environment_reads_split_caddy_real_command() { + let _lock = lock_env(); + let _snapshot = EnvSnapshot::capture(&ENV_KEYS); + unsafe { + env::remove_var("CADDER_CADDY_REAL_COMMAND"); + env::set_var("CADDER_CADDY__REAL_COMMAND", "env-caddy"); + } + + let config = CadderConfig::from_environment().unwrap(); + + assert_eq!( + configured_real_caddy_command(&config).as_deref(), + Some("env-caddy") + ); + } + + #[test] + fn from_environment_legacy_real_command_overrides_split_key() { + let _lock = lock_env(); + let _snapshot = EnvSnapshot::capture(&ENV_KEYS); + unsafe { + env::set_var("CADDER_CADDY__REAL_COMMAND", "split-caddy"); + env::set_var("CADDER_CADDY_REAL_COMMAND", " legacy-caddy "); + } + + let config = CadderConfig::from_environment().unwrap(); + + assert_eq!( + configured_real_caddy_command(&config).as_deref(), + Some("legacy-caddy") + ); + } + + #[test] + fn from_environment_ignores_blank_real_command_overrides() { + let _lock = lock_env(); + let _snapshot = EnvSnapshot::capture(&ENV_KEYS); + unsafe { + env::set_var("CADDER_CADDY__REAL_COMMAND", " "); + env::set_var("CADDER_CADDY_REAL_COMMAND", " "); + } + + let config = CadderConfig::from_environment().unwrap(); + + assert_eq!(configured_real_caddy_command(&config), None); + } + #[test] fn from_file_reports_invalid_toml() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/cadder-daemon/src/iis.rs b/crates/cadder-daemon/src/iis.rs index 9a5116a..ac54dbb 100644 --- a/crates/cadder-daemon/src/iis.rs +++ b/crates/cadder-daemon/src/iis.rs @@ -7,6 +7,9 @@ use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; use tokio::sync::Mutex; +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct IisBindingRecord { @@ -349,6 +352,13 @@ impl IisProvider { } } + #[cfg(test)] + pub async fn set_fail_discovery(&self, issue: IisIssue) { + if let IisProviderInner::Fake(state) = &self.inner { + state.lock().await.fail_discovery = Some(issue); + } + } + #[cfg(test)] pub async fn set_fail_remove(&self, issue: IisIssue) { if let IisProviderInner::Fake(state) = &self.inner { @@ -442,8 +452,6 @@ impl FakeIisProviderState { #[cfg(windows)] async fn system_discover() -> Result, IisIssue> { - use tokio::process::Command; - let script = r#" $ErrorActionPreference = 'Stop' Import-Module WebAdministration -ErrorAction Stop @@ -467,7 +475,7 @@ Get-WebBinding | ForEach-Object { } } | ConvertTo-Json -Depth 4 "#; - let output = Command::new("powershell") + let output = powershell_command() .arg("-NoProfile") .arg("-NonInteractive") .arg("-Command") @@ -640,9 +648,7 @@ async fn system_execute_privileged_batch( #[cfg(windows)] async fn run_powershell_mutation(script: String) -> Result<(), IisIssue> { - use tokio::process::Command; - - let output = Command::new("powershell") + let output = powershell_command() .arg("-NoProfile") .arg("-NonInteractive") .arg("-Command") @@ -659,8 +665,6 @@ async fn run_powershell_mutation(script: String) -> Result<(), IisIssue> { #[cfg(windows)] async fn run_elevated_powershell_batch(reason: &str, script: String) -> Result<(), IisIssue> { - use tokio::process::Command; - let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_nanos()) @@ -697,7 +701,7 @@ try {{ "$ErrorActionPreference = 'Stop'; $p = Start-Process -FilePath 'powershell' -Verb RunAs -Wait -PassThru -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode", ps_escape(&script_path.display().to_string()) ); - let output = Command::new("powershell") + let output = powershell_command() .arg("-NoProfile") .arg("-NonInteractive") .arg("-Command") @@ -746,6 +750,25 @@ try {{ } } +#[cfg(windows)] +fn powershell_command() -> tokio::process::Command { + #[cfg(test)] + if let Some(program) = std::env::var_os("CADDER_TEST_POWERSHELL") { + let mut command = tokio::process::Command::new(program); + configure_hidden_child(&mut command); + return command; + } + + let mut command = tokio::process::Command::new("powershell"); + configure_hidden_child(&mut command); + command +} + +#[cfg(windows)] +fn configure_hidden_child(command: &mut tokio::process::Command) { + command.creation_flags(CREATE_NO_WINDOW); +} + #[cfg(windows)] fn ps_escape(value: &str) -> String { value.replace('\'', "''") @@ -822,19 +845,41 @@ fn provider_error(error: std::io::Error) -> IisIssue { #[cfg(windows)] fn classify_powershell_error(stderr: &[u8]) -> IisIssue { let message = String::from_utf8_lossy(stderr).trim().to_string(); - let kind = if message.contains("Access is denied") - || message.contains("UnauthorizedAccess") - || message.contains("administrator") - { - IisIssueKind::InsufficientPrivileges - } else if message.contains("WebAdministration") || message.contains("module") { - IisIssueKind::IisUnavailable + let normalized = message.to_lowercase(); + let (kind, message) = if powershell_message_needs_admin(&normalized) { + ( + IisIssueKind::InsufficientPrivileges, + "IIS access requires administrator approval.".to_string(), + ) + } else if message.contains("WebAdministration") || normalized.contains("module") { + ( + IisIssueKind::IisUnavailable, + "IIS WebAdministration module is unavailable.".to_string(), + ) } else { - IisIssueKind::ProviderError + ( + IisIssueKind::ProviderError, + if message.is_empty() { + "PowerShell IIS command failed.".to_string() + } else { + message + }, + ) }; IisIssue::new(kind, message) } +#[cfg(windows)] +fn powershell_message_needs_admin(normalized: &str) -> bool { + normalized.contains("access is denied") + || normalized.contains("unauthorizedaccess") + || normalized.contains("administrator") + || normalized.contains("elevated") + || normalized.contains("requires elevation") + || normalized.contains("insufficient privilege") + || (normalized.contains("podwy") && normalized.contains("uprawnieni")) +} + pub fn binding_to_view( binding: &IisBindingRecord, state: IisHandoffState, @@ -889,6 +934,8 @@ pub fn unsupported_binding_issue(binding: &IisBindingRecord) -> Option #[cfg(test)] mod tests { use super::*; + #[cfg(windows)] + use std::{env, ffi::OsString, fs, path::Path, process::Command as StdCommand}; fn tls_certificate() -> IisTlsCertificate { IisTlsCertificate { @@ -1034,6 +1081,49 @@ mod tests { assert!(issue.message.contains("TLS certificate metadata")); } + #[test] + fn backend_binding_rejects_unsupported_backend_protocol() { + let binding = + IisBindingRecord::from_binding_information("Default Web Site", "ftp", "*:21:app.localhost") + .unwrap(); + + let issue = binding.backend_binding(41021, "app.localhost").unwrap_err(); + + assert_eq!(issue.kind, IisIssueKind::UnsupportedBindingShape); + assert!(issue.message.contains("protocol `ftp`")); + } + + #[test] + fn backend_binding_rejects_blank_https_certificate_fields() { + let mut binding = IisBindingRecord::from_binding_information( + "Default Web Site", + "https", + "*:443:secure.localhost", + ) + .unwrap(); + binding.tls_certificate = Some(IisTlsCertificate { + thumbprint: " ".to_string(), + store_name: "My".to_string(), + ssl_flags: None, + }); + let blank_thumbprint = binding.backend_binding(41043, "secure.localhost"); + binding.tls_certificate = Some(IisTlsCertificate { + thumbprint: "aabbcc".to_string(), + store_name: " ".to_string(), + ssl_flags: None, + }); + let blank_store = binding.backend_binding(41043, "secure.localhost"); + + assert_eq!( + blank_thumbprint.unwrap_err().kind, + IisIssueKind::MissingTlsCertificate + ); + assert_eq!( + blank_store.unwrap_err().kind, + IisIssueKind::MissingTlsCertificate + ); + } + #[tokio::test] async fn metadata_store_persists_and_removes_handoffs() { let dir = tempfile::tempdir().unwrap(); @@ -1058,6 +1148,50 @@ mod tests { assert!(reloaded.snapshot().await.is_empty()); } + #[tokio::test] + async fn metadata_store_memory_mode_accepts_noop_persistence() { + let binding = + IisBindingRecord::from_binding_information("Default Web Site", "http", "*:80:app.localhost") + .unwrap(); + let record = IisRestoreRecord { + binding: binding.clone(), + domain_key: "app.localhost".to_string(), + registration_id: None, + backend_binding: None, + }; + let store = IisMetadataStore::memory(); + + store.insert(binding.binding_id(), record).await.unwrap(); + let removed = store.remove(&binding.binding_id()).await.unwrap(); + + assert_eq!(removed.unwrap().domain_key, "app.localhost"); + assert!(store.snapshot().await.is_empty()); + } + + #[tokio::test] + async fn metadata_store_reports_write_failures() { + let dir = tempfile::tempdir().unwrap(); + let binding = + IisBindingRecord::from_binding_information("Default Web Site", "http", "*:80:app.localhost") + .unwrap(); + let record = IisRestoreRecord { + binding: binding.clone(), + domain_key: "app.localhost".to_string(), + registration_id: None, + backend_binding: None, + }; + let store = IisMetadataStore::load(dir.path().to_path_buf()) + .await + .unwrap(); + + let error = store + .insert(binding.binding_id(), record) + .await + .unwrap_err(); + + assert!(error.to_string().contains("write daemon metadata")); + } + #[tokio::test] async fn metadata_store_persists_https_certificate_metadata() { let dir = tempfile::tempdir().unwrap(); @@ -1132,6 +1266,22 @@ mod tests { assert_eq!(restore.kind, IisIssueKind::ProviderError); } + #[tokio::test] + async fn fake_provider_reports_configured_discovery_failure() { + let provider = IisProvider::fake(Vec::new()); + provider + .set_fail_discovery(IisIssue::new( + IisIssueKind::IisUnavailable, + "discovery failed", + )) + .await; + + let error = provider.discover().await.unwrap_err(); + + assert_eq!(error.kind, IisIssueKind::IisUnavailable); + assert!(error.message.contains("discovery failed")); + } + #[tokio::test] async fn fake_provider_reports_missing_remove_binding() { let binding = @@ -1156,6 +1306,57 @@ mod tests { assert_eq!(provider.discover().await.unwrap().len(), 1); } + #[tokio::test] + async fn fake_provider_privileged_batch_applies_all_mutation_variants() { + let original = + IisBindingRecord::from_binding_information("Default Web Site", "http", "*:80:app.localhost") + .unwrap(); + let added = + IisBindingRecord::from_binding_information("Default Web Site", "http", "*:80:api.localhost") + .unwrap(); + let provider = IisProvider::fake(vec![original.clone()]); + + provider + .execute_privileged_batch( + "replace IIS bindings", + &[ + IisMutation::remove(original.clone()), + IisMutation::add(added.clone()), + IisMutation::restore(original.clone()), + ], + ) + .await + .unwrap(); + let bindings = provider.discover().await.unwrap(); + + assert_eq!(bindings.len(), 2); + assert!( + bindings + .iter() + .any(|binding| binding.binding_id() == original.binding_id()) + ); + assert!( + bindings + .iter() + .any(|binding| binding.binding_id() == added.binding_id()) + ); + assert!(format!("{:?}", IisProvider::default()).contains("System")); + assert!(format!("{:?}", IisMutation::add(added.clone())).contains("Add")); + + provider + .set_elevation_issue(IisIssue::new(IisIssueKind::ElevationDenied, "denied")) + .await; + let error = provider + .execute_privileged_batch( + "denied IIS bindings", + &[IisMutation::remove(original.clone())], + ) + .await + .unwrap_err(); + + assert_eq!(error.kind, IisIssueKind::ElevationDenied); + } + #[cfg(windows)] #[test] fn parses_powershell_binding_json_shapes() { @@ -1192,6 +1393,13 @@ mod tests { classify_powershell_error(b"Access is denied").kind, IisIssueKind::InsufficientPrivileges ); + assert_eq!( + classify_powershell_error( + b"Import-Module WebAdministration failed: podwy\xBFszonymi uprawnieniami" + ) + .kind, + IisIssueKind::InsufficientPrivileges + ); assert_eq!( classify_powershell_error(b"WebAdministration module missing").kind, IisIssueKind::IisUnavailable @@ -1201,4 +1409,181 @@ mod tests { IisIssueKind::ProviderError ); } + + #[cfg(windows)] + #[test] + fn windows_system_iis_scripts_escape_binding_and_certificate_metadata() { + let binding = with_tls_certificate( + IisBindingRecord::from_binding_information("Bob's Site", "https", "*:443:secure.localhost") + .unwrap(), + ); + + let remove = system_remove_binding_script(&binding); + let add = system_add_binding_script(&binding); + let restore = system_restore_binding_script(&binding); + + assert!(remove.contains("Remove-WebBinding")); + assert!(remove.contains("Bob''s Site")); + assert!(add.contains("New-WebBinding")); + assert!(add.contains("-SslFlags 1")); + assert!(add.contains("AddSslCertificate('aabbcc', 'My')")); + assert_eq!(restore, add); + } + + #[cfg(windows)] + #[tokio::test] + // The guard serializes process-wide environment overrides while awaited fake PowerShell calls read them. + #[allow(clippy::await_holding_lock)] + async fn windows_powershell_boundaries_use_process_results_without_real_iis() { + let _lock = crate::TEST_ENV_LOCK.lock().unwrap(); + let _snapshot = EnvSnapshot::capture(["CADDER_TEST_POWERSHELL", "FAKE_POWERSHELL_MODE"]); + let temp = tempfile::tempdir().unwrap(); + write_fake_powershell(temp.path()); + unsafe { + env::set_var("CADDER_TEST_POWERSHELL", temp.path().join("powershell.exe")); + } + + unsafe { + env::set_var("FAKE_POWERSHELL_MODE", "discover-success"); + } + let discovered = system_discover().await.unwrap(); + assert_eq!(discovered[0].host_header, "app.localhost"); + + unsafe { + env::set_var("FAKE_POWERSHELL_MODE", "module-failure"); + } + assert_eq!( + system_discover().await.unwrap_err().kind, + IisIssueKind::IisUnavailable + ); + + unsafe { + env::set_var("FAKE_POWERSHELL_MODE", "success"); + } + run_powershell_mutation("$true".to_string()).await.unwrap(); + + unsafe { + env::set_var("FAKE_POWERSHELL_MODE", "denied"); + } + assert_eq!( + run_powershell_mutation("$true".to_string()) + .await + .unwrap_err() + .kind, + IisIssueKind::InsufficientPrivileges + ); + + let binding = + IisBindingRecord::from_binding_information("Default Web Site", "http", "*:80:app.localhost") + .unwrap(); + unsafe { + env::set_var("FAKE_POWERSHELL_MODE", "success"); + } + system_execute_privileged_batch( + "test elevated success", + &[ + IisMutation::add(binding.clone()), + IisMutation::remove(binding.clone()), + IisMutation::restore(binding.clone()), + ], + ) + .await + .unwrap(); + + unsafe { + env::set_var("FAKE_POWERSHELL_MODE", "cancel"); + } + assert_eq!( + system_execute_privileged_batch("test elevated cancel", &[IisMutation::remove(binding)]) + .await + .unwrap_err() + .kind, + IisIssueKind::ElevationDenied + ); + } + + #[cfg(windows)] + #[tokio::test] + async fn windows_empty_privileged_batch_short_circuits_without_powershell() { + system_execute_privileged_batch("nothing to do", &[]) + .await + .unwrap(); + } + + #[cfg(windows)] + fn write_fake_powershell(dir: &Path) { + let source_path = dir.join("fake_powershell.rs"); + let exe_path = dir.join("powershell.exe"); + fs::write( + &source_path, + r##" +fn main() { + let mode = std::env::var("FAKE_POWERSHELL_MODE").unwrap_or_default(); + match mode.as_str() { + "discover-success" => { + println!(r#"{{"siteName":"Default Web Site","protocol":"http","bindingInformation":"*:80:app.localhost"}}"#); + std::process::exit(0); + } + "module-failure" => { + eprintln!("WebAdministration module missing"); + std::process::exit(1); + } + "denied" => { + eprintln!("Access is denied"); + std::process::exit(1); + } + "cancel" => { + eprintln!("Operation canceled"); + std::process::exit(1); + } + "success" => std::process::exit(0), + _ => { + eprintln!("unknown fake powershell mode {mode}"); + std::process::exit(1); + } + } +} +"##, + ) + .unwrap(); + let status = StdCommand::new("rustc") + .arg(&source_path) + .arg("-o") + .arg(&exe_path) + .status() + .unwrap(); + assert!(status.success()); + } + + #[cfg(windows)] + struct EnvSnapshot { + values: Vec<(&'static str, Option)>, + } + + #[cfg(windows)] + impl EnvSnapshot { + fn capture(names: [&'static str; N]) -> Self { + Self { + values: names + .into_iter() + .map(|name| (name, env::var_os(name))) + .collect(), + } + } + } + + #[cfg(windows)] + impl Drop for EnvSnapshot { + fn drop(&mut self) { + for (name, value) in &self.values { + unsafe { + if let Some(value) = value { + env::set_var(name, value); + } else { + env::remove_var(name); + } + } + } + } + } } diff --git a/crates/cadder-daemon/src/ipc.rs b/crates/cadder-daemon/src/ipc.rs index 5e6d426..5bdf0a2 100644 --- a/crates/cadder-daemon/src/ipc.rs +++ b/crates/cadder-daemon/src/ipc.rs @@ -1,10 +1,15 @@ -use crate::{DaemonState, RuntimePaths}; +use crate::{ + CaddyBackendMode, DaemonState, IpcEndpointMetadata, IpcEndpointPublication, IpcOperation, + IpcPrincipal, IpcSecurityPolicy, RuntimePaths, RuntimeProfile, +}; use anyhow::{Context, Result, anyhow}; use cadder_protocol::{ - BasicResponse, HeartbeatEntrypointRequest, IpcEnvelope, QueryIisBindingsRequest, - QueryLogsRequest, QueryStateRequest, RegisterEntrypointRequest, SetDomainEnabledRequest, - SetEntrypointEnabledRequest, SetIisHandoffRequest, ShutdownDaemonRequest, StateChangedEvent, - SubscribeStateRequest, UnregisterEntrypointRequest, message_types, + HeartbeatEntrypointRequest, IpcEnvelope, LogAttributionKind, LogSeverity, LogStreamIdentity, + ProtocolError, ProtocolErrorResponse, QueryAutostartRequest, QueryIisBindingsRequest, + QueryLogsRequest, QueryStateRequest, RegisterEntrypointRequest, SetAutostartRequest, + SetDomainEnabledRequest, SetEntrypointEnabledRequest, SetIisHandoffRequest, + ShutdownDaemonRequest, StateChangedEvent, SubscribeStateRequest, UnregisterEntrypointRequest, + message_types, }; use fs4::{FileExt, TryLockError}; use interprocess::local_socket::{ @@ -13,6 +18,7 @@ use interprocess::local_socket::{ }; use serde::{Serialize, de::DeserializeOwned}; use std::{ + collections::BTreeMap, env, fs::{File, OpenOptions, create_dir_all}, io, @@ -31,11 +37,23 @@ use tokio::{ pub struct DaemonServer { paths: RuntimePaths, state: DaemonState, + security_policy: IpcSecurityPolicy, + peer_principal_override: Option, } impl DaemonServer { pub fn new(paths: RuntimePaths, state: DaemonState) -> Self { - Self { paths, state } + Self { + paths, + state, + security_policy: IpcSecurityPolicy, + peer_principal_override: None, + } + } + + pub fn with_peer_principal(mut self, peer_principal: IpcPrincipal) -> Self { + self.peer_principal_override = Some(peer_principal); + self } pub async fn run_until(self, mut shutdown: watch::Receiver) -> Result<()> { @@ -45,6 +63,8 @@ impl DaemonServer { .try_overwrite(true) .create_tokio() .context("create local IPC listener")?; + let endpoint = IpcEndpointMetadata::current(&self.paths); + let _endpoint_publication = IpcEndpointPublication::publish(&self.paths, &endpoint)?; let shutdown_signal = self.state.shutdown_signal(); loop { @@ -59,8 +79,17 @@ impl DaemonServer { match accepted { Ok(conn) => { let state = self.state.clone(); + let peer_principal = self + .peer_principal_override + .clone() + .unwrap_or_else(|| IpcPrincipal::from_peer_credentials(conn.peer_creds().ok())); + let security = ConnectionSecurityContext { + endpoint: endpoint.clone(), + policy: self.security_policy.clone(), + peer_principal, + }; tokio::spawn(async move { - let _ = handle_connection(conn, state).await; + let _ = handle_connection(conn, state, security).await; }); } Err(error) if error.kind() == io::ErrorKind::Interrupted => {} @@ -74,11 +103,48 @@ impl DaemonServer { } } -async fn handle_connection(conn: Stream, state: DaemonState) -> Result<()> { - let mut owned = Vec::<(String, String)>::new(); +#[derive(Debug, Clone)] +struct ConnectionSecurityContext { + endpoint: IpcEndpointMetadata, + policy: IpcSecurityPolicy, + peer_principal: IpcPrincipal, +} + +async fn handle_connection( + conn: Stream, + state: DaemonState, + security: ConnectionSecurityContext, +) -> Result<()> { + let mut owned = ConnectionRegistrations::default(); + let result = handle_connection_loop(conn, state.clone(), &mut owned, &security).await; + for (id, nonce) in owned.into_entries() { + state.unregister_for_ipc_disconnect(&id, &nonce).await; + } + result +} + +async fn handle_connection_loop( + conn: Stream, + state: DaemonState, + owned: &mut ConnectionRegistrations, + security: &ConnectionSecurityContext, +) -> Result<()> { let (read_half, mut write_half) = tokio::io::split(conn); let mut reader = BufReader::new(read_half); let mut line = String::new(); + macro_rules! send_response { + ($message_type:expr, $response:expr) => { + write_envelope(&mut write_half, $message_type, &$response).await?; + }; + } + macro_rules! decode_request { + ($envelope:expr, $request:ty) => { + match decode_or_reject::<$request, _>(&mut write_half, $envelope).await? { + Some(request) => request, + None => continue, + } + }; + } loop { line.clear(); @@ -87,10 +153,23 @@ async fn handle_connection(conn: Stream, state: DaemonState) -> Result<()> { break; } - let envelope: IpcEnvelope = serde_json::from_str(line.trim_end())?; + let envelope: IpcEnvelope = match serde_json::from_str(line.trim_end()) { + Ok(envelope) => envelope, + Err(error) => { + let response = ProtocolErrorResponse::rejected( + "unparseable", + ProtocolError::payload_decode_failed(error), + ); + send_response!(message_types::PROTOCOL_ERROR_RESPONSE, response); + continue; + } + }; + if !authorize_or_reject(&mut write_half, &state, &envelope, security).await? { + continue; + } match envelope.message_type.as_str() { message_types::REGISTER_ENTRYPOINT_REQUEST => { - let request: RegisterEntrypointRequest = envelope.decode()?; + let request = decode_request!(&envelope, RegisterEntrypointRequest); let nonce = request .registration .entrypoint_instance @@ -104,17 +183,12 @@ async fn handle_connection(conn: Stream, state: DaemonState) -> Result<()> { .as_ref() .filter(|_| response.accepted) { - owned.push((id.clone(), nonce)); + owned.insert(id.clone(), nonce); } - write_envelope( - &mut write_half, - message_types::REGISTER_ENTRYPOINT_RESPONSE, - &response, - ) - .await?; + send_response!(message_types::REGISTER_ENTRYPOINT_RESPONSE, response); } message_types::UNREGISTER_ENTRYPOINT_REQUEST => { - let request: UnregisterEntrypointRequest = envelope.decode()?; + let request = decode_request!(&envelope, UnregisterEntrypointRequest); let response = state .unregister( request.request_id, @@ -122,86 +196,63 @@ async fn handle_connection(conn: Stream, state: DaemonState) -> Result<()> { &request.shim_session_nonce, ) .await; - owned.retain(|(id, _)| id != &request.registration_id); - write_envelope( - &mut write_half, - message_types::UNREGISTER_ENTRYPOINT_RESPONSE, - &response, - ) - .await?; + if response.accepted { + owned.remove(&request.registration_id); + } + send_response!(message_types::UNREGISTER_ENTRYPOINT_RESPONSE, response); } message_types::HEARTBEAT_ENTRYPOINT_REQUEST => { - let request: HeartbeatEntrypointRequest = envelope.decode()?; + let request = decode_request!(&envelope, HeartbeatEntrypointRequest); let response = state.heartbeat(request).await; - write_envelope( - &mut write_half, - message_types::HEARTBEAT_ENTRYPOINT_RESPONSE, - &response, - ) - .await?; + send_response!(message_types::HEARTBEAT_ENTRYPOINT_RESPONSE, response); } message_types::QUERY_STATE_REQUEST => { - let request: QueryStateRequest = envelope.decode()?; + let request = decode_request!(&envelope, QueryStateRequest); let response = state.query_state(request.request_id).await; - write_envelope( - &mut write_half, - message_types::QUERY_STATE_RESPONSE, - &response, - ) - .await?; + send_response!(message_types::QUERY_STATE_RESPONSE, response); } message_types::SET_ENTRYPOINT_ENABLED_REQUEST => { - let request: SetEntrypointEnabledRequest = envelope.decode()?; + let request = decode_request!(&envelope, SetEntrypointEnabledRequest); let response = state.set_entrypoint_enabled(request).await; - write_envelope( - &mut write_half, - message_types::SET_ENTRYPOINT_ENABLED_RESPONSE, - &response, - ) - .await?; + send_response!(message_types::SET_ENTRYPOINT_ENABLED_RESPONSE, response); } message_types::SET_DOMAIN_ENABLED_REQUEST => { - let request: SetDomainEnabledRequest = envelope.decode()?; + let request = decode_request!(&envelope, SetDomainEnabledRequest); let response = state.set_domain_enabled(request).await; - write_envelope( - &mut write_half, - message_types::SET_DOMAIN_ENABLED_RESPONSE, - &response, - ) - .await?; + send_response!(message_types::SET_DOMAIN_ENABLED_RESPONSE, response); } message_types::QUERY_IIS_BINDINGS_REQUEST => { - let request: QueryIisBindingsRequest = envelope.decode()?; + let request = decode_request!(&envelope, QueryIisBindingsRequest); let response = state.query_iis_bindings(request.request_id).await; - write_envelope( - &mut write_half, - message_types::QUERY_IIS_BINDINGS_RESPONSE, - &response, - ) - .await?; + send_response!(message_types::QUERY_IIS_BINDINGS_RESPONSE, response); } message_types::SET_IIS_HANDOFF_REQUEST => { - let request: SetIisHandoffRequest = envelope.decode()?; + let request = decode_request!(&envelope, SetIisHandoffRequest); let response = state.set_iis_handoff(request).await; - write_envelope( - &mut write_half, - message_types::SET_IIS_HANDOFF_RESPONSE, - &response, - ) - .await?; + send_response!(message_types::SET_IIS_HANDOFF_RESPONSE, response); } message_types::QUERY_LOGS_REQUEST => { - let request: QueryLogsRequest = envelope.decode()?; + let request = decode_request!(&envelope, QueryLogsRequest); let response = state.query_logs(request).await; - write_envelope( - &mut write_half, - message_types::QUERY_LOGS_RESPONSE, - &response, - ) - .await?; + send_response!(message_types::QUERY_LOGS_RESPONSE, response); + } + message_types::QUERY_HISTORY_REQUEST => { + let request = decode_request!(&envelope, cadder_protocol::QueryHistoryRequest); + let response = state.query_history(request).await; + send_response!(message_types::QUERY_HISTORY_RESPONSE, response); + } + message_types::QUERY_AUTOSTART_REQUEST => { + let request = decode_request!(&envelope, QueryAutostartRequest); + let response = state.query_autostart(request.request_id).await; + send_response!(message_types::QUERY_AUTOSTART_RESPONSE, response); + } + message_types::SET_AUTOSTART_REQUEST => { + let request = decode_request!(&envelope, SetAutostartRequest); + let response = state.set_autostart(request).await; + send_response!(message_types::SET_AUTOSTART_RESPONSE, response); } message_types::SUBSCRIBE_STATE_REQUEST => { - let request: SubscribeStateRequest = envelope.decode()?; + let request = decode_request!(&envelope, SubscribeStateRequest); let snapshot = state.snapshot().await; let initial = cadder_protocol::StateChangedEvent { request_id: request.request_id.clone(), @@ -210,48 +261,108 @@ async fn handle_connection(conn: Stream, state: DaemonState) -> Result<()> { snapshot, registration_id: None, }; - write_envelope( - &mut write_half, - message_types::STATE_CHANGED_EVENT, - &initial, - ) - .await?; + send_response!(message_types::STATE_CHANGED_EVENT, initial); let mut subscription = state.subscribe(); while let Ok(mut event) = subscription.recv().await { event.request_id = request.request_id.clone(); - write_envelope(&mut write_half, message_types::STATE_CHANGED_EVENT, &event).await?; + send_response!(message_types::STATE_CHANGED_EVENT, event); } } message_types::SHUTDOWN_DAEMON_REQUEST => { - let request: ShutdownDaemonRequest = envelope.decode()?; + let request = decode_request!(&envelope, ShutdownDaemonRequest); let mut response = state.shutdown().await; response.request_id = request.request_id; - write_envelope( - &mut write_half, - message_types::SHUTDOWN_DAEMON_RESPONSE, - &response, - ) - .await?; + send_response!(message_types::SHUTDOWN_DAEMON_RESPONSE, response); break; } other => { - let response = BasicResponse { - request_id: "unsupported".to_string(), - accepted: false, - message: format!("Unsupported IPC message type `{other}`."), - }; - write_envelope(&mut write_half, "error-response", &response).await?; + let response = ProtocolErrorResponse::rejected( + request_id_from_payload(&envelope), + ProtocolError::unsupported_capability( + format!("message-type:{other}"), + cadder_protocol::current_capabilities(), + ), + ); + send_response!(message_types::PROTOCOL_ERROR_RESPONSE, response); } } } - for (id, nonce) in owned { - let _ = state - .unregister("pipe-disconnect".to_string(), &id, &nonce) - .await; + Ok(()) +} + +async fn authorize_or_reject( + writer: &mut W, + state: &DaemonState, + envelope: &IpcEnvelope, + security: &ConnectionSecurityContext, +) -> Result +where + W: AsyncWrite + Unpin, +{ + let operation = operation_for_message_type(&envelope.message_type); + let decision = security + .policy + .evaluate(&security.endpoint, &security.peer_principal, &operation); + if decision.is_allowed() { + return Ok(true); + } + + state.logs().append( + LogStreamIdentity::runtime_control(), + LogSeverity::Warn, + format!( + "Denied Cadder IPC {:?} operation `{}` by local security policy: {}", + operation.kind(), + operation.name(), + decision.reason_code() + ), + LogAttributionKind::RuntimeControl, + Some("ipc-access-denied".to_string()), + ); + let error = ProtocolError::access_denied( + operation.name(), + decision.message(), + decision.guidance().map(ToOwned::to_owned), + ); + let response = ProtocolErrorResponse::rejected(request_id_from_payload(envelope), error); + write_envelope(writer, message_types::PROTOCOL_ERROR_RESPONSE, &response).await?; + Ok(false) +} + +fn operation_for_message_type(message_type: &str) -> IpcOperation { + match message_type { + message_types::REGISTER_ENTRYPOINT_REQUEST + | message_types::UNREGISTER_ENTRYPOINT_REQUEST + | message_types::HEARTBEAT_ENTRYPOINT_REQUEST + | message_types::SET_ENTRYPOINT_ENABLED_REQUEST + | message_types::SET_DOMAIN_ENABLED_REQUEST + | message_types::SET_IIS_HANDOFF_REQUEST + | message_types::SET_AUTOSTART_REQUEST + | message_types::SHUTDOWN_DAEMON_REQUEST => IpcOperation::state_changing(message_type), + _ => IpcOperation::read_only(message_type), } +} - Ok(()) +#[derive(Debug, Default)] +struct ConnectionRegistrations { + by_registration_id: BTreeMap, +} + +impl ConnectionRegistrations { + fn insert(&mut self, registration_id: String, shim_session_nonce: String) { + self + .by_registration_id + .insert(registration_id, shim_session_nonce); + } + + fn remove(&mut self, registration_id: &str) { + self.by_registration_id.remove(registration_id); + } + + fn into_entries(self) -> impl Iterator { + self.by_registration_id.into_iter() + } } async fn write_envelope(writer: &mut W, message_type: &str, payload: &T) -> Result<()> @@ -267,6 +378,31 @@ where Ok(()) } +async fn decode_or_reject(writer: &mut W, envelope: &IpcEnvelope) -> Result> +where + T: DeserializeOwned, + W: AsyncWrite + Unpin, +{ + match envelope.decode_typed() { + Ok(request) => Ok(Some(request)), + Err(error) => { + let response = ProtocolErrorResponse::rejected(request_id_from_payload(envelope), error); + write_envelope(writer, message_types::PROTOCOL_ERROR_RESPONSE, &response).await?; + Ok(None) + } + } +} + +fn request_id_from_payload(envelope: &IpcEnvelope) -> String { + envelope + .payload + .get("requestId") + .and_then(|value| value.as_str()) + .filter(|request_id| !request_id.is_empty()) + .unwrap_or("unknown") + .to_string() +} + #[derive(Debug, Clone)] pub struct CadderClient { paths: RuntimePaths, @@ -353,6 +489,16 @@ impl CadderSession { ); } let envelope: IpcEnvelope = serde_json::from_str(line.trim_end())?; + if envelope.message_type == message_types::PROTOCOL_ERROR_RESPONSE + && response_type != message_types::PROTOCOL_ERROR_RESPONSE + { + let response: ProtocolErrorResponse = envelope.decode()?; + return Err(anyhow!( + "daemon rejected IPC request `{}`: {}", + response.request_id, + response.error + )); + } if envelope.message_type != response_type { return Err(anyhow!( "unexpected response type `{}`, expected `{response_type}`", @@ -397,6 +543,14 @@ impl StateSubscription { ); } let envelope: IpcEnvelope = serde_json::from_str(line.trim_end())?; + if envelope.message_type == message_types::PROTOCOL_ERROR_RESPONSE { + let response: ProtocolErrorResponse = envelope.decode()?; + return Err(anyhow!( + "daemon rejected state subscription `{}`: {}", + response.request_id, + response.error + )); + } if envelope.message_type != message_types::STATE_CHANGED_EVENT { return Err(anyhow!( "unexpected response type `{}`, expected `{}`", @@ -411,8 +565,109 @@ impl StateSubscription { #[derive(Debug, Clone, Default)] pub struct DaemonLaunchOptions { pub explicit_daemon: Option, + pub runtime_profile: Option, pub real_caddy_command: Option, + pub caddy_backend: Option, pub shim_path: Option, + pub launch_mode: DaemonLaunchMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DaemonLaunchMode { + #[default] + Background, + ForegroundDiagnostic, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DaemonStdioMode { + Null, + Inherit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct DaemonProcessConfig { + stdio: DaemonStdioMode, + #[cfg(windows)] + creation_flags: u32, + #[cfg(unix)] + starts_new_session: bool, +} + +impl DaemonProcessConfig { + fn for_launch_mode(mode: DaemonLaunchMode) -> Self { + match mode { + DaemonLaunchMode::Background => Self { + stdio: DaemonStdioMode::Null, + #[cfg(windows)] + creation_flags: BACKGROUND_DAEMON_CREATION_FLAGS, + #[cfg(unix)] + starts_new_session: true, + }, + DaemonLaunchMode::ForegroundDiagnostic => Self { + stdio: DaemonStdioMode::Inherit, + #[cfg(windows)] + creation_flags: 0, + #[cfg(unix)] + starts_new_session: false, + }, + } + } + + fn configure_command(self, command: &mut Command) { + #[cfg(windows)] + if self.creation_flags != 0 { + command.creation_flags(self.creation_flags); + } + + #[cfg(unix)] + if self.starts_new_session { + // SAFETY: the closure only calls async-signal-safe `setsid` and returns + // an `io::Result`, so it does not touch shared Rust state after fork. + unsafe { + command.pre_exec(start_new_unix_session); + } + } + } + + fn configure_stdio(self, command: &mut Command) { + match self.stdio { + DaemonStdioMode::Null => { + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + } + DaemonStdioMode::Inherit => {} + } + } +} + +#[cfg(windows)] +const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; +#[cfg(windows)] +const DETACHED_PROCESS: u32 = 0x0000_0008; +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; +#[cfg(windows)] +const BACKGROUND_DAEMON_CREATION_FLAGS: u32 = + CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS | CREATE_NO_WINDOW; +const DAEMON_READY_ATTEMPTS: usize = 300; +const DAEMON_READY_POLL_INTERVAL: Duration = Duration::from_millis(100); + +#[cfg(unix)] +fn start_new_unix_session() -> io::Result<()> { + unsafe extern "C" { + fn setsid() -> i32; + } + + // SAFETY: `setsid` has no Rust aliasing requirements and is called in the + // child process before exec through `pre_exec`. + if unsafe { setsid() } == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } } pub async fn ensure_daemon_running( @@ -437,10 +692,7 @@ pub async fn ensure_daemon_running_with_options( return Ok(()); } - let Some(_launch_lock) = DaemonLaunchLock::try_acquire(paths)? else { - wait_for_daemon_ready(paths) - .await - .context("wait for concurrent cadderd launch")?; + let Some(_launch_lock) = acquire_launch_lock_or_wait_for_ready(paths).await? else { return Ok(()); }; @@ -454,35 +706,92 @@ pub async fn ensure_daemon_running_with_options( .or_else(|| find_on_path("cadderd")) .ok_or_else(|| anyhow!("could not find `cadderd`; pass --daemon-path or add it to PATH"))?; - let mut command = Command::new(daemon); - configure_background_daemon(&mut command); + let process_config = DaemonProcessConfig::for_launch_mode(options.launch_mode); + let caddy_backend = options + .caddy_backend + .map_or_else(CaddyBackendMode::from_env, Ok)?; + if caddy_backend == CaddyBackendMode::Mock && options.real_caddy_command.is_some() { + return Err(anyhow!( + "--real-caddy-command cannot be combined with --caddy-backend mock" + )); + } + let daemon_dir = daemon.parent().map(PathBuf::from); + let mut command = Command::new(&daemon); + process_config.configure_command(&mut command); + if let Some(daemon_dir) = &daemon_dir { + command.current_dir(daemon_dir); + prepend_path_dir(&mut command, daemon_dir); + } command .arg("--runtime-dir") .arg(paths.runtime_dir()) - .arg("--detach-ready") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); + .arg("--detach-ready"); + process_config.configure_stdio(&mut command); if let Some(real_caddy_command) = options.real_caddy_command { command.arg("--real-caddy-command").arg(real_caddy_command); } + if caddy_backend != CaddyBackendMode::Real { + command.arg("--caddy-backend").arg(caddy_backend.as_str()); + } if let Some(shim_path) = options.shim_path { command.env("CADDER_CADDY_SHIM_PATH", shim_path); } - command.spawn().context("start cadderd")?; + command.env("CADDER_RUNTIME_DIR", paths.runtime_dir()); + let mut child = command.spawn().context("start cadderd")?; + + wait_for_daemon_ready(paths, &mut child).await +} + +async fn acquire_launch_lock_or_wait_for_ready( + paths: &RuntimePaths, +) -> Result> { + for _ in 0..DAEMON_READY_ATTEMPTS { + if can_connect(paths).await { + return Ok(None); + } + if let Some(lock) = DaemonLaunchLock::try_acquire(paths)? { + return Ok(Some(lock)); + } + sleep(DAEMON_READY_POLL_INTERVAL).await; + } - wait_for_daemon_ready(paths).await + if can_connect(paths).await { + return Ok(None); + } + + Err(anyhow!( + "another cadderd launch still holds {} but no healthy daemon socket became available for runtime {}; wait for that launch to finish, then retry `cadder daemon start --runtime-dir \"{}\"`", + paths.runtime_dir().join("cadder-launch.lock").display(), + paths.runtime_dir().display(), + paths.runtime_dir().display() + )) } -async fn wait_for_daemon_ready(paths: &RuntimePaths) -> Result<()> { - for _ in 0..50 { +async fn wait_for_daemon_ready( + paths: &RuntimePaths, + child: &mut tokio::process::Child, +) -> Result<()> { + for _ in 0..DAEMON_READY_ATTEMPTS { if can_connect(paths).await { return Ok(()); } - sleep(Duration::from_millis(100)).await; + if let Some(status) = child.try_wait().context("inspect cadderd launch process")? { + return Err(anyhow!( + "cadderd exited before opening IPC for runtime {}; exit status: {status}", + paths.runtime_dir().display() + )); + } + sleep(DAEMON_READY_POLL_INTERVAL).await; } - Err(anyhow!("cadderd did not become ready before timeout")) + Err(anyhow!( + "cadderd did not become ready within 30 seconds for runtime {}; the launch process is still running or left stale runtime residue", + paths.runtime_dir().display() + )) +} + +pub(crate) async fn is_daemon_ready(paths: &RuntimePaths) -> bool { + can_connect(paths).await } #[derive(Debug)] @@ -551,24 +860,34 @@ fn exe_name(name: &str) -> String { } } -fn configure_background_daemon(command: &mut Command) { - #[cfg(windows)] - { - const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; - command.creation_flags(CREATE_NEW_PROCESS_GROUP); - } - - #[cfg(unix)] - { - command.process_group(0); +fn prepend_path_dir(command: &mut Command, dir: &std::path::Path) { + let paths = env::var_os("PATH") + .map(|path| { + std::iter::once(dir.to_path_buf()) + .chain(env::split_paths(&path)) + .collect() + }) + .unwrap_or_else(|| vec![dir.to_path_buf()]); + if let Ok(joined) = env::join_paths(paths) { + command.env("PATH", joined); } } #[cfg(test)] mod tests { use super::*; - use cadder_protocol::{IpcEnvelope, QueryStateRequest}; - use tokio::io::AsyncReadExt; + use crate::{CaddyConfigCoordinator, PrivilegeStatus, discover_ipc_endpoint, logs::LogQuery}; + use cadder_protocol::{ + AutostartMode, BasicResponse, IpcEnvelope, ProtocolErrorKind, ProtocolErrorResponse, + QueryStateRequest, QueryStateResponse, message_types, new_request_id, + }; + use std::{env, ffi::OsString, fs, future::Future}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + sync::watch, + task::JoinHandle, + time::{Duration, sleep, timeout}, + }; #[tokio::test] async fn write_envelope_serializes_newline_delimited_json() { @@ -605,4 +924,661 @@ mod tests { assert!(DaemonLaunchLock::try_acquire(&paths).unwrap().is_some()); } + + #[test] + fn connection_registrations_replace_remove_and_iterate_owned_entries() { + let mut registrations = ConnectionRegistrations::default(); + + registrations.insert("shim-1".to_string(), "nonce-1".to_string()); + registrations.insert("shim-1".to_string(), "nonce-2".to_string()); + registrations.insert("shim-2".to_string(), "nonce-3".to_string()); + registrations.remove("missing"); + registrations.remove("shim-2"); + + let entries = registrations.into_entries().collect::>(); + assert_eq!(entries, vec![("shim-1".to_string(), "nonce-2".to_string())]); + } + + #[test] + fn cadder_client_from_env_uses_runtime_dir_override() { + let _lock = lock_env(); + let _snapshot = EnvSnapshot::capture("CADDER_RUNTIME_DIR"); + let temp = tempfile::tempdir().unwrap(); + let runtime_dir = temp.path().join("env-runtime"); + unsafe { + env::set_var("CADDER_RUNTIME_DIR", &runtime_dir); + } + + let client = CadderClient::from_env().unwrap(); + + assert_eq!(client.paths.runtime_dir(), runtime_dir); + } + + #[test] + fn find_on_path_uses_platform_executable_name() { + let _lock = lock_env(); + let _snapshot = EnvSnapshot::capture("PATH"); + let temp = tempfile::tempdir().unwrap(); + let executable = temp.path().join(exe_name("cadderd")); + fs::write(&executable, b"").unwrap(); + unsafe { + env::set_var("PATH", env::join_paths([temp.path()]).unwrap()); + } + + assert_eq!(find_on_path("cadderd"), Some(executable)); + } + + #[test] + fn find_on_path_reports_missing_path_and_missing_executable() { + let _lock = lock_env(); + let _snapshot = EnvSnapshot::capture("PATH"); + + unsafe { + env::remove_var("PATH"); + } + assert!(find_on_path("cadderd").is_none()); + + let temp = tempfile::tempdir().unwrap(); + unsafe { + env::set_var("PATH", env::join_paths([temp.path()]).unwrap()); + } + assert!(find_on_path("cadderd").is_none()); + } + + #[tokio::test] + async fn cadder_session_request_reports_eof_without_response() { + let server = ScriptedIpcServer::start(|conn| async move { + let (read_half, write_half) = tokio::io::split(conn); + let mut reader = BufReader::new(read_half); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + drop(write_half); + }); + let mut session = CadderSession::connect(&server.paths).await.unwrap(); + + let error = session + .request::<_, QueryStateResponse>( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("test-query-state"), + }, + ) + .await + .unwrap_err(); + + assert!( + error.to_string().contains("without a response"), + "{error:?}" + ); + server.finish().await; + } + + #[tokio::test] + async fn cadder_session_request_rejects_unexpected_response_type() { + let server = ScriptedIpcServer::start(|conn| async move { + let (_line, mut writer) = read_one_request(conn).await; + write_basic_response(&mut writer, message_types::QUERY_LOGS_RESPONSE).await; + }); + let mut session = CadderSession::connect(&server.paths).await.unwrap(); + + let error = session + .request::<_, QueryStateResponse>( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("test-query-state"), + }, + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("unexpected response type `query-logs-response`"), + "{error:?}" + ); + server.finish().await; + } + + #[tokio::test] + async fn cadder_session_request_rejects_malformed_response_json() { + let server = ScriptedIpcServer::start(|conn| async move { + let (_line, mut writer) = read_one_request(conn).await; + writer.write_all(b"{not-json}\n").await.unwrap(); + writer.flush().await.unwrap(); + }); + let mut session = CadderSession::connect(&server.paths).await.unwrap(); + + let error = session + .request::<_, QueryStateResponse>( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("test-query-state"), + }, + ) + .await + .unwrap_err(); + + assert!( + error.to_string().contains("key must be a string"), + "{error:?}" + ); + server.finish().await; + } + + #[tokio::test] + async fn cadder_session_request_rejects_response_payload_shape() { + let server = ScriptedIpcServer::start(|conn| async move { + let (_line, mut writer) = read_one_request(conn).await; + write_envelope( + &mut writer, + message_types::QUERY_STATE_RESPONSE, + &serde_json::json!({ "accepted": true }), + ) + .await + .unwrap(); + }); + let mut session = CadderSession::connect(&server.paths).await.unwrap(); + + let error = session + .request::<_, QueryStateResponse>( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("test-query-state"), + }, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("missing field"), "{error:?}"); + server.finish().await; + } + + #[tokio::test] + async fn state_subscription_reports_eof_without_event() { + let server = ScriptedIpcServer::start(|conn| async move { + let (read_half, write_half) = tokio::io::split(conn); + let mut reader = BufReader::new(read_half); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + drop(write_half); + }); + let session = CadderSession::connect(&server.paths).await.unwrap(); + let mut subscription = session + .subscribe_state(new_request_id("test-subscribe")) + .await + .unwrap(); + + let error = subscription.next_event().await.unwrap_err(); + + assert!( + error + .to_string() + .contains("daemon closed the state subscription"), + "{error:?}" + ); + server.finish().await; + } + + #[tokio::test] + async fn state_subscription_rejects_unexpected_event_type() { + let server = ScriptedIpcServer::start(|conn| async move { + let (_line, mut writer) = read_one_request(conn).await; + write_basic_response(&mut writer, message_types::QUERY_STATE_RESPONSE).await; + }); + let session = CadderSession::connect(&server.paths).await.unwrap(); + let mut subscription = session + .subscribe_state(new_request_id("test-subscribe")) + .await + .unwrap(); + + let error = subscription.next_event().await.unwrap_err(); + + assert!( + error + .to_string() + .contains("unexpected response type `query-state-response`"), + "{error:?}" + ); + server.finish().await; + } + + #[tokio::test] + async fn state_subscription_rejects_malformed_event_json() { + let server = ScriptedIpcServer::start(|conn| async move { + let (_line, mut writer) = read_one_request(conn).await; + writer.write_all(b"{not-json}\n").await.unwrap(); + writer.flush().await.unwrap(); + }); + let session = CadderSession::connect(&server.paths).await.unwrap(); + let mut subscription = session + .subscribe_state(new_request_id("test-subscribe")) + .await + .unwrap(); + + let error = subscription.next_event().await.unwrap_err(); + + assert!( + error.to_string().contains("key must be a string"), + "{error:?}" + ); + server.finish().await; + } + + #[tokio::test] + async fn state_subscription_rejects_event_payload_shape() { + let server = ScriptedIpcServer::start(|conn| async move { + let (_line, mut writer) = read_one_request(conn).await; + write_basic_response(&mut writer, message_types::STATE_CHANGED_EVENT).await; + }); + let session = CadderSession::connect(&server.paths).await.unwrap(); + let mut subscription = session + .subscribe_state(new_request_id("test-subscribe")) + .await + .unwrap(); + + let error = subscription.next_event().await.unwrap_err(); + + assert!(error.to_string().contains("missing field"), "{error:?}"); + server.finish().await; + } + + #[tokio::test] + async fn daemon_server_denies_unauthorized_state_changing_request_before_execution() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let state = DaemonState::with_runtime_paths( + CaddyConfigCoordinator::new_mock(paths.clone()), + paths.clone(), + ) + .await + .unwrap(); + let logs = state.logs(); + let denied_account = format!( + "{}-other-token=secret", + IpcPrincipal::current_process(crate::current_privilege_status()).account() + ); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let daemon = tokio::spawn( + DaemonServer::new(paths.clone(), state) + .with_peer_principal(IpcPrincipal::new( + denied_account.clone(), + PrivilegeStatus::NormalUser, + )) + .run_until(shutdown_rx), + ); + wait_for_ready(&paths).await; + + let endpoint = discover_ipc_endpoint(&paths).unwrap(); + let mut session = CadderSession::connect(&paths).await.unwrap(); + let response: ProtocolErrorResponse = session + .request( + message_types::SET_AUTOSTART_REQUEST, + message_types::PROTOCOL_ERROR_RESPONSE, + &SetAutostartRequest { + request_id: new_request_id("deny-autostart"), + mode: AutostartMode::Daemon, + }, + ) + .await + .unwrap(); + let denial_log = logs.query( + LogQuery { + stream: LogStreamIdentity::runtime_control(), + limit: 10, + after_sequence: None, + minimum_severity: Some(LogSeverity::Warn), + }, + true, + ); + + assert_eq!(endpoint.socket_name, paths.socket_name()); + assert_eq!(response.error.kind, ProtocolErrorKind::AccessDenied); + assert_eq!( + response.error.denied_operation.as_deref(), + Some(message_types::SET_AUTOSTART_REQUEST) + ); + assert!(denial_log.entries.iter().any(|entry| { + entry.operation.as_deref() == Some("ipc-access-denied") + && entry + .raw_message + .contains(message_types::SET_AUTOSTART_REQUEST) + && !entry.raw_message.contains(&denied_account) + && !entry.raw_message.contains("secret") + })); + + drop(session); + shutdown_tx.send(true).unwrap(); + timeout(Duration::from_secs(2), daemon) + .await + .unwrap() + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn ensure_daemon_running_returns_when_socket_already_available() { + let server = ScriptedIpcServer::start(|_conn| async move {}); + + ensure_daemon_running_with_options( + &server.paths, + DaemonLaunchOptions { + explicit_daemon: Some(PathBuf::from("missing-cadderd")), + real_caddy_command: Some("real-caddy".to_string()), + shim_path: Some(PathBuf::from("shim")), + ..DaemonLaunchOptions::default() + }, + ) + .await + .unwrap(); + + server.finish().await; + } + + #[tokio::test] + async fn ensure_daemon_running_waits_for_concurrent_launch_owner() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let _launch_lock = DaemonLaunchLock::try_acquire(&paths).unwrap().unwrap(); + let server = ScriptedIpcServer::start_after( + paths.clone(), + Duration::from_millis(50), + |_conn| async move {}, + ); + + ensure_daemon_running_with_options( + &paths, + DaemonLaunchOptions { + explicit_daemon: Some(PathBuf::from("missing-cadderd")), + real_caddy_command: None, + shim_path: None, + ..DaemonLaunchOptions::default() + }, + ) + .await + .unwrap(); + + server.finish().await; + } + + #[tokio::test] + async fn ensure_daemon_running_takes_over_after_failed_concurrent_launch_owner() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let launch_lock = DaemonLaunchLock::try_acquire(&paths).unwrap().unwrap(); + let release_lock = tokio::spawn(async move { + sleep(Duration::from_millis(40)).await; + drop(launch_lock); + }); + let missing_daemon = temp.path().join(if cfg!(windows) { + "missing-cadderd.exe" + } else { + "missing-cadderd" + }); + + let error = ensure_daemon_running_with_options( + &paths, + DaemonLaunchOptions { + explicit_daemon: Some(missing_daemon), + real_caddy_command: None, + shim_path: None, + ..DaemonLaunchOptions::default() + }, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("start cadderd"), "{error:?}"); + release_lock.await.unwrap(); + } + + #[tokio::test] + async fn ensure_daemon_running_reports_missing_explicit_daemon_start_failure() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let missing_daemon = temp.path().join(if cfg!(windows) { + "missing-cadderd.exe" + } else { + "missing-cadderd" + }); + + let error = ensure_daemon_running(&paths, Some(missing_daemon)) + .await + .unwrap_err(); + + assert!(error.to_string().contains("start cadderd"), "{error:?}"); + } + + #[tokio::test] + async fn ensure_daemon_running_applies_launch_options_before_spawn_failure() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let missing_daemon = temp.path().join(if cfg!(windows) { + "missing-cadderd.exe" + } else { + "missing-cadderd" + }); + + let error = ensure_daemon_running_with_options( + &paths, + DaemonLaunchOptions { + explicit_daemon: Some(missing_daemon), + real_caddy_command: Some("real-caddy".to_string()), + shim_path: Some(temp.path().join("shim")), + ..DaemonLaunchOptions::default() + }, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("start cadderd"), "{error:?}"); + } + + #[tokio::test] + async fn ensure_daemon_running_rejects_mock_backend_with_real_command_before_spawn() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let missing_daemon = temp.path().join(if cfg!(windows) { + "missing-cadderd.exe" + } else { + "missing-cadderd" + }); + + let error = ensure_daemon_running_with_options( + &paths, + DaemonLaunchOptions { + explicit_daemon: Some(missing_daemon), + real_caddy_command: Some("real-caddy".to_string()), + caddy_backend: Some(CaddyBackendMode::Mock), + ..DaemonLaunchOptions::default() + }, + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("--real-caddy-command cannot be combined"), + "{error:?}" + ); + } + + #[test] + fn daemon_launch_options_default_to_background_mode() { + assert_eq!( + DaemonLaunchOptions::default().launch_mode, + DaemonLaunchMode::Background + ); + } + + #[test] + fn daemon_process_config_distinguishes_background_from_foreground() { + let background = DaemonProcessConfig::for_launch_mode(DaemonLaunchMode::Background); + let foreground = DaemonProcessConfig::for_launch_mode(DaemonLaunchMode::ForegroundDiagnostic); + + assert_eq!(background.stdio, DaemonStdioMode::Null); + assert_eq!(foreground.stdio, DaemonStdioMode::Inherit); + } + + #[cfg(windows)] + #[test] + fn background_daemon_process_uses_no_terminal_windows_flags() { + let background = DaemonProcessConfig::for_launch_mode(DaemonLaunchMode::Background); + let foreground = DaemonProcessConfig::for_launch_mode(DaemonLaunchMode::ForegroundDiagnostic); + + assert_eq!( + background.creation_flags, + DETACHED_PROCESS | CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP + ); + assert_eq!(foreground.creation_flags, 0); + } + + #[cfg(unix)] + #[test] + fn background_daemon_process_starts_new_unix_session() { + let background = DaemonProcessConfig::for_launch_mode(DaemonLaunchMode::Background); + let foreground = DaemonProcessConfig::for_launch_mode(DaemonLaunchMode::ForegroundDiagnostic); + + assert!(background.starts_new_session); + assert!(!foreground.starts_new_session); + } + + struct ScriptedIpcServer { + paths: RuntimePaths, + task: JoinHandle<()>, + _temp: tempfile::TempDir, + } + + impl ScriptedIpcServer { + fn start(handler: F) -> Self + where + F: FnOnce(Stream) -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let name = paths + .socket_name() + .to_ns_name::() + .unwrap(); + let listener = ListenerOptions::new() + .name(name) + .try_overwrite(true) + .create_tokio() + .unwrap(); + let task = tokio::spawn(async move { + let conn = listener.accept().await.unwrap(); + handler(conn).await; + }); + Self { + paths, + task, + _temp: temp, + } + } + + fn start_after(paths: RuntimePaths, delay: Duration, handler: F) -> Self + where + F: FnOnce(Stream) -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + let temp = tempfile::tempdir().unwrap(); + let server_paths = paths.clone(); + let task = tokio::spawn(async move { + if !delay.is_zero() { + sleep(delay).await; + } + let name = server_paths + .socket_name() + .to_ns_name::() + .unwrap(); + let listener = ListenerOptions::new() + .name(name) + .try_overwrite(true) + .create_tokio() + .unwrap(); + let conn = listener.accept().await.unwrap(); + handler(conn).await; + }); + Self { + paths, + task, + _temp: temp, + } + } + + async fn finish(self) { + self.task.await.unwrap(); + } + } + + async fn read_one_request(conn: Stream) -> (String, tokio::io::WriteHalf) { + let (read_half, writer) = tokio::io::split(conn); + let mut reader = BufReader::new(read_half); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + assert!(!line.is_empty(), "client did not send an IPC request"); + (line, writer) + } + + async fn wait_for_ready(paths: &RuntimePaths) { + for _ in 0..50 { + if can_connect(paths).await { + return; + } + sleep(Duration::from_millis(20)).await; + } + + panic!("daemon server did not become ready"); + } + + async fn write_basic_response(writer: &mut tokio::io::WriteHalf, message_type: &str) { + write_envelope( + writer, + message_type, + &BasicResponse { + request_id: "test-response".to_string(), + accepted: true, + message: "ok".to_string(), + }, + ) + .await + .unwrap(); + } + + struct EnvSnapshot { + key: &'static str, + value: Option, + } + + impl EnvSnapshot { + fn capture(key: &'static str) -> Self { + Self { + key, + value: env::var_os(key), + } + } + } + + impl Drop for EnvSnapshot { + fn drop(&mut self) { + unsafe { + match &self.value { + Some(value) => env::set_var(self.key, value), + None => env::remove_var(self.key), + } + } + } + } + + fn lock_env() -> std::sync::MutexGuard<'static, ()> { + crate::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } } diff --git a/crates/cadder-daemon/src/ipc_security.rs b/crates/cadder-daemon/src/ipc_security.rs new file mode 100644 index 0000000..7302e09 --- /dev/null +++ b/crates/cadder-daemon/src/ipc_security.rs @@ -0,0 +1,586 @@ +use anyhow::{Context, Result}; +use cadder_protocol::{MIN_COMPATIBLE_PROTOCOL_VERSION, PROTOCOL_VERSION, ProtocolCapabilities}; +use chrono::{DateTime, Utc}; +use interprocess::local_socket::PeerCreds; +use serde::{Deserialize, Serialize}; +use std::{ + env, + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; + +use crate::{PrivilegeStatus, RuntimePaths, current_privilege_status}; + +const IPC_ENDPOINT_METADATA_VERSION: u16 = 1; +const IPC_SECURITY_POLICY_VERSION: u16 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IpcPrincipal { + account: String, + privilege_status: PrivilegeStatus, +} + +impl IpcPrincipal { + pub fn new(account: impl Into, privilege_status: PrivilegeStatus) -> Self { + Self { + account: normalize_account(account.into()), + privilege_status, + } + } + + pub fn current_process(privilege_status: PrivilegeStatus) -> Self { + Self::new(current_account_label(), privilege_status) + } + + pub fn from_peer_credentials(credentials: Option) -> Self { + let account = credentials + .and_then(peer_account_label) + .unwrap_or_else(|| "unknown".to_string()); + Self::new(account, PrivilegeStatus::Unknown) + } + + pub fn account(&self) -> &str { + &self.account + } + + pub fn privilege_status(&self) -> PrivilegeStatus { + self.privilege_status + } + + fn is_same_account(&self, other: &Self) -> bool { + self.account.eq_ignore_ascii_case(&other.account) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum IpcOperationKind { + ReadOnly, + StateChanging, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IpcOperation { + name: String, + kind: IpcOperationKind, +} + +impl IpcOperation { + pub fn read_only(name: impl Into) -> Self { + Self { + name: name.into(), + kind: IpcOperationKind::ReadOnly, + } + } + + pub fn state_changing(name: impl Into) -> Self { + Self { + name: name.into(), + kind: IpcOperationKind::StateChanging, + } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn kind(&self) -> IpcOperationKind { + self.kind + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IpcSecurityPolicySummary { + pub policy_version: u16, + pub allowed_principal: String, + pub allowed_operations: Box<[String]>, + pub denied_principals: Box<[String]>, +} + +impl IpcSecurityPolicySummary { + fn current() -> Self { + Self { + policy_version: IPC_SECURITY_POLICY_VERSION, + allowed_principal: "same-runtime-owner-account".to_string(), + allowed_operations: ["read-only", "state-changing"] + .into_iter() + .map(String::from) + .collect(), + denied_principals: ["different-local-account", "unknown-local-account"] + .into_iter() + .map(String::from) + .collect(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IpcEndpointMetadata { + pub metadata_version: u16, + pub cadder_version: String, + pub protocol_version: u16, + pub minimum_compatible_protocol_version: u16, + pub capabilities: ProtocolCapabilities, + pub runtime_profile: String, + pub runtime_dir: String, + pub instance_key: String, + pub socket_name: String, + pub process_id: u32, + pub published_at_utc: DateTime, + pub executable_path: Option, + pub owner_principal: IpcPrincipal, + pub privilege_status: PrivilegeStatus, + pub security_policy: IpcSecurityPolicySummary, +} + +impl IpcEndpointMetadata { + pub fn current(paths: &RuntimePaths) -> Self { + let privilege_status = current_privilege_status(); + Self::new(paths, IpcPrincipal::current_process(privilege_status)) + } + + pub fn new(paths: &RuntimePaths, owner_principal: IpcPrincipal) -> Self { + Self { + metadata_version: IPC_ENDPOINT_METADATA_VERSION, + cadder_version: env!("CARGO_PKG_VERSION").to_string(), + protocol_version: PROTOCOL_VERSION, + minimum_compatible_protocol_version: MIN_COMPATIBLE_PROTOCOL_VERSION, + capabilities: ProtocolCapabilities::current(), + runtime_profile: paths.runtime_profile().to_string(), + runtime_dir: paths.runtime_dir().display().to_string(), + instance_key: paths.instance_key().to_string(), + socket_name: paths.socket_name().to_string(), + process_id: std::process::id(), + published_at_utc: Utc::now(), + executable_path: env::current_exe() + .ok() + .map(|path| path.display().to_string()), + privilege_status: owner_principal.privilege_status(), + owner_principal, + security_policy: IpcSecurityPolicySummary::current(), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct IpcSecurityPolicy; + +impl IpcSecurityPolicy { + pub fn evaluate( + &self, + endpoint: &IpcEndpointMetadata, + peer: &IpcPrincipal, + operation: &IpcOperation, + ) -> IpcAccessDecision { + if endpoint.owner_principal.is_same_account(peer) { + return IpcAccessDecision::allowed("same-runtime-owner-account"); + } + + IpcAccessDecision::denied( + "principal-outside-runtime-owner", + format!( + "Cadder IPC operation `{}` is not allowed for principals outside the runtime owner account.", + operation.name() + ), + "Use the same local account that owns the Cadder runtime, or start a separate runtime profile for this account." + .to_string(), + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IpcAccessDecision { + allowed: bool, + reason_code: &'static str, + message: String, + guidance: Option, +} + +impl IpcAccessDecision { + fn allowed(reason_code: &'static str) -> Self { + Self { + allowed: true, + reason_code, + message: "Cadder IPC operation is allowed by the local security policy.".to_string(), + guidance: None, + } + } + + fn denied(reason_code: &'static str, message: String, guidance: String) -> Self { + Self { + allowed: false, + reason_code, + message, + guidance: Some(guidance), + } + } + + pub fn is_allowed(&self) -> bool { + self.allowed + } + + pub fn reason_code(&self) -> &'static str { + self.reason_code + } + + pub fn message(&self) -> &str { + &self.message + } + + pub fn guidance(&self) -> Option<&str> { + self.guidance.as_deref() + } +} + +#[derive(Debug)] +pub struct IpcEndpointPublication { + path: PathBuf, +} + +impl IpcEndpointPublication { + pub fn publish_current(paths: &RuntimePaths) -> Result { + let metadata = IpcEndpointMetadata::current(paths); + Self::publish(paths, &metadata) + } + + pub fn publish(paths: &RuntimePaths, metadata: &IpcEndpointMetadata) -> Result { + let path = paths.ipc_endpoint_path(); + write_ipc_endpoint_metadata(&path, metadata) + .with_context(|| format!("write IPC endpoint metadata {}", path.display()))?; + Ok(Self { path }) + } +} + +impl Drop for IpcEndpointPublication { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +pub fn discover_ipc_endpoint(paths: &RuntimePaths) -> Result { + let path = paths.ipc_endpoint_path(); + let content = fs::read_to_string(&path) + .with_context(|| format!("read IPC endpoint metadata {}", path.display()))?; + serde_json::from_str(content.trim()) + .with_context(|| format!("parse IPC endpoint metadata {}", path.display())) +} + +fn write_ipc_endpoint_metadata(path: &Path, metadata: &IpcEndpointMetadata) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "create IPC endpoint metadata directory {}", + parent.display() + ) + })?; + } + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) + .with_context(|| format!("open IPC endpoint metadata {}", path.display()))?; + serde_json::to_writer_pretty(&mut file, metadata)?; + file.write_all(b"\n")?; + file.sync_data()?; + Ok(()) +} + +#[cfg(windows)] +fn current_account_label() -> String { + windows_current_process_account_label() + .or_else(env_account_label) + .unwrap_or_else(|| "unknown".to_string()) +} + +#[cfg(unix)] +fn current_account_label() -> String { + // SAFETY: `geteuid` has no preconditions and only reads process identity. + format!("uid:{}", unsafe { libc::geteuid() }) +} + +#[cfg(not(any(unix, windows)))] +fn current_account_label() -> String { + env_account_label().unwrap_or_else(|| "unknown".to_string()) +} + +#[cfg(unix)] +fn peer_account_label(credentials: PeerCreds) -> Option { + credentials.euid().map(|uid| format!("uid:{uid}")) +} + +#[cfg(windows)] +fn peer_account_label(credentials: PeerCreds) -> Option { + windows_process_account_label(credentials.pid()?) +} + +#[cfg(not(any(unix, windows)))] +fn peer_account_label(_credentials: PeerCreds) -> Option { + None +} + +fn env_account_label() -> Option { + let domain = env_value("USERDOMAIN"); + let name = env_value("USERNAME").or_else(|| env_value("USER")); + match (domain, name) { + (Some(domain), Some(name)) if !domain.eq_ignore_ascii_case(&name) => { + Some(normalize_account(format!("{domain}\\{name}"))) + } + (_, Some(name)) => Some(normalize_account(name)), + _ => None, + } +} + +#[cfg(windows)] +fn windows_current_process_account_label() -> Option { + use windows_sys::Win32::System::Threading::GetCurrentProcess; + + let mut token = std::ptr::null_mut(); + // SAFETY: `GetCurrentProcess` returns a pseudo-handle that is valid for + // `OpenProcessToken`; `token` is initialized by Windows on success. + if unsafe { windows_open_process_token(GetCurrentProcess(), &mut token) } { + let label = windows_token_account_label(token); + // SAFETY: `token` was returned by `OpenProcessToken` and must be closed. + unsafe { + windows_sys::Win32::Foundation::CloseHandle(token); + } + return label; + } + + None +} + +#[cfg(windows)] +fn windows_process_account_label(process_id: u32) -> Option { + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}; + + // SAFETY: Opening another process is fallible; invalid or inaccessible PIDs + // return a null handle, which is handled below. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + return None; + } + + let mut token = std::ptr::null_mut(); + // SAFETY: `process` is a valid handle from `OpenProcess`; `token` is + // initialized by Windows on success. + let label = if unsafe { windows_open_process_token(process, &mut token) } { + let label = windows_token_account_label(token); + // SAFETY: `token` was returned by `OpenProcessToken` and must be closed. + unsafe { + windows_sys::Win32::Foundation::CloseHandle(token); + } + label + } else { + None + }; + + // SAFETY: `process` was returned by `OpenProcess` and must be closed. + unsafe { + windows_sys::Win32::Foundation::CloseHandle(process); + } + label +} + +#[cfg(windows)] +unsafe fn windows_open_process_token( + process: windows_sys::Win32::Foundation::HANDLE, + token: *mut windows_sys::Win32::Foundation::HANDLE, +) -> bool { + use windows_sys::Win32::{Security::TOKEN_QUERY, System::Threading::OpenProcessToken}; + + // SAFETY: The caller guarantees `process` and `token` are valid for the + // Windows API call. + unsafe { OpenProcessToken(process, TOKEN_QUERY, token) != 0 } +} + +#[cfg(windows)] +fn windows_token_account_label(token: windows_sys::Win32::Foundation::HANDLE) -> Option { + use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_USER, TokenUser}; + + let mut required_len = 0; + // SAFETY: The first call intentionally passes a null buffer to obtain the + // required size for the token user record. + unsafe { + GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut required_len); + } + if required_len == 0 { + return None; + } + + let mut buffer = vec![0_u8; required_len as usize]; + // SAFETY: `buffer` has the length requested by Windows and is writable. + let ok = unsafe { + GetTokenInformation( + token, + TokenUser, + buffer.as_mut_ptr().cast(), + required_len, + &mut required_len, + ) != 0 + }; + if !ok { + return None; + } + + // SAFETY: A successful `GetTokenInformation(TokenUser)` fills the buffer + // with a `TOKEN_USER`; `Vec` alignment is not guaranteed, so read the + // header unaligned and copy it by value. + let token_user = unsafe { std::ptr::read_unaligned(buffer.as_ptr().cast::()) }; + windows_sid_account_label(token_user.User.Sid) +} + +#[cfg(windows)] +fn windows_sid_account_label(sid: windows_sys::Win32::Security::PSID) -> Option { + use windows_sys::Win32::Security::{LookupAccountSidW, SID_NAME_USE}; + + let mut name_len = 0; + let mut domain_len = 0; + let mut sid_name_use: SID_NAME_USE = 0; + // SAFETY: This sizing call follows the documented `LookupAccountSidW` + // pattern with null output buffers to retrieve required lengths. + unsafe { + LookupAccountSidW( + std::ptr::null(), + sid, + std::ptr::null_mut(), + &mut name_len, + std::ptr::null_mut(), + &mut domain_len, + &mut sid_name_use, + ); + } + if name_len == 0 { + return None; + } + + let mut name = vec![0_u16; name_len as usize]; + let mut domain = vec![0_u16; domain_len as usize]; + let domain_ptr = if domain.is_empty() { + std::ptr::null_mut() + } else { + domain.as_mut_ptr() + }; + // SAFETY: `name` and `domain` buffers are sized from the previous Windows + // API call and remain valid for the duration of this call. + let ok = unsafe { + LookupAccountSidW( + std::ptr::null(), + sid, + name.as_mut_ptr(), + &mut name_len, + domain_ptr, + &mut domain_len, + &mut sid_name_use, + ) != 0 + }; + if !ok { + return None; + } + + let name = String::from_utf16_lossy(&name[..name_len as usize]); + let domain = String::from_utf16_lossy(&domain[..domain_len as usize]); + if domain.is_empty() || domain.eq_ignore_ascii_case(&name) { + Some(normalize_account(name)) + } else { + Some(normalize_account(format!("{domain}\\{name}"))) + } +} + +fn env_value(key: &str) -> Option { + env::var(key) + .ok() + .map(normalize_account) + .filter(|value| !value.is_empty()) +} + +fn normalize_account(account: String) -> String { + let account = account.trim(); + if account.is_empty() { + "unknown".to_string() + } else { + account.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_allows_same_user_non_elevated_client_to_elevated_endpoint() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let endpoint = IpcEndpointMetadata::new( + &paths, + IpcPrincipal::new("DESKTOP\\alice", PrivilegeStatus::Elevated), + ); + let peer = IpcPrincipal::new("desktop\\alice", PrivilegeStatus::NormalUser); + let decision = IpcSecurityPolicy.evaluate( + &endpoint, + &peer, + &IpcOperation::state_changing("set-autostart"), + ); + + assert!(decision.is_allowed()); + } + + #[test] + fn policy_denies_different_user_without_leaking_peer_account() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let endpoint = IpcEndpointMetadata::new( + &paths, + IpcPrincipal::new("DESKTOP\\alice", PrivilegeStatus::Elevated), + ); + let peer = IpcPrincipal::new("DESKTOP\\bob-token=secret", PrivilegeStatus::NormalUser); + let decision = + IpcSecurityPolicy.evaluate(&endpoint, &peer, &IpcOperation::state_changing("shutdown")); + + assert!(!decision.is_allowed()); + assert_eq!(decision.reason_code(), "principal-outside-runtime-owner"); + assert!(!decision.message().contains("bob-token")); + assert!(!decision.message().contains("secret")); + } + + #[test] + fn endpoint_metadata_roundtrips_policy_and_capabilities() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let endpoint = IpcEndpointMetadata::new( + &paths, + IpcPrincipal::new("alice", PrivilegeStatus::NormalUser), + ); + let json = serde_json::to_string(&endpoint).unwrap(); + let decoded: IpcEndpointMetadata = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded.socket_name, paths.socket_name()); + assert_eq!( + decoded.security_policy.allowed_principal, + "same-runtime-owner-account" + ); + assert!(decoded.capabilities.supports("logs")); + } + + #[test] + fn publication_writes_and_removes_endpoint_metadata() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let metadata = IpcEndpointMetadata::new( + &paths, + IpcPrincipal::new("alice", PrivilegeStatus::NormalUser), + ); + + let publication = IpcEndpointPublication::publish(&paths, &metadata).unwrap(); + let discovered = discover_ipc_endpoint(&paths).unwrap(); + + assert_eq!(discovered.socket_name, paths.socket_name()); + drop(publication); + assert!(!paths.ipc_endpoint_path().exists()); + } +} diff --git a/crates/cadder-daemon/src/lib.rs b/crates/cadder-daemon/src/lib.rs index 57a9a54..8f4b855 100644 --- a/crates/cadder-daemon/src/lib.rs +++ b/crates/cadder-daemon/src/lib.rs @@ -1,45 +1,435 @@ +mod autostart; mod caddy; mod config; mod iis; mod ipc; +mod ipc_security; mod logs; mod paths; +mod privilege; mod runtime; +mod runtime_lock; mod state; +mod storage; -pub use caddy::{CaddyConfigAdapter, CaddyConfigCoordinator, RealCaddyResolver}; +pub use caddy::{ + CaddyBackendMode, CaddyConfigAdapter, CaddyConfigCoordinator, CaddyRegistrationAdapter, + RealCaddyResolver, +}; pub use config::{CONFIG_FILE_NAME, CadderConfig, CaddyRuntimeConfig}; pub use iis::{IisBindingRecord, IisMetadataStore, IisProvider}; pub use ipc::{ - CadderClient, CadderSession, DaemonLaunchOptions, DaemonServer, StateSubscription, - ensure_daemon_running, ensure_daemon_running_with_options, + CadderClient, CadderSession, DaemonLaunchMode, DaemonLaunchOptions, DaemonServer, + StateSubscription, ensure_daemon_running, ensure_daemon_running_with_options, +}; +pub use ipc_security::{ + IpcAccessDecision, IpcEndpointMetadata, IpcEndpointPublication, IpcOperation, IpcOperationKind, + IpcPrincipal, IpcSecurityPolicy, discover_ipc_endpoint, }; pub use logs::{CaddyLogStore, Redactor}; -pub use paths::{DaemonLock, RuntimePaths}; -pub use runtime::ProcessRuntime; +pub use paths::{RuntimePaths, RuntimeProfile}; +pub use privilege::{ + PrivilegeDiagnostic, PrivilegeStatus, current_privilege_status, + elevated_management_surface_diagnostic, management_surface_privilege_diagnostic, + shim_privilege_diagnostic, +}; +pub use runtime::{CaddyRuntime, MockCaddyRuntime, ProcessRuntime, RuntimeTimeouts}; +pub use runtime_lock::DaemonLock; pub use state::DaemonState; +pub use storage::RuntimeStore; -use anyhow::Result; +use anyhow::{Result, bail}; +use cadder_protocol::{LogAttributionKind, LogSeverity, LogStreamIdentity}; use std::path::PathBuf; use tokio::sync::watch; +use tokio::time::{Duration, sleep}; + +#[cfg(test)] +pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[derive(Debug, Clone)] pub struct DaemonOptions { pub runtime_dir: Option, + pub runtime_profile: Option, pub real_caddy_command: Option, + pub caddy_backend: Option, } +const DAEMON_RUNTIME_RELEASE_ATTEMPTS: usize = 50; +const DAEMON_RUNTIME_RELEASE_POLL_INTERVAL: Duration = Duration::from_millis(100); + pub async fn run_daemon(options: DaemonOptions, shutdown: watch::Receiver) -> Result<()> { - let paths = RuntimePaths::resolve(options.runtime_dir)?; + let paths = RuntimePaths::resolve_with_profile(options.runtime_dir, options.runtime_profile)?; paths.ensure_dirs()?; - let _lock = DaemonLock::acquire(paths.lock_path())?; + let Some(lock) = acquire_daemon_lock_or_wait_for_ready(&paths).await? else { + return Ok(()); + }; + let lock_recovery = lock.recovery().cloned(); - let real_caddy = RealCaddyResolver::new(options.real_caddy_command); - let adapter = CaddyConfigAdapter::new(real_caddy.clone()); - let runtime = ProcessRuntime::new(real_caddy, paths.clone()); - let coordinator = CaddyConfigCoordinator::new(adapter, runtime); + let caddy_backend = options + .caddy_backend + .map_or_else(CaddyBackendMode::from_env, Ok)?; + if caddy_backend == CaddyBackendMode::Mock && options.real_caddy_command.is_some() { + bail!("--real-caddy-command cannot be combined with --caddy-backend mock"); + } + let coordinator = match caddy_backend { + CaddyBackendMode::Real => { + let real_caddy = RealCaddyResolver::new(options.real_caddy_command); + let adapter = CaddyConfigAdapter::new(real_caddy.clone()); + let runtime = ProcessRuntime::new(real_caddy, paths.clone()); + CaddyConfigCoordinator::new(adapter, runtime) + } + CaddyBackendMode::Mock => CaddyConfigCoordinator::new_mock(paths.clone()), + }; let state = DaemonState::with_runtime_paths(coordinator, paths.clone()).await?; + if let Some(recovery) = lock_recovery { + state.logs().append( + LogStreamIdentity::runtime_control(), + LogSeverity::Warn, + recovery.log_message(), + LogAttributionKind::RuntimeControl, + Some("runtime-lock-recovery".to_string()), + ); + } let server = DaemonServer::new(paths, state); - server.run_until(shutdown).await + let result = server.run_until(shutdown).await; + drop(lock); + result +} + +pub async fn wait_for_daemon_runtime_released(paths: &RuntimePaths) -> Result<()> { + wait_for_daemon_runtime_released_with_limits( + paths, + DAEMON_RUNTIME_RELEASE_ATTEMPTS, + DAEMON_RUNTIME_RELEASE_POLL_INTERVAL, + ) + .await +} + +async fn wait_for_daemon_runtime_released_with_limits( + paths: &RuntimePaths, + attempts: usize, + poll_interval: Duration, +) -> Result<()> { + for _ in 0..attempts { + if !ipc::is_daemon_ready(paths).await + && let Some(lock) = DaemonLock::try_acquire(paths.lock_path())? + { + drop(lock); + return Ok(()); + } + sleep(poll_interval).await; + } + + bail!( + "previous cadderd owner did not release socket and daemon lock before timeout for runtime {}; retry restart after it exits, or inspect `cadder daemon status --runtime-dir \"{}\"` before removing stale runtime files", + paths.runtime_dir().display(), + paths.runtime_dir().display() + ) +} + +async fn acquire_daemon_lock_or_wait_for_ready(paths: &RuntimePaths) -> Result> { + acquire_daemon_lock_or_wait_for_ready_with_limits( + paths, + DAEMON_RUNTIME_RELEASE_ATTEMPTS, + DAEMON_RUNTIME_RELEASE_POLL_INTERVAL, + ) + .await +} + +async fn acquire_daemon_lock_or_wait_for_ready_with_limits( + paths: &RuntimePaths, + attempts: usize, + poll_interval: Duration, +) -> Result> { + for _ in 0..attempts { + if ipc::is_daemon_ready(paths).await { + return Ok(None); + } + if let Some(lock) = DaemonLock::try_acquire_for_runtime(paths)? { + return Ok(Some(lock)); + } + sleep(poll_interval).await; + } + + if ipc::is_daemon_ready(paths).await { + return Ok(None); + } + + bail!( + "previous cadderd owner did not release socket and daemon lock before timeout for runtime {}; {}", + paths.runtime_dir().display(), + DaemonLock::active_owner_diagnostic(paths) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use cadder_protocol::{ + LogSeverity, LogStreamIdentity, QueryLogsRequest, QueryLogsResponse, QueryStateRequest, + QueryStateResponse, message_types, new_request_id, + }; + use std::fs; + use tokio::time::{Duration, sleep, timeout}; + + #[tokio::test] + async fn run_daemon_starts_ipc_and_stops_on_shutdown_signal() { + let temp = tempfile::tempdir().unwrap(); + let runtime_dir = temp.path().join("runtime"); + let paths = RuntimePaths::resolve(Some(runtime_dir.clone())).unwrap(); + let client = CadderClient::new(paths); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let daemon = tokio::spawn(run_daemon( + DaemonOptions { + runtime_dir: Some(runtime_dir), + real_caddy_command: Some("caddy".to_string()), + runtime_profile: None, + caddy_backend: None, + }, + shutdown_rx, + )); + + let response = wait_for_query_state(&client).await; + shutdown_tx.send(true).unwrap(); + let daemon_result = timeout(Duration::from_secs(2), daemon) + .await + .unwrap() + .unwrap(); + + assert!(response.accepted); + assert!(response.snapshot.is_some()); + daemon_result.unwrap(); + } + + #[tokio::test] + async fn run_daemon_returns_ok_when_runtime_already_has_healthy_socket() { + let temp = tempfile::tempdir().unwrap(); + let runtime_dir = temp.path().join("runtime"); + let paths = RuntimePaths::resolve(Some(runtime_dir.clone())).unwrap(); + let client = CadderClient::new(paths); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let daemon = tokio::spawn(run_daemon( + DaemonOptions { + runtime_dir: Some(runtime_dir.clone()), + real_caddy_command: None, + runtime_profile: None, + caddy_backend: Some(CaddyBackendMode::Mock), + }, + shutdown_rx, + )); + let response = wait_for_query_state(&client).await; + + let (_second_shutdown_tx, second_shutdown_rx) = watch::channel(false); + let second_start = timeout( + Duration::from_secs(1), + run_daemon( + DaemonOptions { + runtime_dir: Some(runtime_dir), + real_caddy_command: None, + runtime_profile: None, + caddy_backend: Some(CaddyBackendMode::Mock), + }, + second_shutdown_rx, + ), + ) + .await + .unwrap(); + + assert!(response.accepted); + second_start.unwrap(); + shutdown_tx.send(true).unwrap(); + timeout(Duration::from_secs(2), daemon) + .await + .unwrap() + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn run_daemon_logs_stale_lock_recovery() { + let temp = tempfile::tempdir().unwrap(); + let runtime_dir = temp.path().join("runtime"); + let paths = RuntimePaths::resolve(Some(runtime_dir.clone())).unwrap(); + paths.ensure_dirs().unwrap(); + fs::write( + paths.lock_metadata_path(), + r#"{ + "metadataVersion": 1, + "cadderVersion": "0.7.0", + "protocolVersion": 2, + "minimumCompatibleProtocolVersion": 1, + "capabilities": { + "protocolVersion": 2, + "minimumCompatibleProtocolVersion": 1, + "supportedCapabilities": [], + "supportedCapabilityVersions": [] + }, + "runtimeProfile": "default", + "runtimeDir": "stale-runtime", + "instanceKey": "stale-instance", + "socketName": "stale.sock", + "processId": 1, + "acquiredAtUtc": "2026-01-01T00:00:00Z", + "executablePath": "old-cadderd" + }"#, + ) + .unwrap(); + let client = CadderClient::new(paths); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let daemon = tokio::spawn(run_daemon( + DaemonOptions { + runtime_dir: Some(runtime_dir), + real_caddy_command: None, + runtime_profile: None, + caddy_backend: Some(CaddyBackendMode::Mock), + }, + shutdown_rx, + )); + + let response = wait_for_recovery_log(&client).await; + shutdown_tx.send(true).unwrap(); + timeout(Duration::from_secs(2), daemon) + .await + .unwrap() + .unwrap() + .unwrap(); + + assert!(response.entries.iter().any(|entry| { + entry.operation.as_deref() == Some("runtime-lock-recovery") + && entry.severity == LogSeverity::Warn + && entry + .raw_message + .contains("Recovered stale daemon lock metadata") + })); + } + + #[tokio::test] + async fn daemon_runtime_release_waits_for_previous_lock_owner() { + let temp = tempfile::tempdir().unwrap(); + let runtime_dir = temp.path().join("runtime"); + let paths = RuntimePaths::resolve(Some(runtime_dir)).unwrap(); + paths.ensure_dirs().unwrap(); + let lock = DaemonLock::acquire(paths.lock_path()).unwrap(); + let release_path = paths.lock_path(); + let release_lock = tokio::spawn(async move { + sleep(Duration::from_millis(40)).await; + drop(lock); + assert!(DaemonLock::try_acquire(release_path).unwrap().is_some()); + }); + + wait_for_daemon_runtime_released(&paths).await.unwrap(); + release_lock.await.unwrap(); + } + + #[tokio::test] + async fn daemon_runtime_release_reports_precise_timeout_for_locked_runtime() { + let temp = tempfile::tempdir().unwrap(); + let runtime_dir = temp.path().join("runtime"); + let paths = RuntimePaths::resolve(Some(runtime_dir)).unwrap(); + paths.ensure_dirs().unwrap(); + let _lock = DaemonLock::acquire(paths.lock_path()).unwrap(); + + let error = wait_for_daemon_runtime_released_with_limits(&paths, 2, Duration::from_millis(1)) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("previous cadderd owner did not release socket and daemon lock"), + "{error:?}" + ); + } + + #[tokio::test] + async fn daemon_lock_wait_can_take_over_after_failed_previous_start() { + let temp = tempfile::tempdir().unwrap(); + let runtime_dir = temp.path().join("runtime"); + let paths = RuntimePaths::resolve(Some(runtime_dir)).unwrap(); + paths.ensure_dirs().unwrap(); + let lock = DaemonLock::acquire(paths.lock_path()).unwrap(); + let release_lock = tokio::spawn(async move { + sleep(Duration::from_millis(40)).await; + drop(lock); + }); + + let acquired = + acquire_daemon_lock_or_wait_for_ready_with_limits(&paths, 20, Duration::from_millis(10)) + .await + .unwrap(); + + assert!(acquired.is_some()); + release_lock.await.unwrap(); + } + + #[tokio::test] + async fn daemon_lock_wait_reports_locked_runtime_without_ready_socket() { + let temp = tempfile::tempdir().unwrap(); + let runtime_dir = temp.path().join("runtime"); + let paths = RuntimePaths::resolve(Some(runtime_dir)).unwrap(); + paths.ensure_dirs().unwrap(); + let _lock = DaemonLock::acquire(paths.lock_path()).unwrap(); + let error = + acquire_daemon_lock_or_wait_for_ready_with_limits(&paths, 2, Duration::from_millis(1)) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("previous cadderd owner did not release socket and daemon lock"), + "{error:?}" + ); + } + + async fn wait_for_query_state(client: &CadderClient) -> QueryStateResponse { + for _ in 0..50 { + let result = client + .request::<_, QueryStateResponse>( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("wait"), + }, + ) + .await; + if let Ok(response) = result { + return response; + } + sleep(Duration::from_millis(20)).await; + } + + panic!("daemon server did not become ready"); + } + + async fn wait_for_recovery_log(client: &CadderClient) -> QueryLogsResponse { + for _ in 0..50 { + let result = client + .request::<_, QueryLogsResponse>( + message_types::QUERY_LOGS_REQUEST, + message_types::QUERY_LOGS_RESPONSE, + &QueryLogsRequest { + request_id: new_request_id("wait-lock-recovery"), + stream: LogStreamIdentity::runtime_control(), + limit: Some(20), + cursor: None, + minimum_severity: None, + }, + ) + .await; + if let Ok(response) = result + && response + .entries + .iter() + .any(|entry| entry.operation.as_deref() == Some("runtime-lock-recovery")) + { + return response; + } + sleep(Duration::from_millis(20)).await; + } + + panic!("daemon recovery log did not become available"); + } } diff --git a/crates/cadder-daemon/src/paths.rs b/crates/cadder-daemon/src/paths.rs index 75d2c7a..ce4c5e5 100644 --- a/crates/cadder-daemon/src/paths.rs +++ b/crates/cadder-daemon/src/paths.rs @@ -1,44 +1,106 @@ use anyhow::{Context, Result, anyhow}; use directories::ProjectDirs; -use fs4::FileExt; use sha2::{Digest, Sha256}; use std::{ - env, - fs::{self, File, OpenOptions}, + env, fmt, fs, path::{Path, PathBuf}, + str::FromStr, }; +pub const CADDER_RUNTIME_PROFILE_ENV: &str = "CADDER_RUNTIME_PROFILE"; +pub const CADDER_DEV_WORKSPACE_ENV: &str = "CADDER_DEV_WORKSPACE"; +pub const CADDER_DEV_ID_ENV: &str = "CADDER_DEV_ID"; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum RuntimeProfile { + #[default] + Default, + Dev, +} + +impl RuntimeProfile { + pub fn from_env() -> Result { + env::var(CADDER_RUNTIME_PROFILE_ENV) + .ok() + .map_or(Ok(Self::Default), |value| value.parse()) + } + + pub fn parse_cli(value: &str) -> std::result::Result { + value.parse::().map_err(|error| error.to_string()) + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Dev => "dev", + } + } +} + +impl fmt::Display for RuntimeProfile { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for RuntimeProfile { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "" | "default" | "prod" | "production" => Ok(Self::Default), + "dev" | "development" => Ok(Self::Dev), + other => Err(anyhow!( + "unknown Cadder runtime profile `{other}`; expected `default` or `dev`" + )), + } + } +} + #[derive(Debug, Clone)] pub struct RuntimePaths { runtime_dir: PathBuf, + instance_key: String, socket_name: String, + runtime_profile: RuntimeProfile, } impl RuntimePaths { pub fn resolve(override_dir: Option) -> Result { - let runtime_dir = match override_dir { - Some(path) => path, - None if env::var_os("CADDER_RUNTIME_DIR").is_some() => { - PathBuf::from(env::var_os("CADDER_RUNTIME_DIR").expect("checked")) - } - None => { - let dirs = ProjectDirs::from("dev", "Cadder", "Cadder") - .ok_or_else(|| anyhow!("could not resolve per-user project directories"))?; - dirs - .runtime_dir() - .map(Path::to_path_buf) - .unwrap_or_else(|| dirs.data_local_dir().join("run")) + Self::resolve_with_profile(override_dir, None) + } + + pub fn resolve_with_profile( + override_dir: Option, + runtime_profile: Option, + ) -> Result { + let env_runtime_dir = env::var_os("CADDER_RUNTIME_DIR"); + let runtime_profile = match runtime_profile { + Some(profile) => profile, + None if override_dir.is_none() && env_runtime_dir.is_none() => RuntimeProfile::from_env()?, + None => RuntimeProfile::Default, + }; + let runtime_dir = if let Some(path) = override_dir { + path + } else if let Some(path) = env_runtime_dir { + PathBuf::from(path) + } else { + match runtime_profile { + RuntimeProfile::Default => default_runtime_dir()?, + RuntimeProfile::Dev => dev_runtime_dir()?, } }; let mut hasher = Sha256::new(); hasher.update(runtime_dir.to_string_lossy().as_bytes()); - let suffix = hex::encode(&hasher.finalize()[..8]); - let socket_name = format!("cadder-{suffix}.sock"); + let instance_key = hex::encode(&hasher.finalize()[..8]); + let socket_name = format!("cadder-{instance_key}.sock"); Ok(Self { runtime_dir, + instance_key, socket_name, + runtime_profile, }) } @@ -55,53 +117,130 @@ impl RuntimePaths { &self.socket_name } + pub fn instance_key(&self) -> &str { + &self.instance_key + } + + pub fn runtime_profile(&self) -> RuntimeProfile { + self.runtime_profile + } + pub fn lock_path(&self) -> PathBuf { self.runtime_dir.join("cadder.lock") } + pub fn lock_metadata_path(&self) -> PathBuf { + self.runtime_dir.join("cadder.lock.json") + } + + pub fn ipc_endpoint_path(&self) -> PathBuf { + self.runtime_dir.join("cadder-ipc.json") + } + pub fn metadata_path(&self) -> PathBuf { self.runtime_dir.join("daemon.json") } + pub fn storage_path(&self) -> PathBuf { + self.runtime_dir.join("runtime.sqlite3") + } + pub fn effective_config_path(&self) -> PathBuf { self.runtime_dir.join("effective-caddy.json") } } -#[derive(Debug)] -pub struct DaemonLock { - _file: File, +fn default_runtime_dir() -> Result { + let dirs = ProjectDirs::from("dev", "Cadder", "Cadder") + .ok_or_else(|| anyhow!("could not resolve per-user project directories"))?; + Ok( + dirs + .runtime_dir() + .map(Path::to_path_buf) + .unwrap_or_else(|| dirs.data_local_dir().join("run")), + ) } -impl DaemonLock { - pub fn acquire(path: PathBuf) -> Result { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("create lock directory {}", parent.display()))?; - } +fn dev_runtime_dir() -> Result { + Ok( + default_runtime_dir()? + .join("profiles") + .join("dev") + .join(dev_profile_id()?), + ) +} + +fn dev_profile_id() -> Result { + if let Some(value) = env::var(CADDER_DEV_ID_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + return validate_dev_profile_id(value); + } - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&path) - .with_context(|| format!("open daemon lock {}", path.display()))?; - FileExt::try_lock(&file).with_context(|| format!("acquire daemon lock {}", path.display()))?; - Ok(Self { _file: file }) + let workspace = env::var_os(CADDER_DEV_WORKSPACE_ENV) + .map(PathBuf::from) + .map(Ok) + .unwrap_or_else(env::current_dir) + .context("resolve Cadder dev workspace")?; + let canonical = workspace.canonicalize().unwrap_or(workspace); + let mut hasher = Sha256::new(); + hasher.update(canonical.to_string_lossy().as_bytes()); + Ok(format!( + "workspace-{}", + hex::encode(&hasher.finalize()[..8]) + )) +} + +fn validate_dev_profile_id(value: String) -> Result { + if !matches!(value.as_str(), "." | "..") + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) + { + Ok(value) + } else { + Err(anyhow!( + "{CADDER_DEV_ID_ENV} may only contain ASCII letters, digits, `.`, `_`, or `-`" + )) } } #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; - #[test] - fn lock_rejects_second_owner() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("daemon.lock"); - let _first = DaemonLock::acquire(path.clone()).unwrap(); - assert!(DaemonLock::acquire(path).is_err()); + struct EnvSnapshot { + key: &'static str, + value: Option, + } + + impl EnvSnapshot { + fn capture(key: &'static str) -> Self { + Self { + key, + value: env::var_os(key), + } + } + } + + impl Drop for EnvSnapshot { + fn drop(&mut self) { + unsafe { + match &self.value { + Some(value) => env::set_var(self.key, value), + None => env::remove_var(self.key), + } + } + } + } + + fn lock_env() -> std::sync::MutexGuard<'static, ()> { + crate::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) } #[test] @@ -112,16 +251,117 @@ mod tests { let second = RuntimePaths::resolve(Some(dir.path().to_path_buf())).unwrap(); assert_eq!(first.runtime_dir(), dir.path()); + assert_eq!(first.instance_key(), second.instance_key()); assert_eq!(first.socket_name(), second.socket_name()); assert!(first.socket_name().starts_with("cadder-")); + assert_eq!(first.runtime_profile(), RuntimeProfile::Default); assert_eq!(first.lock_path(), dir.path().join("cadder.lock")); + assert_eq!( + first.lock_metadata_path(), + dir.path().join("cadder.lock.json") + ); + assert_eq!( + first.ipc_endpoint_path(), + dir.path().join("cadder-ipc.json") + ); assert_eq!(first.metadata_path(), dir.path().join("daemon.json")); + assert_eq!(first.storage_path(), dir.path().join("runtime.sqlite3")); assert_eq!( first.effective_config_path(), dir.path().join("effective-caddy.json") ); } + #[test] + fn resolve_uses_runtime_dir_environment_override() { + let _lock = lock_env(); + let _snapshot = EnvSnapshot::capture("CADDER_RUNTIME_DIR"); + let _profile_snapshot = EnvSnapshot::capture(CADDER_RUNTIME_PROFILE_ENV); + let dir = tempfile::tempdir().unwrap(); + let runtime_dir = dir.path().join("env-runtime"); + unsafe { + env::set_var("CADDER_RUNTIME_DIR", &runtime_dir); + env::set_var(CADDER_RUNTIME_PROFILE_ENV, "dev"); + } + + let paths = RuntimePaths::resolve(None).unwrap(); + + assert_eq!(paths.runtime_dir(), runtime_dir); + assert!(paths.socket_name().starts_with("cadder-")); + } + + #[test] + fn resolve_without_override_uses_project_runtime_location() { + let _lock = lock_env(); + let _snapshot = EnvSnapshot::capture("CADDER_RUNTIME_DIR"); + let _profile_snapshot = EnvSnapshot::capture(CADDER_RUNTIME_PROFILE_ENV); + unsafe { + env::remove_var("CADDER_RUNTIME_DIR"); + env::remove_var(CADDER_RUNTIME_PROFILE_ENV); + } + + let paths = RuntimePaths::resolve(None).unwrap(); + + assert!(!paths.runtime_dir().as_os_str().is_empty()); + assert!(paths.socket_name().starts_with("cadder-")); + } + + #[test] + fn resolve_dev_profile_uses_deterministic_workspace_identity() { + let _lock = lock_env(); + let _runtime_snapshot = EnvSnapshot::capture("CADDER_RUNTIME_DIR"); + let _profile_snapshot = EnvSnapshot::capture(CADDER_RUNTIME_PROFILE_ENV); + let _workspace_snapshot = EnvSnapshot::capture(CADDER_DEV_WORKSPACE_ENV); + let _id_snapshot = EnvSnapshot::capture(CADDER_DEV_ID_ENV); + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + unsafe { + env::remove_var("CADDER_RUNTIME_DIR"); + env::set_var(CADDER_RUNTIME_PROFILE_ENV, "dev"); + env::set_var(CADDER_DEV_WORKSPACE_ENV, &workspace); + env::remove_var(CADDER_DEV_ID_ENV); + } + + let first = RuntimePaths::resolve(None).unwrap(); + let second = RuntimePaths::resolve_with_profile(None, Some(RuntimeProfile::Dev)).unwrap(); + let default = RuntimePaths::resolve_with_profile(None, Some(RuntimeProfile::Default)).unwrap(); + + assert_eq!(first.runtime_dir(), second.runtime_dir()); + assert_ne!(first.runtime_dir(), default.runtime_dir()); + assert_eq!(first.runtime_profile(), RuntimeProfile::Dev); + assert_eq!(default.runtime_profile(), RuntimeProfile::Default); + assert!(first.runtime_dir().ends_with(first_profile_id(&workspace))); + assert_eq!(first.socket_name(), second.socket_name()); + assert_eq!(first.instance_key(), second.instance_key()); + } + + #[test] + fn resolve_dev_profile_id_override_controls_runtime_identity() { + let _lock = lock_env(); + let _runtime_snapshot = EnvSnapshot::capture("CADDER_RUNTIME_DIR"); + let _profile_snapshot = EnvSnapshot::capture(CADDER_RUNTIME_PROFILE_ENV); + let _workspace_snapshot = EnvSnapshot::capture(CADDER_DEV_WORKSPACE_ENV); + let _id_snapshot = EnvSnapshot::capture(CADDER_DEV_ID_ENV); + unsafe { + env::remove_var("CADDER_RUNTIME_DIR"); + env::set_var(CADDER_RUNTIME_PROFILE_ENV, "dev"); + env::set_var(CADDER_DEV_WORKSPACE_ENV, "ignored-workspace"); + env::set_var(CADDER_DEV_ID_ENV, "ci-profile"); + } + + let paths = RuntimePaths::resolve(None).unwrap(); + + assert!(paths.runtime_dir().ends_with("ci-profile")); + } + + #[test] + fn runtime_profile_parser_rejects_unknown_values() { + let error = "isolated".parse::().unwrap_err(); + + assert!(error.to_string().contains("unknown Cadder runtime profile")); + } + #[test] fn ensure_dirs_creates_runtime_directory() { let dir = tempfile::tempdir().unwrap(); @@ -132,4 +372,11 @@ mod tests { assert!(runtime_dir.is_dir()); } + + fn first_profile_id(workspace: &Path) -> String { + let canonical = workspace.canonicalize().unwrap(); + let mut hasher = Sha256::new(); + hasher.update(canonical.to_string_lossy().as_bytes()); + format!("workspace-{}", hex::encode(&hasher.finalize()[..8])) + } } diff --git a/crates/cadder-daemon/src/privilege.rs b/crates/cadder-daemon/src/privilege.rs new file mode 100644 index 0000000..d569554 --- /dev/null +++ b/crates/cadder-daemon/src/privilege.rs @@ -0,0 +1,157 @@ +#[cfg(any(test, debug_assertions))] +use std::env; + +use serde::{Deserialize, Serialize}; + +#[cfg(any(test, debug_assertions))] +const TEST_OVERRIDE_ENV: &str = "CADDER_TEST_ELEVATED_CONTEXT"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum PrivilegeStatus { + NormalUser, + Elevated, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrivilegeDiagnostic { + pub code: &'static str, + pub message: String, + pub guidance: String, +} + +pub fn current_privilege_status() -> PrivilegeStatus { + if let Some(status) = test_override_status() { + return status; + } + + platform_privilege_status() +} + +pub fn management_surface_privilege_diagnostic(surface_label: &str) -> Option { + (current_privilege_status() == PrivilegeStatus::Elevated) + .then(|| elevated_management_surface_diagnostic(surface_label)) +} + +pub fn shim_privilege_diagnostic(surface_label: &str) -> Option { + (current_privilege_status() == PrivilegeStatus::Elevated) + .then(|| elevated_shim_diagnostic(surface_label)) +} + +pub fn elevated_management_surface_diagnostic(surface_label: &str) -> PrivilegeDiagnostic { + PrivilegeDiagnostic { + code: "least-privilege-elevated-context", + message: format!( + "{surface_label} is running with elevated privileges. Cadder management surfaces are designed to run as the normal user." + ), + guidance: "Close this process and reopen it from a normal user shell when possible. Cadder will request administrator approval only for the specific IIS mutation batch that needs it." + .to_string(), + } +} + +pub fn elevated_shim_diagnostic(surface_label: &str) -> PrivilegeDiagnostic { + PrivilegeDiagnostic { + code: "least-privilege-elevated-shim", + message: format!( + "{surface_label} is running with elevated privileges. Normal Cadder shim registrations should run as the user that owns the Cadder runtime." + ), + guidance: "Restart from a normal user shell unless the hosted project itself requires elevation. IIS handoff will request administrator approval only for the IIS mutation batch that needs it." + .to_string(), + } +} + +#[cfg(windows)] +fn platform_privilege_status() -> PrivilegeStatus { + if is_elevated::is_elevated() { + PrivilegeStatus::Elevated + } else { + PrivilegeStatus::NormalUser + } +} + +#[cfg(unix)] +fn platform_privilege_status() -> PrivilegeStatus { + // SAFETY: `geteuid` has no preconditions and only reads process identity. + if unsafe { libc::geteuid() } == 0 { + PrivilegeStatus::Elevated + } else { + PrivilegeStatus::NormalUser + } +} + +#[cfg(not(any(unix, windows)))] +fn platform_privilege_status() -> PrivilegeStatus { + PrivilegeStatus::Unknown +} + +fn test_override_status() -> Option { + #[cfg(any(test, debug_assertions))] + { + let value = env::var(TEST_OVERRIDE_ENV).ok()?; + if value.eq_ignore_ascii_case("elevated") + || value.eq_ignore_ascii_case("admin") + || value.eq_ignore_ascii_case("root") + || value == "1" + { + return Some(PrivilegeStatus::Elevated); + } + if value.eq_ignore_ascii_case("normal") || value.eq_ignore_ascii_case("user") || value == "0" { + return Some(PrivilegeStatus::NormalUser); + } + if value.eq_ignore_ascii_case("unknown") { + return Some(PrivilegeStatus::Unknown); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn elevated_management_surface_diagnostic_names_surface_and_guidance() { + let diagnostic = elevated_management_surface_diagnostic("cadder"); + + assert_eq!(diagnostic.code, "least-privilege-elevated-context"); + assert!(diagnostic.message.contains("cadder")); + assert!(diagnostic.guidance.contains("normal user shell")); + assert!(diagnostic.guidance.contains("IIS mutation batch")); + } + + #[test] + fn elevated_shim_diagnostic_uses_shim_specific_guidance() { + let diagnostic = elevated_shim_diagnostic("caddy shim"); + + assert_eq!(diagnostic.code, "least-privilege-elevated-shim"); + assert!(diagnostic.message.contains("caddy shim")); + assert!( + diagnostic + .message + .contains("user that owns the Cadder runtime") + ); + assert!( + diagnostic + .guidance + .contains("hosted project itself requires elevation") + ); + } + + #[test] + fn test_override_status_accepts_supported_values() { + let _guard = crate::TEST_ENV_LOCK.lock().unwrap(); + + unsafe { env::set_var(TEST_OVERRIDE_ENV, "elevated") }; + assert_eq!(current_privilege_status(), PrivilegeStatus::Elevated); + + unsafe { env::set_var(TEST_OVERRIDE_ENV, "normal") }; + assert_eq!(current_privilege_status(), PrivilegeStatus::NormalUser); + + unsafe { env::set_var(TEST_OVERRIDE_ENV, "unknown") }; + assert_eq!(current_privilege_status(), PrivilegeStatus::Unknown); + + unsafe { env::remove_var(TEST_OVERRIDE_ENV) }; + } +} diff --git a/crates/cadder-daemon/src/runtime.rs b/crates/cadder-daemon/src/runtime.rs index 0056a72..9d857b8 100644 --- a/crates/cadder-daemon/src/runtime.rs +++ b/crates/cadder-daemon/src/runtime.rs @@ -9,45 +9,174 @@ use tokio::{ io::{AsyncBufReadExt, BufReader}, process::{Child, Command}, sync::Mutex, + task::yield_now, time::timeout, }; use crate::caddy::RealCaddyResolver; +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +fn hidden_command(binary: PathBuf) -> Command { + let mut command = Command::new(binary); + configure_hidden_child(&mut command); + command +} + +#[cfg(windows)] +fn configure_hidden_child(command: &mut Command) { + command.creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(windows))] +fn configure_hidden_child(_command: &mut Command) {} + +#[derive(Debug, Clone)] +pub enum CaddyRuntime { + Real(ProcessRuntime), + Mock(MockCaddyRuntime), +} + +impl CaddyRuntime { + pub fn real(resolver: RealCaddyResolver, paths: RuntimePaths) -> Self { + Self::Real(ProcessRuntime::new(resolver, paths)) + } + + pub fn mock(paths: RuntimePaths) -> Self { + Self::Mock(MockCaddyRuntime::new(paths)) + } + + pub async fn inspect(&self) -> RuntimeState { + match self { + Self::Real(runtime) => runtime.inspect().await, + Self::Mock(runtime) => runtime.inspect().await, + } + } + + pub async fn apply_config(&self, rendered: &[u8], logs: &CaddyLogStore) -> Result<()> { + match self { + Self::Real(runtime) => runtime.apply_config(rendered, logs).await, + Self::Mock(runtime) => runtime.apply_config(rendered, logs).await, + } + } + + pub async fn stop(&self) -> Result<()> { + match self { + Self::Real(runtime) => runtime.stop().await, + Self::Mock(runtime) => runtime.stop().await, + } + } +} + +impl From for CaddyRuntime { + fn from(runtime: ProcessRuntime) -> Self { + Self::Real(runtime) + } +} + #[derive(Debug, Clone)] pub struct ProcessRuntime { resolver: RealCaddyResolver, paths: RuntimePaths, child: Arc>>, + timeouts: RuntimeTimeouts, +} + +#[derive(Debug, Clone, Copy)] +pub struct RuntimeTimeouts { + pub start_check: Duration, + pub reload: Duration, + pub graceful_stop: Duration, + pub stop_wait: Duration, + pub kill_wait: Duration, +} + +impl Default for RuntimeTimeouts { + fn default() -> Self { + Self { + start_check: Duration::from_millis(250), + reload: Duration::from_secs(30), + graceful_stop: Duration::from_secs(5), + stop_wait: Duration::from_secs(5), + kill_wait: Duration::from_secs(5), + } + } } impl ProcessRuntime { pub fn new(resolver: RealCaddyResolver, paths: RuntimePaths) -> Self { + Self::with_timeouts(resolver, paths, RuntimeTimeouts::default()) + } + + pub fn with_timeouts( + resolver: RealCaddyResolver, + paths: RuntimePaths, + timeouts: RuntimeTimeouts, + ) -> Self { Self { resolver, paths, child: Arc::new(Mutex::new(None)), + timeouts, } } pub async fn inspect(&self) -> RuntimeState { - let child = self.child.lock().await; - if let Some(child) = child.as_ref() { - RuntimeState { - status: RuntimeStatus::Running, - binary_path: self - .resolver - .resolve() - .ok() - .map(|path| path.display().to_string()), - version: None, - process_id: child.id(), - admin_endpoint: Some("localhost:2019".to_string()), - diagnostics: Vec::new(), + let binary_path = self + .resolver + .resolve() + .ok() + .map(|path| path.display().to_string()); + let mut guard = self.child.lock().await; + if let Some(child) = guard.as_mut() { + let process_id = child.id(); + let status = child.try_wait(); + match status { + Ok(None) => { + return RuntimeState { + status: RuntimeStatus::Running, + binary_path, + version: None, + process_id, + admin_endpoint: Some("localhost:2019".to_string()), + diagnostics: Vec::new(), + }; + } + Ok(Some(status)) => { + *guard = None; + return RuntimeState { + status: RuntimeStatus::Unhealthy, + binary_path, + version: None, + process_id, + admin_endpoint: Some("localhost:2019".to_string()), + diagnostics: vec![cadder_protocol::RuntimeDiagnostic { + code: "runtime-exited".to_string(), + message: format!("real Caddy runtime exited with status {status}"), + operation: Some("inspect".to_string()), + }], + }; + } + Err(error) => { + *guard = None; + return RuntimeState { + status: RuntimeStatus::Unhealthy, + binary_path, + version: None, + process_id, + admin_endpoint: Some("localhost:2019".to_string()), + diagnostics: vec![cadder_protocol::RuntimeDiagnostic { + code: "runtime-inspect-failed".to_string(), + message: format!("could not inspect real Caddy runtime: {error}"), + operation: Some("inspect".to_string()), + }], + }; + } } - } else { - RuntimeState::idle() } + + RuntimeState::idle() } pub async fn apply_config(&self, rendered: &[u8], logs: &CaddyLogStore) -> Result<()> { @@ -56,7 +185,7 @@ impl ProcessRuntime { .await .with_context(|| format!("write effective config {}", config_path.display()))?; - if self.child.lock().await.is_none() { + if !self.runtime_is_running(logs).await { self.start(&config_path, logs).await?; } else { self.reload(&config_path, logs).await?; @@ -66,14 +195,15 @@ impl ProcessRuntime { async fn start(&self, config_path: &PathBuf, logs: &CaddyLogStore) -> Result<()> { let binary = self.resolver.resolve()?; - let mut child = Command::new(binary) + let mut command = hidden_command(binary); + command .arg("run") .arg("--config") .arg(config_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .spawn() - .context("start real Caddy runtime")?; + .kill_on_drop(true); + let mut child = command.spawn().context("start real Caddy runtime")?; if let Some(stdout) = child.stdout.take() { spawn_log_reader(stdout, logs.clone(), "stdout"); @@ -82,6 +212,15 @@ impl ProcessRuntime { spawn_log_reader(stderr, logs.clone(), "stderr"); } + yield_now().await; + match timeout(self.timeouts.start_check, child.wait()).await { + Ok(Ok(status)) => { + anyhow::bail!("real Caddy runtime exited immediately with status {status}"); + } + Ok(Err(error)) => return Err(error).context("inspect started real Caddy runtime"), + Err(_) => {} + } + *self.child.lock().await = Some(child); logs.append( LogStreamIdentity::runtime_control(), @@ -95,14 +234,23 @@ impl ProcessRuntime { async fn reload(&self, config_path: &PathBuf, logs: &CaddyLogStore) -> Result<()> { let binary = self.resolver.resolve()?; - let output = Command::new(binary) + let mut command = hidden_command(binary); + command .arg("reload") .arg("--config") .arg(config_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .output() + .kill_on_drop(true); + let child = command.spawn().context("start real Caddy reload")?; + let output = timeout(self.timeouts.reload, child.wait_with_output()) .await + .with_context(|| { + format!( + "real Caddy reload timed out after {} seconds", + self.timeouts.reload.as_secs() + ) + })? .context("reload real Caddy runtime")?; if output.status.success() { logs.append( @@ -127,20 +275,152 @@ impl ProcessRuntime { } pub async fn stop(&self) -> Result<()> { - let mut child = self.child.lock().await; - if let Some(mut child) = child.take() { + let child = { + let mut guard = self.child.lock().await; + guard.take() + }; + if let Some(mut child) = child { + if child + .try_wait() + .context("inspect real Caddy runtime before stop")? + .is_some() + { + return Ok(()); + } + let mut stop_error = None; if let Ok(binary) = self.resolver.resolve() { - let _ = request_graceful_stop(binary).await; + stop_error = request_graceful_stop(binary, self.timeouts.graceful_stop) + .await + .err(); + } + if timeout(self.timeouts.stop_wait, child.wait()) + .await + .is_err() + { + let _ = child.kill().await; + timeout(self.timeouts.kill_wait, child.wait()) + .await + .context("wait for killed real Caddy runtime")? + .context("wait for real Caddy runtime after kill")?; + if stop_error.is_none() { + stop_error = Some(anyhow::anyhow!( + "real Caddy runtime did not stop within {} seconds and was killed", + self.timeouts.stop_wait.as_secs() + )); + } + } + if let Some(error) = stop_error { + return Err(error); } - let _ = child.kill().await; - let _ = child.wait().await; } Ok(()) } + + async fn runtime_is_running(&self, logs: &CaddyLogStore) -> bool { + let mut guard = self.child.lock().await; + let Some(child) = guard.as_mut() else { + return false; + }; + let status = child.try_wait(); + match status { + Ok(None) => true, + Ok(Some(status)) => { + logs.append( + LogStreamIdentity::runtime_control(), + LogSeverity::Warn, + format!("real Caddy runtime exited with status {status}; restarting"), + LogAttributionKind::RuntimeControl, + Some("runtime-liveness".to_string()), + ); + *guard = None; + false + } + Err(error) => { + logs.append( + LogStreamIdentity::runtime_control(), + LogSeverity::Error, + format!("could not inspect real Caddy runtime: {error}; restarting"), + LogAttributionKind::RuntimeControl, + Some("runtime-liveness".to_string()), + ); + *guard = None; + false + } + } + } } -async fn request_graceful_stop(binary: PathBuf) -> Result<()> { - let mut command = Command::new(binary); +#[derive(Debug, Clone)] +pub struct MockCaddyRuntime { + paths: RuntimePaths, + state: Arc>, +} + +#[derive(Debug, Default)] +struct MockCaddyRuntimeState { + running: bool, + applied_config_bytes: usize, +} + +impl MockCaddyRuntime { + pub fn new(paths: RuntimePaths) -> Self { + Self { + paths, + state: Arc::new(Mutex::new(MockCaddyRuntimeState::default())), + } + } + + pub async fn inspect(&self) -> RuntimeState { + let state = self.state.lock().await; + if state.running { + RuntimeState { + status: RuntimeStatus::Running, + binary_path: Some("mock-caddy".to_string()), + version: Some(format!("mock:{}", state.applied_config_bytes)), + process_id: None, + admin_endpoint: None, + diagnostics: Vec::new(), + } + } else { + RuntimeState::idle() + } + } + + pub async fn apply_config(&self, rendered: &[u8], logs: &CaddyLogStore) -> Result<()> { + let config_path = self.paths.effective_config_path(); + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent) + .await + .with_context(|| format!("create mock runtime directory {}", parent.display()))?; + } + fs::write(&config_path, rendered) + .await + .with_context(|| format!("write mock effective config {}", config_path.display()))?; + + { + let mut state = self.state.lock().await; + state.running = true; + state.applied_config_bytes = rendered.len(); + } + + logs.append( + LogStreamIdentity::runtime_control(), + LogSeverity::Info, + "mock Caddy runtime accepted effective config without starting a process", + LogAttributionKind::RuntimeControl, + Some("mock-apply".to_string()), + ); + Ok(()) + } + + pub async fn stop(&self) -> Result<()> { + self.state.lock().await.running = false; + Ok(()) + } +} + +async fn request_graceful_stop(binary: PathBuf, graceful_stop: Duration) -> Result<()> { + let mut command = hidden_command(binary); command .arg("stop") .arg("--address") @@ -149,9 +429,19 @@ async fn request_graceful_stop(binary: PathBuf) -> Result<()> { .stderr(Stdio::null()) .kill_on_drop(true); let mut child = command.spawn().context("start caddy stop")?; - if timeout(Duration::from_secs(5), child.wait()).await.is_err() { - let _ = child.kill().await; - let _ = child.wait().await; + let status = match timeout(graceful_stop, child.wait()).await { + Ok(result) => result.context("wait for caddy stop")?, + Err(_) => { + let _ = child.kill().await; + let _ = child.wait().await; + anyhow::bail!( + "caddy stop timed out after {} seconds", + graceful_stop.as_secs() + ); + } + }; + if !status.success() { + anyhow::bail!("caddy stop failed with status {status}"); } Ok(()) } @@ -182,3 +472,355 @@ where } }); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::logs::LogQuery; + use std::{fs as std_fs, path::Path}; + use tokio::time::sleep; + + #[derive(Debug, Clone, Copy)] + enum FakeRuntimeMode { + LongRunning, + ShortRun, + FailReload, + FailStop, + SlowStop, + } + + struct RuntimeFixture { + runtime: ProcessRuntime, + logs: CaddyLogStore, + command_log: PathBuf, + _temp: tempfile::TempDir, + } + + fn short_timeouts() -> RuntimeTimeouts { + RuntimeTimeouts { + start_check: Duration::from_millis(250), + reload: Duration::from_millis(500), + graceful_stop: Duration::from_millis(500), + stop_wait: Duration::from_secs(2), + kill_wait: Duration::from_secs(1), + } + } + + fn runtime_fixture(mode: FakeRuntimeMode) -> RuntimeFixture { + let temp = tempfile::tempdir().unwrap(); + let runtime_dir = temp.path().join("run"); + let paths = RuntimePaths::resolve(Some(runtime_dir)).unwrap(); + paths.ensure_dirs().unwrap(); + let command_log = temp.path().join("fake-caddy.log"); + let fake_caddy = write_fake_caddy(temp.path(), &command_log, mode); + let resolver = RealCaddyResolver::new(Some(fake_caddy.display().to_string())); + let runtime = ProcessRuntime::with_timeouts(resolver, paths, short_timeouts()); + + RuntimeFixture { + runtime, + logs: CaddyLogStore::default(), + command_log, + _temp: temp, + } + } + + #[test] + fn default_timeouts_keep_runtime_operations_bounded() { + let timeouts = RuntimeTimeouts::default(); + + assert_eq!(timeouts.start_check, Duration::from_millis(250)); + assert_eq!(timeouts.reload, Duration::from_secs(30)); + assert_eq!(timeouts.graceful_stop, Duration::from_secs(5)); + assert_eq!(timeouts.stop_wait, Duration::from_secs(5)); + assert_eq!(timeouts.kill_wait, Duration::from_secs(5)); + assert!(format!("{:?}", timeouts).contains("RuntimeTimeouts")); + } + + #[test] + fn process_runtime_clone_and_debug_keep_shared_child_contract() { + let fixture = runtime_fixture(FakeRuntimeMode::LongRunning); + let cloned = fixture.runtime.clone(); + + assert!(format!("{:?}", fixture.runtime).contains("ProcessRuntime")); + assert!(Arc::ptr_eq(&fixture.runtime.child, &cloned.child)); + } + + #[tokio::test] + async fn inspect_idle_runtime_has_no_stale_process_metadata() { + let fixture = runtime_fixture(FakeRuntimeMode::LongRunning); + + let state = fixture.runtime.inspect().await; + + assert_eq!(state.status, RuntimeStatus::Idle); + assert!(state.process_id.is_none()); + assert!(state.admin_endpoint.is_none()); + assert!(state.diagnostics.is_empty()); + } + + #[tokio::test] + async fn apply_config_starts_and_stops_running_runtime() { + let fixture = runtime_fixture(FakeRuntimeMode::LongRunning); + + fixture + .runtime + .apply_config(br#"{"apps":{}}"#, &fixture.logs) + .await + .unwrap(); + wait_for_command_log(&fixture.command_log, "run").await; + let state = fixture.runtime.inspect().await; + + assert_eq!(state.status, RuntimeStatus::Running); + assert!(state.process_id.is_some()); + fixture.runtime.stop().await.unwrap(); + wait_for_command_log(&fixture.command_log, "stop").await; + } + + #[tokio::test] + async fn apply_config_reloads_running_runtime_and_records_control_log() { + let fixture = runtime_fixture(FakeRuntimeMode::LongRunning); + fixture + .runtime + .apply_config(br#"{"apps":{}}"#, &fixture.logs) + .await + .unwrap(); + wait_for_command_log(&fixture.command_log, "run").await; + + fixture + .runtime + .apply_config(br#"{"apps":{"http":{}}}"#, &fixture.logs) + .await + .unwrap(); + wait_for_command_log(&fixture.command_log, "reload").await; + let logs = fixture.logs.query( + LogQuery { + stream: LogStreamIdentity::runtime_control(), + limit: 20, + after_sequence: None, + minimum_severity: None, + }, + true, + ); + + assert!(logs.entries.iter().any(|entry| { + entry.operation.as_deref() == Some("reload") + && entry.raw_message.contains("real Caddy runtime reloaded") + })); + fixture.runtime.stop().await.unwrap(); + } + + #[tokio::test] + async fn apply_config_reports_reload_failure_for_running_runtime() { + let fixture = runtime_fixture(FakeRuntimeMode::FailReload); + fixture + .runtime + .apply_config(br#"{"apps":{}}"#, &fixture.logs) + .await + .unwrap(); + wait_for_command_log(&fixture.command_log, "run").await; + + let error = fixture + .runtime + .apply_config(br#"{"apps":{"http":{}}}"#, &fixture.logs) + .await + .unwrap_err(); + + assert!(error.to_string().contains("caddy reload failed")); + wait_for_command_log(&fixture.command_log, "reload").await; + fixture.runtime.stop().await.unwrap(); + } + + #[tokio::test] + async fn apply_config_restarts_after_child_exit_without_prior_inspect() { + let fixture = runtime_fixture(FakeRuntimeMode::ShortRun); + fixture + .runtime + .apply_config(br#"{"apps":{}}"#, &fixture.logs) + .await + .unwrap(); + wait_for_command_log(&fixture.command_log, "run-exited").await; + + fixture + .runtime + .apply_config(br#"{"apps":{"tls":{}}}"#, &fixture.logs) + .await + .unwrap(); + + wait_for_command_count(&fixture.command_log, "run", 2).await; + } + + #[tokio::test] + async fn stop_times_out_slow_graceful_stop_and_kills_child() { + let fixture = runtime_fixture(FakeRuntimeMode::SlowStop); + fixture + .runtime + .apply_config(br#"{"apps":{}}"#, &fixture.logs) + .await + .unwrap(); + wait_for_command_log(&fixture.command_log, "run").await; + + let error = fixture.runtime.stop().await.unwrap_err(); + + assert!(error.to_string().contains("caddy stop timed out")); + wait_for_command_log(&fixture.command_log, "stop").await; + } + + #[tokio::test] + async fn request_graceful_stop_times_out_the_stop_command() { + let temp = tempfile::tempdir().unwrap(); + let command_log = temp.path().join("fake-caddy.log"); + let fake_caddy = write_fake_caddy(temp.path(), &command_log, FakeRuntimeMode::SlowStop); + + let error = request_graceful_stop(fake_caddy, Duration::from_millis(500)) + .await + .unwrap_err(); + + assert!(error.to_string().contains("caddy stop timed out")); + wait_for_command_log(&command_log, "stop").await; + } + + #[tokio::test] + async fn request_graceful_stop_reports_failed_stop_status() { + let temp = tempfile::tempdir().unwrap(); + let command_log = temp.path().join("fake-caddy.log"); + let fake_caddy = write_fake_caddy(temp.path(), &command_log, FakeRuntimeMode::FailStop); + + let error = request_graceful_stop(fake_caddy, Duration::from_secs(1)) + .await + .unwrap_err(); + + assert!(error.to_string().contains("caddy stop failed")); + wait_for_command_log(&command_log, "stop").await; + } + + async fn wait_for_command_log(path: &Path, command: &str) { + for _ in 0..500 { + let log = std_fs::read_to_string(path).unwrap_or_default(); + if log.lines().any(|line| line.starts_with(command)) { + return; + } + sleep(Duration::from_millis(20)).await; + } + let log = std_fs::read_to_string(path).unwrap_or_default(); + panic!("expected command `{command}` in fake Caddy log:\n{log}"); + } + + async fn wait_for_command_count(path: &Path, command: &str, expected: usize) { + for _ in 0..500 { + let log = std_fs::read_to_string(path).unwrap_or_default(); + let count = log.lines().filter(|line| line.starts_with(command)).count(); + if count >= expected { + return; + } + sleep(Duration::from_millis(20)).await; + } + let log = std_fs::read_to_string(path).unwrap_or_default(); + panic!("expected {expected} `{command}` commands in fake Caddy log:\n{log}"); + } + + fn write_fake_caddy(dir: &Path, command_log: &Path, mode: FakeRuntimeMode) -> PathBuf { + let stop_file = dir.join("fake-caddy.stop"); + + #[cfg(windows)] + { + let path = dir.join("fake-caddy.cmd"); + let reload_behavior = if matches!(mode, FakeRuntimeMode::FailReload) { + "echo reload failed 1>&2\r\n exit /b 7" + } else { + "exit /b 0" + }; + let stop_behavior = if matches!(mode, FakeRuntimeMode::SlowStop) { + "ping -n 3 127.0.0.1 >nul\r\n echo stop> \"{stop_file}\"\r\n exit /b 0" + } else if matches!(mode, FakeRuntimeMode::FailStop) { + "echo stop> \"{stop_file}\"\r\n exit /b 7" + } else { + "echo stop> \"{stop_file}\"\r\n exit /b 0" + }; + let run_behavior = if matches!(mode, FakeRuntimeMode::ShortRun) { + "ping -n 2 127.0.0.1 >nul\r\n echo run-exited>> \"{command_log}\"\r\n exit /b 0" + } else { + ":run_loop\r\n if exist \"{stop_file}\" exit /b 0\r\n ping -n 2 127.0.0.1 >nul\r\n goto run_loop" + }; + std_fs::write( + &path, + format!( + r#"@echo off +echo %*>> "{command_log}" +if "%1"=="reload" ( + {reload_behavior} +) +if "%1"=="stop" ( + {stop_behavior} +) +if "%1"=="run" ( + echo fake runtime started + {run_behavior} +) +exit /b 0 +"#, + command_log = command_log.display(), + reload_behavior = + reload_behavior.replace("{stop_file}", &stop_file.display().to_string()), + stop_behavior = stop_behavior.replace("{stop_file}", &stop_file.display().to_string()), + run_behavior = run_behavior + .replace("{stop_file}", &stop_file.display().to_string()) + .replace("{command_log}", &command_log.display().to_string()), + ), + ) + .unwrap(); + path + } + + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + let path = dir.join("fake-caddy"); + let reload_behavior = if matches!(mode, FakeRuntimeMode::FailReload) { + "printf '%s\n' 'reload failed' >&2\n exit 7" + } else { + "exit 0" + }; + let stop_behavior = if matches!(mode, FakeRuntimeMode::SlowStop) { + "sleep 1\n : > '{stop_file}'\n exit 0" + } else if matches!(mode, FakeRuntimeMode::FailStop) { + ": > '{stop_file}'\n exit 7" + } else { + ": > '{stop_file}'\n exit 0" + }; + let run_behavior = if matches!(mode, FakeRuntimeMode::ShortRun) { + "sleep 1\n printf '%s\n' 'run-exited' >> '{command_log}'\n exit 0" + } else { + "while [ ! -f '{stop_file}' ]; do sleep 0.2; done\n exit 0" + }; + std_fs::write( + &path, + format!( + r#"#!/usr/bin/env sh +printf '%s\n' "$*" >> '{command_log}' +if [ "$1" = "reload" ]; then + {reload_behavior} +fi +if [ "$1" = "stop" ]; then + {stop_behavior} +fi +if [ "$1" = "run" ]; then + echo fake runtime started + {run_behavior} +fi +exit 0 + "#, + command_log = command_log.display(), + reload_behavior = reload_behavior, + stop_behavior = stop_behavior.replace("{stop_file}", &stop_file.display().to_string()), + run_behavior = run_behavior + .replace("{stop_file}", &stop_file.display().to_string()) + .replace("{command_log}", &command_log.display().to_string()), + ), + ) + .unwrap(); + let mut permissions = std_fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o755); + std_fs::set_permissions(&path, permissions).unwrap(); + path + } + } +} diff --git a/crates/cadder-daemon/src/runtime_lock.rs b/crates/cadder-daemon/src/runtime_lock.rs new file mode 100644 index 0000000..6fc0f47 --- /dev/null +++ b/crates/cadder-daemon/src/runtime_lock.rs @@ -0,0 +1,512 @@ +use anyhow::{Context, Result, anyhow}; +use cadder_protocol::{MIN_COMPATIBLE_PROTOCOL_VERSION, PROTOCOL_VERSION, ProtocolCapabilities}; +use chrono::{DateTime, Utc}; +use fs4::{FileExt, TryLockError}; +use serde::{Deserialize, Serialize}; +use std::{ + env, + fs::{self, File, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; + +use crate::paths::RuntimePaths; + +const LOCK_METADATA_VERSION: u16 = 1; + +#[derive(Debug)] +pub struct DaemonLock { + _file: File, + metadata_path: Option, + recovery: Option, +} + +impl DaemonLock { + pub fn acquire(path: PathBuf) -> Result { + Self::try_acquire(path.clone())?.ok_or_else(|| { + anyhow!( + "daemon lock {} is held by another cadderd owner", + path.display() + ) + }) + } + + pub fn try_acquire(path: PathBuf) -> Result> { + let file = match try_lock_file(&path)? { + Some(file) => file, + None => return Ok(None), + }; + + Ok(Some(Self { + _file: file, + metadata_path: None, + recovery: None, + })) + } + + pub(crate) fn try_acquire_for_runtime(paths: &RuntimePaths) -> Result> { + let path = paths.lock_path(); + let metadata_path = paths.lock_metadata_path(); + let file = match try_lock_file(&path)? { + Some(file) => file, + None => return Ok(None), + }; + + let previous = read_lock_metadata(&metadata_path)?; + let metadata = DaemonLockMetadata::new(paths); + let recovery = match previous { + LockMetadataRead::Empty => None, + LockMetadataRead::Valid(stale_owner) => Some(DaemonLockRecovery::ReplacedStaleOwner { + stale_owner, + recovered_by: metadata.clone(), + }), + LockMetadataRead::Invalid { error } => Some(DaemonLockRecovery::ReplacedUnreadableMetadata { + error, + recovered_by: metadata.clone(), + }), + }; + + write_lock_metadata(&metadata_path, &metadata) + .with_context(|| format!("write daemon lock metadata {}", metadata_path.display()))?; + + Ok(Some(Self { + _file: file, + metadata_path: Some(metadata_path), + recovery, + })) + } + + pub(crate) fn recovery(&self) -> Option<&DaemonLockRecovery> { + self.recovery.as_ref() + } + + pub(crate) fn active_owner_diagnostic(paths: &RuntimePaths) -> String { + let lock_path = paths.lock_path(); + let metadata_path = paths.lock_metadata_path(); + match read_lock_metadata(&metadata_path) { + Ok(LockMetadataRead::Empty) => { + active_owner_without_metadata(paths, &lock_path, &metadata_path) + } + Ok(LockMetadataRead::Valid(metadata)) => { + active_owner_from_metadata(paths, &lock_path, &metadata) + } + Ok(LockMetadataRead::Invalid { error }) => { + active_owner_with_unreadable_metadata(paths, &lock_path, &metadata_path, &error) + } + Err(error) => { + active_owner_with_unreadable_metadata(paths, &lock_path, &metadata_path, &error.to_string()) + } + } + } +} + +impl Drop for DaemonLock { + fn drop(&mut self) { + if let Some(path) = &self.metadata_path { + let _ = fs::remove_file(path); + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DaemonLockMetadata { + pub metadata_version: u16, + pub cadder_version: String, + pub protocol_version: u16, + pub minimum_compatible_protocol_version: u16, + pub capabilities: ProtocolCapabilities, + pub runtime_profile: String, + pub runtime_dir: String, + pub instance_key: String, + pub socket_name: String, + pub process_id: u32, + pub acquired_at_utc: DateTime, + pub executable_path: Option, +} + +impl DaemonLockMetadata { + fn new(paths: &RuntimePaths) -> Self { + Self { + metadata_version: LOCK_METADATA_VERSION, + cadder_version: env!("CARGO_PKG_VERSION").to_string(), + protocol_version: PROTOCOL_VERSION, + minimum_compatible_protocol_version: MIN_COMPATIBLE_PROTOCOL_VERSION, + capabilities: ProtocolCapabilities::current(), + runtime_profile: paths.runtime_profile().to_string(), + runtime_dir: paths.runtime_dir().display().to_string(), + instance_key: paths.instance_key().to_string(), + socket_name: paths.socket_name().to_string(), + process_id: std::process::id(), + acquired_at_utc: Utc::now(), + executable_path: env::current_exe() + .ok() + .map(|path| path.display().to_string()), + } + } + + fn owner_summary(&self) -> String { + let executable = self + .executable_path + .as_deref() + .unwrap_or("unknown executable"); + format!( + "owner pid {}, Cadder {}, protocol {} (compatible {}..={}), profile `{}`, runtime `{}`, socket `{}`, executable `{}`", + self.process_id, + self.cadder_version, + self.protocol_version, + self.minimum_compatible_protocol_version, + self.protocol_version, + self.runtime_profile, + self.runtime_dir, + self.socket_name, + executable + ) + } + + fn compatibility(&self) -> LockCompatibility { + if self.metadata_version > LOCK_METADATA_VERSION { + return LockCompatibility::Incompatible(format!( + "lock metadata version {} is newer than supported version {}; update this cadderd before attaching", + self.metadata_version, LOCK_METADATA_VERSION + )); + } + + if self.minimum_compatible_protocol_version > PROTOCOL_VERSION { + return LockCompatibility::Incompatible(format!( + "owner requires Cadder IPC protocol {} or newer while this cadderd supports up to {}; update this cadderd or stop the newer runtime", + self.minimum_compatible_protocol_version, PROTOCOL_VERSION + )); + } + + if self.protocol_version < MIN_COMPATIBLE_PROTOCOL_VERSION { + return LockCompatibility::Incompatible(format!( + "owner uses Cadder IPC protocol {} which is older than this cadderd supports; update the older runtime before reuse", + self.protocol_version + )); + } + + if self.protocol_version < PROTOCOL_VERSION { + return LockCompatibility::Compatible(Some(format!( + "owner uses older Cadder IPC protocol {}; update that older node when practical, but do not remove the lock while the process is active", + self.protocol_version + ))); + } + + if self.protocol_version > PROTOCOL_VERSION { + return LockCompatibility::Compatible(Some(format!( + "owner uses newer Cadder IPC protocol {}; update this cadderd if capability negotiation later fails", + self.protocol_version + ))); + } + + LockCompatibility::Compatible(None) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DaemonLockRecovery { + ReplacedStaleOwner { + stale_owner: DaemonLockMetadata, + recovered_by: DaemonLockMetadata, + }, + ReplacedUnreadableMetadata { + error: String, + recovered_by: DaemonLockMetadata, + }, +} + +impl DaemonLockRecovery { + pub(crate) fn log_message(&self) -> String { + match self { + Self::ReplacedStaleOwner { + stale_owner, + recovered_by, + } => format!( + "Recovered stale daemon lock metadata: previous {} no longer held the OS lock; new {}", + stale_owner.owner_summary(), + recovered_by.owner_summary() + ), + Self::ReplacedUnreadableMetadata { + error, + recovered_by, + } => format!( + "Recovered unreadable daemon lock metadata ({error}); new {}", + recovered_by.owner_summary() + ), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum LockCompatibility { + Compatible(Option), + Incompatible(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum LockMetadataRead { + Empty, + Valid(DaemonLockMetadata), + Invalid { error: String }, +} + +fn try_lock_file(path: &Path) -> Result> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create lock directory {}", parent.display()))?; + } + + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .with_context(|| format!("open daemon lock {}", path.display()))?; + match FileExt::try_lock(&file) { + Ok(()) => Ok(Some(file)), + Err(TryLockError::WouldBlock) => Ok(None), + Err(TryLockError::Error(error)) => { + Err(error).with_context(|| format!("acquire daemon lock {}", path.display())) + } + } +} + +fn read_lock_metadata(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(content) if content.trim().is_empty() => Ok(LockMetadataRead::Empty), + Ok(content) => match serde_json::from_str(content.trim()) { + Ok(metadata) => Ok(LockMetadataRead::Valid(metadata)), + Err(error) => Ok(LockMetadataRead::Invalid { + error: error.to_string(), + }), + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(LockMetadataRead::Empty), + Err(error) => { + Err(error).with_context(|| format!("read daemon lock metadata {}", path.display())) + } + } +} + +fn write_lock_metadata(path: &Path, metadata: &DaemonLockMetadata) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create daemon lock metadata directory {}", parent.display()))?; + } + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) + .with_context(|| format!("open daemon lock metadata {}", path.display()))?; + serde_json::to_writer_pretty(&mut file, metadata)?; + file.write_all(b"\n")?; + file.sync_data()?; + Ok(()) +} + +fn active_owner_from_metadata( + paths: &RuntimePaths, + lock_path: &Path, + metadata: &DaemonLockMetadata, +) -> String { + let recovery = recovery_options(paths); + match metadata.compatibility() { + LockCompatibility::Compatible(Some(note)) => format!( + "active Cadder runtime owns daemon lock {}; {}; {}; {}", + lock_path.display(), + metadata.owner_summary(), + note, + recovery + ), + LockCompatibility::Compatible(None) => format!( + "active Cadder runtime owns daemon lock {}; {}; {}", + lock_path.display(), + metadata.owner_summary(), + recovery + ), + LockCompatibility::Incompatible(note) => format!( + "active incompatible Cadder runtime owns daemon lock {}; {}; {}; {}", + lock_path.display(), + metadata.owner_summary(), + note, + recovery + ), + } +} + +fn active_owner_without_metadata( + paths: &RuntimePaths, + lock_path: &Path, + metadata_path: &Path, +) -> String { + format!( + "active Cadder runtime owns daemon lock {} but has not published owner metadata at {} yet; {}", + lock_path.display(), + metadata_path.display(), + recovery_options(paths) + ) +} + +fn active_owner_with_unreadable_metadata( + paths: &RuntimePaths, + lock_path: &Path, + metadata_path: &Path, + error: &str, +) -> String { + format!( + "active incompatible Cadder runtime owns daemon lock {} but owner metadata at {} could not be decoded ({error}); {}", + lock_path.display(), + metadata_path.display(), + recovery_options(paths) + ) +} + +fn recovery_options(paths: &RuntimePaths) -> String { + format!( + "recovery options: wait for that process to finish, inspect `cadder daemon status --runtime-dir \"{}\"`, stop the active owner if it is stale, then retry; do not delete runtime files while the owner process is active", + paths.runtime_dir().display() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_lock_rejects_second_owner() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("daemon.lock"); + let _first = DaemonLock::acquire(path.clone()).unwrap(); + + assert!(DaemonLock::acquire(path).is_err()); + } + + #[test] + fn runtime_lock_writes_owner_metadata() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + + let _lock = DaemonLock::try_acquire_for_runtime(&paths) + .unwrap() + .unwrap(); + let metadata = read_metadata_file(&paths); + + assert_eq!( + metadata.runtime_dir, + paths.runtime_dir().display().to_string() + ); + assert_eq!(metadata.instance_key, paths.instance_key()); + assert_eq!(metadata.socket_name, paths.socket_name()); + assert_eq!(metadata.protocol_version, PROTOCOL_VERSION); + } + + #[test] + fn runtime_lock_recovers_stale_owner_metadata() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + paths.ensure_dirs().unwrap(); + let stale = stale_metadata(&paths, 1); + fs::write( + paths.lock_metadata_path(), + serde_json::to_string(&stale).unwrap(), + ) + .unwrap(); + + let lock = DaemonLock::try_acquire_for_runtime(&paths) + .unwrap() + .unwrap(); + let recovery = lock.recovery().unwrap(); + + assert!( + recovery + .log_message() + .contains("Recovered stale daemon lock") + ); + assert!(recovery.log_message().contains("owner pid 1")); + let metadata = read_metadata_file(&paths); + assert_eq!(metadata.process_id, std::process::id()); + } + + #[test] + fn runtime_lock_recovers_unreadable_metadata() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + paths.ensure_dirs().unwrap(); + fs::write(paths.lock_metadata_path(), "{not-json").unwrap(); + + let lock = DaemonLock::try_acquire_for_runtime(&paths) + .unwrap() + .unwrap(); + + assert!( + lock + .recovery() + .unwrap() + .log_message() + .contains("Recovered unreadable daemon lock metadata") + ); + } + + #[test] + fn active_owner_diagnostic_reports_owner_identity_and_recovery_options() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + paths.ensure_dirs().unwrap(); + let metadata = stale_metadata(&paths, 77); + fs::write( + paths.lock_metadata_path(), + serde_json::to_string(&metadata).unwrap(), + ) + .unwrap(); + let _lock = DaemonLock::acquire(paths.lock_path()).unwrap(); + + let diagnostic = DaemonLock::active_owner_diagnostic(&paths); + + assert!(diagnostic.contains("active Cadder runtime owns daemon lock")); + assert!(diagnostic.contains("owner pid 77")); + assert!(diagnostic.contains("recovery options")); + } + + #[test] + fn active_owner_diagnostic_reports_incompatible_newer_metadata() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + paths.ensure_dirs().unwrap(); + let mut metadata = stale_metadata(&paths, 77); + metadata.metadata_version = LOCK_METADATA_VERSION + 1; + fs::write( + paths.lock_metadata_path(), + serde_json::to_string(&metadata).unwrap(), + ) + .unwrap(); + let _lock = DaemonLock::acquire(paths.lock_path()).unwrap(); + + let diagnostic = DaemonLock::active_owner_diagnostic(&paths); + + assert!(diagnostic.contains("active incompatible Cadder runtime")); + assert!(diagnostic.contains("update this cadderd")); + } + + fn stale_metadata(paths: &RuntimePaths, process_id: u32) -> DaemonLockMetadata { + DaemonLockMetadata { + metadata_version: LOCK_METADATA_VERSION, + cadder_version: "0.7.0".to_string(), + protocol_version: PROTOCOL_VERSION, + minimum_compatible_protocol_version: MIN_COMPATIBLE_PROTOCOL_VERSION, + capabilities: ProtocolCapabilities::current(), + runtime_profile: paths.runtime_profile().to_string(), + runtime_dir: paths.runtime_dir().display().to_string(), + instance_key: paths.instance_key().to_string(), + socket_name: paths.socket_name().to_string(), + process_id, + acquired_at_utc: Utc::now(), + executable_path: Some("old-cadderd".to_string()), + } + } + + fn read_metadata_file(paths: &RuntimePaths) -> DaemonLockMetadata { + serde_json::from_str(&fs::read_to_string(paths.lock_metadata_path()).unwrap()).unwrap() + } +} diff --git a/crates/cadder-daemon/src/state.rs b/crates/cadder-daemon/src/state.rs index 0004163..d3a6810 100644 --- a/crates/cadder-daemon/src/state.rs +++ b/crates/cadder-daemon/src/state.rs @@ -1,22 +1,25 @@ use crate::{ CaddyConfigCoordinator, - caddy::IisProxyBackendProtocol, + autostart::AutostartManager, + caddy::{CaddyApplyAction, IisProxyBackendProtocol}, iis::{ IisBindingRecord, IisMetadataStore, IisMutation, IisProvider, IisRestoreRecord, binding_to_view, unsupported_binding_issue, }, logs::{CaddyLogStore, LogQuery}, paths::RuntimePaths, + storage::RuntimeStore, }; use anyhow::Result; use cadder_protocol::{ ActivationState, BasicResponse, ConfigApplyStatus, ConfigState, EntrypointRegistration, - GuiStateSnapshot, HeartbeatEntrypointRequest, IisBinding, IisElevationApproval, + GuiStateSnapshot, HeartbeatEntrypointRequest, HistoryKind, IisBinding, IisElevationApproval, IisFollowUpAction, IisHandoffState, IisIssue, IisIssueKind, IisOperationStep, - IisOperationStepStatus, LogStreamIdentity, QueryIisBindingsResponse, QueryLogsResponse, - QueryStateResponse, RegisterEntrypointResponse, RuntimeState, SetDomainEnabledRequest, - SetEntrypointEnabledRequest, SetIisHandoffRequest, SetIisHandoffResponse, StateChangeKind, - StateChangedEvent, canonicalize_domain, + IisOperationStepStatus, LogAttributionKind, LogSeverity, LogStreamIdentity, + QueryAutostartResponse, QueryHistoryResponse, QueryIisBindingsResponse, QueryLogsResponse, + QueryStateResponse, RegisterEntrypointResponse, SetAutostartRequest, SetAutostartResponse, + SetDomainEnabledRequest, SetEntrypointEnabledRequest, SetIisHandoffRequest, + SetIisHandoffResponse, StateChangeKind, StateChangedEvent, canonicalize_domain, }; use chrono::Utc; use std::{ @@ -28,11 +31,29 @@ use std::{ }; use tokio::sync::{Mutex, Notify, broadcast}; +mod autostart_control; +mod config_apply; +mod history; +mod iis_handoff; +mod lifecycle; +mod log_queries; +mod registrations; +mod runtime_status; +mod shutdown; + +#[cfg(test)] +mod tests; + #[derive(Debug, Clone)] pub struct DaemonState { inner: Arc>, + coordinator: Arc>, + config_operation: Arc>, + publish_operation: Arc>, events: broadcast::Sender, logs: CaddyLogStore, + store: RuntimeStore, + autostart: AutostartManager, iis_provider: IisProvider, iis_store: IisMetadataStore, iis_operation: Arc>, @@ -42,7 +63,6 @@ pub struct DaemonState { #[derive(Debug)] struct DaemonInner { registrations: BTreeMap, - coordinator: CaddyConfigCoordinator, sequence: u64, } @@ -68,2189 +88,3 @@ impl ShutdownSignal { notified.await; } } - -impl DaemonState { - pub fn new(coordinator: CaddyConfigCoordinator) -> Self { - let (events, _) = broadcast::channel(256); - Self { - inner: Arc::new(Mutex::new(DaemonInner { - registrations: BTreeMap::new(), - coordinator, - sequence: 0, - })), - events, - logs: CaddyLogStore::default(), - iis_provider: IisProvider::system(), - iis_store: IisMetadataStore::memory(), - iis_operation: Arc::new(Mutex::new(())), - shutdown_signal: ShutdownSignal::default(), - } - } - - pub async fn with_runtime_paths( - mut coordinator: CaddyConfigCoordinator, - paths: RuntimePaths, - ) -> Result { - let iis_store = IisMetadataStore::load(paths.metadata_path()).await?; - let handoffs = iis_store.snapshot().await; - for (binding_id, restore) in &handoffs { - let backend_binding = legacy_backend_binding(restore); - coordinator.set_iis_proxy_route( - binding_id.clone(), - restore.domain_key.clone(), - backend_dial(&backend_binding), - IisProxyBackendProtocol::from_iis_protocol(&backend_binding.protocol), - ); - } - let mut state = Self::new(coordinator); - state.iis_store = iis_store; - if !handoffs.is_empty() { - let mut inner = state.inner.lock().await; - inner.coordinator.apply(&[], &state.logs).await; - } - Ok(state) - } - - #[cfg(test)] - fn with_iis_provider(coordinator: CaddyConfigCoordinator, iis_provider: IisProvider) -> Self { - let mut state = Self::new(coordinator); - state.iis_provider = iis_provider; - state - } - - pub fn subscribe(&self) -> broadcast::Receiver { - self.events.subscribe() - } - - pub(crate) fn shutdown_signal(&self) -> ShutdownSignal { - self.shutdown_signal.clone() - } - - pub fn logs(&self) -> CaddyLogStore { - self.logs.clone() - } - - pub async fn register( - &self, - request_id: String, - registration: EntrypointRegistration, - ) -> RegisterEntrypointResponse { - if let Err(message) = registration.validate_owner() { - return RegisterEntrypointResponse { - request_id, - accepted: false, - message, - registration_id: None, - }; - } - - let mut inner = self.inner.lock().await; - let mut prepared = inner.coordinator.prepare_registration(registration).await; - let now = Utc::now(); - prepared.created_at_utc = now; - prepared.last_heartbeat_utc = now; - let id = prepared.registration_id.clone(); - inner.registrations.insert(id.clone(), prepared); - let registrations = inner.registrations.values().cloned().collect::>(); - inner.coordinator.apply(®istrations, &self.logs).await; - self - .publish_locked( - &mut inner, - StateChangeKind::RegistrationsChanged, - Some(id.clone()), - ) - .await; - - RegisterEntrypointResponse { - request_id, - accepted: true, - message: "Entrypoint registered.".to_string(), - registration_id: Some(id), - } - } - - pub async fn unregister( - &self, - request_id: String, - registration_id: &str, - shim_session_nonce: &str, - ) -> BasicResponse { - let mut inner = self.inner.lock().await; - let removed = inner - .registrations - .get(registration_id) - .is_some_and(|registration| { - registration.entrypoint_instance.shim_session_nonce == shim_session_nonce - }); - if removed { - inner.registrations.remove(registration_id); - let registrations = inner.registrations.values().cloned().collect::>(); - inner.coordinator.apply(®istrations, &self.logs).await; - self - .publish_locked( - &mut inner, - StateChangeKind::RegistrationsChanged, - Some(registration_id.to_string()), - ) - .await; - } - - BasicResponse { - request_id, - accepted: removed, - message: if removed { - "Entrypoint unregistered." - } else { - "Entrypoint was not found for the requested owner." - } - .to_string(), - } - } - - pub async fn heartbeat(&self, request: HeartbeatEntrypointRequest) -> BasicResponse { - let mut inner = self.inner.lock().await; - let accepted = inner - .registrations - .get_mut(&request.registration_id) - .filter(|registration| { - registration.entrypoint_instance.shim_session_nonce == request.shim_session_nonce - }) - .map(|registration| { - registration.last_heartbeat_utc = Utc::now(); - }) - .is_some(); - if accepted { - self - .publish_locked( - &mut inner, - StateChangeKind::RegistrationsChanged, - Some(request.registration_id), - ) - .await; - } - - BasicResponse { - request_id: request.request_id, - accepted, - message: if accepted { - "Heartbeat accepted." - } else { - "Entrypoint was not found for the requested owner." - } - .to_string(), - } - } - - pub async fn set_entrypoint_enabled( - &self, - request: SetEntrypointEnabledRequest, - ) -> BasicResponse { - let mut inner = self.inner.lock().await; - let accepted = inner - .registrations - .get_mut(&request.registration_id) - .filter(|registration| { - request - .shim_session_nonce - .as_ref() - .is_none_or(|nonce| registration.entrypoint_instance.shim_session_nonce == *nonce) - }) - .map(|registration| { - registration.activation_state = ActivationState::from_enabled(request.enabled); - }) - .is_some(); - - if accepted { - let registrations = inner.registrations.values().cloned().collect::>(); - inner.coordinator.apply(®istrations, &self.logs).await; - self - .publish_locked( - &mut inner, - StateChangeKind::RegistrationsChanged, - Some(request.registration_id), - ) - .await; - } - - BasicResponse { - request_id: request.request_id, - accepted, - message: if accepted { - "Entrypoint activation updated." - } else { - "Entrypoint was not found." - } - .to_string(), - } - } - - pub async fn set_domain_enabled(&self, request: SetDomainEnabledRequest) -> BasicResponse { - let mut inner = self.inner.lock().await; - let accepted = inner - .registrations - .get_mut(&request.registration_id) - .and_then(|registration| { - registration.registered_domains.iter_mut().find(|domain| { - domain - .name - .canonical - .eq_ignore_ascii_case(&request.domain_key) - }) - }) - .map(|domain| { - domain.activation_state = ActivationState::from_enabled(request.enabled); - }) - .is_some(); - - if accepted { - let registrations = inner.registrations.values().cloned().collect::>(); - inner.coordinator.apply(®istrations, &self.logs).await; - self - .publish_locked( - &mut inner, - StateChangeKind::RegistrationsChanged, - Some(request.registration_id), - ) - .await; - } - - BasicResponse { - request_id: request.request_id, - accepted, - message: if accepted { - "Domain activation updated." - } else { - "Domain was not found." - } - .to_string(), - } - } - - pub async fn query_state(&self, request_id: String) -> QueryStateResponse { - QueryStateResponse { - request_id, - accepted: true, - message: "State snapshot returned.".to_string(), - snapshot: Some(self.snapshot().await), - } - } - - pub async fn query_iis_bindings(&self, request_id: String) -> QueryIisBindingsResponse { - let records = match self.iis_provider.discover().await { - Ok(records) => records, - Err(issue) => { - return QueryIisBindingsResponse { - request_id, - accepted: false, - message: issue.message.clone(), - bindings: Vec::new(), - issue: Some(issue), - }; - } - }; - let handoffs = self.iis_store.snapshot().await; - let inner = self.inner.lock().await; - QueryIisBindingsResponse { - request_id, - accepted: true, - message: "IIS bindings returned.".to_string(), - bindings: self.iis_binding_views_locked(&inner, &records, &handoffs), - issue: None, - } - } - - pub async fn set_iis_handoff(&self, request: SetIisHandoffRequest) -> SetIisHandoffResponse { - let request_id = request.request_id.clone(); - let Ok(_operation) = self.iis_operation.try_lock() else { - let issue = IisIssue::new( - IisIssueKind::Busy, - "Another IIS handoff operation is already running.", - ); - return iis_response(request_id, false, issue.message.clone(), None, issue); - }; - - if request.enabled { - self.enable_iis_handoff(request).await - } else { - self.disable_iis_handoff(request).await - } - } - - pub async fn query_logs(&self, request: cadder_protocol::QueryLogsRequest) -> QueryLogsResponse { - let active = self.stream_is_active(&request.stream).await; - let result = self.logs.query( - LogQuery { - stream: request.stream.clone(), - limit: request.limit.unwrap_or(100).clamp(1, 500), - after_sequence: request - .cursor - .as_deref() - .and_then(|cursor| cursor.strip_prefix("seq:")) - .and_then(|sequence| sequence.parse::().ok()), - minimum_severity: request.minimum_severity, - }, - active, - ); - - QueryLogsResponse { - request_id: request.request_id, - accepted: true, - message: "Caddy logs returned.".to_string(), - stream: request.stream, - stream_status: result.status, - entries: result.entries, - next_cursor: result.next_cursor, - has_gap: result.has_gap, - has_more_before: result.has_more_before, - truncated_by_retention: result.truncated_by_retention, - } - } - - pub async fn shutdown(&self) -> BasicResponse { - let mut inner = self.inner.lock().await; - let result = inner.coordinator.shutdown().await; - if result.is_ok() { - self.shutdown_signal.request(); - } - BasicResponse { - request_id: "shutdown".to_string(), - accepted: result.is_ok(), - message: result - .map(|_| "Daemon shutdown requested.".to_string()) - .unwrap_or_else(|error| error.to_string()), - } - } - - pub async fn snapshot(&self) -> GuiStateSnapshot { - let inner = self.inner.lock().await; - self.snapshot_locked(&inner).await - } - - async fn publish_locked( - &self, - inner: &mut DaemonInner, - kind: StateChangeKind, - registration_id: Option, - ) { - inner.sequence += 1; - let event = StateChangedEvent { - request_id: "state-change".to_string(), - sequence_number: inner.sequence, - change_kind: kind, - snapshot: self.snapshot_locked(inner).await, - registration_id, - }; - let _ = self.events.send(event); - } - - async fn snapshot_locked(&self, inner: &DaemonInner) -> GuiStateSnapshot { - GuiStateSnapshot { - captured_at_utc: Utc::now(), - registrations: inner.registrations.values().cloned().collect(), - runtime: self.runtime_state_locked(inner).await, - config: self.config_state_locked(inner), - } - } - - async fn runtime_state_locked(&self, inner: &DaemonInner) -> RuntimeState { - inner.coordinator_runtime_state().await - } - - fn config_state_locked(&self, inner: &DaemonInner) -> ConfigState { - inner.coordinator.current_state() - } - - async fn stream_is_active(&self, stream: &LogStreamIdentity) -> bool { - let inner = self.inner.lock().await; - if stream.stream_id == "runtime" || stream.stream_id == "runtime-control" { - return true; - } - inner.registrations.values().any(|registration| { - (registration.log_stream == *stream && registration.activation_state.is_enabled()) - || registration - .registered_domains - .iter() - .any(|domain| domain.log_stream == *stream && domain.activation_state.is_enabled()) - }) - } - - async fn enable_iis_handoff(&self, request: SetIisHandoffRequest) -> SetIisHandoffResponse { - let records = match self.iis_provider.discover().await { - Ok(records) => records, - Err(issue) => { - return iis_response( - request.request_id, - false, - issue.message.clone(), - None, - issue, - ); - } - }; - let Some(binding) = records - .iter() - .find(|binding| binding.binding_id() == request.binding_id) - .cloned() - else { - return iis_response( - request.request_id, - false, - "IIS binding was not found.", - None, - IisIssue::new(IisIssueKind::MissingBinding, "IIS binding was not found."), - ); - }; - let mut steps = enable_iis_steps(); - mark_step_succeeded(&mut steps, "iis-discover-bindings"); - - if let Some(issue) = unsupported_binding_issue(&binding) { - mark_step_issue(&mut steps, "iis-classify-binding", &issue); - let view = binding_to_view( - &binding, - IisHandoffState::Unsupported, - Some(issue.clone()), - None, - ); - return iis_response_with_steps( - request.request_id, - false, - issue.message.clone(), - Some(view), - issue.clone(), - steps, - Vec::new(), - ); - } - - let domain_key = match route_host_for_binding(&binding, request.route_host.as_deref()) { - Ok(domain_key) => domain_key, - Err(issue) => { - mark_step_issue(&mut steps, "iis-classify-binding", &issue); - let view = binding_to_view( - &binding, - IisHandoffState::MissingRoute, - Some(issue.clone()), - None, - ); - return iis_response_with_steps( - request.request_id, - false, - issue.message.clone(), - Some(view), - issue.clone(), - steps, - Vec::new(), - ); - } - }; - if iis_route_host_conflicts(&records, &binding.binding_id(), &domain_key) { - let issue = IisIssue::new( - IisIssueKind::Conflict, - format!("IIS host `{domain_key}` appears on multiple bindings."), - ); - mark_step_issue(&mut steps, "iis-classify-binding", &issue); - let view = binding_to_view( - &binding, - IisHandoffState::Conflict, - Some(issue.clone()), - None, - ); - return iis_response_with_steps( - request.request_id, - false, - issue.message.clone(), - Some(view), - issue.clone(), - steps, - Vec::new(), - ); - } - - let registration_conflict = { - let inner = self.inner.lock().await; - active_registration_conflict(&inner.registrations, &domain_key) - }; - if let Some(issue) = registration_conflict { - mark_step_issue(&mut steps, "iis-classify-binding", &issue); - let view = binding_to_view( - &binding, - IisHandoffState::Conflict, - Some(issue.clone()), - None, - ); - return iis_response_with_steps( - request.request_id, - false, - issue.message.clone(), - Some(view), - issue.clone(), - steps, - Vec::new(), - ); - } - mark_step_succeeded(&mut steps, "iis-classify-binding"); - - let backend_binding = - match binding.backend_binding(backend_port_for_binding(&binding), &domain_key) { - Ok(backend_binding) => backend_binding, - Err(issue) => { - mark_step_issue(&mut steps, "iis-classify-binding", &issue); - let view = binding_to_view( - &binding, - IisHandoffState::Unsupported, - Some(issue.clone()), - None, - ); - return iis_response_with_steps( - request.request_id, - false, - issue.message.clone(), - Some(view), - issue.clone(), - steps, - Vec::new(), - ); - } - }; - let restore = IisRestoreRecord { - binding: binding.clone(), - domain_key: domain_key.clone(), - registration_id: None, - backend_binding: Some(backend_binding.clone()), - }; - if let Err(error) = self - .iis_store - .insert(binding.binding_id(), restore.clone()) - .await - { - let issue = IisIssue::new( - IisIssueKind::ProviderError, - format!("Could not persist IIS restore metadata: {error}"), - ); - mark_step_issue(&mut steps, "iis-write-restore-metadata", &issue); - return iis_response_with_steps( - request.request_id, - false, - issue.message.clone(), - None, - issue.clone(), - steps, - Vec::new(), - ); - } - mark_step_succeeded(&mut steps, "iis-write-restore-metadata"); - - let privileged_step_ids = ["iis-create-loopback-binding", "iis-remove-public-binding"]; - let privileged_reason = format!( - "Cadder needs administrator approval to hand IIS host `{domain_key}` to Caddy by creating the loopback backend binding and removing the original public IIS binding." - ); - if let Err(issue) = self - .iis_provider - .execute_privileged_batch( - &privileged_reason, - &[ - IisMutation::add(backend_binding.clone()), - IisMutation::remove(binding.clone()), - ], - ) - .await - { - let _ = self.iis_store.remove(&binding.binding_id()).await; - mark_privileged_batch_issue(&mut steps, &privileged_step_ids, &issue); - mark_step_skipped(&mut steps, "caddy-apply-proxy-route"); - let view = binding_to_view( - &binding, - IisHandoffState::Available, - Some(issue.clone()), - None, - ); - let mut follow_up_actions = follow_up_actions_for_issue(&issue); - if matches!(issue.kind, IisIssueKind::ProviderError) { - follow_up_actions.push(IisFollowUpAction::RemoveLoopbackBinding); - } - return iis_response_with_steps( - request.request_id, - false, - issue.message.clone(), - Some(view), - issue.clone(), - steps, - follow_up_actions, - ); - } - mark_privileged_batch_approved(&mut steps, &privileged_step_ids); - - let apply_state = { - let mut inner = self.inner.lock().await; - inner.coordinator.set_iis_proxy_route( - binding.binding_id(), - domain_key.clone(), - backend_dial(&backend_binding), - IisProxyBackendProtocol::from_iis_protocol(&backend_binding.protocol), - ); - let registrations = inner.registrations.values().cloned().collect::>(); - let apply_state = inner.coordinator.apply(®istrations, &self.logs).await; - self - .publish_locked(&mut inner, StateChangeKind::RegistrationsChanged, None) - .await; - apply_state - }; - - if apply_state.status == ConfigApplyStatus::Failed { - let apply_issue = IisIssue::new( - IisIssueKind::ProviderError, - "Cadder could not apply the IIS proxy route.", - ); - mark_step_issue(&mut steps, "caddy-apply-proxy-route", &apply_issue); - { - let mut inner = self.inner.lock().await; - inner.coordinator.remove_iis_proxy_route(&domain_key); - let registrations = inner.registrations.values().cloned().collect::>(); - inner.coordinator.apply(®istrations, &self.logs).await; - self - .publish_locked(&mut inner, StateChangeKind::RegistrationsChanged, None) - .await; - } - steps.push(IisOperationStep::administrator( - "iis-rollback-public-binding", - "Restore original IIS binding after Caddy apply failure.", - )); - steps.push(IisOperationStep::administrator( - "iis-rollback-loopback-binding", - "Remove loopback IIS binding after Caddy apply failure.", - )); - let rollback_step_ids = [ - "iis-rollback-public-binding", - "iis-rollback-loopback-binding", - ]; - let rollback = self - .iis_provider - .execute_privileged_batch( - "Cadder needs administrator approval to roll IIS back after Caddy route apply failed.", - &[ - IisMutation::restore(binding.clone()), - IisMutation::remove(backend_binding.clone()), - ], - ) - .await; - let (issue, view) = match rollback { - Ok(()) => { - let _ = self.iis_store.remove(&binding.binding_id()).await; - let issue = IisIssue::new( - IisIssueKind::RollbackSucceeded, - "Cadder could not apply the IIS proxy route; original IIS binding was restored.", - ); - mark_privileged_batch_approved(&mut steps, &rollback_step_ids); - let view = binding_to_view( - &binding, - IisHandoffState::Available, - Some(issue.clone()), - None, - ); - (issue, view) - } - Err(error) => { - let issue = IisIssue::new( - IisIssueKind::RollbackFailed, - format!( - "Cadder could not apply the IIS proxy route and IIS rollback failed: {}", - error.message - ), - ); - mark_privileged_batch_issue(&mut steps, &rollback_step_ids, &issue); - let view = handoff_binding_to_view(&binding, Some(issue.clone()), &restore); - (issue, view) - } - }; - let follow_up_actions = follow_up_actions_for_issue(&issue); - return iis_response_with_steps( - request.request_id, - false, - issue.message.clone(), - Some(view), - issue.clone(), - steps, - follow_up_actions, - ); - } - mark_step_succeeded(&mut steps, "caddy-apply-proxy-route"); - - let view = binding_to_view( - &binding, - IisHandoffState::HandedOff, - None, - Some(binding.restore_summary()), - ); - let mut view = view; - view.domain_key = Some(domain_key.clone()); - SetIisHandoffResponse { - request_id: request.request_id, - accepted: true, - message: format!( - "IIS binding `{domain_key}` is proxied through Cadder to 127.0.0.1:{}.", - backend_binding.port - ), - binding: Some(view), - issue: None, - steps, - follow_up_actions: Vec::new(), - } - } - - async fn disable_iis_handoff(&self, request: SetIisHandoffRequest) -> SetIisHandoffResponse { - let mut steps = disable_iis_steps(); - let handoffs = self.iis_store.snapshot().await; - let Some(restore) = handoffs.get(&request.binding_id).cloned() else { - let issue = IisIssue::new( - IisIssueKind::MissingBinding, - "IIS handoff restore metadata was not found.", - ); - mark_step_issue(&mut steps, "iis-read-restore-metadata", &issue); - return iis_response_with_steps( - request.request_id, - false, - "IIS handoff restore metadata was not found.", - None, - issue.clone(), - steps, - Vec::new(), - ); - }; - mark_step_succeeded(&mut steps, "iis-read-restore-metadata"); - let backend_binding = legacy_backend_binding(&restore); - - { - let inner = self.inner.lock().await; - if caddy_front_door_needed(&inner.registrations, &restore.domain_key) - || inner - .coordinator - .has_iis_proxy_routes_except(&restore.domain_key) - { - let issue = IisIssue::new( - IisIssueKind::Conflict, - format!( - "Cannot restore IIS binding `{}` while other Cadder routes still need the front-door port.", - restore.domain_key - ), - ); - let view = binding_to_view( - &restore.binding, - IisHandoffState::HandedOff, - Some(issue.clone()), - Some(restore.binding.restore_summary()), - ); - mark_step_issue(&mut steps, "caddy-remove-proxy-route", &issue); - return iis_response_with_steps( - request.request_id, - false, - issue.message.clone(), - Some(view), - issue.clone(), - steps, - Vec::new(), - ); - } - } - - { - let mut inner = self.inner.lock().await; - inner - .coordinator - .remove_iis_proxy_route(&restore.domain_key); - let registrations = inner.registrations.values().cloned().collect::>(); - inner.coordinator.apply(®istrations, &self.logs).await; - self - .publish_locked(&mut inner, StateChangeKind::RegistrationsChanged, None) - .await; - } - mark_step_succeeded(&mut steps, "caddy-remove-proxy-route"); - - let privileged_step_ids = ["iis-restore-public-binding", "iis-remove-loopback-binding"]; - if let Err(issue) = self - .iis_provider - .execute_privileged_batch( - "Cadder needs administrator approval to restore the original IIS binding and remove the loopback backend binding.", - &[ - IisMutation::restore(restore.binding.clone()), - IisMutation::remove(backend_binding.clone()), - ], - ) - .await - { - let mut inner = self.inner.lock().await; - inner.coordinator.set_iis_proxy_route( - request.binding_id.clone(), - restore.domain_key.clone(), - backend_dial(&backend_binding), - IisProxyBackendProtocol::from_iis_protocol(&backend_binding.protocol), - ); - let registrations = inner.registrations.values().cloned().collect::>(); - inner.coordinator.apply(®istrations, &self.logs).await; - self - .publish_locked(&mut inner, StateChangeKind::RegistrationsChanged, None) - .await; - let issue = IisIssue::new( - IisIssueKind::RestoreFailed, - format!( - "Cadder restored the proxy route because the privileged IIS restore batch failed: {}", - issue.message - ), - ); - mark_privileged_batch_issue(&mut steps, &privileged_step_ids, &issue); - mark_step_skipped(&mut steps, "iis-clear-restore-metadata"); - let view = handoff_binding_to_view(&restore.binding, Some(issue.clone()), &restore); - return iis_response_with_steps( - request.request_id, - false, - issue.message.clone(), - Some(view), - issue.clone(), - steps, - follow_up_actions_for_issue(&issue), - ); - } - mark_privileged_batch_approved(&mut steps, &privileged_step_ids); - if let Err(error) = self.iis_store.remove(&request.binding_id).await { - let issue = IisIssue::new( - IisIssueKind::ProviderError, - format!("IIS binding was restored but restore metadata could not be cleared: {error}"), - ); - mark_step_issue(&mut steps, "iis-clear-restore-metadata", &issue); - return iis_response_with_steps( - request.request_id, - false, - issue.message.clone(), - None, - issue.clone(), - steps, - vec![IisFollowUpAction::ClearRestoreMetadata], - ); - } - mark_step_succeeded(&mut steps, "iis-clear-restore-metadata"); - - let view = binding_to_view(&restore.binding, IisHandoffState::Available, None, None); - SetIisHandoffResponse { - request_id: request.request_id, - accepted: true, - message: format!("IIS binding `{}` restored to IIS.", restore.domain_key), - binding: Some(view), - issue: None, - steps, - follow_up_actions: Vec::new(), - } - } - - fn iis_binding_views_locked( - &self, - inner: &DaemonInner, - records: &[IisBindingRecord], - handoffs: &BTreeMap, - ) -> Vec { - let backend_binding_ids = handoffs - .values() - .filter_map(|restore| restore.backend_binding.as_ref()) - .map(IisBindingRecord::binding_id) - .collect::>(); - let public_records = records - .iter() - .filter(|binding| !backend_binding_ids.contains(&binding.binding_id())) - .cloned() - .collect::>(); - let duplicate_hosts = duplicate_iis_hosts(&public_records); - let mut seen = BTreeSet::new(); - let mut bindings = public_records - .iter() - .map(|binding| { - let binding_id = binding.binding_id(); - seen.insert(binding_id.clone()); - if let Some(restore) = handoffs.get(&binding_id) { - return handoff_binding_to_view(binding, None, restore); - } - if let Some(issue) = unsupported_binding_issue(binding) { - return binding_to_view(binding, IisHandoffState::Unsupported, Some(issue), None); - } - let domain_key = match route_host_for_binding(binding, None) { - Ok(domain_key) => domain_key, - Err(issue) => { - return binding_to_view(binding, IisHandoffState::MissingRoute, Some(issue), None); - } - }; - if duplicate_hosts.contains(&domain_key) { - return binding_to_view( - binding, - IisHandoffState::Conflict, - Some(IisIssue::new( - IisIssueKind::Conflict, - format!("IIS host `{domain_key}` appears on multiple bindings."), - )), - None, - ); - } - if let Err(issue) = binding.backend_binding(backend_port_for_binding(binding), &domain_key) - { - return binding_to_view(binding, IisHandoffState::Unsupported, Some(issue), None); - } - if let Some(issue) = active_registration_conflict(&inner.registrations, &domain_key) { - binding_to_view(binding, IisHandoffState::Conflict, Some(issue), None) - } else { - binding_to_view(binding, IisHandoffState::Available, None, None) - } - }) - .collect::>(); - - bindings.extend( - handoffs - .iter() - .filter(|(binding_id, _)| !seen.contains(*binding_id)) - .map(|(_, restore)| handoff_binding_to_view(&restore.binding, None, restore)), - ); - bindings - } -} - -impl DaemonInner { - async fn coordinator_runtime_state(&self) -> RuntimeState { - self.coordinator.runtime_state().await - } -} - -fn handoff_binding_to_view( - binding: &IisBindingRecord, - issue: Option, - restore: &IisRestoreRecord, -) -> IisBinding { - let mut view = binding_to_view( - binding, - IisHandoffState::HandedOff, - issue, - Some(restore.binding.restore_summary()), - ); - view.domain_key = Some(restore.domain_key.clone()); - view -} - -fn duplicate_iis_hosts(records: &[IisBindingRecord]) -> BTreeSet { - let mut counts = BTreeMap::::new(); - for binding in records { - if unsupported_binding_issue(binding).is_none() - && let Ok(domain_key) = route_host_for_binding(binding, None) - { - *counts.entry(domain_key).or_default() += 1; - } - } - counts - .into_iter() - .filter_map(|(host, count)| (count > 1).then_some(host)) - .collect() -} - -fn iis_route_host_conflicts( - records: &[IisBindingRecord], - selected_binding_id: &str, - domain_key: &str, -) -> bool { - duplicate_iis_hosts(records).contains(domain_key) - || records.iter().any(|binding| { - binding.binding_id() != selected_binding_id - && unsupported_binding_issue(binding).is_none() - && route_host_for_binding(binding, None) - .is_ok_and(|host| host.eq_ignore_ascii_case(domain_key)) - }) -} - -fn route_host_for_binding( - binding: &IisBindingRecord, - route_host: Option<&str>, -) -> std::result::Result { - let binding_host = binding.host_header.trim(); - let selected = if binding_host.is_empty() || binding_host == "*" { - route_host.unwrap_or_default() - } else { - binding_host - }; - let candidate = extract_host_candidate(selected); - let domain_key = canonicalize_domain(candidate); - if domain_key.is_empty() || domain_key == "*" { - return Err(IisIssue::new( - IisIssueKind::MissingRoute, - "Wildcard IIS bindings need a route host. In the TUI, enter the host with `/` before pressing Space.", - )); - } - if domain_key.contains('/') || domain_key.contains('\\') || domain_key.contains(' ') { - return Err(IisIssue::new( - IisIssueKind::UnsupportedBindingShape, - format!("IIS route host `{selected}` is not a valid DNS host."), - )); - } - Ok(domain_key) -} - -fn extract_host_candidate(raw: &str) -> &str { - let without_scheme = raw - .split_once("://") - .map(|(_, rest)| rest) - .unwrap_or(raw) - .trim(); - let without_path = without_scheme - .split(['/', '?', '#']) - .next() - .unwrap_or(without_scheme) - .trim(); - if let Some((host, port)) = without_path.rsplit_once(':') - && !host.is_empty() - && port.chars().all(|ch| ch.is_ascii_digit()) - { - return host; - } - without_path -} - -fn backend_port_for_binding(binding: &IisBindingRecord) -> u16 { - let hash = binding.binding_id().bytes().fold(0_u32, |hash, byte| { - hash.wrapping_mul(33).wrapping_add(byte as u32) - }); - 41000 + (hash % 8000) as u16 -} - -fn legacy_backend_binding(restore: &IisRestoreRecord) -> IisBindingRecord { - restore.backend_binding.clone().unwrap_or_else(|| { - restore.binding.backend_http_binding( - backend_port_for_binding(&restore.binding), - &restore.domain_key, - ) - }) -} - -fn backend_dial(binding: &IisBindingRecord) -> String { - format!("127.0.0.1:{}", binding.port) -} - -fn active_registration_conflict( - registrations: &BTreeMap, - domain_key: &str, -) -> Option { - let matches = registrations - .values() - .filter(|registration| registration.activation_state.is_enabled()) - .filter(|registration| { - registration - .registered_domains - .iter() - .filter(|domain| domain.activation_state.is_enabled()) - .any(|domain| domain.name.canonical.eq_ignore_ascii_case(domain_key)) - }) - .map(|registration| registration.registration_id.clone()) - .collect::>(); - (!matches.is_empty()).then(|| { - IisIssue::new( - IisIssueKind::Conflict, - format!("Cadder already has an active route for IIS host `{domain_key}`."), - ) - }) -} - -fn caddy_front_door_needed( - registrations: &BTreeMap, - restoring_domain: &str, -) -> bool { - registrations - .values() - .filter(|registration| registration.activation_state.is_enabled()) - .flat_map(|registration| ®istration.registered_domains) - .any(|domain| { - domain.activation_state.is_enabled() - && !domain.name.canonical.eq_ignore_ascii_case(restoring_domain) - }) -} - -fn enable_iis_steps() -> Vec { - vec![ - IisOperationStep::user("iis-discover-bindings", "Discover IIS bindings."), - IisOperationStep::user("iis-classify-binding", "Classify selected IIS binding."), - IisOperationStep::user( - "iis-write-restore-metadata", - "Write IIS restore metadata before mutation.", - ), - IisOperationStep::administrator( - "iis-create-loopback-binding", - "Create loopback IIS binding for Cadder proxying.", - ), - IisOperationStep::administrator( - "iis-remove-public-binding", - "Remove original public IIS binding.", - ), - IisOperationStep::user("caddy-apply-proxy-route", "Apply Caddy IIS proxy route."), - ] -} - -fn disable_iis_steps() -> Vec { - vec![ - IisOperationStep::user("iis-read-restore-metadata", "Read IIS restore metadata."), - IisOperationStep::user("caddy-remove-proxy-route", "Remove Caddy IIS proxy route."), - IisOperationStep::administrator( - "iis-restore-public-binding", - "Restore original public IIS binding.", - ), - IisOperationStep::administrator( - "iis-remove-loopback-binding", - "Remove loopback IIS backend binding.", - ), - IisOperationStep::user("iis-clear-restore-metadata", "Clear IIS restore metadata."), - ] -} - -fn mark_step_succeeded(steps: &mut [IisOperationStep], step_id: &str) { - if let Some(step) = steps.iter_mut().find(|step| step.step_id == step_id) { - step.status = IisOperationStepStatus::Succeeded; - step.approval = IisElevationApproval::NotRequired; - } -} - -fn mark_step_issue(steps: &mut [IisOperationStep], step_id: &str, issue: &IisIssue) { - if let Some(step) = steps.iter_mut().find(|step| step.step_id == step_id) { - step.status = match issue.kind { - IisIssueKind::ElevationDenied => IisOperationStepStatus::Denied, - IisIssueKind::ElevationUnsupported | IisIssueKind::IisUnavailable => { - IisOperationStepStatus::Unsupported - } - _ => IisOperationStepStatus::Failed, - }; - step.issue = Some(issue.clone()); - } -} - -fn mark_privileged_batch_approved(steps: &mut [IisOperationStep], step_ids: &[&str]) { - for step_id in step_ids { - if let Some(step) = steps.iter_mut().find(|step| step.step_id == *step_id) { - step.status = IisOperationStepStatus::Succeeded; - step.approval = IisElevationApproval::Approved; - } - } -} - -fn mark_privileged_batch_issue( - steps: &mut [IisOperationStep], - step_ids: &[&str], - issue: &IisIssue, -) { - for step_id in step_ids { - if let Some(step) = steps.iter_mut().find(|step| step.step_id == *step_id) { - step.status = match issue.kind { - IisIssueKind::ElevationDenied => IisOperationStepStatus::Denied, - IisIssueKind::ElevationUnsupported | IisIssueKind::IisUnavailable => { - IisOperationStepStatus::Unsupported - } - _ => IisOperationStepStatus::Failed, - }; - step.approval = match issue.kind { - IisIssueKind::ElevationDenied => IisElevationApproval::Denied, - IisIssueKind::ElevationUnsupported | IisIssueKind::IisUnavailable => { - IisElevationApproval::Unsupported - } - _ => IisElevationApproval::Approved, - }; - step.issue = Some(issue.clone()); - } - } -} - -fn mark_step_skipped(steps: &mut [IisOperationStep], step_id: &str) { - if let Some(step) = steps.iter_mut().find(|step| step.step_id == step_id) { - step.status = IisOperationStepStatus::Skipped; - } -} - -fn follow_up_actions_for_issue(issue: &IisIssue) -> Vec { - match issue.kind { - IisIssueKind::ElevationDenied - | IisIssueKind::ElevationRequired - | IisIssueKind::InsufficientPrivileges => vec![IisFollowUpAction::RetryElevation], - IisIssueKind::ElevationUnsupported => Vec::new(), - IisIssueKind::RollbackFailed => vec![ - IisFollowUpAction::RollbackHandoff, - IisFollowUpAction::RetryElevation, - ], - IisIssueKind::RestoreFailed => vec![ - IisFollowUpAction::RetryRestore, - IisFollowUpAction::RetryElevation, - ], - IisIssueKind::ProviderError => vec![IisFollowUpAction::RetryElevation], - _ => Vec::new(), - } -} - -fn iis_response( - request_id: String, - accepted: bool, - message: impl Into, - binding: Option, - issue: IisIssue, -) -> SetIisHandoffResponse { - iis_response_with_steps( - request_id, - accepted, - message, - binding, - issue, - Vec::new(), - Vec::new(), - ) -} - -fn iis_response_with_steps( - request_id: String, - accepted: bool, - message: impl Into, - binding: Option, - issue: IisIssue, - steps: Vec, - follow_up_actions: Vec, -) -> SetIisHandoffResponse { - SetIisHandoffResponse { - request_id, - accepted, - message: message.into(), - binding, - issue: Some(issue), - steps, - follow_up_actions, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - CaddyConfigAdapter, CaddyConfigCoordinator, ProcessRuntime, RealCaddyResolver, RuntimePaths, - }; - use cadder_protocol::{ - EntrypointInstanceIdentity, LogAttributionKind, LogSeverity, LogStreamIdentity, - LogStreamStatus, OwnerProcessIdentity, QueryLogsRequest, RegisteredDomain, SourcePath, - }; - use chrono::Utc; - use std::{fs, path::Path}; - - fn state() -> DaemonState { - let paths = RuntimePaths::resolve(Some(tempfile::tempdir().unwrap().keep())).unwrap(); - let resolver = RealCaddyResolver::new(Some("definitely-missing-caddy".to_string())); - let adapter = CaddyConfigAdapter::new(resolver.clone()); - let runtime = ProcessRuntime::new(resolver, paths); - DaemonState::new(CaddyConfigCoordinator::new(adapter, runtime)) - } - - fn state_with_iis(provider: IisProvider) -> DaemonState { - let paths = RuntimePaths::resolve(Some(tempfile::tempdir().unwrap().keep())).unwrap(); - let resolver = RealCaddyResolver::new(Some("definitely-missing-caddy".to_string())); - let adapter = CaddyConfigAdapter::new(resolver.clone()); - let runtime = ProcessRuntime::new(resolver, paths); - DaemonState::with_iis_provider(CaddyConfigCoordinator::new(adapter, runtime), provider) - } - - fn state_with_fake_caddy(provider: IisProvider, caddy: &Path) -> DaemonState { - let (state, _) = state_with_fake_caddy_paths(provider, caddy); - state - } - - fn state_with_fake_caddy_paths( - provider: IisProvider, - caddy: &Path, - ) -> (DaemonState, RuntimePaths) { - let paths = RuntimePaths::resolve(Some(tempfile::tempdir().unwrap().keep())).unwrap(); - let resolver = RealCaddyResolver::new(Some(caddy.display().to_string())); - let adapter = CaddyConfigAdapter::new(resolver.clone()); - let runtime = ProcessRuntime::new(resolver, paths.clone()); - ( - DaemonState::with_iis_provider(CaddyConfigCoordinator::new(adapter, runtime), provider), - paths, - ) - } - - fn iis_binding(site: &str, protocol: &str, binding: &str) -> IisBindingRecord { - IisBindingRecord::from_binding_information(site, protocol, binding).unwrap() - } - - fn tls_certificate() -> crate::iis::IisTlsCertificate { - crate::iis::IisTlsCertificate { - thumbprint: "aabbcc".to_string(), - store_name: "My".to_string(), - ssl_flags: Some(1), - } - } - - fn https_iis_binding(site: &str, binding: &str) -> IisBindingRecord { - let mut binding = iis_binding(site, "https", binding); - binding.tls_certificate = Some(tls_certificate()); - binding - } - - fn write_fake_caddy(path: &Path) { - #[cfg(windows)] - fs::write( - path, - r#"@echo off -if "%1"=="adapt" ( - echo {"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["app.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}} - exit /b 0 -) -if "%1"=="reload" exit /b 0 -if "%1"=="stop" exit /b 0 -if "%1"=="run" exit /b 0 -exit /b 1 -"#, - ) - .unwrap(); - - #[cfg(not(windows))] - { - use std::os::unix::fs::PermissionsExt; - fs::write( - path, - r#"#!/usr/bin/env sh -case "$1" in - adapt) - printf '%s\n' '{"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["app.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}}' - exit 0 - ;; - reload|stop) - exit 0 - ;; - run) - exit 0 - ;; -esac -exit 1 -"#, - ) - .unwrap(); - let mut permissions = fs::metadata(path).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(path, permissions).unwrap(); - } - } - - fn registration(id: &str, nonce: &str) -> EntrypointRegistration { - let now = Utc::now(); - EntrypointRegistration { - registration_id: id.to_string(), - entrypoint_instance: EntrypointInstanceIdentity { - instance_id: id.to_string(), - started_at_utc: now, - shim_session_nonce: nonce.to_string(), - }, - source_working_directory: SourcePath::new(".", None), - source_config_path: SourcePath::new("Caddyfile", None), - registered_domains: vec![RegisteredDomain::active("app.localhost")], - activation_state: ActivationState::Active, - owner_process: OwnerProcessIdentity { - process_id: 1, - process_start_time_utc: now, - shim_session_nonce: nonce.to_string(), - executable_path: None, - }, - log_stream: LogStreamIdentity::entrypoint(id), - shim_run: None, - created_at_utc: now, - last_heartbeat_utc: now, - } - } - - #[tokio::test] - async fn register_and_unregister_preserve_owner_boundary() { - let state = state(); - let response = state - .register("register".to_string(), registration("shim-1", "nonce-1")) - .await; - assert!(response.accepted); - - let wrong = state - .unregister("wrong".to_string(), "shim-1", "other") - .await; - assert!(!wrong.accepted); - assert_eq!(state.snapshot().await.registrations.len(), 1); - - let right = state - .unregister("right".to_string(), "shim-1", "nonce-1") - .await; - assert!(right.accepted); - assert!(state.snapshot().await.registrations.is_empty()); - } - - #[tokio::test] - async fn disabled_domain_log_stream_is_reported_stale() { - let state = state(); - let response = state - .register("register".to_string(), registration("shim-1", "nonce-1")) - .await; - assert!(response.accepted); - let stream = LogStreamIdentity::domain("app.localhost"); - state.logs().append( - stream.clone(), - LogSeverity::Info, - "domain log", - LogAttributionKind::Domain, - None, - ); - - let toggle = state - .set_domain_enabled(SetDomainEnabledRequest { - request_id: "toggle".to_string(), - registration_id: "shim-1".to_string(), - domain_key: "app.localhost".to_string(), - enabled: false, - }) - .await; - assert!(toggle.accepted); - - let logs = state - .query_logs(QueryLogsRequest { - request_id: "logs".to_string(), - stream, - limit: Some(10), - cursor: None, - minimum_severity: None, - }) - .await; - - assert_eq!(logs.stream_status, LogStreamStatus::Stale); - assert_eq!(logs.entries.len(), 1); - } - - #[tokio::test] - async fn query_iis_bindings_reports_safety_states_from_fake_provider() { - let provider = IisProvider::fake(vec![ - iis_binding("Default Web Site", "http", "*:80:app.localhost"), - iis_binding("Default Web Site", "http", "127.0.0.1:80:app.localhost"), - https_iis_binding("Default Web Site", "*:443:secure.localhost"), - iis_binding("Other", "http", "*:80:missing.localhost"), - iis_binding("Default Web Site", "https", "*:443:"), - ]); - let state = state_with_iis(provider); - state - .register("register".to_string(), registration("shim-1", "nonce-1")) - .await; - - let response = state.query_iis_bindings("iis".to_string()).await; - - assert!(response.accepted); - assert_eq!(response.bindings.len(), 5); - assert_eq!( - response.bindings[0].handoff_state, - IisHandoffState::Conflict - ); - assert_eq!( - response.bindings[2].handoff_state, - IisHandoffState::Available - ); - assert_eq!( - response.bindings[3].handoff_state, - IisHandoffState::Available - ); - assert_eq!( - response.bindings[4].handoff_state, - IisHandoffState::MissingRoute - ); - } - - #[tokio::test] - async fn iis_handoff_proxies_wildcard_https_binding_with_route_host() { - let temp = tempfile::tempdir().unwrap(); - let caddy = temp.path().join(if cfg!(windows) { - "fake-caddy.cmd" - } else { - "fake-caddy" - }); - write_fake_caddy(&caddy); - let binding = https_iis_binding("Default Web Site", "*:443:"); - let provider = IisProvider::fake(vec![binding.clone()]); - let (state, paths) = state_with_fake_caddy_paths(provider, &caddy); - let backend_port = backend_port_for_binding(&binding); - let expected_backend_dial = format!("127.0.0.1:{backend_port}"); - assert!((41000..49000).contains(&backend_port)); - - let enabled = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-on".to_string(), - binding_id: binding.binding_id(), - enabled: true, - route_host: Some("https://iis-app.localhost/legacy/status".to_string()), - }) - .await; - let handed_off = state.query_iis_bindings("iis-handed-off".to_string()).await; - let rendered = fs::read_to_string(paths.effective_config_path()).unwrap(); - let config: serde_json::Value = serde_json::from_str(&rendered).unwrap(); - let routes = config - .pointer("/apps/http/servers/cadder_https/routes") - .and_then(serde_json::Value::as_array) - .unwrap(); - let iis_route = routes - .iter() - .find(|route| { - route - .pointer("/match/0/host/0") - .and_then(serde_json::Value::as_str) - == Some("iis-app.localhost") - }) - .unwrap(); - let _ = state.shutdown().await; - - assert!(enabled.accepted, "{enabled:?}"); - assert_eq!( - enabled - .binding - .as_ref() - .and_then(|binding| binding.domain_key.as_deref()), - Some("iis-app.localhost") - ); - assert_eq!(handed_off.bindings.len(), 1); - assert_eq!( - handed_off.bindings[0].identity.binding_id, - "Default Web Site|https|*:443:" - ); - assert_eq!( - handed_off.bindings[0].handoff_state, - IisHandoffState::HandedOff - ); - assert_eq!( - handed_off.bindings[0].domain_key.as_deref(), - Some("iis-app.localhost") - ); - assert_eq!( - iis_route - .pointer("/handle/0/upstreams/0/dial") - .and_then(serde_json::Value::as_str), - Some(expected_backend_dial.as_str()) - ); - assert_eq!( - iis_route - .pointer("/handle/0/transport/tls/server_name") - .and_then(serde_json::Value::as_str), - Some("iis-app.localhost") - ); - assert_eq!( - iis_route - .pointer("/handle/0/transport/tls/insecure_skip_verify") - .and_then(serde_json::Value::as_bool), - Some(true) - ); - } - - #[tokio::test] - async fn iis_handoff_rejects_https_binding_without_certificate_before_mutation() { - let binding = iis_binding("Default Web Site", "https", "*:443:secure.localhost"); - let provider = IisProvider::fake(vec![binding.clone()]); - let state = state_with_iis(provider.clone()); - - let response = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-on".to_string(), - binding_id: binding.binding_id(), - enabled: true, - route_host: None, - }) - .await; - let rediscovered = provider.discover().await.unwrap(); - - assert!(!response.accepted); - assert_eq!( - response.issue.as_ref().map(|issue| issue.kind), - Some(IisIssueKind::MissingTlsCertificate) - ); - assert!(response.steps.iter().any(|step| { - step.step_id == "iis-classify-binding" && step.status == IisOperationStepStatus::Failed - })); - assert_eq!(rediscovered, vec![binding]); - assert!(state.iis_store.snapshot().await.is_empty()); - } - - #[tokio::test] - async fn iis_handoff_rejects_explicit_route_host_used_by_another_binding() { - let selected = iis_binding("Default Web Site", "https", "*:443:"); - let existing = iis_binding("Default Web Site", "https", "*:443:iis-app.localhost"); - let provider = IisProvider::fake(vec![selected.clone(), existing]); - let state = state_with_iis(provider); - - let response = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-on".to_string(), - binding_id: selected.binding_id(), - enabled: true, - route_host: Some("iis-app.localhost".to_string()), - }) - .await; - - assert!(!response.accepted); - assert_eq!( - response.issue.as_ref().map(|issue| issue.kind), - Some(IisIssueKind::Conflict) - ); - } - - #[tokio::test] - async fn runtime_paths_hydrate_iis_proxy_routes_from_metadata() { - let temp = tempfile::tempdir().unwrap(); - let caddy = temp.path().join(if cfg!(windows) { - "fake-caddy.cmd" - } else { - "fake-caddy" - }); - write_fake_caddy(&caddy); - let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); - paths.ensure_dirs().unwrap(); - let binding = https_iis_binding("Default Web Site", "*:443:"); - let domain_key = "iis-app.localhost".to_string(); - let backend_binding = binding - .backend_binding(backend_port_for_binding(&binding), &domain_key) - .unwrap(); - let store = IisMetadataStore::load(paths.metadata_path()).await.unwrap(); - store - .insert( - binding.binding_id(), - IisRestoreRecord { - binding: binding.clone(), - domain_key: domain_key.clone(), - registration_id: None, - backend_binding: Some(backend_binding.clone()), - }, - ) - .await - .unwrap(); - let resolver = RealCaddyResolver::new(Some(caddy.display().to_string())); - let adapter = CaddyConfigAdapter::new(resolver.clone()); - let runtime = ProcessRuntime::new(resolver, paths.clone()); - let state = - DaemonState::with_runtime_paths(CaddyConfigCoordinator::new(adapter, runtime), paths.clone()) - .await - .unwrap(); - - let rendered = fs::read_to_string(paths.effective_config_path()).unwrap(); - let _ = state.shutdown().await; - - assert!(rendered.contains("iis-app.localhost")); - assert!(rendered.contains(&format!("127.0.0.1:{}", backend_binding.port))); - assert!(rendered.contains("\"server_name\": \"iis-app.localhost\"")); - } - - #[tokio::test] - async fn iis_handoff_rolls_back_when_caddy_apply_fails() { - let binding = iis_binding("Default Web Site", "http", "*:80:iis.localhost"); - let provider = IisProvider::fake(vec![binding.clone()]); - let state = state_with_iis(provider); - state - .register("register".to_string(), registration("shim-1", "nonce-1")) - .await; - - let response = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-on".to_string(), - binding_id: binding.binding_id(), - enabled: true, - route_host: None, - }) - .await; - let rediscovered = state.query_iis_bindings("iis".to_string()).await; - - assert!(!response.accepted); - assert_eq!( - response.issue.as_ref().map(|issue| issue.kind), - Some(IisIssueKind::RollbackSucceeded) - ); - assert_eq!(rediscovered.bindings.len(), 1); - assert_eq!( - rediscovered.bindings[0].handoff_state, - IisHandoffState::Available - ); - assert!(state.iis_store.snapshot().await.is_empty()); - } - - #[tokio::test] - async fn iis_handoff_keeps_restore_metadata_when_caddy_apply_and_rollback_fail() { - let binding = iis_binding("Default Web Site", "http", "*:80:iis.localhost"); - let provider = IisProvider::fake(vec![binding.clone()]); - provider - .set_fail_restore(IisIssue::new(IisIssueKind::ProviderError, "restore denied")) - .await; - let state = state_with_iis(provider); - state - .register("register".to_string(), registration("shim-1", "nonce-1")) - .await; - - let response = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-on".to_string(), - binding_id: binding.binding_id(), - enabled: true, - route_host: None, - }) - .await; - let rediscovered = state.query_iis_bindings("iis".to_string()).await; - let handoffs = state.iis_store.snapshot().await; - - assert!(!response.accepted); - assert_eq!( - response.issue.as_ref().map(|issue| issue.kind), - Some(IisIssueKind::RollbackFailed) - ); - assert!(handoffs.contains_key(&binding.binding_id())); - assert_eq!(rediscovered.bindings.len(), 1); - assert_eq!( - rediscovered.bindings[0].handoff_state, - IisHandoffState::HandedOff - ); - assert_eq!( - rediscovered.bindings[0].domain_key.as_deref(), - Some("iis.localhost") - ); - } - - #[tokio::test] - async fn iis_handoff_reports_busy_when_another_operation_is_running() { - let binding = iis_binding("Default Web Site", "http", "*:80:iis.localhost"); - let provider = IisProvider::fake(vec![binding.clone()]); - let state = state_with_iis(provider); - let _operation = state.iis_operation.try_lock().unwrap(); - - let response = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-on".to_string(), - binding_id: binding.binding_id(), - enabled: true, - route_host: None, - }) - .await; - - assert!(!response.accepted); - assert_eq!( - response.issue.as_ref().map(|issue| issue.kind), - Some(IisIssueKind::Busy) - ); - } - - #[tokio::test] - async fn iis_handoff_success_and_restore_use_fake_provider() { - let temp = tempfile::tempdir().unwrap(); - let caddy = temp.path().join(if cfg!(windows) { - "fake-caddy.cmd" - } else { - "fake-caddy" - }); - write_fake_caddy(&caddy); - let binding = iis_binding("Default Web Site", "http", "*:80:app.localhost"); - let provider = IisProvider::fake(vec![binding.clone()]); - let state = state_with_fake_caddy(provider, &caddy); - state - .register("register".to_string(), registration("shim-1", "nonce-1")) - .await; - state - .set_domain_enabled(SetDomainEnabledRequest { - request_id: "disable".to_string(), - registration_id: "shim-1".to_string(), - domain_key: "app.localhost".to_string(), - enabled: false, - }) - .await; - - let enabled = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-on".to_string(), - binding_id: binding.binding_id(), - enabled: true, - route_host: None, - }) - .await; - let handed_off = state.query_iis_bindings("iis-handed-off".to_string()).await; - let restored = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-off".to_string(), - binding_id: binding.binding_id(), - enabled: false, - route_host: None, - }) - .await; - let rediscovered = state.query_iis_bindings("iis".to_string()).await; - let _ = state.shutdown().await; - - assert!(enabled.accepted, "{enabled:?}"); - assert_eq!( - enabled - .binding - .as_ref() - .map(|binding| binding.handoff_state), - Some(IisHandoffState::HandedOff) - ); - assert!(enabled.steps.iter().any(|step| { - step.step_id == "iis-remove-public-binding" - && step.approval == IisElevationApproval::Approved - && step.status == IisOperationStepStatus::Succeeded - })); - assert_eq!(handed_off.bindings.len(), 1); - assert_eq!( - handed_off.bindings[0].handoff_state, - IisHandoffState::HandedOff - ); - assert!(restored.accepted, "{restored:?}"); - assert_eq!(rediscovered.bindings.len(), 1); - assert_eq!( - rediscovered.bindings[0].handoff_state, - IisHandoffState::Available - ); - } - - #[tokio::test] - async fn iis_https_handoff_success_and_restore_use_https_backend_binding() { - let temp = tempfile::tempdir().unwrap(); - let caddy = temp.path().join(if cfg!(windows) { - "fake-caddy.cmd" - } else { - "fake-caddy" - }); - write_fake_caddy(&caddy); - let binding = https_iis_binding("Default Web Site", "*:443:secure.localhost"); - let provider = IisProvider::fake(vec![binding.clone()]); - let state = state_with_fake_caddy(provider.clone(), &caddy); - - let enabled = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-on".to_string(), - binding_id: binding.binding_id(), - enabled: true, - route_host: None, - }) - .await; - let after_enable = provider.discover().await.unwrap(); - let restored = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-off".to_string(), - binding_id: binding.binding_id(), - enabled: false, - route_host: None, - }) - .await; - let after_restore = provider.discover().await.unwrap(); - let _ = state.shutdown().await; - - assert!(enabled.accepted, "{enabled:?}"); - assert_eq!(after_enable.len(), 1); - assert_eq!(after_enable[0].protocol, "https"); - assert_eq!(after_enable[0].ip_address, "127.0.0.1"); - assert_eq!(after_enable[0].host_header, "secure.localhost"); - assert_eq!(after_enable[0].tls_certificate, Some(tls_certificate())); - assert!(restored.accepted, "{restored:?}"); - assert_eq!(after_restore, vec![binding]); - } - - #[tokio::test] - async fn iis_handoff_denied_by_elevation_keeps_user_level_state_usable() { - let binding = iis_binding("Default Web Site", "http", "*:80:app.localhost"); - let provider = IisProvider::fake(vec![binding.clone()]); - provider - .set_elevation_issue(IisIssue::new( - IisIssueKind::ElevationDenied, - "Administrator approval was denied.", - )) - .await; - let state = state_with_iis(provider); - - let response = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-on".to_string(), - binding_id: binding.binding_id(), - enabled: true, - route_host: None, - }) - .await; - let rediscovered = state.query_iis_bindings("iis".to_string()).await; - - assert!(!response.accepted); - assert_eq!( - response.issue.as_ref().map(|issue| issue.kind), - Some(IisIssueKind::ElevationDenied) - ); - assert!(response.steps.iter().any(|step| { - step.step_id == "iis-remove-public-binding" - && step.approval == IisElevationApproval::Denied - && step.status == IisOperationStepStatus::Denied - })); - assert_eq!( - response.follow_up_actions, - vec![IisFollowUpAction::RetryElevation] - ); - assert!(state.iis_store.snapshot().await.is_empty()); - assert_eq!(rediscovered.bindings.len(), 1); - assert_eq!( - rediscovered.bindings[0].handoff_state, - IisHandoffState::Available - ); - } - - #[tokio::test] - async fn iis_handoff_reports_unsupported_elevation_without_mutating_iis() { - let binding = https_iis_binding("Default Web Site", "*:443:secure.localhost"); - let provider = IisProvider::fake(vec![binding.clone()]); - provider - .set_elevation_issue(IisIssue::new( - IisIssueKind::ElevationUnsupported, - "IIS elevation prompts are only available on Windows.", - )) - .await; - let state = state_with_iis(provider); - - let response = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-on".to_string(), - binding_id: binding.binding_id(), - enabled: true, - route_host: None, - }) - .await; - let rediscovered = state.query_iis_bindings("iis".to_string()).await; - - assert!(!response.accepted); - assert_eq!( - response.issue.as_ref().map(|issue| issue.kind), - Some(IisIssueKind::ElevationUnsupported) - ); - assert!(response.follow_up_actions.is_empty()); - assert!(response.steps.iter().any(|step| { - step.step_id == "iis-create-loopback-binding" - && step.approval == IisElevationApproval::Unsupported - && step.status == IisOperationStepStatus::Unsupported - })); - assert!(state.iis_store.snapshot().await.is_empty()); - assert_eq!(rediscovered.bindings.len(), 1); - assert_eq!( - rediscovered.bindings[0].handoff_state, - IisHandoffState::Available - ); - } - - #[tokio::test] - async fn iis_restore_keeps_metadata_when_backend_cleanup_fails() { - let temp = tempfile::tempdir().unwrap(); - let caddy = temp.path().join(if cfg!(windows) { - "fake-caddy.cmd" - } else { - "fake-caddy" - }); - write_fake_caddy(&caddy); - let binding = iis_binding("Default Web Site", "http", "*:80:app.localhost"); - let provider = IisProvider::fake(vec![binding.clone()]); - let state = state_with_fake_caddy(provider.clone(), &caddy); - state - .register("register".to_string(), registration("shim-1", "nonce-1")) - .await; - state - .set_domain_enabled(SetDomainEnabledRequest { - request_id: "disable".to_string(), - registration_id: "shim-1".to_string(), - domain_key: "app.localhost".to_string(), - enabled: false, - }) - .await; - - let enabled = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-on".to_string(), - binding_id: binding.binding_id(), - enabled: true, - route_host: None, - }) - .await; - provider - .set_fail_remove(IisIssue::new(IisIssueKind::ProviderError, "cleanup failed")) - .await; - let restored = state - .set_iis_handoff(SetIisHandoffRequest { - request_id: "iis-off".to_string(), - binding_id: binding.binding_id(), - enabled: false, - route_host: None, - }) - .await; - let handoffs = state.iis_store.snapshot().await; - let _ = state.shutdown().await; - - assert!(enabled.accepted, "{enabled:?}"); - assert!(!restored.accepted); - assert_eq!( - restored.issue.as_ref().map(|issue| issue.kind), - Some(IisIssueKind::RestoreFailed) - ); - assert!(handoffs.contains_key(&binding.binding_id())); - } - - #[tokio::test] - async fn register_rejects_invalid_owner_identity() { - let state = state(); - let mut registration = registration("shim-1", "nonce-1"); - registration.owner_process.shim_session_nonce = "different".to_string(); - - let response = state.register("register".to_string(), registration).await; - - assert!(!response.accepted); - assert!(response.message.contains("nonce values must match")); - assert!(state.snapshot().await.registrations.is_empty()); - } - - #[tokio::test] - async fn subscribe_receives_registration_change_event() { - let state = state(); - let mut events = state.subscribe(); - - let response = state - .register("register".to_string(), registration("shim-1", "nonce-1")) - .await; - let event = events.recv().await.unwrap(); - - assert!(response.accepted); - assert_eq!(event.sequence_number, 1); - assert_eq!(event.change_kind, StateChangeKind::RegistrationsChanged); - assert_eq!(event.registration_id.as_deref(), Some("shim-1")); - assert_eq!(event.snapshot.registrations.len(), 1); - } - - #[tokio::test] - async fn heartbeat_accepts_owner_and_rejects_wrong_nonce() { - let state = state(); - state - .register("register".to_string(), registration("shim-1", "nonce-1")) - .await; - - let accepted = state - .heartbeat(HeartbeatEntrypointRequest { - request_id: "heartbeat".to_string(), - registration_id: "shim-1".to_string(), - shim_session_nonce: "nonce-1".to_string(), - }) - .await; - let rejected = state - .heartbeat(HeartbeatEntrypointRequest { - request_id: "heartbeat".to_string(), - registration_id: "shim-1".to_string(), - shim_session_nonce: "wrong".to_string(), - }) - .await; - - assert!(accepted.accepted); - assert_eq!(accepted.message, "Heartbeat accepted."); - assert!(!rejected.accepted); - assert_eq!( - rejected.message, - "Entrypoint was not found for the requested owner." - ); - } - - #[tokio::test] - async fn set_entrypoint_enabled_accepts_optional_owner_and_rejects_wrong_owner() { - let state = state(); - state - .register("register".to_string(), registration("shim-1", "nonce-1")) - .await; - - let accepted = state - .set_entrypoint_enabled(SetEntrypointEnabledRequest { - request_id: "disable".to_string(), - registration_id: "shim-1".to_string(), - shim_session_nonce: None, - enabled: false, - }) - .await; - let rejected = state - .set_entrypoint_enabled(SetEntrypointEnabledRequest { - request_id: "enable".to_string(), - registration_id: "shim-1".to_string(), - shim_session_nonce: Some("wrong".to_string()), - enabled: true, - }) - .await; - let snapshot = state.snapshot().await; - - assert!(accepted.accepted); - assert_eq!(accepted.message, "Entrypoint activation updated."); - assert!(!rejected.accepted); - assert_eq!( - snapshot.registrations[0].activation_state, - ActivationState::Inactive - ); - } - - #[tokio::test] - async fn set_domain_enabled_rejects_unknown_domain() { - let state = state(); - state - .register("register".to_string(), registration("shim-1", "nonce-1")) - .await; - - let response = state - .set_domain_enabled(SetDomainEnabledRequest { - request_id: "toggle".to_string(), - registration_id: "shim-1".to_string(), - domain_key: "missing.localhost".to_string(), - enabled: false, - }) - .await; - - assert!(!response.accepted); - assert_eq!(response.message, "Domain was not found."); - } - - #[tokio::test] - async fn query_state_returns_snapshot_with_request_id() { - let state = state(); - state - .register("register".to_string(), registration("shim-1", "nonce-1")) - .await; - - let response = state.query_state("state".to_string()).await; - - assert!(response.accepted); - assert_eq!(response.request_id, "state"); - assert_eq!(response.message, "State snapshot returned."); - assert_eq!(response.snapshot.unwrap().registrations.len(), 1); - } - - #[tokio::test] - async fn runtime_control_logs_are_active_and_limit_is_clamped() { - let state = state(); - let stream = LogStreamIdentity::runtime_control(); - state.logs().append( - stream.clone(), - LogSeverity::Info, - "first", - LogAttributionKind::RuntimeControl, - Some("start".to_string()), - ); - state.logs().append( - stream.clone(), - LogSeverity::Warn, - "second", - LogAttributionKind::RuntimeControl, - Some("reload".to_string()), - ); - - let response = state - .query_logs(QueryLogsRequest { - request_id: "logs".to_string(), - stream: stream.clone(), - limit: Some(0), - cursor: Some("not-a-sequence".to_string()), - minimum_severity: Some(LogSeverity::Info), - }) - .await; - - assert_eq!(response.stream, stream); - assert_eq!(response.stream_status, LogStreamStatus::Active); - assert_eq!(response.entries.len(), 1); - assert_eq!(response.entries[0].raw_message, "second"); - assert!(response.next_cursor.is_some()); - } - - #[tokio::test] - async fn shutdown_returns_success_when_runtime_is_idle() { - let state = state(); - - let response = state.shutdown().await; - - assert!(response.accepted); - assert_eq!(response.message, "Daemon shutdown requested."); - } -} diff --git a/crates/cadder-daemon/src/state/autostart_control.rs b/crates/cadder-daemon/src/state/autostart_control.rs new file mode 100644 index 0000000..2111a5e --- /dev/null +++ b/crates/cadder-daemon/src/state/autostart_control.rs @@ -0,0 +1,43 @@ +use super::*; + +impl DaemonState { + pub async fn query_autostart(&self, request_id: String) -> QueryAutostartResponse { + let view = self.autostart.query(); + QueryAutostartResponse { + request_id, + accepted: !matches!(view.status, cadder_protocol::AutostartStatus::Unsupported), + message: "Autostart status returned.".to_string(), + mode: view.mode, + status: view.status, + target: view.target, + diagnostics: view.diagnostics, + } + } + + pub async fn set_autostart(&self, request: SetAutostartRequest) -> SetAutostartResponse { + let view = self.autostart.set(request.mode); + self.store.record_history( + HistoryKind::Autostart, + format!("Set autostart mode to {:?}.", request.mode), + None, + None, + &serde_json::json!({ + "mode": request.mode, + "status": view.status, + "target": view.target + }), + ); + SetAutostartResponse { + request_id: request.request_id, + accepted: matches!( + view.status, + cadder_protocol::AutostartStatus::Disabled | cadder_protocol::AutostartStatus::Enabled + ), + message: "Autostart mode updated.".to_string(), + mode: view.mode, + status: view.status, + target: view.target, + diagnostics: view.diagnostics, + } + } +} diff --git a/crates/cadder-daemon/src/state/config_apply.rs b/crates/cadder-daemon/src/state/config_apply.rs new file mode 100644 index 0000000..3c0b400 --- /dev/null +++ b/crates/cadder-daemon/src/state/config_apply.rs @@ -0,0 +1,42 @@ +use super::*; + +impl DaemonState { + pub(super) async fn apply_registrations( + &self, + registrations: Vec, + ) -> ConfigState { + let (action, runtime) = { + let mut coordinator = self.coordinator.lock().await; + ( + coordinator.begin_apply(®istrations), + coordinator.runtime(), + ) + }; + match action { + CaddyApplyAction::Current(state) => state, + CaddyApplyAction::Stop { attempted } => { + if let Err(error) = runtime.stop().await { + self.logs.append( + LogStreamIdentity::runtime_control(), + LogSeverity::Error, + error.to_string(), + LogAttributionKind::RuntimeControl, + Some("idle-stop".to_string()), + ); + } + let mut coordinator = self.coordinator.lock().await; + coordinator.finish_idle(attempted) + } + CaddyApplyAction::Apply { + attempted, + rendered, + hash, + source_config_paths, + } => { + let result = runtime.apply_config(&rendered, &self.logs).await; + let mut coordinator = self.coordinator.lock().await; + coordinator.finish_runtime_apply(attempted, hash, source_config_paths, result) + } + } + } +} diff --git a/crates/cadder-daemon/src/state/history.rs b/crates/cadder-daemon/src/state/history.rs new file mode 100644 index 0000000..eb63e62 --- /dev/null +++ b/crates/cadder-daemon/src/state/history.rs @@ -0,0 +1,19 @@ +use super::*; + +impl DaemonState { + pub async fn query_history( + &self, + request: cadder_protocol::QueryHistoryRequest, + ) -> QueryHistoryResponse { + QueryHistoryResponse { + request_id: request.request_id, + accepted: true, + message: "Runtime history returned.".to_string(), + records: self + .store + .query_history(request.kind, request.limit.unwrap_or(100)) + .await, + storage: Some(self.store.state()), + } + } +} diff --git a/crates/cadder-daemon/src/state/iis_handoff.rs b/crates/cadder-daemon/src/state/iis_handoff.rs new file mode 100644 index 0000000..5426872 --- /dev/null +++ b/crates/cadder-daemon/src/state/iis_handoff.rs @@ -0,0 +1,831 @@ +use super::*; + +impl DaemonState { + pub async fn query_iis_bindings(&self, request_id: String) -> QueryIisBindingsResponse { + let records = match self.iis_provider.discover().await { + Ok(records) => records, + Err(issue) => { + return QueryIisBindingsResponse { + request_id, + accepted: false, + message: issue.message.clone(), + bindings: Vec::new(), + issue: Some(issue), + }; + } + }; + let handoffs = self.iis_store.snapshot().await; + let inner = self.inner.lock().await; + QueryIisBindingsResponse { + request_id, + accepted: true, + message: "IIS bindings returned.".to_string(), + bindings: self.iis_binding_views_locked(&inner, &records, &handoffs), + issue: None, + } + } + + pub async fn set_iis_handoff(&self, request: SetIisHandoffRequest) -> SetIisHandoffResponse { + let request_id = request.request_id.clone(); + let Ok(_operation) = self.iis_operation.try_lock() else { + let issue = IisIssue::new( + IisIssueKind::Busy, + "Another IIS handoff operation is already running.", + ); + return iis_failure(request_id, issue, None, Vec::new()); + }; + + if request.enabled { + self.enable_iis_handoff(request).await + } else { + self.disable_iis_handoff(request).await + } + } + + async fn enable_iis_handoff(&self, request: SetIisHandoffRequest) -> SetIisHandoffResponse { + let records = match self.iis_provider.discover().await { + Ok(records) => records, + Err(issue) => { + return iis_failure(request.request_id, issue, None, Vec::new()); + } + }; + let Some(binding) = records + .iter() + .find(|binding| binding.binding_id() == request.binding_id) + .cloned() + else { + let issue = IisIssue::new(IisIssueKind::MissingBinding, "IIS binding was not found."); + return iis_failure(request.request_id, issue, None, Vec::new()); + }; + let mut steps = enable_iis_steps(); + mark_step_succeeded(&mut steps, "iis-discover-bindings"); + + if let Some(issue) = unsupported_binding_issue(&binding) { + mark_step_issue(&mut steps, "iis-classify-binding", &issue); + let view = binding_to_view( + &binding, + IisHandoffState::Unsupported, + Some(issue.clone()), + None, + ); + return iis_failure(request.request_id, issue, Some(view), steps); + } + + let domain_key = match route_host_for_binding(&binding, request.route_host.as_deref()) { + Ok(domain_key) => domain_key, + Err(issue) => { + mark_step_issue(&mut steps, "iis-classify-binding", &issue); + let view = binding_to_view( + &binding, + IisHandoffState::MissingRoute, + Some(issue.clone()), + None, + ); + return iis_failure(request.request_id, issue, Some(view), steps); + } + }; + if iis_route_host_conflicts(&records, &binding.binding_id(), &domain_key) { + let issue = IisIssue::new( + IisIssueKind::Conflict, + format!("IIS host `{domain_key}` appears on multiple bindings."), + ); + mark_step_issue(&mut steps, "iis-classify-binding", &issue); + let view = binding_to_view( + &binding, + IisHandoffState::Conflict, + Some(issue.clone()), + None, + ); + return iis_failure(request.request_id, issue, Some(view), steps); + } + + let registration_conflict = { + let inner = self.inner.lock().await; + active_registration_conflict(&inner.registrations, &domain_key) + }; + if let Some(issue) = registration_conflict { + mark_step_issue(&mut steps, "iis-classify-binding", &issue); + let view = binding_to_view( + &binding, + IisHandoffState::Conflict, + Some(issue.clone()), + None, + ); + return iis_failure(request.request_id, issue, Some(view), steps); + } + mark_step_succeeded(&mut steps, "iis-classify-binding"); + + let backend_binding = + match binding.backend_binding(backend_port_for_binding(&binding), &domain_key) { + Ok(backend_binding) => backend_binding, + Err(issue) => { + mark_step_issue(&mut steps, "iis-classify-binding", &issue); + let view = binding_to_view( + &binding, + IisHandoffState::Unsupported, + Some(issue.clone()), + None, + ); + return iis_failure(request.request_id, issue, Some(view), steps); + } + }; + let restore = IisRestoreRecord { + binding: binding.clone(), + domain_key: domain_key.clone(), + registration_id: None, + backend_binding: Some(backend_binding.clone()), + }; + if let Err(error) = self + .iis_store + .insert(binding.binding_id(), restore.clone()) + .await + { + let issue = IisIssue::new( + IisIssueKind::ProviderError, + format!("Could not persist IIS restore metadata: {error}"), + ); + mark_step_issue(&mut steps, "iis-write-restore-metadata", &issue); + return iis_failure(request.request_id, issue, None, steps); + } + mark_step_succeeded(&mut steps, "iis-write-restore-metadata"); + + let privileged_step_ids = ["iis-create-loopback-binding", "iis-remove-public-binding"]; + let privileged_reason = format!( + "Cadder needs administrator approval to hand IIS host `{domain_key}` to Caddy by creating the loopback backend binding and removing the original public IIS binding." + ); + if let Err(issue) = self + .iis_provider + .execute_privileged_batch( + &privileged_reason, + &[ + IisMutation::add(backend_binding.clone()), + IisMutation::remove(binding.clone()), + ], + ) + .await + { + let _ = self.iis_store.remove(&binding.binding_id()).await; + mark_privileged_batch_issue(&mut steps, &privileged_step_ids, &issue); + mark_step_skipped(&mut steps, "caddy-apply-proxy-route"); + let view = binding_to_view( + &binding, + IisHandoffState::Available, + Some(issue.clone()), + None, + ); + let mut follow_up_actions = follow_up_actions_for_issue(&issue); + if matches!(issue.kind, IisIssueKind::ProviderError) { + follow_up_actions.push(IisFollowUpAction::RemoveLoopbackBinding); + } + return iis_failure_with_actions( + request.request_id, + issue, + Some(view), + steps, + follow_up_actions, + ); + } + mark_privileged_batch_approved(&mut steps, &privileged_step_ids); + + let apply_state = { + let _operation = self.config_operation.lock().await; + { + let mut coordinator = self.coordinator.lock().await; + coordinator.set_iis_proxy_route( + binding.binding_id(), + domain_key.clone(), + backend_dial(&backend_binding), + IisProxyBackendProtocol::from_iis_protocol(&backend_binding.protocol), + ); + } + let registrations = { + let inner = self.inner.lock().await; + inner.registrations.values().cloned().collect::>() + }; + let apply_state = self.apply_registrations(registrations).await; + self + .publish_change(StateChangeKind::RegistrationsChanged, None) + .await; + apply_state + }; + + if apply_state.status == ConfigApplyStatus::Failed { + let apply_issue = IisIssue::new( + IisIssueKind::ProviderError, + "Cadder could not apply the IIS proxy route.", + ); + mark_step_issue(&mut steps, "caddy-apply-proxy-route", &apply_issue); + { + let _operation = self.config_operation.lock().await; + { + let mut coordinator = self.coordinator.lock().await; + coordinator.remove_iis_proxy_route(&domain_key); + } + let registrations = { + let inner = self.inner.lock().await; + inner.registrations.values().cloned().collect::>() + }; + self.apply_registrations(registrations).await; + self + .publish_change(StateChangeKind::RegistrationsChanged, None) + .await; + } + steps.push(IisOperationStep::administrator( + "iis-rollback-public-binding", + "Restore original IIS binding after Caddy apply failure.", + )); + steps.push(IisOperationStep::administrator( + "iis-rollback-loopback-binding", + "Remove loopback IIS binding after Caddy apply failure.", + )); + let rollback_step_ids = [ + "iis-rollback-public-binding", + "iis-rollback-loopback-binding", + ]; + let rollback = self + .iis_provider + .execute_privileged_batch( + "Cadder needs administrator approval to roll IIS back after Caddy route apply failed.", + &[ + IisMutation::restore(binding.clone()), + IisMutation::remove(backend_binding.clone()), + ], + ) + .await; + let (issue, view) = match rollback { + Ok(()) => { + let _ = self.iis_store.remove(&binding.binding_id()).await; + let issue = IisIssue::new( + IisIssueKind::RollbackSucceeded, + "Cadder could not apply the IIS proxy route; original IIS binding was restored.", + ); + mark_privileged_batch_approved(&mut steps, &rollback_step_ids); + let view = binding_to_view( + &binding, + IisHandoffState::Available, + Some(issue.clone()), + None, + ); + (issue, view) + } + Err(error) => { + let issue = IisIssue::new( + IisIssueKind::RollbackFailed, + format!( + "Cadder could not apply the IIS proxy route and IIS rollback failed: {}", + error.message + ), + ); + mark_privileged_batch_issue(&mut steps, &rollback_step_ids, &issue); + let view = handoff_binding_to_view(&binding, Some(issue.clone()), &restore); + (issue, view) + } + }; + let follow_up_actions = follow_up_actions_for_issue(&issue); + return iis_failure_with_actions( + request.request_id, + issue, + Some(view), + steps, + follow_up_actions, + ); + } + mark_step_succeeded(&mut steps, "caddy-apply-proxy-route"); + + let view = binding_to_view( + &binding, + IisHandoffState::HandedOff, + None, + Some(binding.restore_summary()), + ); + let mut view = view; + view.domain_key = Some(domain_key.clone()); + SetIisHandoffResponse { + request_id: request.request_id, + accepted: true, + message: format!( + "IIS binding `{domain_key}` is proxied through Cadder to 127.0.0.1:{}.", + backend_binding.port + ), + binding: Some(view), + issue: None, + steps, + follow_up_actions: Vec::new(), + } + } + + async fn disable_iis_handoff(&self, request: SetIisHandoffRequest) -> SetIisHandoffResponse { + let mut steps = disable_iis_steps(); + let handoffs = self.iis_store.snapshot().await; + let Some(restore) = handoffs.get(&request.binding_id).cloned() else { + let issue = IisIssue::new( + IisIssueKind::MissingBinding, + "IIS handoff restore metadata was not found.", + ); + mark_step_issue(&mut steps, "iis-read-restore-metadata", &issue); + return iis_failure(request.request_id, issue, None, steps); + }; + mark_step_succeeded(&mut steps, "iis-read-restore-metadata"); + let backend_binding = legacy_backend_binding(&restore); + + { + let front_door_needed = { + let inner = self.inner.lock().await; + caddy_front_door_needed(&inner.registrations, &restore.domain_key) + }; + let other_iis_routes = { + let coordinator = self.coordinator.lock().await; + coordinator.has_iis_proxy_routes_except(&restore.domain_key) + }; + if front_door_needed || other_iis_routes { + let issue = IisIssue::new( + IisIssueKind::Conflict, + format!( + "Cannot restore IIS binding `{}` while other Cadder routes still need the front-door port.", + restore.domain_key + ), + ); + let view = binding_to_view( + &restore.binding, + IisHandoffState::HandedOff, + Some(issue.clone()), + Some(restore.binding.restore_summary()), + ); + mark_step_issue(&mut steps, "caddy-remove-proxy-route", &issue); + return iis_failure(request.request_id, issue, Some(view), steps); + } + } + + { + let _operation = self.config_operation.lock().await; + { + let mut coordinator = self.coordinator.lock().await; + coordinator.remove_iis_proxy_route(&restore.domain_key); + } + let registrations = { + let inner = self.inner.lock().await; + inner.registrations.values().cloned().collect::>() + }; + self.apply_registrations(registrations).await; + self + .publish_change(StateChangeKind::RegistrationsChanged, None) + .await; + } + mark_step_succeeded(&mut steps, "caddy-remove-proxy-route"); + + let privileged_step_ids = ["iis-restore-public-binding", "iis-remove-loopback-binding"]; + if let Err(issue) = self + .iis_provider + .execute_privileged_batch( + "Cadder needs administrator approval to restore the original IIS binding and remove the loopback backend binding.", + &[ + IisMutation::restore(restore.binding.clone()), + IisMutation::remove(backend_binding.clone()), + ], + ) + .await + { + let _operation = self.config_operation.lock().await; + { + let mut coordinator = self.coordinator.lock().await; + coordinator.set_iis_proxy_route( + request.binding_id.clone(), + restore.domain_key.clone(), + backend_dial(&backend_binding), + IisProxyBackendProtocol::from_iis_protocol(&backend_binding.protocol), + ); + } + let registrations = { + let inner = self.inner.lock().await; + inner.registrations.values().cloned().collect::>() + }; + self.apply_registrations(registrations).await; + self + .publish_change(StateChangeKind::RegistrationsChanged, None) + .await; + let issue = IisIssue::new( + IisIssueKind::RestoreFailed, + format!( + "Cadder restored the proxy route because the privileged IIS restore batch failed: {}", + issue.message + ), + ); + mark_privileged_batch_issue(&mut steps, &privileged_step_ids, &issue); + mark_step_skipped(&mut steps, "iis-clear-restore-metadata"); + let view = handoff_binding_to_view(&restore.binding, Some(issue.clone()), &restore); + return iis_failure_with_actions( + request.request_id, + issue.clone(), + Some(view), + steps, + follow_up_actions_for_issue(&issue), + ); + } + mark_privileged_batch_approved(&mut steps, &privileged_step_ids); + if let Err(error) = self.iis_store.remove(&request.binding_id).await { + let issue = IisIssue::new( + IisIssueKind::ProviderError, + format!("IIS binding was restored but restore metadata could not be cleared: {error}"), + ); + mark_step_issue(&mut steps, "iis-clear-restore-metadata", &issue); + return iis_failure_with_actions( + request.request_id, + issue, + None, + steps, + vec![IisFollowUpAction::ClearRestoreMetadata], + ); + } + mark_step_succeeded(&mut steps, "iis-clear-restore-metadata"); + + let view = binding_to_view(&restore.binding, IisHandoffState::Available, None, None); + SetIisHandoffResponse { + request_id: request.request_id, + accepted: true, + message: format!("IIS binding `{}` restored to IIS.", restore.domain_key), + binding: Some(view), + issue: None, + steps, + follow_up_actions: Vec::new(), + } + } + + fn iis_binding_views_locked( + &self, + inner: &DaemonInner, + records: &[IisBindingRecord], + handoffs: &BTreeMap, + ) -> Vec { + let backend_binding_ids = handoffs + .values() + .filter_map(|restore| restore.backend_binding.as_ref()) + .map(IisBindingRecord::binding_id) + .collect::>(); + let public_records = records + .iter() + .filter(|binding| !backend_binding_ids.contains(&binding.binding_id())) + .cloned() + .collect::>(); + let duplicate_hosts = duplicate_iis_hosts(&public_records); + let mut seen = BTreeSet::new(); + let mut bindings = public_records + .iter() + .map(|binding| { + let binding_id = binding.binding_id(); + seen.insert(binding_id.clone()); + if let Some(restore) = handoffs.get(&binding_id) { + return handoff_binding_to_view(binding, None, restore); + } + if let Some(issue) = unsupported_binding_issue(binding) { + return binding_to_view(binding, IisHandoffState::Unsupported, Some(issue), None); + } + let domain_key = match route_host_for_binding(binding, None) { + Ok(domain_key) => domain_key, + Err(issue) => { + return binding_to_view(binding, IisHandoffState::MissingRoute, Some(issue), None); + } + }; + if duplicate_hosts.contains(&domain_key) { + return binding_to_view( + binding, + IisHandoffState::Conflict, + Some(IisIssue::new( + IisIssueKind::Conflict, + format!("IIS host `{domain_key}` appears on multiple bindings."), + )), + None, + ); + } + if let Err(issue) = binding.backend_binding(backend_port_for_binding(binding), &domain_key) + { + return binding_to_view(binding, IisHandoffState::Unsupported, Some(issue), None); + } + if let Some(issue) = active_registration_conflict(&inner.registrations, &domain_key) { + binding_to_view(binding, IisHandoffState::Conflict, Some(issue), None) + } else { + binding_to_view(binding, IisHandoffState::Available, None, None) + } + }) + .collect::>(); + + bindings.extend( + handoffs + .iter() + .filter(|(binding_id, _)| !seen.contains(*binding_id)) + .map(|(_, restore)| handoff_binding_to_view(&restore.binding, None, restore)), + ); + bindings + } +} + +pub(super) fn handoff_binding_to_view( + binding: &IisBindingRecord, + issue: Option, + restore: &IisRestoreRecord, +) -> IisBinding { + let mut view = binding_to_view( + binding, + IisHandoffState::HandedOff, + issue, + Some(restore.binding.restore_summary()), + ); + view.domain_key = Some(restore.domain_key.clone()); + view +} + +pub(super) fn duplicate_iis_hosts(records: &[IisBindingRecord]) -> BTreeSet { + let mut counts = BTreeMap::::new(); + for binding in records { + if unsupported_binding_issue(binding).is_none() + && let Ok(domain_key) = route_host_for_binding(binding, None) + { + *counts.entry(domain_key).or_default() += 1; + } + } + counts + .into_iter() + .filter_map(|(host, count)| (count > 1).then_some(host)) + .collect() +} + +pub(super) fn iis_route_host_conflicts( + records: &[IisBindingRecord], + selected_binding_id: &str, + domain_key: &str, +) -> bool { + duplicate_iis_hosts(records).contains(domain_key) + || records.iter().any(|binding| { + binding.binding_id() != selected_binding_id + && unsupported_binding_issue(binding).is_none() + && route_host_for_binding(binding, None) + .is_ok_and(|host| host.eq_ignore_ascii_case(domain_key)) + }) +} + +pub(super) fn route_host_for_binding( + binding: &IisBindingRecord, + route_host: Option<&str>, +) -> std::result::Result { + let binding_host = binding.host_header.trim(); + let selected = if binding_host.is_empty() || binding_host == "*" { + route_host.unwrap_or_default() + } else { + binding_host + }; + let candidate = extract_host_candidate(selected); + let domain_key = canonicalize_domain(candidate); + if domain_key.is_empty() || domain_key == "*" { + return Err(IisIssue::new( + IisIssueKind::MissingRoute, + "Wildcard IIS bindings need a route host. In the TUI, enter the host with `/` before pressing Space.", + )); + } + if domain_key.contains('/') || domain_key.contains('\\') || domain_key.contains(' ') { + return Err(IisIssue::new( + IisIssueKind::UnsupportedBindingShape, + format!("IIS route host `{selected}` is not a valid DNS host."), + )); + } + Ok(domain_key) +} + +pub(super) fn extract_host_candidate(raw: &str) -> &str { + let without_scheme = raw + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(raw) + .trim(); + let without_path = without_scheme + .split(['/', '?', '#']) + .next() + .unwrap_or(without_scheme) + .trim(); + if let Some((host, port)) = without_path.rsplit_once(':') + && !host.is_empty() + && port.chars().all(|ch| ch.is_ascii_digit()) + { + return host; + } + without_path +} + +pub(super) fn backend_port_for_binding(binding: &IisBindingRecord) -> u16 { + let hash = binding.binding_id().bytes().fold(0_u32, |hash, byte| { + hash.wrapping_mul(33).wrapping_add(byte as u32) + }); + 41000 + (hash % 8000) as u16 +} + +pub(super) fn legacy_backend_binding(restore: &IisRestoreRecord) -> IisBindingRecord { + restore.backend_binding.clone().unwrap_or_else(|| { + restore.binding.backend_http_binding( + backend_port_for_binding(&restore.binding), + &restore.domain_key, + ) + }) +} + +pub(super) fn backend_dial(binding: &IisBindingRecord) -> String { + format!("127.0.0.1:{}", binding.port) +} + +pub(super) fn active_registration_conflict( + registrations: &BTreeMap, + domain_key: &str, +) -> Option { + let matches = registrations + .values() + .filter(|registration| registration.activation_state.is_enabled()) + .filter(|registration| { + registration + .registered_domains + .iter() + .filter(|domain| domain.activation_state.is_enabled()) + .any(|domain| domain.name.canonical.eq_ignore_ascii_case(domain_key)) + }) + .map(|registration| registration.registration_id.clone()) + .collect::>(); + (!matches.is_empty()).then(|| { + IisIssue::new( + IisIssueKind::Conflict, + format!("Cadder already has an active route for IIS host `{domain_key}`."), + ) + }) +} + +pub(super) fn caddy_front_door_needed( + registrations: &BTreeMap, + restoring_domain: &str, +) -> bool { + registrations + .values() + .filter(|registration| registration.activation_state.is_enabled()) + .flat_map(|registration| ®istration.registered_domains) + .any(|domain| { + domain.activation_state.is_enabled() + && !domain.name.canonical.eq_ignore_ascii_case(restoring_domain) + }) +} + +pub(super) fn enable_iis_steps() -> Vec { + vec![ + IisOperationStep::user("iis-discover-bindings", "Discover IIS bindings."), + IisOperationStep::user("iis-classify-binding", "Classify selected IIS binding."), + IisOperationStep::user( + "iis-write-restore-metadata", + "Write IIS restore metadata before mutation.", + ), + IisOperationStep::administrator( + "iis-create-loopback-binding", + "Create loopback IIS binding for Cadder proxying.", + ), + IisOperationStep::administrator( + "iis-remove-public-binding", + "Remove original public IIS binding.", + ), + IisOperationStep::user("caddy-apply-proxy-route", "Apply Caddy IIS proxy route."), + ] +} + +pub(super) fn disable_iis_steps() -> Vec { + vec![ + IisOperationStep::user("iis-read-restore-metadata", "Read IIS restore metadata."), + IisOperationStep::user("caddy-remove-proxy-route", "Remove Caddy IIS proxy route."), + IisOperationStep::administrator( + "iis-restore-public-binding", + "Restore original public IIS binding.", + ), + IisOperationStep::administrator( + "iis-remove-loopback-binding", + "Remove loopback IIS backend binding.", + ), + IisOperationStep::user("iis-clear-restore-metadata", "Clear IIS restore metadata."), + ] +} + +pub(super) fn mark_step_succeeded(steps: &mut [IisOperationStep], step_id: &str) { + if let Some(step) = steps.iter_mut().find(|step| step.step_id == step_id) { + step.status = IisOperationStepStatus::Succeeded; + step.approval = IisElevationApproval::NotRequired; + } +} + +pub(super) fn mark_step_issue(steps: &mut [IisOperationStep], step_id: &str, issue: &IisIssue) { + if let Some(step) = steps.iter_mut().find(|step| step.step_id == step_id) { + step.status = match issue.kind { + IisIssueKind::ElevationDenied => IisOperationStepStatus::Denied, + IisIssueKind::ElevationUnsupported | IisIssueKind::IisUnavailable => { + IisOperationStepStatus::Unsupported + } + _ => IisOperationStepStatus::Failed, + }; + step.issue = Some(issue.clone()); + } +} + +pub(super) fn mark_privileged_batch_approved(steps: &mut [IisOperationStep], step_ids: &[&str]) { + for step_id in step_ids { + if let Some(step) = steps.iter_mut().find(|step| step.step_id == *step_id) { + step.status = IisOperationStepStatus::Succeeded; + step.approval = IisElevationApproval::Approved; + } + } +} + +pub(super) fn mark_privileged_batch_issue( + steps: &mut [IisOperationStep], + step_ids: &[&str], + issue: &IisIssue, +) { + for step_id in step_ids { + if let Some(step) = steps.iter_mut().find(|step| step.step_id == *step_id) { + step.status = match issue.kind { + IisIssueKind::ElevationDenied => IisOperationStepStatus::Denied, + IisIssueKind::ElevationUnsupported | IisIssueKind::IisUnavailable => { + IisOperationStepStatus::Unsupported + } + _ => IisOperationStepStatus::Failed, + }; + step.approval = match issue.kind { + IisIssueKind::ElevationDenied => IisElevationApproval::Denied, + IisIssueKind::ElevationUnsupported | IisIssueKind::IisUnavailable => { + IisElevationApproval::Unsupported + } + _ => IisElevationApproval::Approved, + }; + step.issue = Some(issue.clone()); + } + } +} + +pub(super) fn mark_step_skipped(steps: &mut [IisOperationStep], step_id: &str) { + if let Some(step) = steps.iter_mut().find(|step| step.step_id == step_id) { + step.status = IisOperationStepStatus::Skipped; + } +} + +pub(super) fn follow_up_actions_for_issue(issue: &IisIssue) -> Vec { + match issue.kind { + IisIssueKind::ElevationDenied + | IisIssueKind::ElevationRequired + | IisIssueKind::InsufficientPrivileges => vec![IisFollowUpAction::RetryElevation], + IisIssueKind::ElevationUnsupported => Vec::new(), + IisIssueKind::RollbackFailed => vec![ + IisFollowUpAction::RollbackHandoff, + IisFollowUpAction::RetryElevation, + ], + IisIssueKind::RestoreFailed => vec![ + IisFollowUpAction::RetryRestore, + IisFollowUpAction::RetryElevation, + ], + IisIssueKind::ProviderError => vec![IisFollowUpAction::RetryElevation], + _ => Vec::new(), + } +} + +pub(super) fn iis_failure( + request_id: String, + issue: IisIssue, + binding: Option, + steps: Vec, +) -> SetIisHandoffResponse { + iis_failure_with_actions(request_id, issue, binding, steps, Vec::new()) +} + +pub(super) fn iis_failure_with_actions( + request_id: String, + issue: IisIssue, + binding: Option, + steps: Vec, + follow_up_actions: Vec, +) -> SetIisHandoffResponse { + iis_response_with_steps( + request_id, + false, + issue.message.clone(), + binding, + issue, + steps, + follow_up_actions, + ) +} + +pub(super) fn iis_response_with_steps( + request_id: String, + accepted: bool, + message: impl Into, + binding: Option, + issue: IisIssue, + steps: Vec, + follow_up_actions: Vec, +) -> SetIisHandoffResponse { + SetIisHandoffResponse { + request_id, + accepted, + message: message.into(), + binding, + issue: Some(issue), + steps, + follow_up_actions, + } +} diff --git a/crates/cadder-daemon/src/state/lifecycle.rs b/crates/cadder-daemon/src/state/lifecycle.rs new file mode 100644 index 0000000..d06860d --- /dev/null +++ b/crates/cadder-daemon/src/state/lifecycle.rs @@ -0,0 +1,74 @@ +use super::iis_handoff::{backend_dial, legacy_backend_binding}; +use super::*; + +impl DaemonState { + pub fn new(coordinator: CaddyConfigCoordinator) -> Self { + let (events, _) = broadcast::channel(256); + Self { + inner: Arc::new(Mutex::new(DaemonInner { + registrations: BTreeMap::new(), + sequence: 0, + })), + coordinator: Arc::new(Mutex::new(coordinator)), + config_operation: Arc::new(Mutex::new(())), + publish_operation: Arc::new(Mutex::new(())), + events, + logs: CaddyLogStore::default(), + store: RuntimeStore::memory(), + autostart: AutostartManager::disabled(), + iis_provider: IisProvider::system(), + iis_store: IisMetadataStore::memory(), + iis_operation: Arc::new(Mutex::new(())), + shutdown_signal: ShutdownSignal::default(), + } + } + + pub async fn with_runtime_paths( + mut coordinator: CaddyConfigCoordinator, + paths: RuntimePaths, + ) -> Result { + let iis_store = IisMetadataStore::load(paths.metadata_path()).await?; + let store = RuntimeStore::open(paths.storage_path()); + let handoffs = iis_store.snapshot().await; + for (binding_id, restore) in &handoffs { + let backend_binding = legacy_backend_binding(restore); + coordinator.set_iis_proxy_route( + binding_id.clone(), + restore.domain_key.clone(), + backend_dial(&backend_binding), + IisProxyBackendProtocol::from_iis_protocol(&backend_binding.protocol), + ); + } + let mut state = Self::new(coordinator); + state.store = store; + state.autostart = AutostartManager::new(&paths); + state.iis_store = iis_store; + if !handoffs.is_empty() { + let _operation = state.config_operation.lock().await; + state.apply_registrations(Vec::new()).await; + } + Ok(state) + } + + #[cfg(test)] + pub(super) fn with_iis_provider( + coordinator: CaddyConfigCoordinator, + iis_provider: IisProvider, + ) -> Self { + let mut state = Self::new(coordinator); + state.iis_provider = iis_provider; + state + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.events.subscribe() + } + + pub(crate) fn shutdown_signal(&self) -> ShutdownSignal { + self.shutdown_signal.clone() + } + + pub fn logs(&self) -> CaddyLogStore { + self.logs.clone() + } +} diff --git a/crates/cadder-daemon/src/state/log_queries.rs b/crates/cadder-daemon/src/state/log_queries.rs new file mode 100644 index 0000000..0f7dd30 --- /dev/null +++ b/crates/cadder-daemon/src/state/log_queries.rs @@ -0,0 +1,47 @@ +use super::*; + +impl DaemonState { + pub async fn query_logs(&self, request: cadder_protocol::QueryLogsRequest) -> QueryLogsResponse { + let active = self.stream_is_active(&request.stream).await; + let result = self.logs.query( + LogQuery { + stream: request.stream.clone(), + limit: request.limit.unwrap_or(100).clamp(1, 500), + after_sequence: request + .cursor + .as_deref() + .and_then(|cursor| cursor.strip_prefix("seq:")) + .and_then(|sequence| sequence.parse::().ok()), + minimum_severity: request.minimum_severity, + }, + active, + ); + + QueryLogsResponse { + request_id: request.request_id, + accepted: true, + message: "Caddy logs returned.".to_string(), + stream: request.stream, + stream_status: result.status, + entries: result.entries, + next_cursor: result.next_cursor, + has_gap: result.has_gap, + has_more_before: result.has_more_before, + truncated_by_retention: result.truncated_by_retention, + } + } + + async fn stream_is_active(&self, stream: &LogStreamIdentity) -> bool { + let inner = self.inner.lock().await; + if stream.stream_id == "runtime" || stream.stream_id == "runtime-control" { + return true; + } + inner.registrations.values().any(|registration| { + (registration.log_stream == *stream && registration.activation_state.is_enabled()) + || registration + .registered_domains + .iter() + .any(|domain| domain.log_stream == *stream && domain.activation_state.is_enabled()) + }) + } +} diff --git a/crates/cadder-daemon/src/state/registrations.rs b/crates/cadder-daemon/src/state/registrations.rs new file mode 100644 index 0000000..697a285 --- /dev/null +++ b/crates/cadder-daemon/src/state/registrations.rs @@ -0,0 +1,353 @@ +use super::*; + +impl DaemonState { + pub async fn register( + &self, + request_id: String, + registration: EntrypointRegistration, + ) -> RegisterEntrypointResponse { + if let Err(message) = registration.validate_owner() { + return RegisterEntrypointResponse { + request_id, + accepted: false, + message, + registration_id: None, + }; + } + + let _operation = self.config_operation.lock().await; + { + let inner = self.inner.lock().await; + if inner + .registrations + .get(®istration.registration_id) + .is_some_and(|existing| { + existing.entrypoint_instance.shim_session_nonce + != registration.entrypoint_instance.shim_session_nonce + }) + { + return RegisterEntrypointResponse { + request_id, + accepted: false, + message: "Entrypoint registration ID is already owned by another shim session." + .to_string(), + registration_id: None, + }; + } + } + + let source_path = registration.source_config_path.raw.clone(); + let adapter = { + let coordinator = self.coordinator.lock().await; + coordinator.adapter() + }; + let prepared = adapter.prepare(registration).await; + { + let inner = self.inner.lock().await; + if inner + .registrations + .get(&prepared.registration.registration_id) + .is_some_and(|existing| { + existing.entrypoint_instance.shim_session_nonce + != prepared.registration.entrypoint_instance.shim_session_nonce + }) + { + return RegisterEntrypointResponse { + request_id, + accepted: false, + message: "Entrypoint registration ID is already owned by another shim session." + .to_string(), + registration_id: None, + }; + } + } + let mut prepared = { + let mut coordinator = self.coordinator.lock().await; + coordinator.commit_prepared_registration(source_path, prepared) + }; + let now = Utc::now(); + prepared.created_at_utc = now; + prepared.last_heartbeat_utc = now; + let id = prepared.registration_id.clone(); + let registrations = { + let mut inner = self.inner.lock().await; + inner.registrations.insert(id.clone(), prepared); + inner.registrations.values().cloned().collect::>() + }; + self.apply_registrations(registrations.clone()).await; + self.store.record_history( + HistoryKind::Registration, + format!("Registered entrypoint `{id}`."), + Some(&id), + None, + &serde_json::json!({ + "registrationId": id, + "domainCount": registrations + .iter() + .find(|registration| registration.registration_id == id) + .map(|registration| registration.registered_domains.len()) + .unwrap_or_default() + }), + ); + self + .publish_change(StateChangeKind::RegistrationsChanged, Some(id.clone())) + .await; + + RegisterEntrypointResponse { + request_id, + accepted: true, + message: "Entrypoint registered.".to_string(), + registration_id: Some(id), + } + } + + pub async fn unregister( + &self, + request_id: String, + registration_id: &str, + shim_session_nonce: &str, + ) -> BasicResponse { + let _operation = self.config_operation.lock().await; + let (removed, removed_registration, registrations) = { + let mut inner = self.inner.lock().await; + let removed = inner + .registrations + .get(registration_id) + .is_some_and(|registration| { + registration.entrypoint_instance.shim_session_nonce == shim_session_nonce + }); + if !removed { + (false, None, Vec::new()) + } else { + let removed_registration = inner.registrations.remove(registration_id); + ( + true, + removed_registration, + inner.registrations.values().cloned().collect::>(), + ) + } + }; + if removed { + self.apply_registrations(registrations).await; + self.store.record_history( + HistoryKind::Registration, + format!("Unregistered entrypoint `{registration_id}`."), + Some(registration_id), + None, + &serde_json::json!({ + "registrationId": registration_id, + "domainCount": removed_registration + .as_ref() + .map(|registration| registration.registered_domains.len()) + .unwrap_or_default() + }), + ); + self + .publish_change( + StateChangeKind::RegistrationsChanged, + Some(registration_id.to_string()), + ) + .await; + } + + BasicResponse { + request_id, + accepted: removed, + message: if removed { + "Entrypoint unregistered." + } else { + "Entrypoint was not found for the requested owner." + } + .to_string(), + } + } + + pub(crate) async fn unregister_for_ipc_disconnect( + &self, + registration_id: &str, + shim_session_nonce: &str, + ) { + let response = self + .unregister( + "pipe-disconnect".to_string(), + registration_id, + shim_session_nonce, + ) + .await; + if !response.accepted { + self.logs.append( + LogStreamIdentity::runtime_control(), + LogSeverity::Warn, + response.message, + LogAttributionKind::RuntimeControl, + Some("ipc-disconnect-cleanup".to_string()), + ); + } + } + + pub async fn heartbeat(&self, request: HeartbeatEntrypointRequest) -> BasicResponse { + let registration_id = request.registration_id.clone(); + let accepted = { + let mut inner = self.inner.lock().await; + inner + .registrations + .get_mut(&request.registration_id) + .filter(|registration| { + registration.entrypoint_instance.shim_session_nonce == request.shim_session_nonce + }) + .map(|registration| { + registration.last_heartbeat_utc = Utc::now(); + }) + .is_some() + }; + if accepted { + self + .publish_change(StateChangeKind::RegistrationsChanged, Some(registration_id)) + .await; + } + + BasicResponse { + request_id: request.request_id, + accepted, + message: if accepted { + "Heartbeat accepted." + } else { + "Entrypoint was not found for the requested owner." + } + .to_string(), + } + } + + pub async fn set_entrypoint_enabled( + &self, + request: SetEntrypointEnabledRequest, + ) -> BasicResponse { + let _operation = self.config_operation.lock().await; + let (accepted, registrations) = { + let mut inner = self.inner.lock().await; + let accepted = inner + .registrations + .get_mut(&request.registration_id) + .filter(|registration| { + request + .shim_session_nonce + .as_ref() + .is_none_or(|nonce| registration.entrypoint_instance.shim_session_nonce == *nonce) + }) + .map(|registration| { + registration.activation_state = ActivationState::from_enabled(request.enabled); + }) + .is_some(); + ( + accepted, + inner.registrations.values().cloned().collect::>(), + ) + }; + + if accepted { + self.apply_registrations(registrations).await; + self.store.record_history( + HistoryKind::Registration, + format!( + "{} entrypoint `{}`.", + if request.enabled { + "Enabled" + } else { + "Disabled" + }, + request.registration_id + ), + Some(&request.registration_id), + None, + &serde_json::json!({ + "registrationId": request.registration_id, + "enabled": request.enabled + }), + ); + self + .publish_change( + StateChangeKind::RegistrationsChanged, + Some(request.registration_id), + ) + .await; + } + + BasicResponse { + request_id: request.request_id, + accepted, + message: if accepted { + "Entrypoint activation updated." + } else { + "Entrypoint was not found." + } + .to_string(), + } + } + + pub async fn set_domain_enabled(&self, request: SetDomainEnabledRequest) -> BasicResponse { + let _operation = self.config_operation.lock().await; + let (accepted, registrations) = { + let mut inner = self.inner.lock().await; + let accepted = inner + .registrations + .get_mut(&request.registration_id) + .and_then(|registration| { + registration.registered_domains.iter_mut().find(|domain| { + domain + .name + .canonical + .eq_ignore_ascii_case(&request.domain_key) + }) + }) + .map(|domain| { + domain.activation_state = ActivationState::from_enabled(request.enabled); + }) + .is_some(); + ( + accepted, + inner.registrations.values().cloned().collect::>(), + ) + }; + + if accepted { + self.apply_registrations(registrations).await; + self.store.record_history( + HistoryKind::Registration, + format!( + "{} domain `{}` on entrypoint `{}`.", + if request.enabled { + "Enabled" + } else { + "Disabled" + }, + request.domain_key, + request.registration_id + ), + Some(&request.registration_id), + Some(&request.domain_key), + &serde_json::json!({ + "registrationId": request.registration_id, + "domainKey": request.domain_key, + "enabled": request.enabled + }), + ); + self + .publish_change( + StateChangeKind::RegistrationsChanged, + Some(request.registration_id), + ) + .await; + } + + BasicResponse { + request_id: request.request_id, + accepted, + message: if accepted { + "Domain activation updated." + } else { + "Domain was not found." + } + .to_string(), + } + } +} diff --git a/crates/cadder-daemon/src/state/runtime_status.rs b/crates/cadder-daemon/src/state/runtime_status.rs new file mode 100644 index 0000000..eb9b883 --- /dev/null +++ b/crates/cadder-daemon/src/state/runtime_status.rs @@ -0,0 +1,61 @@ +use super::*; + +impl DaemonState { + pub async fn query_state(&self, request_id: String) -> QueryStateResponse { + QueryStateResponse { + request_id, + accepted: true, + message: "State snapshot returned.".to_string(), + snapshot: Some(self.snapshot().await), + } + } + + pub async fn snapshot(&self) -> GuiStateSnapshot { + let registrations = { + let inner = self.inner.lock().await; + inner.registrations.values().cloned().collect::>() + }; + self.snapshot_from_parts(registrations).await + } + + pub(super) async fn publish_change( + &self, + kind: StateChangeKind, + registration_id: Option, + ) { + let _publish = self.publish_operation.lock().await; + let (sequence, registrations) = { + let mut inner = self.inner.lock().await; + inner.sequence += 1; + ( + inner.sequence, + inner.registrations.values().cloned().collect::>(), + ) + }; + let event = StateChangedEvent { + request_id: "state-change".to_string(), + sequence_number: sequence, + change_kind: kind, + snapshot: self.snapshot_from_parts(registrations).await, + registration_id, + }; + let _ = self.events.send(event); + } + + async fn snapshot_from_parts( + &self, + registrations: Vec, + ) -> GuiStateSnapshot { + let (config, runtime) = { + let coordinator = self.coordinator.lock().await; + (coordinator.current_state(), coordinator.runtime()) + }; + GuiStateSnapshot { + captured_at_utc: Utc::now(), + registrations, + runtime: runtime.inspect().await, + config, + storage: Some(self.store.state()), + } + } +} diff --git a/crates/cadder-daemon/src/state/shutdown.rs b/crates/cadder-daemon/src/state/shutdown.rs new file mode 100644 index 0000000..d758baf --- /dev/null +++ b/crates/cadder-daemon/src/state/shutdown.rs @@ -0,0 +1,29 @@ +use super::*; + +impl DaemonState { + pub async fn shutdown(&self) -> BasicResponse { + let _operation = self.config_operation.lock().await; + let runtime = { + let coordinator = self.coordinator.lock().await; + coordinator.runtime() + }; + let result = runtime.stop().await; + if result.is_ok() { + self.store.record_history( + HistoryKind::Runtime, + "Daemon shutdown requested.", + None, + None, + &serde_json::json!({ "accepted": true }), + ); + self.shutdown_signal.request(); + } + BasicResponse { + request_id: "shutdown".to_string(), + accepted: result.is_ok(), + message: result + .map(|_| "Daemon shutdown requested.".to_string()) + .unwrap_or_else(|error| error.to_string()), + } + } +} diff --git a/crates/cadder-daemon/src/state/tests.rs b/crates/cadder-daemon/src/state/tests.rs new file mode 100644 index 0000000..9a13235 --- /dev/null +++ b/crates/cadder-daemon/src/state/tests.rs @@ -0,0 +1,2042 @@ +use super::iis_handoff::{ + active_registration_conflict, backend_dial, backend_port_for_binding, caddy_front_door_needed, + disable_iis_steps, duplicate_iis_hosts, enable_iis_steps, follow_up_actions_for_issue, + iis_failure, iis_route_host_conflicts, legacy_backend_binding, mark_privileged_batch_approved, + mark_privileged_batch_issue, mark_step_issue, mark_step_skipped, mark_step_succeeded, + route_host_for_binding, +}; +use super::*; +use crate::{ + CaddyConfigAdapter, CaddyConfigCoordinator, ProcessRuntime, RealCaddyResolver, RuntimePaths, +}; +use cadder_protocol::{ + AutostartMode, AutostartStatus, EntrypointInstanceIdentity, LogAttributionKind, LogSeverity, + LogStreamIdentity, LogStreamStatus, OwnerProcessIdentity, QueryLogsRequest, RegisteredDomain, + SourcePath, +}; +use chrono::Utc; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +struct StateFixture { + state: DaemonState, + _temp: tempfile::TempDir, +} + +impl std::ops::Deref for StateFixture { + type Target = DaemonState; + + fn deref(&self) -> &Self::Target { + &self.state + } +} + +impl std::ops::DerefMut for StateFixture { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.state + } +} + +fn state() -> StateFixture { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().to_path_buf())).unwrap(); + let resolver = RealCaddyResolver::new(Some("definitely-missing-caddy".to_string())); + let adapter = CaddyConfigAdapter::new(resolver.clone()); + let runtime = ProcessRuntime::new(resolver, paths); + StateFixture { + state: DaemonState::new(CaddyConfigCoordinator::new(adapter, runtime)), + _temp: temp, + } +} + +fn state_with_iis(provider: IisProvider) -> StateFixture { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().to_path_buf())).unwrap(); + let resolver = RealCaddyResolver::new(Some("definitely-missing-caddy".to_string())); + let adapter = CaddyConfigAdapter::new(resolver.clone()); + let runtime = ProcessRuntime::new(resolver, paths); + StateFixture { + state: DaemonState::with_iis_provider(CaddyConfigCoordinator::new(adapter, runtime), provider), + _temp: temp, + } +} + +fn state_with_fake_caddy(provider: IisProvider, caddy: &Path) -> StateFixture { + let (state, _) = state_with_fake_caddy_paths(provider, caddy); + state +} + +fn state_with_fake_caddy_paths( + provider: IisProvider, + caddy: &Path, +) -> (StateFixture, RuntimePaths) { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().to_path_buf())).unwrap(); + let resolver = RealCaddyResolver::new(Some(caddy.display().to_string())); + let adapter = CaddyConfigAdapter::new(resolver.clone()); + let runtime = ProcessRuntime::new(resolver, paths.clone()); + ( + StateFixture { + state: DaemonState::with_iis_provider( + CaddyConfigCoordinator::new(adapter, runtime), + provider, + ), + _temp: temp, + }, + paths, + ) +} + +fn iis_binding(site: &str, protocol: &str, binding: &str) -> IisBindingRecord { + IisBindingRecord::from_binding_information(site, protocol, binding).unwrap() +} + +fn tls_certificate() -> crate::iis::IisTlsCertificate { + crate::iis::IisTlsCertificate { + thumbprint: "aabbcc".to_string(), + store_name: "My".to_string(), + ssl_flags: Some(1), + } +} + +fn https_iis_binding(site: &str, binding: &str) -> IisBindingRecord { + let mut binding = iis_binding(site, "https", binding); + binding.tls_certificate = Some(tls_certificate()); + binding +} + +fn write_fake_caddy(path: &Path) { + #[cfg(windows)] + fs::write( + path, + r#"@echo off +if "%1"=="adapt" ( + echo {"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["app.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}} + exit /b 0 +) +if "%1"=="reload" exit /b 0 +if "%1"=="stop" exit /b 0 +if "%1"=="run" ( + ping -n 6 127.0.0.1 >nul + exit /b 0 +) +exit /b 1 +"#, + ) + .unwrap(); + + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + fs::write( + path, + r#"#!/usr/bin/env sh +case "$1" in + adapt) + printf '%s\n' '{"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["app.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}}' + exit 0 + ;; + reload|stop) + exit 0 + ;; + run) + sleep 5 + exit 0 + ;; +esac +exit 1 +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); + } +} + +fn write_slow_adapt_fake_caddy(path: &Path, marker: &Path) { + #[cfg(windows)] + fs::write( + path, + format!( + r#"@echo off +if "%1"=="adapt" ( + echo started>"{}" + ping -n 3 127.0.0.1 >nul + echo {{"apps":{{"http":{{"servers":{{"srv0":{{"routes":[{{"match":[{{"host":["app.localhost"]}}],"handle":[{{"handler":"static_response","body":"ok"}}],"terminal":true}}]}}}}}}}}}} + exit /b 0 +) +if "%1"=="reload" exit /b 0 +if "%1"=="stop" exit /b 0 +if "%1"=="run" ( + ping -n 6 127.0.0.1 >nul + exit /b 0 +) +exit /b 1 +"#, + marker.display() + ), + ) + .unwrap(); + + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + fs::write( + path, + format!( + r#"#!/usr/bin/env sh +if [ "$1" = "adapt" ]; then + printf '%s\n' started > '{}' + sleep 1 + printf '%s\n' '{{"apps":{{"http":{{"servers":{{"srv0":{{"routes":[{{"match":[{{"host":["app.localhost"]}}],"handle":[{{"handler":"static_response","body":"ok"}}],"terminal":true}}]}}}}}}}}}}' + exit 0 +fi +if [ "$1" = "reload" ] || [ "$1" = "stop" ]; then + exit 0 +fi +if [ "$1" = "run" ]; then + sleep 5 + exit 0 +fi +exit 1 +"#, + marker.display() + ), + ) + .unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); + } +} + +async fn wait_for_marker(path: &Path) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !path.exists() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); +} + +fn fake_caddy_path(dir: &Path) -> PathBuf { + #[cfg(windows)] + { + dir.join("fake-caddy.cmd") + } + #[cfg(not(windows))] + { + dir.join("fake-caddy") + } +} + +fn registration(id: &str, nonce: &str) -> EntrypointRegistration { + let now = Utc::now(); + EntrypointRegistration { + registration_id: id.to_string(), + entrypoint_instance: EntrypointInstanceIdentity { + instance_id: id.to_string(), + started_at_utc: now, + shim_session_nonce: nonce.to_string(), + }, + source_working_directory: SourcePath::new(".", None), + source_config_path: SourcePath::new("Caddyfile", None), + registered_domains: vec![RegisteredDomain::active("app.localhost")], + activation_state: ActivationState::Active, + owner_process: OwnerProcessIdentity { + process_id: 1, + process_start_time_utc: now, + shim_session_nonce: nonce.to_string(), + executable_path: None, + }, + log_stream: LogStreamIdentity::entrypoint(id), + shim_run: None, + created_at_utc: now, + last_heartbeat_utc: now, + } +} + +#[test] +fn iis_and_step_helpers_cover_edge_branches() { + let wildcard = iis_binding("Default Web Site", "http", "*:80:"); + let app = iis_binding("Default Web Site", "http", "*:80:app.localhost"); + let duplicate = iis_binding("Other Site", "http", "*:80:APP.localhost"); + assert_eq!( + route_host_for_binding(&wildcard, Some("https://IIS.Localhost:443/path")).unwrap(), + "iis.localhost" + ); + assert_eq!( + route_host_for_binding(&wildcard, None).unwrap_err().kind, + IisIssueKind::MissingRoute + ); + assert_eq!( + route_host_for_binding(&wildcard, Some("bad host")) + .unwrap_err() + .kind, + IisIssueKind::UnsupportedBindingShape + ); + assert!(duplicate_iis_hosts(&[app.clone(), duplicate.clone()]).contains("app.localhost")); + assert!(iis_route_host_conflicts( + &[app.clone(), duplicate], + &app.binding_id(), + "app.localhost" + )); + + let restore = IisRestoreRecord { + binding: wildcard.clone(), + domain_key: "iis.localhost".to_string(), + registration_id: None, + backend_binding: None, + }; + let backend = legacy_backend_binding(&restore); + assert_eq!( + backend_dial(&backend), + format!("127.0.0.1:{}", backend.port) + ); + assert!(backend_port_for_binding(&wildcard) >= 41000); + + let mut registrations = BTreeMap::new(); + registrations.insert("shim-1".to_string(), registration("shim-1", "nonce-1")); + assert!(active_registration_conflict(®istrations, "app.localhost").is_some()); + assert!(caddy_front_door_needed(®istrations, "iis.localhost")); + assert!(!caddy_front_door_needed(®istrations, "app.localhost")); + + let mut steps = enable_iis_steps(); + mark_step_succeeded(&mut steps, "iis-discover-bindings"); + mark_step_issue( + &mut steps, + "iis-classify-binding", + &IisIssue::new(IisIssueKind::IisUnavailable, "missing IIS"), + ); + mark_step_issue( + &mut steps, + "iis-write-restore-metadata", + &IisIssue::new(IisIssueKind::ElevationDenied, "denied"), + ); + mark_step_issue( + &mut steps, + "caddy-apply-proxy-route", + &IisIssue::new(IisIssueKind::ProviderError, "provider failed"), + ); + mark_privileged_batch_approved( + &mut steps, + &["iis-create-loopback-binding", "iis-remove-public-binding"], + ); + mark_privileged_batch_issue( + &mut steps, + &["iis-remove-public-binding"], + &IisIssue::new(IisIssueKind::ElevationDenied, "denied"), + ); + mark_privileged_batch_issue( + &mut steps, + &["iis-create-loopback-binding"], + &IisIssue::new(IisIssueKind::ElevationUnsupported, "unsupported"), + ); + mark_step_skipped(&mut steps, "caddy-apply-proxy-route"); + assert!(steps.iter().any(|step| { + step.step_id == "iis-classify-binding" && step.status == IisOperationStepStatus::Unsupported + })); + assert!(steps.iter().any(|step| { + step.step_id == "iis-write-restore-metadata" && step.status == IisOperationStepStatus::Denied + })); + assert!(steps.iter().any(|step| { + step.step_id == "iis-remove-public-binding" && step.approval == IisElevationApproval::Denied + })); + assert!(steps.iter().any(|step| { + step.step_id == "iis-create-loopback-binding" + && step.approval == IisElevationApproval::Unsupported + })); + assert_eq!( + follow_up_actions_for_issue(&IisIssue::new(IisIssueKind::ElevationDenied, "denied")), + vec![IisFollowUpAction::RetryElevation] + ); + assert_eq!( + follow_up_actions_for_issue(&IisIssue::new( + IisIssueKind::ElevationUnsupported, + "unsupported" + )), + Vec::::new() + ); + assert_eq!( + follow_up_actions_for_issue(&IisIssue::new(IisIssueKind::ProviderError, "provider")), + vec![IisFollowUpAction::RetryElevation] + ); + assert_eq!( + follow_up_actions_for_issue(&IisIssue::new(IisIssueKind::RollbackFailed, "rollback")), + vec![ + IisFollowUpAction::RollbackHandoff, + IisFollowUpAction::RetryElevation + ] + ); + assert_eq!( + follow_up_actions_for_issue(&IisIssue::new(IisIssueKind::Conflict, "conflict")), + Vec::::new() + ); + assert_eq!( + follow_up_actions_for_issue(&IisIssue::new(IisIssueKind::RestoreFailed, "restore")), + vec![ + IisFollowUpAction::RetryRestore, + IisFollowUpAction::RetryElevation + ] + ); + let response = iis_failure( + "iis".to_string(), + IisIssue::new(IisIssueKind::ProviderError, "failed"), + None, + Vec::new(), + ); + assert_eq!(response.message, "failed"); + assert!( + disable_iis_steps() + .iter() + .any(|step| step.step_id == "iis-clear-restore-metadata") + ); +} + +#[tokio::test] +async fn shutdown_signal_notifies_pending_and_pre_requested_waiters() { + let signal = ShutdownSignal::default(); + let waiter_signal = signal.clone(); + let waiter = tokio::spawn(async move { + waiter_signal.wait().await; + }); + tokio::task::yield_now().await; + + signal.request(); + + tokio::time::timeout(std::time::Duration::from_secs(1), waiter) + .await + .unwrap() + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), signal.wait()) + .await + .unwrap(); +} + +#[tokio::test] +async fn register_and_unregister_preserve_owner_boundary() { + let state = state(); + let response = state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + assert!(response.accepted); + + let wrong = state + .unregister("wrong".to_string(), "shim-1", "other") + .await; + assert!(!wrong.accepted); + assert_eq!(state.snapshot().await.registrations.len(), 1); + + let right = state + .unregister("right".to_string(), "shim-1", "nonce-1") + .await; + assert!(right.accepted); + assert!(state.snapshot().await.registrations.is_empty()); +} + +#[tokio::test] +async fn unregister_unknown_registration_returns_not_found() { + let state = state(); + + let response = state + .unregister("unregister".to_string(), "missing", "nonce-1") + .await; + + assert!(!response.accepted); + assert_eq!( + response.message, + "Entrypoint was not found for the requested owner." + ); +} + +#[tokio::test] +async fn ipc_disconnect_cleanup_logs_failed_unregister() { + let state = state(); + + state + .unregister_for_ipc_disconnect("missing", "nonce-1") + .await; + let logs = state + .query_logs(QueryLogsRequest { + request_id: "logs".to_string(), + stream: LogStreamIdentity::runtime_control(), + limit: Some(10), + cursor: None, + minimum_severity: None, + }) + .await; + + assert!(logs.entries.iter().any(|entry| { + entry.operation.as_deref() == Some("ipc-disconnect-cleanup") + && entry.severity == LogSeverity::Warn + && entry + .raw_message + .contains("Entrypoint was not found for the requested owner.") + })); +} + +#[tokio::test] +async fn disabled_domain_log_stream_is_reported_stale() { + let state = state(); + let response = state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + assert!(response.accepted); + let stream = LogStreamIdentity::domain("app.localhost"); + state.logs().append( + stream.clone(), + LogSeverity::Info, + "domain log", + LogAttributionKind::Domain, + None, + ); + + let toggle = state + .set_domain_enabled(SetDomainEnabledRequest { + request_id: "toggle".to_string(), + registration_id: "shim-1".to_string(), + domain_key: "app.localhost".to_string(), + enabled: false, + }) + .await; + assert!(toggle.accepted); + + let logs = state + .query_logs(QueryLogsRequest { + request_id: "logs".to_string(), + stream, + limit: Some(10), + cursor: None, + minimum_severity: None, + }) + .await; + + assert_eq!(logs.stream_status, LogStreamStatus::Stale); + assert_eq!(logs.entries.len(), 1); +} + +#[tokio::test] +async fn entrypoint_log_stream_tracks_activation_state() { + let state = state(); + state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + let stream = LogStreamIdentity::entrypoint("shim-1"); + state.logs().append( + stream.clone(), + LogSeverity::Info, + "entrypoint log", + LogAttributionKind::Entrypoint, + None, + ); + + let active = state + .query_logs(QueryLogsRequest { + request_id: "active-logs".to_string(), + stream: stream.clone(), + limit: Some(10), + cursor: None, + minimum_severity: None, + }) + .await; + let disabled = state + .set_entrypoint_enabled(SetEntrypointEnabledRequest { + request_id: "disable".to_string(), + registration_id: "shim-1".to_string(), + shim_session_nonce: Some("nonce-1".to_string()), + enabled: false, + }) + .await; + let stale = state + .query_logs(QueryLogsRequest { + request_id: "stale-logs".to_string(), + stream, + limit: Some(10), + cursor: None, + minimum_severity: None, + }) + .await; + + assert_eq!(active.stream_status, LogStreamStatus::Active); + assert!(disabled.accepted); + assert_eq!(stale.stream_status, LogStreamStatus::Stale); + assert_eq!(stale.entries.len(), 1); +} + +#[tokio::test] +async fn query_iis_bindings_reports_safety_states_from_fake_provider() { + let provider = IisProvider::fake(vec![ + iis_binding("Default Web Site", "http", "*:80:app.localhost"), + iis_binding("Default Web Site", "http", "127.0.0.1:80:app.localhost"), + https_iis_binding("Default Web Site", "*:443:secure.localhost"), + iis_binding("Other", "http", "*:80:missing.localhost"), + iis_binding("Default Web Site", "https", "*:443:"), + ]); + let state = state_with_iis(provider); + state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + + let response = state.query_iis_bindings("iis".to_string()).await; + + assert!(response.accepted); + assert_eq!(response.bindings.len(), 5); + assert_eq!( + response.bindings[0].handoff_state, + IisHandoffState::Conflict + ); + assert_eq!( + response.bindings[2].handoff_state, + IisHandoffState::Available + ); + assert_eq!( + response.bindings[3].handoff_state, + IisHandoffState::Available + ); + assert_eq!( + response.bindings[4].handoff_state, + IisHandoffState::MissingRoute + ); +} + +#[tokio::test] +async fn query_iis_bindings_reports_provider_discovery_failure() { + let provider = IisProvider::fake(Vec::new()); + provider + .set_fail_discovery(IisIssue::new( + IisIssueKind::IisUnavailable, + "IIS discovery failed.", + )) + .await; + let state = state_with_iis(provider); + + let response = state.query_iis_bindings("iis".to_string()).await; + + assert!(!response.accepted); + assert_eq!(response.message, "IIS discovery failed."); + assert!(response.bindings.is_empty()); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::IisUnavailable) + ); +} + +#[tokio::test] +async fn query_iis_bindings_includes_orphaned_restore_metadata_as_handed_off() { + let binding = iis_binding("Default Web Site", "http", "*:80:app.localhost"); + let restore = IisRestoreRecord { + binding: binding.clone(), + domain_key: "app.localhost".to_string(), + registration_id: None, + backend_binding: Some(binding.backend_binding(41080, "app.localhost").unwrap()), + }; + let state = state_with_iis(IisProvider::fake(Vec::new())); + state + .iis_store + .insert(binding.binding_id(), restore) + .await + .unwrap(); + + let response = state.query_iis_bindings("iis".to_string()).await; + + assert!(response.accepted); + assert_eq!(response.bindings.len(), 1); + assert_eq!( + response.bindings[0].handoff_state, + IisHandoffState::HandedOff + ); + assert_eq!( + response.bindings[0].domain_key.as_deref(), + Some("app.localhost") + ); +} + +#[tokio::test] +async fn iis_handoff_proxies_wildcard_https_binding_with_route_host() { + let temp = tempfile::tempdir().unwrap(); + let caddy = temp.path().join(if cfg!(windows) { + "fake-caddy.cmd" + } else { + "fake-caddy" + }); + write_fake_caddy(&caddy); + let binding = https_iis_binding("Default Web Site", "*:443:"); + let provider = IisProvider::fake(vec![binding.clone()]); + let (state, paths) = state_with_fake_caddy_paths(provider, &caddy); + let backend_port = backend_port_for_binding(&binding); + let expected_backend_dial = format!("127.0.0.1:{backend_port}"); + assert!((41000..49000).contains(&backend_port)); + + let enabled = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: Some("https://iis-app.localhost/legacy/status".to_string()), + }) + .await; + let handed_off = state.query_iis_bindings("iis-handed-off".to_string()).await; + let rendered = fs::read_to_string(paths.effective_config_path()).unwrap(); + let config: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + let routes = config + .pointer("/apps/http/servers/cadder_https/routes") + .and_then(serde_json::Value::as_array) + .unwrap(); + let iis_route = routes + .iter() + .find(|route| { + route + .pointer("/match/0/host/0") + .and_then(serde_json::Value::as_str) + == Some("iis-app.localhost") + }) + .unwrap(); + let _ = state.shutdown().await; + + assert!(enabled.accepted, "{enabled:?}"); + assert_eq!( + enabled + .binding + .as_ref() + .and_then(|binding| binding.domain_key.as_deref()), + Some("iis-app.localhost") + ); + assert_eq!(handed_off.bindings.len(), 1); + assert_eq!( + handed_off.bindings[0].identity.binding_id, + "Default Web Site|https|*:443:" + ); + assert_eq!( + handed_off.bindings[0].handoff_state, + IisHandoffState::HandedOff + ); + assert_eq!( + handed_off.bindings[0].domain_key.as_deref(), + Some("iis-app.localhost") + ); + assert_eq!( + iis_route + .pointer("/handle/0/upstreams/0/dial") + .and_then(serde_json::Value::as_str), + Some(expected_backend_dial.as_str()) + ); + assert_eq!( + iis_route + .pointer("/handle/0/transport/tls/server_name") + .and_then(serde_json::Value::as_str), + Some("iis-app.localhost") + ); + assert_eq!( + iis_route + .pointer("/handle/0/transport/tls/insecure_skip_verify") + .and_then(serde_json::Value::as_bool), + Some(true) + ); +} + +#[tokio::test] +async fn iis_handoff_rejects_https_binding_without_certificate_before_mutation() { + let binding = iis_binding("Default Web Site", "https", "*:443:secure.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + let state = state_with_iis(provider.clone()); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + let rediscovered = provider.discover().await.unwrap(); + + assert!(!response.accepted); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::MissingTlsCertificate) + ); + assert!(response.steps.iter().any(|step| { + step.step_id == "iis-classify-binding" && step.status == IisOperationStepStatus::Failed + })); + assert_eq!(rediscovered, vec![binding]); + assert!(state.iis_store.snapshot().await.is_empty()); +} + +#[tokio::test] +async fn iis_handoff_rejects_explicit_route_host_used_by_another_binding() { + let selected = iis_binding("Default Web Site", "https", "*:443:"); + let existing = iis_binding("Default Web Site", "https", "*:443:iis-app.localhost"); + let provider = IisProvider::fake(vec![selected.clone(), existing]); + let state = state_with_iis(provider); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: selected.binding_id(), + enabled: true, + route_host: Some("iis-app.localhost".to_string()), + }) + .await; + + assert!(!response.accepted); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::Conflict) + ); +} + +#[tokio::test] +async fn iis_handoff_rejects_invalid_explicit_route_host_before_mutation() { + let binding = iis_binding("Default Web Site", "http", "*:80:"); + let provider = IisProvider::fake(vec![binding.clone()]); + let state = state_with_iis(provider.clone()); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: Some("bad host".to_string()), + }) + .await; + let rediscovered = provider.discover().await.unwrap(); + + assert!(!response.accepted); + assert_eq!( + response + .binding + .as_ref() + .map(|binding| binding.handoff_state), + Some(IisHandoffState::MissingRoute) + ); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::UnsupportedBindingShape) + ); + assert!(response.steps.iter().any(|step| { + step.step_id == "iis-classify-binding" && step.status == IisOperationStepStatus::Failed + })); + assert_eq!(rediscovered, vec![binding]); +} + +#[tokio::test] +async fn iis_handoff_reports_metadata_write_failure_before_mutating_iis() { + let temp = tempfile::tempdir().unwrap(); + let blocked_parent = temp.path().join("metadata-parent"); + fs::write(&blocked_parent, "not a directory").unwrap(); + let binding = iis_binding("Default Web Site", "http", "*:80:iis.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + let mut state = state_with_iis(provider.clone()); + state.iis_store = IisMetadataStore::load(blocked_parent.join("iis.json")) + .await + .unwrap(); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + let rediscovered = provider.discover().await.unwrap(); + + assert!(!response.accepted); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::ProviderError) + ); + assert!(response.message.contains("restore metadata")); + assert!(response.steps.iter().any(|step| { + step.step_id == "iis-write-restore-metadata" && step.status == IisOperationStepStatus::Failed + })); + assert_eq!(rediscovered, vec![binding]); +} + +#[tokio::test] +async fn set_autostart_reports_unsupported_missing_targets_and_records_history() { + let state = state(); + + let response = state + .set_autostart(SetAutostartRequest { + request_id: "autostart-set".to_string(), + mode: AutostartMode::Daemon, + }) + .await; + let history = state + .query_history(cadder_protocol::QueryHistoryRequest { + request_id: "history".to_string(), + kind: Some(HistoryKind::Autostart), + limit: Some(10), + }) + .await; + + assert!(!response.accepted); + assert_eq!(response.status, AutostartStatus::Unsupported); + assert!(response.target.is_none()); + assert!(response.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "autostart-update-failed" + && diagnostic.message.contains("cadderd executable") + })); + assert_eq!(history.records.len(), 1); + assert_eq!(history.records[0].kind, HistoryKind::Autostart); +} + +#[cfg(not(all(unix, not(target_os = "macos"))))] +#[tokio::test] +async fn query_autostart_reports_disabled_manager_status() { + let state = state(); + + let response = state.query_autostart("autostart".to_string()).await; + + assert_eq!(response.request_id, "autostart"); + assert!(response.accepted); + assert_eq!(response.status, AutostartStatus::Disabled); + assert_eq!(response.mode, AutostartMode::Disabled); + assert!(response.target.is_none()); +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[tokio::test] +async fn query_autostart_reports_unavailable_linux_config_dir() { + let state = state(); + + let response = state.query_autostart("autostart".to_string()).await; + + assert_eq!(response.request_id, "autostart"); + assert!(!response.accepted); + assert_eq!(response.status, AutostartStatus::Unsupported); + assert_eq!(response.mode, AutostartMode::Disabled); + assert!(response.target.is_none()); + assert_eq!( + response + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code.as_str()) + .collect::>(), + vec!["autostart-config-dir-unavailable"] + ); +} + +#[tokio::test] +async fn iis_handoff_rejects_missing_binding_before_steps() { + let state = state_with_iis(IisProvider::fake(Vec::new())); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: "missing|http|*:80:app.localhost".to_string(), + enabled: true, + route_host: None, + }) + .await; + + assert!(!response.accepted); + assert!(response.binding.is_none()); + assert!(response.steps.is_empty()); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::MissingBinding) + ); +} + +#[tokio::test] +async fn iis_handoff_reports_provider_discovery_failure() { + let provider = IisProvider::fake(Vec::new()); + provider + .set_fail_discovery(IisIssue::new( + IisIssueKind::IisUnavailable, + "IIS discovery failed.", + )) + .await; + let state = state_with_iis(provider); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: "Default Web Site|http|*:80:app.localhost".to_string(), + enabled: true, + route_host: None, + }) + .await; + + assert!(!response.accepted); + assert!(response.binding.is_none()); + assert!(response.steps.is_empty()); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::IisUnavailable) + ); +} + +#[tokio::test] +async fn iis_handoff_rejects_unsupported_binding_with_failed_classification_step() { + let binding = iis_binding("Default Web Site", "ftp", "*:21:files.localhost"); + let state = state_with_iis(IisProvider::fake(vec![binding.clone()])); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + + assert!(!response.accepted); + assert_eq!( + response + .binding + .as_ref() + .map(|binding| binding.handoff_state), + Some(IisHandoffState::Unsupported) + ); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::UnsupportedBindingShape) + ); + assert!(response.steps.iter().any(|step| { + step.step_id == "iis-classify-binding" && step.status == IisOperationStepStatus::Failed + })); +} + +#[tokio::test] +async fn iis_handoff_rejects_wildcard_binding_without_route_host() { + let binding = iis_binding("Default Web Site", "http", "*:80:"); + let state = state_with_iis(IisProvider::fake(vec![binding.clone()])); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + + assert!(!response.accepted); + assert_eq!( + response + .binding + .as_ref() + .map(|binding| binding.handoff_state), + Some(IisHandoffState::MissingRoute) + ); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::MissingRoute) + ); +} + +#[tokio::test] +async fn iis_handoff_rejects_binding_when_active_registration_owns_route() { + let temp = tempfile::tempdir().unwrap(); + let fake_caddy = fake_caddy_path(temp.path()); + write_fake_caddy(&fake_caddy); + let binding = iis_binding("Default Web Site", "http", "*:80:app.localhost"); + let state = state_with_fake_caddy(IisProvider::fake(vec![binding.clone()]), &fake_caddy); + let registered = state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + assert!(registered.accepted, "{registered:?}"); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + let _ = state.shutdown().await; + + assert!(!response.accepted); + assert_eq!( + response + .binding + .as_ref() + .map(|binding| binding.handoff_state), + Some(IisHandoffState::Conflict) + ); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::Conflict) + ); + assert!(response.message.contains("already has an active route")); +} + +#[tokio::test] +async fn iis_restore_rejects_missing_restore_metadata() { + let state = state_with_iis(IisProvider::fake(Vec::new())); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-off".to_string(), + binding_id: "missing|http|*:80:app.localhost".to_string(), + enabled: false, + route_host: None, + }) + .await; + + assert!(!response.accepted); + assert!(response.binding.is_none()); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::MissingBinding) + ); + assert!(response.steps.iter().any(|step| { + step.step_id == "iis-read-restore-metadata" && step.status == IisOperationStepStatus::Failed + })); +} + +#[tokio::test] +async fn runtime_paths_hydrate_iis_proxy_routes_from_metadata() { + let temp = tempfile::tempdir().unwrap(); + let caddy = temp.path().join(if cfg!(windows) { + "fake-caddy.cmd" + } else { + "fake-caddy" + }); + write_fake_caddy(&caddy); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + paths.ensure_dirs().unwrap(); + let binding = https_iis_binding("Default Web Site", "*:443:"); + let domain_key = "iis-app.localhost".to_string(); + let backend_binding = binding + .backend_binding(backend_port_for_binding(&binding), &domain_key) + .unwrap(); + let store = IisMetadataStore::load(paths.metadata_path()).await.unwrap(); + store + .insert( + binding.binding_id(), + IisRestoreRecord { + binding: binding.clone(), + domain_key: domain_key.clone(), + registration_id: None, + backend_binding: Some(backend_binding.clone()), + }, + ) + .await + .unwrap(); + let resolver = RealCaddyResolver::new(Some(caddy.display().to_string())); + let adapter = CaddyConfigAdapter::new(resolver.clone()); + let runtime = ProcessRuntime::new(resolver, paths.clone()); + let state = + DaemonState::with_runtime_paths(CaddyConfigCoordinator::new(adapter, runtime), paths.clone()) + .await + .unwrap(); + + let rendered = fs::read_to_string(paths.effective_config_path()).unwrap(); + let _ = state.shutdown().await; + + assert!(rendered.contains("iis-app.localhost")); + assert!(rendered.contains(&format!("127.0.0.1:{}", backend_binding.port))); + assert!(rendered.contains("\"server_name\": \"iis-app.localhost\"")); +} + +#[tokio::test] +async fn iis_handoff_rolls_back_when_caddy_apply_fails() { + let binding = iis_binding("Default Web Site", "http", "*:80:iis.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + let state = state_with_iis(provider); + state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + let rediscovered = state.query_iis_bindings("iis".to_string()).await; + + assert!(!response.accepted); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::RollbackSucceeded) + ); + assert_eq!(rediscovered.bindings.len(), 1); + assert_eq!( + rediscovered.bindings[0].handoff_state, + IisHandoffState::Available + ); + assert!(state.iis_store.snapshot().await.is_empty()); +} + +#[tokio::test] +async fn iis_handoff_keeps_restore_metadata_when_caddy_apply_and_rollback_fail() { + let binding = iis_binding("Default Web Site", "http", "*:80:iis.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + provider + .set_fail_restore(IisIssue::new(IisIssueKind::ProviderError, "restore denied")) + .await; + let state = state_with_iis(provider); + state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + let rediscovered = state.query_iis_bindings("iis".to_string()).await; + let handoffs = state.iis_store.snapshot().await; + + assert!(!response.accepted); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::RollbackFailed) + ); + assert!(handoffs.contains_key(&binding.binding_id())); + assert_eq!(rediscovered.bindings.len(), 1); + assert_eq!( + rediscovered.bindings[0].handoff_state, + IisHandoffState::HandedOff + ); + assert_eq!( + rediscovered.bindings[0].domain_key.as_deref(), + Some("iis.localhost") + ); +} + +#[tokio::test] +async fn iis_handoff_reports_busy_when_another_operation_is_running() { + let binding = iis_binding("Default Web Site", "http", "*:80:iis.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + let state = state_with_iis(provider); + let _operation = state.iis_operation.try_lock().unwrap(); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + + assert!(!response.accepted); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::Busy) + ); +} + +#[tokio::test] +async fn iis_handoff_success_and_restore_use_fake_provider() { + let temp = tempfile::tempdir().unwrap(); + let caddy = temp.path().join(if cfg!(windows) { + "fake-caddy.cmd" + } else { + "fake-caddy" + }); + write_fake_caddy(&caddy); + let binding = iis_binding("Default Web Site", "http", "*:80:app.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + let state = state_with_fake_caddy(provider, &caddy); + state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + state + .set_domain_enabled(SetDomainEnabledRequest { + request_id: "disable".to_string(), + registration_id: "shim-1".to_string(), + domain_key: "app.localhost".to_string(), + enabled: false, + }) + .await; + + let enabled = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + let handed_off = state.query_iis_bindings("iis-handed-off".to_string()).await; + let restored = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-off".to_string(), + binding_id: binding.binding_id(), + enabled: false, + route_host: None, + }) + .await; + let rediscovered = state.query_iis_bindings("iis".to_string()).await; + let _ = state.shutdown().await; + + assert!(enabled.accepted, "{enabled:?}"); + assert_eq!( + enabled + .binding + .as_ref() + .map(|binding| binding.handoff_state), + Some(IisHandoffState::HandedOff) + ); + assert!(enabled.steps.iter().any(|step| { + step.step_id == "iis-remove-public-binding" + && step.approval == IisElevationApproval::Approved + && step.status == IisOperationStepStatus::Succeeded + })); + assert_eq!(handed_off.bindings.len(), 1); + assert_eq!( + handed_off.bindings[0].handoff_state, + IisHandoffState::HandedOff + ); + assert!(restored.accepted, "{restored:?}"); + assert_eq!(rediscovered.bindings.len(), 1); + assert_eq!( + rediscovered.bindings[0].handoff_state, + IisHandoffState::Available + ); +} + +#[tokio::test] +async fn iis_https_handoff_success_and_restore_use_https_backend_binding() { + let temp = tempfile::tempdir().unwrap(); + let caddy = temp.path().join(if cfg!(windows) { + "fake-caddy.cmd" + } else { + "fake-caddy" + }); + write_fake_caddy(&caddy); + let binding = https_iis_binding("Default Web Site", "*:443:secure.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + let state = state_with_fake_caddy(provider.clone(), &caddy); + + let enabled = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + let after_enable = provider.discover().await.unwrap(); + let restored = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-off".to_string(), + binding_id: binding.binding_id(), + enabled: false, + route_host: None, + }) + .await; + let after_restore = provider.discover().await.unwrap(); + let _ = state.shutdown().await; + + assert!(enabled.accepted, "{enabled:?}"); + assert_eq!(after_enable.len(), 1); + assert_eq!(after_enable[0].protocol, "https"); + assert_eq!(after_enable[0].ip_address, "127.0.0.1"); + assert_eq!(after_enable[0].host_header, "secure.localhost"); + assert_eq!(after_enable[0].tls_certificate, Some(tls_certificate())); + assert!(restored.accepted, "{restored:?}"); + assert_eq!(after_restore, vec![binding]); +} + +#[tokio::test] +async fn iis_handoff_denied_by_elevation_keeps_user_level_state_usable() { + let binding = iis_binding("Default Web Site", "http", "*:80:app.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + provider + .set_elevation_issue(IisIssue::new( + IisIssueKind::ElevationDenied, + "Administrator approval was denied.", + )) + .await; + let state = state_with_iis(provider); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + let rediscovered = state.query_iis_bindings("iis".to_string()).await; + + assert!(!response.accepted); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::ElevationDenied) + ); + assert!(response.steps.iter().any(|step| { + step.step_id == "iis-remove-public-binding" + && step.approval == IisElevationApproval::Denied + && step.status == IisOperationStepStatus::Denied + })); + assert_eq!( + response.follow_up_actions, + vec![IisFollowUpAction::RetryElevation] + ); + assert!(state.iis_store.snapshot().await.is_empty()); + assert_eq!(rediscovered.bindings.len(), 1); + assert_eq!( + rediscovered.bindings[0].handoff_state, + IisHandoffState::Available + ); +} + +#[tokio::test] +async fn iis_handoff_reports_unsupported_elevation_without_mutating_iis() { + let binding = https_iis_binding("Default Web Site", "*:443:secure.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + provider + .set_elevation_issue(IisIssue::new( + IisIssueKind::ElevationUnsupported, + "IIS elevation prompts are only available on Windows.", + )) + .await; + let state = state_with_iis(provider); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + let rediscovered = state.query_iis_bindings("iis".to_string()).await; + + assert!(!response.accepted); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::ElevationUnsupported) + ); + assert!(response.follow_up_actions.is_empty()); + assert!(response.steps.iter().any(|step| { + step.step_id == "iis-create-loopback-binding" + && step.approval == IisElevationApproval::Unsupported + && step.status == IisOperationStepStatus::Unsupported + })); + assert!(state.iis_store.snapshot().await.is_empty()); + assert_eq!(rediscovered.bindings.len(), 1); + assert_eq!( + rediscovered.bindings[0].handoff_state, + IisHandoffState::Available + ); +} + +#[tokio::test] +async fn iis_handoff_provider_error_requests_loopback_cleanup_follow_up() { + let binding = iis_binding("Default Web Site", "http", "*:80:app.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + provider + .set_elevation_issue(IisIssue::new( + IisIssueKind::ProviderError, + "privileged mutation failed", + )) + .await; + let state = state_with_iis(provider); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + let handoffs = state.iis_store.snapshot().await; + + assert!(!response.accepted); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::ProviderError) + ); + assert!(response.steps.iter().any(|step| { + step.step_id == "iis-create-loopback-binding" + && step.approval == IisElevationApproval::Approved + && step.status == IisOperationStepStatus::Failed + })); + assert_eq!( + response.follow_up_actions, + vec![ + IisFollowUpAction::RetryElevation, + IisFollowUpAction::RemoveLoopbackBinding + ] + ); + assert!(handoffs.is_empty()); +} + +#[tokio::test] +async fn iis_handoff_partial_privileged_batch_exposes_loopback_cleanup_follow_up() { + let binding = iis_binding("Default Web Site", "http", "*:80:app.localhost"); + let backend_binding = + binding.backend_http_binding(backend_port_for_binding(&binding), "app.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + provider + .set_fail_remove(IisIssue::new( + IisIssueKind::ProviderError, + "public binding removal failed", + )) + .await; + let state = state_with_iis(provider.clone()); + + let response = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + let discovered = provider.discover().await.unwrap(); + let handoffs = state.iis_store.snapshot().await; + + assert!(!response.accepted); + assert_eq!( + response.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::ProviderError) + ); + assert!(response.steps.iter().any(|step| { + step.step_id == "iis-create-loopback-binding" + && step.approval == IisElevationApproval::Approved + && step.status == IisOperationStepStatus::Failed + })); + assert!(response.steps.iter().any(|step| { + step.step_id == "iis-remove-public-binding" + && step.approval == IisElevationApproval::Approved + && step.status == IisOperationStepStatus::Failed + })); + assert_eq!( + response.follow_up_actions, + vec![ + IisFollowUpAction::RetryElevation, + IisFollowUpAction::RemoveLoopbackBinding + ] + ); + assert!(handoffs.is_empty()); + assert!( + discovered + .iter() + .any(|candidate| candidate.binding_id() == binding.binding_id()) + ); + assert!( + discovered + .iter() + .any(|candidate| { candidate.binding_id() == backend_binding.binding_id() }) + ); +} + +#[tokio::test] +async fn iis_restore_rejects_when_other_cadder_routes_need_front_door() { + let temp = tempfile::tempdir().unwrap(); + let caddy = fake_caddy_path(temp.path()); + write_fake_caddy(&caddy); + let binding = iis_binding("Default Web Site", "http", "*:80:iis.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + let state = state_with_fake_caddy(provider, &caddy); + let registered = state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + assert!(registered.accepted, "{registered:?}"); + + let enabled = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + let restored = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-off".to_string(), + binding_id: binding.binding_id(), + enabled: false, + route_host: None, + }) + .await; + let _ = state.shutdown().await; + + assert!(enabled.accepted, "{enabled:?}"); + assert!(!restored.accepted); + assert_eq!( + restored.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::Conflict) + ); + assert!( + restored + .message + .contains("other Cadder routes still need the front-door port") + ); + assert!(restored.steps.iter().any(|step| { + step.step_id == "caddy-remove-proxy-route" && step.status == IisOperationStepStatus::Failed + })); +} + +#[tokio::test] +async fn iis_restore_keeps_metadata_when_backend_cleanup_fails() { + let temp = tempfile::tempdir().unwrap(); + let caddy = temp.path().join(if cfg!(windows) { + "fake-caddy.cmd" + } else { + "fake-caddy" + }); + write_fake_caddy(&caddy); + let binding = iis_binding("Default Web Site", "http", "*:80:app.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + let state = state_with_fake_caddy(provider.clone(), &caddy); + state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + state + .set_domain_enabled(SetDomainEnabledRequest { + request_id: "disable".to_string(), + registration_id: "shim-1".to_string(), + domain_key: "app.localhost".to_string(), + enabled: false, + }) + .await; + + let enabled = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + provider + .set_fail_remove(IisIssue::new(IisIssueKind::ProviderError, "cleanup failed")) + .await; + let restored = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-off".to_string(), + binding_id: binding.binding_id(), + enabled: false, + route_host: None, + }) + .await; + let handoffs = state.iis_store.snapshot().await; + let _ = state.shutdown().await; + + assert!(enabled.accepted, "{enabled:?}"); + assert!(!restored.accepted); + assert_eq!( + restored.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::RestoreFailed) + ); + assert!(handoffs.contains_key(&binding.binding_id())); +} + +#[tokio::test] +async fn iis_restore_reports_metadata_clear_failure_after_iis_restore() { + let temp = tempfile::tempdir().unwrap(); + let caddy = fake_caddy_path(temp.path()); + write_fake_caddy(&caddy); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + paths.ensure_dirs().unwrap(); + let resolver = RealCaddyResolver::new(Some(caddy.display().to_string())); + let adapter = CaddyConfigAdapter::new(resolver.clone()); + let runtime = ProcessRuntime::new(resolver, paths.clone()); + let binding = iis_binding("Default Web Site", "http", "*:80:app.localhost"); + let provider = IisProvider::fake(vec![binding.clone()]); + let mut state = + DaemonState::with_runtime_paths(CaddyConfigCoordinator::new(adapter, runtime), paths.clone()) + .await + .unwrap(); + state.iis_provider = provider; + state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + state + .set_domain_enabled(SetDomainEnabledRequest { + request_id: "disable".to_string(), + registration_id: "shim-1".to_string(), + domain_key: "app.localhost".to_string(), + enabled: false, + }) + .await; + let enabled = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-on".to_string(), + binding_id: binding.binding_id(), + enabled: true, + route_host: None, + }) + .await; + assert!(enabled.accepted, "{enabled:?}"); + let metadata_path = paths.metadata_path(); + let original_permissions = fs::metadata(&metadata_path).unwrap().permissions(); + let mut readonly_permissions = original_permissions.clone(); + readonly_permissions.set_readonly(true); + fs::set_permissions(&metadata_path, readonly_permissions).unwrap(); + + let restored = state + .set_iis_handoff(SetIisHandoffRequest { + request_id: "iis-off".to_string(), + binding_id: binding.binding_id(), + enabled: false, + route_host: None, + }) + .await; + let _ = fs::set_permissions(&metadata_path, original_permissions); + let _ = state.shutdown().await; + + assert!(!restored.accepted); + assert_eq!( + restored.issue.as_ref().map(|issue| issue.kind), + Some(IisIssueKind::ProviderError) + ); + assert_eq!( + restored.follow_up_actions, + vec![IisFollowUpAction::ClearRestoreMetadata] + ); + assert!(restored.steps.iter().any(|step| { + step.step_id == "iis-clear-restore-metadata" && step.status == IisOperationStepStatus::Failed + })); +} + +#[tokio::test] +async fn register_rejects_invalid_owner_identity() { + let state = state(); + let mut registration = registration("shim-1", "nonce-1"); + registration.owner_process.shim_session_nonce = "different".to_string(); + + let response = state.register("register".to_string(), registration).await; + + assert!(!response.accepted); + assert!(response.message.contains("nonce values must match")); + assert!(state.snapshot().await.registrations.is_empty()); +} + +#[tokio::test] +async fn register_rejects_existing_registration_id_owned_by_different_nonce() { + let state = state(); + let first = state + .register("first".to_string(), registration("shim-1", "nonce-1")) + .await; + assert!(first.accepted, "{first:?}"); + + let second = state + .register("second".to_string(), registration("shim-1", "nonce-2")) + .await; + let snapshot = state.snapshot().await; + + assert!(!second.accepted); + assert!(second.message.contains("already owned")); + assert_eq!(snapshot.registrations.len(), 1); + assert_eq!( + snapshot.registrations[0] + .entrypoint_instance + .shim_session_nonce, + "nonce-1" + ); +} + +#[tokio::test] +async fn subscribe_receives_registration_change_event() { + let state = state(); + let mut events = state.subscribe(); + + let response = state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + let event = events.recv().await.unwrap(); + + assert!(response.accepted); + assert_eq!(event.sequence_number, 1); + assert_eq!(event.change_kind, StateChangeKind::RegistrationsChanged); + assert_eq!(event.registration_id.as_deref(), Some("shim-1")); + assert_eq!(event.snapshot.registrations.len(), 1); +} + +#[tokio::test] +async fn heartbeat_accepts_owner_and_rejects_wrong_nonce() { + let state = state(); + state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + + let accepted = state + .heartbeat(HeartbeatEntrypointRequest { + request_id: "heartbeat".to_string(), + registration_id: "shim-1".to_string(), + shim_session_nonce: "nonce-1".to_string(), + }) + .await; + let rejected = state + .heartbeat(HeartbeatEntrypointRequest { + request_id: "heartbeat".to_string(), + registration_id: "shim-1".to_string(), + shim_session_nonce: "wrong".to_string(), + }) + .await; + + assert!(accepted.accepted); + assert_eq!(accepted.message, "Heartbeat accepted."); + assert!(!rejected.accepted); + assert_eq!( + rejected.message, + "Entrypoint was not found for the requested owner." + ); +} + +#[tokio::test] +async fn set_entrypoint_enabled_accepts_optional_owner_and_rejects_wrong_owner() { + let state = state(); + state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + + let accepted = state + .set_entrypoint_enabled(SetEntrypointEnabledRequest { + request_id: "disable".to_string(), + registration_id: "shim-1".to_string(), + shim_session_nonce: None, + enabled: false, + }) + .await; + let rejected = state + .set_entrypoint_enabled(SetEntrypointEnabledRequest { + request_id: "enable".to_string(), + registration_id: "shim-1".to_string(), + shim_session_nonce: Some("wrong".to_string()), + enabled: true, + }) + .await; + let snapshot = state.snapshot().await; + + assert!(accepted.accepted); + assert_eq!(accepted.message, "Entrypoint activation updated."); + assert!(!rejected.accepted); + assert_eq!( + snapshot.registrations[0].activation_state, + ActivationState::Inactive + ); +} + +#[tokio::test] +async fn set_domain_enabled_rejects_unknown_domain() { + let state = state(); + state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + + let response = state + .set_domain_enabled(SetDomainEnabledRequest { + request_id: "toggle".to_string(), + registration_id: "shim-1".to_string(), + domain_key: "missing.localhost".to_string(), + enabled: false, + }) + .await; + + assert!(!response.accepted); + assert_eq!(response.message, "Domain was not found."); +} + +#[tokio::test] +async fn query_state_returns_snapshot_with_request_id() { + let state = state(); + state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + + let response = state.query_state("state".to_string()).await; + + assert!(response.accepted); + assert_eq!(response.request_id, "state"); + assert_eq!(response.message, "State snapshot returned."); + assert_eq!(response.snapshot.unwrap().registrations.len(), 1); +} + +#[tokio::test] +async fn query_state_does_not_wait_for_stalled_history_worker() { + let mut state = state(); + let (store, release) = RuntimeStore::memory_stalled_for_test(1); + state.store = store; + state.store.record_history( + HistoryKind::Runtime, + "Queued history event.", + None, + None, + &serde_json::json!({ "queued": true }), + ); + + let response = tokio::time::timeout( + std::time::Duration::from_millis(100), + state.query_state("state".to_string()), + ) + .await + .unwrap(); + + assert!(response.accepted); + assert_eq!( + response + .snapshot + .and_then(|snapshot| snapshot.storage) + .map(|storage| storage.backend), + Some("sqlite".to_string()) + ); + release.send(()).unwrap(); +} + +#[tokio::test] +async fn query_history_returns_persisted_registration_events() { + let state = state(); + state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await; + + let response = state + .query_history(cadder_protocol::QueryHistoryRequest { + request_id: "history".to_string(), + kind: Some(HistoryKind::Registration), + limit: Some(10), + }) + .await; + + assert!(response.accepted); + assert_eq!(response.request_id, "history"); + assert_eq!(response.records.len(), 1); + assert_eq!( + response.records[0].registration_id.as_deref(), + Some("shim-1") + ); + assert_eq!( + response + .storage + .as_ref() + .map(|state| state.backend.as_str()), + Some("sqlite") + ); +} + +#[tokio::test] +async fn query_history_reports_storage_diagnostics_when_worker_queue_is_full() { + let mut state = state(); + let (store, release) = RuntimeStore::memory_stalled_for_test(1); + state.store = store; + state.store.record_history( + HistoryKind::Runtime, + "Queued history event.", + None, + None, + &serde_json::json!({ "queued": true }), + ); + + let response = tokio::time::timeout( + std::time::Duration::from_millis(100), + state.query_history(cadder_protocol::QueryHistoryRequest { + request_id: "history".to_string(), + kind: None, + limit: Some(10), + }), + ) + .await + .unwrap(); + let diagnostics = response.storage.unwrap().diagnostics; + + assert!(response.accepted); + assert!(response.records.is_empty()); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "storage-history-query-queue-full"), + "{diagnostics:?}" + ); + release.send(()).unwrap(); +} + +#[tokio::test] +async fn register_revalidates_existing_owner_after_slow_adapt() { + let temp = tempfile::tempdir().unwrap(); + let fake_caddy = fake_caddy_path(temp.path()); + let marker = temp.path().join("adapt-started"); + write_slow_adapt_fake_caddy(&fake_caddy, &marker); + let state = state_with_fake_caddy(IisProvider::fake(Vec::new()), &fake_caddy); + let registering_state = state.clone(); + + let pending = tokio::spawn(async move { + registering_state + .register("register".to_string(), registration("shim-1", "nonce-1")) + .await + }); + wait_for_marker(&marker).await; + { + let mut inner = state.inner.lock().await; + inner.registrations.insert( + "shim-1".to_string(), + registration("shim-1", "different-nonce"), + ); + } + + let response = pending.await.unwrap(); + let snapshot = state.snapshot().await; + + assert!(!response.accepted, "{response:?}"); + assert_eq!( + response.message, + "Entrypoint registration ID is already owned by another shim session." + ); + assert_eq!(snapshot.registrations.len(), 1); + assert_eq!( + snapshot.registrations[0] + .entrypoint_instance + .shim_session_nonce, + "different-nonce" + ); +} + +#[tokio::test] +async fn runtime_control_logs_are_active_and_limit_is_clamped() { + let state = state(); + let stream = LogStreamIdentity::runtime_control(); + state.logs().append( + stream.clone(), + LogSeverity::Info, + "first", + LogAttributionKind::RuntimeControl, + Some("start".to_string()), + ); + state.logs().append( + stream.clone(), + LogSeverity::Warn, + "second", + LogAttributionKind::RuntimeControl, + Some("reload".to_string()), + ); + + let response = state + .query_logs(QueryLogsRequest { + request_id: "logs".to_string(), + stream: stream.clone(), + limit: Some(0), + cursor: Some("not-a-sequence".to_string()), + minimum_severity: Some(LogSeverity::Info), + }) + .await; + + assert_eq!(response.stream, stream); + assert_eq!(response.stream_status, LogStreamStatus::Active); + assert_eq!(response.entries.len(), 1); + assert_eq!(response.entries[0].raw_message, "second"); + assert!(response.next_cursor.is_some()); +} + +#[tokio::test] +async fn shutdown_returns_success_when_runtime_is_idle() { + let state = state(); + + let response = state.shutdown().await; + + assert!(response.accepted); + assert_eq!(response.message, "Daemon shutdown requested."); +} diff --git a/crates/cadder-daemon/src/storage.rs b/crates/cadder-daemon/src/storage.rs new file mode 100644 index 0000000..4bafb70 --- /dev/null +++ b/crates/cadder-daemon/src/storage.rs @@ -0,0 +1,823 @@ +use anyhow::{Context, Result, bail}; +use cadder_protocol::{HistoryKind, HistoryRecord, RuntimeDiagnostic, StorageState}; +use chrono::{DateTime, Utc}; +use rusqlite::{Connection, OptionalExtension, params}; +use serde::Serialize; +use serde_json::Value; +use std::{ + fmt, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + thread, +}; +use tokio::sync::{mpsc, oneshot}; + +const SCHEMA_VERSION: u32 = 1; +const HISTORY_RETENTION_LIMIT: i64 = 10_000; +const STORAGE_QUEUE_CAPACITY: usize = 256; +const MAX_STORAGE_DIAGNOSTICS: usize = 32; + +#[derive(Clone)] +pub struct RuntimeStore { + sender: Option>, + sidecar: Arc>, +} + +struct StorageSidecar { + backend: String, + path: Option, + diagnostics: Vec, +} + +struct HistoryWrite { + kind: HistoryKind, + summary: String, + registration_id: Option, + domain_key: Option, + payload: Value, + retention_limit: i64, +} + +enum StorageCommand { + Record(HistoryWrite), + Query { + kind: Option, + limit: i64, + reply: oneshot::Sender>, + }, +} + +impl fmt::Debug for RuntimeStore { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RuntimeStore") + .field("state", &self.state()) + .finish() + } +} + +impl RuntimeStore { + pub fn open(path: impl AsRef) -> Self { + let path = path.as_ref(); + match Self::try_open(path) { + Ok(store) => store, + Err(error) => Self::unavailable( + Some(path.to_path_buf()), + format!("Could not open runtime storage: {error}"), + ), + } + } + + pub fn memory() -> Self { + match Self::try_memory() { + Ok(store) => store, + Err(error) => Self::unavailable(None, format!("Could not open memory storage: {error}")), + } + } + + pub fn state(&self) -> StorageState { + let sidecar = self + .sidecar + .lock() + .expect("runtime storage sidecar mutex poisoned"); + StorageState { + backend: sidecar.backend.clone(), + path: sidecar.path.as_ref().map(|path| path.display().to_string()), + schema_version: SCHEMA_VERSION, + diagnostics: sidecar.diagnostics.clone(), + } + } + + pub fn record_history( + &self, + kind: HistoryKind, + summary: impl Into, + registration_id: Option<&str>, + domain_key: Option<&str>, + payload: &T, + ) { + self.record_history_with_retention( + kind, + summary, + registration_id, + domain_key, + payload, + HISTORY_RETENTION_LIMIT, + ); + } + + fn record_history_with_retention( + &self, + kind: HistoryKind, + summary: impl Into, + registration_id: Option<&str>, + domain_key: Option<&str>, + payload: &T, + retention_limit: i64, + ) { + let payload = serde_json::to_value(payload).unwrap_or_else(|error| { + serde_json::json!({ + "serializationError": error.to_string() + }) + }); + let write = HistoryWrite { + kind, + summary: summary.into(), + registration_id: registration_id.map(str::to_string), + domain_key: domain_key.map(str::to_string), + payload, + retention_limit, + }; + let Some(sender) = &self.sender else { + return; + }; + match sender.try_send(StorageCommand::Record(write)) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => push_diagnostic( + &self.sidecar, + "storage-history-queue-full", + "Runtime history queue is full; dropped history record.".to_string(), + ), + Err(mpsc::error::TrySendError::Closed(_)) => push_diagnostic( + &self.sidecar, + "storage-history-worker-unavailable", + "Runtime history worker is unavailable; dropped history record.".to_string(), + ), + } + } + + pub async fn query_history(&self, kind: Option, limit: usize) -> Vec { + let Some(sender) = self.sender.clone() else { + return Vec::new(); + }; + let (reply, response) = oneshot::channel(); + let command = StorageCommand::Query { + kind, + limit: limit.clamp(1, 500) as i64, + reply, + }; + match sender.try_send(command) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + push_diagnostic( + &self.sidecar, + "storage-history-query-queue-full", + "Runtime history queue is full; could not read history.".to_string(), + ); + return Vec::new(); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + push_diagnostic( + &self.sidecar, + "storage-history-worker-unavailable", + "Runtime history worker is unavailable; could not read history.".to_string(), + ); + return Vec::new(); + } + } + match response.await { + Ok(records) => records, + Err(_) => { + push_diagnostic( + &self.sidecar, + "storage-history-worker-unavailable", + "Runtime history worker closed without returning history.".to_string(), + ); + Vec::new() + } + } + } + + fn try_memory() -> Result { + let connection = Connection::open_in_memory()?; + initialize_schema(&connection)?; + Self::with_connection(None, connection, STORAGE_QUEUE_CAPACITY, None) + } + + fn try_open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create runtime storage directory {}", parent.display()))?; + } + let connection = + Connection::open(path).with_context(|| format!("open runtime storage {}", path.display()))?; + initialize_schema(&connection)?; + Self::with_connection( + Some(path.to_path_buf()), + connection, + STORAGE_QUEUE_CAPACITY, + None, + ) + } + + fn with_connection( + path: Option, + connection: Connection, + queue_capacity: usize, + start_gate: Option>, + ) -> Result { + let sidecar = Arc::new(Mutex::new(StorageSidecar { + backend: "sqlite".to_string(), + path, + diagnostics: Vec::new(), + })); + let sender = spawn_storage_worker(connection, sidecar.clone(), queue_capacity, start_gate)?; + Ok(Self { + sender: Some(sender), + sidecar, + }) + } + + fn unavailable(path: Option, message: String) -> Self { + let sidecar = Arc::new(Mutex::new(StorageSidecar { + backend: "unavailable".to_string(), + path, + diagnostics: vec![RuntimeDiagnostic { + code: "storage-unavailable".to_string(), + message, + operation: Some("open-storage".to_string()), + }], + })); + Self { + sender: None, + sidecar, + } + } + + #[cfg(test)] + pub(crate) fn memory_stalled_for_test( + queue_capacity: usize, + ) -> (Self, std::sync::mpsc::Sender<()>) { + let connection = Connection::open_in_memory().unwrap(); + initialize_schema(&connection).unwrap(); + let (release, gate) = std::sync::mpsc::channel(); + let store = Self::with_connection(None, connection, queue_capacity, Some(gate)).unwrap(); + (store, release) + } +} + +fn spawn_storage_worker( + connection: Connection, + sidecar: Arc>, + queue_capacity: usize, + start_gate: Option>, +) -> Result> { + let (sender, mut receiver) = mpsc::channel(queue_capacity); + thread::Builder::new() + .name("cadder-runtime-storage".to_string()) + .spawn(move || { + if let Some(gate) = start_gate { + let _ = gate.recv(); + } + while let Some(command) = receiver.blocking_recv() { + match command { + StorageCommand::Record(write) => record_history_sync(&connection, &sidecar, write), + StorageCommand::Query { kind, limit, reply } => { + let records = query_history_sync(&connection, &sidecar, kind, limit); + let _ = reply.send(records); + } + } + } + }) + .context("start runtime storage worker")?; + Ok(sender) +} + +fn record_history_sync( + connection: &Connection, + sidecar: &Arc>, + write: HistoryWrite, +) { + let result = connection.execute( + "INSERT INTO history (timestamp_utc, kind, summary, registration_id, domain_key, payload_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + Utc::now().to_rfc3339(), + history_kind_name(write.kind), + write.summary, + write.registration_id, + write.domain_key, + write.payload.to_string(), + ], + ); + if let Err(error) = result { + push_diagnostic( + sidecar, + "storage-history-write", + format!("Could not persist runtime history: {error}"), + ); + return; + } + if let Err(error) = prune_history(connection, write.retention_limit) { + push_diagnostic( + sidecar, + "storage-history-prune", + format!("Could not prune old runtime history: {error}"), + ); + } +} + +fn query_history_sync( + connection: &Connection, + sidecar: &Arc>, + kind: Option, + limit: i64, +) -> Vec { + let result = match kind { + Some(kind) => query_history_by_kind(connection, history_kind_name(kind), limit), + None => query_history_all(connection, limit), + }; + match result { + Ok(records) => records, + Err(error) => { + push_diagnostic( + sidecar, + "storage-history-read", + format!("Could not read runtime history: {error}"), + ); + Vec::new() + } + } +} + +fn initialize_schema(connection: &Connection) -> Result<()> { + connection.execute_batch( + " + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ); + ", + )?; + if let Some(version) = existing_schema_version(connection)? + && version > SCHEMA_VERSION + { + bail!( + "runtime storage schema version {version} is newer than supported version {SCHEMA_VERSION}" + ); + } + connection.execute_batch( + " + CREATE TABLE IF NOT EXISTS history ( + sequence_number INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp_utc TEXT NOT NULL, + kind TEXT NOT NULL, + summary TEXT NOT NULL, + registration_id TEXT, + domain_key TEXT, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS history_kind_sequence_idx + ON history(kind, sequence_number DESC); + CREATE INDEX IF NOT EXISTS history_sequence_idx + ON history(sequence_number DESC); + ", + )?; + connection.execute( + "INSERT INTO metadata (key, value) VALUES ('schema_version', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![SCHEMA_VERSION.to_string()], + )?; + Ok(()) +} + +fn existing_schema_version(connection: &Connection) -> Result> { + let raw = connection + .query_row( + "SELECT value FROM metadata WHERE key = 'schema_version'", + [], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw + .map(|value| { + value + .parse::() + .with_context(|| format!("parse runtime storage schema version `{value}`")) + }) + .transpose() +} + +fn prune_history(connection: &Connection, retention_limit: i64) -> rusqlite::Result { + connection.execute( + "DELETE FROM history + WHERE sequence_number NOT IN ( + SELECT sequence_number FROM history + ORDER BY sequence_number DESC + LIMIT ?1 + )", + params![retention_limit], + ) +} + +fn query_history_all(connection: &Connection, limit: i64) -> rusqlite::Result> { + let mut statement = connection.prepare( + "SELECT sequence_number, timestamp_utc, kind, summary, registration_id, domain_key, payload_json + FROM history + ORDER BY sequence_number DESC + LIMIT ?1", + )?; + read_history_rows(statement.query(params![limit])?) +} + +fn query_history_by_kind( + connection: &Connection, + kind: &str, + limit: i64, +) -> rusqlite::Result> { + let mut statement = connection.prepare( + "SELECT sequence_number, timestamp_utc, kind, summary, registration_id, domain_key, payload_json + FROM history + WHERE kind = ?1 + ORDER BY sequence_number DESC + LIMIT ?2", + )?; + read_history_rows(statement.query(params![kind, limit])?) +} + +fn read_history_rows(mut rows: rusqlite::Rows<'_>) -> rusqlite::Result> { + let mut records = Vec::new(); + while let Some(row) = rows.next()? { + let kind_name: String = row.get(2)?; + let timestamp: String = row.get(1)?; + let payload_json: String = row.get(6)?; + records.push(HistoryRecord { + sequence_number: row.get(0)?, + timestamp_utc: parse_timestamp(×tamp).unwrap_or_else(Utc::now), + kind: history_kind_from_name(&kind_name).unwrap_or(HistoryKind::Runtime), + summary: row.get(3)?, + registration_id: row.get(4)?, + domain_key: row.get(5)?, + payload: serde_json::from_str(&payload_json).unwrap_or(Value::Null), + }); + } + Ok(records) +} + +fn parse_timestamp(raw: &str) -> Option> { + DateTime::parse_from_rfc3339(raw) + .map(|timestamp| timestamp.with_timezone(&Utc)) + .ok() +} + +fn history_kind_name(kind: HistoryKind) -> &'static str { + match kind { + HistoryKind::Registration => "registration", + HistoryKind::Runtime => "runtime", + HistoryKind::Config => "config", + HistoryKind::Iis => "iis", + HistoryKind::Autostart => "autostart", + HistoryKind::Log => "log", + } +} + +fn history_kind_from_name(raw: &str) -> Option { + match raw { + "registration" => Some(HistoryKind::Registration), + "runtime" => Some(HistoryKind::Runtime), + "config" => Some(HistoryKind::Config), + "iis" => Some(HistoryKind::Iis), + "autostart" => Some(HistoryKind::Autostart), + "log" => Some(HistoryKind::Log), + _ => None, + } +} + +fn push_diagnostic(sidecar: &Arc>, code: &str, message: String) { + let mut sidecar = sidecar + .lock() + .expect("runtime storage sidecar mutex poisoned"); + if sidecar + .diagnostics + .last() + .is_some_and(|diagnostic| diagnostic.code == code && diagnostic.message == message) + { + return; + } + sidecar.diagnostics.push(RuntimeDiagnostic { + code: code.to_string(), + message, + operation: Some("runtime-storage".to_string()), + }); + if sidecar.diagnostics.len() > MAX_STORAGE_DIAGNOSTICS { + sidecar.diagnostics.remove(0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[tokio::test] + async fn records_and_filters_history_preserving_worker_order() { + let store = RuntimeStore::memory(); + + store.record_history( + HistoryKind::Registration, + "Registered shim-1.", + Some("shim-1"), + Some("app.localhost"), + &serde_json::json!({ "accepted": true }), + ); + store.record_history( + HistoryKind::Runtime, + "Runtime started.", + None, + None, + &serde_json::json!({ "status": "running" }), + ); + + let registrations = store + .query_history(Some(HistoryKind::Registration), 10) + .await; + + assert_eq!(registrations.len(), 1); + assert_eq!(registrations[0].summary, "Registered shim-1."); + assert_eq!(registrations[0].registration_id.as_deref(), Some("shim-1")); + } + + #[test] + fn opens_file_backed_storage() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("runtime.sqlite3"); + + let store = RuntimeStore::open(&path); + + assert_eq!(store.state().backend, "sqlite"); + assert_eq!( + store.state().path.as_deref(), + Some(path.display().to_string().as_str()) + ); + } + + #[test] + fn rejects_newer_schema_version() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("runtime.sqlite3"); + let connection = Connection::open(&path).unwrap(); + connection + .execute_batch( + " + CREATE TABLE metadata ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ); + INSERT INTO metadata (key, value) VALUES ('schema_version', '999'); + ", + ) + .unwrap(); + drop(connection); + + let store = RuntimeStore::open(&path); + + let state = store.state(); + assert_eq!(state.backend, "unavailable"); + assert!( + state.diagnostics[0] + .message + .contains("newer than supported version") + ); + } + + #[tokio::test] + async fn unavailable_store_keeps_state_diagnostics_and_ignores_history() { + let path = PathBuf::from("D:/missing/runtime.sqlite3"); + let store = RuntimeStore::unavailable(Some(path.clone()), "storage unavailable".to_string()); + + store.record_history( + HistoryKind::Runtime, + "Runtime event.", + None, + None, + &serde_json::json!({ "ignored": true }), + ); + + let state = store.state(); + assert_eq!(state.backend, "unavailable"); + assert_eq!( + state.path.as_deref(), + Some(path.display().to_string().as_str()) + ); + assert_eq!(state.diagnostics[0].code, "storage-unavailable"); + assert_eq!( + state.diagnostics[0].operation.as_deref(), + Some("open-storage") + ); + assert!(store.query_history(None, 10).await.is_empty()); + assert!(format!("{store:?}").contains("unavailable")); + } + + #[tokio::test] + async fn prunes_history_to_retention_limit() { + let store = RuntimeStore::memory(); + + let retention_limit = 3; + for index in 0..8 { + store.record_history_with_retention( + HistoryKind::Runtime, + format!("Runtime event {index}."), + None, + None, + &serde_json::json!({ "index": index }), + retention_limit, + ); + } + + let records = store.query_history(None, 500).await; + + assert_eq!(records.len(), retention_limit as usize); + assert!(records[0].summary.contains("7")); + assert!(records[2].summary.contains("5")); + } + + #[tokio::test] + async fn records_serialization_errors_and_tolerates_corrupt_history_rows() { + struct FailingPayload; + + impl Serialize for FailingPayload { + fn serialize(&self, _serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + Err(serde::ser::Error::custom("payload failed")) + } + } + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("runtime.sqlite3"); + let store = RuntimeStore::open(&path); + store.record_history( + HistoryKind::Log, + "Could not serialize payload.", + None, + None, + &FailingPayload, + ); + assert_eq!( + store.query_history(Some(HistoryKind::Log), 10).await.len(), + 1 + ); + + { + let connection = Connection::open(&path).unwrap(); + connection + .execute( + "INSERT INTO history (timestamp_utc, kind, summary, registration_id, domain_key, payload_json) + VALUES (?1, ?2, ?3, NULL, NULL, ?4)", + params![ + "not-a-timestamp", + "unknown-kind", + "Corrupt row.", + "{not-json" + ], + ) + .unwrap(); + } + + let records = store.query_history(None, 10).await; + + assert_eq!(records.len(), 2); + let corrupt = records + .iter() + .find(|record| record.summary == "Corrupt row.") + .unwrap(); + assert_eq!(corrupt.kind, HistoryKind::Runtime); + assert_eq!(corrupt.payload, Value::Null); + let serialized = records + .iter() + .find(|record| record.summary == "Could not serialize payload.") + .unwrap(); + assert_eq!(serialized.kind, HistoryKind::Log); + assert_eq!(serialized.payload["serializationError"], "payload failed"); + } + + #[tokio::test] + async fn storage_query_and_write_failures_are_reported_as_diagnostics() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("runtime.sqlite3"); + let store = RuntimeStore::open(&path); + + { + let connection = Connection::open(&path).unwrap(); + connection.execute("DROP TABLE history", []).unwrap(); + } + + store.record_history( + HistoryKind::Runtime, + "Write after schema removal.", + None, + None, + &serde_json::json!({ "event": "write" }), + ); + assert!(store.query_history(None, 10).await.is_empty()); + + let diagnostics = store.state().diagnostics; + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "storage-history-write"), + "{diagnostics:?}" + ); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "storage-history-read"), + "{diagnostics:?}" + ); + } + + #[test] + fn record_history_reports_queue_full_without_worker_progress() { + let (store, release) = RuntimeStore::memory_stalled_for_test(1); + + store.record_history( + HistoryKind::Runtime, + "First queued event.", + None, + None, + &serde_json::json!({ "index": 1 }), + ); + store.record_history( + HistoryKind::Runtime, + "Dropped event.", + None, + None, + &serde_json::json!({ "index": 2 }), + ); + + let diagnostics = store.state().diagnostics; + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "storage-history-queue-full"), + "{diagnostics:?}" + ); + release.send(()).unwrap(); + } + + #[tokio::test] + async fn query_history_reports_queue_full_without_worker_progress() { + let (store, release) = RuntimeStore::memory_stalled_for_test(1); + store.record_history( + HistoryKind::Runtime, + "Queued event.", + None, + None, + &serde_json::json!({ "index": 1 }), + ); + + let records = tokio::time::timeout(Duration::from_millis(50), store.query_history(None, 10)) + .await + .unwrap(); + let diagnostics = store.state().diagnostics; + + assert!(records.is_empty()); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "storage-history-query-queue-full"), + "{diagnostics:?}" + ); + release.send(()).unwrap(); + } + + #[tokio::test] + async fn state_is_available_while_worker_is_stalled() { + let (store, release) = RuntimeStore::memory_stalled_for_test(1); + store.record_history( + HistoryKind::Runtime, + "Queued event.", + None, + None, + &serde_json::json!({ "queued": true }), + ); + + let state = tokio::time::timeout(Duration::from_millis(50), async { store.state() }) + .await + .unwrap(); + + assert_eq!(state.backend, "sqlite"); + assert!(state.diagnostics.is_empty()); + release.send(()).unwrap(); + } + + #[test] + fn history_kind_names_roundtrip_all_variants() { + let cases = [ + (HistoryKind::Registration, "registration"), + (HistoryKind::Runtime, "runtime"), + (HistoryKind::Config, "config"), + (HistoryKind::Iis, "iis"), + (HistoryKind::Autostart, "autostart"), + (HistoryKind::Log, "log"), + ]; + + for (kind, name) in cases { + assert_eq!(history_kind_name(kind), name); + assert_eq!(history_kind_from_name(name), Some(kind)); + } + assert_eq!(history_kind_from_name("unknown"), None); + } +} diff --git a/crates/cadder-daemon/tests/caddy_runtime.rs b/crates/cadder-daemon/tests/caddy_runtime.rs new file mode 100644 index 0000000..c66f2e1 --- /dev/null +++ b/crates/cadder-daemon/tests/caddy_runtime.rs @@ -0,0 +1,401 @@ +use cadder_daemon::{ + CaddyConfigAdapter, CaddyConfigCoordinator, CaddyLogStore, ProcessRuntime, RealCaddyResolver, + RuntimePaths, RuntimeTimeouts, +}; +use cadder_protocol::{ + ActivationState, EntrypointInstanceIdentity, EntrypointRegistration, LogStreamIdentity, + OwnerProcessIdentity, RegisteredDomain, RuntimeStatus, ShimRunMetadata, SourcePath, +}; +use chrono::Utc; +use std::{ + fs, + path::{Path, PathBuf}, + time::Duration, +}; +use tokio::time::sleep; + +#[tokio::test] +async fn adapter_uses_raw_config_path_and_shim_adapter_metadata() { + let temp = tempfile::tempdir().unwrap(); + let command_log = temp.path().join("fake-caddy.log"); + let fake_caddy = write_fake_caddy(temp.path(), &command_log, FakeMode::LongRunning); + let config_path = temp.path().join("Caddyfile.json"); + fs::write(&config_path, r#"{"apps":{}}"#).unwrap(); + let adapter = CaddyConfigAdapter::new(RealCaddyResolver::new(Some( + fake_caddy.display().to_string(), + ))); + let mut registration = registration("adapter", "nonce", &config_path); + registration.source_working_directory = SourcePath::new(temp.path().display().to_string(), None); + registration.source_config_path = SourcePath::new(config_path.display().to_string(), None); + registration.shim_run = Some(ShimRunMetadata { + adapter: Some("json".to_string()), + raw_arguments: vec![ + "run".to_string(), + "--adapter".to_string(), + "json".to_string(), + ], + command_line: "run --adapter json".to_string(), + }); + + let prepared = adapter.prepare(registration).await; + let command_log = fs::read_to_string(&command_log).unwrap(); + + assert!( + prepared.diagnostics.is_empty(), + "{:?}", + prepared.diagnostics + ); + assert_eq!( + prepared.registration.registered_domains[0].name.canonical, + "adapter.localhost" + ); + assert!( + command_log.contains("adapt") + && command_log.contains("--config") + && command_log.contains(config_path.to_string_lossy().as_ref()) + && command_log.contains("--adapter") + && command_log.contains("json"), + "{command_log}" + ); +} + +#[test] +fn resolver_anchors_relative_command_from_working_directory_config() { + let temp = tempfile::tempdir().unwrap(); + let bin_dir = temp.path().join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + let command_name = if cfg!(windows) { + "real-caddy.cmd" + } else { + "real-caddy" + }; + let command = bin_dir.join(command_name); + fs::write(&command, "").unwrap(); + fs::write( + temp.path().join("cadder.toml"), + format!("[caddy]\nreal_command = \"bin/{command_name}\"\n"), + ) + .unwrap(); + let resolver = RealCaddyResolver::new(None); + + let resolved = resolver.resolve_for_working_directory(temp.path()).unwrap(); + + assert_eq!(resolved, command.canonicalize().unwrap()); +} + +#[tokio::test] +async fn adapter_reports_nonzero_adapt_failures() { + let temp = tempfile::tempdir().unwrap(); + let command_log = temp.path().join("fake-caddy.log"); + let fake_caddy = write_fake_caddy(temp.path(), &command_log, FakeMode::FailAdapt); + let config_path = temp.path().join("Caddyfile"); + fs::write(&config_path, "broken.localhost { respond ok }").unwrap(); + let adapter = CaddyConfigAdapter::new(RealCaddyResolver::new(Some( + fake_caddy.display().to_string(), + ))); + + let prepared = adapter + .prepare(registration("broken", "nonce", &config_path)) + .await; + + assert_eq!(prepared.diagnostics[0].code, "adapt-failed"); + assert!( + prepared.diagnostics[0] + .message + .contains("caddy adapt failed"), + "{:?}", + prepared.diagnostics + ); +} + +#[tokio::test] +async fn adapter_reports_invalid_adapt_json() { + let temp = tempfile::tempdir().unwrap(); + let command_log = temp.path().join("fake-caddy.log"); + let fake_caddy = write_fake_caddy(temp.path(), &command_log, FakeMode::InvalidAdaptJson); + let config_path = temp.path().join("Caddyfile"); + fs::write(&config_path, "invalid-json.localhost { respond ok }").unwrap(); + let adapter = CaddyConfigAdapter::new(RealCaddyResolver::new(Some( + fake_caddy.display().to_string(), + ))); + + let prepared = adapter + .prepare(registration("invalid-json", "nonce", &config_path)) + .await; + + assert_eq!(prepared.diagnostics[0].code, "adapt-failed"); + assert!( + prepared.diagnostics[0] + .message + .contains("parse adapted Caddy JSON"), + "{:?}", + prepared.diagnostics + ); +} + +#[tokio::test] +async fn adapter_reports_adapt_timeout() { + let temp = tempfile::tempdir().unwrap(); + let command_log = temp.path().join("fake-caddy.log"); + let fake_caddy = write_fake_caddy(temp.path(), &command_log, FakeMode::SlowAdapt); + let config_path = temp.path().join("Caddyfile"); + fs::write(&config_path, "slow.localhost { respond ok }").unwrap(); + let adapter = CaddyConfigAdapter::with_command_timeout( + RealCaddyResolver::new(Some(fake_caddy.display().to_string())), + Duration::from_millis(50), + ); + + let prepared = adapter + .prepare(registration("slow", "nonce", &config_path)) + .await; + + assert_eq!(prepared.diagnostics[0].code, "adapt-failed"); + assert!( + prepared.diagnostics[0].message.contains("timed out"), + "{:?}", + prepared.diagnostics + ); +} + +#[tokio::test] +async fn process_runtime_starts_reports_running_reloads_and_stops() { + let temp = tempfile::tempdir().unwrap(); + let command_log = temp.path().join("fake-caddy.log"); + let fake_caddy = write_fake_caddy(temp.path(), &command_log, FakeMode::LongRunning); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + paths.ensure_dirs().unwrap(); + let runtime = ProcessRuntime::with_timeouts( + RealCaddyResolver::new(Some(fake_caddy.display().to_string())), + paths, + RuntimeTimeouts { + start_check: Duration::from_millis(150), + reload: Duration::from_secs(1), + graceful_stop: Duration::from_secs(1), + stop_wait: Duration::from_secs(1), + kill_wait: Duration::from_secs(1), + }, + ); + let logs = CaddyLogStore::default(); + + runtime + .apply_config(br#"{"apps":{}}"#, &logs) + .await + .unwrap(); + wait_for_command_log(&command_log, "run").await; + let running = runtime.inspect().await; + runtime + .apply_config(br#"{"apps":{"http":{}}}"#, &logs) + .await + .unwrap(); + wait_for_command_log(&command_log, "reload").await; + runtime.stop().await.unwrap(); + wait_for_command_log(&command_log, "stop").await; + + assert_eq!(running.status, RuntimeStatus::Running); + assert!(running.process_id.is_some()); +} + +#[tokio::test] +async fn coordinator_apply_tracks_runtime_success_failure_and_idle_stop() { + let temp = tempfile::tempdir().unwrap(); + let command_log = temp.path().join("fake-caddy.log"); + let fake_caddy = write_fake_caddy(temp.path(), &command_log, FakeMode::FailReload); + let config_path = temp.path().join("Caddyfile"); + fs::write(&config_path, "coordinator.localhost { respond ok }").unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + paths.ensure_dirs().unwrap(); + let resolver = RealCaddyResolver::new(Some(fake_caddy.display().to_string())); + let runtime = ProcessRuntime::with_timeouts( + resolver.clone(), + paths, + RuntimeTimeouts { + start_check: Duration::from_millis(150), + reload: Duration::from_secs(1), + graceful_stop: Duration::from_secs(1), + stop_wait: Duration::from_secs(1), + kill_wait: Duration::from_secs(1), + }, + ); + let mut coordinator = + CaddyConfigCoordinator::new(CaddyConfigAdapter::new(resolver), runtime.clone()); + let logs = CaddyLogStore::default(); + let mut active = registration("coordinator", "nonce", &config_path); + + active = coordinator.prepare_registration(active).await; + let applied = coordinator.apply(&[active.clone()], &logs).await; + let failed = coordinator.apply(&[active.clone()], &logs).await; + active.activation_state = ActivationState::Inactive; + let idle = coordinator.apply(&[active], &logs).await; + let _ = runtime.stop().await; + + assert_eq!(applied.status, cadder_protocol::ConfigApplyStatus::Applied); + assert_eq!(failed.status, cadder_protocol::ConfigApplyStatus::Failed); + assert_eq!(failed.diagnostics[0].code, "runtime-apply-failed"); + assert_eq!(idle.status, cadder_protocol::ConfigApplyStatus::Idle); +} + +fn registration(id: &str, nonce: &str, config_path: &Path) -> EntrypointRegistration { + let now = Utc::now(); + let identity = EntrypointInstanceIdentity { + instance_id: id.to_string(), + started_at_utc: now, + shim_session_nonce: nonce.to_string(), + }; + EntrypointRegistration { + registration_id: id.to_string(), + entrypoint_instance: identity, + source_working_directory: SourcePath::new(".", None), + source_config_path: SourcePath::new( + config_path.display().to_string(), + Some(config_path.display().to_string()), + ), + registered_domains: vec![RegisteredDomain::active(format!("{id}.localhost"))], + activation_state: ActivationState::Active, + owner_process: OwnerProcessIdentity { + process_id: 1, + process_start_time_utc: now, + shim_session_nonce: nonce.to_string(), + executable_path: None, + }, + log_stream: LogStreamIdentity::entrypoint(id), + shim_run: Some(ShimRunMetadata { + adapter: Some("caddyfile".to_string()), + raw_arguments: vec!["run".to_string()], + command_line: "run".to_string(), + }), + created_at_utc: now, + last_heartbeat_utc: now, + } +} + +#[derive(Clone, Copy)] +enum FakeMode { + LongRunning, + FailReload, + FailAdapt, + InvalidAdaptJson, + SlowAdapt, +} + +fn write_fake_caddy(dir: &Path, command_log: &Path, mode: FakeMode) -> PathBuf { + let stop_file = dir.join("fake-caddy.stop"); + #[cfg(windows)] + { + let path = dir.join("fake-caddy.cmd"); + let reload_behavior = if matches!(mode, FakeMode::FailReload) { + "echo reload failed 1>&2\r\n exit /b 7" + } else { + "exit /b 0" + }; + let adapt_behavior = match mode { + FakeMode::FailAdapt => "echo adapt failed 1>&2\r\n exit /b 6".to_string(), + FakeMode::InvalidAdaptJson => "echo not-json\r\n exit /b 0".to_string(), + FakeMode::SlowAdapt => { + "powershell -NoProfile -NonInteractive -Command \"Start-Sleep -Milliseconds 500\"\r\n echo {\"apps\":{}}\r\n exit /b 0".to_string() + } + _ => { + r#"echo {"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["adapter.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}} + exit /b 0"# + .to_string() + } + }; + fs::write( + &path, + format!( + r#"@echo off +echo %*>> "{command_log}" +if "%1"=="adapt" ( + {adapt_behavior} +) +if "%1"=="reload" ( + {reload_behavior} +) +if "%1"=="stop" ( + echo stop> "{stop_file}" + exit /b 0 +) +if "%1"=="run" ( + :run_loop + if exist "{stop_file}" exit /b 0 + powershell -NoProfile -NonInteractive -Command "Start-Sleep -Milliseconds 100" >nul + goto run_loop +) +exit /b 0 +"#, + command_log = command_log.display(), + stop_file = stop_file.display(), + reload_behavior = reload_behavior, + adapt_behavior = adapt_behavior, + ), + ) + .unwrap(); + path + } + + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + let path = dir.join("fake-caddy"); + let reload_behavior = if matches!(mode, FakeMode::FailReload) { + "printf '%s\n' 'reload failed' >&2\n exit 7" + } else { + "exit 0" + }; + let adapt_behavior = match mode { + FakeMode::FailAdapt => "printf '%s\n' 'adapt failed' >&2\n exit 6".to_string(), + FakeMode::InvalidAdaptJson => "printf '%s\n' 'not-json'\n exit 0".to_string(), + FakeMode::SlowAdapt => { + "sleep 1\n printf '%s\n' '{\"apps\":{}}'\n exit 0".to_string() + } + _ => { + r#"printf '%s\n' '{"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["adapter.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}}' + exit 0"# + .to_string() + } + }; + fs::write( + &path, + format!( + r#"#!/usr/bin/env sh +printf '%s\n' "$*" >> '{command_log}' +if [ "$1" = "adapt" ]; then + {adapt_behavior} +fi +if [ "$1" = "reload" ]; then + {reload_behavior} +fi +if [ "$1" = "stop" ]; then + : > '{stop_file}' + exit 0 +fi +if [ "$1" = "run" ]; then + while [ ! -f '{stop_file}' ]; do sleep 0.1; done + exit 0 +fi +exit 0 +"#, + command_log = command_log.display(), + stop_file = stop_file.display(), + reload_behavior = reload_behavior, + adapt_behavior = adapt_behavior, + ), + ) + .unwrap(); + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).unwrap(); + path + } +} + +async fn wait_for_command_log(path: &Path, command: &str) { + for _ in 0..200 { + let log = fs::read_to_string(path).unwrap_or_default(); + if log.lines().any(|line| line.starts_with(command)) { + return; + } + sleep(Duration::from_millis(20)).await; + } + let log = fs::read_to_string(path).unwrap_or_default(); + panic!("expected command `{command}` in fake Caddy log:\n{log}"); +} diff --git a/crates/cadder-daemon/tests/ipc_lifecycle.rs b/crates/cadder-daemon/tests/ipc_lifecycle.rs index ce43305..dd3d1fe 100644 --- a/crates/cadder-daemon/tests/ipc_lifecycle.rs +++ b/crates/cadder-daemon/tests/ipc_lifecycle.rs @@ -1,22 +1,37 @@ use cadder_daemon::{ CadderClient, CadderSession, CaddyConfigAdapter, CaddyConfigCoordinator, DaemonServer, - DaemonState, ProcessRuntime, RealCaddyResolver, RuntimePaths, + DaemonState, ProcessRuntime, RealCaddyResolver, RuntimePaths, RuntimeTimeouts, + ensure_daemon_running, }; use cadder_protocol::{ ActivationState, BasicResponse, ConfigApplyStatus, EntrypointInstanceIdentity, - EntrypointRegistration, HeartbeatEntrypointRequest, LogAttributionKind, LogSeverity, - LogStreamIdentity, LogStreamStatus, OwnerProcessIdentity, QueryLogsRequest, QueryLogsResponse, - QueryStateRequest, QueryStateResponse, RegisterEntrypointRequest, RegisterEntrypointResponse, - SetDomainEnabledRequest, SetEntrypointEnabledRequest, ShimRunMetadata, SourcePath, - StateChangeKind, UnregisterEntrypointRequest, message_types, new_request_id, + EntrypointRegistration, HeartbeatEntrypointRequest, IpcEnvelope, LogAttributionKind, LogSeverity, + LogStreamIdentity, LogStreamStatus, OwnerProcessIdentity, PROTOCOL_VERSION, ProtocolErrorKind, + ProtocolErrorResponse, QueryAutostartRequest, QueryAutostartResponse, QueryIisBindingsRequest, + QueryIisBindingsResponse, QueryLogsRequest, QueryLogsResponse, QueryStateRequest, + QueryStateResponse, RegisterEntrypointRequest, RegisterEntrypointResponse, RuntimeStatus, + SetAutostartRequest, SetDomainEnabledRequest, SetEntrypointEnabledRequest, SetIisHandoffRequest, + SetIisHandoffResponse, ShimRunMetadata, SourcePath, StateChangeKind, UnregisterEntrypointRequest, + message_types, new_request_id, }; use chrono::Utc; +use interprocess::local_socket::{ + GenericNamespaced, ListenerOptions, ToNsName, + tokio::{Stream, prelude::*}, +}; +use serde::Serialize; use std::{ fs, + future::Future, path::{Path, PathBuf}, time::Duration, }; -use tokio::{sync::watch, time::sleep}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}, + sync::watch, + task::JoinHandle, + time::sleep, +}; #[tokio::test] async fn ipc_lifecycle_starts_with_zero_registrations() { @@ -124,6 +139,112 @@ async fn ipc_lifecycle_registers_one_shim_and_applies_config() { harness.shutdown().await; } +#[tokio::test] +async fn ipc_local_operator_requests_cover_autostart_status() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + + let autostart: QueryAutostartResponse = harness + .client + .request( + message_types::QUERY_AUTOSTART_REQUEST, + message_types::QUERY_AUTOSTART_RESPONSE, + &QueryAutostartRequest { + request_id: new_request_id("test-query-autostart"), + }, + ) + .await + .unwrap(); + #[cfg(all(unix, not(target_os = "macos")))] + { + assert!(!autostart.accepted, "{}", autostart.message); + assert_eq!( + autostart.status, + cadder_protocol::AutostartStatus::Unsupported + ); + assert!( + autostart + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.code == "autostart-config-dir-unavailable" }) + ); + } + #[cfg(not(all(unix, not(target_os = "macos"))))] + assert!(autostart.accepted, "{}", autostart.message); + + harness.shutdown().await; +} + +#[tokio::test] +async fn ipc_set_autostart_request_records_history_over_wire() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + + let response: cadder_protocol::SetAutostartResponse = harness + .client + .request( + message_types::SET_AUTOSTART_REQUEST, + message_types::SET_AUTOSTART_RESPONSE, + &SetAutostartRequest { + request_id: new_request_id("test-set-autostart"), + mode: cadder_protocol::AutostartMode::Daemon, + }, + ) + .await + .unwrap(); + let history = query_history(&harness.client, cadder_protocol::HistoryKind::Autostart).await; + + assert!(!response.accepted); + assert_eq!( + response.status, + cadder_protocol::AutostartStatus::Unsupported + ); + assert!(response.target.is_none()); + assert!(history.records.iter().any(|record| { + record.kind == cadder_protocol::HistoryKind::Autostart + && record.summary == "Set autostart mode to Daemon." + })); + harness.shutdown().await; +} + +#[tokio::test] +async fn ipc_iis_operator_requests_return_typed_responses() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + + let bindings: QueryIisBindingsResponse = harness + .client + .request( + message_types::QUERY_IIS_BINDINGS_REQUEST, + message_types::QUERY_IIS_BINDINGS_RESPONSE, + &QueryIisBindingsRequest { + request_id: new_request_id("test-query-iis"), + }, + ) + .await + .unwrap(); + let handoff: SetIisHandoffResponse = harness + .client + .request( + message_types::SET_IIS_HANDOFF_REQUEST, + message_types::SET_IIS_HANDOFF_RESPONSE, + &SetIisHandoffRequest { + request_id: new_request_id("test-set-iis"), + binding_id: "missing|http|127.0.0.1:1:missing.localhost".to_string(), + enabled: true, + route_host: None, + }, + ) + .await + .unwrap(); + + assert!(bindings.request_id.starts_with("test-query-iis-")); + assert!(handoff.request_id.starts_with("test-set-iis-")); + assert!(!handoff.accepted); + assert!(handoff.issue.is_some() || handoff.binding.is_none()); + harness.shutdown().await; +} + #[tokio::test] async fn shutdown_daemon_request_stops_server_and_rejects_new_clients() { let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); @@ -162,6 +283,47 @@ async fn shutdown_daemon_request_stops_server_and_rejects_new_clients() { panic!("daemon server still accepted new IPC clients after shutdown request"); } +#[tokio::test] +async fn shutdown_daemon_request_reports_stop_timeout_and_keeps_server_available() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture).slow_stop(short_runtime_timeouts())).await; + let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + assert!( + register_on_session( + &mut session, + registration("shim-1", "nonce-1", &harness.config_path) + ) + .await + .accepted + ); + wait_for_command_log(&harness.command_log_path, "run").await; + + let response: BasicResponse = harness + .client + .request( + message_types::SHUTDOWN_DAEMON_REQUEST, + message_types::SHUTDOWN_DAEMON_RESPONSE, + &cadder_protocol::ShutdownDaemonRequest { + request_id: new_request_id("shutdown-daemon"), + }, + ) + .await + .unwrap(); + let snapshot = query_state(&harness.client).await; + let history = query_history(&harness.client, cadder_protocol::HistoryKind::Runtime).await; + + assert!(!response.accepted); + assert!(response.message.contains("timed out")); + assert_eq!(snapshot.registrations.len(), 1); + assert!( + history + .records + .iter() + .all(|record| record.summary != "Daemon shutdown requested.") + ); + harness.shutdown().await; +} + #[tokio::test] async fn ipc_lifecycle_supports_many_registrations_and_owner_cleanup() { let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); @@ -277,6 +439,114 @@ async fn ipc_disconnect_cleanup_removes_only_that_session_registration() { panic!("pipe disconnect cleanup did not remove only the dropped session"); } +#[tokio::test] +async fn ipc_parse_error_cleanup_removes_owned_registration() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + let (mut reader, mut writer) = raw_ipc_session(&harness.paths).await; + + write_raw_envelope( + &mut writer, + message_types::REGISTER_ENTRYPOINT_REQUEST, + &RegisterEntrypointRequest { + request_id: new_request_id("raw-register"), + registration: registration("shim-1", "nonce-1", &harness.config_path), + }, + ) + .await; + let response: RegisterEntrypointResponse = read_raw_envelope(&mut reader).await.decode().unwrap(); + assert!(response.accepted, "{}", response.message); + assert_eq!(query_state(&harness.client).await.registrations.len(), 1); + + write_raw_line(&mut writer, "{not-json}\n").await; + drop(writer); + drop(reader); + + wait_for_empty_registrations( + &harness.client, + "parse error cleanup did not remove the owned registration", + ) + .await; + harness.shutdown().await; +} + +#[tokio::test] +async fn ipc_failed_unregister_keeps_owner_for_disconnect_cleanup() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + assert!( + register_on_session( + &mut session, + registration("shim-1", "nonce-1", &harness.config_path) + ) + .await + .accepted + ); + + let wrong_owner: BasicResponse = session + .request( + message_types::UNREGISTER_ENTRYPOINT_REQUEST, + message_types::UNREGISTER_ENTRYPOINT_RESPONSE, + &UnregisterEntrypointRequest { + request_id: new_request_id("wrong-owner-unregister"), + registration_id: "shim-1".to_string(), + shim_session_nonce: "wrong-nonce".to_string(), + }, + ) + .await + .unwrap(); + assert!(!wrong_owner.accepted); + assert_eq!(query_state(&harness.client).await.registrations.len(), 1); + + drop(session); + + wait_for_empty_registrations( + &harness.client, + "failed unregister removed the session owner before disconnect cleanup", + ) + .await; + harness.shutdown().await; +} + +#[tokio::test] +async fn ipc_subscription_disconnect_cleanup_removes_owned_registration() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + assert!( + register_on_session( + &mut session, + registration("shim-1", "nonce-1", &harness.config_path) + ) + .await + .accepted + ); + let mut subscription = session + .subscribe_state(new_request_id("subscription-cleanup")) + .await + .unwrap(); + let initial = tokio::time::timeout(Duration::from_secs(1), subscription.next_event()) + .await + .unwrap() + .unwrap(); + assert_eq!(initial.change_kind, StateChangeKind::Snapshot); + assert_eq!(initial.snapshot.registrations.len(), 1); + + drop(subscription); + + for attempt in 0..50 { + let _ = + set_entrypoint_enabled(&harness.client, "shim-1", Some("nonce-1"), attempt % 2 == 0).await; + if query_state(&harness.client).await.registrations.is_empty() { + harness.shutdown().await; + return; + } + sleep(Duration::from_millis(20)).await; + } + panic!("subscription disconnect cleanup did not remove the owned registration"); +} + #[tokio::test] async fn fake_caddy_reload_tracks_effective_config_after_domain_toggle() { let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); @@ -302,6 +572,38 @@ async fn fake_caddy_reload_tracks_effective_config_after_domain_toggle() { assert!(!effective.contains("api.smarketing.localhost")); assert!(effective.contains("app.smarketing.localhost")); wait_for_command_log(&harness.command_log_path, "reload").await; + let logs = query_logs( + &harness.client, + LogStreamIdentity::runtime_control(), + Some(20), + None, + ) + .await; + assert!(logs.entries.iter().any(|entry| { + entry.operation.as_deref() == Some("reload") + && entry.severity == LogSeverity::Info + && entry.raw_message.contains("runtime reloaded") + })); + + let reenabled = + set_domain_enabled(&harness.client, "shim-1", "api.smarketing.localhost", true).await; + assert!(reenabled.accepted, "{}", reenabled.message); + let restored = query_state(&harness.client).await; + assert_eq!( + restored.registrations[0].registered_domains[0].activation_state, + ActivationState::Active + ); + let history = query_history(&harness.client, cadder_protocol::HistoryKind::Registration).await; + assert!(history.records.iter().any(|record| { + record + .summary + .contains("Disabled domain `api.smarketing.localhost`") + })); + assert!(history.records.iter().any(|record| { + record + .summary + .contains("Enabled domain `api.smarketing.localhost`") + })); harness.shutdown().await; } @@ -458,9 +760,45 @@ async fn runtime_reload_failure_reports_diagnostic_and_control_log() { } #[tokio::test] -async fn per_domain_log_queries_report_status_and_cursor() { +async fn slow_adapt_does_not_block_state_queries() { let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); - let harness = Harness::start(FakeCaddy::new(fixture)).await; + let harness = + Harness::start(FakeCaddy::new(fixture).slow_adapt(Duration::from_millis(500))).await; + let paths = harness.paths.clone(); + let config_path = harness.config_path.clone(); + let register_task = tokio::spawn(async move { + let mut session = CadderSession::connect(&paths).await.unwrap(); + let response = register_on_session( + &mut session, + registration("shim-1", "nonce-1", &config_path), + ) + .await; + (response, session) + }); + wait_for_command_log(&harness.command_log_path, "adapt").await; + + let snapshot = tokio::time::timeout(Duration::from_millis(200), query_state(&harness.client)) + .await + .expect("query-state should not wait for slow adapt"); + let (response, _session) = register_task.await.unwrap(); + let final_snapshot = query_state(&harness.client).await; + + assert!(snapshot.registrations.is_empty()); + assert!(response.accepted, "{response:?}"); + assert_eq!(final_snapshot.config.status, ConfigApplyStatus::Failed); + assert_eq!(final_snapshot.config.diagnostics[0].code, "adapt-failed"); + assert!( + final_snapshot.config.diagnostics[0] + .message + .contains("timed out") + ); + harness.shutdown().await; +} + +#[tokio::test] +async fn slow_reload_does_not_block_state_queries_and_reports_timeout() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture).slow_reload(short_runtime_timeouts())).await; let mut session = CadderSession::connect(&harness.paths).await.unwrap(); assert!( register_on_session( @@ -470,42 +808,55 @@ async fn per_domain_log_queries_report_status_and_cursor() { .await .accepted ); - let stream = LogStreamIdentity::domain("api.smarketing.localhost"); - harness.state.logs().append( - stream.clone(), - LogSeverity::Info, - "first domain log", - LogAttributionKind::Domain, - None, - ); - harness.state.logs().append( - stream.clone(), - LogSeverity::Error, - "second domain log", - LogAttributionKind::Domain, - None, - ); - - let first_page = query_logs(&harness.client, stream.clone(), Some(1), None).await; + wait_for_command_log(&harness.command_log_path, "run").await; - assert_eq!(first_page.stream_status, LogStreamStatus::Active); - assert_eq!(first_page.entries.len(), 1); - assert_eq!(first_page.entries[0].raw_message, "second domain log"); - assert!(first_page.has_more_before); - let cursor = first_page.next_cursor.clone(); + let client = harness.client.clone(); + let toggle_task = tokio::spawn(async move { + set_domain_enabled(&client, "shim-1", "api.smarketing.localhost", false).await + }); + wait_for_command_log(&harness.command_log_path, "reload").await; - let next_page = query_logs(&harness.client, stream, Some(10), cursor).await; + let snapshot = tokio::time::timeout(Duration::from_millis(200), query_state(&harness.client)) + .await + .expect("query-state should not wait for slow reload"); + let response = toggle_task.await.unwrap(); + let final_snapshot = query_state(&harness.client).await; - assert_eq!(next_page.stream_status, LogStreamStatus::Active); - assert!(next_page.entries.is_empty()); + assert_eq!(snapshot.registrations.len(), 1); + assert!(response.accepted, "{response:?}"); + assert_eq!(final_snapshot.config.status, ConfigApplyStatus::Failed); + assert_eq!( + final_snapshot.config.diagnostics[0].code, + "runtime-apply-failed" + ); + assert!( + final_snapshot.config.diagnostics[0] + .message + .contains("timed out") + ); harness.shutdown().await; } #[tokio::test] -async fn ipc_heartbeat_and_entrypoint_toggle_update_registered_state() { +async fn slow_reload_subscription_event_reports_coherent_failed_snapshot() { let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); - let harness = Harness::start(FakeCaddy::new(fixture)).await; + let harness = Harness::start(FakeCaddy::new(fixture).slow_reload(short_runtime_timeouts())).await; + let mut subscription = harness + .client + .subscribe_state(new_request_id("dashboard")) + .await + .unwrap(); + let initial = tokio::time::timeout(Duration::from_secs(1), subscription.next_event()) + .await + .unwrap() + .unwrap(); let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + assert!(!initial.snapshot.registrations.iter().any(|registration| { + registration + .registered_domains + .iter() + .any(|domain| domain.name.canonical == "api.smarketing.localhost") + })); assert!( register_on_session( &mut session, @@ -514,21 +865,332 @@ async fn ipc_heartbeat_and_entrypoint_toggle_update_registered_state() { .await .accepted ); + wait_for_command_log(&harness.command_log_path, "run").await; + let _registered = tokio::time::timeout(Duration::from_secs(1), subscription.next_event()) + .await + .unwrap() + .unwrap(); - let heartbeat: BasicResponse = harness - .client - .request( - message_types::HEARTBEAT_ENTRYPOINT_REQUEST, - message_types::HEARTBEAT_ENTRYPOINT_RESPONSE, - &HeartbeatEntrypointRequest { - request_id: new_request_id("test-heartbeat"), - registration_id: "shim-1".to_string(), - shim_session_nonce: "nonce-1".to_string(), - }, - ) + let response = + set_domain_enabled(&harness.client, "shim-1", "api.smarketing.localhost", false).await; + let event = tokio::time::timeout(Duration::from_secs(2), subscription.next_event()) .await + .unwrap() .unwrap(); - let disabled: BasicResponse = harness + let api_domain = event.snapshot.registrations[0] + .registered_domains + .iter() + .find(|domain| domain.name.canonical == "api.smarketing.localhost") + .unwrap(); + + assert!(response.accepted, "{response:?}"); + assert_eq!(event.change_kind, StateChangeKind::RegistrationsChanged); + assert_eq!(api_domain.activation_state, ActivationState::Inactive); + assert_eq!(event.snapshot.config.status, ConfigApplyStatus::Failed); + assert_eq!( + event.snapshot.config.diagnostics[0].code, + "runtime-apply-failed" + ); + assert!( + event.snapshot.config.diagnostics[0] + .message + .contains("timed out") + ); + harness.shutdown().await; +} + +#[tokio::test] +async fn slow_stop_does_not_block_state_queries_and_logs_timeout() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture).slow_stop(short_runtime_timeouts())).await; + let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + assert!( + register_on_session( + &mut session, + registration("shim-1", "nonce-1", &harness.config_path) + ) + .await + .accepted + ); + wait_for_command_log(&harness.command_log_path, "run").await; + + let client = harness.client.clone(); + let toggle_task = tokio::spawn(async move { + client + .request::<_, BasicResponse>( + message_types::SET_ENTRYPOINT_ENABLED_REQUEST, + message_types::SET_ENTRYPOINT_ENABLED_RESPONSE, + &SetEntrypointEnabledRequest { + request_id: new_request_id("test-disable-entrypoint"), + registration_id: "shim-1".to_string(), + shim_session_nonce: Some("nonce-1".to_string()), + enabled: false, + }, + ) + .await + .unwrap() + }); + wait_for_command_log(&harness.command_log_path, "stop").await; + + let snapshot = tokio::time::timeout(Duration::from_millis(200), query_state(&harness.client)) + .await + .expect("query-state should not wait for slow stop"); + let response = toggle_task.await.unwrap(); + let logs = query_logs( + &harness.client, + LogStreamIdentity::runtime_control(), + Some(10), + None, + ) + .await; + + assert_eq!(snapshot.registrations.len(), 1); + assert!(response.accepted, "{response:?}"); + assert!(logs.entries.iter().any(|entry| { + entry.severity == LogSeverity::Error && entry.raw_message.contains("timed out") + })); + harness.shutdown().await; +} + +#[tokio::test] +async fn exited_runtime_child_is_not_reported_running() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture).short_run()).await; + let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + assert!( + register_on_session( + &mut session, + registration("shim-1", "nonce-1", &harness.config_path) + ) + .await + .accepted + ); + wait_for_command_log(&harness.command_log_path, "run").await; + wait_for_command_log(&harness.command_log_path, "run-exited").await; + + let snapshot = wait_for_runtime_status(&harness.client, RuntimeStatus::Unhealthy).await; + + assert_eq!(snapshot.runtime.status, RuntimeStatus::Unhealthy); + assert_eq!(snapshot.runtime.diagnostics[0].code, "runtime-exited"); + let follow_up = query_state(&harness.client).await; + assert_eq!(follow_up.runtime.status, RuntimeStatus::Idle); + assert!(follow_up.runtime.process_id.is_none()); + harness.shutdown().await; +} + +#[tokio::test] +async fn immediate_runtime_exit_reports_start_failure() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture).exit_run().runtime_timeouts( + RuntimeTimeouts { + start_check: Duration::from_secs(2), + ..RuntimeTimeouts::default() + }, + )) + .await; + let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + + let response = register_on_session( + &mut session, + registration("shim-1", "nonce-1", &harness.config_path), + ) + .await; + let snapshot = query_state(&harness.client).await; + + assert!(response.accepted, "{response:?}"); + assert_eq!(snapshot.config.status, ConfigApplyStatus::Failed); + assert_eq!(snapshot.config.diagnostics[0].code, "runtime-apply-failed"); + assert!( + snapshot.config.diagnostics[0] + .message + .contains("exited immediately") + ); + harness.shutdown().await; +} + +#[tokio::test] +async fn shutdown_after_runtime_child_exit_is_accepted() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture).short_run()).await; + let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + assert!( + register_on_session( + &mut session, + registration("shim-1", "nonce-1", &harness.config_path) + ) + .await + .accepted + ); + wait_for_command_log(&harness.command_log_path, "run").await; + wait_for_command_log(&harness.command_log_path, "run-exited").await; + + let response: BasicResponse = harness + .client + .request( + message_types::SHUTDOWN_DAEMON_REQUEST, + message_types::SHUTDOWN_DAEMON_RESPONSE, + &cadder_protocol::ShutdownDaemonRequest { + request_id: new_request_id("shutdown-daemon"), + }, + ) + .await + .unwrap(); + + assert!(response.accepted, "{}", response.message); +} + +#[tokio::test] +async fn apply_after_runtime_child_exit_restarts_without_prior_inspect() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture).short_run()).await; + let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + assert!( + register_on_session( + &mut session, + registration("shim-1", "nonce-1", &harness.config_path) + ) + .await + .accepted + ); + wait_for_command_log(&harness.command_log_path, "run").await; + wait_for_command_log(&harness.command_log_path, "run-exited").await; + + let response = + set_domain_enabled(&harness.client, "shim-1", "api.smarketing.localhost", false).await; + wait_for_command_count(&harness.command_log_path, "run", 2).await; + let snapshot = query_state(&harness.client).await; + let logs = wait_for_runtime_control_log(&harness.client, "runtime-liveness", "restarting").await; + + assert!(response.accepted, "{response:?}"); + assert_eq!(snapshot.config.status, ConfigApplyStatus::Applied); + assert!(logs.entries.iter().any(|entry| { + entry.operation.as_deref() == Some("runtime-liveness") + && entry.raw_message.contains("restarting") + })); + harness.shutdown().await; +} + +#[tokio::test] +async fn runtime_restart_after_child_exit_publishes_recovered_snapshot() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture).short_run()).await; + let mut subscription = harness + .client + .subscribe_state(new_request_id("dashboard")) + .await + .unwrap(); + let _initial = tokio::time::timeout(Duration::from_secs(1), subscription.next_event()) + .await + .unwrap() + .unwrap(); + let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + assert!( + register_on_session( + &mut session, + registration("shim-1", "nonce-1", &harness.config_path) + ) + .await + .accepted + ); + wait_for_command_log(&harness.command_log_path, "run").await; + wait_for_command_log(&harness.command_log_path, "run-exited").await; + let _registered = tokio::time::timeout(Duration::from_secs(1), subscription.next_event()) + .await + .unwrap() + .unwrap(); + + let response = + set_domain_enabled(&harness.client, "shim-1", "api.smarketing.localhost", false).await; + wait_for_command_count(&harness.command_log_path, "run", 2).await; + let event = tokio::time::timeout(Duration::from_secs(1), subscription.next_event()) + .await + .unwrap() + .unwrap(); + let logs = wait_for_runtime_control_log(&harness.client, "runtime-liveness", "restarting").await; + + assert!(response.accepted, "{response:?}"); + assert_eq!(event.change_kind, StateChangeKind::RegistrationsChanged); + assert_eq!(event.snapshot.config.status, ConfigApplyStatus::Applied); + assert_ne!(event.snapshot.runtime.status, RuntimeStatus::Idle); + assert!(logs.entries.iter().any(|entry| { + entry.operation.as_deref() == Some("runtime-liveness") + && entry.raw_message.contains("restarting") + })); + harness.shutdown().await; +} + +#[tokio::test] +async fn per_domain_log_queries_report_status_and_cursor() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + assert!( + register_on_session( + &mut session, + registration("shim-1", "nonce-1", &harness.config_path) + ) + .await + .accepted + ); + let stream = LogStreamIdentity::domain("api.smarketing.localhost"); + harness.state.logs().append( + stream.clone(), + LogSeverity::Info, + "first domain log", + LogAttributionKind::Domain, + None, + ); + harness.state.logs().append( + stream.clone(), + LogSeverity::Error, + "second domain log", + LogAttributionKind::Domain, + None, + ); + + let first_page = query_logs(&harness.client, stream.clone(), Some(1), None).await; + + assert_eq!(first_page.stream_status, LogStreamStatus::Active); + assert_eq!(first_page.entries.len(), 1); + assert_eq!(first_page.entries[0].raw_message, "second domain log"); + assert!(first_page.has_more_before); + let cursor = first_page.next_cursor.clone(); + + let next_page = query_logs(&harness.client, stream, Some(10), cursor).await; + + assert_eq!(next_page.stream_status, LogStreamStatus::Active); + assert!(next_page.entries.is_empty()); + harness.shutdown().await; +} + +#[tokio::test] +async fn ipc_heartbeat_and_entrypoint_toggle_update_registered_state() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + assert!( + register_on_session( + &mut session, + registration("shim-1", "nonce-1", &harness.config_path) + ) + .await + .accepted + ); + + let heartbeat: BasicResponse = harness + .client + .request( + message_types::HEARTBEAT_ENTRYPOINT_REQUEST, + message_types::HEARTBEAT_ENTRYPOINT_RESPONSE, + &HeartbeatEntrypointRequest { + request_id: new_request_id("test-heartbeat"), + registration_id: "shim-1".to_string(), + shim_session_nonce: "nonce-1".to_string(), + }, + ) + .await + .unwrap(); + let disabled: BasicResponse = harness .client .request( message_types::SET_ENTRYPOINT_ENABLED_REQUEST, @@ -555,28 +1217,136 @@ async fn ipc_heartbeat_and_entrypoint_toggle_update_registered_state() { } #[tokio::test] -async fn ipc_reports_unsupported_message_type() { +async fn ipc_reports_unsupported_message_type() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + let mut session = CadderSession::connect(&harness.paths).await.unwrap(); + + let response: ProtocolErrorResponse = session + .request( + "unknown-message", + message_types::PROTOCOL_ERROR_RESPONSE, + &QueryStateRequest { + request_id: "unsupported".to_string(), + }, + ) + .await + .unwrap(); + + assert!(!response.accepted); + assert_eq!(response.request_id, "unsupported"); + assert_eq!( + response.error.kind, + ProtocolErrorKind::UnsupportedCapability + ); + assert_eq!( + response.error.required_capability.as_deref(), + Some("message-type:unknown-message") + ); + assert!( + response + .capabilities + .as_ref() + .expect("protocol error response should advertise daemon capabilities") + .supports(cadder_protocol::capabilities::LOGS) + ); + harness.shutdown().await; +} + +#[tokio::test] +async fn ipc_accepts_newer_protocol_version_when_capabilities_are_compatible() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + let (mut reader, mut writer) = raw_ipc_session(&harness.paths).await; + let mut request = IpcEnvelope::new( + message_types::QUERY_STATE_REQUEST, + &QueryStateRequest { + request_id: "newer-protocol".to_string(), + }, + ) + .unwrap(); + request.protocol_version = PROTOCOL_VERSION.saturating_add(1); + let rendered = serde_json::to_string(&request).unwrap(); + + write_raw_line(&mut writer, &format!("{rendered}\n")).await; + let envelope = read_raw_envelope(&mut reader).await; + let response: QueryStateResponse = envelope.decode().unwrap(); + + assert_eq!(envelope.message_type, message_types::QUERY_STATE_RESPONSE); + assert!(response.accepted); + assert_eq!(response.request_id, "newer-protocol"); + assert!(response.snapshot.is_some()); + harness.shutdown().await; +} + +#[tokio::test] +async fn ipc_rejects_protocol_version_below_compatibility_floor_with_typed_guidance() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + let (mut reader, mut writer) = raw_ipc_session(&harness.paths).await; + let mut request = IpcEnvelope::new( + message_types::QUERY_STATE_REQUEST, + &QueryStateRequest { + request_id: "too-old-protocol".to_string(), + }, + ) + .unwrap(); + request.protocol_version = cadder_protocol::MIN_COMPATIBLE_PROTOCOL_VERSION.saturating_sub(1); + let rendered = serde_json::to_string(&request).unwrap(); + + write_raw_line(&mut writer, &format!("{rendered}\n")).await; + let envelope = read_raw_envelope(&mut reader).await; + let response: ProtocolErrorResponse = envelope.decode().unwrap(); + + assert_eq!( + envelope.message_type, + message_types::PROTOCOL_ERROR_RESPONSE + ); + assert!(!response.accepted); + assert_eq!(response.request_id, "too-old-protocol"); + assert_eq!( + response.error.kind, + ProtocolErrorKind::IncompatibleProtocolVersion + ); + assert!( + response + .error + .guidance + .as_deref() + .is_some_and(|guidance| guidance.contains("Upgrade the Cadder client")) + ); + assert!( + response + .capabilities + .as_ref() + .expect("protocol error response should advertise daemon capabilities") + .supports(cadder_protocol::capabilities::LOGS) + ); + harness.shutdown().await; +} + +#[tokio::test] +async fn session_reports_protocol_error_response_as_request_error() { let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); let harness = Harness::start(FakeCaddy::new(fixture)).await; let mut session = CadderSession::connect(&harness.paths).await.unwrap(); - let response: BasicResponse = session - .request( + let error = session + .request::<_, QueryStateResponse>( "unknown-message", - "error-response", + message_types::QUERY_STATE_RESPONSE, &QueryStateRequest { request_id: "unsupported".to_string(), }, ) .await - .unwrap(); + .unwrap_err(); - assert!(!response.accepted); - assert_eq!(response.request_id, "unsupported"); assert!( - response - .message - .contains("Unsupported IPC message type `unknown-message`") + error + .to_string() + .contains("daemon rejected IPC request `unsupported`"), + "{error:?}" ); harness.shutdown().await; } @@ -606,6 +1376,224 @@ async fn session_rejects_unexpected_response_type() { harness.shutdown().await; } +#[tokio::test] +async fn ipc_rejects_invalid_payload_shapes_for_supported_messages() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + let supported_messages = [ + message_types::REGISTER_ENTRYPOINT_REQUEST, + message_types::UNREGISTER_ENTRYPOINT_REQUEST, + message_types::HEARTBEAT_ENTRYPOINT_REQUEST, + message_types::QUERY_STATE_REQUEST, + message_types::SET_ENTRYPOINT_ENABLED_REQUEST, + message_types::SET_DOMAIN_ENABLED_REQUEST, + message_types::QUERY_IIS_BINDINGS_REQUEST, + message_types::SET_IIS_HANDOFF_REQUEST, + message_types::QUERY_LOGS_REQUEST, + message_types::QUERY_HISTORY_REQUEST, + message_types::QUERY_AUTOSTART_REQUEST, + message_types::SET_AUTOSTART_REQUEST, + message_types::SUBSCRIBE_STATE_REQUEST, + message_types::SHUTDOWN_DAEMON_REQUEST, + ]; + + for message_type in supported_messages { + let (mut reader, mut writer) = raw_ipc_session(&harness.paths).await; + write_raw_envelope(&mut writer, message_type, &serde_json::Value::Null).await; + + let envelope = read_raw_envelope(&mut reader).await; + let response: ProtocolErrorResponse = envelope.decode().unwrap(); + + assert_eq!( + envelope.message_type, + message_types::PROTOCOL_ERROR_RESPONSE + ); + assert!(!response.accepted); + assert_eq!(response.request_id, "unknown"); + assert_eq!(response.error.kind, ProtocolErrorKind::PayloadDecodeFailed); + } + + assert!(query_state(&harness.client).await.registrations.is_empty()); + harness.shutdown().await; +} + +#[tokio::test] +async fn session_reports_eof_when_peer_closes_without_response() { + let peer = ScriptedIpcPeer::start(|conn| async move { + let (read_half, write_half) = tokio::io::split(conn); + let mut reader = BufReader::new(read_half); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + drop(write_half); + }); + let mut session = CadderSession::connect(&peer.paths).await.unwrap(); + + let error = session + .request::<_, QueryStateResponse>( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("eof-query-state"), + }, + ) + .await + .unwrap_err(); + + assert!( + error.to_string().contains("without a response"), + "{error:?}" + ); + peer.finish().await; +} + +#[tokio::test] +async fn session_rejects_malformed_response_json() { + let peer = ScriptedIpcPeer::start(|conn| async move { + let (_line, mut writer) = read_peer_request(conn).await; + write_raw_line(&mut writer, "{not-json}\n").await; + }); + let mut session = CadderSession::connect(&peer.paths).await.unwrap(); + + let error = session + .request::<_, QueryStateResponse>( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("bad-json-query-state"), + }, + ) + .await + .unwrap_err(); + + assert!( + error.to_string().contains("key must be a string"), + "{error:?}" + ); + peer.finish().await; +} + +#[tokio::test] +async fn session_rejects_invalid_response_payload() { + let peer = ScriptedIpcPeer::start(|conn| async move { + let (_line, mut writer) = read_peer_request(conn).await; + write_raw_envelope( + &mut writer, + message_types::QUERY_STATE_RESPONSE, + &serde_json::json!({ "accepted": true }), + ) + .await; + }); + let mut session = CadderSession::connect(&peer.paths).await.unwrap(); + + let error = session + .request::<_, QueryStateResponse>( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: new_request_id("bad-payload-query-state"), + }, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("missing field"), "{error:?}"); + peer.finish().await; +} + +#[tokio::test] +async fn state_subscription_reports_eof_when_peer_closes_before_event() { + let peer = ScriptedIpcPeer::start(|conn| async move { + let (read_half, write_half) = tokio::io::split(conn); + let mut reader = BufReader::new(read_half); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + drop(write_half); + }); + let session = CadderSession::connect(&peer.paths).await.unwrap(); + let mut subscription = session + .subscribe_state(new_request_id("eof-subscribe")) + .await + .unwrap(); + + let error = subscription.next_event().await.unwrap_err(); + + assert!( + error + .to_string() + .contains("daemon closed the state subscription"), + "{error:?}" + ); + peer.finish().await; +} + +#[tokio::test] +async fn state_subscription_rejects_non_event_message_type() { + let peer = ScriptedIpcPeer::start(|conn| async move { + let (_line, mut writer) = read_peer_request(conn).await; + write_raw_envelope( + &mut writer, + message_types::QUERY_STATE_RESPONSE, + &BasicResponse { + request_id: "wrong-event-type".to_string(), + accepted: true, + message: "ok".to_string(), + }, + ) + .await; + }); + let session = CadderSession::connect(&peer.paths).await.unwrap(); + let mut subscription = session + .subscribe_state(new_request_id("wrong-event-type")) + .await + .unwrap(); + + let error = subscription.next_event().await.unwrap_err(); + + assert!( + error + .to_string() + .contains("unexpected response type `query-state-response`"), + "{error:?}" + ); + peer.finish().await; +} + +#[tokio::test] +async fn state_subscription_rejects_invalid_event_payload() { + let peer = ScriptedIpcPeer::start(|conn| async move { + let (_line, mut writer) = read_peer_request(conn).await; + write_raw_envelope( + &mut writer, + message_types::STATE_CHANGED_EVENT, + &BasicResponse { + request_id: "invalid-event-payload".to_string(), + accepted: true, + message: "ok".to_string(), + }, + ) + .await; + }); + let session = CadderSession::connect(&peer.paths).await.unwrap(); + let mut subscription = session + .subscribe_state(new_request_id("invalid-event-payload")) + .await + .unwrap(); + + let error = subscription.next_event().await.unwrap_err(); + + assert!(error.to_string().contains("missing field"), "{error:?}"); + peer.finish().await; +} + +#[tokio::test] +async fn ensure_daemon_running_returns_ok_when_socket_is_already_accepting() { + let peer = ScriptedIpcPeer::start(|_conn| async move {}); + + ensure_daemon_running(&peer.paths, None).await.unwrap(); + + peer.finish().await; +} + async fn query_state(client: &CadderClient) -> cadder_protocol::GuiStateSnapshot { let response: QueryStateResponse = client .request( @@ -620,6 +1608,34 @@ async fn query_state(client: &CadderClient) -> cadder_protocol::GuiStateSnapshot response.snapshot.unwrap() } +async fn wait_for_empty_registrations(client: &CadderClient, failure_message: &str) { + for _ in 0..50 { + if query_state(client).await.registrations.is_empty() { + return; + } + sleep(Duration::from_millis(20)).await; + } + panic!("{failure_message}"); +} + +async fn wait_for_runtime_status( + client: &CadderClient, + status: RuntimeStatus, +) -> cadder_protocol::GuiStateSnapshot { + for _ in 0..COMMAND_LOG_WAIT_ATTEMPTS { + let snapshot = query_state(client).await; + if snapshot.runtime.status == status { + return snapshot; + } + sleep(Duration::from_millis(20)).await; + } + let snapshot = query_state(client).await; + panic!( + "expected runtime status {:?}, got {:?}", + status, snapshot.runtime.status + ); +} + async fn query_logs( client: &CadderClient, stream: LogStreamIdentity, @@ -642,6 +1658,45 @@ async fn query_logs( .unwrap() } +async fn wait_for_runtime_control_log( + client: &CadderClient, + operation: &str, + message_fragment: &str, +) -> QueryLogsResponse { + for _ in 0..COMMAND_LOG_WAIT_ATTEMPTS { + let logs = query_logs(client, LogStreamIdentity::runtime_control(), Some(20), None).await; + if logs.entries.iter().any(|entry| { + entry.operation.as_deref() == Some(operation) && entry.raw_message.contains(message_fragment) + }) { + return logs; + } + sleep(Duration::from_millis(20)).await; + } + let logs = query_logs(client, LogStreamIdentity::runtime_control(), Some(20), None).await; + panic!( + "expected runtime control log operation `{operation}` containing `{message_fragment}`, got {:?}", + logs.entries + ); +} + +async fn query_history( + client: &CadderClient, + kind: cadder_protocol::HistoryKind, +) -> cadder_protocol::QueryHistoryResponse { + client + .request( + message_types::QUERY_HISTORY_REQUEST, + message_types::QUERY_HISTORY_RESPONSE, + &cadder_protocol::QueryHistoryRequest { + request_id: new_request_id("test-history"), + kind: Some(kind), + limit: Some(20), + }, + ) + .await + .unwrap() +} + async fn set_domain_enabled( client: &CadderClient, registration_id: &str, @@ -663,6 +1718,27 @@ async fn set_domain_enabled( .unwrap() } +async fn set_entrypoint_enabled( + client: &CadderClient, + registration_id: &str, + shim_session_nonce: Option<&str>, + enabled: bool, +) -> BasicResponse { + client + .request( + message_types::SET_ENTRYPOINT_ENABLED_REQUEST, + message_types::SET_ENTRYPOINT_ENABLED_RESPONSE, + &SetEntrypointEnabledRequest { + request_id: new_request_id("test-entrypoint-toggle"), + registration_id: registration_id.to_string(), + shim_session_nonce: shim_session_nonce.map(str::to_string), + enabled, + }, + ) + .await + .unwrap() +} + async fn register_on_session( session: &mut CadderSession, registration: EntrypointRegistration, @@ -680,6 +1756,97 @@ async fn register_on_session( .unwrap() } +async fn raw_ipc_session( + paths: &RuntimePaths, +) -> ( + BufReader>, + tokio::io::WriteHalf, +) { + let name = paths + .socket_name() + .to_ns_name::() + .unwrap(); + let conn = Stream::connect(name).await.unwrap(); + let (read_half, writer) = tokio::io::split(conn); + (BufReader::new(read_half), writer) +} + +async fn write_raw_envelope(writer: &mut W, message_type: &str, payload: &T) +where + W: AsyncWrite + Unpin, + T: Serialize, +{ + let envelope = IpcEnvelope::new(message_type, payload).unwrap(); + let rendered = serde_json::to_string(&envelope).unwrap(); + writer.write_all(rendered.as_bytes()).await.unwrap(); + writer.write_all(b"\n").await.unwrap(); + writer.flush().await.unwrap(); +} + +async fn write_raw_line(writer: &mut W, line: &str) +where + W: AsyncWrite + Unpin, +{ + writer.write_all(line.as_bytes()).await.unwrap(); + writer.flush().await.unwrap(); +} + +async fn read_raw_envelope(reader: &mut BufReader>) -> IpcEnvelope { + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + assert!(!line.is_empty(), "daemon closed the raw IPC session"); + serde_json::from_str(line.trim_end()).unwrap() +} + +struct ScriptedIpcPeer { + paths: RuntimePaths, + task: JoinHandle<()>, + _temp: tempfile::TempDir, +} + +impl ScriptedIpcPeer { + fn start(handler: F) -> Self + where + F: FnOnce(Stream) -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let name = paths + .socket_name() + .to_ns_name::() + .unwrap(); + let listener = ListenerOptions::new() + .name(name) + .try_overwrite(true) + .create_tokio() + .unwrap(); + let task = tokio::spawn(async move { + let conn = listener.accept().await.unwrap(); + handler(conn).await; + }); + + Self { + paths, + task, + _temp: temp, + } + } + + async fn finish(self) { + self.task.await.unwrap(); + } +} + +async fn read_peer_request(conn: Stream) -> (String, tokio::io::WriteHalf) { + let (read_half, writer) = tokio::io::split(conn); + let mut reader = BufReader::new(read_half); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + assert!(!line.is_empty(), "client did not send an IPC request"); + (line, writer) +} + struct Harness { client: CadderClient, state: DaemonState, @@ -702,8 +1869,14 @@ impl Harness { let fake_caddy_path = write_fake_caddy(temp.path(), &command_log_path, fake_caddy); let resolver = RealCaddyResolver::new(Some(fake_caddy_path.display().to_string())); - let adapter = CaddyConfigAdapter::new(resolver.clone()); - let runtime = ProcessRuntime::new(resolver, paths.clone()); + let adapter = fake_caddy + .adapt_timeout + .map(|timeout| CaddyConfigAdapter::with_command_timeout(resolver.clone(), timeout)) + .unwrap_or_else(|| CaddyConfigAdapter::new(resolver.clone())); + let runtime = fake_caddy + .runtime_timeouts + .map(|timeouts| ProcessRuntime::with_timeouts(resolver.clone(), paths.clone(), timeouts)) + .unwrap_or_else(|| ProcessRuntime::new(resolver, paths.clone())); let state = DaemonState::new(CaddyConfigCoordinator::new(adapter, runtime)); let server = DaemonServer::new(paths.clone(), state.clone()); let (shutdown_tx, shutdown_rx) = watch::channel(false); @@ -749,6 +1922,13 @@ struct FakeCaddy<'a> { caddyfile: &'a str, fail_adapt: bool, fail_reload: bool, + slow_adapt: bool, + slow_reload: bool, + slow_stop: bool, + exit_run: bool, + short_run: bool, + adapt_timeout: Option, + runtime_timeouts: Option, } impl<'a> FakeCaddy<'a> { @@ -757,6 +1937,13 @@ impl<'a> FakeCaddy<'a> { caddyfile, fail_adapt: false, fail_reload: false, + slow_adapt: false, + slow_reload: false, + slow_stop: false, + exit_run: false, + short_run: false, + adapt_timeout: None, + runtime_timeouts: None, } } @@ -769,6 +1956,39 @@ impl<'a> FakeCaddy<'a> { self.fail_reload = true; self } + + fn slow_adapt(mut self, timeout: Duration) -> Self { + self.slow_adapt = true; + self.adapt_timeout = Some(timeout); + self + } + + fn slow_reload(mut self, timeouts: RuntimeTimeouts) -> Self { + self.slow_reload = true; + self.runtime_timeouts = Some(timeouts); + self + } + + fn slow_stop(mut self, timeouts: RuntimeTimeouts) -> Self { + self.slow_stop = true; + self.runtime_timeouts = Some(timeouts); + self + } + + fn exit_run(mut self) -> Self { + self.exit_run = true; + self + } + + fn runtime_timeouts(mut self, timeouts: RuntimeTimeouts) -> Self { + self.runtime_timeouts = Some(timeouts); + self + } + + fn short_run(mut self) -> Self { + self.short_run = true; + self + } } fn registration(id: &str, nonce: &str, config_path: &Path) -> EntrypointRegistration { @@ -805,8 +2025,10 @@ fn registration(id: &str, nonce: &str, config_path: &Path) -> EntrypointRegistra } } +const COMMAND_LOG_WAIT_ATTEMPTS: usize = 500; + async fn wait_for_command_log(path: &Path, command: &str) { - for _ in 0..50 { + for _ in 0..COMMAND_LOG_WAIT_ATTEMPTS { let log = fs::read_to_string(path).unwrap_or_default(); if log.lines().any(|line| line.starts_with(command)) { return; @@ -817,8 +2039,32 @@ async fn wait_for_command_log(path: &Path, command: &str) { panic!("expected command `{command}` in fake Caddy log:\n{log}"); } +async fn wait_for_command_count(path: &Path, command: &str, expected: usize) { + for _ in 0..COMMAND_LOG_WAIT_ATTEMPTS { + let log = fs::read_to_string(path).unwrap_or_default(); + let count = log.lines().filter(|line| line.starts_with(command)).count(); + if count >= expected { + return; + } + sleep(Duration::from_millis(20)).await; + } + let log = fs::read_to_string(path).unwrap_or_default(); + panic!("expected {expected} `{command}` commands in fake Caddy log:\n{log}"); +} + +fn short_runtime_timeouts() -> RuntimeTimeouts { + RuntimeTimeouts { + start_check: Duration::from_millis(250), + reload: Duration::from_millis(500), + graceful_stop: Duration::from_millis(500), + stop_wait: Duration::from_millis(500), + kill_wait: Duration::from_secs(1), + } +} + fn write_fake_caddy(dir: &Path, command_log_path: &Path, fake_caddy: FakeCaddy<'_>) -> PathBuf { const ADAPTED_JSON: &str = r#"{"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["api.smarketing.localhost","app.smarketing.localhost","mailbox.smarketing.localhost","storage.smarketing.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}}"#; + let adapted_json = ADAPTED_JSON; let adapt_failure = if fake_caddy.fail_adapt { "adapt failed" } else { @@ -829,6 +2075,76 @@ fn write_fake_caddy(dir: &Path, command_log_path: &Path, fake_caddy: FakeCaddy<' } else { "" }; + let slow_adapt = if fake_caddy.slow_adapt { + #[cfg(windows)] + { + "ping -n 2 127.0.0.1 >nul" + } + #[cfg(not(windows))] + { + "sleep 1" + } + } else { + "" + }; + let slow_reload = if fake_caddy.slow_reload { + #[cfg(windows)] + { + "ping -n 2 127.0.0.1 >nul" + } + #[cfg(not(windows))] + { + "sleep 1" + } + } else { + "" + }; + let slow_stop = if fake_caddy.slow_stop { + #[cfg(windows)] + { + "ping -n 2 127.0.0.1 >nul" + } + #[cfg(not(windows))] + { + "sleep 1" + } + } else { + "" + }; + let run_behavior = if fake_caddy.exit_run { + #[cfg(windows)] + { + "exit /b 0".to_string() + } + #[cfg(not(windows))] + { + "exit 0".to_string() + } + } else if fake_caddy.short_run { + #[cfg(windows)] + { + format!( + "ping -n 2 127.0.0.1 >nul\r\n echo run-exited>> \"{}\"\r\n exit /b 0", + command_log_path.display() + ) + } + #[cfg(not(windows))] + { + format!( + "sleep 1\n printf '%s\\n' 'run-exited' >> '{}'\n exit 0", + command_log_path.display() + ) + } + } else { + #[cfg(windows)] + { + "ping -n 6 127.0.0.1 >nul\r\n exit /b 0".to_string() + } + #[cfg(not(windows))] + { + "sleep 5\n exit 0".to_string() + } + }; #[cfg(windows)] { let path = dir.join("fake-caddy.cmd"); @@ -838,6 +2154,7 @@ fn write_fake_caddy(dir: &Path, command_log_path: &Path, fake_caddy: FakeCaddy<' r#"@echo off echo %*>> "{command_log}" if "%1"=="adapt" ( + {slow_adapt} if not "{adapt_failure}"=="" ( echo {adapt_failure} 1>&2 exit 6 @@ -846,22 +2163,31 @@ if "%1"=="adapt" ( exit 0 ) if "%1"=="reload" ( + {slow_reload} if not "{reload_failure}"=="" ( echo {reload_failure} 1>&2 exit 7 ) exit 0 ) +if "%1"=="stop" ( + {slow_stop} + exit 0 +) if "%1"=="run" ( echo fake runtime started - exit 0 + {run_behavior} ) exit 0 "#, command_log = command_log_path.display(), - adapted_json = ADAPTED_JSON, + adapted_json = adapted_json, adapt_failure = adapt_failure, reload_failure = reload_failure, + slow_adapt = slow_adapt, + slow_reload = slow_reload, + slow_stop = slow_stop, + run_behavior = run_behavior, ), ) .unwrap(); @@ -878,6 +2204,7 @@ exit 0 r#"#!/usr/bin/env sh printf '%s\n' "$*" >> '{command_log}' if [ "$1" = "adapt" ]; then + {slow_adapt} if [ -n '{adapt_failure}' ]; then printf '%s\n' '{adapt_failure}' >&2 exit 6 @@ -886,19 +2213,28 @@ if [ "$1" = "adapt" ]; then exit 0 fi if [ "$1" = "reload" ]; then + {slow_reload} if [ -n '{reload_failure}' ]; then printf '%s\n' '{reload_failure}' >&2 exit 7 fi exit 0 fi -if [ "$1" = "run" ]; then echo fake runtime started; exit 0; fi +if [ "$1" = "stop" ]; then + {slow_stop} + exit 0 +fi +if [ "$1" = "run" ]; then echo fake runtime started; {run_behavior}; fi exit 0 "#, command_log = command_log_path.display(), - adapted_json = ADAPTED_JSON, + adapted_json = adapted_json, adapt_failure = adapt_failure, reload_failure = reload_failure, + slow_adapt = slow_adapt, + slow_reload = slow_reload, + slow_stop = slow_stop, + run_behavior = run_behavior, ), ) .unwrap(); diff --git a/crates/cadder-mcp/Cargo.toml b/crates/cadder-mcp/Cargo.toml deleted file mode 100644 index 98cc46d..0000000 --- a/crates/cadder-mcp/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "cadder-mcp" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -anyhow = "=1.0.102" -cadder-daemon = { version = "0.8.0", path = "../cadder-daemon" } -cadder-operator = { version = "0.8.0", path = "../cadder-operator" } -cadder-protocol = { version = "0.8.0", path = "../cadder-protocol" } -clap = { version = "=4.6.1", features = ["derive"] } -rmcp = { version = "=1.7.0", features = ["transport-io"] } -serde = { version = "=1.0.228", features = ["derive"] } -tokio = { version = "=1.52.3", features = ["full"] } - -[dev-dependencies] -chrono = { version = "=0.4.45", features = ["serde"] } -rmcp = { version = "=1.7.0", features = ["client", "transport-child-process", "which-command"] } -tempfile = "=3.27.0" diff --git a/crates/cadder-mcp/src/dto.rs b/crates/cadder-mcp/src/dto.rs deleted file mode 100644 index edd7236..0000000 --- a/crates/cadder-mcp/src/dto.rs +++ /dev/null @@ -1,260 +0,0 @@ -use rmcp::schemars; -use rmcp::schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ListEntrypointsParams { - pub registration_id: Option, -} - -#[derive(Debug, Clone, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ListDomainsParams { - pub registration_id: Option, -} - -#[derive(Debug, Clone, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct GetLogsParams { - pub target: LogTargetKind, - pub registration_id: Option, - pub domain: Option, - pub limit: Option, - pub cursor: Option, - pub minimum_severity: Option, -} - -#[derive(Debug, Clone, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct SetEntrypointEnabledParams { - pub registration_id: String, - pub enabled: bool, -} - -#[derive(Debug, Clone, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct SetDomainEnabledParams { - pub domain: String, - pub registration_id: Option, - pub enabled: bool, -} - -#[derive(Debug, Clone, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub enum LogTargetKind { - Runtime, - Entrypoint, - Domain, -} - -#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum SeverityParam { - Trace, - Debug, - Info, - Warn, - Error, - Fatal, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct OverviewOutput { - pub captured_at_utc: String, - pub runtime_dir: String, - pub trust_boundary: TrustBoundaryOutput, - pub counts: OverviewCountsOutput, - pub runtime: RuntimeSummaryOutput, - pub config: ConfigSummaryOutput, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct TrustBoundaryOutput { - pub transport: String, - pub exposure: String, - pub daemon_model: String, - pub implicit_start: bool, - pub explicit_start_tool: String, - pub preferred_surfaces: Vec, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct OverviewCountsOutput { - pub entrypoints: usize, - pub domains: usize, - pub active_domains: usize, - pub runtime_diagnostic_count: usize, - pub config_diagnostic_count: usize, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeSummaryOutput { - pub status: String, - pub version: Option, - pub binary_path: Option, - pub admin_endpoint: Option, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ConfigSummaryOutput { - pub status: String, - pub effective_config_hash: Option, - pub last_attempted_at_utc: Option, - pub last_successful_reload_at_utc: Option, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct EntrypointListOutput { - pub captured_at_utc: String, - pub entrypoints: Vec, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct EntrypointOutput { - pub registration_id: String, - pub activation_state: String, - pub working_directory: String, - pub config_path: String, - pub started_at_utc: String, - pub last_heartbeat_utc: String, - pub executable_path: Option, - pub domain_count: usize, - pub active_domain_count: usize, - pub domains: Vec, - pub adapter: Option, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct DomainListOutput { - pub captured_at_utc: String, - pub domains: Vec, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct DomainOutput { - pub registration_id: String, - pub domain: String, - pub canonical_domain: String, - pub activation_state: String, - pub entrypoint_activation_state: String, - pub working_directory: String, - pub config_path: String, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct DiagnosticsOutput { - pub captured_at_utc: String, - pub runtime_status: String, - pub config_status: String, - pub runtime_diagnostics: Vec, - pub config_diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeDiagnosticOutput { - pub code: String, - pub message: String, - pub operation: Option, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ConfigDiagnosticOutput { - pub code: String, - pub message: String, - pub domain_key: Option, - pub source_config_paths: Vec, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct LogsOutput { - pub stream_id: String, - pub channel: String, - pub domain_key: Option, - pub stream_status: String, - pub entries: Vec, - pub next_cursor: Option, - pub has_gap: bool, - pub has_more_before: bool, - pub truncated_by_retention: bool, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct LogEntryOutput { - pub sequence_number: u64, - pub timestamp_utc: String, - pub severity: String, - pub attribution_kind: String, - pub entry_kind: String, - pub message: String, - pub operation: Option, - pub source_registration_id: Option, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct StartDaemonOutput { - pub started: bool, - pub message: String, - pub overview: OverviewOutput, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ToggleEntrypointOutput { - pub registration_id: String, - pub enabled: bool, - pub message: String, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ToggleDomainOutput { - pub canonical_domain: String, - pub registration_id: Option, - pub enabled: bool, - pub message: String, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ToolErrorResponse { - pub ok: bool, - pub error: ToolErrorPayload, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ToolErrorPayload { - pub kind: ToolErrorKind, - pub message: String, - pub guidance: Option, - pub retryable: bool, -} - -#[derive(Debug, Clone, Copy, Serialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub enum ToolErrorKind { - InvalidUsage, - DaemonUnavailable, - DaemonStartFailure, - TargetNotFound, - ConflictOrRejected, - PermissionOrElevation, - UnsupportedOperation, - IpcFailure, -} diff --git a/crates/cadder-mcp/src/lib.rs b/crates/cadder-mcp/src/lib.rs deleted file mode 100644 index b8db857..0000000 --- a/crates/cadder-mcp/src/lib.rs +++ /dev/null @@ -1,616 +0,0 @@ -mod dto; -mod redaction; - -use anyhow::Result; -use cadder_daemon::DaemonLaunchOptions; -use cadder_operator::{ - DomainSelector, OperatorContext, OperatorError, OperatorErrorKind, counts, diagnostics_view, - domains_view, entrypoints_view, -}; -use clap::Parser; -use dto::{ - ConfigDiagnosticOutput, ConfigSummaryOutput, DiagnosticsOutput, DomainListOutput, DomainOutput, - EntrypointListOutput, EntrypointOutput, GetLogsParams, ListDomainsParams, ListEntrypointsParams, - LogEntryOutput, LogTargetKind, LogsOutput, OverviewCountsOutput, OverviewOutput, - RuntimeDiagnosticOutput, RuntimeSummaryOutput, SetDomainEnabledParams, - SetEntrypointEnabledParams, SeverityParam, StartDaemonOutput, ToggleDomainOutput, - ToggleEntrypointOutput, ToolErrorKind, ToolErrorPayload, ToolErrorResponse, TrustBoundaryOutput, -}; -use redaction::{McpRedactor, truncate}; -use rmcp::{ - ServerHandler, ServiceExt, - handler::server::wrapper::{Json, Parameters}, - model::{CallToolResult, Implementation, ServerInfo}, - serde_json, tool, tool_handler, tool_router, - transport::stdio, -}; -use std::path::PathBuf; - -const MAX_LOG_LIMIT: usize = 200; -const DEFAULT_LOG_LIMIT: usize = 50; - -#[derive(Debug, Clone, Parser)] -#[command( - name = "cadder-mcp", - version, - about = "Cadder local stdio MCP server for agent-safe inspection and bounded management" -)] -pub struct Args { - #[arg( - long, - help = "Override the Cadder runtime directory used to find daemon IPC and state" - )] - pub runtime_dir: Option, - - #[arg( - long, - help = "Path to a cadderd executable for the explicit daemon start tool" - )] - pub daemon_path: Option, - - #[arg( - long, - help = "Command or path passed to cadderd when explicitly starting the real Caddy binary" - )] - pub real_caddy_command: Option, -} - -#[derive(Clone)] -pub struct CadderMcpServer { - operator: OperatorContext, - redactor: McpRedactor, -} - -impl CadderMcpServer { - pub fn new(args: Args) -> Result { - let operator = OperatorContext::new( - "cadder-mcp", - args.runtime_dir, - DaemonLaunchOptions { - explicit_daemon: args.daemon_path, - real_caddy_command: args.real_caddy_command, - shim_path: None, - }, - )?; - let redactor = McpRedactor::new( - std::env::current_dir().ok(), - operator.paths().runtime_dir().to_path_buf(), - ); - Ok(Self { operator, redactor }) - } - - pub async fn run_stdio(self) -> Result<()> { - let service = self.serve(stdio()).await?; - service.waiting().await?; - Ok(()) - } - - fn map_error(&self, error: OperatorError) -> CallToolResult { - let (kind, retryable, guidance) = match error.kind { - OperatorErrorKind::InvalidUsage => (ToolErrorKind::InvalidUsage, false, None), - OperatorErrorKind::DaemonUnavailable => ( - ToolErrorKind::DaemonUnavailable, - true, - Some("Call `cadder_start_daemon`, or start `cadderd` for the same runtime directory, then retry.".to_string()), - ), - OperatorErrorKind::DaemonStartFailure => ( - ToolErrorKind::DaemonStartFailure, - true, - Some("Fix the daemon path or startup configuration, then retry `cadder_start_daemon`.".to_string()), - ), - OperatorErrorKind::TargetNotFound => (ToolErrorKind::TargetNotFound, false, None), - OperatorErrorKind::ConflictOrRejected => ( - ToolErrorKind::ConflictOrRejected, - true, - Some("Refresh Cadder state and retry with a more explicit selector if needed.".to_string()), - ), - OperatorErrorKind::PermissionOrElevation => ( - ToolErrorKind::PermissionOrElevation, - true, - Some("Check local IPC and runtime-directory permissions, then retry.".to_string()), - ), - OperatorErrorKind::UnsupportedOperation => (ToolErrorKind::UnsupportedOperation, false, None), - OperatorErrorKind::IpcFailure => ( - ToolErrorKind::IpcFailure, - true, - Some("Retry after Cadder finishes its current transition or reconnect to the daemon.".to_string()), - ), - }; - - self.error_response( - kind, - self.redactor.redact_text(&error.message), - guidance, - retryable, - ) - } - - fn error_response( - &self, - kind: ToolErrorKind, - message: String, - guidance: Option, - retryable: bool, - ) -> CallToolResult { - let value = serde_json::to_value(ToolErrorResponse { - ok: false, - error: ToolErrorPayload { - kind, - message, - guidance, - retryable, - }, - }) - .expect("tool error response serializes"); - CallToolResult::structured_error(value) - } - - fn invalid_usage(&self, message: impl Into, guidance: Option) -> CallToolResult { - self.error_response( - ToolErrorKind::InvalidUsage, - self.redactor.redact_text(&message.into()), - guidance, - false, - ) - } - - fn build_overview(&self, snapshot: &cadder_protocol::GuiStateSnapshot) -> OverviewOutput { - let counts_view = counts(snapshot); - OverviewOutput { - captured_at_utc: snapshot.captured_at_utc.to_rfc3339(), - runtime_dir: self - .redactor - .redact_path(&self.operator.paths().runtime_dir().display().to_string()), - trust_boundary: TrustBoundaryOutput { - transport: "stdio".to_string(), - exposure: "local-only".to_string(), - daemon_model: "same-user per-runtime local daemon".to_string(), - implicit_start: false, - explicit_start_tool: "cadder_start_daemon".to_string(), - preferred_surfaces: vec![ - "Use MCP for bounded inspect and toggle workflows.".to_string(), - "Use cadderctl for watches, tails, and shell automation.".to_string(), - "Use cadder-tui for interactive browsing and richer operator context.".to_string(), - ], - }, - counts: OverviewCountsOutput { - entrypoints: counts_view.entrypoints, - domains: counts_view.domains, - active_domains: counts_view.active_domains, - runtime_diagnostic_count: snapshot.runtime.diagnostics.len(), - config_diagnostic_count: snapshot.config.diagnostics.len(), - }, - runtime: RuntimeSummaryOutput { - status: format!("{:?}", snapshot.runtime.status), - version: snapshot.runtime.version.clone(), - binary_path: snapshot - .runtime - .binary_path - .as_deref() - .map(|path| self.redactor.redact_path(path)), - admin_endpoint: self - .redactor - .redact_endpoint(snapshot.runtime.admin_endpoint.as_deref()), - }, - config: ConfigSummaryOutput { - status: format!("{:?}", snapshot.config.status), - effective_config_hash: snapshot.config.effective_config_hash.clone(), - last_attempted_at_utc: snapshot - .config - .last_attempted_at_utc - .map(|value| value.to_rfc3339()), - last_successful_reload_at_utc: snapshot - .config - .last_successful_reload_at_utc - .map(|value| value.to_rfc3339()), - }, - } - } - - fn build_entrypoints( - &self, - snapshot: &cadder_protocol::GuiStateSnapshot, - registration_filter: Option<&str>, - ) -> EntrypointListOutput { - let view = entrypoints_view(snapshot); - let entrypoints = view - .entrypoints - .into_iter() - .filter(|entrypoint| { - registration_filter.is_none_or(|filter| entrypoint.registration_id == filter) - }) - .map(|entrypoint| { - let domains = snapshot - .registrations - .iter() - .find(|registration| registration.registration_id == entrypoint.registration_id) - .map(|registration| { - registration - .registered_domains - .iter() - .map(|domain| domain.name.canonical.clone()) - .collect() - }) - .unwrap_or_default(); - - EntrypointOutput { - registration_id: entrypoint.registration_id, - activation_state: format!("{:?}", entrypoint.activation_state), - working_directory: self.redactor.redact_path(&entrypoint.working_directory), - config_path: self.redactor.redact_path(&entrypoint.config_path), - started_at_utc: entrypoint.started_at_utc.to_rfc3339(), - last_heartbeat_utc: entrypoint.last_heartbeat_utc.to_rfc3339(), - executable_path: entrypoint - .executable_path - .as_deref() - .map(|path| self.redactor.redact_path(path)), - domain_count: entrypoint.domain_count, - active_domain_count: entrypoint.active_domain_count, - domains, - adapter: entrypoint.adapter, - } - }) - .collect(); - - EntrypointListOutput { - captured_at_utc: view.captured_at_utc.to_rfc3339(), - entrypoints, - } - } - - fn build_domains(&self, view: cadder_operator::DomainListView) -> DomainListOutput { - DomainListOutput { - captured_at_utc: view.captured_at_utc.to_rfc3339(), - domains: view - .domains - .into_iter() - .map(|domain| DomainOutput { - registration_id: domain.registration_id, - domain: domain.domain, - canonical_domain: domain.canonical_domain, - activation_state: format!("{:?}", domain.activation_state), - entrypoint_activation_state: format!("{:?}", domain.entrypoint_activation_state), - working_directory: self.redactor.redact_path(&domain.working_directory), - config_path: self.redactor.redact_path(&domain.config_path), - }) - .collect(), - } - } - - fn build_diagnostics(&self, snapshot: &cadder_protocol::GuiStateSnapshot) -> DiagnosticsOutput { - let view = diagnostics_view(snapshot); - DiagnosticsOutput { - captured_at_utc: view.captured_at_utc.to_rfc3339(), - runtime_status: view.runtime.status, - config_status: view.config.status, - runtime_diagnostics: view - .runtime_diagnostics - .into_iter() - .map(|diagnostic| RuntimeDiagnosticOutput { - code: diagnostic.code, - message: self.redactor.redact_text(&diagnostic.message), - operation: diagnostic - .operation - .as_deref() - .map(|operation| truncate(&self.redactor.redact_text(operation), 120)), - }) - .collect(), - config_diagnostics: view - .config_diagnostics - .into_iter() - .map(|diagnostic| ConfigDiagnosticOutput { - code: diagnostic.code, - message: self.redactor.redact_text(&diagnostic.message), - domain_key: diagnostic.domain_key, - source_config_paths: diagnostic - .source_config_paths - .iter() - .map(|path| self.redactor.redact_path(path)) - .collect(), - }) - .collect(), - } - } - - fn build_logs(&self, view: cadder_operator::LogsView) -> LogsOutput { - LogsOutput { - stream_id: view.stream.stream_id, - channel: view.stream.channel, - domain_key: view.stream.domain_key, - stream_status: format!("{:?}", view.stream_status), - entries: view - .entries - .into_iter() - .map(|entry| LogEntryOutput { - sequence_number: entry.sequence_number, - timestamp_utc: entry.timestamp_utc.to_rfc3339(), - severity: format!("{:?}", entry.severity), - attribution_kind: format!("{:?}", entry.attribution_kind), - entry_kind: format!("{:?}", entry.entry_kind), - message: self.redactor.redact_text(&entry.raw_message), - operation: entry - .operation - .as_deref() - .map(|operation| truncate(&self.redactor.redact_text(operation), 120)), - source_registration_id: entry.source_registration_id, - }) - .collect(), - next_cursor: view.next_cursor, - has_gap: view.has_gap, - has_more_before: view.has_more_before, - truncated_by_retention: view.truncated_by_retention, - } - } -} - -#[tool_router] -impl CadderMcpServer { - #[tool( - name = "cadder_get_overview", - description = "Inspect local Cadder daemon health, runtime state, config summary, counts, and trust boundaries." - )] - async fn cadder_get_overview(&self) -> std::result::Result, CallToolResult> { - let snapshot = self - .operator - .query_snapshot("cadder_get_overview", "query daemon overview") - .await - .map_err(|error| self.map_error(error))?; - Ok(Json(self.build_overview(&snapshot))) - } - - #[tool( - name = "cadder_list_entrypoints", - description = "List Cadder entrypoints with bounded, redacted metadata suitable for agent inspection." - )] - async fn cadder_list_entrypoints( - &self, - Parameters(params): Parameters, - ) -> std::result::Result, CallToolResult> { - let snapshot = self - .operator - .query_snapshot("cadder_list_entrypoints", "query entrypoints") - .await - .map_err(|error| self.map_error(error))?; - Ok(Json(self.build_entrypoints( - &snapshot, - params.registration_id.as_deref(), - ))) - } - - #[tool( - name = "cadder_list_domains", - description = "List Cadder domains and their activation state without exposing raw host-specific paths." - )] - async fn cadder_list_domains( - &self, - Parameters(params): Parameters, - ) -> std::result::Result, CallToolResult> { - let snapshot = self - .operator - .query_snapshot("cadder_list_domains", "query domains") - .await - .map_err(|error| self.map_error(error))?; - let view = domains_view(&snapshot, params.registration_id.as_deref()); - Ok(Json(self.build_domains(view))) - } - - #[tool( - name = "cadder_show_diagnostics", - description = "Show bounded Cadder runtime and config diagnostics with secret and path redaction." - )] - async fn cadder_show_diagnostics( - &self, - ) -> std::result::Result, CallToolResult> { - let snapshot = self - .operator - .query_snapshot("cadder_show_diagnostics", "query diagnostics") - .await - .map_err(|error| self.map_error(error))?; - Ok(Json(self.build_diagnostics(&snapshot))) - } - - #[tool( - name = "cadder_get_logs", - description = "Read recent Cadder logs for the runtime, one entrypoint, or one domain with bounded results and redacted text." - )] - async fn cadder_get_logs( - &self, - Parameters(params): Parameters, - ) -> std::result::Result, CallToolResult> { - let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT); - if !(1..=MAX_LOG_LIMIT).contains(&limit) { - return Err(self.invalid_usage( - format!("`limit` must be between 1 and {MAX_LOG_LIMIT}."), - None, - )); - } - - if let Some(cursor) = params.cursor.as_deref() { - let valid_cursor = cursor - .strip_prefix("seq:") - .is_some_and(|rest| !rest.is_empty() && rest.chars().all(|ch| ch.is_ascii_digit())); - if !valid_cursor { - return Err(self.invalid_usage("Cursor must use the daemon `seq:` format.", None)); - } - } - - let target = match params.target { - LogTargetKind::Runtime => { - if params.registration_id.is_some() || params.domain.is_some() { - return Err(self.invalid_usage( - "Runtime log queries do not accept `registrationId` or `domain`.", - None, - )); - } - cadder_operator::LogsTarget::Runtime - } - LogTargetKind::Entrypoint => { - let Some(registration_id) = params.registration_id else { - return Err(self.invalid_usage("Entrypoint log queries require `registrationId`.", None)); - }; - if params.domain.is_some() { - return Err(self.invalid_usage("Entrypoint log queries do not accept `domain`.", None)); - } - cadder_operator::LogsTarget::Entrypoint { registration_id } - } - LogTargetKind::Domain => { - let Some(domain) = params.domain else { - return Err(self.invalid_usage("Domain log queries require `domain`.", None)); - }; - cadder_operator::LogsTarget::Domain(DomainSelector { - domain, - registration: params.registration_id, - }) - } - }; - - let stream = self - .operator - .resolve_logs_target("cadder_get_logs", target) - .await - .map_err(|error| self.map_error(error))?; - let view = self - .operator - .query_logs( - "cadder_get_logs", - "query retained logs", - stream, - limit, - params.cursor, - map_severity(params.minimum_severity), - ) - .await - .map_err(|error| self.map_error(error))?; - Ok(Json(self.build_logs(view))) - } - - #[tool( - name = "cadder_start_daemon", - description = "Explicitly start the local Cadder daemon for this runtime directory and return an updated overview." - )] - async fn cadder_start_daemon( - &self, - ) -> std::result::Result, CallToolResult> { - let already_running = self.operator.query_state_response().await.is_ok(); - self - .operator - .ensure_daemon_running("cadder_start_daemon") - .await - .map_err(|error| self.map_error(error))?; - let snapshot = self - .operator - .query_snapshot("cadder_start_daemon", "query daemon status after start") - .await - .map_err(|error| self.map_error(error))?; - Ok(Json(StartDaemonOutput { - started: !already_running, - message: if already_running { - "cadderd is already running.".to_string() - } else { - "cadderd started.".to_string() - }, - overview: self.build_overview(&snapshot), - })) - } - - #[tool( - name = "cadder_set_entrypoint_enabled", - description = "Enable or disable one Cadder entrypoint by registration ID." - )] - async fn cadder_set_entrypoint_enabled( - &self, - Parameters(params): Parameters, - ) -> std::result::Result, CallToolResult> { - let response = self - .operator - .set_entrypoint_enabled( - "cadder_set_entrypoint_enabled", - params.registration_id.clone(), - params.enabled, - ) - .await - .map_err(|error| self.map_error(error))?; - Ok(Json(ToggleEntrypointOutput { - registration_id: params.registration_id, - enabled: params.enabled, - message: self.redactor.redact_text(&response.message), - })) - } - - #[tool( - name = "cadder_set_domain_enabled", - description = "Enable or disable one Cadder domain with an optional registration filter for disambiguation." - )] - async fn cadder_set_domain_enabled( - &self, - Parameters(params): Parameters, - ) -> std::result::Result, CallToolResult> { - let response = self - .operator - .set_domain_enabled( - "cadder_set_domain_enabled", - &DomainSelector { - domain: params.domain.clone(), - registration: params.registration_id.clone(), - }, - params.enabled, - ) - .await - .map_err(|error| self.map_error(error))?; - Ok(Json(ToggleDomainOutput { - canonical_domain: cadder_protocol::canonicalize_domain(¶ms.domain), - registration_id: params.registration_id, - enabled: params.enabled, - message: self.redactor.redact_text(&response.message), - })) - } -} - -#[tool_handler] -impl ServerHandler for CadderMcpServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::default() - .with_server_info( - Implementation::new("cadder-mcp", env!("CARGO_PKG_VERSION")) - .with_title("Cadder MCP") - .with_description("Local stdio MCP server for safe Cadder inspection and bounded management."), - ) - .with_instructions( - "Cadder MCP is local-only over stdio. It talks to the same per-user Cadder daemon model as cadderctl. Only `cadder_start_daemon` may launch `cadderd`; all other tools are attach-only and return typed unavailable-daemon errors when the backend is absent. Prefer cadderctl for streaming watch/tail workflows and cadder-tui for interactive browsing." - ) - } -} - -fn map_severity(value: Option) -> Option { - match value { - None => None, - Some(SeverityParam::Trace) => Some(cadder_protocol::LogSeverity::Trace), - Some(SeverityParam::Debug) => Some(cadder_protocol::LogSeverity::Debug), - Some(SeverityParam::Info) => Some(cadder_protocol::LogSeverity::Info), - Some(SeverityParam::Warn) => Some(cadder_protocol::LogSeverity::Warn), - Some(SeverityParam::Error) => Some(cadder_protocol::LogSeverity::Error), - Some(SeverityParam::Fatal) => Some(cadder_protocol::LogSeverity::Fatal), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::redaction::McpRedactor; - - #[test] - fn invalid_usage_response_marks_tool_error() { - let server = CadderMcpServer { - operator: OperatorContext::from_paths( - cadder_daemon::RuntimePaths::resolve(Some(PathBuf::from("D:/runtime"))).unwrap(), - DaemonLaunchOptions::default(), - ), - redactor: McpRedactor::new(None, PathBuf::from("D:/runtime")), - }; - - let error = server.invalid_usage("bad input", Some("fix it".to_string())); - - assert_eq!(error.is_error, Some(true)); - assert!(error.structured_content.is_some()); - } -} diff --git a/crates/cadder-mcp/src/main.rs b/crates/cadder-mcp/src/main.rs deleted file mode 100644 index 2a3557f..0000000 --- a/crates/cadder-mcp/src/main.rs +++ /dev/null @@ -1,7 +0,0 @@ -use clap::Parser; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let args = cadder_mcp::Args::parse(); - cadder_mcp::CadderMcpServer::new(args)?.run_stdio().await -} diff --git a/crates/cadder-mcp/src/redaction.rs b/crates/cadder-mcp/src/redaction.rs deleted file mode 100644 index 7a66f66..0000000 --- a/crates/cadder-mcp/src/redaction.rs +++ /dev/null @@ -1,179 +0,0 @@ -use cadder_daemon::Redactor; -use std::path::{Path, PathBuf}; - -const MAX_FREE_TEXT_CHARS: usize = 400; - -#[derive(Debug, Clone)] -pub struct McpRedactor { - workspace_root: Option, - runtime_dir: PathBuf, -} - -impl McpRedactor { - pub fn new(workspace_root: Option, runtime_dir: PathBuf) -> Self { - Self { - workspace_root, - runtime_dir, - } - } - - pub fn redact_path(&self, raw: &str) -> String { - if raw.trim().is_empty() { - return raw.to_string(); - } - - let path = Path::new(raw); - if !path.is_absolute() { - return raw.replace('\\', "/"); - } - - if let Some(relative) = self.strip_prefix(path, self.workspace_root.as_deref()) { - return relative; - } - - if let Some(relative) = self.strip_prefix(path, Some(&self.runtime_dir)) { - return format!("/{}", relative); - } - - path - .file_name() - .and_then(|name| name.to_str()) - .map(|name| format!("/{name}")) - .unwrap_or_else(|| "".to_string()) - } - - pub fn redact_endpoint(&self, endpoint: Option<&str>) -> Option { - endpoint.map(|endpoint| { - if endpoint.contains("127.0.0.1") || endpoint.contains("localhost") { - "loopback".to_string() - } else { - "".to_string() - } - }) - } - - pub fn redact_text(&self, text: &str) -> String { - let base = Redactor::redact(text); - let sanitized = base - .split_whitespace() - .map(|token| self.redact_token(token)) - .collect::>() - .join(" "); - truncate(&sanitized, MAX_FREE_TEXT_CHARS) - } - - fn redact_token(&self, token: &str) -> String { - let (prefix, core, suffix) = split_token(token); - if core.is_empty() { - return token.to_string(); - } - - let redacted = if looks_like_absolute_path(&core) { - self.redact_path(&core) - } else { - core.to_string() - }; - - format!("{prefix}{redacted}{suffix}") - } - - fn strip_prefix(&self, path: &Path, base: Option<&Path>) -> Option { - let base = base?; - let relative = path.strip_prefix(base).ok()?; - Some(relative.to_string_lossy().replace('\\', "/")) - } -} - -fn looks_like_absolute_path(token: &str) -> bool { - let path = Path::new(token); - path.is_absolute() - || token.starts_with("\\\\") - || token.starts_with('/') - || token - .as_bytes() - .get(1) - .is_some_and(|separator| *separator == b':') -} - -fn split_token(token: &str) -> (String, String, String) { - let bytes = token.as_bytes(); - let mut start = 0; - while start < bytes.len() && is_wrapper(bytes[start] as char) { - start += 1; - } - - let mut end = bytes.len(); - while end > start && is_wrapper(bytes[end - 1] as char) { - end -= 1; - } - - ( - token[..start].to_string(), - token[start..end].to_string(), - token[end..].to_string(), - ) -} - -fn is_wrapper(ch: char) -> bool { - matches!( - ch, - '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | '!' | '?' - ) -} - -pub fn truncate(text: &str, max_chars: usize) -> String { - let char_count = text.chars().count(); - if char_count <= max_chars { - return text.to_string(); - } - - let truncated = text - .chars() - .take(max_chars.saturating_sub(3)) - .collect::(); - format!("{truncated}...") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn workspace_paths_become_relative() { - let redactor = McpRedactor::new( - Some(PathBuf::from("D:/Projects/Personal/Cadder")), - PathBuf::from("D:/Users/test/runtime"), - ); - - assert_eq!( - redactor.redact_path("D:/Projects/Personal/Cadder/crates/cadderctl/src/app.rs"), - "crates/cadderctl/src/app.rs" - ); - } - - #[test] - fn runtime_paths_use_runtime_prefix() { - let redactor = McpRedactor::new( - Some(PathBuf::from("D:/Projects/Personal/Cadder")), - PathBuf::from("D:/Users/test/runtime"), - ); - - assert_eq!( - redactor.redact_path("D:/Users/test/runtime/daemon.json"), - "/daemon.json" - ); - } - - #[test] - fn free_text_redacts_secrets_and_paths() { - let redactor = McpRedactor::new( - Some(PathBuf::from("D:/Projects/Personal/Cadder")), - PathBuf::from("D:/Users/test/runtime"), - ); - - let text = redactor - .redact_text("Authorization: bearer token=abc D:/Projects/Personal/Cadder/Caddyfile failed"); - - assert_eq!(text, "[redacted] [redacted] [redacted] Caddyfile failed"); - } -} diff --git a/crates/cadder-mcp/tests/mcp.rs b/crates/cadder-mcp/tests/mcp.rs deleted file mode 100644 index eb501cc..0000000 --- a/crates/cadder-mcp/tests/mcp.rs +++ /dev/null @@ -1,171 +0,0 @@ -mod support; - -use rmcp::{ - ServiceExt, model::CallToolRequestParams, object, serde_json, transport::TokioChildProcess, -}; -use serde_json::Value; -use std::process::Stdio; -use support::Harness; -use tokio::process::Command; - -fn parse_structured(result: &rmcp::model::CallToolResult) -> Value { - result.structured_content.clone().unwrap() -} - -async fn spawn_client(runtime_dir: &str) -> rmcp::service::RunningService { - let mut command = Command::new(env!("CARGO_BIN_EXE_cadder-mcp")); - command - .arg("--runtime-dir") - .arg(runtime_dir) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); - let transport = TokioChildProcess::new(command).unwrap(); - ().serve(transport).await.unwrap() -} - -#[tokio::test] -async fn stdio_smoke_and_tool_discovery_are_stable() { - let harness = Harness::start().await; - harness.register_entrypoint("shim-1").await; - let runtime_dir = harness.paths.runtime_dir().display().to_string(); - - let client = spawn_client(&runtime_dir).await; - let tools = client.peer().list_all_tools().await.unwrap(); - let names = tools - .iter() - .map(|tool| tool.name.as_ref()) - .collect::>(); - - assert_eq!( - names, - vec![ - "cadder_get_logs", - "cadder_get_overview", - "cadder_list_domains", - "cadder_list_entrypoints", - "cadder_set_domain_enabled", - "cadder_set_entrypoint_enabled", - "cadder_show_diagnostics", - "cadder_start_daemon", - ] - ); - assert!(tools.iter().all(|tool| tool.description.is_some())); - - let overview = client - .peer() - .call_tool(CallToolRequestParams::new("cadder_get_overview")) - .await - .unwrap(); - assert_eq!(overview.is_error, Some(false)); - assert_eq!(parse_structured(&overview)["counts"]["entrypoints"], 1); - - let toggle = client - .peer() - .call_tool( - CallToolRequestParams::new("cadder_set_domain_enabled").with_arguments(object!({ - "domain": "app.smarketing.localhost", - "registrationId": "shim-1", - "enabled": false - })), - ) - .await - .unwrap(); - assert_eq!(toggle.is_error, Some(false)); - - let snapshot = harness.snapshot().await; - let domain = snapshot.registrations[0] - .registered_domains - .iter() - .find(|domain| domain.name.canonical == "app.smarketing.localhost") - .unwrap(); - assert_eq!(format!("{:?}", domain.activation_state), "Inactive"); - - client.cancel().await.unwrap(); - harness.shutdown().await; -} - -#[tokio::test] -async fn unavailable_daemon_returns_typed_tool_error() { - let runtime_dir = tempfile::tempdir().unwrap(); - let runtime_arg = runtime_dir.path().to_string_lossy().to_string(); - let client = spawn_client(&runtime_arg).await; - - let result = client - .peer() - .call_tool(CallToolRequestParams::new("cadder_list_domains").with_arguments(object!({}))) - .await - .unwrap(); - - let structured = parse_structured(&result); - assert_eq!(result.is_error, Some(true)); - assert_eq!(structured["error"]["kind"], "daemonUnavailable"); - assert!( - structured["error"]["guidance"] - .as_str() - .unwrap() - .contains("cadder_start_daemon") - ); - - client.cancel().await.unwrap(); -} - -#[tokio::test] -async fn log_results_are_redacted_and_bounded() { - let harness = Harness::start().await; - harness.register_entrypoint("shim-1").await; - harness.append_domain_log( - "app.smarketing.localhost", - cadder_protocol::LogSeverity::Error, - "Authorization: bearer token=abc D:/Projects/Personal/Cadder/Caddyfile exploded", - ); - let runtime_dir = harness.paths.runtime_dir().display().to_string(); - let client = spawn_client(&runtime_dir).await; - - let result = client - .peer() - .call_tool( - CallToolRequestParams::new("cadder_get_logs").with_arguments(object!({ - "target": "domain", - "domain": "app.smarketing.localhost", - "registrationId": "shim-1", - "limit": 10 - })), - ) - .await - .unwrap(); - - let structured = parse_structured(&result); - let message = structured["entries"][0]["message"].as_str().unwrap(); - assert_eq!(result.is_error, Some(false)); - assert!(!message.contains("token=abc")); - assert!(!message.contains("D:/Projects/Personal/Cadder")); - - client.cancel().await.unwrap(); - harness.shutdown().await; -} - -#[tokio::test] -async fn invalid_log_limit_returns_typed_error() { - let harness = Harness::start().await; - let runtime_dir = harness.paths.runtime_dir().display().to_string(); - let client = spawn_client(&runtime_dir).await; - - let result = client - .peer() - .call_tool( - CallToolRequestParams::new("cadder_get_logs").with_arguments(object!({ - "target": "runtime", - "limit": 0 - })), - ) - .await - .unwrap(); - - let structured = parse_structured(&result); - assert_eq!(result.is_error, Some(true)); - assert_eq!(structured["error"]["kind"], "invalidUsage"); - - client.cancel().await.unwrap(); - harness.shutdown().await; -} diff --git a/crates/cadder-mcp/tests/support/mod.rs b/crates/cadder-mcp/tests/support/mod.rs deleted file mode 100644 index 47824a0..0000000 --- a/crates/cadder-mcp/tests/support/mod.rs +++ /dev/null @@ -1,227 +0,0 @@ -use cadder_daemon::{ - CadderClient, CadderSession, CaddyConfigAdapter, CaddyConfigCoordinator, DaemonServer, - DaemonState, ProcessRuntime, RealCaddyResolver, RuntimePaths, -}; -use cadder_protocol::{ - ActivationState, EntrypointInstanceIdentity, EntrypointRegistration, LogAttributionKind, - LogSeverity, LogStreamIdentity, OwnerProcessIdentity, QueryStateRequest, QueryStateResponse, - RegisterEntrypointRequest, RegisterEntrypointResponse, ShimRunMetadata, SourcePath, - message_types, new_request_id, -}; -use chrono::Utc; -use std::{ - fs, - path::{Path, PathBuf}, - sync::Mutex, - time::Duration, -}; -use tokio::{sync::watch, time::sleep}; - -const FIXTURE: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../cadder-daemon/tests/fixtures/SmarketingReverseProxy.Caddyfile" -)); - -pub struct Harness { - pub client: CadderClient, - pub state: DaemonState, - pub paths: RuntimePaths, - pub config_path: PathBuf, - sessions: Mutex>, - shutdown_tx: watch::Sender, - _temp: tempfile::TempDir, -} - -impl Harness { - pub async fn start() -> Self { - let temp = tempfile::tempdir().unwrap(); - let runtime_dir = temp.path().join("runtime"); - let paths = RuntimePaths::resolve(Some(runtime_dir)).unwrap(); - paths.ensure_dirs().unwrap(); - let config_path = temp.path().join("Caddyfile"); - fs::write(&config_path, FIXTURE).unwrap(); - let command_log_path = temp.path().join("fake-caddy-commands.log"); - let fake_caddy_path = write_fake_caddy(temp.path(), &command_log_path); - - let resolver = RealCaddyResolver::new(Some(fake_caddy_path.display().to_string())); - let adapter = CaddyConfigAdapter::new(resolver.clone()); - let runtime = ProcessRuntime::new(resolver, paths.clone()); - let state = DaemonState::new(CaddyConfigCoordinator::new(adapter, runtime)); - let server = DaemonServer::new(paths.clone(), state.clone()); - let (shutdown_tx, shutdown_rx) = watch::channel(false); - tokio::spawn(async move { - let _ = server.run_until(shutdown_rx).await; - }); - - let client = CadderClient::new(paths.clone()); - for _ in 0..50 { - if client - .request::<_, QueryStateResponse>( - message_types::QUERY_STATE_REQUEST, - message_types::QUERY_STATE_RESPONSE, - &QueryStateRequest { - request_id: new_request_id("wait"), - }, - ) - .await - .is_ok() - { - return Self { - client, - state, - paths, - config_path, - sessions: Mutex::new(Vec::new()), - shutdown_tx, - _temp: temp, - }; - } - sleep(Duration::from_millis(20)).await; - } - - panic!("daemon server did not become ready"); - } - - pub async fn register_entrypoint(&self, id: &str) { - let mut session = CadderSession::connect(&self.paths).await.unwrap(); - let response: RegisterEntrypointResponse = session - .request( - message_types::REGISTER_ENTRYPOINT_REQUEST, - message_types::REGISTER_ENTRYPOINT_RESPONSE, - &RegisterEntrypointRequest { - request_id: new_request_id("test-register"), - registration: registration(id, &format!("{id}-nonce"), &self.config_path), - }, - ) - .await - .unwrap(); - assert!(response.accepted, "{}", response.message); - self.sessions.lock().unwrap().push(session); - } - - pub async fn snapshot(&self) -> cadder_protocol::GuiStateSnapshot { - let response: QueryStateResponse = self - .client - .request( - message_types::QUERY_STATE_REQUEST, - message_types::QUERY_STATE_RESPONSE, - &QueryStateRequest { - request_id: new_request_id("test-query"), - }, - ) - .await - .unwrap(); - response.snapshot.unwrap() - } - - pub fn append_domain_log(&self, domain_key: &str, severity: LogSeverity, message: &str) { - self.state.logs().append( - LogStreamIdentity::domain(domain_key), - severity, - message, - LogAttributionKind::Domain, - None, - ); - } - - pub async fn shutdown(self) { - let _ = self.shutdown_tx.send(true); - } -} - -fn registration(id: &str, nonce: &str, config_path: &Path) -> EntrypointRegistration { - let now = Utc::now(); - let identity = EntrypointInstanceIdentity { - instance_id: id.to_string(), - started_at_utc: now, - shim_session_nonce: nonce.to_string(), - }; - EntrypointRegistration { - registration_id: id.to_string(), - entrypoint_instance: identity.clone(), - source_working_directory: SourcePath::new(".", None), - source_config_path: SourcePath::new( - config_path.display().to_string(), - Some(config_path.display().to_string()), - ), - registered_domains: Vec::new(), - activation_state: ActivationState::Active, - owner_process: OwnerProcessIdentity { - process_id: 1, - process_start_time_utc: now, - shim_session_nonce: nonce.to_string(), - executable_path: None, - }, - log_stream: LogStreamIdentity::entrypoint(id), - shim_run: Some(ShimRunMetadata { - adapter: Some("caddyfile".to_string()), - raw_arguments: vec!["run".to_string()], - command_line: "run".to_string(), - }), - created_at_utc: now, - last_heartbeat_utc: now, - } -} - -fn write_fake_caddy(dir: &Path, command_log_path: &Path) -> PathBuf { - const ADAPTED_JSON: &str = r#"{"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["api.smarketing.localhost","app.smarketing.localhost","mailbox.smarketing.localhost","storage.smarketing.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}}"#; - - #[cfg(windows)] - { - let path = dir.join("fake-caddy.cmd"); - fs::write( - &path, - format!( - r#"@echo off -echo %*>> "{command_log}" -if "%1"=="adapt" ( - echo {adapted_json} - exit 0 -) -if "%1"=="reload" ( - exit 0 -) -if "%1"=="run" ( - echo fake runtime started - exit 0 -) -exit 0 -"#, - command_log = command_log_path.display(), - adapted_json = ADAPTED_JSON, - ), - ) - .unwrap(); - path - } - - #[cfg(not(windows))] - { - use std::os::unix::fs::PermissionsExt; - let path = dir.join("fake-caddy"); - fs::write( - &path, - format!( - r#"#!/usr/bin/env sh -printf '%s\n' "$*" >> '{command_log}' -if [ "$1" = "adapt" ]; then - printf '%s\n' '{adapted_json}' - exit 0 -fi -if [ "$1" = "reload" ]; then - exit 0 -fi -if [ "$1" = "run" ]; then echo fake runtime started; exit 0; fi -exit 0 -"#, - command_log = command_log_path.display(), - adapted_json = ADAPTED_JSON, - ), - ) - .unwrap(); - let mut permissions = fs::metadata(&path).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&path, permissions).unwrap(); - path - } -} diff --git a/crates/cadder-operator/Cargo.toml b/crates/cadder-operator/Cargo.toml index 40a3a88..2f68425 100644 --- a/crates/cadder-operator/Cargo.toml +++ b/crates/cadder-operator/Cargo.toml @@ -11,3 +11,6 @@ cadder-daemon = { version = "0.8.0", path = "../cadder-daemon" } cadder-protocol = { version = "0.8.0", path = "../cadder-protocol" } chrono = { version = "=0.4.45", features = ["serde"] } serde = { version = "=1.0.228", features = ["derive"] } + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/crates/cadder-operator/src/error.rs b/crates/cadder-operator/src/error.rs index ead0852..f186de6 100644 --- a/crates/cadder-operator/src/error.rs +++ b/crates/cadder-operator/src/error.rs @@ -189,7 +189,7 @@ impl OperatorError { OperatorErrorKind::DaemonStartFailure, format!("Could not start cadderd: {}.", format_error_chain(error)), Some(format!( - "Retry `cadderctl daemon start --runtime-dir \"{}\"` after fixing the daemon path or startup problem.", + "Retry `cadder daemon start --runtime-dir \"{}\"` after fixing the daemon path or startup problem.", paths.display() )), ) @@ -240,7 +240,7 @@ pub fn error_indicates_permission(error: &Error) -> bool { pub fn start_guidance(paths: &Path) -> String { format!( - "Start `cadderd --runtime-dir \"{}\"` or run `cadderctl daemon start --runtime-dir \"{}\"`, then retry.", + "Start `cadderd --runtime-dir \"{}\"` or run `cadder daemon start --runtime-dir \"{}\"`, then retry.", paths.display(), paths.display() ) @@ -249,7 +249,7 @@ pub fn start_guidance(paths: &Path) -> String { #[cfg(test)] mod tests { use super::*; - use anyhow::anyhow; + use anyhow::{Context, anyhow}; #[test] fn exit_codes_remain_stable_for_all_error_kinds() { @@ -280,7 +280,7 @@ mod tests { assert_eq!(mapped.kind, OperatorErrorKind::DaemonUnavailable); assert_eq!(mapped.exit_code().code(), 3); - assert!(mapped.guidance.unwrap().contains("cadderctl daemon start")); + assert!(mapped.guidance.unwrap().contains("cadder daemon start")); } #[test] @@ -327,4 +327,90 @@ mod tests { assert_eq!(mapped.exit_code().code(), 4); assert!(mapped.message.contains("spawn failed")); } + + #[test] + fn operator_error_kinds_and_constructors_keep_stable_contracts() { + let cases = [ + ( + OperatorErrorKind::InvalidUsage, + OperatorExitCode::InvalidUsage, + ), + ( + OperatorErrorKind::DaemonUnavailable, + OperatorExitCode::DaemonUnavailable, + ), + ( + OperatorErrorKind::DaemonStartFailure, + OperatorExitCode::DaemonStartFailure, + ), + ( + OperatorErrorKind::TargetNotFound, + OperatorExitCode::TargetNotFound, + ), + ( + OperatorErrorKind::ConflictOrRejected, + OperatorExitCode::ConflictOrRejected, + ), + ( + OperatorErrorKind::PermissionOrElevation, + OperatorExitCode::PermissionOrElevation, + ), + ( + OperatorErrorKind::UnsupportedOperation, + OperatorExitCode::UnsupportedOperation, + ), + (OperatorErrorKind::IpcFailure, OperatorExitCode::IpcFailure), + ]; + + for (kind, exit_code) in cases { + assert_eq!(kind.exit_code(), exit_code); + } + + let unsupported = OperatorError::unsupported( + "iis handoff", + "not supported", + Some("retry elsewhere".to_string()), + ); + assert_eq!(unsupported.kind, OperatorErrorKind::UnsupportedOperation); + assert_eq!(unsupported.to_string(), "not supported"); + assert_eq!(unsupported.exit_code().code(), 8); + + let target = OperatorError::target_not_found("domains enable", "missing", None); + assert_eq!(target.kind, OperatorErrorKind::TargetNotFound); + + let conflict = OperatorError::conflict_or_rejected("domains enable", "busy", None); + assert_eq!(conflict.kind, OperatorErrorKind::ConflictOrRejected); + } + + #[test] + fn daemon_request_and_permission_detection_cover_permission_and_unavailable_kinds() { + let permission = Error::from(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "access is denied", + )); + let mapped = OperatorError::daemon_request( + "history show", + Path::new("runtime"), + "query history", + &permission, + ); + assert_eq!(mapped.kind, OperatorErrorKind::PermissionOrElevation); + assert!(error_indicates_permission(&permission)); + + for kind in [ + std::io::ErrorKind::NotFound, + std::io::ErrorKind::ConnectionRefused, + std::io::ErrorKind::ConnectionAborted, + std::io::ErrorKind::ConnectionReset, + std::io::ErrorKind::UnexpectedEof, + ] { + let error = Error::from(std::io::Error::new(kind, "daemon unavailable")); + assert!(daemon_error_indicates_unavailable(&error), "{kind:?}"); + } + + let text_permission = anyhow!("Access is denied while opening pipe"); + assert!(error_indicates_permission(&text_permission)); + let chained = Err::<(), _>(anyhow!("outer")).context("outer").unwrap_err(); + assert_eq!(format_error_chain(&chained), "outer"); + } } diff --git a/crates/cadder-operator/src/service.rs b/crates/cadder-operator/src/service.rs index 851ee5d..202a75e 100644 --- a/crates/cadder-operator/src/service.rs +++ b/crates/cadder-operator/src/service.rs @@ -41,13 +41,14 @@ impl OperatorContext { runtime_dir: Option, launch_options: DaemonLaunchOptions, ) -> Result { - let paths = RuntimePaths::resolve(runtime_dir).map_err(|error| { - OperatorError::invalid_usage( - command, - format!("Could not resolve the Cadder runtime directory: {error}."), - None, - ) - })?; + let paths = RuntimePaths::resolve_with_profile(runtime_dir, launch_options.runtime_profile) + .map_err(|error| { + OperatorError::invalid_usage( + command, + format!("Could not resolve the Cadder runtime directory: {error}."), + None, + ) + })?; Ok(Self::from_paths(paths, launch_options)) } @@ -187,9 +188,7 @@ impl OperatorContext { OperatorError::target_not_found( command, format!("Entrypoint `{registration_id}` was not found."), - Some( - "Run `cadderctl entrypoints list` to inspect valid registration IDs.".to_string(), - ), + Some("Run `cadder entrypoints list` to inspect valid registration IDs.".to_string()), ) })?; Ok(entrypoint.log_stream.clone()) @@ -238,7 +237,7 @@ impl OperatorContext { return Err(OperatorError::target_not_found( command, format!("Entrypoint `{registration_id}` was not found."), - Some("Run `cadderctl entrypoints list` to inspect valid registration IDs.".to_string()), + Some("Run `cadder entrypoints list` to inspect valid registration IDs.".to_string()), )); } @@ -299,7 +298,7 @@ impl OperatorContext { None => format!("Domain `{canonical_domain}` was not found."), }, Some( - "Run `cadderctl domains list` to inspect the current domain registrations.".to_string(), + "Run `cadder domains list` to inspect the current domain registrations.".to_string(), ), ), crate::DomainResolveError::Ambiguous { @@ -370,3 +369,237 @@ pub fn connected_status( ) -> crate::DaemonStatusView { daemon_status_connected(context.paths.runtime_dir(), snapshot) } + +#[cfg(test)] +mod tests { + use super::*; + use cadder_protocol::{ + ActivationState, ConfigState, DomainName, EntrypointInstanceIdentity, EntrypointRegistration, + OwnerProcessIdentity, RegisteredDomain, RuntimeState, SourcePath, + }; + use chrono::{TimeZone, Utc}; + + fn timestamp() -> chrono::DateTime { + Utc + .with_ymd_and_hms(2026, 6, 17, 11, 30, 0) + .single() + .unwrap() + } + + fn registration(id: &str, domains: &[&str]) -> EntrypointRegistration { + let now = timestamp(); + let identity = EntrypointInstanceIdentity { + instance_id: id.to_string(), + started_at_utc: now, + shim_session_nonce: format!("{id}-nonce"), + }; + EntrypointRegistration { + registration_id: id.to_string(), + entrypoint_instance: identity.clone(), + source_working_directory: SourcePath::new("D:/Projects/App", None), + source_config_path: SourcePath::new("D:/Projects/App/Caddyfile", None), + registered_domains: domains + .iter() + .map(|domain| RegisteredDomain { + name: DomainName::parse(*domain), + activation_state: ActivationState::Active, + upstream: None, + log_stream: LogStreamIdentity::domain(domain), + }) + .collect(), + activation_state: ActivationState::Active, + owner_process: OwnerProcessIdentity { + process_id: 42, + process_start_time_utc: now, + shim_session_nonce: identity.shim_session_nonce.clone(), + executable_path: Some("caddy.exe".to_string()), + }, + log_stream: LogStreamIdentity::entrypoint(id), + shim_run: None, + created_at_utc: now, + last_heartbeat_utc: now, + } + } + + fn snapshot(registrations: Vec) -> GuiStateSnapshot { + GuiStateSnapshot { + captured_at_utc: timestamp(), + registrations, + runtime: RuntimeState::idle(), + config: ConfigState::idle(), + storage: None, + } + } + + fn context() -> (tempfile::TempDir, OperatorContext) { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + ( + temp, + OperatorContext::from_paths(paths, DaemonLaunchOptions::default()), + ) + } + + #[test] + fn context_preserves_runtime_paths_and_launch_options() { + let temp = tempfile::tempdir().unwrap(); + let runtime_dir = temp.path().join("runtime"); + let daemon_path = temp.path().join("cadderd.exe"); + let context = OperatorContext::new( + "test", + Some(runtime_dir.clone()), + DaemonLaunchOptions { + explicit_daemon: Some(daemon_path.clone()), + real_caddy_command: Some("caddy-real".to_string()), + shim_path: Some(temp.path().join("caddy.exe")), + launch_mode: cadder_daemon::DaemonLaunchMode::ForegroundDiagnostic, + ..DaemonLaunchOptions::default() + }, + ) + .unwrap(); + + assert_eq!(context.paths().runtime_dir(), runtime_dir.as_path()); + assert_eq!( + context.launch_options().explicit_daemon.as_deref(), + Some(daemon_path.as_path()) + ); + assert_eq!( + context.launch_options().real_caddy_command.as_deref(), + Some("caddy-real") + ); + assert!(context.launch_options().shim_path.is_some()); + assert_eq!( + context.launch_options().launch_mode, + cadder_daemon::DaemonLaunchMode::ForegroundDiagnostic + ); + } + + #[test] + fn select_domain_returns_precise_operator_errors() { + let (_temp, context) = context(); + let snapshot = snapshot(vec![ + registration("shim-1", &["app.localhost"]), + registration("shim-2", &["app.localhost", "api.localhost"]), + ]); + + let selected = context + .select_domain( + "domains enable", + &snapshot, + &DomainSelector { + domain: "api.localhost".to_string(), + registration: None, + }, + ) + .unwrap(); + assert_eq!(selected.registration_id, "shim-2"); + assert_eq!(selected.canonical_domain, "api.localhost"); + + let missing = context + .select_domain( + "domains enable", + &snapshot, + &DomainSelector { + domain: "missing.localhost".to_string(), + registration: Some("shim-1".to_string()), + }, + ) + .unwrap_err(); + assert_eq!(missing.kind, crate::OperatorErrorKind::TargetNotFound); + assert!(missing.message.contains("shim-1")); + + let missing_any_entrypoint = context + .select_domain( + "domains enable", + &snapshot, + &DomainSelector { + domain: "missing.localhost".to_string(), + registration: None, + }, + ) + .unwrap_err(); + assert_eq!( + missing_any_entrypoint.kind, + crate::OperatorErrorKind::TargetNotFound + ); + assert_eq!( + missing_any_entrypoint.message, + "Domain `missing.localhost` was not found." + ); + + let ambiguous = context + .select_domain( + "domains enable", + &snapshot, + &DomainSelector { + domain: "app.localhost".to_string(), + registration: None, + }, + ) + .unwrap_err(); + assert_eq!(ambiguous.kind, crate::OperatorErrorKind::ConflictOrRejected); + assert!(ambiguous.message.contains("shim-1, shim-2")); + } + + #[test] + fn selector_and_logs_target_contracts_are_stable() { + let selector = DomainSelector { + domain: "App.Localhost".to_string(), + registration: Some("shim-1".to_string()), + }; + let target = LogsTarget::Domain(selector.clone()); + + assert_eq!(selector.clone(), selector); + assert_eq!(target.clone(), LogsTarget::Domain(selector)); + assert!(format!("{target:?}").contains("Domain")); + assert_eq!(LogsTarget::Runtime, LogsTarget::Runtime.clone()); + assert_eq!( + LogsTarget::Entrypoint { + registration_id: "shim-1".to_string(), + }, + LogsTarget::Entrypoint { + registration_id: "shim-1".to_string(), + } + .clone() + ); + } + + #[test] + fn connected_and_unavailable_statuses_include_operator_context() { + let (_temp, context) = context(); + let status = connected_status( + &context, + &snapshot(vec![registration( + "shim-1", + &["app.localhost", "api.localhost"], + )]), + ); + + assert_eq!(status.connection_state, ConnectionStateView::Connected); + assert_eq!(status.counts.entrypoints, 1); + assert_eq!(status.counts.domains, 2); + assert_eq!(status.counts.active_domains, 2); + + let not_running = anyhow::Error::from(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "connection refused", + )); + let status = unavailable_status(&context, ¬_running); + assert_eq!(status.connection_state, ConnectionStateView::NotRunning); + assert!(status.message.contains("is not running")); + assert!( + status + .guidance + .as_deref() + .is_some_and(|guidance| guidance.contains("cadderd --runtime-dir")) + ); + + let failed = anyhow::Error::msg("protocol mismatch"); + let status = unavailable_status(&context, &failed); + assert_eq!( + status.connection_state, + ConnectionStateView::ConnectionFailed + ); + assert!(status.message.contains("protocol mismatch")); + } +} diff --git a/crates/cadder-operator/src/view.rs b/crates/cadder-operator/src/view.rs index 29e0e99..f310b29 100644 --- a/crates/cadder-operator/src/view.rs +++ b/crates/cadder-operator/src/view.rs @@ -149,6 +149,7 @@ pub struct DomainView { pub registration_id: String, pub domain: String, pub canonical_domain: String, + pub upstream: Option, pub activation_state: ActivationState, pub entrypoint_activation_state: ActivationState, pub working_directory: String, @@ -268,6 +269,7 @@ pub fn domains_view( registration_id: registration.registration_id.clone(), domain: domain.name.raw.clone(), canonical_domain: domain.name.canonical.clone(), + upstream: domain.upstream.clone(), activation_state: domain.activation_state, entrypoint_activation_state: registration.activation_state, working_directory: registration.source_working_directory.raw.clone(), @@ -386,6 +388,7 @@ mod tests { .map(|domain| RegisteredDomain { name: DomainName::parse(*domain), activation_state: ActivationState::Active, + upstream: None, log_stream: LogStreamIdentity::domain(domain), }) .collect(), @@ -409,6 +412,7 @@ mod tests { registrations, runtime: RuntimeState::idle(), config: ConfigState::idle(), + storage: None, } } diff --git a/crates/cadder-protocol/src/autostart.rs b/crates/cadder-protocol/src/autostart.rs new file mode 100644 index 0000000..f76396f --- /dev/null +++ b/crates/cadder-protocol/src/autostart.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AutostartMode { + Disabled, + Daemon, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AutostartStatus { + Unknown, + Disabled, + Enabled, + Unsupported, + Misconfigured, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AutostartDiagnostic { + pub code: String, + pub message: String, +} diff --git a/crates/cadder-protocol/src/client.rs b/crates/cadder-protocol/src/client.rs new file mode 100644 index 0000000..69784fd --- /dev/null +++ b/crates/cadder-protocol/src/client.rs @@ -0,0 +1,9 @@ +use crate::{IpcEnvelope, ProtocolResult, StateChangedEvent}; + +pub trait EnvelopeClient { + fn request_envelope(&mut self, envelope: IpcEnvelope) -> ProtocolResult; +} + +pub trait StateEventSubscription { + fn next_state_event(&mut self) -> ProtocolResult; +} diff --git a/crates/cadder-protocol/src/commands.rs b/crates/cadder-protocol/src/commands.rs new file mode 100644 index 0000000..6d1cca6 --- /dev/null +++ b/crates/cadder-protocol/src/commands.rs @@ -0,0 +1,88 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{AutostartMode, EntrypointRegistration, HistoryKind}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RegisterEntrypointRequest { + pub request_id: String, + pub registration: EntrypointRegistration, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UnregisterEntrypointRequest { + pub request_id: String, + pub registration_id: String, + pub shim_session_nonce: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HeartbeatEntrypointRequest { + pub request_id: String, + pub registration_id: String, + pub shim_session_nonce: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QueryStateRequest { + pub request_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SubscribeStateRequest { + pub request_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SetEntrypointEnabledRequest { + pub request_id: String, + pub registration_id: String, + pub shim_session_nonce: Option, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SetDomainEnabledRequest { + pub request_id: String, + pub registration_id: String, + pub domain_key: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QueryHistoryRequest { + pub request_id: String, + pub kind: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QueryAutostartRequest { + pub request_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SetAutostartRequest { + pub request_id: String, + pub mode: AutostartMode, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ShutdownDaemonRequest { + pub request_id: String, +} + +pub fn new_request_id(prefix: &str) -> String { + format!("{prefix}-{}", Uuid::new_v4().simple()) +} diff --git a/crates/cadder-protocol/src/envelopes.rs b/crates/cadder-protocol/src/envelopes.rs new file mode 100644 index 0000000..49adea0 --- /dev/null +++ b/crates/cadder-protocol/src/envelopes.rs @@ -0,0 +1,58 @@ +use std::io; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::Value; + +use crate::{ + MIN_COMPATIBLE_PROTOCOL_VERSION, PROTOCOL_VERSION, ProtocolCapabilities, ProtocolError, + ProtocolResult, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IpcEnvelope { + pub protocol_version: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, + #[serde(rename = "type")] + pub message_type: String, + pub payload: Value, +} + +impl IpcEnvelope { + pub fn new( + message_type: impl Into, + payload: &T, + ) -> serde_json::Result { + Ok(Self { + protocol_version: PROTOCOL_VERSION, + capabilities: Some(ProtocolCapabilities::current()), + message_type: message_type.into(), + payload: serde_json::to_value(payload)?, + }) + } + + pub fn decode(&self) -> serde_json::Result { + self.decode_typed().map_err(|error| { + serde_json::Error::io(io::Error::new( + io::ErrorKind::InvalidData, + error.to_string(), + )) + }) + } + + pub fn decode_typed(&self) -> ProtocolResult { + ensure_compatible_protocol_version(self.protocol_version)?; + serde_json::from_value(self.payload.clone()).map_err(ProtocolError::payload_decode_failed) + } +} + +pub fn ensure_compatible_protocol_version(protocol_version: u16) -> ProtocolResult<()> { + if protocol_version >= MIN_COMPATIBLE_PROTOCOL_VERSION { + return Ok(()); + } + + Err(ProtocolError::incompatible_protocol_version( + protocol_version, + )) +} diff --git a/crates/cadder-protocol/src/errors.rs b/crates/cadder-protocol/src/errors.rs new file mode 100644 index 0000000..d3729d7 --- /dev/null +++ b/crates/cadder-protocol/src/errors.rs @@ -0,0 +1,309 @@ +use std::{error::Error, fmt}; + +use serde::{Deserialize, Serialize}; + +use crate::{MIN_COMPATIBLE_PROTOCOL_VERSION, PROTOCOL_VERSION}; + +pub mod capabilities { + pub const AUTOSTART: &str = "autostart"; + pub const HISTORY: &str = "history"; + pub const IIS_HANDOFF: &str = "iis-handoff"; + pub const LOGS: &str = "logs"; + pub const STATE_SUBSCRIPTION: &str = "state-subscription"; +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProtocolCapability { + pub name: String, + pub version: u16, + pub minimum_compatible_version: u16, +} + +impl ProtocolCapability { + pub fn new(name: impl Into, version: u16, minimum_compatible_version: u16) -> Self { + Self { + name: name.into(), + version, + minimum_compatible_version, + } + } + + pub fn supports_version(&self, required_version: u16) -> bool { + (self.minimum_compatible_version..=self.version).contains(&required_version) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProtocolCapabilities { + pub protocol_version: u16, + pub minimum_compatible_protocol_version: u16, + #[serde(default)] + pub supported_capabilities: Box<[String]>, + #[serde(default)] + pub supported_capability_versions: Box<[ProtocolCapability]>, +} + +impl ProtocolCapabilities { + pub fn current() -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + minimum_compatible_protocol_version: MIN_COMPATIBLE_PROTOCOL_VERSION, + supported_capabilities: current_capabilities(), + supported_capability_versions: current_capability_versions(), + } + } + + pub fn supports(&self, capability: &str) -> bool { + self + .supported_capability_versions + .iter() + .any(|supported| supported.name == capability) + || self + .supported_capabilities + .iter() + .any(|supported| supported == capability) + } + + pub fn require(&self, capability: impl Into) -> ProtocolResult<()> { + self.require_version(capability, 1) + } + + pub fn supports_version(&self, capability: &str, required_version: u16) -> bool { + if let Some(supported) = self + .supported_capability_versions + .iter() + .find(|supported| supported.name == capability) + { + return supported.supports_version(required_version); + } + + required_version == 1 + && self + .supported_capabilities + .iter() + .any(|supported| supported == capability) + } + + pub fn require_version( + &self, + capability: impl Into, + required_version: u16, + ) -> ProtocolResult<()> { + let capability = capability.into(); + if self.supports_version(&capability, required_version) { + return Ok(()); + } + + Err(ProtocolError::unsupported_capability_version( + capability, + required_version, + self.supported_capabilities.clone(), + self.supported_capability_versions.clone(), + )) + } +} + +pub fn current_capabilities() -> Box<[String]> { + [ + capabilities::AUTOSTART, + capabilities::HISTORY, + capabilities::IIS_HANDOFF, + capabilities::LOGS, + capabilities::STATE_SUBSCRIPTION, + ] + .iter() + .map(|capability| (*capability).to_string()) + .collect() +} + +pub fn current_capability_versions() -> Box<[ProtocolCapability]> { + [ + capabilities::AUTOSTART, + capabilities::HISTORY, + capabilities::IIS_HANDOFF, + capabilities::LOGS, + capabilities::STATE_SUBSCRIPTION, + ] + .into_iter() + .map(|capability| ProtocolCapability::new(capability, 1, 1)) + .collect() +} + +pub type ProtocolResult = Result; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ProtocolErrorKind { + IncompatibleProtocolVersion, + UnsupportedCapability, + PayloadDecodeFailed, + AccessDenied, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProtocolError { + pub kind: ProtocolErrorKind, + pub message: Box, + pub guidance: Option>, + pub protocol_version: Option, + pub minimum_compatible_protocol_version: Option, + pub current_protocol_version: Option, + pub required_capability: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub required_capability_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub denied_operation: Option>, + #[serde(default)] + pub supported_capabilities: Box<[String]>, + #[serde(default)] + pub supported_capability_versions: Box<[ProtocolCapability]>, +} + +impl ProtocolError { + pub fn incompatible_protocol_version(protocol_version: u16) -> Self { + Self { + kind: ProtocolErrorKind::IncompatibleProtocolVersion, + message: format!( + "unsupported Cadder IPC protocol version {protocol_version}; supported compatible range is {MIN_COMPATIBLE_PROTOCOL_VERSION}..={PROTOCOL_VERSION}" + ) + .into_boxed_str(), + guidance: Some(protocol_version_guidance(protocol_version).into_boxed_str()), + protocol_version: Some(protocol_version), + minimum_compatible_protocol_version: Some(MIN_COMPATIBLE_PROTOCOL_VERSION), + current_protocol_version: Some(PROTOCOL_VERSION), + required_capability: None, + required_capability_version: None, + denied_operation: None, + supported_capabilities: current_capabilities(), + supported_capability_versions: current_capability_versions(), + } + } + + pub fn unsupported_capability( + required_capability: impl Into, + supported_capabilities: impl Into>, + ) -> Self { + let supported_capabilities = supported_capabilities.into(); + let supported_capability_versions = supported_capabilities + .iter() + .map(|capability| ProtocolCapability::new(capability.clone(), 1, 1)) + .collect::>(); + Self::unsupported_capability_version( + required_capability, + 1, + supported_capabilities, + supported_capability_versions, + ) + } + + pub fn unsupported_capability_version( + required_capability: impl Into, + required_capability_version: u16, + supported_capabilities: impl Into>, + supported_capability_versions: impl Into>, + ) -> Self { + let required_capability = required_capability.into(); + Self { + kind: ProtocolErrorKind::UnsupportedCapability, + message: format!( + "unsupported Cadder protocol capability `{required_capability}` version {required_capability_version}" + ) + .into_boxed_str(), + guidance: Some(format!( + "Use one of the advertised Cadder capabilities, or upgrade the older Cadder node to a build that supports `{required_capability}` version {required_capability_version}." + ) + .into_boxed_str()), + protocol_version: Some(PROTOCOL_VERSION), + minimum_compatible_protocol_version: Some(MIN_COMPATIBLE_PROTOCOL_VERSION), + current_protocol_version: Some(PROTOCOL_VERSION), + required_capability: Some(required_capability.into_boxed_str()), + required_capability_version: Some(required_capability_version), + denied_operation: None, + supported_capabilities: supported_capabilities.into(), + supported_capability_versions: supported_capability_versions.into(), + } + } + + pub fn access_denied( + operation: impl Into, + message: impl Into, + guidance: Option, + ) -> Self { + Self { + kind: ProtocolErrorKind::AccessDenied, + message: message.into().into_boxed_str(), + guidance: guidance.map(String::into_boxed_str), + protocol_version: Some(PROTOCOL_VERSION), + minimum_compatible_protocol_version: Some(MIN_COMPATIBLE_PROTOCOL_VERSION), + current_protocol_version: Some(PROTOCOL_VERSION), + required_capability: None, + required_capability_version: None, + denied_operation: Some(operation.into().into_boxed_str()), + supported_capabilities: current_capabilities(), + supported_capability_versions: current_capability_versions(), + } + } + + pub fn payload_decode_failed(error: serde_json::Error) -> Self { + Self { + kind: ProtocolErrorKind::PayloadDecodeFailed, + message: format!("could not decode Cadder protocol payload: {error}").into_boxed_str(), + guidance: Some( + "Verify that the request payload matches the Cadder IPC schema for this message type." + .into(), + ), + protocol_version: None, + minimum_compatible_protocol_version: Some(MIN_COMPATIBLE_PROTOCOL_VERSION), + current_protocol_version: Some(PROTOCOL_VERSION), + required_capability: None, + required_capability_version: None, + denied_operation: None, + supported_capabilities: current_capabilities(), + supported_capability_versions: current_capability_versions(), + } + } +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for ProtocolError {} + +fn protocol_version_guidance(protocol_version: u16) -> String { + if protocol_version < MIN_COMPATIBLE_PROTOCOL_VERSION { + return format!( + "Upgrade the Cadder client to protocol version {MIN_COMPATIBLE_PROTOCOL_VERSION} or newer." + ); + } + + format!( + "Upgrade cadderd to a build that supports protocol version {protocol_version}, or downgrade the client to protocol version {PROTOCOL_VERSION}." + ) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProtocolErrorResponse { + pub request_id: String, + pub accepted: bool, + pub error: ProtocolError, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, +} + +impl ProtocolErrorResponse { + pub fn rejected(request_id: impl Into, error: ProtocolError) -> Self { + Self { + request_id: request_id.into(), + accepted: false, + error, + capabilities: Some(ProtocolCapabilities::current()), + } + } +} diff --git a/crates/cadder-protocol/src/events.rs b/crates/cadder-protocol/src/events.rs new file mode 100644 index 0000000..66e9120 --- /dev/null +++ b/crates/cadder-protocol/src/events.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +use crate::GuiStateSnapshot; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum StateChangeKind { + Snapshot, + RegistrationsChanged, + RuntimeChanged, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StateChangedEvent { + pub request_id: String, + pub sequence_number: u64, + pub change_kind: StateChangeKind, + pub snapshot: GuiStateSnapshot, + pub registration_id: Option, +} diff --git a/crates/cadder-protocol/src/history.rs b/crates/cadder-protocol/src/history.rs new file mode 100644 index 0000000..c9d0445 --- /dev/null +++ b/crates/cadder-protocol/src/history.rs @@ -0,0 +1,37 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::RuntimeDiagnostic; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StorageState { + pub backend: String, + pub path: Option, + pub schema_version: u32, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum HistoryKind { + Registration, + Runtime, + Config, + Iis, + Autostart, + Log, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HistoryRecord { + pub sequence_number: i64, + pub timestamp_utc: DateTime, + pub kind: HistoryKind, + pub summary: String, + pub registration_id: Option, + pub domain_key: Option, + pub payload: Value, +} diff --git a/crates/cadder-protocol/src/iis.rs b/crates/cadder-protocol/src/iis.rs new file mode 100644 index 0000000..899c401 --- /dev/null +++ b/crates/cadder-protocol/src/iis.rs @@ -0,0 +1,195 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IisBindingIdentity { + pub binding_id: String, + pub site_name: String, + pub protocol: String, + pub binding_information: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum IisHandoffState { + Available, + HandedOff, + Unsupported, + Conflict, + MissingRoute, + Unavailable, + Busy, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum IisIssueKind { + IisUnavailable, + InsufficientPrivileges, + ElevationRequired, + ElevationDenied, + ElevationUnsupported, + UnsupportedBindingShape, + MissingTlsCertificate, + Conflict, + MissingBinding, + MissingRoute, + RollbackSucceeded, + RollbackFailed, + RestoreFailed, + Busy, + ProviderError, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IisIssue { + pub kind: IisIssueKind, + pub message: String, +} + +impl IisIssue { + pub fn new(kind: IisIssueKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IisRestoreMetadataSummary { + pub site_name: String, + pub protocol: String, + pub ip_address: String, + pub port: u16, + pub host_header: String, + pub binding_information: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum IisPrivilegeLevel { + User, + Administrator, + Unsupported, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum IisOperationStepStatus { + Pending, + Succeeded, + RequiresElevation, + Approved, + Denied, + Failed, + Skipped, + Unsupported, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum IisElevationApproval { + NotRequired, + Required, + Approved, + Denied, + Unsupported, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IisOperationStep { + pub step_id: String, + pub label: String, + pub privilege_level: IisPrivilegeLevel, + pub status: IisOperationStepStatus, + pub approval: IisElevationApproval, + pub issue: Option, +} + +impl IisOperationStep { + pub fn user(step_id: impl Into, label: impl Into) -> Self { + Self { + step_id: step_id.into(), + label: label.into(), + privilege_level: IisPrivilegeLevel::User, + status: IisOperationStepStatus::Pending, + approval: IisElevationApproval::NotRequired, + issue: None, + } + } + + pub fn administrator(step_id: impl Into, label: impl Into) -> Self { + Self { + step_id: step_id.into(), + label: label.into(), + privilege_level: IisPrivilegeLevel::Administrator, + status: IisOperationStepStatus::RequiresElevation, + approval: IisElevationApproval::Required, + issue: None, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum IisFollowUpAction { + RetryElevation, + RollbackHandoff, + RetryRestore, + RemoveLoopbackBinding, + ClearRestoreMetadata, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IisBinding { + pub identity: IisBindingIdentity, + pub ip_address: String, + pub port: u16, + pub host_header: String, + pub domain_key: Option, + pub handoff_state: IisHandoffState, + pub issue: Option, + pub restore_metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QueryIisBindingsRequest { + pub request_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QueryIisBindingsResponse { + pub request_id: String, + pub accepted: bool, + pub message: String, + pub bindings: Vec, + pub issue: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SetIisHandoffRequest { + pub request_id: String, + pub binding_id: String, + pub enabled: bool, + pub route_host: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SetIisHandoffResponse { + pub request_id: String, + pub accepted: bool, + pub message: String, + pub binding: Option, + pub issue: Option, + pub steps: Vec, + pub follow_up_actions: Vec, +} diff --git a/crates/cadder-protocol/src/lib.rs b/crates/cadder-protocol/src/lib.rs index 2483d05..1df7b80 100644 --- a/crates/cadder-protocol/src/lib.rs +++ b/crates/cadder-protocol/src/lib.rs @@ -1,752 +1,704 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use serde_json::Value; -use uuid::Uuid; - -pub const PROTOCOL_VERSION: u16 = 1; - -pub mod message_types { - pub const REGISTER_ENTRYPOINT_REQUEST: &str = "register-entrypoint-request"; - pub const REGISTER_ENTRYPOINT_RESPONSE: &str = "register-entrypoint-response"; - pub const UNREGISTER_ENTRYPOINT_REQUEST: &str = "unregister-entrypoint-request"; - pub const UNREGISTER_ENTRYPOINT_RESPONSE: &str = "unregister-entrypoint-response"; - pub const HEARTBEAT_ENTRYPOINT_REQUEST: &str = "heartbeat-entrypoint-request"; - pub const HEARTBEAT_ENTRYPOINT_RESPONSE: &str = "heartbeat-entrypoint-response"; - pub const QUERY_STATE_REQUEST: &str = "query-state-request"; - pub const QUERY_STATE_RESPONSE: &str = "query-state-response"; - pub const SUBSCRIBE_STATE_REQUEST: &str = "subscribe-state-request"; - pub const STATE_CHANGED_EVENT: &str = "state-changed-event"; - pub const SET_ENTRYPOINT_ENABLED_REQUEST: &str = "set-entrypoint-enabled-request"; - pub const SET_ENTRYPOINT_ENABLED_RESPONSE: &str = "set-entrypoint-enabled-response"; - pub const SET_DOMAIN_ENABLED_REQUEST: &str = "set-domain-enabled-request"; - pub const SET_DOMAIN_ENABLED_RESPONSE: &str = "set-domain-enabled-response"; - pub const QUERY_IIS_BINDINGS_REQUEST: &str = "query-iis-bindings-request"; - pub const QUERY_IIS_BINDINGS_RESPONSE: &str = "query-iis-bindings-response"; - pub const SET_IIS_HANDOFF_REQUEST: &str = "set-iis-handoff-request"; - pub const SET_IIS_HANDOFF_RESPONSE: &str = "set-iis-handoff-response"; - pub const QUERY_LOGS_REQUEST: &str = "query-logs-request"; - pub const QUERY_LOGS_RESPONSE: &str = "query-logs-response"; - pub const SHUTDOWN_DAEMON_REQUEST: &str = "shutdown-daemon-request"; - pub const SHUTDOWN_DAEMON_RESPONSE: &str = "shutdown-daemon-response"; -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct IpcEnvelope { - pub protocol_version: u16, - #[serde(rename = "type")] - pub message_type: String, - pub payload: Value, -} +pub const PROTOCOL_VERSION: u16 = 2; +pub const MIN_COMPATIBLE_PROTOCOL_VERSION: u16 = 1; + +pub mod autostart; +pub mod client; +pub mod commands; +pub mod envelopes; +pub mod errors; +pub mod events; +pub mod history; +pub mod iis; +pub mod logs; +pub mod message_types; +pub mod responses; +pub mod state; + +pub use autostart::*; +pub use client::*; +pub use commands::*; +pub use envelopes::*; +pub use errors::*; +pub use events::*; +pub use history::*; +pub use iis::*; +pub use logs::*; +pub use message_types::*; +pub use responses::*; +pub use state::*; +#[cfg(test)] +mod tests { + use chrono::Utc; + use serde::{Serialize, de::DeserializeOwned}; -impl IpcEnvelope { - pub fn new( - message_type: impl Into, - payload: &T, - ) -> serde_json::Result { - Ok(Self { - protocol_version: PROTOCOL_VERSION, - message_type: message_type.into(), - payload: serde_json::to_value(payload)?, - }) - } + use super::*; - pub fn decode(&self) -> serde_json::Result { - serde_json::from_value(self.payload.clone()) + #[test] + fn canonicalizes_domains() { + assert_eq!( + canonicalize_domain(" WWW.Example.Localhost. "), + "www.example.localhost" + ); } -} -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum ActivationState { - Unknown, - Registered, - Activating, - Active, - Inactive, - Faulted, -} + #[test] + fn serializes_envelope_with_camel_case_payload() { + let request = QueryStateRequest { + request_id: "request-1".to_string(), + }; + let envelope = IpcEnvelope::new(message_types::QUERY_STATE_REQUEST, &request).unwrap(); + let json = serde_json::to_string(&envelope).unwrap(); -impl ActivationState { - pub fn is_enabled(self) -> bool { - matches!(self, Self::Registered | Self::Activating | Self::Active) - } + assert!(json.contains(&format!("\"protocolVersion\":{PROTOCOL_VERSION}"))); + assert!(json.contains("\"capabilities\"")); + assert!(json.contains("\"supportedCapabilityVersions\"")); + assert!(json.contains("\"type\":\"query-state-request\"")); + assert!(json.contains("\"requestId\":\"request-1\"")); - pub fn from_enabled(enabled: bool) -> Self { - if enabled { - Self::Active - } else { - Self::Inactive - } + let decoded: QueryStateRequest = envelope.decode().unwrap(); + assert_eq!(decoded, request); } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum RuntimeStatus { - Unknown, - NotResolved, - Resolved, - Running, - Unhealthy, - Idle, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum ConfigApplyStatus { - Unknown, - NotApplied, - Applied, - Failed, - Idle, -} -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "camelCase")] -pub enum LogSeverity { - Unknown, - Trace, - Debug, - Info, - Warn, - Error, - Fatal, -} + #[test] + fn decode_rejects_protocol_version_below_compatibility_floor() { + let request = QueryStateRequest { + request_id: "request-1".to_string(), + }; + let mut envelope = IpcEnvelope::new(message_types::QUERY_STATE_REQUEST, &request).unwrap(); + envelope.protocol_version = MIN_COMPATIBLE_PROTOCOL_VERSION.saturating_sub(1); -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum LogAttributionKind { - Unknown, - Runtime, - RuntimeControl, - Entrypoint, - Domain, -} + let error = envelope.decode::().unwrap_err(); -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum LogEntryKind { - Normal, - Lifecycle, - IngestionOverflow, - RetentionGap, -} + assert!( + error + .to_string() + .contains("unsupported Cadder IPC protocol version") + ); + } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum LogStreamStatus { - Unknown, - Empty, - Active, - Stale, - Removed, - ReadError, -} + #[test] + fn decode_accepts_newer_protocol_version_with_supported_payload() { + let request = QueryStateRequest { + request_id: "request-1".to_string(), + }; + let mut envelope = IpcEnvelope::new(message_types::QUERY_STATE_REQUEST, &request).unwrap(); + envelope.protocol_version = PROTOCOL_VERSION.saturating_add(10); -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SourcePath { - pub raw: String, - pub canonical: Option, -} + let decoded: QueryStateRequest = envelope.decode().unwrap(); -impl SourcePath { - pub fn new(raw: impl Into, canonical: Option) -> Self { - Self { - raw: raw.into(), - canonical, - } + assert_eq!(decoded, request); } -} -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct DomainName { - pub raw: String, - pub canonical: String, -} + #[test] + fn decode_accepts_older_compatible_protocol_version_with_known_fields() { + let request = QueryStateRequest { + request_id: "request-1".to_string(), + }; + let mut envelope = IpcEnvelope::new(message_types::QUERY_STATE_REQUEST, &request).unwrap(); + envelope.protocol_version = MIN_COMPATIBLE_PROTOCOL_VERSION; + envelope.payload["futureOptionalField"] = serde_json::json!("ignored"); -impl DomainName { - pub fn parse(raw: impl Into) -> Self { - let raw = raw.into(); - Self { - canonical: canonicalize_domain(&raw), - raw, - } + let decoded: QueryStateRequest = envelope.decode().unwrap(); + + assert_eq!(decoded, request); } -} -pub fn canonicalize_domain(raw: &str) -> String { - raw.trim().trim_end_matches('.').to_ascii_lowercase() -} + #[test] + fn decode_typed_reports_protocol_floor_compatibility_error() { + let request = QueryStateRequest { + request_id: "request-1".to_string(), + }; + let mut envelope = IpcEnvelope::new(message_types::QUERY_STATE_REQUEST, &request).unwrap(); + envelope.protocol_version = MIN_COMPATIBLE_PROTOCOL_VERSION.saturating_sub(1); -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct EntrypointInstanceIdentity { - pub instance_id: String, - pub started_at_utc: DateTime, - pub shim_session_nonce: String, -} + let error = envelope.decode_typed::().unwrap_err(); -impl EntrypointInstanceIdentity { - pub fn new(started_at_utc: DateTime) -> Self { - let id = format!("shim-{}", Uuid::new_v4().simple()); - Self { - instance_id: id, - started_at_utc, - shim_session_nonce: Uuid::new_v4().simple().to_string(), - } + assert_eq!(error.kind, ProtocolErrorKind::IncompatibleProtocolVersion); + assert_eq!( + error.minimum_compatible_protocol_version, + Some(MIN_COMPATIBLE_PROTOCOL_VERSION) + ); + assert_eq!(error.current_protocol_version, Some(PROTOCOL_VERSION)); + assert!( + error + .guidance + .as_deref() + .is_some_and(|guidance| guidance.contains("Upgrade the Cadder client")) + ); } -} -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct OwnerProcessIdentity { - pub process_id: u32, - pub process_start_time_utc: DateTime, - pub shim_session_nonce: String, - pub executable_path: Option, -} + #[test] + fn protocol_capabilities_report_unsupported_capability_as_typed_error() { + let capabilities = ProtocolCapabilities::current(); -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct LogStreamIdentity { - pub stream_id: String, - pub domain_key: Option, - pub channel: String, -} + let error = capabilities + .require("future-dashboard") + .expect_err("future dashboard must not be supported by the reset contract"); -impl LogStreamIdentity { - pub fn runtime_control() -> Self { - Self { - stream_id: "runtime-control".to_string(), - domain_key: None, - channel: "control".to_string(), - } + assert_eq!(error.kind, ProtocolErrorKind::UnsupportedCapability); + assert_eq!( + error.required_capability.as_deref(), + Some("future-dashboard") + ); + assert_eq!(error.required_capability_version, Some(1)); + assert!(error.supported_capabilities.contains(&"logs".to_string())); + assert!( + error + .supported_capability_versions + .iter() + .any(|capability| capability.name == "logs" && capability.version == 1) + ); + assert!( + error + .guidance + .as_deref() + .is_some_and(|guidance| guidance.contains("future-dashboard")) + ); } - pub fn entrypoint(registration_id: &str) -> Self { - Self { - stream_id: format!("entrypoint-{registration_id}"), - domain_key: None, - channel: "caddy".to_string(), - } - } + #[test] + fn protocol_capabilities_check_versioned_capability_ranges() { + let capabilities = ProtocolCapabilities { + protocol_version: PROTOCOL_VERSION, + minimum_compatible_protocol_version: MIN_COMPATIBLE_PROTOCOL_VERSION, + supported_capabilities: vec!["logs".to_string()].into_boxed_slice(), + supported_capability_versions: vec![ProtocolCapability::new("logs", 3, 2)].into_boxed_slice(), + }; - pub fn domain(domain_key: &str) -> Self { - Self { - stream_id: format!("domain-{domain_key}"), - domain_key: Some(domain_key.to_string()), - channel: "caddy".to_string(), - } - } -} + assert!(capabilities.supports_version("logs", 2)); + assert!(capabilities.supports_version("logs", 3)); + assert!(!capabilities.supports_version("logs", 1)); -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct RegisteredDomain { - pub name: DomainName, - pub activation_state: ActivationState, - pub log_stream: LogStreamIdentity, -} + let error = capabilities + .require_version("logs", 4) + .expect_err("future logs capability version should require a newer node"); -impl RegisteredDomain { - pub fn active(raw: impl Into) -> Self { - let name = DomainName::parse(raw); - Self { - log_stream: LogStreamIdentity::domain(&name.canonical), - name, - activation_state: ActivationState::Active, - } + assert_eq!(error.kind, ProtocolErrorKind::UnsupportedCapability); + assert_eq!(error.required_capability.as_deref(), Some("logs")); + assert_eq!(error.required_capability_version, Some(4)); + assert_eq!(error.supported_capability_versions[0].version, 3); } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ShimRunMetadata { - pub adapter: Option, - pub raw_arguments: Vec, - pub command_line: String, -} -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct EntrypointRegistration { - pub registration_id: String, - pub entrypoint_instance: EntrypointInstanceIdentity, - pub source_working_directory: SourcePath, - pub source_config_path: SourcePath, - pub registered_domains: Vec, - pub activation_state: ActivationState, - pub owner_process: OwnerProcessIdentity, - pub log_stream: LogStreamIdentity, - pub shim_run: Option, - pub created_at_utc: DateTime, - pub last_heartbeat_utc: DateTime, -} + #[test] + fn access_denied_error_reports_denied_operation() { + let error = ProtocolError::access_denied( + "set-autostart-request", + "IPC policy rejected the request.", + Some("Use the owning local account.".to_string()), + ); -impl EntrypointRegistration { - pub fn validate_owner(&self) -> Result<(), String> { - if self.registration_id.trim().is_empty() { - return Err("registration_id is required".to_string()); - } - if self.registration_id != self.entrypoint_instance.instance_id { - return Err("registration_id must match instance_id".to_string()); - } - if self - .entrypoint_instance - .shim_session_nonce - .trim() - .is_empty() - || self.entrypoint_instance.shim_session_nonce != self.owner_process.shim_session_nonce - { - return Err("entrypoint and owner shim session nonce values must match".to_string()); - } - Ok(()) + assert_eq!(error.kind, ProtocolErrorKind::AccessDenied); + assert_eq!( + error.denied_operation.as_deref(), + Some("set-autostart-request") + ); + assert!( + error + .guidance + .as_deref() + .is_some_and(|guidance| guidance.contains("owning local account")) + ); } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeDiagnostic { - pub code: String, - pub message: String, - pub operation: Option, -} -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeState { - pub status: RuntimeStatus, - pub binary_path: Option, - pub version: Option, - pub process_id: Option, - pub admin_endpoint: Option, - pub diagnostics: Vec, -} - -impl RuntimeState { - pub fn idle() -> Self { - Self { - status: RuntimeStatus::Idle, - binary_path: None, - version: None, - process_id: None, - admin_endpoint: None, - diagnostics: Vec::new(), + #[test] + fn envelope_new_returns_payload_serialization_errors() { + struct FailingPayload; + + impl Serialize for FailingPayload { + fn serialize(&self, _serializer: S) -> Result + where + S: serde::Serializer, + { + Err(serde::ser::Error::custom("payload serialization failed")) + } } - } -} -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ConfigDiagnostic { - pub code: String, - pub message: String, - pub domain_key: Option, - pub source_config_paths: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ConfigState { - pub status: ConfigApplyStatus, - pub last_attempted_at_utc: Option>, - pub last_successful_reload_at_utc: Option>, - pub effective_config_hash: Option, - pub diagnostics: Vec, -} + let error = IpcEnvelope::new("failing-payload", &FailingPayload).unwrap_err(); -impl ConfigState { - pub fn idle() -> Self { - Self { - status: ConfigApplyStatus::Idle, - last_attempted_at_utc: None, - last_successful_reload_at_utc: None, - effective_config_hash: None, - diagnostics: Vec::new(), - } + assert!(error.to_string().contains("payload serialization failed")); } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct LogEntry { - pub sequence_number: u64, - pub timestamp_utc: DateTime, - pub severity: LogSeverity, - pub stream: LogStreamIdentity, - pub attribution_kind: LogAttributionKind, - pub entry_kind: LogEntryKind, - pub raw_message: String, - pub domain_key: Option, - pub source_registration_id: Option, - pub source_instance_id: Option, - pub operation: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct GuiStateSnapshot { - pub captured_at_utc: DateTime, - pub registrations: Vec, - pub runtime: RuntimeState, - pub config: ConfigState, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct RegisterEntrypointRequest { - pub request_id: String, - pub registration: EntrypointRegistration, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct RegisterEntrypointResponse { - pub request_id: String, - pub accepted: bool, - pub message: String, - pub registration_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UnregisterEntrypointRequest { - pub request_id: String, - pub registration_id: String, - pub shim_session_nonce: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct BasicResponse { - pub request_id: String, - pub accepted: bool, - pub message: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct HeartbeatEntrypointRequest { - pub request_id: String, - pub registration_id: String, - pub shim_session_nonce: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct QueryStateRequest { - pub request_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct QueryStateResponse { - pub request_id: String, - pub accepted: bool, - pub message: String, - pub snapshot: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SubscribeStateRequest { - pub request_id: String, -} -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum StateChangeKind { - Snapshot, - RegistrationsChanged, - RuntimeChanged, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct StateChangedEvent { - pub request_id: String, - pub sequence_number: u64, - pub change_kind: StateChangeKind, - pub snapshot: GuiStateSnapshot, - pub registration_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SetEntrypointEnabledRequest { - pub request_id: String, - pub registration_id: String, - pub shim_session_nonce: Option, - pub enabled: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SetDomainEnabledRequest { - pub request_id: String, - pub registration_id: String, - pub domain_key: String, - pub enabled: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct IisBindingIdentity { - pub binding_id: String, - pub site_name: String, - pub protocol: String, - pub binding_information: String, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum IisHandoffState { - Available, - HandedOff, - Unsupported, - Conflict, - MissingRoute, - Unavailable, - Busy, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum IisIssueKind { - IisUnavailable, - InsufficientPrivileges, - ElevationRequired, - ElevationDenied, - ElevationUnsupported, - UnsupportedBindingShape, - MissingTlsCertificate, - Conflict, - MissingBinding, - MissingRoute, - RollbackSucceeded, - RollbackFailed, - RestoreFailed, - Busy, - ProviderError, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct IisIssue { - pub kind: IisIssueKind, - pub message: String, -} - -impl IisIssue { - pub fn new(kind: IisIssueKind, message: impl Into) -> Self { - Self { - kind, - message: message.into(), + #[test] + fn activation_state_enabled_variants_are_stable() { + let cases = [ + (ActivationState::Unknown, false), + (ActivationState::Registered, true), + (ActivationState::Activating, true), + (ActivationState::Active, true), + (ActivationState::Inactive, false), + (ActivationState::Faulted, false), + ]; + + for (state, expected) in cases { + assert_eq!(state.is_enabled(), expected, "{state:?}"); } } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct IisRestoreMetadataSummary { - pub site_name: String, - pub protocol: String, - pub ip_address: String, - pub port: u16, - pub host_header: String, - pub binding_information: String, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum IisPrivilegeLevel { - User, - Administrator, - Unsupported, -} -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum IisOperationStepStatus { - Pending, - Succeeded, - RequiresElevation, - Approved, - Denied, - Failed, - Skipped, - Unsupported, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum IisElevationApproval { - NotRequired, - Required, - Approved, - Denied, - Unsupported, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct IisOperationStep { - pub step_id: String, - pub label: String, - pub privilege_level: IisPrivilegeLevel, - pub status: IisOperationStepStatus, - pub approval: IisElevationApproval, - pub issue: Option, -} - -impl IisOperationStep { - pub fn user(step_id: impl Into, label: impl Into) -> Self { - Self { - step_id: step_id.into(), - label: label.into(), - privilege_level: IisPrivilegeLevel::User, - status: IisOperationStepStatus::Pending, - approval: IisElevationApproval::NotRequired, - issue: None, + #[test] + fn enum_contract_variants_roundtrip_with_camel_case_names() { + fn roundtrip(values: &[T], expected: &[&str]) + where + T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug, + { + let json = serde_json::to_string(values).unwrap(); + for variant in expected { + assert!(json.contains(variant), "{json} should contain {variant}"); + } + let decoded: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, values); } - } - pub fn administrator(step_id: impl Into, label: impl Into) -> Self { - Self { - step_id: step_id.into(), - label: label.into(), - privilege_level: IisPrivilegeLevel::Administrator, - status: IisOperationStepStatus::RequiresElevation, - approval: IisElevationApproval::Required, - issue: None, - } + roundtrip( + &[ + ActivationState::Unknown, + ActivationState::Registered, + ActivationState::Activating, + ActivationState::Active, + ActivationState::Inactive, + ActivationState::Faulted, + ], + &[ + "unknown", + "registered", + "activating", + "active", + "inactive", + "faulted", + ], + ); + roundtrip( + &[ + RuntimeStatus::Unknown, + RuntimeStatus::NotResolved, + RuntimeStatus::Resolved, + RuntimeStatus::Running, + RuntimeStatus::Unhealthy, + RuntimeStatus::Idle, + ], + &[ + "unknown", + "notResolved", + "resolved", + "running", + "unhealthy", + "idle", + ], + ); + roundtrip( + &[ + ConfigApplyStatus::Unknown, + ConfigApplyStatus::NotApplied, + ConfigApplyStatus::Applied, + ConfigApplyStatus::Failed, + ConfigApplyStatus::Idle, + ], + &["unknown", "notApplied", "applied", "failed", "idle"], + ); + roundtrip( + &[ + LogSeverity::Unknown, + LogSeverity::Trace, + LogSeverity::Debug, + LogSeverity::Info, + LogSeverity::Warn, + LogSeverity::Error, + LogSeverity::Fatal, + ], + &[ + "unknown", "trace", "debug", "info", "warn", "error", "fatal", + ], + ); + roundtrip( + &[ + LogAttributionKind::Unknown, + LogAttributionKind::Runtime, + LogAttributionKind::RuntimeControl, + LogAttributionKind::Entrypoint, + LogAttributionKind::Domain, + ], + &[ + "unknown", + "runtime", + "runtimeControl", + "entrypoint", + "domain", + ], + ); + roundtrip( + &[ + LogEntryKind::Normal, + LogEntryKind::Lifecycle, + LogEntryKind::IngestionOverflow, + LogEntryKind::RetentionGap, + ], + &["normal", "lifecycle", "ingestionOverflow", "retentionGap"], + ); + roundtrip( + &[ + LogStreamStatus::Unknown, + LogStreamStatus::Empty, + LogStreamStatus::Active, + LogStreamStatus::Stale, + LogStreamStatus::Removed, + LogStreamStatus::ReadError, + ], + &[ + "unknown", + "empty", + "active", + "stale", + "removed", + "readError", + ], + ); + roundtrip( + &[ + HistoryKind::Registration, + HistoryKind::Runtime, + HistoryKind::Config, + HistoryKind::Autostart, + HistoryKind::Iis, + HistoryKind::Log, + ], + &[ + "registration", + "runtime", + "config", + "autostart", + "iis", + "log", + ], + ); + roundtrip( + &[AutostartMode::Disabled, AutostartMode::Daemon], + &["disabled", "daemon"], + ); + roundtrip( + &[ + AutostartStatus::Unknown, + AutostartStatus::Enabled, + AutostartStatus::Disabled, + AutostartStatus::Unsupported, + AutostartStatus::Misconfigured, + ], + &[ + "unknown", + "enabled", + "disabled", + "unsupported", + "misconfigured", + ], + ); + roundtrip( + &[ + StateChangeKind::Snapshot, + StateChangeKind::RegistrationsChanged, + StateChangeKind::RuntimeChanged, + ], + &["snapshot", "registrationsChanged", "runtimeChanged"], + ); + roundtrip( + &[ + IisHandoffState::Available, + IisHandoffState::HandedOff, + IisHandoffState::Unsupported, + IisHandoffState::Conflict, + IisHandoffState::MissingRoute, + IisHandoffState::Unavailable, + IisHandoffState::Busy, + ], + &[ + "available", + "handedOff", + "unsupported", + "conflict", + "missingRoute", + "unavailable", + "busy", + ], + ); + roundtrip( + &[ + IisIssueKind::IisUnavailable, + IisIssueKind::InsufficientPrivileges, + IisIssueKind::ElevationRequired, + IisIssueKind::ElevationDenied, + IisIssueKind::ElevationUnsupported, + IisIssueKind::UnsupportedBindingShape, + IisIssueKind::MissingTlsCertificate, + IisIssueKind::Conflict, + IisIssueKind::MissingBinding, + IisIssueKind::MissingRoute, + IisIssueKind::RollbackSucceeded, + IisIssueKind::RollbackFailed, + IisIssueKind::RestoreFailed, + IisIssueKind::Busy, + IisIssueKind::ProviderError, + ], + &[ + "iisUnavailable", + "insufficientPrivileges", + "elevationRequired", + "elevationDenied", + "elevationUnsupported", + "unsupportedBindingShape", + "missingTlsCertificate", + "conflict", + "missingBinding", + "missingRoute", + "rollbackSucceeded", + "rollbackFailed", + "restoreFailed", + "busy", + "providerError", + ], + ); + roundtrip( + &[ + IisPrivilegeLevel::User, + IisPrivilegeLevel::Administrator, + IisPrivilegeLevel::Unsupported, + ], + &["user", "administrator", "unsupported"], + ); + roundtrip( + &[ + IisOperationStepStatus::Pending, + IisOperationStepStatus::Succeeded, + IisOperationStepStatus::RequiresElevation, + IisOperationStepStatus::Approved, + IisOperationStepStatus::Denied, + IisOperationStepStatus::Failed, + IisOperationStepStatus::Skipped, + IisOperationStepStatus::Unsupported, + ], + &[ + "pending", + "succeeded", + "requiresElevation", + "approved", + "denied", + "failed", + "skipped", + "unsupported", + ], + ); + roundtrip( + &[ + IisElevationApproval::NotRequired, + IisElevationApproval::Required, + IisElevationApproval::Approved, + IisElevationApproval::Denied, + IisElevationApproval::Unsupported, + ], + &[ + "notRequired", + "required", + "approved", + "denied", + "unsupported", + ], + ); + roundtrip( + &[ + IisFollowUpAction::RetryElevation, + IisFollowUpAction::RollbackHandoff, + IisFollowUpAction::RemoveLoopbackBinding, + IisFollowUpAction::RetryRestore, + IisFollowUpAction::ClearRestoreMetadata, + ], + &[ + "retryElevation", + "rollbackHandoff", + "removeLoopbackBinding", + "retryRestore", + "clearRestoreMetadata", + ], + ); } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum IisFollowUpAction { - RetryElevation, - RollbackHandoff, - RetryRestore, - RemoveLoopbackBinding, - ClearRestoreMetadata, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct IisBinding { - pub identity: IisBindingIdentity, - pub ip_address: String, - pub port: u16, - pub host_header: String, - pub domain_key: Option, - pub handoff_state: IisHandoffState, - pub issue: Option, - pub restore_metadata: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct QueryIisBindingsRequest { - pub request_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct QueryIisBindingsResponse { - pub request_id: String, - pub accepted: bool, - pub message: String, - pub bindings: Vec, - pub issue: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SetIisHandoffRequest { - pub request_id: String, - pub binding_id: String, - pub enabled: bool, - pub route_host: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SetIisHandoffResponse { - pub request_id: String, - pub accepted: bool, - pub message: String, - pub binding: Option, - pub issue: Option, - pub steps: Vec, - pub follow_up_actions: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct QueryLogsRequest { - pub request_id: String, - pub stream: LogStreamIdentity, - pub limit: Option, - pub cursor: Option, - pub minimum_severity: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct QueryLogsResponse { - pub request_id: String, - pub accepted: bool, - pub message: String, - pub stream: LogStreamIdentity, - pub stream_status: LogStreamStatus, - pub entries: Vec, - pub next_cursor: Option, - pub has_gap: bool, - pub has_more_before: bool, - pub truncated_by_retention: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ShutdownDaemonRequest { - pub request_id: String, -} - -pub fn new_request_id(prefix: &str) -> String { - format!("{prefix}-{}", Uuid::new_v4().simple()) -} - -#[cfg(test)] -mod tests { - use super::*; #[test] - fn canonicalizes_domains() { + fn message_type_constants_and_identity_constructors_are_stable() { + let message_types = [ + message_types::REGISTER_ENTRYPOINT_REQUEST, + message_types::REGISTER_ENTRYPOINT_RESPONSE, + message_types::UNREGISTER_ENTRYPOINT_REQUEST, + message_types::UNREGISTER_ENTRYPOINT_RESPONSE, + message_types::HEARTBEAT_ENTRYPOINT_REQUEST, + message_types::HEARTBEAT_ENTRYPOINT_RESPONSE, + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + message_types::SUBSCRIBE_STATE_REQUEST, + message_types::STATE_CHANGED_EVENT, + message_types::SET_ENTRYPOINT_ENABLED_REQUEST, + message_types::SET_ENTRYPOINT_ENABLED_RESPONSE, + message_types::SET_DOMAIN_ENABLED_REQUEST, + message_types::SET_DOMAIN_ENABLED_RESPONSE, + message_types::QUERY_IIS_BINDINGS_REQUEST, + message_types::QUERY_IIS_BINDINGS_RESPONSE, + message_types::SET_IIS_HANDOFF_REQUEST, + message_types::SET_IIS_HANDOFF_RESPONSE, + message_types::QUERY_LOGS_REQUEST, + message_types::QUERY_LOGS_RESPONSE, + message_types::QUERY_HISTORY_REQUEST, + message_types::QUERY_HISTORY_RESPONSE, + message_types::QUERY_AUTOSTART_REQUEST, + message_types::QUERY_AUTOSTART_RESPONSE, + message_types::SET_AUTOSTART_REQUEST, + message_types::SET_AUTOSTART_RESPONSE, + message_types::SHUTDOWN_DAEMON_REQUEST, + message_types::SHUTDOWN_DAEMON_RESPONSE, + message_types::PROTOCOL_ERROR_RESPONSE, + ]; + let unique = message_types + .iter() + .copied() + .collect::>(); + let source = SourcePath::new("Caddyfile", Some("/workspace/Caddyfile".to_string())); + let domain = DomainName::parse(" App.Localhost. "); + let runtime_stream = LogStreamIdentity::runtime_control(); + let entrypoint_stream = LogStreamIdentity::entrypoint("shim-1"); + let domain_stream = LogStreamIdentity::domain(&domain.canonical); + let registered = RegisteredDomain::active("API.Localhost."); + + assert_eq!(message_types.len(), unique.len()); assert_eq!( - canonicalize_domain(" WWW.Example.Localhost. "), - "www.example.localhost" + message_types::SHUTDOWN_DAEMON_RESPONSE, + "shutdown-daemon-response" + ); + assert_eq!(source.raw, "Caddyfile"); + assert_eq!(source.canonical.as_deref(), Some("/workspace/Caddyfile")); + assert_eq!(domain.raw, " App.Localhost. "); + assert_eq!(domain.canonical, "app.localhost"); + assert_eq!(runtime_stream.stream_id, "runtime-control"); + assert_eq!(entrypoint_stream.stream_id, "entrypoint-shim-1"); + assert_eq!(domain_stream.domain_key.as_deref(), Some("app.localhost")); + assert_eq!(registered.name.canonical, "api.localhost"); + assert_eq!( + registered.log_stream.domain_key.as_deref(), + Some("api.localhost") ); } #[test] - fn serializes_envelope_with_camel_case_payload() { - let request = QueryStateRequest { - request_id: "request-1".to_string(), + fn public_dtos_keep_debug_clone_and_json_contracts() { + let now = Utc::now(); + let identity = EntrypointInstanceIdentity::new(now); + let registration = EntrypointRegistration { + registration_id: identity.instance_id.clone(), + entrypoint_instance: identity.clone(), + source_working_directory: SourcePath::new(".", Some("/workspace".to_string())), + source_config_path: SourcePath::new("Caddyfile", Some("/workspace/Caddyfile".to_string())), + registered_domains: vec![RegisteredDomain::active("app.localhost")], + activation_state: ActivationState::Active, + owner_process: OwnerProcessIdentity { + process_id: 42, + process_start_time_utc: now, + shim_session_nonce: identity.shim_session_nonce.clone(), + executable_path: Some("caddy".to_string()), + }, + log_stream: LogStreamIdentity::entrypoint(&identity.instance_id), + shim_run: Some(ShimRunMetadata { + adapter: Some("caddyfile".to_string()), + raw_arguments: vec![ + "run".to_string(), + "--config".to_string(), + "Caddyfile".to_string(), + ], + command_line: "caddy run --config Caddyfile".to_string(), + }), + created_at_utc: now, + last_heartbeat_utc: now, }; - let envelope = IpcEnvelope::new(message_types::QUERY_STATE_REQUEST, &request).unwrap(); - let json = serde_json::to_string(&envelope).unwrap(); - - assert!(json.contains("\"protocolVersion\":1")); - assert!(json.contains("\"type\":\"query-state-request\"")); - assert!(json.contains("\"requestId\":\"request-1\"")); - - let decoded: QueryStateRequest = envelope.decode().unwrap(); - assert_eq!(decoded, request); + let runtime_state = RuntimeState { + status: RuntimeStatus::Running, + binary_path: Some("caddy".to_string()), + version: Some("2.10.0".to_string()), + process_id: Some(1234), + admin_endpoint: Some("localhost:2019".to_string()), + diagnostics: vec![RuntimeDiagnostic { + code: "runtime-ok".to_string(), + message: "Runtime is running.".to_string(), + operation: Some("inspect".to_string()), + }], + }; + let config_state = ConfigState { + status: ConfigApplyStatus::Applied, + last_attempted_at_utc: Some(now), + last_successful_reload_at_utc: Some(now), + effective_config_hash: Some("hash".to_string()), + diagnostics: vec![ConfigDiagnostic { + code: "config-ok".to_string(), + message: "Config applied.".to_string(), + domain_key: Some("app.localhost".to_string()), + source_config_paths: vec!["Caddyfile".to_string()], + }], + }; + let logs = QueryLogsResponse { + request_id: "logs-1".to_string(), + accepted: true, + message: "Logs returned.".to_string(), + stream: LogStreamIdentity::runtime_control(), + stream_status: LogStreamStatus::Active, + entries: vec![LogEntry { + sequence_number: 1, + timestamp_utc: now, + severity: LogSeverity::Info, + domain_key: None, + stream: LogStreamIdentity::runtime_control(), + attribution_kind: LogAttributionKind::RuntimeControl, + entry_kind: LogEntryKind::Lifecycle, + raw_message: "started".to_string(), + source_registration_id: Some(registration.registration_id.clone()), + source_instance_id: Some(identity.instance_id.clone()), + operation: Some("start".to_string()), + }], + next_cursor: Some("1".to_string()), + has_gap: false, + has_more_before: false, + truncated_by_retention: false, + }; + let step = IisOperationStep::administrator("iis-restore", "Restore IIS binding"); + let response = RegisterEntrypointResponse { + request_id: "register-1".to_string(), + accepted: true, + message: "Registered.".to_string(), + registration_id: Some(registration.registration_id.clone()), + }; + let snapshot = GuiStateSnapshot { + captured_at_utc: now, + registrations: vec![registration.clone()], + runtime: runtime_state.clone(), + config: config_state.clone(), + storage: Some(StorageState { + backend: "sqlite".to_string(), + path: Some("runtime.sqlite3".to_string()), + schema_version: 1, + diagnostics: Vec::new(), + }), + }; + let values = serde_json::json!({ + "registerRequest": RegisterEntrypointRequest { request_id: "register-1".to_string(), registration: registration.clone() }, + "registerResponse": response.clone(), + "unregister": UnregisterEntrypointRequest { request_id: "unregister-1".to_string(), registration_id: registration.registration_id.clone(), shim_session_nonce: identity.shim_session_nonce.clone() }, + "logs": logs.clone(), + "snapshot": snapshot.clone(), + "step": step.clone(), + "basic": BasicResponse { request_id: "basic-1".to_string(), accepted: true, message: "ok".to_string() }, + }); + let rendered = serde_json::to_string(&values).unwrap(); + + assert!(format!("{:?}", registration.clone()).contains("EntrypointRegistration")); + assert!(format!("{:?}", runtime_state.clone()).contains("RuntimeState")); + assert!(format!("{:?}", config_state.clone()).contains("ConfigState")); + assert!(format!("{:?}", logs.clone()).contains("QueryLogsResponse")); + assert!(format!("{:?}", step.clone()).contains("IisOperationStep")); + assert!(rendered.contains("\"requiresElevation\"")); + assert_eq!(snapshot.registrations[0], registration); + assert_eq!( + response.registration_id.as_deref(), + Some(identity.instance_id.as_str()) + ); } #[test] @@ -861,4 +813,271 @@ mod tests { assert_eq!(registration.validate_owner(), Ok(())); } + + #[test] + fn helper_constructors_and_validation_errors_stay_stable() { + assert!(ActivationState::from_enabled(true).is_enabled()); + assert_eq!( + ActivationState::from_enabled(false), + ActivationState::Inactive + ); + assert_eq!(RuntimeState::idle().status, RuntimeStatus::Idle); + assert_eq!(ConfigState::idle().status, ConfigApplyStatus::Idle); + + let user_step = IisOperationStep::user("discover", "Discover IIS bindings"); + assert_eq!(user_step.privilege_level, IisPrivilegeLevel::User); + assert_eq!(user_step.approval, IisElevationApproval::NotRequired); + let admin_step = IisOperationStep::administrator("restore", "Restore IIS binding"); + assert_eq!(admin_step.privilege_level, IisPrivilegeLevel::Administrator); + assert_eq!(admin_step.status, IisOperationStepStatus::RequiresElevation); + + let now = Utc::now(); + let identity = EntrypointInstanceIdentity::new(now); + let mut registration = EntrypointRegistration { + registration_id: String::new(), + entrypoint_instance: identity.clone(), + source_working_directory: SourcePath::new(".", Some("/tmp/project".to_string())), + source_config_path: SourcePath::new("Caddyfile", Some("/tmp/project/Caddyfile".to_string())), + registered_domains: Vec::new(), + activation_state: ActivationState::Active, + owner_process: OwnerProcessIdentity { + process_id: 42, + process_start_time_utc: now, + shim_session_nonce: identity.shim_session_nonce.clone(), + executable_path: None, + }, + log_stream: LogStreamIdentity::entrypoint(&identity.instance_id), + shim_run: None, + created_at_utc: now, + last_heartbeat_utc: now, + }; + assert_eq!( + registration.validate_owner().unwrap_err(), + "registration_id is required" + ); + + registration.registration_id = "other".to_string(); + assert_eq!( + registration.validate_owner().unwrap_err(), + "registration_id must match instance_id" + ); + + registration.registration_id = registration.entrypoint_instance.instance_id.clone(); + registration.owner_process.shim_session_nonce = "wrong".to_string(); + assert_eq!( + registration.validate_owner().unwrap_err(), + "entrypoint and owner shim session nonce values must match" + ); + + let request_id = new_request_id("unit"); + assert!(request_id.starts_with("unit-")); + } + + #[test] + fn serializes_state_and_control_contracts() { + let now = Utc::now(); + let identity = EntrypointInstanceIdentity::new(now); + let registration = EntrypointRegistration { + registration_id: identity.instance_id.clone(), + entrypoint_instance: identity.clone(), + source_working_directory: SourcePath::new(".", Some("/workspace".to_string())), + source_config_path: SourcePath::new("Caddyfile", Some("/workspace/Caddyfile".to_string())), + registered_domains: vec![RegisteredDomain::active("App.Localhost.")], + activation_state: ActivationState::Registered, + owner_process: OwnerProcessIdentity { + process_id: 42, + process_start_time_utc: now, + shim_session_nonce: identity.shim_session_nonce.clone(), + executable_path: Some("caddy".to_string()), + }, + log_stream: LogStreamIdentity::entrypoint(&identity.instance_id), + shim_run: Some(ShimRunMetadata { + adapter: Some("caddyfile".to_string()), + raw_arguments: vec!["run".to_string()], + command_line: "caddy run".to_string(), + }), + created_at_utc: now, + last_heartbeat_utc: now, + }; + let snapshot = GuiStateSnapshot { + captured_at_utc: now, + registrations: vec![registration.clone()], + runtime: RuntimeState { + status: RuntimeStatus::Running, + binary_path: Some("caddy".to_string()), + version: Some("2.10.0".to_string()), + process_id: Some(1234), + admin_endpoint: Some("localhost:2019".to_string()), + diagnostics: vec![RuntimeDiagnostic { + code: "runtime-ok".to_string(), + message: "Runtime is running.".to_string(), + operation: Some("inspect".to_string()), + }], + }, + config: ConfigState { + status: ConfigApplyStatus::Applied, + last_attempted_at_utc: Some(now), + last_successful_reload_at_utc: Some(now), + effective_config_hash: Some("hash".to_string()), + diagnostics: vec![ConfigDiagnostic { + code: "config-ok".to_string(), + message: "Config applied.".to_string(), + domain_key: Some("app.localhost".to_string()), + source_config_paths: vec!["Caddyfile".to_string()], + }], + }, + storage: Some(StorageState { + backend: "sqlite".to_string(), + path: Some("runtime.sqlite3".to_string()), + schema_version: 1, + diagnostics: vec![RuntimeDiagnostic { + code: "storage-ok".to_string(), + message: "Storage is ready.".to_string(), + operation: Some("open".to_string()), + }], + }), + }; + let state_response = QueryStateResponse { + request_id: "state-1".to_string(), + accepted: true, + message: "State returned.".to_string(), + snapshot: Some(snapshot.clone()), + }; + let state_event = StateChangedEvent { + request_id: "watch-1".to_string(), + sequence_number: 7, + change_kind: StateChangeKind::RuntimeChanged, + snapshot, + registration_id: Some(identity.instance_id.clone()), + }; + let requests = serde_json::json!([ + QueryStateRequest { + request_id: "state".to_string() + }, + SubscribeStateRequest { + request_id: "sub".to_string() + }, + HeartbeatEntrypointRequest { + request_id: "heartbeat".to_string(), + registration_id: identity.instance_id.clone(), + shim_session_nonce: identity.shim_session_nonce.clone(), + }, + SetEntrypointEnabledRequest { + request_id: "entrypoint-enable".to_string(), + registration_id: identity.instance_id.clone(), + shim_session_nonce: Some(identity.shim_session_nonce.clone()), + enabled: false, + }, + SetDomainEnabledRequest { + request_id: "domain-enable".to_string(), + registration_id: identity.instance_id.clone(), + domain_key: "app.localhost".to_string(), + enabled: true, + }, + QueryIisBindingsRequest { + request_id: "iis-bindings".to_string() + }, + QueryLogsRequest { + request_id: "logs".to_string(), + stream: LogStreamIdentity::domain("app.localhost"), + limit: Some(50), + cursor: Some("10".to_string()), + minimum_severity: Some(LogSeverity::Warn), + }, + QueryAutostartRequest { + request_id: "autostart".to_string() + }, + SetAutostartRequest { + request_id: "set-autostart".to_string(), + mode: AutostartMode::Daemon, + }, + ShutdownDaemonRequest { + request_id: "shutdown".to_string() + }, + ]); + + let envelope = IpcEnvelope::new(message_types::QUERY_STATE_RESPONSE, &state_response).unwrap(); + let decoded: QueryStateResponse = envelope.decode().unwrap(); + let autostart_response = QueryAutostartResponse { + request_id: "autostart".to_string(), + accepted: false, + message: "Autostart is unavailable.".to_string(), + mode: AutostartMode::Daemon, + status: AutostartStatus::Unsupported, + target: Some("cadderd --runtime-dir runtime".to_string()), + diagnostics: vec![AutostartDiagnostic { + code: "unsupported".to_string(), + message: "OS autostart is unsupported.".to_string(), + }], + }; + let autostart_envelope = + IpcEnvelope::new(message_types::QUERY_AUTOSTART_RESPONSE, &autostart_response).unwrap(); + let decoded_autostart: QueryAutostartResponse = autostart_envelope.decode().unwrap(); + let event_json = serde_json::to_string(&state_event).unwrap(); + let request_json = serde_json::to_string(&requests).unwrap(); + let variants = serde_json::json!({ + "runtime": [RuntimeStatus::Unknown, RuntimeStatus::NotResolved, RuntimeStatus::Resolved, RuntimeStatus::Running, RuntimeStatus::Unhealthy, RuntimeStatus::Idle], + "config": [ConfigApplyStatus::Unknown, ConfigApplyStatus::NotApplied, ConfigApplyStatus::Applied, ConfigApplyStatus::Failed, ConfigApplyStatus::Idle], + "history": [HistoryKind::Registration, HistoryKind::Runtime, HistoryKind::Config, HistoryKind::Iis, HistoryKind::Autostart, HistoryKind::Log], + "autostart": [AutostartStatus::Unknown, AutostartStatus::Disabled, AutostartStatus::Enabled, AutostartStatus::Unsupported, AutostartStatus::Misconfigured], + "iis": [IisHandoffState::Available, IisHandoffState::HandedOff, IisHandoffState::Unsupported, IisHandoffState::Conflict, IisHandoffState::MissingRoute, IisHandoffState::Unavailable, IisHandoffState::Busy], + "steps": [IisOperationStepStatus::Pending, IisOperationStepStatus::Succeeded, IisOperationStepStatus::RequiresElevation, IisOperationStepStatus::Approved, IisOperationStepStatus::Denied, IisOperationStepStatus::Failed, IisOperationStepStatus::Skipped, IisOperationStepStatus::Unsupported], + "approval": [IisElevationApproval::NotRequired, IisElevationApproval::Required, IisElevationApproval::Approved, IisElevationApproval::Denied, IisElevationApproval::Unsupported], + "followUp": [IisFollowUpAction::RetryElevation, IisFollowUpAction::RollbackHandoff, IisFollowUpAction::RetryRestore, IisFollowUpAction::RemoveLoopbackBinding, IisFollowUpAction::ClearRestoreMetadata], + }); + + assert_eq!(decoded, state_response); + assert_eq!(decoded_autostart, autostart_response); + assert!(event_json.contains("\"runtimeChanged\"")); + assert!(request_json.contains("\"minimumSeverity\":\"warn\"")); + assert!(request_json.contains("\"set-autostart\"")); + assert!(variants.to_string().contains("misconfigured")); + } + + #[test] + fn serializes_history_and_autostart_contracts() { + let now = Utc::now(); + let history = QueryHistoryResponse { + request_id: "history-1".to_string(), + accepted: true, + message: "History returned.".to_string(), + records: vec![HistoryRecord { + sequence_number: 1, + timestamp_utc: now, + kind: HistoryKind::Runtime, + summary: "Runtime started.".to_string(), + registration_id: Some("shim-1".to_string()), + domain_key: Some("app.localhost".to_string()), + payload: serde_json::json!({ "status": "running" }), + }], + storage: Some(StorageState { + backend: "sqlite".to_string(), + path: Some("runtime.sqlite3".to_string()), + schema_version: 1, + diagnostics: Vec::new(), + }), + }; + let envelope = IpcEnvelope::new(message_types::QUERY_HISTORY_RESPONSE, &history).unwrap(); + let json = serde_json::to_string(&envelope).unwrap(); + let decoded: QueryHistoryResponse = envelope.decode().unwrap(); + + assert!(json.contains("\"runtime\"")); + assert_eq!(decoded, history); + + let autostart = SetAutostartResponse { + request_id: "autostart-1".to_string(), + accepted: true, + message: "Autostart mode updated.".to_string(), + mode: AutostartMode::Daemon, + status: AutostartStatus::Enabled, + target: Some("cadderd".to_string()), + diagnostics: Vec::new(), + }; + let envelope = IpcEnvelope::new(message_types::SET_AUTOSTART_RESPONSE, &autostart).unwrap(); + let json = serde_json::to_string(&envelope).unwrap(); + let decoded: SetAutostartResponse = envelope.decode().unwrap(); + + assert!(json.contains("\"daemon\"")); + assert_eq!(decoded, autostart); + } } diff --git a/crates/cadder-protocol/src/logs.rs b/crates/cadder-protocol/src/logs.rs new file mode 100644 index 0000000..52ba465 --- /dev/null +++ b/crates/cadder-protocol/src/logs.rs @@ -0,0 +1,119 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub enum LogSeverity { + Unknown, + Trace, + Debug, + Info, + Warn, + Error, + Fatal, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum LogAttributionKind { + Unknown, + Runtime, + RuntimeControl, + Entrypoint, + Domain, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum LogEntryKind { + Normal, + Lifecycle, + IngestionOverflow, + RetentionGap, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum LogStreamStatus { + Unknown, + Empty, + Active, + Stale, + Removed, + ReadError, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LogStreamIdentity { + pub stream_id: String, + pub domain_key: Option, + pub channel: String, +} + +impl LogStreamIdentity { + pub fn runtime_control() -> Self { + Self { + stream_id: "runtime-control".to_string(), + domain_key: None, + channel: "control".to_string(), + } + } + + pub fn entrypoint(registration_id: &str) -> Self { + Self { + stream_id: format!("entrypoint-{registration_id}"), + domain_key: None, + channel: "caddy".to_string(), + } + } + + pub fn domain(domain_key: &str) -> Self { + Self { + stream_id: format!("domain-{domain_key}"), + domain_key: Some(domain_key.to_string()), + channel: "caddy".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LogEntry { + pub sequence_number: u64, + pub timestamp_utc: DateTime, + pub severity: LogSeverity, + pub stream: LogStreamIdentity, + pub attribution_kind: LogAttributionKind, + pub entry_kind: LogEntryKind, + pub raw_message: String, + pub domain_key: Option, + pub source_registration_id: Option, + pub source_instance_id: Option, + pub operation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QueryLogsRequest { + pub request_id: String, + pub stream: LogStreamIdentity, + pub limit: Option, + pub cursor: Option, + pub minimum_severity: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QueryLogsResponse { + pub request_id: String, + pub accepted: bool, + pub message: String, + pub stream: LogStreamIdentity, + pub stream_status: LogStreamStatus, + pub entries: Vec, + pub next_cursor: Option, + pub has_gap: bool, + pub has_more_before: bool, + pub truncated_by_retention: bool, +} diff --git a/crates/cadder-protocol/src/message_types.rs b/crates/cadder-protocol/src/message_types.rs new file mode 100644 index 0000000..857b8cf --- /dev/null +++ b/crates/cadder-protocol/src/message_types.rs @@ -0,0 +1,29 @@ +pub const REGISTER_ENTRYPOINT_REQUEST: &str = "register-entrypoint-request"; +pub const REGISTER_ENTRYPOINT_RESPONSE: &str = "register-entrypoint-response"; +pub const UNREGISTER_ENTRYPOINT_REQUEST: &str = "unregister-entrypoint-request"; +pub const UNREGISTER_ENTRYPOINT_RESPONSE: &str = "unregister-entrypoint-response"; +pub const HEARTBEAT_ENTRYPOINT_REQUEST: &str = "heartbeat-entrypoint-request"; +pub const HEARTBEAT_ENTRYPOINT_RESPONSE: &str = "heartbeat-entrypoint-response"; +pub const QUERY_STATE_REQUEST: &str = "query-state-request"; +pub const QUERY_STATE_RESPONSE: &str = "query-state-response"; +pub const SUBSCRIBE_STATE_REQUEST: &str = "subscribe-state-request"; +pub const STATE_CHANGED_EVENT: &str = "state-changed-event"; +pub const SET_ENTRYPOINT_ENABLED_REQUEST: &str = "set-entrypoint-enabled-request"; +pub const SET_ENTRYPOINT_ENABLED_RESPONSE: &str = "set-entrypoint-enabled-response"; +pub const SET_DOMAIN_ENABLED_REQUEST: &str = "set-domain-enabled-request"; +pub const SET_DOMAIN_ENABLED_RESPONSE: &str = "set-domain-enabled-response"; +pub const QUERY_IIS_BINDINGS_REQUEST: &str = "query-iis-bindings-request"; +pub const QUERY_IIS_BINDINGS_RESPONSE: &str = "query-iis-bindings-response"; +pub const SET_IIS_HANDOFF_REQUEST: &str = "set-iis-handoff-request"; +pub const SET_IIS_HANDOFF_RESPONSE: &str = "set-iis-handoff-response"; +pub const QUERY_LOGS_REQUEST: &str = "query-logs-request"; +pub const QUERY_LOGS_RESPONSE: &str = "query-logs-response"; +pub const QUERY_HISTORY_REQUEST: &str = "query-history-request"; +pub const QUERY_HISTORY_RESPONSE: &str = "query-history-response"; +pub const QUERY_AUTOSTART_REQUEST: &str = "query-autostart-request"; +pub const QUERY_AUTOSTART_RESPONSE: &str = "query-autostart-response"; +pub const SET_AUTOSTART_REQUEST: &str = "set-autostart-request"; +pub const SET_AUTOSTART_RESPONSE: &str = "set-autostart-response"; +pub const SHUTDOWN_DAEMON_REQUEST: &str = "shutdown-daemon-request"; +pub const SHUTDOWN_DAEMON_RESPONSE: &str = "shutdown-daemon-response"; +pub const PROTOCOL_ERROR_RESPONSE: &str = "protocol-error-response"; diff --git a/crates/cadder-protocol/src/responses.rs b/crates/cadder-protocol/src/responses.rs new file mode 100644 index 0000000..b3c7bf7 --- /dev/null +++ b/crates/cadder-protocol/src/responses.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + AutostartDiagnostic, AutostartMode, AutostartStatus, GuiStateSnapshot, HistoryRecord, + StorageState, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RegisterEntrypointResponse { + pub request_id: String, + pub accepted: bool, + pub message: String, + pub registration_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BasicResponse { + pub request_id: String, + pub accepted: bool, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QueryStateResponse { + pub request_id: String, + pub accepted: bool, + pub message: String, + pub snapshot: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QueryHistoryResponse { + pub request_id: String, + pub accepted: bool, + pub message: String, + pub records: Vec, + pub storage: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QueryAutostartResponse { + pub request_id: String, + pub accepted: bool, + pub message: String, + pub mode: AutostartMode, + pub status: AutostartStatus, + pub target: Option, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SetAutostartResponse { + pub request_id: String, + pub accepted: bool, + pub message: String, + pub mode: AutostartMode, + pub status: AutostartStatus, + pub target: Option, + pub diagnostics: Vec, +} diff --git a/crates/cadder-protocol/src/state.rs b/crates/cadder-protocol/src/state.rs new file mode 100644 index 0000000..7e05e07 --- /dev/null +++ b/crates/cadder-protocol/src/state.rs @@ -0,0 +1,256 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{LogStreamIdentity, StorageState}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ActivationState { + Unknown, + Registered, + Activating, + Active, + Inactive, + Faulted, +} + +impl ActivationState { + pub fn is_enabled(self) -> bool { + matches!(self, Self::Registered | Self::Activating | Self::Active) + } + + pub fn from_enabled(enabled: bool) -> Self { + if enabled { + Self::Active + } else { + Self::Inactive + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RuntimeStatus { + Unknown, + NotResolved, + Resolved, + Running, + Unhealthy, + Idle, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ConfigApplyStatus { + Unknown, + NotApplied, + Applied, + Failed, + Idle, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SourcePath { + pub raw: String, + pub canonical: Option, +} + +impl SourcePath { + pub fn new(raw: impl Into, canonical: Option) -> Self { + Self { + raw: raw.into(), + canonical, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DomainName { + pub raw: String, + pub canonical: String, +} + +impl DomainName { + pub fn parse(raw: impl Into) -> Self { + let raw = raw.into(); + Self { + canonical: canonicalize_domain(&raw), + raw, + } + } +} + +pub fn canonicalize_domain(raw: &str) -> String { + raw.trim().trim_end_matches('.').to_ascii_lowercase() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EntrypointInstanceIdentity { + pub instance_id: String, + pub started_at_utc: DateTime, + pub shim_session_nonce: String, +} + +impl EntrypointInstanceIdentity { + pub fn new(started_at_utc: DateTime) -> Self { + let id = format!("shim-{}", Uuid::new_v4().simple()); + Self { + instance_id: id, + started_at_utc, + shim_session_nonce: Uuid::new_v4().simple().to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct OwnerProcessIdentity { + pub process_id: u32, + pub process_start_time_utc: DateTime, + pub shim_session_nonce: String, + pub executable_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RegisteredDomain { + pub name: DomainName, + pub activation_state: ActivationState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream: Option, + pub log_stream: LogStreamIdentity, +} + +impl RegisteredDomain { + pub fn active(raw: impl Into) -> Self { + let name = DomainName::parse(raw); + Self { + log_stream: LogStreamIdentity::domain(&name.canonical), + name, + activation_state: ActivationState::Active, + upstream: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ShimRunMetadata { + pub adapter: Option, + pub raw_arguments: Vec, + pub command_line: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EntrypointRegistration { + pub registration_id: String, + pub entrypoint_instance: EntrypointInstanceIdentity, + pub source_working_directory: SourcePath, + pub source_config_path: SourcePath, + pub registered_domains: Vec, + pub activation_state: ActivationState, + pub owner_process: OwnerProcessIdentity, + pub log_stream: LogStreamIdentity, + pub shim_run: Option, + pub created_at_utc: DateTime, + pub last_heartbeat_utc: DateTime, +} + +impl EntrypointRegistration { + pub fn validate_owner(&self) -> Result<(), String> { + if self.registration_id.trim().is_empty() { + return Err("registration_id is required".to_string()); + } + if self.registration_id != self.entrypoint_instance.instance_id { + return Err("registration_id must match instance_id".to_string()); + } + if self + .entrypoint_instance + .shim_session_nonce + .trim() + .is_empty() + || self.entrypoint_instance.shim_session_nonce != self.owner_process.shim_session_nonce + { + return Err("entrypoint and owner shim session nonce values must match".to_string()); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeDiagnostic { + pub code: String, + pub message: String, + pub operation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeState { + pub status: RuntimeStatus, + pub binary_path: Option, + pub version: Option, + pub process_id: Option, + pub admin_endpoint: Option, + pub diagnostics: Vec, +} + +impl RuntimeState { + pub fn idle() -> Self { + Self { + status: RuntimeStatus::Idle, + binary_path: None, + version: None, + process_id: None, + admin_endpoint: None, + diagnostics: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConfigDiagnostic { + pub code: String, + pub message: String, + pub domain_key: Option, + pub source_config_paths: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConfigState { + pub status: ConfigApplyStatus, + pub last_attempted_at_utc: Option>, + pub last_successful_reload_at_utc: Option>, + pub effective_config_hash: Option, + pub diagnostics: Vec, +} + +impl ConfigState { + pub fn idle() -> Self { + Self { + status: ConfigApplyStatus::Idle, + last_attempted_at_utc: None, + last_successful_reload_at_utc: None, + effective_config_hash: None, + diagnostics: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GuiStateSnapshot { + pub captured_at_utc: DateTime, + pub registrations: Vec, + pub runtime: RuntimeState, + pub config: ConfigState, + pub storage: Option, +} diff --git a/crates/cadder-shim/Cargo.toml b/crates/cadder-shim/Cargo.toml index d692066..7ae73ac 100644 --- a/crates/cadder-shim/Cargo.toml +++ b/crates/cadder-shim/Cargo.toml @@ -18,3 +18,6 @@ chrono = { version = "0.4.45", features = ["serde"] } clap = { version = "4.6.1", features = ["derive"] } serde_json = "1.0.150" tokio = { version = "1.52.3", features = ["full"] } + +[dev-dependencies] +tempfile = "=3.27.0" diff --git a/crates/cadder-shim/src/main.rs b/crates/cadder-shim/src/main.rs index 3a3eba2..072437e 100644 --- a/crates/cadder-shim/src/main.rs +++ b/crates/cadder-shim/src/main.rs @@ -1,5 +1,8 @@ use anyhow::{Context, Result, anyhow}; -use cadder_daemon::{CadderSession, RealCaddyResolver, RuntimePaths}; +use cadder_daemon::{ + CadderSession, CaddyBackendMode, DaemonLaunchOptions, RealCaddyResolver, RuntimePaths, + RuntimeProfile, ensure_daemon_running_with_options, shim_privilege_diagnostic, +}; use cadder_protocol::{ ActivationState, BasicResponse, EntrypointInstanceIdentity, EntrypointRegistration, HeartbeatEntrypointRequest, LogStreamIdentity, OwnerProcessIdentity, RegisterEntrypointRequest, @@ -10,13 +13,15 @@ use chrono::Utc; use clap::Parser; use std::{ env, + future::Future, path::PathBuf, process::{ExitCode, Stdio}, + sync::Arc, time::Duration, }; use tokio::{process::Command, sync::Mutex, time::interval}; -#[derive(Debug, Parser)] +#[derive(Debug, Clone, Parser)] #[command( name = "caddy", version, @@ -27,12 +32,26 @@ struct ShimArgs { #[arg(long = "cadder-runtime-dir", hide = true)] runtime_dir: Option, + #[arg( + long = "cadder-runtime-profile", + hide = true, + value_parser = RuntimeProfile::parse_cli + )] + runtime_profile: Option, + #[arg(long = "cadder-daemon-path", hide = true)] daemon_path: Option, #[arg(long = "cadder-real-caddy-command", hide = true)] real_caddy_command: Option, + #[arg( + long = "cadder-caddy-backend", + hide = true, + value_parser = CaddyBackendMode::parse_cli + )] + caddy_backend: Option, + #[arg( value_name = "CADDY_ARGS", trailing_var_arg = true, @@ -61,21 +80,43 @@ async fn main() -> Result { return Ok(ExitCode::SUCCESS); } + let caddy_backend = args + .caddy_backend + .map_or_else(CaddyBackendMode::from_env, Ok)?; + if args.caddy_args.first().is_some_and(|arg| arg == "run") { run_managed(args).await + } else if caddy_backend == CaddyBackendMode::Mock { + run_mock_caddy_command(&args.caddy_args).await } else { delegate_to_real_caddy(args.real_caddy_command, &args.caddy_args).await } } async fn run_managed(args: ShimArgs) -> Result { - let paths = RuntimePaths::resolve(args.runtime_dir)?; - let session = match CadderSession::connect(&paths).await { - Ok(session) => std::sync::Arc::new(Mutex::new(session)), - Err(error) => { - eprintln!("{}", managed_backend_unavailable_message(&paths, &error)); - return Ok(ExitCode::FAILURE); - } + write_shim_privilege_warning(); + run_managed_until(args, tokio::signal::ctrl_c()).await +} + +fn write_shim_privilege_warning() { + if let Some(message) = shim_privilege_warning_text() { + eprintln!("warning: {message}"); + } +} + +fn shim_privilege_warning_text() -> Option { + shim_privilege_diagnostic("caddy shim") + .map(|diagnostic| format!("{} {}", diagnostic.message, diagnostic.guidance)) +} + +async fn run_managed_until(args: ShimArgs, shutdown: F) -> Result +where + F: Future>, +{ + let paths = RuntimePaths::resolve_with_profile(args.runtime_dir.clone(), args.runtime_profile)?; + let session = match open_managed_run_target(&args, &paths).await? { + ManagedRunTarget::Cadder(session) => session, + ManagedRunTarget::Exit(code) => return Ok(code), }; let registration = build_registration(&args.caddy_args)?; let registration_id = registration.registration_id.clone(); @@ -120,9 +161,9 @@ async fn run_managed(args: ShimArgs) -> Result { } }); - tokio::signal::ctrl_c() + shutdown .await - .context("wait for Ctrl+C while registered with Cadder")?; + .context("wait for shutdown signal while registered with Cadder")?; heartbeat.abort(); let _response: BasicResponse = session @@ -142,10 +183,161 @@ async fn run_managed(args: ShimArgs) -> Result { Ok(ExitCode::SUCCESS) } +enum ManagedRunTarget { + Cadder(Arc>), + Exit(ExitCode), +} + +async fn open_managed_run_target( + args: &ShimArgs, + paths: &RuntimePaths, +) -> Result { + open_managed_run_target_with_starter(args, paths, start_missing_daemon_owned).await +} + +async fn open_managed_run_target_with_starter( + args: &ShimArgs, + paths: &RuntimePaths, + start_daemon: F, +) -> Result +where + F: FnOnce(ShimArgs, RuntimePaths) -> Fut, + Fut: Future>, +{ + match CadderSession::connect(paths).await { + Ok(session) => Ok(ManagedRunTarget::Cadder(Arc::new(Mutex::new(session)))), + Err(error) if !daemon_error_indicates_not_running(&error) => { + eprintln!("{}", managed_backend_unavailable_message(paths, &error)); + Ok(ManagedRunTarget::Exit(ExitCode::FAILURE)) + } + Err(error) => { + let attach_error = format_error_chain(&error); + let caddy_backend = args + .caddy_backend + .map_or_else(CaddyBackendMode::from_env, Ok)?; + let real_caddy_result = if caddy_backend == CaddyBackendMode::Real { + match run_real_caddy_fallback(args.real_caddy_command.clone(), &args.caddy_args).await { + Ok(code) => return Ok(ManagedRunTarget::Exit(code)), + Err(error) => RecoveryStepResult::Failed(format_error_chain(&error)), + } + } else { + RecoveryStepResult::Skipped(format!( + "Caddy backend mode is `{}`", + caddy_backend.as_str() + )) + }; + + match start_daemon(args.clone(), paths.clone()).await { + Ok(()) => match CadderSession::connect(paths).await { + Ok(session) => Ok(ManagedRunTarget::Cadder(Arc::new(Mutex::new(session)))), + Err(error) => { + eprintln!( + "{}", + managed_recovery_failed_message( + paths, + &attach_error, + &real_caddy_result, + &RecoveryStepResult::Failed(format!( + "started cadderd, but attach failed: {}", + format_error_chain(&error) + )), + ) + ); + Ok(ManagedRunTarget::Exit(ExitCode::FAILURE)) + } + }, + Err(error) => { + eprintln!( + "{}", + managed_recovery_failed_message( + paths, + &attach_error, + &real_caddy_result, + &RecoveryStepResult::Failed(format_error_chain(&error)), + ) + ); + Ok(ManagedRunTarget::Exit(ExitCode::FAILURE)) + } + } + } + } +} + +async fn start_missing_daemon(args: &ShimArgs, paths: &RuntimePaths) -> Result<()> { + ensure_daemon_running_with_options( + paths, + DaemonLaunchOptions { + explicit_daemon: args.daemon_path.clone(), + runtime_profile: args.runtime_profile, + real_caddy_command: args.real_caddy_command.clone(), + caddy_backend: args.caddy_backend, + shim_path: env::current_exe().ok(), + ..DaemonLaunchOptions::default() + }, + ) + .await +} + +async fn start_missing_daemon_owned(args: ShimArgs, paths: RuntimePaths) -> Result<()> { + start_missing_daemon(&args, &paths).await +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RecoveryStepResult { + Failed(String), + Skipped(String), +} + +async fn run_mock_caddy_command(args: &[String]) -> Result { + match args.first().map(String::as_str) { + None | Some("--version" | "version") => { + println!("mock-caddy dev backend"); + Ok(ExitCode::SUCCESS) + } + Some("adapt") => { + println!( + "{}", + serde_json::json!({ "apps": { "http": { "servers": {} } } }) + ); + Ok(ExitCode::SUCCESS) + } + Some(command) => { + eprintln!("Cadder mock Caddy backend does not execute external caddy command `{command}`."); + Ok(ExitCode::FAILURE) + } + } +} + +fn managed_recovery_failed_message( + paths: &RuntimePaths, + attach_error: &str, + real_caddy_result: &RecoveryStepResult, + daemon_result: &RecoveryStepResult, +) -> String { + format!( + "Cadder could not recover `caddy run` for backend runtime `{}`.\n\ + Initial attach failed: {attach_error}.\n\ + Real Caddy fallback: {}.\n\ + Daemon startup: {}.\n\ + Next: configure a safe real Caddy command, start `cadderd --background --runtime-dir \"{}\"`, or run `cadder daemon start` for the same runtime and retry.", + paths.runtime_dir().display(), + recovery_step_summary(real_caddy_result), + recovery_step_summary(daemon_result), + paths.runtime_dir().display(), + ) +} + +fn recovery_step_summary(result: &RecoveryStepResult) -> String { + match result { + RecoveryStepResult::Failed(message) => format!("failed: {message}"), + RecoveryStepResult::Skipped(message) => format!("skipped: {message}"), + } +} + fn managed_backend_unavailable_message(paths: &RuntimePaths, error: &anyhow::Error) -> String { let runtime_dir = paths.runtime_dir().display(); let retry = format!( - "Start `cadderd --runtime-dir \"{}\"` explicitly, or open `cadder-tui` and press s, then retry `caddy run`.", + "Start `cadderd --background --runtime-dir \"{}\"` explicitly, or run `cadder daemon start`, then retry `caddy run`.", runtime_dir ); @@ -237,14 +429,21 @@ async fn delegate_to_real_caddy( real_caddy_command: Option, args: &[String], ) -> Result { - let resolver = RealCaddyResolver::new(real_caddy_command); - let binary = match resolver.resolve() { - Ok(binary) => binary, + match run_real_caddy_fallback(real_caddy_command, args).await { + Ok(code) => Ok(code), Err(error) => { eprintln!("{}", RealCaddyResolver::resolution_help(&error)); - return Ok(ExitCode::FAILURE); + Ok(ExitCode::FAILURE) } - }; + } +} + +async fn run_real_caddy_fallback( + real_caddy_command: Option, + args: &[String], +) -> Result { + let resolver = RealCaddyResolver::new(real_caddy_command); + let binary = resolver.resolve()?; let status = Command::new(binary) .args(args) .stdin(Stdio::inherit()) @@ -259,7 +458,14 @@ async fn delegate_to_real_caddy( #[cfg(test)] mod tests { use super::*; + use cadder_daemon::{ + CaddyConfigAdapter, CaddyConfigCoordinator, DaemonServer, DaemonState, ProcessRuntime, + }; use clap::CommandFactory; + use std::{fs, path::Path, sync::Mutex as StdMutex}; + use tokio::{sync::watch, time::sleep}; + + static TEST_ENV_LOCK: StdMutex<()> = StdMutex::new(()); #[test] fn command_metadata_matches_release_identity() { @@ -297,6 +503,20 @@ mod tests { ); } + #[test] + fn shim_privilege_warning_text_is_available_for_elevated_managed_runs() { + let _guard = TEST_ENV_LOCK.lock().unwrap(); + unsafe { env::set_var("CADDER_TEST_ELEVATED_CONTEXT", "elevated") }; + + let message = shim_privilege_warning_text().unwrap(); + + unsafe { env::remove_var("CADDER_TEST_ELEVATED_CONTEXT") }; + + assert!(message.contains("caddy shim is running with elevated privileges")); + assert!(message.contains("user that owns the Cadder runtime")); + assert!(message.contains("IIS handoff")); + } + #[test] fn parses_config_and_adapter_flags() { let cwd = PathBuf::from("/project"); @@ -396,20 +616,48 @@ mod tests { let message = managed_backend_unavailable_message(&paths, &error); assert!(message.contains("Cadder backend `cadderd` is not running")); - assert!(message.contains("Start `cadderd --runtime-dir")); + assert!(message.contains("Start `cadderd --background --runtime-dir")); assert!(message.contains("retry `caddy run`")); } + #[test] + fn backend_error_helpers_classify_transport_and_protocol_failures() { + let paths = + RuntimePaths::resolve(Some(std::env::temp_dir().join("cadder-shim-protocol-test"))).unwrap(); + let protocol_error = anyhow!("protocol mismatch"); + let message = managed_backend_unavailable_message(&paths, &protocol_error); + + assert!(message.contains("could not attach `caddy run`")); + assert!(message.contains("protocol mismatch")); + for kind in [ + std::io::ErrorKind::NotFound, + std::io::ErrorKind::ConnectionAborted, + std::io::ErrorKind::ConnectionReset, + std::io::ErrorKind::UnexpectedEof, + ] { + let error = anyhow::Error::from(std::io::Error::new(kind, "backend unavailable")); + assert!(daemon_error_indicates_not_running(&error)); + } + + let duplicated = Err::<(), _>(anyhow!("same")).context("same").unwrap_err(); + assert_eq!(format_error_chain(&duplicated), "same"); + let nested = Err::<(), _>(anyhow!("inner")).context("outer").unwrap_err(); + assert_eq!(format_error_chain(&nested), "outer: inner"); + } + #[tokio::test] async fn run_managed_returns_failure_when_backend_is_unavailable() { let runtime_dir = std::env::temp_dir().join(format!( "cadder-shim-missing-backend-{}", std::process::id() )); + let missing_daemon = runtime_dir.join(fake_daemon_name_for_test()); let code = run_managed(ShimArgs { runtime_dir: Some(runtime_dir), - daemon_path: None, + runtime_profile: None, + daemon_path: Some(missing_daemon), real_caddy_command: None, + caddy_backend: Some(CaddyBackendMode::Mock), caddy_args: vec!["run".to_string()], }) .await @@ -417,4 +665,241 @@ mod tests { assert_eq!(code, ExitCode::FAILURE); } + + #[tokio::test] + async fn run_managed_uses_real_caddy_fallback_when_backend_is_missing() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let fake_caddy = temp.path().join(fake_caddy_name_for_test()); + write_fake_caddy(&fake_caddy); + + let code = run_managed(ShimArgs { + runtime_dir: Some(paths.runtime_dir().to_path_buf()), + runtime_profile: None, + daemon_path: Some(temp.path().join(fake_daemon_name_for_test())), + real_caddy_command: Some(fake_caddy.display().to_string()), + caddy_backend: None, + caddy_args: vec!["run".to_string()], + }) + .await + .unwrap(); + + assert_eq!(code, ExitCode::SUCCESS); + } + + #[tokio::test] + async fn open_managed_run_target_starts_missing_daemon_when_fallback_is_skipped() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + paths.ensure_dirs().unwrap(); + let state = DaemonState::new(CaddyConfigCoordinator::new_mock(paths.clone())); + let server = DaemonServer::new(paths.clone(), state); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let args = ShimArgs { + runtime_dir: Some(paths.runtime_dir().to_path_buf()), + runtime_profile: None, + daemon_path: Some(temp.path().join(fake_daemon_name_for_test())), + real_caddy_command: None, + caddy_backend: Some(CaddyBackendMode::Mock), + caddy_args: vec!["run".to_string()], + }; + let starter = move |_args: ShimArgs, paths: RuntimePaths| async move { + tokio::spawn(async move { + let _ = server.run_until(shutdown_rx).await; + }); + wait_for_backend(&paths).await; + Ok(()) + }; + + let target = open_managed_run_target_with_starter(&args, &paths, starter) + .await + .unwrap(); + + match target { + ManagedRunTarget::Cadder(session) => { + let response: cadder_protocol::QueryStateResponse = session + .lock() + .await + .request( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &cadder_protocol::QueryStateRequest { + request_id: new_request_id("test-query"), + }, + ) + .await + .unwrap(); + assert!(response.accepted); + } + ManagedRunTarget::Exit(code) => panic!("expected Cadder session, got exit code {code:?}"), + } + let _ = shutdown_tx.send(true); + } + + #[tokio::test] + async fn open_managed_run_target_reports_complete_recovery_failure() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + let args = ShimArgs { + runtime_dir: Some(paths.runtime_dir().to_path_buf()), + runtime_profile: None, + daemon_path: Some(temp.path().join(fake_daemon_name_for_test())), + real_caddy_command: Some("definitely-missing-caddy-binary".to_string()), + caddy_backend: None, + caddy_args: vec!["run".to_string()], + }; + let starter = + |_args: ShimArgs, _paths: RuntimePaths| async { Err(anyhow!("test daemon start failed")) }; + + let target = open_managed_run_target_with_starter(&args, &paths, starter) + .await + .unwrap(); + + match target { + ManagedRunTarget::Exit(code) => assert_eq!(code, ExitCode::FAILURE), + ManagedRunTarget::Cadder(_) => panic!("expected recovery failure"), + } + } + + #[tokio::test] + async fn mock_caddy_command_handles_adapt_without_delegating_to_real_caddy() { + let code = run_mock_caddy_command(&["adapt".to_string()]) + .await + .unwrap(); + + assert_eq!(code, ExitCode::SUCCESS); + } + + #[tokio::test] + async fn run_managed_registers_heartbeats_and_unregisters_on_shutdown() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("run"))).unwrap(); + paths.ensure_dirs().unwrap(); + let fake_caddy = temp.path().join(fake_caddy_name_for_test()); + write_fake_caddy(&fake_caddy); + fs::write( + temp.path().join("Caddyfile"), + "app.localhost { respond ok }", + ) + .unwrap(); + let resolver = RealCaddyResolver::new(Some(fake_caddy.display().to_string())); + let state = DaemonState::new(CaddyConfigCoordinator::new( + CaddyConfigAdapter::new(resolver.clone()), + ProcessRuntime::new(resolver, paths.clone()), + )); + let server = DaemonServer::new(paths.clone(), state.clone()); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + tokio::spawn(async move { + let _ = server.run_until(shutdown_rx).await; + }); + wait_for_backend(&paths).await; + let config_path = temp.path().join("Caddyfile"); + + let code = run_managed_until( + ShimArgs { + runtime_dir: Some(paths.runtime_dir().to_path_buf()), + runtime_profile: None, + daemon_path: None, + real_caddy_command: None, + caddy_backend: None, + caddy_args: vec![ + "run".to_string(), + "--config".to_string(), + config_path.display().to_string(), + "--adapter".to_string(), + "caddyfile".to_string(), + ], + }, + async { Ok(()) }, + ) + .await + .unwrap(); + let snapshot = state.snapshot().await; + + assert_eq!(code, ExitCode::SUCCESS); + assert!(snapshot.registrations.is_empty()); + assert_eq!( + snapshot.config.status, + cadder_protocol::ConfigApplyStatus::Idle + ); + let _ = shutdown_tx.send(true); + } + + async fn wait_for_backend(paths: &RuntimePaths) { + for _ in 0..50 { + if CadderSession::connect(paths).await.is_ok() { + return; + } + sleep(Duration::from_millis(20)).await; + } + panic!("backend did not become ready"); + } + + #[cfg(windows)] + fn fake_caddy_name_for_test() -> &'static str { + "fake-caddy.cmd" + } + + #[cfg(not(windows))] + fn fake_caddy_name_for_test() -> &'static str { + "fake-caddy" + } + + #[cfg(windows)] + fn fake_daemon_name_for_test() -> &'static str { + "missing-cadderd.exe" + } + + #[cfg(not(windows))] + fn fake_daemon_name_for_test() -> &'static str { + "missing-cadderd" + } + + fn write_fake_caddy(path: &Path) { + #[cfg(windows)] + fs::write( + path, + r#"@echo off +if "%1"=="adapt" ( + echo {"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["app.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}} + exit /b 0 +) +if "%1"=="run" ( + ping -n 2 127.0.0.1 >nul + exit /b 0 +) +if "%1"=="stop" ( + exit /b 0 +) +if "%1"=="reload" ( + exit /b 0 +) +exit /b 0 +"#, + ) + .unwrap(); + + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + fs::write( + path, + r#"#!/usr/bin/env sh +if [ "$1" = "adapt" ]; then + printf '%s\n' '{"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["app.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}}' + exit 0 +fi +if [ "$1" = "run" ]; then + sleep 1 + exit 0 +fi +exit 0 +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); + } + } } diff --git a/crates/cadder-shim/tests/shim_binary.rs b/crates/cadder-shim/tests/shim_binary.rs new file mode 100644 index 0000000..fa470f5 --- /dev/null +++ b/crates/cadder-shim/tests/shim_binary.rs @@ -0,0 +1,150 @@ +use std::{ + fs, + path::PathBuf, + process::{Command, Output}, +}; + +fn run_shim(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_caddy")) + .args(args) + .output() + .unwrap() +} + +fn unique_runtime_dir(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "cadder-shim-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) +} + +fn write_fake_real_caddy(name: &str, exit_code: u8) -> PathBuf { + let dir = unique_runtime_dir(name); + fs::create_dir_all(&dir).unwrap(); + + #[cfg(windows)] + { + let path = dir.join("real-caddy.cmd"); + fs::write( + &path, + format!( + r#"@echo off +echo delegated %* +exit /b {exit_code} +"# + ), + ) + .unwrap(); + path + } + + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + let path = dir.join("real-caddy"); + fs::write( + &path, + format!( + r#"#!/usr/bin/env sh +printf '%s\n' "delegated $*" +exit {exit_code} +"# + ), + ) + .unwrap(); + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).unwrap(); + path + } +} + +#[test] +fn shim_info_flag_reports_release_identity_json() { + let output = run_shim(&["--cadder-shim-info"]); + + assert!(output.status.success()); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["role"], "caddy-shim"); + assert_eq!(json["version"], env!("CARGO_PKG_VERSION")); + assert!(json["executable"].as_str().is_some()); +} + +#[test] +fn shim_info_flag_ignores_invalid_backend_env() { + let output = Command::new(env!("CARGO_BIN_EXE_caddy")) + .arg("--cadder-shim-info") + .env("CADDER_CADDY_BACKEND", "invalid") + .output() + .unwrap(); + + assert!(output.status.success()); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["role"], "caddy-shim"); +} + +#[test] +fn delegated_command_reports_real_caddy_resolution_failure() { + let output = run_shim(&[ + "--cadder-real-caddy-command", + "definitely-missing-caddy", + "version", + ]); + + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("could not resolve a safe real Caddy binary"), + "{stderr}" + ); +} + +#[test] +fn delegated_command_propagates_real_caddy_exit_code() { + let fake_caddy = write_fake_real_caddy("delegated-exit", 7); + let fake_caddy_arg = fake_caddy.display().to_string(); + + let output = run_shim(&["--cadder-real-caddy-command", &fake_caddy_arg, "version"]); + + assert_eq!(output.status.code(), Some(7)); + assert!( + String::from_utf8(output.stdout) + .unwrap() + .contains("delegated version") + ); + let _ = fs::remove_dir_all(fake_caddy.parent().unwrap()); +} + +#[test] +fn managed_run_reports_missing_backend_for_runtime_dir() { + let runtime_dir = unique_runtime_dir("missing-backend"); + let runtime_dir_arg = runtime_dir.display().to_string(); + let missing_daemon = runtime_dir.join(if cfg!(windows) { + "missing-cadderd.exe" + } else { + "missing-cadderd" + }); + let missing_daemon_arg = missing_daemon.display().to_string(); + + let output = run_shim(&[ + "--cadder-runtime-dir", + &runtime_dir_arg, + "--cadder-daemon-path", + &missing_daemon_arg, + "--cadder-caddy-backend", + "mock", + "run", + ]); + + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("Cadder could not recover `caddy run`"), + "{stderr}" + ); + let _ = fs::remove_dir_all(runtime_dir); +} diff --git a/crates/cadder-tui/Cargo.toml b/crates/cadder-tui/Cargo.toml deleted file mode 100644 index ae93845..0000000 --- a/crates/cadder-tui/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "cadder-tui" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Cadder terminal UI for daemon state, domains, logs, and diagnostics" - -[dependencies] -anyhow = "1.0.102" -cadder-daemon = { version = "0.8.0", path = "../cadder-daemon" } -cadder-protocol = { version = "0.8.0", path = "../cadder-protocol" } -chrono = { version = "0.4.45", features = ["serde"] } -clap = { version = "4.6.1", features = ["derive"] } -crossterm = "0.29.0" -ratatui = "0.30.1" -tokio = { version = "1.52.3", features = ["full"] } -tracing-subscriber = "0.3.23" diff --git a/crates/cadder-tui/src/main.rs b/crates/cadder-tui/src/main.rs deleted file mode 100644 index c32de9c..0000000 --- a/crates/cadder-tui/src/main.rs +++ /dev/null @@ -1,2923 +0,0 @@ -mod model; - -use anyhow::{Context, Result}; -use cadder_daemon::{ - CadderClient, DaemonLaunchOptions, RuntimePaths, ensure_daemon_running_with_options, -}; -use cadder_protocol::{ - ActivationState, BasicResponse, ConfigApplyStatus, GuiStateSnapshot, LogEntry, LogSeverity, - LogStreamIdentity, LogStreamStatus, QueryLogsRequest, QueryLogsResponse, QueryStateRequest, - QueryStateResponse, RuntimeStatus, SetDomainEnabledRequest, SetEntrypointEnabledRequest, - ShutdownDaemonRequest, StateChangedEvent, message_types, new_request_id, -}; -#[cfg(windows)] -use cadder_protocol::{ - QueryIisBindingsRequest, QueryIisBindingsResponse, SetIisHandoffRequest, SetIisHandoffResponse, -}; -use clap::Parser; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; -use model::{DaemonUnavailableKind, SeverityFilter, TuiModel, View}; -use ratatui::{ - DefaultTerminal, Frame, - layout::{Constraint, Layout}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, TableState, Tabs, Wrap}, -}; -use std::{ - io, - path::PathBuf, - time::{Duration, Instant}, -}; -use tokio::sync::mpsc; - -const STATE_REFRESH_INTERVAL: Duration = Duration::from_secs(2); -const LOG_REFRESH_INTERVAL: Duration = Duration::from_millis(750); -const DAEMON_START_MESSAGE: &str = "Starting cadderd."; - -#[derive(Debug, Parser)] -#[command( - name = "cadder-tui", - version, - about = "Cadder terminal UI for daemon state, domains, logs, and diagnostics", - long_about = "Opens the Cadder terminal UI. It attaches to an existing cadderd backend, displays entrypoints, domains, logs, diagnostics, activation controls, and offers an explicit start action when the backend is unavailable." -)] -struct Args { - #[arg( - long, - help = "Override the Cadder runtime directory used to find daemon IPC and state" - )] - runtime_dir: Option, - - #[arg( - long, - help = "Path to a cadderd executable for the explicit backend start action" - )] - daemon_path: Option, - - #[arg( - long, - help = "Command or path passed to cadderd when explicitly starting the real Caddy binary" - )] - real_caddy_command: Option, - - #[arg( - long, - help = "Deprecated compatibility alias; the dashboard is attach-only by default and never auto-starts cadderd" - )] - no_start: bool, -} - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt::init(); - let args = Args::parse(); - let paths = RuntimePaths::resolve(args.runtime_dir)?; - let no_start = args.no_start; - let launch_options = DaemonLaunchOptions { - explicit_daemon: args.daemon_path, - real_caddy_command: args.real_caddy_command, - shim_path: None, - }; - let mut app = TuiApp::new(paths, launch_options); - if no_start { - app.message = "`--no-start` is now the default attach-only behavior.".to_string(); - } - app.start_state_refresh(); - - let terminal = ratatui::init(); - let result = app.run(terminal).await; - ratatui::restore(); - result -} - -struct TuiApp { - paths: RuntimePaths, - launch_options: DaemonLaunchOptions, - client: CadderClient, - model: TuiModel, - message: String, - responses_tx: mpsc::UnboundedSender, - responses_rx: mpsc::UnboundedReceiver, - state_request_in_flight: bool, - state_refresh_pending: bool, - state_request_serial: u64, - state_subscription_running: bool, - state_subscription_generation: u64, - daemon_start_in_flight: bool, - toggle_request_in_flight: bool, - shutdown_request_in_flight: bool, - last_state_refresh: Instant, - last_log_refresh: Instant, - log_refresh_pending: bool, - log_request_serial: u64, -} - -#[derive(Debug)] -enum AppResponse { - State { - serial: u64, - result: Result, - }, - StateEvent { - generation: u64, - event: StateChangedEvent, - }, - StateSubscriptionClosed { - generation: u64, - issue: DaemonConnectionIssue, - }, - DaemonStart(Result<(), String>), - Toggle(Result), - #[cfg(windows)] - IisBindings(Result), - #[cfg(windows)] - IisHandoff(Result), - Logs { - serial: u64, - stream: LogStreamIdentity, - minimum_severity: Option, - result: Result, - }, - Shutdown(Result), -} - -#[derive(Debug, Clone)] -struct DaemonConnectionIssue { - kind: DaemonUnavailableKind, - message: String, -} - -impl TuiApp { - fn new(paths: RuntimePaths, launch_options: DaemonLaunchOptions) -> Self { - let client = CadderClient::new(paths.clone()); - let (responses_tx, responses_rx) = mpsc::unbounded_channel(); - Self { - paths, - launch_options, - client, - model: TuiModel::default(), - message: String::new(), - responses_tx, - responses_rx, - state_request_in_flight: false, - state_refresh_pending: false, - state_request_serial: 0, - state_subscription_running: false, - state_subscription_generation: 0, - daemon_start_in_flight: false, - toggle_request_in_flight: false, - shutdown_request_in_flight: false, - last_state_refresh: Instant::now(), - last_log_refresh: Instant::now(), - log_refresh_pending: false, - log_request_serial: 0, - } - } - - async fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> { - loop { - self.drain_responses(); - self.maybe_refresh_state(); - self.maybe_tail_logs(); - terminal.draw(|frame| self.draw(frame))?; - if event::poll(Duration::from_millis(200))? - && let Event::Key(key) = event::read()? - { - if key.kind != KeyEventKind::Press { - continue; - } - if self.handle_key_code(key.code)? { - return Ok(()); - } - } - } - } - - fn handle_key_code(&mut self, code: KeyCode) -> Result { - #[cfg(windows)] - if self.model.iis.route_host_input_mode { - match code { - KeyCode::Esc => self.model.iis.finish_route_host_input(), - KeyCode::Enter => self.model.iis.finish_route_host_input(), - KeyCode::Backspace => self.model.iis.pop_route_host_char(), - KeyCode::Char(ch) => self.model.iis.push_route_host_char(ch), - _ => {} - } - return Ok(false); - } - - if self.model.search_mode { - match code { - KeyCode::Esc => { - self.model.search_mode = false; - self.model.search.clear(); - } - KeyCode::Enter => { - self.model.search_mode = false; - } - KeyCode::Backspace => { - self.model.search.pop(); - } - KeyCode::Char(ch) => self.model.search.push(ch), - _ => {} - } - self.model.clamp_selections(); - return Ok(false); - } - - match code { - KeyCode::Char('q') => return Ok(true), - KeyCode::Char('s') => self.start_daemon(), - KeyCode::Char('r') => { - self.start_state_refresh(); - #[cfg(windows)] - if self.model.view == View::IisHandoff { - self.start_iis_refresh(); - } - } - KeyCode::Tab | KeyCode::Right => self.model.next_view(), - KeyCode::BackTab | KeyCode::Left => self.model.previous_view(), - KeyCode::Down => self.model.move_selection(1), - KeyCode::Up => self.model.move_selection(-1), - #[cfg(windows)] - KeyCode::Char('/') if self.model.view == View::IisHandoff => { - if let Some(binding) = self.model.iis.selected_binding() { - self - .model - .iis - .begin_route_host_input(binding.identity.binding_id); - } else { - self.message = "No IIS binding selected.".to_string(); - } - } - KeyCode::Char('f') => self.model.search_mode = true, - KeyCode::Char(' ') if self.model.view == View::Settings => self.apply_settings_log_severity(), - #[cfg(windows)] - KeyCode::Char(' ') if self.model.view == View::IisHandoff => self.start_iis_handoff_toggle(), - #[cfg(windows)] - KeyCode::Enter if self.model.view == View::IisHandoff => self.start_iis_refresh(), - KeyCode::Char(' ') => self.start_toggle_selected(), - KeyCode::Char('p') if self.model.view == View::Logs => { - self.model.logs.paused = !self.model.logs.paused; - self.message = if self.model.logs.paused { - "Log tailing paused.".to_string() - } else { - "Log tailing resumed.".to_string() - }; - } - KeyCode::Char('0') if self.model.view == View::Logs => { - self.set_log_severity(None); - } - KeyCode::Char('i') if self.model.view == View::Logs => { - self.set_log_severity(Some(LogSeverity::Info)); - } - KeyCode::Char('w') if self.model.view == View::Logs => { - self.set_log_severity(Some(LogSeverity::Warn)); - } - KeyCode::Char('e') if self.model.view == View::Logs => { - self.set_log_severity(Some(LogSeverity::Error)); - } - KeyCode::Char('x') if self.model.view == View::Logs => self.export_logs()?, - KeyCode::Char('d') => self.start_shutdown_daemon(), - KeyCode::Enter if self.model.view == View::Settings => self.apply_settings_log_severity(), - KeyCode::Enter if self.model.view == View::Domains => self.open_selected_domain_logs(), - KeyCode::Char('l') if self.model.view == View::Domains => self.open_selected_domain_logs(), - KeyCode::Enter if self.model.view == View::Logs => self.start_log_refresh(), - _ => {} - } - - Ok(false) - } - - fn drain_responses(&mut self) { - while let Ok(response) = self.responses_rx.try_recv() { - match response { - AppResponse::State { serial, result } => { - if serial != self.state_request_serial { - continue; - } - self.state_request_in_flight = false; - self.last_state_refresh = Instant::now(); - match result { - Ok(response) => self.apply_state_response(response), - Err(issue) => { - if !self.daemon_start_in_flight { - self.apply_daemon_issue("Daemon unavailable", issue); - } - } - } - self.start_pending_state_refresh(); - } - AppResponse::StateEvent { generation, event } => { - if generation != self.state_subscription_generation { - continue; - } - self.state_subscription_running = true; - self.apply_state_event(event); - } - AppResponse::StateSubscriptionClosed { generation, issue } => { - if generation != self.state_subscription_generation { - continue; - } - self.state_subscription_running = false; - if !self.daemon_start_in_flight { - self.apply_daemon_issue("Daemon unavailable", issue); - } - } - AppResponse::DaemonStart(result) => { - self.daemon_start_in_flight = false; - match result { - Ok(()) => { - self.message = "cadderd started. Attaching dashboard.".to_string(); - self.start_state_subscription(); - } - Err(error) => { - self.model.mark_daemon_start_failed(error.clone()); - self.message = format!("Daemon start failed: {error}"); - } - } - self.start_pending_state_refresh(); - } - AppResponse::Toggle(result) => { - self.toggle_request_in_flight = false; - match result { - Ok(response) => { - self.message = response.message; - self.refresh_state_after_mutation(); - } - Err(error) => self.apply_daemon_request_error("Toggle failed", error), - } - } - #[cfg(windows)] - AppResponse::IisBindings(result) => { - self.model.iis.request_in_flight = false; - match result { - Ok(response) => { - let message = response.message.clone(); - self.model.iis.apply_response(response); - self.message = if let Some(error) = &self.model.iis.last_error { - format!("IIS discovery warning: {error}") - } else { - message - }; - } - Err(error) => { - self.model.iis.apply_error(error.clone()); - self.apply_daemon_request_error("IIS discovery failed", error); - } - } - } - #[cfg(windows)] - AppResponse::IisHandoff(result) => { - self.model.iis.action_in_flight = false; - match result { - Ok(response) => { - self.message = iis_handoff_message(&response); - self.start_iis_refresh(); - self.refresh_state_after_mutation(); - } - Err(error) => self.apply_daemon_request_error("IIS handoff failed", error), - } - } - AppResponse::Logs { - serial, - stream, - minimum_severity, - result, - } => { - let current_request = serial == self.log_request_serial - && self - .model - .logs - .active_stream() - .is_some_and(|active| active == stream); - if !current_request { - continue; - } - if self.model.logs.minimum_severity != minimum_severity { - self.model.logs.request_in_flight = false; - self.finish_stale_log_response(); - continue; - } - match result { - Ok(response) => { - self.message = response.message.clone(); - self.model.logs.apply_response(response); - } - Err(error) => { - self.model.logs.apply_read_error(error.clone()); - self.apply_daemon_request_error("Log refresh failed", error); - } - } - self.start_pending_log_refresh(); - } - AppResponse::Shutdown(result) => { - self.shutdown_request_in_flight = false; - match result { - Ok(response) => { - self.state_subscription_generation += 1; - self.state_subscription_running = false; - self.model.mark_daemon_unavailable( - DaemonUnavailableKind::NotRunning, - "Daemon shutdown was requested.", - ); - self.message = response.message; - } - Err(error) => self.apply_daemon_request_error("Shutdown failed", error), - } - } - } - } - } - - fn maybe_tail_logs(&mut self) { - if self.model.view != View::Logs - || !self.model.can_use_daemon() - || self.model.logs.paused - || self.model.logs.request_in_flight - || self.model.logs.target.is_none() - || self.last_log_refresh.elapsed() < LOG_REFRESH_INTERVAL - { - return; - } - self.start_log_refresh(); - } - - fn maybe_refresh_state(&mut self) { - if self.daemon_start_in_flight - || self.state_request_in_flight - || (self.model.can_use_daemon() && self.state_subscription_running) - || self.last_state_refresh.elapsed() < STATE_REFRESH_INTERVAL - { - return; - } - self.start_state_refresh(); - } - - fn apply_state_response(&mut self, response: QueryStateResponse) { - if let Some(snapshot) = response.snapshot { - self.apply_snapshot(snapshot); - self.message = response.message; - if !self.state_subscription_running { - self.start_state_subscription(); - } - } else { - let message = "Daemon returned no state snapshot."; - self - .model - .mark_daemon_unavailable(DaemonUnavailableKind::ConnectionFailed, message); - self.message = message.to_string(); - } - } - - fn start_state_refresh(&mut self) { - if self.daemon_start_in_flight { - self.message = "Backend start is already in progress.".to_string(); - return; - } - if self.state_request_in_flight { - self.state_refresh_pending = true; - return; - } - self.state_request_in_flight = true; - self.state_refresh_pending = false; - self.last_state_refresh = Instant::now(); - self.state_request_serial += 1; - let serial = self.state_request_serial; - let client = self.client.clone(); - let responses = self.responses_tx.clone(); - tokio::spawn(async move { - let result = client - .request( - message_types::QUERY_STATE_REQUEST, - message_types::QUERY_STATE_RESPONSE, - &QueryStateRequest { - request_id: new_request_id("tui-state"), - }, - ) - .await - .map_err(classify_daemon_error); - let _ = responses.send(AppResponse::State { serial, result }); - }); - } - - fn start_state_subscription(&mut self) { - if self.state_subscription_running { - return; - } - self.state_subscription_running = true; - self.state_subscription_generation += 1; - let generation = self.state_subscription_generation; - let client = self.client.clone(); - let responses = self.responses_tx.clone(); - tokio::spawn(async move { - let mut subscription = match client - .subscribe_state(new_request_id("tui-subscribe")) - .await - { - Ok(subscription) => subscription, - Err(error) => { - let _ = responses.send(AppResponse::StateSubscriptionClosed { - generation, - issue: classify_daemon_error(error), - }); - return; - } - }; - - loop { - match subscription.next_event().await { - Ok(event) => { - let _ = responses.send(AppResponse::StateEvent { generation, event }); - } - Err(error) => { - let _ = responses.send(AppResponse::StateSubscriptionClosed { - generation, - issue: classify_daemon_error(error), - }); - return; - } - } - } - }); - } - - fn start_pending_state_refresh(&mut self) { - if !self.state_refresh_pending { - return; - } - self.state_refresh_pending = false; - self.start_state_refresh(); - } - - fn start_daemon(&mut self) { - if self.model.can_use_daemon() { - self.message = "Dashboard is already attached to cadderd.".to_string(); - return; - } - if !self.model.can_start_daemon() || self.daemon_start_in_flight { - self.message = "Backend start is already in progress.".to_string(); - return; - } - self.daemon_start_in_flight = true; - self.state_refresh_pending = false; - self.state_request_in_flight = false; - self.state_request_serial += 1; - self.model.mark_daemon_starting(DAEMON_START_MESSAGE); - self.message = DAEMON_START_MESSAGE.to_string(); - let paths = self.paths.clone(); - let launch_options = self.launch_options.clone(); - let responses = self.responses_tx.clone(); - tokio::spawn(async move { - let result = ensure_daemon_running_with_options(&paths, launch_options) - .await - .context("start cadderd") - .map_err(|error| format_error_chain(&error)); - let _ = responses.send(AppResponse::DaemonStart(result)); - }); - } - - #[cfg(windows)] - fn start_iis_refresh(&mut self) { - if !self.model.can_use_daemon() { - self.message = daemon_action_unavailable("IIS discovery"); - return; - } - if self.model.iis.request_in_flight { - return; - } - self.model.iis.mark_loading(); - let client = self.client.clone(); - let responses = self.responses_tx.clone(); - tokio::spawn(async move { - let result = client - .request( - message_types::QUERY_IIS_BINDINGS_REQUEST, - message_types::QUERY_IIS_BINDINGS_RESPONSE, - &QueryIisBindingsRequest { - request_id: new_request_id("tui-iis"), - }, - ) - .await - .map_err(|error| error.to_string()); - let _ = responses.send(AppResponse::IisBindings(result)); - }); - } - - #[cfg(windows)] - fn start_iis_handoff_toggle(&mut self) { - if !self.model.can_use_daemon() { - self.message = daemon_action_unavailable("IIS handoff"); - return; - } - if self.model.iis.action_in_flight { - return; - } - let Some(binding) = self.model.iis.selected_binding() else { - self.message = "No IIS binding selected.".to_string(); - return; - }; - let enabled = binding.handoff_state != cadder_protocol::IisHandoffState::HandedOff; - let route_host = if enabled && binding.domain_key.is_none() { - self - .model - .iis - .route_host_for_binding(&binding.identity.binding_id) - .map(str::to_string) - } else { - None - }; - if matches!( - binding.handoff_state, - cadder_protocol::IisHandoffState::Unsupported - | cadder_protocol::IisHandoffState::Conflict - | cadder_protocol::IisHandoffState::Unavailable - | cadder_protocol::IisHandoffState::Busy - ) && enabled - { - self.message = binding - .issue - .map(|issue| issue.message) - .unwrap_or_else(|| "Selected IIS binding cannot be handed off safely.".to_string()); - return; - } - if binding.handoff_state == cadder_protocol::IisHandoffState::MissingRoute - && enabled - && route_host.is_none() - { - self.message = binding.issue.map(|issue| issue.message).unwrap_or_else(|| { - "Enter a route host with / before handing off this IIS binding.".to_string() - }); - return; - } - self.message = if enabled { - format!( - "Administrator approval may be requested to create the IIS loopback binding and remove `{}` from the public front door.", - binding.identity.binding_information - ) - } else { - format!( - "Administrator approval may be requested to restore `{}` to IIS and remove the loopback binding.", - binding.identity.binding_information - ) - }; - self.model.iis.action_in_flight = true; - let client = self.client.clone(); - let responses = self.responses_tx.clone(); - tokio::spawn(async move { - let result = client - .request( - message_types::SET_IIS_HANDOFF_REQUEST, - message_types::SET_IIS_HANDOFF_RESPONSE, - &SetIisHandoffRequest { - request_id: new_request_id("tui-iis-handoff"), - binding_id: binding.identity.binding_id, - enabled, - route_host, - }, - ) - .await - .map_err(|error| error.to_string()); - let _ = responses.send(AppResponse::IisHandoff(result)); - }); - } - - fn start_toggle_selected(&mut self) { - if !self.model.can_use_daemon() { - self.message = daemon_action_unavailable("Activation changes"); - return; - } - if self.toggle_request_in_flight { - return; - } - let request = match self.model.view { - View::Entrypoints => self.model.selected_entrypoint().map(|entrypoint| { - ToggleRequest::Entrypoint(SetEntrypointEnabledRequest { - request_id: new_request_id("tui-entrypoint-toggle"), - registration_id: entrypoint.registration_id.clone(), - shim_session_nonce: None, - enabled: !entrypoint.activation_state.is_enabled(), - }) - }), - View::Domains => self - .model - .selected_domain() - .map(|(registration_id, domain)| { - ToggleRequest::Domain(SetDomainEnabledRequest { - request_id: new_request_id("tui-domain-toggle"), - registration_id, - domain_key: domain.name.canonical, - enabled: !domain.activation_state.is_enabled(), - }) - }), - _ => None, - }; - - let Some(request) = request else { - return; - }; - self.toggle_request_in_flight = true; - let client = self.client.clone(); - let responses = self.responses_tx.clone(); - tokio::spawn(async move { - let result = match request { - ToggleRequest::Entrypoint(request) => { - client - .request( - message_types::SET_ENTRYPOINT_ENABLED_REQUEST, - message_types::SET_ENTRYPOINT_ENABLED_RESPONSE, - &request, - ) - .await - } - ToggleRequest::Domain(request) => { - client - .request( - message_types::SET_DOMAIN_ENABLED_REQUEST, - message_types::SET_DOMAIN_ENABLED_RESPONSE, - &request, - ) - .await - } - } - .map_err(|error| error.to_string()); - let _ = responses.send(AppResponse::Toggle(result)); - }); - } - - fn open_selected_domain_logs(&mut self) { - if self.model.open_selected_domain_logs() { - self.message = "Loading domain logs.".to_string(); - self.start_log_refresh(); - } - } - - fn set_log_severity(&mut self, severity: Option) { - if self.model.logs.minimum_severity == severity { - self.message = format!( - "Log severity filter already set to {}.", - describe_log_severity(severity) - ); - return; - } - self.model.logs.reset_for_filter(severity); - self.model.sync_settings_severity_from_logs(); - self.message = match severity { - Some(severity) => format!("Log severity filter set to {severity:?}."), - None => "Log severity filter cleared.".to_string(), - }; - if self.model.logs.target.is_some() { - self.start_log_refresh(); - } - } - - fn apply_settings_log_severity(&mut self) { - let filter = self.model.settings.selected_filter(); - self.set_log_severity(filter.minimum_severity()); - } - - fn start_log_refresh(&mut self) { - if !self.model.can_use_daemon() { - self.message = daemon_action_unavailable("Log refresh"); - return; - } - if self.model.logs.request_in_flight { - self.log_refresh_pending = true; - return; - } - let Some(stream) = self.model.logs.active_stream() else { - self.message = "Select a domain and press Enter or l to view logs.".to_string(); - return; - }; - let cursor = self.model.logs.next_cursor.clone(); - let minimum_severity = self.model.logs.minimum_severity; - self.model.logs.mark_loading(); - self.log_refresh_pending = false; - self.last_log_refresh = Instant::now(); - self.log_request_serial += 1; - let serial = self.log_request_serial; - let client = self.client.clone(); - let responses = self.responses_tx.clone(); - let response_stream = stream.clone(); - tokio::spawn(async move { - let result = client - .request( - message_types::QUERY_LOGS_REQUEST, - message_types::QUERY_LOGS_RESPONSE, - &QueryLogsRequest { - request_id: new_request_id("tui-logs"), - stream, - limit: Some(100), - cursor, - minimum_severity, - }, - ) - .await - .map_err(|error| error.to_string()); - let _ = responses.send(AppResponse::Logs { - serial, - stream: response_stream, - minimum_severity, - result, - }); - }); - } - - fn start_pending_log_refresh(&mut self) { - if !self.log_refresh_pending { - return; - } - if !self.model.can_use_daemon() { - self.log_refresh_pending = false; - return; - } - self.log_refresh_pending = false; - self.start_log_refresh(); - } - - fn finish_stale_log_response(&mut self) { - if self.log_refresh_pending { - self.start_pending_log_refresh(); - } else { - self.model.logs.loading = false; - } - } - - fn start_shutdown_daemon(&mut self) { - if !self.model.can_use_daemon() { - self.message = daemon_action_unavailable("Daemon shutdown"); - return; - } - if self.shutdown_request_in_flight { - return; - } - self.shutdown_request_in_flight = true; - let client = self.client.clone(); - let responses = self.responses_tx.clone(); - tokio::spawn(async move { - let result = client - .request( - message_types::SHUTDOWN_DAEMON_REQUEST, - message_types::SHUTDOWN_DAEMON_RESPONSE, - &ShutdownDaemonRequest { - request_id: new_request_id("tui-shutdown"), - }, - ) - .await - .map_err(|error| error.to_string()); - let _ = responses.send(AppResponse::Shutdown(result)); - }); - } - - fn apply_daemon_issue(&mut self, prefix: &str, issue: DaemonConnectionIssue) { - self - .model - .mark_daemon_unavailable(issue.kind, issue.message.clone()); - self.message = format!( - "{prefix}: {}. Press r to retry attach or s to start cadderd.", - issue.message - ); - } - - fn apply_daemon_request_error(&mut self, prefix: &str, error: String) { - self.apply_daemon_issue( - prefix, - DaemonConnectionIssue { - kind: DaemonUnavailableKind::ConnectionFailed, - message: error, - }, - ); - } - - fn export_logs(&mut self) -> Result<()> { - let Some(target) = self.model.logs.target.as_ref() else { - self.message = "No domain log stream is open.".to_string(); - return Ok(()); - }; - if self.model.logs.entries.is_empty() { - self.message = "No log entries to export.".to_string(); - return Ok(()); - } - let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); - let filename = format!( - "cadder-logs-{}-{timestamp}.txt", - safe_filename(&target.domain_name) - ); - let output_path = std::env::current_dir() - .context("resolve current directory")? - .join(filename); - std::fs::write( - &output_path, - format_log_excerpt(target, &self.model.logs.entries), - ) - .with_context(|| format!("write {}", output_path.display()))?; - self.message = format!("Exported {}", output_path.display()); - Ok(()) - } - - fn apply_snapshot(&mut self, snapshot: GuiStateSnapshot) -> bool { - let was_connected = self.model.can_use_daemon(); - self.model.set_snapshot(snapshot); - self.model.mark_daemon_connected(); - #[cfg(windows)] - if !was_connected { - self.start_iis_refresh(); - } - !was_connected - } - - fn apply_state_event(&mut self, event: StateChangedEvent) { - if self.apply_snapshot(event.snapshot) { - self.message = "Attached to cadderd.".to_string(); - } - } - - fn refresh_state_after_mutation(&mut self) { - if !self.state_subscription_running { - self.start_state_refresh(); - } - } -} - -enum ToggleRequest { - Entrypoint(SetEntrypointEnabledRequest), - Domain(SetDomainEnabledRequest), -} - -fn daemon_action_unavailable(action: &str) -> String { - format!( - "{action} requires a connected daemon. Press r to retry attach, s to start cadderd, or q to quit." - ) -} - -#[cfg(windows)] -fn iis_handoff_message(response: &SetIisHandoffResponse) -> String { - let succeeded = response - .steps - .iter() - .filter(|step| { - step.status == cadder_protocol::IisOperationStepStatus::Succeeded - || step.status == cadder_protocol::IisOperationStepStatus::Approved - }) - .count(); - let denied = response - .steps - .iter() - .filter(|step| step.status == cadder_protocol::IisOperationStepStatus::Denied) - .count(); - let failed = response - .steps - .iter() - .filter(|step| step.status == cadder_protocol::IisOperationStepStatus::Failed) - .count(); - let approved_admin = response - .steps - .iter() - .filter(|step| step.approval == cadder_protocol::IisElevationApproval::Approved) - .count(); - - if response.steps.is_empty() { - return response.message.clone(); - } - - let mut parts = vec![format!( - "{} Steps: {succeeded}/{} succeeded", - response.message, - response.steps.len() - )]; - if approved_admin > 0 { - parts.push(format!("{approved_admin} admin-approved")); - } - if denied > 0 { - parts.push(format!("{denied} admin-denied")); - } - if failed > 0 { - parts.push(format!("{failed} failed")); - } - if !response.follow_up_actions.is_empty() { - let actions = response - .follow_up_actions - .iter() - .map(|action| format!("{action:?}")) - .collect::>() - .join(", "); - parts.push(format!("follow-up: {actions}")); - } - parts.join("; ") -} - -fn classify_daemon_error(error: anyhow::Error) -> DaemonConnectionIssue { - let kind = if error.chain().any(|cause| { - cause.downcast_ref::().is_some_and(|error| { - matches!( - error.kind(), - io::ErrorKind::NotFound - | io::ErrorKind::ConnectionRefused - | io::ErrorKind::ConnectionAborted - | io::ErrorKind::ConnectionReset - | io::ErrorKind::UnexpectedEof - ) - }) - }) { - DaemonUnavailableKind::NotRunning - } else { - DaemonUnavailableKind::ConnectionFailed - }; - - DaemonConnectionIssue { - kind, - message: format_error_chain(&error), - } -} - -fn format_error_chain(error: &anyhow::Error) -> String { - let mut messages = error.chain().map(ToString::to_string).collect::>(); - messages.dedup(); - messages.join(": ") -} - -fn describe_log_severity(severity: Option) -> &'static str { - match severity { - Some(LogSeverity::Info) => "Info and higher", - Some(LogSeverity::Warn) => "Warnings and errors", - Some(LogSeverity::Error | LogSeverity::Fatal) => "Errors only", - _ => "All", - } -} - -fn safe_filename(input: &str) -> String { - let mut output = String::with_capacity(input.len()); - for ch in input.chars() { - if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { - output.push(ch); - } else { - output.push('-'); - } - } - output.trim_matches('-').to_string() -} - -fn format_log_excerpt(target: &model::LogTarget, entries: &[LogEntry]) -> String { - let mut lines = vec![ - format!("Cadder diagnostic log excerpt for {}", target.domain_name), - format!("Registration: {}", target.registration_id), - format!("Stream: {}", target.stream.stream_id), - "Messages are daemon-redacted before export.".to_string(), - String::new(), - ]; - lines.extend(entries.iter().map(|entry| { - format!( - "{} {:?} domain={} source={:?} {}", - entry.timestamp_utc.to_rfc3339(), - entry.severity, - entry - .domain_key - .as_deref() - .unwrap_or(target.domain_name.as_str()), - entry.source_registration_id, - entry.raw_message - ) - })); - lines.push(String::new()); - lines.join("\n") -} - -struct UiStyles; - -impl UiStyles { - fn tab_selected() -> Style { - Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD) - } - - fn block_border() -> Style { - Style::new().fg(Color::Blue) - } - - fn title() -> Style { - Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD) - } - - fn header() -> Style { - Style::new() - .fg(Color::LightCyan) - .add_modifier(Modifier::BOLD) - } - - fn selected_row() -> Style { - Style::new() - .fg(Color::White) - .bg(Color::Blue) - .add_modifier(Modifier::BOLD | Modifier::REVERSED) - } - - fn active() -> Style { - Style::new().fg(Color::Green).add_modifier(Modifier::BOLD) - } - - fn disabled() -> Style { - Style::new().fg(Color::DarkGray) - } - - fn warning() -> Style { - Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) - } - - fn error() -> Style { - Style::new().fg(Color::Red).add_modifier(Modifier::BOLD) - } - - fn info() -> Style { - Style::new().fg(Color::LightBlue) - } - - fn muted() -> Style { - Style::new().fg(Color::DarkGray) - } -} - -fn app_block(title: impl Into) -> Block<'static> { - Block::bordered() - .title(title.into()) - .border_style(UiStyles::block_border()) - .title_style(UiStyles::title()) -} - -fn activation_marker(state: ActivationState) -> String { - let marker = if state.is_enabled() { "[x]" } else { "[ ]" }; - format!("{marker} {state:?}") -} - -fn activation_style(state: ActivationState) -> Style { - match state { - ActivationState::Active | ActivationState::Registered | ActivationState::Activating => { - UiStyles::active() - } - ActivationState::Faulted => UiStyles::error(), - ActivationState::Unknown => UiStyles::warning(), - ActivationState::Inactive => UiStyles::disabled(), - } -} - -fn runtime_status_style(status: RuntimeStatus) -> Style { - match status { - RuntimeStatus::Running | RuntimeStatus::Resolved => UiStyles::active(), - RuntimeStatus::Idle => UiStyles::muted(), - RuntimeStatus::Unhealthy | RuntimeStatus::NotResolved => UiStyles::error(), - RuntimeStatus::Unknown => UiStyles::warning(), - } -} - -fn config_status_style(status: ConfigApplyStatus) -> Style { - match status { - ConfigApplyStatus::Applied => UiStyles::active(), - ConfigApplyStatus::Idle | ConfigApplyStatus::NotApplied => UiStyles::muted(), - ConfigApplyStatus::Failed => UiStyles::error(), - ConfigApplyStatus::Unknown => UiStyles::warning(), - } -} - -fn daemon_state_style(state: &model::DaemonConnectionState) -> Style { - match state { - model::DaemonConnectionState::Connected => UiStyles::active(), - model::DaemonConnectionState::Starting { .. } => UiStyles::info(), - model::DaemonConnectionState::NotRunning { .. } => UiStyles::warning(), - model::DaemonConnectionState::ConnectionFailed { .. } - | model::DaemonConnectionState::StartFailed { .. } => UiStyles::error(), - } -} - -fn severity_style(severity: LogSeverity) -> Style { - match severity { - LogSeverity::Warn => UiStyles::warning(), - LogSeverity::Error | LogSeverity::Fatal => UiStyles::error(), - LogSeverity::Info => UiStyles::info(), - LogSeverity::Unknown | LogSeverity::Trace | LogSeverity::Debug => UiStyles::muted(), - } -} - -#[cfg(windows)] -fn iis_state_style(state: cadder_protocol::IisHandoffState) -> Style { - match state { - cadder_protocol::IisHandoffState::Available => UiStyles::active(), - cadder_protocol::IisHandoffState::HandedOff => UiStyles::info(), - cadder_protocol::IisHandoffState::Unsupported - | cadder_protocol::IisHandoffState::MissingRoute - | cadder_protocol::IisHandoffState::Unavailable => UiStyles::warning(), - cadder_protocol::IisHandoffState::Conflict => UiStyles::error(), - cadder_protocol::IisHandoffState::Busy => UiStyles::muted(), - } -} - -impl TuiApp { - fn draw(&self, frame: &mut Frame<'_>) { - let [tabs_area, body_area, status_area] = Layout::vertical([ - Constraint::Length(3), - Constraint::Min(3), - Constraint::Length(3), - ]) - .areas(frame.area()); - - let tabs = Tabs::new( - View::ALL - .iter() - .map(|view| view.title()) - .collect::>(), - ) - .select(self.model.view.index()) - .block( - Block::new() - .borders(Borders::BOTTOM) - .title("Cadder") - .border_style(UiStyles::block_border()) - .title_style(UiStyles::title()), - ) - .highlight_style(UiStyles::tab_selected()); - frame.render_widget(tabs, tabs_area); - - match self.model.view { - View::Overview => self.draw_overview(frame, body_area), - View::Entrypoints => self.draw_entrypoints(frame, body_area), - View::Domains => self.draw_domains(frame, body_area), - #[cfg(windows)] - View::IisHandoff => self.draw_iis_handoff(frame, body_area), - View::Logs => self.draw_logs(frame, body_area), - View::Settings => self.draw_settings(frame, body_area), - View::Diagnostics => self.draw_diagnostics(frame, body_area), - } - - #[cfg(windows)] - let iis_route_host = if self.model.view == View::IisHandoff { - if self.model.iis.route_host_input_mode { - format!(" IIS route host: {}", self.model.iis.route_host_input) - } else { - self - .model - .iis - .selected_binding() - .and_then(|binding| { - self - .model - .iis - .route_host_for_binding(&binding.identity.binding_id) - .map(str::to_string) - }) - .map(|host| format!(" IIS route host set: {host}")) - .unwrap_or_default() - } - } else { - String::new() - }; - #[cfg(not(windows))] - let iis_route_host = String::new(); - let search = if self.model.search_mode { - format!(" search: {}", self.model.search) - } else if self.model.search.is_empty() { - String::new() - } else { - format!(" filter: {}", self.model.search) - }; - let status_context = format!( - "{} daemon: {}{}{} severity: {}", - self.message, - self.model.daemon.state.label(), - search, - iis_route_host, - describe_log_severity(self.model.logs.minimum_severity) - ); - let navigation_help = "Tab/Shift+Tab/Left/Right views r attach/refresh s start backend f search Space toggle/apply"; - #[cfg(windows)] - let action_help = "IIS: / route host Enter refresh Space handoff/restore Settings: Up/Down severity Enter apply Logs: p pause Enter refresh x export d shutdown q quit"; - #[cfg(not(windows))] - let action_help = "Settings: Up/Down severity Enter apply Logs: p pause Enter refresh x export d shutdown q quit"; - frame.render_widget( - Paragraph::new(vec![ - Line::from(status_context), - Line::styled(navigation_help, UiStyles::muted()), - Line::styled(action_help, UiStyles::muted()), - ]) - .wrap(Wrap { trim: true }), - status_area, - ); - } - - fn draw_overview(&self, frame: &mut Frame<'_>, area: ratatui::layout::Rect) { - let summary = self.model.summary(); - let snapshot = self.model.snapshot(); - let daemon = &self.model.daemon.state; - let lines = vec![ - Line::from(vec![ - Span::styled("Daemon: ", UiStyles::header()), - Span::styled(daemon.label(), daemon_state_style(daemon)), - ]), - Line::styled(daemon.detail().to_string(), UiStyles::muted()), - Line::styled(daemon.guidance(), UiStyles::info()), - Line::from(""), - Line::from(vec![ - Span::styled("Runtime: ", UiStyles::header()), - Span::styled( - summary.runtime, - runtime_status_style(snapshot.runtime.status), - ), - ]), - Line::from(vec![ - Span::styled("Config: ", UiStyles::header()), - Span::styled(summary.config, config_status_style(snapshot.config.status)), - ]), - Line::from(vec![ - Span::styled("Entrypoints: ", UiStyles::header()), - Span::styled(summary.entrypoints.to_string(), UiStyles::info()), - ]), - Line::from(vec![ - Span::styled("Domains: ", UiStyles::header()), - Span::styled(summary.domains.to_string(), UiStyles::info()), - ]), - Line::from(vec![ - Span::styled("Active domains: ", UiStyles::header()), - Span::styled(summary.active_domains.to_string(), UiStyles::active()), - ]), - ]; - frame.render_widget( - Paragraph::new(lines) - .block(app_block("Overview")) - .wrap(Wrap { trim: true }), - area, - ); - } - - fn draw_entrypoints(&self, frame: &mut Frame<'_>, area: ratatui::layout::Rect) { - let rows = self - .model - .filtered_registrations() - .into_iter() - .map(|registration| { - Row::new(vec![ - Cell::from(registration.registration_id.clone()), - Cell::from(activation_marker(registration.activation_state)), - Cell::from(registration.source_working_directory.raw.clone()), - Cell::from(registration.registered_domains.len().to_string()), - ]) - .style(activation_style(registration.activation_state)) - }); - let table = Table::new( - rows, - [ - Constraint::Length(24), - Constraint::Length(16), - Constraint::Percentage(60), - Constraint::Length(8), - ], - ) - .header(Row::new(["ID", "State", "Source", "Domains"]).style(UiStyles::header())) - .block(app_block("Entrypoints")) - .row_highlight_style(UiStyles::selected_row()) - .highlight_symbol(">> "); - let mut state = TableState::default().with_selected(Some(self.model.entrypoint_selected)); - frame.render_stateful_widget(table, area, &mut state); - } - - fn draw_domains(&self, frame: &mut Frame<'_>, area: ratatui::layout::Rect) { - let rows = self - .model - .filtered_domains() - .into_iter() - .map(|(registration, domain)| { - Row::new(vec![ - Cell::from(registration.registration_id.clone()), - Cell::from(domain.name.canonical.clone()), - Cell::from(activation_marker(domain.activation_state)), - ]) - .style(activation_style(domain.activation_state)) - }); - let table = Table::new( - rows, - [ - Constraint::Length(24), - Constraint::Percentage(60), - Constraint::Length(16), - ], - ) - .header(Row::new(["Entrypoint", "Domain", "State"]).style(UiStyles::header())) - .block(app_block("Domains")) - .row_highlight_style(UiStyles::selected_row()) - .highlight_symbol(">> "); - let mut state = TableState::default().with_selected(Some(self.model.domain_selected)); - frame.render_stateful_widget(table, area, &mut state); - } - - #[cfg(windows)] - fn draw_iis_handoff(&self, frame: &mut Frame<'_>, area: ratatui::layout::Rect) { - let rows = self.model.iis.bindings.iter().map(|binding| { - let host = iis_host_display(binding, &self.model.iis); - let issue = binding - .issue - .as_ref() - .map(|issue| issue.message.clone()) - .unwrap_or_else(|| { - if binding.handoff_state == cadder_protocol::IisHandoffState::HandedOff { - "Space restores the original IIS binding.".to_string() - } else { - "Space hands this host to Cadder.".to_string() - } - }); - Row::new(vec![ - Cell::from(binding.identity.site_name.clone()), - Cell::from(binding.identity.protocol.clone()), - Cell::from(binding.ip_address.clone()), - Cell::from(binding.port.to_string()), - Cell::from(host), - Cell::from(format!("{:?}", binding.handoff_state)), - Cell::from(issue), - ]) - .style(iis_state_style(binding.handoff_state)) - }); - let title = if self.model.iis.loading { - "IIS Handoff: loading" - } else if self.model.iis.last_error.is_some() { - "IIS Handoff: unavailable" - } else { - "IIS Handoff" - }; - let table = Table::new( - rows, - [ - Constraint::Length(22), - Constraint::Length(8), - Constraint::Length(12), - Constraint::Length(6), - Constraint::Length(28), - Constraint::Length(14), - Constraint::Percentage(40), - ], - ) - .header( - Row::new(["Site", "Protocol", "IP", "Port", "Host", "State", "Safety"]) - .style(UiStyles::header()), - ) - .block(app_block(title)) - .row_highlight_style(UiStyles::selected_row()) - .highlight_symbol(">> "); - let mut state = TableState::default().with_selected(Some(self.model.iis.selected)); - frame.render_stateful_widget(table, area, &mut state); - } - - fn draw_logs(&self, frame: &mut Frame<'_>, area: ratatui::layout::Rect) { - let logs = &self.model.logs; - let target = logs - .target - .as_ref() - .map(|target| target.domain_name.as_str()) - .unwrap_or("no domain selected"); - let mode = if logs.paused { "paused" } else { "tailing" }; - let title = format!( - "Logs: {target} | {mode} | {:?} | {}", - logs.status, - describe_log_severity(logs.minimum_severity) - ); - let mut items = Vec::new(); - items.extend( - self - .log_state_lines() - .into_iter() - .map(|line| ListItem::new(Line::styled(line, UiStyles::muted()))), - ); - items.extend(logs.entries.iter().map(|entry| { - ListItem::new(Line::from(vec![ - Span::styled( - entry.timestamp_utc.format("%H:%M:%S").to_string(), - UiStyles::muted(), - ), - Span::raw(" "), - Span::styled( - format!("{:?}", entry.severity), - severity_style(entry.severity), - ), - Span::raw(" "), - Span::raw(entry.raw_message.clone()), - ])) - })); - frame.render_widget(List::new(items).block(app_block(title)), area); - } - - fn log_state_lines(&self) -> Vec { - let logs = &self.model.logs; - let mut lines = Vec::new(); - if logs.target.is_none() { - lines.push("Select a domain row and press Enter or l to view logs.".to_string()); - return lines; - } - if logs.loading { - lines.push("Loading log entries...".to_string()); - } - if logs.paused { - lines.push("Auto-scroll paused. Press p to resume or Enter to refresh.".to_string()); - } - if let Some(error) = &logs.read_error { - lines.push(format!("Read error: {error}")); - } - match logs.status { - LogStreamStatus::Empty if !logs.loading => { - lines.push("No log entries for this domain.".to_string()) - } - LogStreamStatus::Stale => { - lines.push("The stream is stale because the domain is not active.".to_string()) - } - LogStreamStatus::Removed => { - lines.push("The domain was removed; retained entries may still be shown.".to_string()) - } - LogStreamStatus::ReadError => {} - _ => {} - } - if logs.has_gap { - lines.push("Some entries were skipped before this page.".to_string()); - } - if logs.has_more_before { - lines.push("Older entries exist before this excerpt.".to_string()); - } - if logs.truncated_by_retention { - lines.push("Older entries were truncated by daemon retention.".to_string()); - } - lines - } - - fn draw_settings(&self, frame: &mut Frame<'_>, area: ratatui::layout::Rect) { - let active_filter = SeverityFilter::from_minimum_severity(self.model.logs.minimum_severity); - let rows = SeverityFilter::ALL.into_iter().map(|filter| { - let applied = if filter == active_filter { - "[x]" - } else { - "[ ]" - }; - let style = if filter == active_filter { - UiStyles::active() - } else { - Style::new() - }; - Row::new(vec![ - Cell::from(applied), - Cell::from(filter.label()), - Cell::from(filter.description()), - ]) - .style(style) - }); - let table = Table::new( - rows, - [ - Constraint::Length(8), - Constraint::Length(24), - Constraint::Percentage(68), - ], - ) - .header(Row::new(["Applied", "Severity", "Effect"]).style(UiStyles::header())) - .block(app_block("Settings")) - .row_highlight_style(UiStyles::selected_row()) - .highlight_symbol(">> "); - let mut state = - TableState::default().with_selected(Some(self.model.settings.selected_severity)); - frame.render_stateful_widget(table, area, &mut state); - } - - fn draw_diagnostics(&self, frame: &mut Frame<'_>, area: ratatui::layout::Rect) { - let snapshot = self.model.snapshot(); - let mut lines = Vec::new(); - lines.extend(snapshot.config.diagnostics.iter().map(|diagnostic| { - ListItem::new(Line::styled( - format!("config:{} {}", diagnostic.code, diagnostic.message), - UiStyles::error(), - )) - })); - lines.extend(snapshot.runtime.diagnostics.iter().map(|diagnostic| { - ListItem::new(Line::styled( - format!("runtime:{} {}", diagnostic.code, diagnostic.message), - UiStyles::warning(), - )) - })); - if lines.is_empty() { - lines.push(ListItem::new(Line::styled( - "No diagnostics.", - UiStyles::muted(), - ))); - } - frame.render_widget(List::new(lines).block(app_block("Diagnostics")), area); - } -} - -#[cfg(windows)] -fn iis_host_display(binding: &cadder_protocol::IisBinding, model: &model::IisViewModel) -> String { - let host_header = binding.host_header.trim(); - if !host_header.is_empty() && host_header != "*" { - return binding.host_header.clone(); - } - if let Some(domain_key) = binding.domain_key.as_ref() { - return domain_key.clone(); - } - model - .route_host_for_binding(&binding.identity.binding_id) - .map(str::to_string) - .unwrap_or_else(|| binding.host_header.clone()) -} - -#[allow(dead_code)] -fn _assert_snapshot_send_sync(_: &GuiStateSnapshot) {} - -#[cfg(test)] -mod tests { - use super::*; - use cadder_protocol::{ - ActivationState, ConfigApplyStatus, ConfigDiagnostic, ConfigState, EntrypointInstanceIdentity, - EntrypointRegistration, LogAttributionKind, LogEntryKind, OwnerProcessIdentity, - RegisteredDomain, RuntimeDiagnostic, RuntimeState, RuntimeStatus, SourcePath, - }; - use chrono::Utc; - use clap::CommandFactory; - use ratatui::{Terminal, backend::TestBackend}; - - fn test_runtime_paths(label: &str) -> RuntimePaths { - RuntimePaths::resolve(Some( - std::env::temp_dir().join(format!("cadder-tui-{label}-{}", std::process::id())), - )) - .unwrap() - } - - fn test_app() -> TuiApp { - let mut model = TuiModel::default(); - model.set_snapshot(snapshot()); - model.mark_daemon_connected(); - let mut app = TuiApp::new(test_runtime_paths("test"), DaemonLaunchOptions::default()); - app.model = model; - app.message = "ready".to_string(); - app - } - - fn snapshot() -> GuiStateSnapshot { - let mut snapshot = GuiStateSnapshot { - captured_at_utc: Utc::now(), - registrations: vec![ - registration( - "shim-1", - "D:/work/app", - vec![ - RegisteredDomain::active("app.localhost"), - RegisteredDomain { - activation_state: ActivationState::Inactive, - ..RegisteredDomain::active("api.localhost") - }, - ], - ), - registration( - "shim-2", - "D:/work/admin", - vec![RegisteredDomain::active("admin.localhost")], - ), - ], - runtime: RuntimeState { - status: RuntimeStatus::Unhealthy, - binary_path: Some("D:/tools/caddy.exe".to_string()), - version: Some("2.10.0".to_string()), - process_id: Some(4242), - admin_endpoint: Some("localhost:2019".to_string()), - diagnostics: vec![RuntimeDiagnostic { - code: "runtime-exited".to_string(), - message: "process exited".to_string(), - operation: Some("run".to_string()), - }], - }, - config: ConfigState { - status: ConfigApplyStatus::Failed, - last_attempted_at_utc: Some(Utc::now()), - last_successful_reload_at_utc: None, - effective_config_hash: Some("hash".to_string()), - diagnostics: vec![ConfigDiagnostic { - code: "reload-failed".to_string(), - message: "reload failed".to_string(), - domain_key: Some("app.localhost".to_string()), - source_config_paths: vec!["D:/work/app/Caddyfile".to_string()], - }], - }, - }; - snapshot.registrations[0].shim_run = Some(cadder_protocol::ShimRunMetadata { - adapter: Some("caddyfile".to_string()), - raw_arguments: vec![ - "run".to_string(), - "--adapter".to_string(), - "caddyfile".to_string(), - ], - command_line: "run --adapter caddyfile".to_string(), - }); - snapshot - } - - fn registration( - registration_id: &str, - working_directory: &str, - registered_domains: Vec, - ) -> EntrypointRegistration { - let now = Utc::now(); - let identity = EntrypointInstanceIdentity { - instance_id: registration_id.to_string(), - started_at_utc: now, - shim_session_nonce: format!("{registration_id}-nonce"), - }; - EntrypointRegistration { - registration_id: registration_id.to_string(), - entrypoint_instance: identity.clone(), - source_working_directory: SourcePath::new(working_directory, None), - source_config_path: SourcePath::new(format!("{working_directory}/Caddyfile"), None), - registered_domains, - activation_state: ActivationState::Active, - owner_process: OwnerProcessIdentity { - process_id: 42, - process_start_time_utc: now, - shim_session_nonce: identity.shim_session_nonce, - executable_path: Some("D:/bin/caddy.exe".to_string()), - }, - log_stream: LogStreamIdentity::entrypoint(registration_id), - shim_run: None, - created_at_utc: now, - last_heartbeat_utc: now, - } - } - - fn log_entry(sequence_number: u64, severity: LogSeverity, message: &str) -> LogEntry { - LogEntry { - sequence_number, - timestamp_utc: Utc::now(), - severity, - stream: LogStreamIdentity::domain("app.localhost"), - attribution_kind: LogAttributionKind::Domain, - entry_kind: LogEntryKind::Normal, - raw_message: message.to_string(), - domain_key: Some("app.localhost".to_string()), - source_registration_id: Some("shim-1".to_string()), - source_instance_id: Some("shim-1".to_string()), - operation: Some("run".to_string()), - } - } - - #[cfg(windows)] - fn iis_binding( - id: &str, - host: &str, - state: cadder_protocol::IisHandoffState, - issue: Option, - ) -> cadder_protocol::IisBinding { - cadder_protocol::IisBinding { - identity: cadder_protocol::IisBindingIdentity { - binding_id: id.to_string(), - site_name: "Default Web Site".to_string(), - protocol: if host.starts_with("secure") { - "https".to_string() - } else { - "http".to_string() - }, - binding_information: format!("*:80:{host}"), - }, - ip_address: "*".to_string(), - port: 80, - host_header: host.to_string(), - domain_key: Some(host.to_string()), - handoff_state: state, - issue, - restore_metadata: None, - } - } - - fn buffer_text(terminal: &Terminal) -> String { - terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect() - } - - fn render(app: &TuiApp) -> String { - let backend = TestBackend::new(120, 32); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| app.draw(frame)).unwrap(); - buffer_text(&terminal) - } - - async fn drain_until(app: &mut TuiApp, mut done: F) - where - F: FnMut(&TuiApp) -> bool, - { - for _ in 0..50 { - app.drain_responses(); - if done(app) { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - panic!("timed out waiting for async TUI response"); - } - - #[test] - fn command_metadata_matches_release_identity() { - let command = Args::command(); - - assert_eq!(command.get_name(), "cadder-tui"); - assert_eq!(command.get_version(), Some(env!("CARGO_PKG_VERSION"))); - assert_eq!( - command.get_about().map(ToString::to_string), - Some(env!("CARGO_PKG_DESCRIPTION").to_string()) - ); - } - - #[test] - fn short_help_uses_package_description() { - let help = Args::command().render_help().to_string(); - - assert!( - help.contains(env!("CARGO_PKG_DESCRIPTION")), - "short help output should include the package description: {help}" - ); - } - - #[test] - fn long_help_describes_tui_launch_options() { - let help = Args::command().render_long_help().to_string(); - - assert!( - help.contains("Override the Cadder runtime directory"), - "long help output should describe --runtime-dir: {help}" - ); - assert!( - help.contains("Path to a cadderd executable"), - "long help output should describe --daemon-path: {help}" - ); - assert!( - help.contains("attach-only by default"), - "long help output should describe --no-start: {help}" - ); - } - - #[test] - fn draw_renders_each_view_with_snapshot_content() { - for view in View::ALL { - let mut app = test_app(); - app.model.view = view; - if view == View::Logs { - app.model.logs.reset_for_target(model::LogTarget { - registration_id: "shim-1".to_string(), - domain_name: "app.localhost".to_string(), - stream: LogStreamIdentity::domain("app.localhost"), - }); - app.model.logs.paused = true; - app.model.logs.status = LogStreamStatus::Stale; - app.model.logs.entries = vec![log_entry(1, LogSeverity::Warn, "upstream warning")]; - app.model.logs.has_gap = true; - app.model.logs.has_more_before = true; - app.model.logs.truncated_by_retention = true; - } - - let text = render(&app); - - assert!( - text.contains(view.title()), - "rendered buffer should include the active view title {view:?}: {text}" - ); - assert!( - text.contains("Cadder"), - "rendered buffer should include the app title: {text}" - ); - } - } - - #[test] - fn draw_includes_search_and_severity_status_context() { - let mut app = test_app(); - app.model.view = View::Logs; - app.model.search_mode = true; - app.model.search = "api".to_string(); - app.model.logs.minimum_severity = Some(LogSeverity::Error); - - let text = render(&app); - - assert!(text.contains("search: api")); - assert!(text.contains("severity: Errors only")); - assert!(text.contains("Left/Right")); - assert!(text.contains("f search")); - assert!(!text.contains("/ search")); - #[cfg(windows)] - assert!(text.contains("IIS: / route host")); - assert!(text.contains("Settings: Up/Down severity Enter apply")); - assert!(!text.contains("i/w/e/0")); - assert!(text.contains("no domain selected")); - } - - #[test] - fn draw_overview_shows_no_daemon_recovery_state() { - let app = TuiApp::new( - test_runtime_paths("no-daemon"), - DaemonLaunchOptions::default(), - ); - - let text = render(&app); - - assert!(text.contains("Daemon")); - assert!(text.contains("Not running")); - assert!(text.contains("Press s to start")); - assert!(text.contains("s start backend")); - } - - #[test] - fn draw_domains_includes_selection_and_activation_markers() { - let mut app = test_app(); - app.model.view = View::Domains; - app.model.domain_selected = 1; - - let text = render(&app); - - assert!(text.contains(">>")); - assert!(text.contains("app.localhost")); - assert!(text.contains("[x] Active")); - assert!(text.contains("api.localhost")); - assert!(text.contains("[ ] Inactive")); - } - - #[test] - fn draw_settings_shows_applied_filter_and_choices() { - let mut app = test_app(); - app.model.view = View::Settings; - app.model.logs.minimum_severity = Some(LogSeverity::Warn); - app.model.sync_settings_severity_from_logs(); - - let text = render(&app); - - assert!(text.contains("Settings")); - assert!(text.contains("[x]")); - assert!(text.contains("Warnings and errors")); - assert!(text.contains("Errors only")); - } - - #[cfg(windows)] - #[test] - fn draw_iis_handoff_shows_rows_and_safety_reasons() { - let mut app = test_app(); - app.model.view = View::IisHandoff; - app.model.iis.bindings = vec![ - iis_binding( - "available", - "app.localhost", - cadder_protocol::IisHandoffState::Available, - None, - ), - iis_binding( - "unsupported", - "secure.localhost", - cadder_protocol::IisHandoffState::Unsupported, - Some(cadder_protocol::IisIssue::new( - cadder_protocol::IisIssueKind::UnsupportedBindingShape, - "HTTPS unsupported.", - )), - ), - iis_binding( - "restore-failed", - "restore.localhost", - cadder_protocol::IisHandoffState::HandedOff, - Some(cadder_protocol::IisIssue::new( - cadder_protocol::IisIssueKind::RestoreFailed, - "Restore failed.", - )), - ), - iis_binding( - "rollback-failed", - "rollback.localhost", - cadder_protocol::IisHandoffState::Conflict, - Some(cadder_protocol::IisIssue::new( - cadder_protocol::IisIssueKind::RollbackFailed, - "Rollback failed.", - )), - ), - ]; - - let text = render(&app); - - assert!(text.contains("IIS Handoff")); - assert!(text.contains("app.localhost")); - assert!(text.contains("HTTPS unsupported.")); - assert!(text.contains("Restore failed.")); - assert!(text.contains("Rollback failed.")); - } - - #[test] - fn style_helpers_use_high_contrast_terminal_styles() { - assert_eq!(activation_marker(ActivationState::Active), "[x] Active"); - assert_eq!(activation_marker(ActivationState::Inactive), "[ ] Inactive"); - assert_eq!( - activation_style(ActivationState::Active).fg, - Some(Color::Green) - ); - assert_eq!( - activation_style(ActivationState::Inactive).fg, - Some(Color::DarkGray) - ); - assert_eq!(severity_style(LogSeverity::Error).fg, Some(Color::Red)); - assert!( - UiStyles::selected_row() - .add_modifier - .contains(Modifier::REVERSED) - ); - } - - #[test] - fn keyboard_navigation_supports_tabs_arrows_and_selection_preservation() { - let mut app = test_app(); - - app.handle_key_code(KeyCode::Left).unwrap(); - assert_eq!(app.model.view, View::Diagnostics); - app.handle_key_code(KeyCode::Right).unwrap(); - assert_eq!(app.model.view, View::Overview); - app.handle_key_code(KeyCode::Tab).unwrap(); - assert_eq!(app.model.view, View::Entrypoints); - app.handle_key_code(KeyCode::BackTab).unwrap(); - assert_eq!(app.model.view, View::Overview); - - app.model.view = View::Domains; - app.handle_key_code(KeyCode::Down).unwrap(); - assert_eq!(app.model.domain_selected, 1); - app.handle_key_code(KeyCode::Right).unwrap(); - app.handle_key_code(KeyCode::Right).unwrap(); - #[cfg(windows)] - app.handle_key_code(KeyCode::Right).unwrap(); - assert_eq!(app.model.view, View::Settings); - app.handle_key_code(KeyCode::Left).unwrap(); - app.handle_key_code(KeyCode::Left).unwrap(); - #[cfg(windows)] - app.handle_key_code(KeyCode::Left).unwrap(); - assert_eq!(app.model.view, View::Domains); - assert_eq!(app.model.domain_selected, 1); - } - - #[test] - fn search_shortcut_uses_f_not_slash() { - let mut app = test_app(); - - app.handle_key_code(KeyCode::Char('/')).unwrap(); - - assert!(!app.model.search_mode); - assert!(app.model.search.is_empty()); - - app.handle_key_code(KeyCode::Char('f')).unwrap(); - - assert!(app.model.search_mode); - } - - #[test] - fn settings_keyboard_applies_selected_log_severity() { - let mut app = test_app(); - app.model.view = View::Settings; - - app.handle_key_code(KeyCode::Down).unwrap(); - app.handle_key_code(KeyCode::Down).unwrap(); - app.handle_key_code(KeyCode::Enter).unwrap(); - - assert_eq!(app.model.logs.minimum_severity, Some(LogSeverity::Warn)); - assert_eq!(app.model.settings.selected_filter(), SeverityFilter::Warn); - assert_eq!(app.message, "Log severity filter set to Warn."); - assert!(!app.model.logs.request_in_flight); - - app.handle_key_code(KeyCode::Char(' ')).unwrap(); - assert_eq!( - app.message, - "Log severity filter already set to Warnings and errors." - ); - } - - #[test] - fn log_state_lines_cover_empty_paused_error_and_retention_messages() { - let mut app = test_app(); - app.model.view = View::Logs; - - assert_eq!( - app.log_state_lines(), - vec!["Select a domain row and press Enter or l to view logs.".to_string()] - ); - - app.model.logs.reset_for_target(model::LogTarget { - registration_id: "shim-1".to_string(), - domain_name: "app.localhost".to_string(), - stream: LogStreamIdentity::domain("app.localhost"), - }); - app.model.logs.loading = false; - app.model.logs.paused = true; - app.model.logs.read_error = Some("socket closed".to_string()); - app.model.logs.status = LogStreamStatus::Removed; - app.model.logs.has_gap = true; - app.model.logs.has_more_before = true; - app.model.logs.truncated_by_retention = true; - - let lines = app.log_state_lines(); - - assert!(lines.iter().any(|line| line.contains("Auto-scroll paused"))); - assert!(lines.iter().any(|line| line.contains("Read error"))); - assert!(lines.iter().any(|line| line.contains("domain was removed"))); - assert!( - lines - .iter() - .any(|line| line.contains("entries were skipped")) - ); - assert!( - lines - .iter() - .any(|line| line.contains("Older entries exist")) - ); - assert!( - lines - .iter() - .any(|line| line.contains("truncated by daemon retention")) - ); - } - - #[test] - fn drain_responses_applies_matching_results_and_ignores_stale_logs() { - let mut app = test_app(); - app.log_request_serial = 2; - app.model.open_selected_domain_logs(); - app.model.logs.minimum_severity = Some(LogSeverity::Warn); - let stream = app.model.logs.active_stream().unwrap(); - - app - .responses_tx - .send(AppResponse::Logs { - serial: 1, - stream: stream.clone(), - minimum_severity: Some(LogSeverity::Warn), - result: Ok(QueryLogsResponse { - request_id: "old".to_string(), - accepted: true, - message: "old".to_string(), - stream: stream.clone(), - stream_status: LogStreamStatus::Active, - entries: vec![log_entry(1, LogSeverity::Info, "stale")], - next_cursor: Some("seq:1".to_string()), - has_gap: false, - has_more_before: false, - truncated_by_retention: false, - }), - }) - .unwrap(); - app - .responses_tx - .send(AppResponse::Logs { - serial: 2, - stream: stream.clone(), - minimum_severity: Some(LogSeverity::Warn), - result: Ok(QueryLogsResponse { - request_id: "current".to_string(), - accepted: true, - message: "loaded".to_string(), - stream: stream.clone(), - stream_status: LogStreamStatus::Active, - entries: vec![log_entry(2, LogSeverity::Warn, "current")], - next_cursor: Some("seq:2".to_string()), - has_gap: true, - has_more_before: false, - truncated_by_retention: false, - }), - }) - .unwrap(); - app - .responses_tx - .send(AppResponse::Shutdown(Err("denied".to_string()))) - .unwrap(); - - app.drain_responses(); - - assert_eq!(app.model.logs.entries.len(), 1); - assert_eq!(app.model.logs.entries[0].raw_message, "current"); - assert_eq!(app.model.logs.next_cursor.as_deref(), Some("seq:2")); - assert!(app.model.logs.has_gap); - assert!(app.message.starts_with("Shutdown failed: denied")); - assert!(!app.shutdown_request_in_flight); - } - - #[test] - fn drain_responses_reports_state_toggle_and_log_errors() { - let mut app = test_app(); - app.state_request_in_flight = true; - app.toggle_request_in_flight = true; - app.log_request_serial = 3; - app.model.open_selected_domain_logs(); - let stream = app.model.logs.active_stream().unwrap(); - - app - .responses_tx - .send(AppResponse::State { - serial: app.state_request_serial, - result: Ok(QueryStateResponse { - request_id: "state".to_string(), - accepted: true, - message: "no snapshot".to_string(), - snapshot: None, - }), - }) - .unwrap(); - app - .responses_tx - .send(AppResponse::Toggle(Err("rejected".to_string()))) - .unwrap(); - app - .responses_tx - .send(AppResponse::Logs { - serial: 3, - stream, - minimum_severity: None, - result: Err("read failed".to_string()), - }) - .unwrap(); - - app.drain_responses(); - - assert!(app.message.starts_with("Log refresh failed: read failed")); - assert_eq!(app.model.logs.status, LogStreamStatus::ReadError); - assert_eq!(app.model.logs.read_error.as_deref(), Some("read failed")); - assert!(matches!( - app.model.daemon.state, - model::DaemonConnectionState::ConnectionFailed { .. } - )); - assert!(!app.state_request_in_flight); - assert!(!app.toggle_request_in_flight); - } - - #[cfg(windows)] - #[tokio::test] - async fn drain_responses_applies_iis_discovery_and_handoff_results() { - let mut app = test_app(); - app.model.view = View::IisHandoff; - app.model.iis.request_in_flight = true; - app.model.iis.action_in_flight = true; - - app - .responses_tx - .send(AppResponse::IisBindings(Ok(QueryIisBindingsResponse { - request_id: "iis".to_string(), - accepted: true, - message: "IIS bindings returned.".to_string(), - bindings: vec![iis_binding( - "available", - "app.localhost", - cadder_protocol::IisHandoffState::Available, - None, - )], - issue: None, - }))) - .unwrap(); - app - .responses_tx - .send(AppResponse::IisHandoff(Ok(SetIisHandoffResponse { - request_id: "handoff".to_string(), - accepted: true, - message: "IIS binding `app.localhost` handed off to Cadder.".to_string(), - binding: None, - issue: None, - steps: vec![cadder_protocol::IisOperationStep { - step_id: "iis-remove-public-binding".to_string(), - label: "Remove public binding.".to_string(), - privilege_level: cadder_protocol::IisPrivilegeLevel::Administrator, - status: cadder_protocol::IisOperationStepStatus::Succeeded, - approval: cadder_protocol::IisElevationApproval::Approved, - issue: None, - }], - follow_up_actions: Vec::new(), - }))) - .unwrap(); - - app.drain_responses(); - - assert_eq!(app.model.iis.bindings.len(), 1); - assert!(app.model.iis.request_in_flight); - assert!(!app.model.iis.action_in_flight); - assert!( - app - .message - .starts_with("IIS binding `app.localhost` handed off to Cadder.") - ); - assert!(app.message.contains("admin-approved")); - } - - #[cfg(windows)] - #[tokio::test] - async fn iis_missing_route_requires_search_host_before_handoff() { - let mut app = test_app(); - app.model.view = View::IisHandoff; - app.model.iis.bindings = vec![cadder_protocol::IisBinding { - identity: cadder_protocol::IisBindingIdentity { - binding_id: "Default Web Site|https|*:443:".to_string(), - site_name: "Default Web Site".to_string(), - protocol: "https".to_string(), - binding_information: "*:443:".to_string(), - }, - ip_address: "*".to_string(), - port: 443, - host_header: String::new(), - domain_key: None, - handoff_state: cadder_protocol::IisHandoffState::MissingRoute, - issue: Some(cadder_protocol::IisIssue::new( - cadder_protocol::IisIssueKind::MissingRoute, - "Wildcard IIS bindings need a route host.", - )), - restore_metadata: None, - }]; - - app.handle_key_code(KeyCode::Char(' ')).unwrap(); - assert!(!app.model.iis.action_in_flight); - assert_eq!(app.message, "Wildcard IIS bindings need a route host."); - - app.handle_key_code(KeyCode::Char('/')).unwrap(); - assert!(app.model.iis.route_host_input_mode); - assert!(!app.model.search_mode); - for ch in "iis-app.localhost".chars() { - app.handle_key_code(KeyCode::Char(ch)).unwrap(); - } - app.handle_key_code(KeyCode::Enter).unwrap(); - app.handle_key_code(KeyCode::Char(' ')).unwrap(); - - assert_eq!(app.model.iis.route_host_input, "iis-app.localhost"); - assert_eq!( - app - .model - .iis - .route_host_for_binding("Default Web Site|https|*:443:"), - Some("iis-app.localhost") - ); - assert!(app.model.search.is_empty()); - assert!(!app.model.iis.route_host_input_mode); - assert!(app.model.iis.action_in_flight); - } - - #[cfg(windows)] - #[test] - fn iis_route_host_input_is_scoped_to_selected_binding() { - let mut app = test_app(); - app.model.view = View::IisHandoff; - app.model.iis.bindings = vec![ - cadder_protocol::IisBinding { - identity: cadder_protocol::IisBindingIdentity { - binding_id: "Default Web Site|https|*:443:".to_string(), - site_name: "Default Web Site".to_string(), - protocol: "https".to_string(), - binding_information: "*:443:".to_string(), - }, - ip_address: "*".to_string(), - port: 443, - host_header: String::new(), - domain_key: None, - handoff_state: cadder_protocol::IisHandoffState::MissingRoute, - issue: None, - restore_metadata: None, - }, - cadder_protocol::IisBinding { - identity: cadder_protocol::IisBindingIdentity { - binding_id: "Default Web Site|http|*:80:".to_string(), - site_name: "Default Web Site".to_string(), - protocol: "http".to_string(), - binding_information: "*:80:".to_string(), - }, - ip_address: "*".to_string(), - port: 80, - host_header: String::new(), - domain_key: None, - handoff_state: cadder_protocol::IisHandoffState::MissingRoute, - issue: None, - restore_metadata: None, - }, - ]; - - app.handle_key_code(KeyCode::Char('/')).unwrap(); - for ch in "default.local-dev.thinksmart.com".chars() { - app.handle_key_code(KeyCode::Char(ch)).unwrap(); - } - app.handle_key_code(KeyCode::Enter).unwrap(); - - assert_eq!( - app - .model - .iis - .route_host_for_binding("Default Web Site|https|*:443:"), - Some("default.local-dev.thinksmart.com") - ); - assert_eq!( - app - .model - .iis - .route_host_for_binding("Default Web Site|http|*:80:"), - None - ); - - let text = render(&app); - assert!(text.contains("default.local-dev.thinksmart.com")); - assert!(text.contains("IIS route host set: default.local-dev.thinksmart.com")); - - app.handle_key_code(KeyCode::Down).unwrap(); - let text = render(&app); - - assert!(!text.contains("IIS route host set: default.local-dev.thinksmart.com")); - } - - #[cfg(windows)] - #[test] - fn iis_host_display_uses_domain_key_when_original_host_is_empty() { - let model = model::IisViewModel::default(); - let binding = cadder_protocol::IisBinding { - identity: cadder_protocol::IisBindingIdentity { - binding_id: "Default Web Site|https|*:443:".to_string(), - site_name: "Default Web Site".to_string(), - protocol: "https".to_string(), - binding_information: "*:443:".to_string(), - }, - ip_address: "*".to_string(), - port: 443, - host_header: String::new(), - domain_key: Some("default.local-dev.thinksmart.com".to_string()), - handoff_state: cadder_protocol::IisHandoffState::HandedOff, - issue: None, - restore_metadata: None, - }; - - assert_eq!( - iis_host_display(&binding, &model), - "default.local-dev.thinksmart.com" - ); - } - - #[tokio::test] - async fn toggle_success_queues_state_refresh_when_poll_is_in_flight() { - let mut app = test_app(); - app.state_request_in_flight = true; - - app - .responses_tx - .send(AppResponse::Toggle(Ok(BasicResponse { - request_id: "toggle".to_string(), - accepted: true, - message: "domain toggled".to_string(), - }))) - .unwrap(); - app - .responses_tx - .send(AppResponse::State { - serial: app.state_request_serial, - result: Ok(QueryStateResponse { - request_id: "state".to_string(), - accepted: true, - message: "old snapshot".to_string(), - snapshot: Some(snapshot()), - }), - }) - .unwrap(); - - app.drain_responses(); - - assert!(app.state_request_in_flight); - assert!(!app.state_refresh_pending); - drain_until(&mut app, |app| !app.state_request_in_flight).await; - assert!(app.message.starts_with("Daemon unavailable:")); - } - - #[tokio::test] - async fn daemon_start_response_starts_attach_flow_and_applies_subscription_snapshot() { - let mut app = test_app(); - app.daemon_start_in_flight = true; - app.model.mark_daemon_starting(DAEMON_START_MESSAGE); - app - .responses_tx - .send(AppResponse::DaemonStart(Ok(()))) - .unwrap(); - - app.drain_responses(); - - assert!(!app.daemon_start_in_flight); - assert!(app.state_subscription_running); - assert_eq!(app.message, "cadderd started. Attaching dashboard."); - - app - .responses_tx - .send(AppResponse::StateEvent { - generation: app.state_subscription_generation, - event: StateChangedEvent { - request_id: "subscribe".to_string(), - sequence_number: 0, - change_kind: cadder_protocol::StateChangeKind::Snapshot, - snapshot: snapshot(), - registration_id: None, - }, - }) - .unwrap(); - - app.drain_responses(); - - assert!(app.model.can_use_daemon()); - assert_eq!(app.model.daemon.state.label(), "Connected"); - assert_eq!(app.message, "Attached to cadderd."); - } - - #[tokio::test] - async fn daemon_start_failure_stays_usable_and_prevents_overlap() { - let paths = test_runtime_paths("start-fail"); - let missing_daemon = paths.runtime_dir().join("missing-cadderd"); - let mut app = TuiApp::new( - paths, - DaemonLaunchOptions { - explicit_daemon: Some(missing_daemon), - ..DaemonLaunchOptions::default() - }, - ); - app.state_request_in_flight = true; - app.state_request_serial = 7; - - app.start_daemon(); - assert!(app.daemon_start_in_flight); - assert!(!app.state_request_in_flight); - assert_eq!(app.state_request_serial, 8); - assert_eq!(app.model.daemon.state.label(), "Starting"); - - app.start_daemon(); - assert_eq!(app.message, "Backend start is already in progress."); - - drain_until(&mut app, |app| !app.daemon_start_in_flight).await; - - assert_eq!(app.model.daemon.state.label(), "Start failed"); - assert!(app.message.starts_with("Daemon start failed:")); - assert!(app.model.can_start_daemon()); - - app.last_state_refresh = Instant::now() - STATE_REFRESH_INTERVAL - Duration::from_millis(10); - app.maybe_refresh_state(); - assert!(app.state_request_in_flight); - drain_until(&mut app, |app| !app.state_request_in_flight).await; - assert!(matches!( - app.model.daemon.state, - model::DaemonConnectionState::NotRunning { .. } - | model::DaemonConnectionState::ConnectionFailed { .. } - )); - } - - #[tokio::test] - async fn start_state_refresh_reports_connection_error() { - let mut app = test_app(); - - app.start_state_refresh(); - assert!(app.state_request_in_flight); - app.start_state_refresh(); - - drain_until(&mut app, |app| !app.state_request_in_flight).await; - - assert!(app.message.starts_with("Daemon unavailable:")); - assert!(!app.model.can_use_daemon()); - } - - #[tokio::test] - async fn subscription_loss_marks_dashboard_unavailable_and_manual_attach_recovers() { - let mut app = test_app(); - app.state_subscription_running = true; - app.state_subscription_generation = 2; - - app - .responses_tx - .send(AppResponse::StateSubscriptionClosed { - generation: 2, - issue: DaemonConnectionIssue { - kind: DaemonUnavailableKind::NotRunning, - message: "backend exited".to_string(), - }, - }) - .unwrap(); - - app.drain_responses(); - - assert!(!app.model.can_use_daemon()); - assert!(app.message.contains("retry attach")); - - app.state_request_serial = 4; - app.state_request_in_flight = true; - app - .responses_tx - .send(AppResponse::State { - serial: 4, - result: Ok(QueryStateResponse { - request_id: "reconnect".to_string(), - accepted: true, - message: "attached".to_string(), - snapshot: Some(snapshot()), - }), - }) - .unwrap(); - - app.drain_responses(); - - assert!(app.model.can_use_daemon()); - assert!(app.state_subscription_running); - } - - #[tokio::test] - async fn maybe_refresh_state_starts_after_interval_without_overlap() { - let mut app = test_app(); - let elapsed_refresh = Instant::now() - STATE_REFRESH_INTERVAL - Duration::from_millis(10); - - app.state_request_in_flight = true; - app.last_state_refresh = elapsed_refresh; - app.maybe_refresh_state(); - assert_eq!(app.last_state_refresh, elapsed_refresh); - - app.state_request_in_flight = false; - app.maybe_refresh_state(); - assert!(app.state_request_in_flight); - let dispatched_at = app.last_state_refresh; - - app.maybe_refresh_state(); - assert_eq!(app.last_state_refresh, dispatched_at); - - drain_until(&mut app, |app| !app.state_request_in_flight).await; - assert!(app.message.starts_with("Daemon unavailable:")); - } - - #[cfg(windows)] - #[tokio::test] - async fn first_successful_state_snapshot_starts_iis_discovery() { - let mut app = TuiApp::new( - test_runtime_paths("initial-iis"), - DaemonLaunchOptions::default(), - ); - - app.apply_state_response(QueryStateResponse { - request_id: "state".to_string(), - accepted: true, - message: "connected".to_string(), - snapshot: Some(snapshot()), - }); - - assert!(app.model.can_use_daemon()); - assert!(app.model.iis.request_in_flight); - assert!(app.model.iis.loading); - } - - #[tokio::test] - async fn start_toggle_selected_reports_entrypoint_connection_error() { - let mut app = test_app(); - app.model.view = View::Entrypoints; - - app.start_toggle_selected(); - assert!(app.toggle_request_in_flight); - app.start_toggle_selected(); - - drain_until(&mut app, |app| !app.toggle_request_in_flight).await; - - assert!(app.message.starts_with("Toggle failed:")); - } - - #[tokio::test] - async fn start_toggle_selected_reports_domain_connection_error() { - let mut app = test_app(); - app.model.view = View::Domains; - - app.start_toggle_selected(); - - drain_until(&mut app, |app| !app.toggle_request_in_flight).await; - - assert!(app.message.starts_with("Toggle failed:")); - } - - #[test] - fn daemon_dependent_actions_are_guarded_while_unavailable() { - let mut app = test_app(); - app.model.mark_daemon_unavailable( - DaemonUnavailableKind::NotRunning, - "Daemon socket was not found.", - ); - - app.model.view = View::Entrypoints; - app.start_toggle_selected(); - assert!(!app.toggle_request_in_flight); - assert!(app.message.starts_with("Activation changes requires")); - - app.model.view = View::Domains; - app.open_selected_domain_logs(); - assert_eq!(app.model.view, View::Logs); - assert!(!app.model.logs.request_in_flight); - assert!(app.message.starts_with("Log refresh requires")); - - app.last_log_refresh = Instant::now() - LOG_REFRESH_INTERVAL - Duration::from_millis(10); - app.maybe_tail_logs(); - assert!(!app.model.logs.request_in_flight); - - app.start_shutdown_daemon(); - assert!(!app.shutdown_request_in_flight); - assert!(app.message.starts_with("Daemon shutdown requires")); - } - - #[tokio::test] - async fn log_refresh_and_severity_filter_report_connection_error() { - let mut app = test_app(); - app.model.view = View::Domains; - app.model.open_selected_domain_logs(); - - app.set_log_severity(Some(LogSeverity::Error)); - assert_eq!(app.model.logs.minimum_severity, Some(LogSeverity::Error)); - assert!(app.model.logs.request_in_flight); - app.start_log_refresh(); - - drain_until(&mut app, |app| !app.model.logs.request_in_flight).await; - - assert_eq!(app.model.logs.status, LogStreamStatus::ReadError); - assert!(app.message.starts_with("Log refresh failed:")); - } - - #[tokio::test] - async fn severity_change_waits_for_in_flight_log_request_before_refreshing() { - let mut app = test_app(); - app.model.view = View::Domains; - app.model.open_selected_domain_logs(); - let stream = app.model.logs.active_stream().unwrap(); - app.log_request_serial = 1; - app.model.logs.request_in_flight = true; - app.model.logs.loading = true; - - app.set_log_severity(Some(LogSeverity::Warn)); - - assert_eq!(app.log_request_serial, 1); - assert!(app.model.logs.request_in_flight); - assert!(app.log_refresh_pending); - assert_eq!(app.model.logs.minimum_severity, Some(LogSeverity::Warn)); - - app - .responses_tx - .send(AppResponse::Logs { - serial: 1, - stream, - minimum_severity: None, - result: Ok(QueryLogsResponse { - request_id: "old".to_string(), - accepted: true, - message: "old".to_string(), - stream: LogStreamIdentity::domain("app.localhost"), - stream_status: LogStreamStatus::Active, - entries: vec![log_entry(1, LogSeverity::Info, "old info")], - next_cursor: Some("seq:1".to_string()), - has_gap: false, - has_more_before: false, - truncated_by_retention: false, - }), - }) - .unwrap(); - - app.drain_responses(); - - assert_eq!(app.log_request_serial, 2); - assert!(!app.log_refresh_pending); - assert!(app.model.logs.entries.is_empty()); - assert_eq!(app.model.logs.minimum_severity, Some(LogSeverity::Warn)); - drain_until(&mut app, |app| !app.model.logs.request_in_flight).await; - } - - #[test] - fn stale_log_response_without_pending_refresh_clears_loading_state() { - let mut app = test_app(); - app.model.view = View::Domains; - app.model.open_selected_domain_logs(); - let stream = app.model.logs.active_stream().unwrap(); - app.log_request_serial = 1; - app.model.logs.minimum_severity = Some(LogSeverity::Warn); - app.model.logs.request_in_flight = true; - app.model.logs.loading = true; - - app - .responses_tx - .send(AppResponse::Logs { - serial: 1, - stream: stream.clone(), - minimum_severity: None, - result: Ok(QueryLogsResponse { - request_id: "stale".to_string(), - accepted: true, - message: "stale".to_string(), - stream, - stream_status: LogStreamStatus::Active, - entries: vec![log_entry(1, LogSeverity::Info, "old info")], - next_cursor: Some("seq:1".to_string()), - has_gap: false, - has_more_before: false, - truncated_by_retention: false, - }), - }) - .unwrap(); - - app.drain_responses(); - - assert_eq!(app.log_request_serial, 1); - assert!(!app.log_refresh_pending); - assert!(!app.model.logs.request_in_flight); - assert!(!app.model.logs.loading); - assert!(app.model.logs.entries.is_empty()); - } - - #[tokio::test] - async fn maybe_tail_logs_starts_refresh_when_interval_elapsed() { - let mut app = test_app(); - app.model.view = View::Domains; - app.model.open_selected_domain_logs(); - app.model.logs.request_in_flight = false; - app.last_log_refresh = Instant::now() - LOG_REFRESH_INTERVAL - Duration::from_millis(10); - - app.maybe_tail_logs(); - - assert!(app.model.logs.request_in_flight); - drain_until(&mut app, |app| !app.model.logs.request_in_flight).await; - assert!(app.model.logs.read_error.is_some()); - } - - #[tokio::test] - async fn start_shutdown_daemon_reports_connection_error() { - let mut app = test_app(); - - app.start_shutdown_daemon(); - assert!(app.shutdown_request_in_flight); - app.start_shutdown_daemon(); - - drain_until(&mut app, |app| !app.shutdown_request_in_flight).await; - - assert!(app.message.starts_with("Shutdown failed:")); - } - - #[test] - fn apply_state_response_uses_snapshot_or_guidance_message() { - let mut app = test_app(); - app.state_subscription_running = true; - - app.apply_state_response(QueryStateResponse { - request_id: "state".to_string(), - accepted: true, - message: "updated".to_string(), - snapshot: Some(snapshot()), - }); - assert_eq!(app.message, "updated"); - assert_eq!(app.model.summary().entrypoints, 2); - assert!(app.model.can_use_daemon()); - - app.apply_state_response(QueryStateResponse { - request_id: "state".to_string(), - accepted: true, - message: "empty".to_string(), - snapshot: None, - }); - assert_eq!(app.message, "Daemon returned no state snapshot."); - assert!(matches!( - app.model.daemon.state, - model::DaemonConnectionState::ConnectionFailed { .. } - )); - } - - #[test] - fn start_log_refresh_without_target_sets_guidance_message() { - let mut app = test_app(); - app.model.logs.target = None; - - app.start_log_refresh(); - - assert_eq!( - app.message, - "Select a domain and press Enter or l to view logs." - ); - assert_eq!(app.log_request_serial, 0); - } - - #[tokio::test] - async fn open_selected_domain_logs_switches_view_and_marks_loading() { - let mut app = test_app(); - app.model.view = View::Domains; - - app.open_selected_domain_logs(); - - assert_eq!(app.model.view, View::Logs); - assert_eq!(app.message, "Loading domain logs."); - assert!(app.model.logs.loading); - drain_until(&mut app, |app| !app.model.logs.request_in_flight).await; - } - - #[test] - fn safe_filename_replaces_shell_sensitive_characters() { - assert_eq!( - safe_filename("../app localhost:443?token=value"), - "..-app-localhost-443-token-value" - ); - assert_eq!(safe_filename("app.localhost"), "app.localhost"); - } - - #[test] - fn format_log_excerpt_includes_redacted_export_context() { - let target = model::LogTarget { - registration_id: "shim-1".to_string(), - domain_name: "app.localhost".to_string(), - stream: LogStreamIdentity::domain("app.localhost"), - }; - - let excerpt = format_log_excerpt(&target, &[log_entry(7, LogSeverity::Error, "redacted")]); - - assert!(excerpt.contains("Cadder diagnostic log excerpt for app.localhost")); - assert!(excerpt.contains("Registration: shim-1")); - assert!(excerpt.contains("Messages are daemon-redacted before export.")); - assert!(excerpt.contains("Error domain=app.localhost")); - assert!(excerpt.contains("redacted")); - } -} diff --git a/crates/cadder-tui/src/model.rs b/crates/cadder-tui/src/model.rs deleted file mode 100644 index 2c1fafc..0000000 --- a/crates/cadder-tui/src/model.rs +++ /dev/null @@ -1,1230 +0,0 @@ -use cadder_protocol::{ - EntrypointRegistration, GuiStateSnapshot, LogEntry, LogSeverity, LogStreamIdentity, - LogStreamStatus, RegisteredDomain, -}; -#[cfg(windows)] -use cadder_protocol::{IisBinding, QueryIisBindingsResponse}; -#[cfg(windows)] -use std::collections::BTreeMap; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum View { - Overview, - Entrypoints, - Domains, - #[cfg(windows)] - IisHandoff, - Logs, - Settings, - Diagnostics, -} - -impl View { - #[cfg(windows)] - pub const ALL: [Self; 7] = [ - Self::Overview, - Self::Entrypoints, - Self::Domains, - Self::IisHandoff, - Self::Logs, - Self::Settings, - Self::Diagnostics, - ]; - - #[cfg(not(windows))] - pub const ALL: [Self; 6] = [ - Self::Overview, - Self::Entrypoints, - Self::Domains, - Self::Logs, - Self::Settings, - Self::Diagnostics, - ]; - - pub fn title(self) -> &'static str { - match self { - Self::Overview => "Overview", - Self::Entrypoints => "Entrypoints", - Self::Domains => "Domains", - #[cfg(windows)] - Self::IisHandoff => "IIS Handoff", - Self::Logs => "Logs", - Self::Settings => "Settings", - Self::Diagnostics => "Diagnostics", - } - } - - pub fn index(self) -> usize { - Self::ALL.iter().position(|view| *view == self).unwrap_or(0) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SeverityFilter { - All, - Info, - Warn, - Error, -} - -impl SeverityFilter { - pub const ALL: [Self; 4] = [Self::All, Self::Info, Self::Warn, Self::Error]; - - pub fn label(self) -> &'static str { - match self { - Self::All => "All", - Self::Info => "Info and higher", - Self::Warn => "Warnings and errors", - Self::Error => "Errors only", - } - } - - pub fn description(self) -> &'static str { - match self { - Self::All => "Show every retained log entry", - Self::Info => "Hide trace and debug noise", - Self::Warn => "Show warnings and errors", - Self::Error => "Show errors and fatal entries", - } - } - - pub fn minimum_severity(self) -> Option { - match self { - Self::All => None, - Self::Info => Some(LogSeverity::Info), - Self::Warn => Some(LogSeverity::Warn), - Self::Error => Some(LogSeverity::Error), - } - } - - pub fn from_minimum_severity(minimum_severity: Option) -> Self { - match minimum_severity { - None => Self::All, - Some(LogSeverity::Info) => Self::Info, - Some(LogSeverity::Warn) => Self::Warn, - Some(LogSeverity::Error | LogSeverity::Fatal) => Self::Error, - Some(LogSeverity::Unknown | LogSeverity::Trace | LogSeverity::Debug) => Self::All, - } - } - - pub fn index(self) -> usize { - Self::ALL - .iter() - .position(|filter| *filter == self) - .unwrap_or(0) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DaemonUnavailableKind { - NotRunning, - ConnectionFailed, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DaemonConnectionState { - Connected, - NotRunning { message: String }, - ConnectionFailed { message: String }, - Starting { message: String }, - StartFailed { message: String }, -} - -impl DaemonConnectionState { - pub fn label(&self) -> &'static str { - match self { - Self::Connected => "Connected", - Self::NotRunning { .. } => "Not running", - Self::ConnectionFailed { .. } => "Connection failed", - Self::Starting { .. } => "Starting", - Self::StartFailed { .. } => "Start failed", - } - } - - pub fn detail(&self) -> &str { - match self { - Self::Connected => "Daemon state is live.", - Self::NotRunning { message } - | Self::ConnectionFailed { message } - | Self::Starting { message } - | Self::StartFailed { message } => message, - } - } - - pub fn guidance(&self) -> &'static str { - match self { - Self::Connected => { - "Use r to refresh or reattach the dashboard, or d to request backend shutdown." - } - Self::Starting { .. } => "Starting cadderd; controls stay responsive.", - Self::NotRunning { .. } => "Press s to start cadderd, r to retry attach, or q to quit.", - Self::ConnectionFailed { .. } => { - "Press r to retry attach, s to start cadderd if needed, or q to quit." - } - Self::StartFailed { .. } => { - "Fix the backend start error, then press s to retry or r to attach if cadderd is already running." - } - } - } - - pub fn is_connected(&self) -> bool { - matches!(self, Self::Connected) - } - - pub fn is_starting(&self) -> bool { - matches!(self, Self::Starting { .. }) - } - - pub fn can_start(&self) -> bool { - !self.is_starting() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DaemonViewModel { - pub state: DaemonConnectionState, -} - -impl Default for DaemonViewModel { - fn default() -> Self { - Self { - state: DaemonConnectionState::NotRunning { - message: "Daemon connection has not been checked yet.".to_string(), - }, - } - } -} - -impl DaemonViewModel { - pub fn mark_connected(&mut self) { - self.state = DaemonConnectionState::Connected; - } - - pub fn mark_unavailable(&mut self, kind: DaemonUnavailableKind, message: impl Into) { - let message = message.into(); - self.state = match kind { - DaemonUnavailableKind::NotRunning => DaemonConnectionState::NotRunning { message }, - DaemonUnavailableKind::ConnectionFailed => { - DaemonConnectionState::ConnectionFailed { message } - } - }; - } - - pub fn mark_starting(&mut self, message: impl Into) { - self.state = DaemonConnectionState::Starting { - message: message.into(), - }; - } - - pub fn mark_start_failed(&mut self, message: impl Into) { - self.state = DaemonConnectionState::StartFailed { - message: message.into(), - }; - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SettingsViewModel { - pub selected_severity: usize, -} - -impl Default for SettingsViewModel { - fn default() -> Self { - Self { - selected_severity: SeverityFilter::All.index(), - } - } -} - -impl SettingsViewModel { - pub fn selected_filter(&self) -> SeverityFilter { - SeverityFilter::ALL - .get(self.selected_severity) - .copied() - .unwrap_or(SeverityFilter::All) - } - - pub fn select_filter(&mut self, filter: SeverityFilter) { - self.selected_severity = filter.index(); - } - - pub fn move_severity_selection(&mut self, delta: isize) { - move_index( - &mut self.selected_severity, - SeverityFilter::ALL.len(), - delta, - ); - } - - fn clamp(&mut self) { - clamp_index(&mut self.selected_severity, SeverityFilter::ALL.len()); - } -} - -#[derive(Debug, Clone)] -pub struct TuiModel { - pub view: View, - pub daemon: DaemonViewModel, - pub search: String, - pub search_mode: bool, - pub entrypoint_selected: usize, - pub domain_selected: usize, - #[cfg(windows)] - pub iis: IisViewModel, - pub logs: LogViewModel, - pub settings: SettingsViewModel, - snapshot: GuiStateSnapshot, -} - -#[cfg(windows)] -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct IisViewModel { - pub bindings: Vec, - pub selected: usize, - pub route_host_input: String, - pub route_host_input_binding_id: Option, - pub route_host_inputs: BTreeMap, - pub route_host_input_mode: bool, - pub loading: bool, - pub request_in_flight: bool, - pub action_in_flight: bool, - pub last_error: Option, -} - -#[cfg(windows)] -impl IisViewModel { - pub fn mark_loading(&mut self) { - self.loading = true; - self.request_in_flight = true; - self.last_error = None; - } - - pub fn apply_response(&mut self, response: QueryIisBindingsResponse) { - self.loading = false; - self.request_in_flight = false; - self.bindings = response.bindings; - self.route_host_inputs.retain(|binding_id, _| { - self - .bindings - .iter() - .any(|binding| binding.identity.binding_id == *binding_id) - }); - if self - .route_host_input_binding_id - .as_ref() - .is_some_and(|binding_id| { - !self - .bindings - .iter() - .any(|binding| binding.identity.binding_id == *binding_id) - }) - { - self.route_host_input_mode = false; - self.route_host_input.clear(); - self.route_host_input_binding_id = None; - } - self.last_error = response.issue.map(|issue| issue.message); - self.clamp(); - } - - pub fn apply_error(&mut self, message: String) { - self.loading = false; - self.request_in_flight = false; - self.last_error = Some(message); - } - - pub fn selected_binding(&self) -> Option { - self.bindings.get(self.selected).cloned() - } - - pub fn begin_route_host_input(&mut self, binding_id: String) { - self.route_host_input = self - .route_host_inputs - .get(&binding_id) - .cloned() - .unwrap_or_default(); - self.route_host_input_binding_id = Some(binding_id); - self.route_host_input_mode = true; - } - - pub fn finish_route_host_input(&mut self) { - if let Some(binding_id) = self.route_host_input_binding_id.as_ref() { - let route_host = self.route_host_input.trim(); - if route_host.is_empty() { - self.route_host_inputs.remove(binding_id); - } else { - self - .route_host_inputs - .insert(binding_id.clone(), route_host.to_string()); - self.route_host_input = route_host.to_string(); - } - } - self.route_host_input_mode = false; - } - - pub fn route_host_for_binding(&self, binding_id: &str) -> Option<&str> { - self - .route_host_inputs - .get(binding_id) - .map(String::as_str) - .filter(|host| !host.trim().is_empty()) - } - - pub fn push_route_host_char(&mut self, ch: char) { - self.route_host_input.push(ch); - } - - pub fn pop_route_host_char(&mut self) { - self.route_host_input.pop(); - } - - fn move_selection(&mut self, delta: isize) { - move_index(&mut self.selected, self.bindings.len(), delta); - } - - fn clamp(&mut self) { - clamp_index(&mut self.selected, self.bindings.len()); - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LogTarget { - pub registration_id: String, - pub domain_name: String, - pub stream: LogStreamIdentity, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LogViewModel { - pub target: Option, - pub entries: Vec, - pub next_cursor: Option, - pub loading: bool, - pub request_in_flight: bool, - pub paused: bool, - pub minimum_severity: Option, - pub status: LogStreamStatus, - pub has_gap: bool, - pub has_more_before: bool, - pub truncated_by_retention: bool, - pub read_error: Option, -} - -impl Default for LogViewModel { - fn default() -> Self { - Self { - target: None, - entries: Vec::new(), - next_cursor: None, - loading: false, - request_in_flight: false, - paused: false, - minimum_severity: None, - status: LogStreamStatus::Empty, - has_gap: false, - has_more_before: false, - truncated_by_retention: false, - read_error: None, - } - } -} - -impl LogViewModel { - pub fn reset_for_target(&mut self, target: LogTarget) { - let paused = self.paused; - let minimum_severity = self.minimum_severity; - *self = Self { - target: Some(target), - paused, - minimum_severity, - loading: true, - request_in_flight: false, - ..Self::default() - }; - } - - pub fn reset_for_filter(&mut self, minimum_severity: Option) { - self.entries.clear(); - self.next_cursor = None; - self.loading = self.target.is_some(); - self.minimum_severity = minimum_severity; - self.status = LogStreamStatus::Empty; - self.has_gap = false; - self.has_more_before = false; - self.truncated_by_retention = false; - self.read_error = None; - } - - pub fn mark_loading(&mut self) { - self.loading = true; - self.request_in_flight = true; - self.read_error = None; - } - - pub fn apply_response(&mut self, response: cadder_protocol::QueryLogsResponse) { - self.loading = false; - self.request_in_flight = false; - self.status = response.stream_status; - self.has_gap = response.has_gap; - self.has_more_before = response.has_more_before; - self.truncated_by_retention = response.truncated_by_retention; - self.next_cursor = response.next_cursor; - self.read_error = None; - self.entries.extend(response.entries); - } - - pub fn apply_read_error(&mut self, message: String) { - self.loading = false; - self.request_in_flight = false; - self.status = LogStreamStatus::ReadError; - self.read_error = Some(message); - } - - pub fn active_stream(&self) -> Option { - self.target.as_ref().map(|target| target.stream.clone()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Summary { - pub runtime: String, - pub config: String, - pub entrypoints: usize, - pub domains: usize, - pub active_domains: usize, -} - -impl Default for TuiModel { - fn default() -> Self { - Self { - view: View::Overview, - daemon: DaemonViewModel::default(), - search: String::new(), - search_mode: false, - entrypoint_selected: 0, - domain_selected: 0, - #[cfg(windows)] - iis: IisViewModel::default(), - logs: LogViewModel::default(), - settings: SettingsViewModel::default(), - snapshot: GuiStateSnapshot { - captured_at_utc: chrono::Utc::now(), - registrations: Vec::new(), - runtime: cadder_protocol::RuntimeState::idle(), - config: cadder_protocol::ConfigState::idle(), - }, - } - } -} - -impl TuiModel { - pub fn set_snapshot(&mut self, snapshot: GuiStateSnapshot) { - self.snapshot = snapshot; - self.clamp_selections(); - } - - pub fn mark_daemon_connected(&mut self) { - self.daemon.mark_connected(); - } - - pub fn mark_daemon_unavailable( - &mut self, - kind: DaemonUnavailableKind, - message: impl Into, - ) { - self.daemon.mark_unavailable(kind, message); - } - - pub fn mark_daemon_starting(&mut self, message: impl Into) { - self.daemon.mark_starting(message); - } - - pub fn mark_daemon_start_failed(&mut self, message: impl Into) { - self.daemon.mark_start_failed(message); - } - - pub fn can_use_daemon(&self) -> bool { - self.daemon.state.is_connected() - } - - pub fn can_start_daemon(&self) -> bool { - self.daemon.state.can_start() - } - - pub fn snapshot(&self) -> &GuiStateSnapshot { - &self.snapshot - } - - pub fn next_view(&mut self) { - let next = (self.view.index() + 1) % View::ALL.len(); - self.view = View::ALL[next]; - self.clamp_current_selection(); - } - - pub fn previous_view(&mut self) { - let index = self.view.index(); - self.view = View::ALL[(index + View::ALL.len() - 1) % View::ALL.len()]; - self.clamp_current_selection(); - } - - pub fn move_selection(&mut self, delta: isize) { - match self.view { - View::Entrypoints => { - let len = self.filtered_registrations().len(); - move_index(&mut self.entrypoint_selected, len, delta); - } - View::Domains => { - let len = self.filtered_domains().len(); - move_index(&mut self.domain_selected, len, delta); - } - #[cfg(windows)] - View::IisHandoff => self.iis.move_selection(delta), - View::Settings => self.settings.move_severity_selection(delta), - _ => {} - } - } - - pub fn clamp_current_selection(&mut self) { - match self.view { - View::Entrypoints => { - let len = self.filtered_registrations().len(); - clamp_index(&mut self.entrypoint_selected, len); - } - View::Domains => { - let len = self.filtered_domains().len(); - clamp_index(&mut self.domain_selected, len); - } - #[cfg(windows)] - View::IisHandoff => self.iis.clamp(), - View::Settings => self.settings.clamp(), - _ => {} - } - } - - pub fn clamp_selections(&mut self) { - let entrypoint_len = self.filtered_registrations().len(); - let domain_len = self.filtered_domains().len(); - clamp_index(&mut self.entrypoint_selected, entrypoint_len); - clamp_index(&mut self.domain_selected, domain_len); - #[cfg(windows)] - self.iis.clamp(); - self.settings.clamp(); - } - - pub fn sync_settings_severity_from_logs(&mut self) { - self - .settings - .select_filter(SeverityFilter::from_minimum_severity( - self.logs.minimum_severity, - )); - } - - pub fn summary(&self) -> Summary { - let domains = self - .snapshot - .registrations - .iter() - .map(|registration| registration.registered_domains.len()) - .sum(); - let active_domains = self - .snapshot - .registrations - .iter() - .flat_map(|registration| ®istration.registered_domains) - .filter(|domain| domain.activation_state.is_enabled()) - .count(); - Summary { - runtime: format!("{:?}", self.snapshot.runtime.status), - config: format!("{:?}", self.snapshot.config.status), - entrypoints: self.snapshot.registrations.len(), - domains, - active_domains, - } - } - - pub fn filtered_registrations(&self) -> Vec<&EntrypointRegistration> { - self - .snapshot - .registrations - .iter() - .filter(|registration| { - self.search.is_empty() - || registration.registration_id.contains(&self.search) - || registration - .source_working_directory - .raw - .contains(&self.search) - || registration.source_config_path.raw.contains(&self.search) - || registration - .registered_domains - .iter() - .any(|domain| domain.name.canonical.contains(&self.search)) - }) - .collect() - } - - pub fn filtered_domains(&self) -> Vec<(&EntrypointRegistration, &RegisteredDomain)> { - self - .snapshot - .registrations - .iter() - .flat_map(|registration| { - registration - .registered_domains - .iter() - .map(move |domain| (registration, domain)) - }) - .filter(|(registration, domain)| { - self.search.is_empty() - || registration.registration_id.contains(&self.search) - || domain.name.canonical.contains(&self.search) - }) - .collect() - } - - pub fn selected_entrypoint(&self) -> Option<&EntrypointRegistration> { - self - .filtered_registrations() - .get(self.entrypoint_selected) - .copied() - } - - pub fn selected_domain(&self) -> Option<(String, RegisteredDomain)> { - self - .filtered_domains() - .get(self.domain_selected) - .map(|(registration, domain)| (registration.registration_id.clone(), (*domain).clone())) - } - - pub fn selected_log_target(&self) -> Option { - self - .selected_domain() - .map(|(registration_id, domain)| LogTarget { - registration_id, - domain_name: domain.name.canonical, - stream: domain.log_stream, - }) - } - - pub fn open_selected_domain_logs(&mut self) -> bool { - let Some(target) = self.selected_log_target() else { - return false; - }; - self.logs.reset_for_target(target); - self.view = View::Logs; - true - } -} - -fn move_index(index: &mut usize, len: usize, delta: isize) { - if len == 0 { - *index = 0; - return; - } - *index = (*index as isize + delta).clamp(0, len.saturating_sub(1) as isize) as usize; -} - -fn clamp_index(index: &mut usize, len: usize) { - if len == 0 { - *index = 0; - return; - } - *index = (*index).min(len.saturating_sub(1)); -} - -#[cfg(test)] -mod tests { - use super::*; - use cadder_protocol::{ - ActivationState, ConfigApplyStatus, ConfigDiagnostic, ConfigState, EntrypointInstanceIdentity, - LogAttributionKind, LogEntryKind, LogStreamIdentity, OwnerProcessIdentity, QueryLogsResponse, - RegisteredDomain, RuntimeDiagnostic, RuntimeState, RuntimeStatus, SourcePath, - }; - use chrono::Utc; - - fn snapshot_with_registrations(registrations: Vec) -> GuiStateSnapshot { - GuiStateSnapshot { - captured_at_utc: Utc::now(), - registrations, - runtime: RuntimeState::idle(), - config: ConfigState::idle(), - } - } - - fn snapshot() -> GuiStateSnapshot { - snapshot_with_registrations(vec![registration( - "shim-1", - "/work/app", - vec![ - RegisteredDomain::active("app.localhost"), - RegisteredDomain { - activation_state: ActivationState::Inactive, - ..RegisteredDomain::active("api.localhost") - }, - ], - )]) - } - - fn registration( - registration_id: &str, - working_directory: &str, - registered_domains: Vec, - ) -> EntrypointRegistration { - let now = Utc::now(); - let identity = EntrypointInstanceIdentity { - instance_id: registration_id.to_string(), - started_at_utc: now, - shim_session_nonce: format!("{registration_id}-nonce"), - }; - EntrypointRegistration { - registration_id: registration_id.to_string(), - entrypoint_instance: identity.clone(), - source_working_directory: SourcePath::new(working_directory, None), - source_config_path: SourcePath::new(format!("{working_directory}/Caddyfile"), None), - registered_domains, - activation_state: ActivationState::Active, - owner_process: OwnerProcessIdentity { - process_id: 1, - process_start_time_utc: now, - shim_session_nonce: identity.shim_session_nonce, - executable_path: None, - }, - log_stream: LogStreamIdentity::entrypoint(registration_id), - shim_run: None, - created_at_utc: now, - last_heartbeat_utc: now, - } - } - - #[test] - fn summary_counts_domains() { - let mut model = TuiModel::default(); - model.set_snapshot(snapshot()); - - let summary = model.summary(); - - assert_eq!(summary.entrypoints, 1); - assert_eq!(summary.domains, 2); - assert_eq!(summary.active_domains, 1); - } - - #[test] - fn daemon_connectivity_state_transitions_are_explicit() { - let mut model = TuiModel::default(); - - assert!(matches!( - model.daemon.state, - DaemonConnectionState::NotRunning { .. } - )); - assert!(!model.can_use_daemon()); - assert!(model.can_start_daemon()); - - model.mark_daemon_starting("starting cadderd"); - assert_eq!(model.daemon.state.label(), "Starting"); - assert!(!model.can_use_daemon()); - assert!(!model.can_start_daemon()); - - model.mark_daemon_unavailable(DaemonUnavailableKind::ConnectionFailed, "permission denied"); - assert!(matches!( - model.daemon.state, - DaemonConnectionState::ConnectionFailed { .. } - )); - assert!(model.can_start_daemon()); - - model.mark_daemon_start_failed("could not find cadderd"); - assert_eq!(model.daemon.state.label(), "Start failed"); - assert!(model.daemon.state.guidance().contains("retry")); - - model.mark_daemon_connected(); - assert!(model.can_use_daemon()); - assert_eq!(model.daemon.state.label(), "Connected"); - } - - #[test] - fn view_titles_indices_and_wrapping_navigation_are_stable() { - #[cfg(windows)] - let expected = [ - ("Overview", 0), - ("Entrypoints", 1), - ("Domains", 2), - ("IIS Handoff", 3), - ("Logs", 4), - ("Settings", 5), - ("Diagnostics", 6), - ]; - #[cfg(not(windows))] - let expected = [ - ("Overview", 0), - ("Entrypoints", 1), - ("Domains", 2), - ("Logs", 3), - ("Settings", 4), - ("Diagnostics", 5), - ]; - - assert_eq!(View::ALL.map(|view| (view.title(), view.index())), expected); - - let mut model = TuiModel::default(); - model.previous_view(); - assert_eq!(model.view, View::Diagnostics); - model.next_view(); - assert_eq!(model.view, View::Overview); - } - - #[test] - fn move_selection_clamps_to_visible_rows() { - let mut model = TuiModel::default(); - model.set_snapshot(snapshot()); - model.view = View::Domains; - - model.move_selection(10); - assert_eq!(model.domain_selected, 1); - model.move_selection(-10); - assert_eq!(model.domain_selected, 0); - } - - #[cfg(windows)] - #[test] - fn iis_selection_clamps_to_discovered_bindings() { - let mut model = TuiModel { - view: View::IisHandoff, - ..TuiModel::default() - }; - model.iis.bindings = vec![ - cadder_protocol::IisBinding { - identity: cadder_protocol::IisBindingIdentity { - binding_id: "one".to_string(), - site_name: "Default Web Site".to_string(), - protocol: "http".to_string(), - binding_information: "*:80:app.localhost".to_string(), - }, - ip_address: "*".to_string(), - port: 80, - host_header: "app.localhost".to_string(), - domain_key: Some("app.localhost".to_string()), - handoff_state: cadder_protocol::IisHandoffState::Available, - issue: None, - restore_metadata: None, - }, - cadder_protocol::IisBinding { - identity: cadder_protocol::IisBindingIdentity { - binding_id: "two".to_string(), - site_name: "Default Web Site".to_string(), - protocol: "https".to_string(), - binding_information: "*:443:secure.localhost".to_string(), - }, - ip_address: "*".to_string(), - port: 443, - host_header: "secure.localhost".to_string(), - domain_key: Some("secure.localhost".to_string()), - handoff_state: cadder_protocol::IisHandoffState::Unsupported, - issue: None, - restore_metadata: None, - }, - ]; - - model.move_selection(10); - assert_eq!(model.iis.selected, 1); - model.iis.bindings.pop(); - model.clamp_current_selection(); - assert_eq!(model.iis.selected, 0); - } - - #[cfg(not(windows))] - #[test] - fn iis_view_is_absent_on_non_windows() { - assert_eq!( - View::ALL.map(|view| view.title()), - [ - "Overview", - "Entrypoints", - "Domains", - "Logs", - "Settings", - "Diagnostics" - ] - ); - } - - #[test] - fn top_level_navigation_preserves_per_view_selection() { - let mut model = TuiModel::default(); - model.set_snapshot(snapshot_with_registrations(vec![ - registration( - "shim-1", - "/work/app", - vec![RegisteredDomain::active("app.localhost")], - ), - registration( - "shim-2", - "/work/api", - vec![ - RegisteredDomain::active("api.localhost"), - RegisteredDomain::active("admin.localhost"), - ], - ), - ])); - - model.view = View::Entrypoints; - model.move_selection(1); - assert_eq!(model.entrypoint_selected, 1); - - model.next_view(); - model.move_selection(2); - assert_eq!(model.view, View::Domains); - assert_eq!(model.domain_selected, 2); - - model.previous_view(); - assert_eq!(model.view, View::Entrypoints); - assert_eq!(model.entrypoint_selected, 1); - - model.next_view(); - assert_eq!(model.view, View::Domains); - assert_eq!(model.domain_selected, 2); - } - - #[test] - fn set_snapshot_clamps_all_row_selections() { - let mut model = TuiModel::default(); - model.set_snapshot(snapshot_with_registrations(vec![ - registration( - "shim-1", - "/work/app", - vec![RegisteredDomain::active("app.localhost")], - ), - registration( - "shim-2", - "/work/api", - vec![RegisteredDomain::active("api.localhost")], - ), - ])); - model.entrypoint_selected = 1; - model.domain_selected = 1; - - model.set_snapshot(snapshot_with_registrations(vec![registration( - "shim-1", - "/work/app", - vec![RegisteredDomain::active("app.localhost")], - )])); - - assert_eq!(model.entrypoint_selected, 0); - assert_eq!(model.domain_selected, 0); - } - - #[test] - fn settings_severity_selection_maps_to_log_filter() { - let mut model = TuiModel { - view: View::Settings, - ..TuiModel::default() - }; - - assert_eq!(model.settings.selected_filter(), SeverityFilter::All); - assert_eq!(model.settings.selected_filter().minimum_severity(), None); - - model.move_selection(2); - assert_eq!(model.settings.selected_filter(), SeverityFilter::Warn); - assert_eq!( - model.settings.selected_filter().minimum_severity(), - Some(LogSeverity::Warn) - ); - - model.logs.minimum_severity = Some(LogSeverity::Error); - model.sync_settings_severity_from_logs(); - - assert_eq!(model.settings.selected_filter(), SeverityFilter::Error); - } - - #[test] - fn filters_domains_by_search() { - let mut model = TuiModel::default(); - model.set_snapshot(snapshot()); - model.search = "api".to_string(); - - let domains = model.filtered_domains(); - - assert_eq!(domains.len(), 1); - assert_eq!(domains[0].1.name.canonical, "api.localhost"); - } - - #[test] - fn domain_rows_keep_entrypoint_association() { - let mut model = TuiModel::default(); - model.set_snapshot(snapshot_with_registrations(vec![ - registration( - "shim-1", - "/work/app", - vec![RegisteredDomain::active("app.localhost")], - ), - registration( - "shim-2", - "/work/api", - vec![RegisteredDomain::active("api.localhost")], - ), - ])); - - let domains = model.filtered_domains(); - - assert_eq!(domains.len(), 2); - assert_eq!(domains[0].0.registration_id, "shim-1"); - assert_eq!(domains[0].1.name.canonical, "app.localhost"); - assert_eq!(domains[1].0.registration_id, "shim-2"); - assert_eq!(domains[1].1.name.canonical, "api.localhost"); - } - - #[test] - fn opens_selected_domain_log_target() { - let mut model = TuiModel::default(); - model.set_snapshot(snapshot()); - model.view = View::Domains; - - assert!(model.open_selected_domain_logs()); - - let target = model.logs.target.as_ref().unwrap(); - assert_eq!(model.view, View::Logs); - assert_eq!(target.registration_id, "shim-1"); - assert_eq!(target.domain_name, "app.localhost"); - assert_eq!(target.stream, LogStreamIdentity::domain("app.localhost")); - assert!(model.logs.loading); - } - - #[test] - fn severity_change_resets_log_cursor_and_entries() { - let mut logs = LogViewModel { - target: Some(LogTarget { - registration_id: "shim-1".to_string(), - domain_name: "app.localhost".to_string(), - stream: LogStreamIdentity::domain("app.localhost"), - }), - next_cursor: Some("seq:10".to_string()), - entries: vec![cadder_protocol::LogEntry { - sequence_number: 10, - timestamp_utc: Utc::now(), - severity: LogSeverity::Info, - stream: LogStreamIdentity::domain("app.localhost"), - attribution_kind: cadder_protocol::LogAttributionKind::Domain, - entry_kind: cadder_protocol::LogEntryKind::Normal, - raw_message: "first".to_string(), - domain_key: Some("app.localhost".to_string()), - source_registration_id: None, - source_instance_id: None, - operation: None, - }], - ..LogViewModel::default() - }; - - logs.reset_for_filter(Some(LogSeverity::Error)); - - assert!(logs.entries.is_empty()); - assert_eq!(logs.next_cursor, None); - assert_eq!(logs.minimum_severity, Some(LogSeverity::Error)); - assert!(logs.loading); - } - - #[test] - fn severity_change_preserves_in_flight_log_request_state() { - let mut logs = LogViewModel { - target: Some(LogTarget { - registration_id: "shim-1".to_string(), - domain_name: "app.localhost".to_string(), - stream: LogStreamIdentity::domain("app.localhost"), - }), - loading: true, - request_in_flight: true, - ..LogViewModel::default() - }; - - logs.reset_for_filter(Some(LogSeverity::Warn)); - - assert!(logs.loading); - assert!(logs.request_in_flight); - assert_eq!(logs.minimum_severity, Some(LogSeverity::Warn)); - } - - #[test] - fn log_response_applies_status_cursor_and_retention_metadata() { - let mut logs = LogViewModel { - target: Some(LogTarget { - registration_id: "shim-1".to_string(), - domain_name: "app.localhost".to_string(), - stream: LogStreamIdentity::domain("app.localhost"), - }), - loading: true, - request_in_flight: true, - ..LogViewModel::default() - }; - - logs.apply_response(QueryLogsResponse { - request_id: "logs".to_string(), - accepted: true, - message: "ok".to_string(), - stream: LogStreamIdentity::domain("app.localhost"), - stream_status: LogStreamStatus::Stale, - entries: vec![cadder_protocol::LogEntry { - sequence_number: 7, - timestamp_utc: Utc::now(), - severity: LogSeverity::Warn, - stream: LogStreamIdentity::domain("app.localhost"), - attribution_kind: LogAttributionKind::Domain, - entry_kind: LogEntryKind::Normal, - raw_message: "retained warning".to_string(), - domain_key: Some("app.localhost".to_string()), - source_registration_id: None, - source_instance_id: None, - operation: None, - }], - next_cursor: Some("seq:7".to_string()), - has_gap: true, - has_more_before: true, - truncated_by_retention: true, - }); - - assert!(!logs.loading); - assert!(!logs.request_in_flight); - assert_eq!(logs.status, LogStreamStatus::Stale); - assert_eq!(logs.next_cursor.as_deref(), Some("seq:7")); - assert!(logs.has_gap); - assert!(logs.has_more_before); - assert!(logs.truncated_by_retention); - } - - #[test] - fn diagnostics_snapshot_is_available_to_diagnostics_view() { - let mut model = TuiModel::default(); - let mut snapshot = snapshot(); - snapshot.config = ConfigState { - status: ConfigApplyStatus::Failed, - last_attempted_at_utc: Some(Utc::now()), - last_successful_reload_at_utc: None, - effective_config_hash: None, - diagnostics: vec![ConfigDiagnostic { - code: "runtime-apply-failed".to_string(), - message: "reload failed".to_string(), - domain_key: None, - source_config_paths: vec!["/work/app/Caddyfile".to_string()], - }], - }; - snapshot.runtime = RuntimeState { - status: RuntimeStatus::Unhealthy, - binary_path: None, - version: None, - process_id: None, - admin_endpoint: None, - diagnostics: vec![RuntimeDiagnostic { - code: "runtime-error".to_string(), - message: "process exited".to_string(), - operation: Some("run".to_string()), - }], - }; - - model.set_snapshot(snapshot); - - assert_eq!( - model.snapshot().config.diagnostics[0].code, - "runtime-apply-failed" - ); - assert_eq!( - model.snapshot().runtime.diagnostics[0].code, - "runtime-error" - ); - } -} diff --git a/crates/cadder/Cargo.toml b/crates/cadder/Cargo.toml new file mode 100644 index 0000000..6b36a1f --- /dev/null +++ b/crates/cadder/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cadder" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +color-eyre = "0.6.5" +crossterm = "0.29.0" +ratatui = "0.30.2" +serde = { version = "1.0.228", features = ["derive"] } +serde_yaml = { version = "0.10.4", package = "yaml_serde" } +tui-term = "0.3.4" diff --git a/crates/cadder/src/app.rs b/crates/cadder/src/app.rs new file mode 100644 index 0000000..4bec0a3 --- /dev/null +++ b/crates/cadder/src/app.rs @@ -0,0 +1,325 @@ +use ratatui::widgets::TableState; +use std::time::{SystemTime, UNIX_EPOCH}; +use tui_term::vt100::Screen; + +use crate::data::{DataModel, DomainTableRow, EntityId, SettingsTableRow}; +use crate::logs::LogStore; + +pub const DETAIL_SHORTCUTS: [crate::widgets::Shortcut; 4] = [ + crate::widgets::Shortcut::new("Arrows", "prev/next item"), + crate::widgets::Shortcut::new("PgUp/PgDn", "scroll details"), + crate::widgets::Shortcut::new("Esc", "close details"), + crate::widgets::Shortcut::new("Ctrl+C/X", "quit"), +]; + +pub struct App { + data: DataModel, + logs: LogStore, + table_states: Vec, + runtime_status: RuntimeStatus, + active_tab: usize, + details: Option, + should_quit: bool, +} + +#[derive(Clone, Copy)] +pub struct RuntimeStatus { + cadderd_running: bool, + caddy_running: bool, +} + +pub struct DetailsState { + title: String, + lines: Vec, + scroll: usize, +} + +impl App { + pub fn load() -> color_eyre::Result { + let data = DataModel::load()?; + let logs = LogStore::new(); + let table_states = vec![ + table_state_for_len(data.domain_rows().len()), + table_state_for_len(data.settings_rows().len()), + TableState::new(), + ]; + + Ok(Self { + data, + logs, + table_states, + runtime_status: pseudorandom_runtime_status(), + active_tab: 0, + details: None, + should_quit: false, + }) + } + + pub fn active_table_state_mut(&mut self) -> &mut TableState { + &mut self.table_states[self.active_tab] + } + + pub const fn active_tab(&self) -> usize { + self.active_tab + } + + pub const fn runtime_status(&self) -> RuntimeStatus { + self.runtime_status + } + + pub fn tab_labels(&self) -> [String; 3] { + [ + "Domains".to_string(), + "Settings".to_string(), + "logs".to_string(), + ] + } + + pub fn domain_rows(&self) -> Vec { + self.data.domain_rows() + } + + pub fn settings_rows(&self) -> Vec { + self.data.settings_rows() + } + + pub fn log_screen(&self) -> Option { + self.logs.screen() + } + + pub fn poll_logs(&mut self) { + self.logs.poll() + } + + pub fn set_logs_viewport(&mut self, rows: u16, cols: u16) { + self.logs.set_viewport(rows, cols); + } + + pub const fn details(&self) -> Option<&DetailsState> { + self.details.as_ref() + } + + pub const fn should_quit(&self) -> bool { + self.should_quit + } + + pub fn quit(&mut self) { + self.should_quit = true; + } + + pub fn next_tab(&mut self) { + self.set_active_tab((self.active_tab + 1) % self.table_states.len()); + } + + pub fn previous_tab(&mut self) { + self.set_active_tab((self.active_tab + self.table_states.len() - 1) % self.table_states.len()); + } + + pub fn set_active_tab(&mut self, index: usize) { + self.active_tab = index.min(self.table_states.len().saturating_sub(1)); + self.ensure_selected_row(); + } + + pub fn select_next(&mut self) { + let len = self.active_row_len(); + if len == 0 { + self.active_table_state_mut().select(None); + return; + } + + let current = self.selected_index().unwrap_or(0); + self + .active_table_state_mut() + .select(Some((current + 1).min(len - 1))); + } + + pub fn select_previous(&mut self) { + let len = self.active_row_len(); + if len == 0 { + self.active_table_state_mut().select(None); + return; + } + + let current = self.selected_index().unwrap_or(0); + self + .active_table_state_mut() + .select(Some(current.saturating_sub(1))); + } + + pub fn scroll_logs_up(&mut self, amount: usize) { + self.logs.scroll_up(amount); + } + + pub fn scroll_logs_down(&mut self, amount: usize) { + self.logs.scroll_down(amount); + } + + pub fn toggle_current(&mut self) { + let Some(entity) = self.selected_entity() else { + return; + }; + + self.data.toggle(entity); + self.ensure_selected_row(); + } + + pub fn open_details(&mut self) { + let Some(entity) = self.selected_entity() else { + return; + }; + + self.details = Some(DetailsState::new( + self.data.title(entity), + self.data.describe(entity), + )); + } + + pub fn close_details(&mut self) { + self.details = None; + } + + pub fn previous_details_item(&mut self) { + if self.details.is_none() { + return; + } + + let previous = self.selected_index(); + self.select_previous(); + if self.selected_index() != previous { + self.open_details(); + } + } + + pub fn next_details_item(&mut self) { + if self.details.is_none() { + return; + } + + let previous = self.selected_index(); + self.select_next(); + if self.selected_index() != previous { + self.open_details(); + } + } + + pub fn page_details_up(&mut self) { + if let Some(details) = &mut self.details { + details.scroll = details.scroll.saturating_sub(8); + } + } + + pub fn page_details_down(&mut self) { + if let Some(details) = &mut self.details { + details.scroll = (details.scroll + 8).min(details.line_count().saturating_sub(1)); + } + } + + pub fn active_row_len(&self) -> usize { + match self.active_tab { + 0 => self.data.domain_rows().len(), + 1 => self.data.settings_rows().len(), + 2 => 0, + _ => 0, + } + } + + fn selected_index(&self) -> Option { + let len = self.active_row_len(); + if len == 0 { + None + } else { + self.table_states[self.active_tab] + .selected() + .map(|index| index.min(len - 1)) + } + } + + fn selected_entity(&self) -> Option { + let index = self.selected_index()?; + match self.active_tab { + 0 => self + .data + .domain_rows() + .get(index) + .map(DomainTableRow::entity), + 1 => self + .data + .settings_rows() + .get(index) + .map(SettingsTableRow::entity), + _ => None, + } + } + + fn ensure_selected_row(&mut self) { + let len = self.active_row_len(); + if len == 0 { + self.active_table_state_mut().select(None); + return; + } + + let selected = self + .table_states + .get(self.active_tab) + .and_then(TableState::selected) + .unwrap_or(0) + .min(len - 1); + self.active_table_state_mut().select(Some(selected)); + } +} + +fn table_state_for_len(len: usize) -> TableState { + if len == 0 { + TableState::new() + } else { + TableState::new().with_selected(0) + } +} + +fn pseudorandom_runtime_status() -> RuntimeStatus { + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + + RuntimeStatus { + cadderd_running: seed & 1 == 0, + caddy_running: seed & 2 == 0, + } +} + +impl RuntimeStatus { + pub const fn cadderd_running(self) -> bool { + self.cadderd_running + } + + pub const fn caddy_running(self) -> bool { + self.caddy_running + } +} + +impl DetailsState { + fn new(title: String, lines: Vec) -> Self { + Self { + title, + lines, + scroll: 0, + } + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn lines(&self) -> &[String] { + &self.lines + } + + pub const fn scroll(&self) -> usize { + self.scroll + } + + pub fn line_count(&self) -> usize { + self.lines.len() + } +} diff --git a/crates/cadder/src/data.rs b/crates/cadder/src/data.rs new file mode 100644 index 0000000..27b494b --- /dev/null +++ b/crates/cadder/src/data.rs @@ -0,0 +1,356 @@ +use color_eyre::eyre::{Context, Result}; +use serde::Deserialize; + +const MOCK_DATA: &str = include_str!("mock_data.yaml"); +const MOCK_SETTINGS: &str = include_str!("mock_settings.yaml"); + +#[derive(Debug, Clone)] +pub struct DataModel { + projects: Vec, + settings: Settings, +} + +#[derive(Debug, Clone)] +pub struct Project { + name: String, + enabled: bool, + is_iis: bool, + domains: Vec, +} + +#[derive(Debug, Clone)] +pub struct Domain { + name: String, + port: u16, + enabled: bool, +} + +#[derive(Debug, Clone)] +pub struct Settings { + deamon_autorun: bool, + rewire_iis: bool, +} + +#[derive(Debug, Clone)] +pub struct DomainTableRow { + entity: EntityId, + kind: DomainRowKind, + enabled: bool, + visually_enabled: bool, + name: String, + port: Option, + count: Option, + spaced_before: bool, + is_iis: bool, +} + +#[derive(Debug, Clone)] +pub struct SettingsTableRow { + entity: EntityId, + enabled: bool, + name: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntityId { + Project(usize), + Domain { project: usize, domain: usize }, + Setting(SettingId), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingId { + DeamonAutorun, + RewireIis, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DomainRowKind { + Project, + Domain, +} + +impl DataModel { + pub fn load() -> Result { + let data: MockDataFile = serde_yaml::from_str(MOCK_DATA).wrap_err("parsing mock_data.yaml")?; + let settings: SettingsFile = + serde_yaml::from_str(MOCK_SETTINGS).wrap_err("parsing mock_settings.yaml")?; + + Ok(Self { + projects: data.into_projects(), + settings: Settings { + deamon_autorun: settings.deamon_autorun, + rewire_iis: settings.rewire_iis, + }, + }) + } + + pub fn domain_rows(&self) -> Vec { + self + .projects + .iter() + .enumerate() + .flat_map(|(project_index, project)| { + let project_row = DomainTableRow { + entity: EntityId::Project(project_index), + kind: DomainRowKind::Project, + enabled: project.enabled, + visually_enabled: project.enabled, + name: project.name.clone(), + port: None, + count: Some(project.domains.len()), + spaced_before: project_index > 0, + is_iis: project.is_iis, + }; + + std::iter::once(project_row).chain(project.domains.iter().enumerate().map( + move |(domain_index, domain)| DomainTableRow { + entity: EntityId::Domain { + project: project_index, + domain: domain_index, + }, + kind: DomainRowKind::Domain, + enabled: domain.enabled, + visually_enabled: project.enabled && domain.enabled, + name: domain.name.clone(), + port: Some(domain.port), + count: None, + spaced_before: false, + is_iis: false, + }, + )) + }) + .collect() + } + + pub fn settings_rows(&self) -> Vec { + vec![ + SettingsTableRow { + entity: EntityId::Setting(SettingId::DeamonAutorun), + enabled: self.settings.deamon_autorun, + name: "deamon autorun".to_string(), + }, + SettingsTableRow { + entity: EntityId::Setting(SettingId::RewireIis), + enabled: self.settings.rewire_iis, + name: "rewire IIS".to_string(), + }, + ] + } + + pub fn toggle(&mut self, entity: EntityId) { + match entity { + EntityId::Project(index) => { + if let Some(project) = self.projects.get_mut(index) { + project.enabled = !project.enabled; + } + } + EntityId::Domain { project, domain } => { + if let Some(domain) = self + .projects + .get_mut(project) + .and_then(|project| project.domains.get_mut(domain)) + { + domain.enabled = !domain.enabled; + } + } + EntityId::Setting(SettingId::DeamonAutorun) => { + self.settings.deamon_autorun = !self.settings.deamon_autorun; + } + EntityId::Setting(SettingId::RewireIis) => { + self.settings.rewire_iis = !self.settings.rewire_iis; + } + } + } + + pub fn describe(&self, entity: EntityId) -> Vec { + match entity { + EntityId::Project(index) => self + .projects + .get(index) + .map(|project| { + vec![ + format!("Selected project: {}", project.name), + format!("Enabled: {}", yes_no(project.enabled)), + format!("Domains: {}", project.domains.len()), + ] + }) + .unwrap_or_else(|| vec!["Selected project: unavailable".to_string()]), + EntityId::Domain { project, domain } => self + .projects + .get(project) + .and_then(|project_data| { + project_data + .domains + .get(domain) + .map(|domain_data| (project_data, domain_data)) + }) + .map(|(project_data, domain_data)| { + vec![ + format!("Selected domain: {}", domain_data.name), + format!("Project: {}", project_data.name), + format!("Port: :{}", domain_data.port), + format!("Enabled: {}", yes_no(domain_data.enabled)), + ] + }) + .unwrap_or_else(|| vec!["Selected domain: unavailable".to_string()]), + EntityId::Setting(SettingId::DeamonAutorun) => vec![ + "Selected setting: deamon autorun".to_string(), + format!("Enabled: {}", yes_no(self.settings.deamon_autorun)), + ], + EntityId::Setting(SettingId::RewireIis) => vec![ + "Selected setting: rewire IIS".to_string(), + format!("Enabled: {}", yes_no(self.settings.rewire_iis)), + ], + } + } + + pub fn title(&self, entity: EntityId) -> String { + match entity { + EntityId::Project(index) => self + .projects + .get(index) + .map(|project| format!(" {} ", project.name)) + .unwrap_or_else(|| " Selected ".to_string()), + EntityId::Domain { project, domain } => self + .projects + .get(project) + .and_then(|project| project.domains.get(domain)) + .map(|domain| format!(" {} ", domain.name)) + .unwrap_or_else(|| " Selected ".to_string()), + EntityId::Setting(SettingId::DeamonAutorun) => " deamon autorun ".to_string(), + EntityId::Setting(SettingId::RewireIis) => " rewire IIS ".to_string(), + } + } +} + +impl DomainTableRow { + pub const fn entity(&self) -> EntityId { + self.entity + } + + pub const fn kind(&self) -> DomainRowKind { + self.kind + } + + pub const fn enabled(&self) -> bool { + self.enabled + } + + pub const fn visually_enabled(&self) -> bool { + self.visually_enabled + } + + pub fn name(&self) -> &str { + &self.name + } + + pub const fn port(&self) -> Option { + self.port + } + + pub const fn count(&self) -> Option { + self.count + } + + pub const fn spaced_before(&self) -> bool { + self.spaced_before + } + + pub const fn is_iis(&self) -> bool { + self.is_iis + } +} + +impl SettingsTableRow { + pub const fn entity(&self) -> EntityId { + self.entity + } + + pub const fn enabled(&self) -> bool { + self.enabled + } + + pub fn name(&self) -> &str { + &self.name + } +} + +#[derive(Debug, Deserialize)] +struct MockDataFile { + #[serde(default)] + projects: Vec, + iis: Option, +} + +#[derive(Debug, Deserialize)] +struct ProjectFile { + name: String, + #[serde(default)] + enabled: bool, + #[serde(default)] + domains: Vec, +} + +#[derive(Debug, Deserialize)] +struct IisFile { + #[serde(default)] + enabled: bool, + #[serde(default)] + domains: Vec, +} + +#[derive(Debug, Deserialize)] +struct DomainFile { + domain: String, + port: u16, + #[serde(default)] + enabled: bool, +} + +#[derive(Debug, Deserialize)] +struct SettingsFile { + deamon_autorun: bool, + #[serde(default)] + rewire_iis: bool, +} + +impl MockDataFile { + fn into_projects(self) -> Vec { + let mut projects = self + .projects + .into_iter() + .map(|project| Project { + name: project.name, + enabled: project.enabled, + is_iis: false, + domains: project.domains.into_iter().map(Domain::from).collect(), + }) + .collect::>(); + + if let Some(iis) = self.iis { + projects.push(Project { + name: "IIS".to_string(), + enabled: iis.enabled, + is_iis: true, + domains: iis.domains.into_iter().map(Domain::from).collect(), + }); + } + + projects + } +} + +impl From for Domain { + fn from(value: DomainFile) -> Self { + Self { + name: value.domain, + port: value.port, + enabled: value.enabled, + } + } +} + +const fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} diff --git a/crates/cadder/src/logs/mod.rs b/crates/cadder/src/logs/mod.rs new file mode 100644 index 0000000..dadb282 --- /dev/null +++ b/crates/cadder/src/logs/mod.rs @@ -0,0 +1,98 @@ +use tui_term::vt100::Screen; + +pub(crate) const MAX_LOG_LINES: usize = 1_000; + +pub struct LogStore { + screen: Option, + scrollback: usize, + follow_tail: bool, + viewport: TerminalSize, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +pub(crate) struct TerminalSize { + rows: u16, + cols: u16, +} + +impl TerminalSize { + fn new(rows: u16, cols: u16) -> Self { + Self { + rows: if rows == 0 { 1 } else { rows }, + cols: if cols == 0 { 1 } else { cols }, + } + } + + pub(crate) const fn rows(self) -> u16 { + self.rows + } + + pub(crate) const fn cols(self) -> u16 { + self.cols + } +} + +impl LogStore { + pub fn new() -> Self { + Self { + screen: None, + scrollback: 0, + follow_tail: true, + viewport: TerminalSize::new(1, 1), + } + } + + pub fn poll(&mut self) {} + + pub fn set_viewport(&mut self, rows: u16, cols: u16) { + let viewport = TerminalSize::new(rows, cols); + let viewport_changed = self.viewport != viewport; + self.viewport = viewport; + + if !viewport_changed { + return; + } + + if let Some(screen) = &mut self.screen { + screen.set_size(viewport.rows(), viewport.cols()); + } + self.clamp_scrollback(); + } + + pub fn screen(&self) -> Option { + let mut screen = self.screen.clone()?; + if !self.follow_tail { + screen.set_scrollback(self.scrollback.min(available_scrollback(&screen))); + } + Some(screen) + } + + pub fn scroll_up(&mut self, amount: usize) { + let available_scrollback = self.current_available_scrollback(); + self.scrollback = self + .scrollback + .saturating_add(amount) + .min(available_scrollback); + self.follow_tail = self.scrollback == 0; + } + + pub fn scroll_down(&mut self, amount: usize) { + self.scrollback = self.scrollback.saturating_sub(amount); + self.follow_tail = self.scrollback == 0; + } + + fn current_available_scrollback(&self) -> usize { + self.screen.as_ref().map(available_scrollback).unwrap_or(0) + } + + fn clamp_scrollback(&mut self) { + self.scrollback = self.scrollback.min(self.current_available_scrollback()); + self.follow_tail = self.scrollback == 0; + } +} + +fn available_scrollback(screen: &Screen) -> usize { + let mut screen = screen.clone(); + screen.set_scrollback(MAX_LOG_LINES); + screen.scrollback() +} diff --git a/crates/cadder/src/main.rs b/crates/cadder/src/main.rs new file mode 100644 index 0000000..7c1bd21 --- /dev/null +++ b/crates/cadder/src/main.rs @@ -0,0 +1,199 @@ +mod app; +mod data; +mod logs; +mod widgets; + +use std::io; +use std::time::Duration; + +use app::App; +use color_eyre::Result; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::layout::{Constraint, Layout, Margin, Rect}; +use ratatui::widgets::StatefulWidget; +use ratatui::{DefaultTerminal, Frame}; +use widgets::{ + AppTabs, DetailOverlay, DimBackground, DomainsTab, HeaderBar, LOG_SHORTCUTS, LogsTab, + MAIN_SHORTCUTS, SettingsTab, ShortcutsBar, +}; + +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn main() -> Result<()> { + color_eyre::install()?; + let mut app = App::load()?; + + let mut terminal = ratatui::init(); + let result = run(&mut terminal, &mut app); + ratatui::restore(); + result?; + + Ok(()) +} + +fn run(terminal: &mut DefaultTerminal, app: &mut App) -> Result<()> { + while !app.should_quit() { + app.poll_logs(); + terminal.draw(|frame| render(frame, app))?; + + if event::poll(Duration::from_millis(16))? { + handle_event(event::read()?, app)?; + } + } + + Ok(()) +} + +fn handle_event(event: Event, app: &mut App) -> io::Result<()> { + match event { + Event::Key(key) if key.kind == KeyEventKind::Press => { + handle_key(key.code, key.modifiers, app); + } + _ => {} + } + + Ok(()) +} + +fn handle_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { + if is_force_quit_key(code, modifiers) { + app.quit(); + return; + } + + if app.details().is_some() { + match code { + KeyCode::Esc => app.close_details(), + KeyCode::Left | KeyCode::Up => app.previous_details_item(), + KeyCode::Right | KeyCode::Down => app.next_details_item(), + KeyCode::PageUp => app.page_details_up(), + KeyCode::PageDown => app.page_details_down(), + _ => {} + } + return; + } + + match code { + KeyCode::Esc => app.quit(), + KeyCode::Left => app.previous_tab(), + KeyCode::Right => app.next_tab(), + KeyCode::Up if app.active_tab() == 2 => app.scroll_logs_up(1), + KeyCode::Down if app.active_tab() == 2 => app.scroll_logs_down(1), + KeyCode::PageUp if app.active_tab() == 2 => app.scroll_logs_up(10), + KeyCode::PageDown if app.active_tab() == 2 => app.scroll_logs_down(10), + KeyCode::Up => app.select_previous(), + KeyCode::Down => app.select_next(), + KeyCode::Char(' ') => app.toggle_current(), + KeyCode::Enter => app.open_details(), + _ => {} + } +} + +fn is_force_quit_key(code: KeyCode, modifiers: KeyModifiers) -> bool { + modifiers.contains(KeyModifiers::CONTROL) && matches!(code, KeyCode::Char('c' | 'C' | 'x' | 'X')) +} + +fn render(frame: &mut Frame<'_>, app: &mut App) { + let root = frame.area(); + let (main_area, footer_area) = root_areas(root); + + render_main(frame, main_area, app); + + if app.details().is_some() { + frame.render_widget(DimBackground, main_area); + } + if let Some(details) = app.details() { + frame.render_widget( + DetailOverlay::new(details.title(), details.lines(), details.scroll()), + main_area, + ); + } + + let shortcuts = if app.details().is_some() { + app::DETAIL_SHORTCUTS.as_slice() + } else if app.active_tab() == 2 { + LOG_SHORTCUTS.as_slice() + } else { + MAIN_SHORTCUTS.as_slice() + }; + frame.render_widget(ShortcutsBar::new(shortcuts), footer_area); +} + +fn render_main(frame: &mut Frame<'_>, area: Rect, app: &mut App) { + if area.height == 0 { + return; + } + + let (header_area, tabs_area, content_area) = main_areas(area); + let logs_viewport = content_area.inner(Margin { + vertical: 1, + horizontal: 1, + }); + + frame.render_widget( + HeaderBar::new(APP_VERSION, app.runtime_status()), + header_area, + ); + frame.render_widget(AppTabs::new(app.active_tab(), app.tab_labels()), tabs_area); + app.set_logs_viewport(logs_viewport.height, logs_viewport.width); + + let active_tab = app.active_tab(); + match active_tab { + 0 => { + let rows = app.domain_rows(); + StatefulWidget::render( + DomainsTab::new(rows), + content_area, + frame.buffer_mut(), + app.active_table_state_mut(), + ); + } + 1 => { + let rows = app.settings_rows(); + StatefulWidget::render( + SettingsTab::new(rows), + content_area, + frame.buffer_mut(), + app.active_table_state_mut(), + ); + } + 2 => { + let screen = app.log_screen(); + StatefulWidget::render( + LogsTab::new(screen.as_ref()), + content_area, + frame.buffer_mut(), + app.active_table_state_mut(), + ); + } + _ => {} + } +} + +fn root_areas(root: Rect) -> (Rect, Rect) { + let footer_height = 1.min(root.height); + let main_area = Rect { + x: root.x, + y: root.y, + width: root.width, + height: root.height.saturating_sub(footer_height), + }; + let footer_area = Rect { + x: root.x, + y: root.y + main_area.height, + width: root.width, + height: footer_height, + }; + + (main_area, footer_area) +} + +fn main_areas(area: Rect) -> (Rect, Rect, Rect) { + let [header_area, tabs_area, content_area] = area.layout(&Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Fill(1), + ])); + + (header_area, tabs_area, content_area) +} diff --git a/crates/cadder/src/mock_data.yaml b/crates/cadder/src/mock_data.yaml new file mode 100644 index 0000000..444f140 --- /dev/null +++ b/crates/cadder/src/mock_data.yaml @@ -0,0 +1,46 @@ +projects: + - name: aaa + path: D:/Projects/Personal/Cadder/crates + enabled: true + created_at: 2026-02-11 11:02:57 + updated_at: 2026-02-12 12:20:40 + domains: + - domain: app.aaa.local + port: 8000 + enabled: true + created_at: 2026-02-11 11:02:57 + updated_at: 2026-02-11 11:02:57 + - domain: api.aaa.local + port: 3000 + enabled: true + created_at: 2026-02-11 11:02:57 + updated_at: 2026-02-11 11:02:57 + - domain: db.aaa.local + port: 3442 + enabled: false + created_at: 2026-02-11 11:02:57 + updated_at: 2026-02-12 12:20:40 + - name: bbb + path: D:/Projects/Personal/Cadder + enabled: false + created_at: 2026-03-03 13:33:33 + updated_at: 2026-03-03 13:33:33 + domains: + - domain: dupa.bbb.local + port: 3233 + enabled: false + created_at: 2026-03-03 13:33:33 + updated_at: 2026-03-03 13:33:33 + - domain: xd.local + port: 2333 + enabled: true + created_at: 2026-03-03 13:33:33 + updated_at: 2026-03-03 13:33:33 +iis: + enabled: true + domains: + - domain: test.iis.local + port: 2221 + created_at: 2026-03-03 13:33:33 + updated_at: 2026-03-03 13:33:33 + enabled: true diff --git a/crates/cadder/src/mock_settings.yaml b/crates/cadder/src/mock_settings.yaml new file mode 100644 index 0000000..2fa482d --- /dev/null +++ b/crates/cadder/src/mock_settings.yaml @@ -0,0 +1,3 @@ +deamon_autorun: false +rewire_iis: false +iis_rewired: true diff --git a/crates/cadder/src/widgets/details_overlay.rs b/crates/cadder/src/widgets/details_overlay.rs new file mode 100644 index 0000000..d09af89 --- /dev/null +++ b/crates/cadder/src/widgets/details_overlay.rs @@ -0,0 +1,95 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::{Constraint, Layout, Margin, Rect}; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{ + Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, + StatefulWidget, Widget, Wrap, +}; + +use crate::widgets::theme::THEME; + +pub struct DetailOverlay<'a> { + title: &'a str, + lines: &'a [String], + scroll: usize, +} + +impl<'a> DetailOverlay<'a> { + pub const fn new(title: &'a str, lines: &'a [String], scroll: usize) -> Self { + Self { + title, + lines, + scroll, + } + } +} + +impl Widget for DetailOverlay<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let popup_area = centered_rect(area, 66, 44); + Clear.render(popup_area, buf); + + let block = Block::new() + .title(Line::from(self.title).style(THEME.overlay_title())) + .borders(Borders::ALL) + .border_style(THEME.overlay_border()) + .style(THEME.overlay()); + let inner = block.inner(popup_area); + block.render(popup_area, buf); + + let text_area = inner.inner(Margin { + vertical: 0, + horizontal: 1, + }); + let viewport_height = usize::from(text_area.height).max(1); + let max_scroll = self.lines.len().saturating_sub(viewport_height); + let scroll = self.scroll.min(max_scroll); + + Paragraph::new(Text::from(detail_lines(self.lines))) + .wrap(Wrap { trim: false }) + .scroll((scroll.min(usize::from(u16::MAX)) as u16, 0)) + .style(THEME.text()) + .render(text_area, buf); + + if self.lines.len() > viewport_height { + let mut scrollbar_state = ScrollbarState::new(self.lines.len()) + .viewport_content_length(viewport_height) + .position(scroll); + StatefulWidget::render( + Scrollbar::new(ScrollbarOrientation::VerticalRight), + inner, + buf, + &mut scrollbar_state, + ); + } + } +} + +fn centered_rect(area: Rect, width_percent: u16, height_percent: u16) -> Rect { + let [_, vertical, _] = area.layout(&Layout::vertical([ + Constraint::Percentage((100 - height_percent) / 2), + Constraint::Percentage(height_percent), + Constraint::Percentage((100 - height_percent) / 2), + ])); + let [_, horizontal, _] = vertical.layout(&Layout::horizontal([ + Constraint::Percentage((100 - width_percent) / 2), + Constraint::Percentage(width_percent), + Constraint::Percentage((100 - width_percent) / 2), + ])); + + horizontal +} + +fn detail_lines(lines: &[String]) -> Vec> { + lines + .iter() + .enumerate() + .map(|(index, line)| { + if index == 0 { + Line::from(line.as_str()).style(THEME.accent_text()) + } else { + Line::from(line.as_str()).style(THEME.text()) + } + }) + .collect() +} diff --git a/crates/cadder/src/widgets/dim_background.rs b/crates/cadder/src/widgets/dim_background.rs new file mode 100644 index 0000000..ec4778c --- /dev/null +++ b/crates/cadder/src/widgets/dim_background.rs @@ -0,0 +1,13 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::Widget; + +use crate::widgets::theme::THEME; + +pub struct DimBackground; + +impl Widget for DimBackground { + fn render(self, area: Rect, buf: &mut Buffer) { + buf.set_style(area, THEME.dim_background()); + } +} diff --git a/crates/cadder/src/widgets/header.rs b/crates/cadder/src/widgets/header.rs new file mode 100644 index 0000000..494291e --- /dev/null +++ b/crates/cadder/src/widgets/header.rs @@ -0,0 +1,75 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::Widget; + +use crate::app::RuntimeStatus; +use crate::widgets::theme::THEME; + +const STATUS_WIDTH: u16 = 16; + +pub struct HeaderBar { + version: &'static str, + runtime_status: RuntimeStatus, +} + +impl HeaderBar { + pub const fn new(version: &'static str, runtime_status: RuntimeStatus) -> Self { + Self { + version, + runtime_status, + } + } +} + +impl Widget for HeaderBar { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + buf.set_style(area, THEME.header()); + + let title_width = if area.width > STATUS_WIDTH + 1 { + area.width - STATUS_WIDTH - 1 + } else { + area.width + }; + let title = format!(" Cadder v{}", self.version); + buf.set_stringn( + area.x, + area.y, + title, + title_width as usize, + THEME.header_title(), + ); + + if area.width >= STATUS_WIDTH { + let status_x = area.x + area.width - STATUS_WIDTH; + render_service_status( + buf, + status_x, + area.y, + "cadderd", + self.runtime_status.cadderd_running(), + ); + buf.set_string(status_x + 7, area.y, " • ", THEME.service_separator()); + render_service_status( + buf, + status_x + 10, + area.y, + "caddy", + self.runtime_status.caddy_running(), + ); + } + } +} + +fn render_service_status(buf: &mut Buffer, x: u16, y: u16, label: &str, running: bool) { + let style = if running { + THEME.service_online() + } else { + THEME.service_offline() + }; + + buf.set_string(x, y, label, style); +} diff --git a/crates/cadder/src/widgets/mod.rs b/crates/cadder/src/widgets/mod.rs new file mode 100644 index 0000000..de7c4a7 --- /dev/null +++ b/crates/cadder/src/widgets/mod.rs @@ -0,0 +1,14 @@ +mod details_overlay; +mod dim_background; +mod header; +mod shortcuts; +mod tabs; +pub mod tabs_content; +mod theme; + +pub use details_overlay::DetailOverlay; +pub use dim_background::DimBackground; +pub use header::HeaderBar; +pub use shortcuts::{LOG_SHORTCUTS, MAIN_SHORTCUTS, Shortcut, ShortcutsBar}; +pub use tabs::AppTabs; +pub use tabs_content::{DomainsTab, LogsTab, SettingsTab}; diff --git a/crates/cadder/src/widgets/shortcuts.rs b/crates/cadder/src/widgets/shortcuts.rs new file mode 100644 index 0000000..e999bf3 --- /dev/null +++ b/crates/cadder/src/widgets/shortcuts.rs @@ -0,0 +1,66 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Widget}; + +use crate::widgets::theme::THEME; + +pub const MAIN_SHORTCUTS: [Shortcut; 6] = [ + Shortcut::new("Left/Right", "switch tab"), + Shortcut::new("Up/Down", "select row"), + Shortcut::new("Space", "toggle enabled"), + Shortcut::new("Enter", "details"), + Shortcut::new("Esc", "quit"), + Shortcut::new("Ctrl+C/X", "quit"), +]; + +pub const LOG_SHORTCUTS: [Shortcut; 5] = [ + Shortcut::new("Left/Right", "switch tab"), + Shortcut::new("Up/Down", "scroll logs"), + Shortcut::new("PgUp/PgDn", "scroll logs"), + Shortcut::new("Esc", "quit"), + Shortcut::new("Ctrl+C/X", "quit"), +]; + +#[derive(Debug, Clone, Copy)] +pub struct Shortcut { + key: &'static str, + description: &'static str, +} + +pub struct ShortcutsBar<'a> { + shortcuts: &'a [Shortcut], +} + +impl Shortcut { + pub const fn new(key: &'static str, description: &'static str) -> Self { + Self { key, description } + } +} + +impl<'a> ShortcutsBar<'a> { + pub const fn new(shortcuts: &'a [Shortcut]) -> Self { + Self { shortcuts } + } +} + +impl Widget for ShortcutsBar<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let shortcut_spans = self + .shortcuts + .iter() + .flat_map(|shortcut| { + [ + Span::styled(shortcut.key, THEME.shortcut_key()), + Span::styled( + format!(" {} ", shortcut.description), + THEME.shortcut_description(), + ), + ] + }) + .collect::>(); + Paragraph::new(Line::from(shortcut_spans)) + .style(THEME.footer()) + .render(area, buf); + } +} diff --git a/crates/cadder/src/widgets/tabs.rs b/crates/cadder/src/widgets/tabs.rs new file mode 100644 index 0000000..161b1d4 --- /dev/null +++ b/crates/cadder/src/widgets/tabs.rs @@ -0,0 +1,30 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::symbols; +use ratatui::widgets::{Tabs, Widget}; + +use crate::widgets::theme::THEME; + +pub struct AppTabs { + selected: usize, + labels: [String; 3], +} + +impl AppTabs { + pub const fn new(selected: usize, labels: [String; 3]) -> Self { + Self { selected, labels } + } +} + +impl Widget for AppTabs { + fn render(self, area: Rect, buf: &mut Buffer) { + let tabs = Tabs::new(self.labels) + .select(self.selected) + .divider(symbols::DOT) + .padding(" ", " ") + .style(THEME.inactive_tab()) + .highlight_style(THEME.active_tab()); + + tabs.render(area, buf); + } +} diff --git a/crates/cadder/src/widgets/tabs_content/body.rs b/crates/cadder/src/widgets/tabs_content/body.rs new file mode 100644 index 0000000..9b1b932 --- /dev/null +++ b/crates/cadder/src/widgets/tabs_content/body.rs @@ -0,0 +1,212 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::{Alignment, Constraint, Margin, Rect}; +use ratatui::style::Color; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, Borders, Cell, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, + Table, TableState, Widget, +}; + +use crate::data::{DomainRowKind, DomainTableRow, SettingsTableRow}; +use crate::widgets::theme::THEME; + +pub(crate) enum TableBodyRows { + Domains(Vec), + Settings(Vec), +} + +pub(crate) struct TableBody { + accent: Color, + rows: TableBodyRows, +} + +impl TableBody { + pub const fn new(accent: Color, rows: TableBodyRows) -> Self { + Self { accent, rows } + } +} + +impl StatefulWidget for TableBody { + type State = TableState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let block = Block::new() + .borders(Borders::ALL) + .border_style(THEME.table_border()) + .style(THEME.table()); + let inner = block.inner(area); + block.render(area, buf); + + let row_count = self.rows.len(); + let has_header = self.rows.has_header(); + let reserved_header = if has_header { 2 } else { 0 }; + let viewport = usize::from(inner.height.saturating_sub(reserved_header)).max(1); + apply_scroll_margin(state, row_count, viewport); + + let header = has_header.then(|| Row::new(self.rows.headers()).style(THEME.table_header())); + let table = Table::new( + self.rows.into_rows(), + [ + Constraint::Length(5), + Constraint::Min(20), + Constraint::Length(14), + ], + ) + .column_spacing(1) + .row_highlight_style(THEME.table_highlight(self.accent)) + .highlight_symbol("> "); + let table = if let Some(header) = header { + table.header(header.bottom_margin(1)) + } else { + table + }; + + StatefulWidget::render(table, inner, buf, state); + + if row_count > viewport { + let mut scrollbar_state = ScrollbarState::new(row_count) + .viewport_content_length(viewport) + .position(state.offset()); + StatefulWidget::render( + Scrollbar::new(ScrollbarOrientation::VerticalRight), + inner.inner(Margin { + vertical: if has_header { 1 } else { 0 }, + horizontal: 0, + }), + buf, + &mut scrollbar_state, + ); + } + } +} + +impl TableBodyRows { + fn len(&self) -> usize { + match self { + Self::Domains(rows) => rows.len(), + Self::Settings(rows) => rows.len(), + } + } + + const fn has_header(&self) -> bool { + true + } + + fn headers(&self) -> [&'static str; 3] { + match self { + Self::Domains(_) => ["", "Name", "Port"], + Self::Settings(_) => ["", "Option", ""], + } + } + + fn into_rows(self) -> Vec> { + match self { + Self::Domains(rows) => rows.into_iter().map(domain_row).collect(), + Self::Settings(rows) => rows.into_iter().map(settings_row).collect(), + } + } +} + +fn domain_row(row: DomainTableRow) -> Row<'static> { + let visually_enabled = row.visually_enabled(); + let row_style = row_style(visually_enabled); + let mut table_row = match row.kind() { + DomainRowKind::Project => Row::new([ + Cell::from(checkbox(row.enabled(), visually_enabled)), + Cell::from( + Line::from(row.name().to_string()) + .style(project_name_style(visually_enabled, row.is_iis())), + ), + Cell::from(right_aligned(format!( + "({})", + row.count().unwrap_or_default() + ))), + ]) + .style(row_style), + DomainRowKind::Domain => Row::new([ + Cell::from(checkbox(row.enabled(), visually_enabled)), + Cell::from(format!(" {}", row.name())), + Cell::from(right_aligned( + row + .port() + .map_or_else(String::new, |port| format!(":{port}")), + )), + ]) + .style(row_style), + }; + + if row.spaced_before() { + table_row = table_row.top_margin(1); + } + + table_row +} + +fn settings_row(row: SettingsTableRow) -> Row<'static> { + let enabled = row.enabled(); + Row::new([ + Cell::from(checkbox(enabled, enabled)), + Cell::from(row.name().to_string()), + Cell::from(""), + ]) + .style(row_style(enabled)) +} + +fn checkbox(checked: bool, visually_enabled: bool) -> Span<'static> { + let marker = if checked { "[x]" } else { "[ ]" }; + Span::styled(marker, checkbox_style(visually_enabled)) +} + +fn row_style(enabled: bool) -> ratatui::style::Style { + if enabled { + THEME.table_row() + } else { + THEME.table_disabled_row() + } +} + +fn checkbox_style(enabled: bool) -> ratatui::style::Style { + if enabled { + THEME.selected_marker() + } else { + THEME.disabled_marker() + } +} + +fn project_name_style(visual_enabled: bool, is_iis: bool) -> ratatui::style::Style { + if is_iis { + THEME.iis_project() + } else if visual_enabled { + THEME.accent_text() + } else { + THEME.table_disabled_row() + } +} + +fn right_aligned(value: String) -> Line<'static> { + Line::from(value).alignment(Alignment::Right) +} + +fn apply_scroll_margin(state: &mut TableState, len: usize, viewport: usize) { + let Some(selected) = state.selected() else { + return; + }; + if len <= viewport { + *state.offset_mut() = 0; + return; + } + + let margin = 2.min(viewport.saturating_sub(1)); + let max_offset = len.saturating_sub(viewport); + let mut offset = state.offset().min(max_offset); + if selected < offset + margin { + offset = selected.saturating_sub(margin); + } else if selected + margin >= offset + viewport { + offset = selected + .saturating_add(margin) + .saturating_add(1) + .saturating_sub(viewport) + .min(max_offset); + } + *state.offset_mut() = offset; +} diff --git a/crates/cadder/src/widgets/tabs_content/domains.rs b/crates/cadder/src/widgets/tabs_content/domains.rs new file mode 100644 index 0000000..a48cbf9 --- /dev/null +++ b/crates/cadder/src/widgets/tabs_content/domains.rs @@ -0,0 +1,26 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::{StatefulWidget, TableState}; + +use crate::data::DomainTableRow; +use crate::widgets::tabs_content::{TableBody, TableBodyRows}; +use crate::widgets::theme::THEME; + +pub struct DomainsTab { + rows: Vec, +} + +impl DomainsTab { + pub const fn new(rows: Vec) -> Self { + Self { rows } + } +} + +impl StatefulWidget for DomainsTab { + type State = TableState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + TableBody::new(THEME.domains_accent(), TableBodyRows::Domains(self.rows)) + .render(area, buf, state); + } +} diff --git a/crates/cadder/src/widgets/tabs_content/logs.rs b/crates/cadder/src/widgets/tabs_content/logs.rs new file mode 100644 index 0000000..f638e09 --- /dev/null +++ b/crates/cadder/src/widgets/tabs_content/logs.rs @@ -0,0 +1,38 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::{Block, Borders, StatefulWidget, TableState, Widget}; +use tui_term::vt100::Screen; +use tui_term::widget::{Cursor, PseudoTerminal}; + +use crate::widgets::theme::THEME; + +pub struct LogsTab<'a> { + screen: Option<&'a Screen>, +} + +impl<'a> LogsTab<'a> { + pub const fn new(screen: Option<&'a Screen>) -> Self { + Self { screen } + } +} + +impl StatefulWidget for LogsTab<'_> { + type State = TableState; + + fn render(self, area: Rect, buf: &mut Buffer, _state: &mut Self::State) { + let block = Block::new() + .borders(Borders::ALL) + .border_style(THEME.table_border()) + .style(THEME.table()); + + if let Some(screen) = self.screen { + PseudoTerminal::new(screen) + .block(block) + .cursor(Cursor::default().visibility(false)) + .style(THEME.text()) + .render(area, buf); + } else { + block.render(area, buf); + } + } +} diff --git a/crates/cadder/src/widgets/tabs_content/mod.rs b/crates/cadder/src/widgets/tabs_content/mod.rs new file mode 100644 index 0000000..9f7496f --- /dev/null +++ b/crates/cadder/src/widgets/tabs_content/mod.rs @@ -0,0 +1,10 @@ +mod body; +mod domains; +mod logs; +mod settings; + +pub use domains::DomainsTab; +pub use logs::LogsTab; +pub use settings::SettingsTab; + +pub(crate) use body::{TableBody, TableBodyRows}; diff --git a/crates/cadder/src/widgets/tabs_content/settings.rs b/crates/cadder/src/widgets/tabs_content/settings.rs new file mode 100644 index 0000000..6953fb8 --- /dev/null +++ b/crates/cadder/src/widgets/tabs_content/settings.rs @@ -0,0 +1,26 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::{StatefulWidget, TableState}; + +use crate::data::SettingsTableRow; +use crate::widgets::tabs_content::{TableBody, TableBodyRows}; +use crate::widgets::theme::THEME; + +pub struct SettingsTab { + rows: Vec, +} + +impl SettingsTab { + pub const fn new(rows: Vec) -> Self { + Self { rows } + } +} + +impl StatefulWidget for SettingsTab { + type State = TableState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + TableBody::new(THEME.settings_accent(), TableBodyRows::Settings(self.rows)) + .render(area, buf, state); + } +} diff --git a/crates/cadder/src/widgets/theme.rs b/crates/cadder/src/widgets/theme.rs new file mode 100644 index 0000000..6f73509 --- /dev/null +++ b/crates/cadder/src/widgets/theme.rs @@ -0,0 +1,198 @@ +use ratatui::style::{Color, Modifier, Style}; + +pub(crate) const THEME: Theme = Theme::cadder(); + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Theme { + colors: Colors, + tabs: TabAccents, +} + +#[derive(Debug, Clone, Copy)] +struct Colors { + backdrop: Color, + surface: Color, + surface_elevated: Color, + surface_selected: Color, + border: Color, + border_strong: Color, + text: Color, + text_muted: Color, + text_soft: Color, + mint: Color, + mint_soft: Color, + cyan_mint: Color, + dim: Color, +} + +#[derive(Debug, Clone, Copy)] +struct TabAccents { + domains: Color, + settings: Color, +} + +impl Theme { + pub const fn cadder() -> Self { + let colors = Colors { + backdrop: Color::Rgb(4, 15, 14), + surface: Color::Rgb(7, 24, 22), + surface_elevated: Color::Rgb(10, 33, 30), + surface_selected: Color::Rgb(12, 55, 49), + border: Color::Rgb(26, 102, 91), + border_strong: Color::Rgb(49, 226, 198), + text: Color::Rgb(223, 255, 249), + text_muted: Color::Rgb(139, 190, 181), + text_soft: Color::Rgb(187, 231, 224), + mint: Color::Rgb(58, 232, 203), + mint_soft: Color::Rgb(125, 244, 221), + cyan_mint: Color::Rgb(67, 213, 226), + dim: Color::Rgb(45, 85, 80), + }; + + Self { + tabs: TabAccents { + domains: colors.mint, + settings: colors.mint, + }, + colors, + } + } + + pub const fn domains_accent(self) -> Color { + self.tabs.domains + } + + pub const fn settings_accent(self) -> Color { + self.tabs.settings + } + + pub fn inactive_tab(self) -> Style { + Style::new().fg(self.colors.text_muted) + } + + pub fn active_tab(self) -> Style { + Style::new() + .fg(self.colors.mint_soft) + .bg(self.colors.surface_selected) + .add_modifier(Modifier::BOLD) + } + + pub fn shortcut_key(self) -> Style { + Style::new() + .fg(self.colors.mint_soft) + .add_modifier(Modifier::BOLD) + } + + pub fn shortcut_description(self) -> Style { + Style::new().fg(self.colors.text_muted) + } + + pub fn footer(self) -> Style { + Style::new().bg(self.colors.backdrop) + } + + pub fn header(self) -> Style { + Style::new().bg(self.colors.backdrop) + } + + pub fn header_title(self) -> Style { + Style::new() + .fg(self.colors.text) + .bg(self.colors.backdrop) + .add_modifier(Modifier::BOLD) + } + + pub fn service_online(self) -> Style { + Style::new() + .fg(self.colors.mint_soft) + .bg(self.colors.backdrop) + .add_modifier(Modifier::BOLD) + } + + pub fn service_offline(self) -> Style { + Style::new() + .fg(self.colors.dim) + .bg(self.colors.backdrop) + .add_modifier(Modifier::DIM) + } + + pub fn service_separator(self) -> Style { + Style::new() + .fg(self.colors.text_muted) + .bg(self.colors.backdrop) + } + + pub fn dim_background(self) -> Style { + Style::new() + .fg(self.colors.dim) + .bg(self.colors.backdrop) + .add_modifier(Modifier::DIM) + } + + pub fn table(self) -> Style { + Style::new().bg(self.colors.surface) + } + + pub fn table_border(self) -> Style { + Style::new().fg(self.colors.border) + } + + pub fn table_row(self) -> Style { + Style::new().fg(self.colors.text_soft) + } + + pub fn table_disabled_row(self) -> Style { + Style::new().fg(self.colors.text_muted) + } + + pub fn table_header(self) -> Style { + Style::new() + .fg(self.colors.mint_soft) + .bg(self.colors.surface_selected) + .add_modifier(Modifier::BOLD) + } + + pub fn table_highlight(self, _accent: Color) -> Style { + Style::new().add_modifier(Modifier::BOLD) + } + + pub fn selected_marker(self) -> Style { + Style::new() + .fg(self.colors.mint_soft) + .add_modifier(Modifier::BOLD) + } + + pub fn disabled_marker(self) -> Style { + Style::new().fg(self.colors.text_muted) + } + + pub fn iis_project(self) -> Style { + Style::new() + .fg(self.colors.cyan_mint) + .add_modifier(Modifier::BOLD) + } + + pub fn overlay(self) -> Style { + Style::new().bg(self.colors.surface_elevated) + } + + pub fn overlay_border(self) -> Style { + Style::new().fg(self.colors.border_strong) + } + + pub fn overlay_title(self) -> Style { + Style::new() + .fg(self.colors.mint_soft) + .add_modifier(Modifier::BOLD) + } + + pub fn text(self) -> Style { + Style::new().fg(self.colors.text) + } + + pub fn accent_text(self) -> Style { + Style::new() + .fg(self.colors.mint_soft) + .add_modifier(Modifier::BOLD) + } +} diff --git a/crates/cadderctl/Cargo.toml b/crates/cadderctl/Cargo.toml deleted file mode 100644 index ae7f959..0000000 --- a/crates/cadderctl/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "cadderctl" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Cadder non-TUI CLI for daemon state, domains, logs, and automation" - -[dependencies] -anyhow = "1.0.102" -cadder-daemon = { version = "0.8.0", path = "../cadder-daemon" } -cadder-operator = { version = "0.8.0", path = "../cadder-operator" } -cadder-protocol = { version = "0.8.0", path = "../cadder-protocol" } -chrono = { version = "0.4.45", features = ["serde"] } -clap = { version = "4.6.1", features = ["derive"] } -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.150" -tokio = { version = "1.52.3", features = ["full"] } - -[dev-dependencies] -tempfile = "3.27.0" diff --git a/crates/cadderctl/src/app.rs b/crates/cadderctl/src/app.rs deleted file mode 100644 index 0fbc51e..0000000 --- a/crates/cadderctl/src/app.rs +++ /dev/null @@ -1,1231 +0,0 @@ -#[cfg(test)] -use crate::view::{SelectedDomain, resolve_domain}; -use crate::{ - cli::{ - CliArgs, Command, DaemonCommand, DiagnosticsCommand, DomainsCommand, LogsCommand, - LogsTargetCommand, OutputMode, WatchCommand, - }, - error::{ - CliError, CliErrorKind, daemon_error_indicates_unavailable, format_error_chain, start_guidance, - }, - render, - view::{ - ActionResultView, ConnectionStateView, DaemonStartView, LogsView, daemon_status_connected, - daemon_status_unavailable, diagnostics_view, domains_view, entrypoints_view, map_severity, - }, -}; -use anyhow::Result as AnyResult; -use cadder_daemon::{DaemonLaunchOptions, RuntimePaths, StateSubscription}; -use cadder_operator::{DomainSelector, OperatorContext}; -use cadder_protocol::{ - BasicResponse, GuiStateSnapshot, LogStreamIdentity, QueryStateResponse, ShutdownDaemonRequest, - StateChangedEvent, message_types, new_request_id, -}; -use chrono::Utc; -use serde::Serialize; -use serde_json::json; -use std::{ - io::{self, Write}, - time::Duration, -}; - -pub async fn run(args: CliArgs, stdout: &mut dyn Write, stderr: &mut dyn Write) -> io::Result { - let output = args.output; - match execute(args, stdout).await { - Ok(()) => Ok(0), - Err(ExecuteError::Cli(error)) => { - write_error(output, &error, stdout, stderr)?; - Ok(error.exit_code().code()) - } - Err(ExecuteError::Io(error)) => Err(error), - } -} - -#[derive(Debug)] -enum ExecuteError { - Cli(CliError), - Io(io::Error), -} - -impl From for ExecuteError { - fn from(value: CliError) -> Self { - Self::Cli(value) - } -} - -impl From for ExecuteError { - fn from(value: io::Error) -> Self { - Self::Io(value) - } -} - -struct AppContext { - output: OutputMode, - operator: OperatorContext, -} - -impl AppContext { - fn new(args: &CliArgs) -> Result { - let command = args.command_label(); - Ok(Self { - output: args.output, - operator: OperatorContext::new( - command, - args.runtime_dir.clone(), - DaemonLaunchOptions { - explicit_daemon: args.daemon_path.clone(), - real_caddy_command: args.real_caddy_command.clone(), - shim_path: None, - }, - )?, - }) - } - - fn paths(&self) -> &RuntimePaths { - self.operator.paths() - } - - async fn query_state_response(&self) -> AnyResult { - self.operator.query_state_response().await - } - - async fn query_snapshot( - &self, - command: &'static str, - action: &str, - ) -> Result { - self.operator.query_snapshot(command, action).await - } - - async fn request_basic( - &self, - command: &'static str, - action: &str, - message_type: &str, - response_type: &str, - request: &impl Serialize, - ) -> Result { - self - .operator - .request_basic(command, action, message_type, response_type, request) - .await - } - - async fn query_logs( - &self, - command: &'static str, - action: &str, - stream: LogStreamIdentity, - limit: usize, - cursor: Option, - minimum_severity: Option, - ) -> Result { - self - .operator - .query_logs(command, action, stream, limit, cursor, minimum_severity) - .await - } - - async fn subscribe_state( - &self, - command: &'static str, - action: &str, - ) -> Result { - self.operator.subscribe_state(command, action).await - } - - async fn ensure_daemon_running(&self, command: &'static str) -> Result<(), CliError> { - self.operator.ensure_daemon_running(command).await - } - - async fn set_entrypoint_enabled( - &self, - command: &'static str, - registration_id: String, - enabled: bool, - ) -> Result { - self - .operator - .set_entrypoint_enabled(command, registration_id, enabled) - .await - } - - async fn set_domain_enabled( - &self, - command: &'static str, - selector: &DomainSelector, - enabled: bool, - ) -> Result { - self - .operator - .set_domain_enabled(command, selector, enabled) - .await - } - - async fn resolve_logs_target( - &self, - command: &'static str, - target: cadder_operator::LogsTarget, - ) -> Result { - self.operator.resolve_logs_target(command, target).await - } -} - -async fn execute(args: CliArgs, stdout: &mut dyn Write) -> Result<(), ExecuteError> { - let command = args.command_label(); - validate_output_mode(args.output, &args.command, command)?; - let context = AppContext::new(&args)?; - - match args.command { - Command::Daemon { command } => handle_daemon(&context, command, stdout).await, - Command::Entrypoints { command } => handle_entrypoints(&context, command, stdout).await, - Command::Domains { command } => handle_domains(&context, command, stdout).await, - Command::Diagnostics { command } => handle_diagnostics(&context, command, stdout).await, - Command::Logs { command } => handle_logs(&context, command, stdout).await, - Command::Watch { command } => handle_watch(&context, command, stdout).await, - } -} - -fn validate_output_mode( - output: OutputMode, - command: &Command, - label: &'static str, -) -> Result<(), CliError> { - let streaming = matches!( - command, - Command::Logs { - command: LogsCommand::Tail { .. } - } | Command::Watch { .. } - ); - - if streaming && output == OutputMode::Json { - return Err(CliError::invalid_usage( - label, - "Streaming commands support `human` or `jsonl` output.".to_string(), - Some("Use `--output jsonl` for streaming automation or omit `--output` for human-readable tail/watch output.".to_string()), - )); - } - - if !streaming && output == OutputMode::Jsonl { - return Err(CliError::invalid_usage( - label, - "`jsonl` output is only available for `watch` and `logs tail`.".to_string(), - Some("Use `--output json` for one-shot commands.".to_string()), - )); - } - - match command { - Command::Logs { - command: LogsCommand::Show { options, .. }, - } if options.limit == 0 || options.limit > 500 => { - return Err(CliError::invalid_usage( - label, - "`--limit` must be between 1 and 500.".to_string(), - None, - )); - } - Command::Logs { - command: LogsCommand::Tail { options, .. }, - } if options.read.limit == 0 || options.read.limit > 500 => { - return Err(CliError::invalid_usage( - label, - "`--limit` must be between 1 and 500.".to_string(), - None, - )); - } - Command::Logs { - command: LogsCommand::Tail { options, .. }, - } if options.poll_interval_ms < 50 || options.poll_interval_ms > 60_000 => { - return Err(CliError::invalid_usage( - label, - "`--poll-interval-ms` must be between 50 and 60000.".to_string(), - None, - )); - } - _ => {} - } - - Ok(()) -} - -async fn handle_daemon( - context: &AppContext, - command: DaemonCommand, - stdout: &mut dyn Write, -) -> Result<(), ExecuteError> { - match command { - DaemonCommand::Status => { - let view = match context.query_state_response().await { - Ok(response) => { - let Some(snapshot) = response.snapshot else { - return Err(CliError::new( - command.label(), - CliErrorKind::IpcFailure, - "Cadder daemon returned no state snapshot.".to_string(), - Some("Retry `cadderctl daemon status` after the daemon finishes its current state transition.".to_string()), - ) - .into()); - }; - daemon_status_connected(context.paths().runtime_dir(), &snapshot) - } - Err(error) if daemon_error_indicates_unavailable(&error) => daemon_status_unavailable( - context.paths().runtime_dir(), - connection_state_from_error(&error), - unavailable_message(context.paths().runtime_dir(), &error), - Some(start_guidance(context.paths().runtime_dir())), - ), - Err(error) => { - return Err( - CliError::daemon_request( - command.label(), - context.paths().runtime_dir(), - "query daemon status", - &error, - ) - .into(), - ); - } - }; - write_one_shot( - context.output, - command.label(), - &view, - stdout, - render::write_human_daemon_status, - )?; - } - DaemonCommand::Start => { - let already_running = match context.query_state_response().await { - Ok(_) => true, - Err(error) if daemon_error_indicates_unavailable(&error) => false, - Err(error) => { - return Err( - CliError::daemon_request( - command.label(), - context.paths().runtime_dir(), - "check daemon status before start", - &error, - ) - .into(), - ); - } - }; - - context.ensure_daemon_running(command.label()).await?; - let snapshot = context - .query_snapshot(command.label(), "query daemon status after start") - .await?; - let view = DaemonStartView { - started: !already_running, - message: if already_running { - "cadderd is already running.".to_string() - } else { - "cadderd started.".to_string() - }, - status: daemon_status_connected(context.paths().runtime_dir(), &snapshot), - }; - write_one_shot( - context.output, - command.label(), - &view, - stdout, - render::write_human_daemon_start, - )?; - } - DaemonCommand::Shutdown => { - let response = context - .request_basic( - command.label(), - "request daemon shutdown", - message_types::SHUTDOWN_DAEMON_REQUEST, - message_types::SHUTDOWN_DAEMON_RESPONSE, - &ShutdownDaemonRequest { - request_id: new_request_id("ctl-shutdown"), - }, - ) - .await?; - if !response.accepted { - return Err( - CliError::conflict_or_rejected( - command.label(), - response.message, - Some("Retry once Cadder has finished the current runtime operation.".to_string()), - ) - .into(), - ); - } - write_one_shot( - context.output, - command.label(), - &ActionResultView { - message: response.message, - }, - stdout, - render::write_human_action, - )?; - } - } - - Ok(()) -} - -async fn handle_entrypoints( - context: &AppContext, - command: crate::cli::EntrypointsCommand, - stdout: &mut dyn Write, -) -> Result<(), ExecuteError> { - match command { - crate::cli::EntrypointsCommand::List => { - let snapshot = context - .query_snapshot("entrypoints list", "query entrypoints") - .await?; - let view = entrypoints_view(&snapshot); - write_one_shot( - context.output, - "entrypoints list", - &view, - stdout, - render::write_human_entrypoints, - )?; - } - crate::cli::EntrypointsCommand::Enable { registration_id } => { - toggle_entrypoint(context, "entrypoints enable", registration_id, true, stdout).await?; - } - crate::cli::EntrypointsCommand::Disable { registration_id } => { - toggle_entrypoint( - context, - "entrypoints disable", - registration_id, - false, - stdout, - ) - .await?; - } - } - Ok(()) -} - -async fn toggle_entrypoint( - context: &AppContext, - command: &'static str, - registration_id: String, - enabled: bool, - stdout: &mut dyn Write, -) -> Result<(), ExecuteError> { - let response = context - .set_entrypoint_enabled(command, registration_id, enabled) - .await?; - - write_one_shot( - context.output, - command, - &ActionResultView { - message: response.message, - }, - stdout, - render::write_human_action, - )?; - Ok(()) -} - -async fn handle_domains( - context: &AppContext, - command: DomainsCommand, - stdout: &mut dyn Write, -) -> Result<(), ExecuteError> { - match command { - DomainsCommand::List { registration } => { - let snapshot = context - .query_snapshot("domains list", "query domains") - .await?; - let view = domains_view(&snapshot, registration.as_deref()); - write_one_shot( - context.output, - "domains list", - &view, - stdout, - render::write_human_domains, - )?; - } - DomainsCommand::Enable { selector } => { - toggle_domain(context, "domains enable", selector, true, stdout).await?; - } - DomainsCommand::Disable { selector } => { - toggle_domain(context, "domains disable", selector, false, stdout).await?; - } - } - Ok(()) -} - -async fn toggle_domain( - context: &AppContext, - command: &'static str, - selector: crate::cli::DomainSelectorArgs, - enabled: bool, - stdout: &mut dyn Write, -) -> Result<(), ExecuteError> { - let response = context - .set_domain_enabled( - command, - &DomainSelector { - domain: selector.domain, - registration: selector.registration, - }, - enabled, - ) - .await?; - write_one_shot( - context.output, - command, - &ActionResultView { - message: response.message, - }, - stdout, - render::write_human_action, - )?; - Ok(()) -} - -async fn handle_diagnostics( - context: &AppContext, - command: DiagnosticsCommand, - stdout: &mut dyn Write, -) -> Result<(), ExecuteError> { - match command { - DiagnosticsCommand::Show => { - let snapshot = context - .query_snapshot(command.label(), "query diagnostics") - .await?; - let view = diagnostics_view(&snapshot); - write_one_shot( - context.output, - command.label(), - &view, - stdout, - render::write_human_diagnostics, - )?; - } - } - Ok(()) -} - -async fn handle_logs( - context: &AppContext, - command: LogsCommand, - stdout: &mut dyn Write, -) -> Result<(), ExecuteError> { - match command { - LogsCommand::Show { target, options } => { - let target = resolve_logs_target(context, "logs show", target).await?; - let view = context - .query_logs( - "logs show", - "query retained logs", - target, - options.limit, - None, - map_severity(options.minimum_severity), - ) - .await?; - write_one_shot( - context.output, - "logs show", - &view, - stdout, - render::write_human_logs, - )?; - } - LogsCommand::Tail { target, options } => { - let target = resolve_logs_target(context, "logs tail", target).await?; - let mut cursor = None; - let mut last_status = None; - let minimum_severity = map_severity(options.read.minimum_severity); - let mut first_page = true; - - loop { - let view = context - .query_logs( - "logs tail", - "continue log tail", - target.clone(), - options.read.limit, - cursor.clone(), - minimum_severity, - ) - .await?; - if let Some(next_cursor) = &view.next_cursor { - cursor = Some(next_cursor.clone()); - } - let should_emit = first_page - || !view.entries.is_empty() - || view.has_gap - || view.truncated_by_retention - || last_status.is_some_and(|status| status != view.stream_status); - if should_emit { - if context.output == OutputMode::Human && !first_page { - writeln!(stdout)?; - } - write_logs_stream_event(context.output, "logsPage", "logs tail", &view, stdout)?; - } - last_status = Some(view.stream_status); - first_page = false; - - tokio::select! { - _ = tokio::signal::ctrl_c() => return Ok(()), - _ = tokio::time::sleep(Duration::from_millis(options.poll_interval_ms)) => {} - } - } - } - } - - Ok(()) -} - -async fn handle_watch( - context: &AppContext, - command: WatchCommand, - stdout: &mut dyn Write, -) -> Result<(), ExecuteError> { - let mut subscription = context - .subscribe_state(command.label(), "subscribe to daemon state") - .await?; - let mut first = true; - loop { - let event = tokio::select! { - _ = tokio::signal::ctrl_c() => return Ok(()), - result = subscription.next_event() => { - result.map_err(|error| CliError::daemon_request(command.label(), context.paths().runtime_dir(), "stream daemon state", &error))? - } - }; - - if context.output == OutputMode::Human && !first { - writeln!(stdout)?; - } - write_watch_event(context, &command, &event, stdout)?; - first = false; - } -} - -#[cfg(test)] -fn select_domain( - command: &'static str, - snapshot: &GuiStateSnapshot, - selector: &crate::cli::DomainSelectorArgs, -) -> Result { - resolve_domain(snapshot, &selector.domain, selector.registration.as_deref()).map_err(|error| { - match error { - crate::view::DomainResolveError::NotFound { - canonical_domain, - requested_registration, - } => CliError::target_not_found( - command, - match requested_registration { - Some(registration_id) => format!( - "Domain `{canonical_domain}` was not found under entrypoint `{registration_id}`." - ), - None => format!("Domain `{canonical_domain}` was not found."), - }, - Some( - "Run `cadderctl domains list` to inspect the current domain registrations.".to_string(), - ), - ), - crate::view::DomainResolveError::Ambiguous { - canonical_domain, - matches, - } => CliError::conflict_or_rejected( - command, - format!( - "Domain `{canonical_domain}` matches multiple entrypoints: {}.", - matches.join(", ") - ), - Some( - "Retry the command with `--registration ` to choose the exact entrypoint." - .to_string(), - ), - ), - } - }) -} - -async fn resolve_logs_target( - context: &AppContext, - command: &'static str, - target: LogsTargetCommand, -) -> Result { - match target { - LogsTargetCommand::Runtime => { - context - .resolve_logs_target(command, cadder_operator::LogsTarget::Runtime) - .await - } - LogsTargetCommand::Entrypoint { registration_id } => { - context - .resolve_logs_target( - command, - cadder_operator::LogsTarget::Entrypoint { registration_id }, - ) - .await - } - LogsTargetCommand::Domain { - domain, - registration, - } => { - context - .resolve_logs_target( - command, - cadder_operator::LogsTarget::Domain(DomainSelector { - domain, - registration, - }), - ) - .await - } - } -} - -fn write_one_shot( - output: OutputMode, - command: &'static str, - value: &T, - stdout: &mut dyn Write, - human: fn(&T, &mut dyn Write) -> io::Result<()>, -) -> io::Result<()> { - match output { - OutputMode::Human => human(value, stdout), - OutputMode::Json => render::write_json_success(command, value, stdout), - OutputMode::Jsonl => unreachable!("jsonl is validated before execution"), - }?; - stdout.flush() -} - -fn write_logs_stream_event( - output: OutputMode, - event: &'static str, - command: &'static str, - view: &LogsView, - stdout: &mut dyn Write, -) -> io::Result<()> { - match output { - OutputMode::Human => render::write_human_logs(view, stdout), - OutputMode::Json => unreachable!("json output is invalid for streaming commands"), - OutputMode::Jsonl => render::write_jsonl( - &json!({ - "event": event, - "command": command, - "generatedAtUtc": Utc::now(), - "data": view, - }), - stdout, - ), - }?; - stdout.flush() -} - -fn write_watch_event( - context: &AppContext, - command: &WatchCommand, - event: &StateChangedEvent, - stdout: &mut dyn Write, -) -> io::Result<()> { - match command { - WatchCommand::Status => { - let status = daemon_status_connected(context.paths().runtime_dir(), &event.snapshot); - match context.output { - OutputMode::Human => { - writeln!( - stdout, - "Event: {:?} (sequence {})", - event.change_kind, event.sequence_number - )?; - if let Some(registration_id) = &event.registration_id { - writeln!(stdout, "Registration: {registration_id}")?; - } - render::write_human_daemon_status(&status, stdout) - } - OutputMode::Jsonl => render::write_jsonl( - &render::WatchEnvelope { - event: "stateChanged", - command: command.label(), - generated_at_utc: Utc::now(), - sequence_number: Some(event.sequence_number), - change_kind: Some(format!("{:?}", event.change_kind)), - registration_id: event.registration_id.as_deref(), - data: &status, - }, - stdout, - ), - OutputMode::Json => unreachable!("json output is invalid for watch commands"), - } - } - WatchCommand::Entrypoints => { - let entrypoints = entrypoints_view(&event.snapshot); - match context.output { - OutputMode::Human => { - writeln!( - stdout, - "Event: {:?} (sequence {})", - event.change_kind, event.sequence_number - )?; - render::write_human_entrypoints(&entrypoints, stdout) - } - OutputMode::Jsonl => render::write_jsonl( - &render::WatchEnvelope { - event: "stateChanged", - command: command.label(), - generated_at_utc: Utc::now(), - sequence_number: Some(event.sequence_number), - change_kind: Some(format!("{:?}", event.change_kind)), - registration_id: event.registration_id.as_deref(), - data: &entrypoints, - }, - stdout, - ), - OutputMode::Json => unreachable!("json output is invalid for watch commands"), - } - } - WatchCommand::Domains => { - let domains = domains_view(&event.snapshot, None); - match context.output { - OutputMode::Human => { - writeln!( - stdout, - "Event: {:?} (sequence {})", - event.change_kind, event.sequence_number - )?; - render::write_human_domains(&domains, stdout) - } - OutputMode::Jsonl => render::write_jsonl( - &render::WatchEnvelope { - event: "stateChanged", - command: command.label(), - generated_at_utc: Utc::now(), - sequence_number: Some(event.sequence_number), - change_kind: Some(format!("{:?}", event.change_kind)), - registration_id: event.registration_id.as_deref(), - data: &domains, - }, - stdout, - ), - OutputMode::Json => unreachable!("json output is invalid for watch commands"), - } - } - WatchCommand::Diagnostics => { - let diagnostics = diagnostics_view(&event.snapshot); - match context.output { - OutputMode::Human => { - writeln!( - stdout, - "Event: {:?} (sequence {})", - event.change_kind, event.sequence_number - )?; - render::write_human_diagnostics(&diagnostics, stdout) - } - OutputMode::Jsonl => render::write_jsonl( - &render::WatchEnvelope { - event: "stateChanged", - command: command.label(), - generated_at_utc: Utc::now(), - sequence_number: Some(event.sequence_number), - change_kind: Some(format!("{:?}", event.change_kind)), - registration_id: event.registration_id.as_deref(), - data: &diagnostics, - }, - stdout, - ), - OutputMode::Json => unreachable!("json output is invalid for watch commands"), - } - } - }?; - stdout.flush() -} - -fn write_error( - output: OutputMode, - error: &CliError, - stdout: &mut dyn Write, - stderr: &mut dyn Write, -) -> io::Result<()> { - match output { - OutputMode::Human => render::write_human_error(error, stderr), - OutputMode::Json => render::write_json_error(error, stdout), - OutputMode::Jsonl => render::write_jsonl( - &json!({ - "event": "error", - "command": error.command, - "generatedAtUtc": Utc::now(), - "error": { - "kind": error.kind, - "exitCode": error.exit_code().code(), - "message": error.message, - "guidance": error.guidance, - } - }), - stdout, - ), - }?; - - match output { - OutputMode::Human => stderr.flush(), - OutputMode::Json | OutputMode::Jsonl => stdout.flush(), - } -} - -fn unavailable_message(runtime_dir: &std::path::Path, error: &anyhow::Error) -> String { - match connection_state_from_error(error) { - ConnectionStateView::NotRunning => format!( - "Cadder daemon is not running for runtime `{}`.", - runtime_dir.display() - ), - ConnectionStateView::ConnectionFailed => format!( - "Could not attach to Cadder daemon for runtime `{}`: {}.", - runtime_dir.display(), - format_error_chain(error) - ), - ConnectionStateView::Connected => "Attached to cadderd.".to_string(), - } -} - -fn connection_state_from_error(error: &anyhow::Error) -> ConnectionStateView { - if error.chain().any(|cause| { - cause.downcast_ref::().is_some_and(|error| { - matches!( - error.kind(), - std::io::ErrorKind::NotFound - | std::io::ErrorKind::ConnectionRefused - | std::io::ErrorKind::ConnectionAborted - ) - }) - }) { - ConnectionStateView::NotRunning - } else { - ConnectionStateView::ConnectionFailed - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cli::{DomainSelectorArgs, EntrypointsCommand, LogReadArgs, TailArgs}; - use cadder_protocol::{ - ActivationState, ConfigState, DomainName, EntrypointInstanceIdentity, EntrypointRegistration, - LogAttributionKind, LogEntry, LogEntryKind, LogSeverity, OwnerProcessIdentity, - RegisteredDomain, RuntimeState, SourcePath, StateChangeKind, - }; - use chrono::{TimeZone, Utc}; - use serde_json::Value; - - fn timestamp() -> chrono::DateTime { - Utc - .with_ymd_and_hms(2026, 6, 16, 19, 0, 0) - .single() - .unwrap() - } - - fn registration(id: &str, domains: &[&str]) -> EntrypointRegistration { - let now = timestamp(); - let identity = EntrypointInstanceIdentity { - instance_id: id.to_string(), - started_at_utc: now, - shim_session_nonce: format!("{id}-nonce"), - }; - EntrypointRegistration { - registration_id: id.to_string(), - entrypoint_instance: identity.clone(), - source_working_directory: SourcePath::new("D:/Projects/App", None), - source_config_path: SourcePath::new("D:/Projects/App/Caddyfile", None), - registered_domains: domains - .iter() - .map(|domain| RegisteredDomain { - name: DomainName::parse(*domain), - activation_state: ActivationState::Active, - log_stream: LogStreamIdentity::domain(domain), - }) - .collect(), - activation_state: ActivationState::Active, - owner_process: OwnerProcessIdentity { - process_id: 17, - process_start_time_utc: now, - shim_session_nonce: identity.shim_session_nonce.clone(), - executable_path: Some("caddy.exe".to_string()), - }, - log_stream: LogStreamIdentity::entrypoint(id), - shim_run: None, - created_at_utc: now, - last_heartbeat_utc: now, - } - } - - fn snapshot(registrations: Vec) -> GuiStateSnapshot { - GuiStateSnapshot { - captured_at_utc: timestamp(), - registrations, - runtime: RuntimeState::idle(), - config: ConfigState::idle(), - } - } - - fn test_context(output: OutputMode) -> (tempfile::TempDir, AppContext) { - let temp = tempfile::tempdir().unwrap(); - let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); - let context = AppContext { - output, - operator: OperatorContext::from_paths(paths, DaemonLaunchOptions::default()), - }; - (temp, context) - } - - fn sample_logs_view() -> LogsView { - LogsView { - stream: LogStreamIdentity::domain("app.localhost"), - stream_status: cadder_protocol::LogStreamStatus::Active, - entries: vec![LogEntry { - sequence_number: 3, - timestamp_utc: timestamp(), - severity: LogSeverity::Warn, - stream: LogStreamIdentity::domain("app.localhost"), - attribution_kind: LogAttributionKind::Domain, - entry_kind: LogEntryKind::Normal, - raw_message: "tail event".to_string(), - domain_key: Some("app.localhost".to_string()), - source_registration_id: Some("shim-1".to_string()), - source_instance_id: Some("shim-1-instance".to_string()), - operation: None, - }], - next_cursor: Some("seq:3".to_string()), - has_gap: false, - has_more_before: false, - truncated_by_retention: false, - } - } - - fn sample_event() -> StateChangedEvent { - StateChangedEvent { - request_id: "watch".to_string(), - sequence_number: 7, - change_kind: StateChangeKind::Snapshot, - snapshot: snapshot(vec![registration("shim-1", &["app.localhost"])]), - registration_id: Some("shim-1".to_string()), - } - } - - fn parse_json(bytes: &[u8]) -> Value { - serde_json::from_slice(bytes).unwrap() - } - - #[test] - fn validate_output_mode_rejects_invalid_combinations_and_ranges() { - let watch = Command::Watch { - command: WatchCommand::Status, - }; - let err = validate_output_mode(OutputMode::Json, &watch, "watch status").unwrap_err(); - assert_eq!(err.kind, CliErrorKind::InvalidUsage); - - let domains = Command::Domains { - command: DomainsCommand::List { registration: None }, - }; - let err = validate_output_mode(OutputMode::Jsonl, &domains, "domains list").unwrap_err(); - assert_eq!(err.kind, CliErrorKind::InvalidUsage); - - let logs_show = Command::Logs { - command: LogsCommand::Show { - target: LogsTargetCommand::Runtime, - options: LogReadArgs { - limit: 0, - minimum_severity: None, - }, - }, - }; - let err = validate_output_mode(OutputMode::Human, &logs_show, "logs show").unwrap_err(); - assert!(err.message.contains("between 1 and 500")); - - let logs_tail = Command::Logs { - command: LogsCommand::Tail { - target: LogsTargetCommand::Runtime, - options: TailArgs { - read: LogReadArgs { - limit: 20, - minimum_severity: None, - }, - poll_interval_ms: 10, - }, - }, - }; - let err = validate_output_mode(OutputMode::Jsonl, &logs_tail, "logs tail").unwrap_err(); - assert!(err.message.contains("poll-interval-ms")); - - let entrypoints = Command::Entrypoints { - command: EntrypointsCommand::List, - }; - validate_output_mode(OutputMode::Json, &entrypoints, "entrypoints list").unwrap(); - } - - #[test] - fn select_domain_maps_not_found_and_ambiguity_into_cli_errors() { - let missing = select_domain( - "domains disable", - &snapshot(vec![registration("shim-1", &["app.localhost"])]), - &DomainSelectorArgs { - domain: "missing.localhost".to_string(), - registration: None, - }, - ) - .unwrap_err(); - assert_eq!(missing.kind, CliErrorKind::TargetNotFound); - assert!(missing.message.contains("missing.localhost")); - - let ambiguous = select_domain( - "domains disable", - &snapshot(vec![ - registration("shim-1", &["app.localhost"]), - registration("shim-2", &["app.localhost"]), - ]), - &DomainSelectorArgs { - domain: "app.localhost".to_string(), - registration: None, - }, - ) - .unwrap_err(); - assert_eq!(ambiguous.kind, CliErrorKind::ConflictOrRejected); - assert!(ambiguous.message.contains("shim-1, shim-2")); - } - - #[test] - fn unavailable_message_distinguishes_not_running_from_connection_failure() { - let not_running = anyhow::Error::from(std::io::Error::new( - std::io::ErrorKind::ConnectionRefused, - "connection refused", - )); - assert_eq!( - connection_state_from_error(¬_running), - ConnectionStateView::NotRunning - ); - assert!( - unavailable_message(std::path::Path::new("runtime"), ¬_running).contains("is not running") - ); - - let connection_failed = anyhow::Error::msg("protocol mismatch"); - assert_eq!( - connection_state_from_error(&connection_failed), - ConnectionStateView::ConnectionFailed - ); - assert!( - unavailable_message(std::path::Path::new("runtime"), &connection_failed) - .contains("Could not attach") - ); - } - - #[test] - fn write_helpers_emit_expected_human_and_machine_output() { - let action = ActionResultView { - message: "updated".to_string(), - }; - - let mut human = Vec::new(); - write_one_shot( - OutputMode::Human, - "domains enable", - &action, - &mut human, - render::write_human_action, - ) - .unwrap(); - assert_eq!(String::from_utf8(human).unwrap(), "updated\n"); - - let mut json = Vec::new(); - write_one_shot( - OutputMode::Json, - "domains enable", - &action, - &mut json, - render::write_human_action, - ) - .unwrap(); - let json = parse_json(&json); - assert_eq!(json["ok"], true); - assert_eq!(json["command"], "domains enable"); - - let logs = sample_logs_view(); - let mut stream = Vec::new(); - write_logs_stream_event( - OutputMode::Human, - "logsPage", - "logs tail", - &logs, - &mut stream, - ) - .unwrap(); - assert!(String::from_utf8(stream).unwrap().contains("tail event")); - - let mut stream = Vec::new(); - write_logs_stream_event( - OutputMode::Jsonl, - "logsPage", - "logs tail", - &logs, - &mut stream, - ) - .unwrap(); - let json = parse_json(&stream); - assert_eq!(json["event"], "logsPage"); - assert_eq!(json["command"], "logs tail"); - - let error = CliError::invalid_usage( - "logs tail", - "Streaming commands support `human` or `jsonl` output.", - Some("Use `--output jsonl`.".to_string()), - ); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - write_error(OutputMode::Human, &error, &mut stdout, &mut stderr).unwrap(); - assert!(String::from_utf8(stderr).unwrap().contains("Guidance:")); - - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - write_error(OutputMode::Json, &error, &mut stdout, &mut stderr).unwrap(); - let json = parse_json(&stdout); - assert_eq!(json["error"]["kind"], "invalidUsage"); - - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - write_error(OutputMode::Jsonl, &error, &mut stdout, &mut stderr).unwrap(); - let json = parse_json(&stdout); - assert_eq!(json["event"], "error"); - assert_eq!(json["error"]["exitCode"], 2); - } - - #[test] - fn write_watch_event_supports_all_views_in_human_and_jsonl() { - let event = sample_event(); - - let (_temp, human_context) = test_context(OutputMode::Human); - let expectations = [ - (WatchCommand::Status, "Runtime directory:"), - (WatchCommand::Entrypoints, "Registration: shim-1"), - (WatchCommand::Domains, "Domain: app.localhost"), - (WatchCommand::Diagnostics, "No diagnostics."), - ]; - for (command, expected) in expectations { - let mut output = Vec::new(); - write_watch_event(&human_context, &command, &event, &mut output).unwrap(); - let text = String::from_utf8(output).unwrap(); - assert!(text.contains("Event: Snapshot (sequence 7)")); - assert!(text.contains(expected), "missing `{expected}` in `{text}`"); - } - - let (_temp, jsonl_context) = test_context(OutputMode::Jsonl); - let expectations = [ - (WatchCommand::Status, "watch status"), - (WatchCommand::Entrypoints, "watch entrypoints"), - (WatchCommand::Domains, "watch domains"), - (WatchCommand::Diagnostics, "watch diagnostics"), - ]; - for (command, label) in expectations { - let mut output = Vec::new(); - write_watch_event(&jsonl_context, &command, &event, &mut output).unwrap(); - let json = parse_json(&output); - assert_eq!(json["event"], "stateChanged"); - assert_eq!(json["command"], label); - assert_eq!(json["sequenceNumber"], 7); - } - } -} diff --git a/crates/cadderctl/src/cli.rs b/crates/cadderctl/src/cli.rs deleted file mode 100644 index fcc0f97..0000000 --- a/crates/cadderctl/src/cli.rs +++ /dev/null @@ -1,438 +0,0 @@ -use clap::{Args, Parser, Subcommand, ValueEnum}; -use std::path::PathBuf; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum OutputMode { - Human, - Json, - Jsonl, -} - -impl OutputMode { - pub fn is_streaming(self) -> bool { - matches!(self, Self::Jsonl) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum SeverityArg { - Trace, - Debug, - Info, - Warn, - Error, - Fatal, -} - -#[derive(Debug, Clone, Parser)] -#[command( - name = "cadderctl", - version, - about = "Cadder non-TUI CLI for daemon state, domains, logs, and automation", - long_about = "Attaches to an existing Cadder daemon to inspect state, toggle managed routes, query diagnostics and logs, and explicitly start or stop cadderd for scripting and operator workflows." -)] -pub struct CliArgs { - #[arg( - long, - global = true, - help = "Override the Cadder runtime directory used to find daemon IPC and state" - )] - pub runtime_dir: Option, - - #[arg( - long, - global = true, - help = "Path to a cadderd executable for the explicit daemon start command" - )] - pub daemon_path: Option, - - #[arg( - long, - global = true, - help = "Command or path passed to cadderd when explicitly starting the real Caddy binary" - )] - pub real_caddy_command: Option, - - #[arg( - long, - global = true, - value_enum, - default_value_t = OutputMode::Human, - help = "Output mode: human for operators, json for one-shot scripting, jsonl for streaming commands" - )] - pub output: OutputMode, - - #[command(subcommand)] - pub command: Command, -} - -impl CliArgs { - pub fn command_label(&self) -> &'static str { - self.command.label() - } -} - -#[derive(Debug, Clone, Subcommand)] -pub enum Command { - Daemon { - #[command(subcommand)] - command: DaemonCommand, - }, - Entrypoints { - #[command(subcommand)] - command: EntrypointsCommand, - }, - Domains { - #[command(subcommand)] - command: DomainsCommand, - }, - Diagnostics { - #[command(subcommand)] - command: DiagnosticsCommand, - }, - Logs { - #[command(subcommand)] - command: LogsCommand, - }, - Watch { - #[command(subcommand)] - command: WatchCommand, - }, -} - -impl Command { - pub fn label(&self) -> &'static str { - match self { - Self::Daemon { command } => command.label(), - Self::Entrypoints { command } => command.label(), - Self::Domains { command } => command.label(), - Self::Diagnostics { command } => command.label(), - Self::Logs { command } => command.label(), - Self::Watch { command } => command.label(), - } - } -} - -#[derive(Debug, Clone, Subcommand)] -pub enum DaemonCommand { - Status, - Start, - Shutdown, -} - -impl DaemonCommand { - pub fn label(&self) -> &'static str { - match self { - Self::Status => "daemon status", - Self::Start => "daemon start", - Self::Shutdown => "daemon shutdown", - } - } -} - -#[derive(Debug, Clone, Subcommand)] -pub enum EntrypointsCommand { - List, - Enable { registration_id: String }, - Disable { registration_id: String }, -} - -impl EntrypointsCommand { - pub fn label(&self) -> &'static str { - match self { - Self::List => "entrypoints list", - Self::Enable { .. } => "entrypoints enable", - Self::Disable { .. } => "entrypoints disable", - } - } -} - -#[derive(Debug, Clone, Args)] -pub struct DomainSelectorArgs { - #[arg(help = "Domain name to select")] - pub domain: String, - - #[arg( - long, - help = "Entrypoint registration ID used to disambiguate repeated domain names" - )] - pub registration: Option, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum DomainsCommand { - List { - #[arg( - long, - help = "Only list domains from the selected entrypoint registration" - )] - registration: Option, - }, - Enable { - #[command(flatten)] - selector: DomainSelectorArgs, - }, - Disable { - #[command(flatten)] - selector: DomainSelectorArgs, - }, -} - -impl DomainsCommand { - pub fn label(&self) -> &'static str { - match self { - Self::List { .. } => "domains list", - Self::Enable { .. } => "domains enable", - Self::Disable { .. } => "domains disable", - } - } -} - -#[derive(Debug, Clone, Subcommand)] -pub enum DiagnosticsCommand { - Show, -} - -impl DiagnosticsCommand { - pub fn label(&self) -> &'static str { - "diagnostics show" - } -} - -#[derive(Debug, Clone, Args)] -pub struct LogReadArgs { - #[arg( - long, - default_value_t = 50, - help = "Maximum number of retained log entries to read per request" - )] - pub limit: usize, - - #[arg( - long, - value_enum, - help = "Only include log entries at or above this severity" - )] - pub minimum_severity: Option, -} - -#[derive(Debug, Clone, Args)] -pub struct TailArgs { - #[command(flatten)] - pub read: LogReadArgs, - - #[arg( - long, - default_value_t = 750, - help = "Polling interval in milliseconds for tailing current log streams" - )] - pub poll_interval_ms: u64, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum LogsTargetCommand { - Runtime, - Entrypoint { - registration_id: String, - }, - Domain { - domain: String, - #[arg( - long, - help = "Entrypoint registration ID used to disambiguate repeated domain names" - )] - registration: Option, - }, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum LogsCommand { - Show { - #[command(subcommand)] - target: LogsTargetCommand, - #[command(flatten)] - options: LogReadArgs, - }, - Tail { - #[command(subcommand)] - target: LogsTargetCommand, - #[command(flatten)] - options: TailArgs, - }, -} - -impl LogsCommand { - pub fn label(&self) -> &'static str { - match self { - Self::Show { .. } => "logs show", - Self::Tail { .. } => "logs tail", - } - } -} - -#[derive(Debug, Clone, Subcommand)] -pub enum WatchCommand { - Status, - Entrypoints, - Domains, - Diagnostics, -} - -impl WatchCommand { - pub fn label(&self) -> &'static str { - match self { - Self::Status => "watch status", - Self::Entrypoints => "watch entrypoints", - Self::Domains => "watch domains", - Self::Diagnostics => "watch diagnostics", - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::CommandFactory; - - #[test] - fn command_metadata_matches_release_identity() { - let command = CliArgs::command(); - - assert_eq!(command.get_name(), "cadderctl"); - assert_eq!(command.get_version(), Some(env!("CARGO_PKG_VERSION"))); - assert_eq!( - command.get_about().map(ToString::to_string), - Some(env!("CARGO_PKG_DESCRIPTION").to_string()) - ); - } - - #[test] - fn long_help_describes_attach_and_start_behavior() { - let help = CliArgs::command().render_long_help().to_string(); - - assert!( - help.contains("Attaches to an existing Cadder daemon"), - "long help should describe attach-only behavior: {help}" - ); - assert!( - help.contains("explicitly start or stop cadderd"), - "long help should mention explicit daemon control: {help}" - ); - } - - #[test] - fn parses_domain_selector_with_registration() { - let args = CliArgs::parse_from([ - "cadderctl", - "domains", - "disable", - "app.localhost", - "--registration", - "shim-1", - ]); - - match args.command { - Command::Domains { - command: - DomainsCommand::Disable { - selector: - DomainSelectorArgs { - domain, - registration, - }, - }, - } => { - assert_eq!(domain, "app.localhost"); - assert_eq!(registration.as_deref(), Some("shim-1")); - } - other => panic!("unexpected command parsed: {other:?}"), - } - } - - #[test] - fn parses_logs_tail_target_and_options() { - let args = CliArgs::parse_from([ - "cadderctl", - "--output", - "jsonl", - "logs", - "tail", - "--limit", - "10", - "--minimum-severity", - "warn", - "--poll-interval-ms", - "1000", - "domain", - "app.localhost", - "--registration", - "shim-1", - ]); - - assert_eq!(args.output, OutputMode::Jsonl); - match args.command { - Command::Logs { - command: - LogsCommand::Tail { - target: - LogsTargetCommand::Domain { - domain, - registration, - }, - options, - }, - } => { - assert_eq!(domain, "app.localhost"); - assert_eq!(registration.as_deref(), Some("shim-1")); - assert_eq!(options.read.limit, 10); - assert_eq!(options.read.minimum_severity, Some(SeverityArg::Warn)); - assert_eq!(options.poll_interval_ms, 1000); - } - other => panic!("unexpected command parsed: {other:?}"), - } - } - - #[test] - fn parses_global_flags_after_subcommand_path() { - let args = CliArgs::parse_from([ - "cadderctl", - "logs", - "show", - "--limit", - "20", - "domain", - "app.localhost", - "--registration", - "shim-1", - "--output", - "json", - "--runtime-dir", - "C:/temp/cadder-runtime", - ]); - - assert_eq!(args.output, OutputMode::Json); - assert_eq!( - args.runtime_dir.as_deref(), - Some(std::path::Path::new("C:/temp/cadder-runtime")) - ); - - match args.command { - Command::Logs { - command: - LogsCommand::Show { - target: - LogsTargetCommand::Domain { - domain, - registration, - }, - options, - }, - } => { - assert_eq!(options.limit, 20); - assert_eq!(domain, "app.localhost"); - assert_eq!(registration.as_deref(), Some("shim-1")); - } - other => panic!("unexpected command parsed: {other:?}"), - } - } -} diff --git a/crates/cadderctl/src/error.rs b/crates/cadderctl/src/error.rs deleted file mode 100644 index ed95e89..0000000 --- a/crates/cadderctl/src/error.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub use cadder_operator::{ - OperatorError as CliError, OperatorErrorKind as CliErrorKind, OperatorExitCode as CliExitCode, - daemon_error_indicates_unavailable, error_indicates_permission, format_error_chain, - start_guidance, -}; diff --git a/crates/cadderctl/src/lib.rs b/crates/cadderctl/src/lib.rs deleted file mode 100644 index b5f157b..0000000 --- a/crates/cadderctl/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod app; -pub mod cli; -pub mod error; -pub mod render; -pub mod view; diff --git a/crates/cadderctl/src/main.rs b/crates/cadderctl/src/main.rs deleted file mode 100644 index 86947a9..0000000 --- a/crates/cadderctl/src/main.rs +++ /dev/null @@ -1,25 +0,0 @@ -use cadderctl::{app, cli::CliArgs}; -use clap::Parser; -use std::{io, process::ExitCode}; - -#[tokio::main] -async fn main() -> ExitCode { - let args = match CliArgs::try_parse() { - Ok(args) => args, - Err(error) => { - let exit_code = error.exit_code(); - let _ = error.print(); - return ExitCode::from(u8::try_from(exit_code).unwrap_or(2)); - } - }; - - let mut stdout = io::stdout().lock(); - let mut stderr = io::stderr().lock(); - match app::run(args, &mut stdout, &mut stderr).await { - Ok(code) => ExitCode::from(code), - Err(error) => { - eprintln!("I/O error: {error}"); - ExitCode::FAILURE - } - } -} diff --git a/crates/cadderctl/src/render.rs b/crates/cadderctl/src/render.rs deleted file mode 100644 index 4c89598..0000000 --- a/crates/cadderctl/src/render.rs +++ /dev/null @@ -1,617 +0,0 @@ -use crate::{ - error::CliError, - view::{ - ActionResultView, DaemonStartView, DaemonStatusView, DiagnosticsView, DomainListView, - EntrypointListView, LogsView, - }, -}; -use chrono::Utc; -use serde::Serialize; -use std::io::{self, Write}; - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct SuccessEnvelope<'a, T> { - ok: bool, - command: &'a str, - generated_at_utc: chrono::DateTime, - data: &'a T, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct ErrorEnvelope<'a> { - ok: bool, - command: &'a str, - generated_at_utc: chrono::DateTime, - error: ErrorPayload<'a>, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct ErrorPayload<'a> { - kind: crate::error::CliErrorKind, - exit_code: u8, - message: &'a str, - guidance: Option<&'a str>, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct WatchEnvelope<'a, T> { - pub event: &'a str, - pub command: &'a str, - pub generated_at_utc: chrono::DateTime, - pub sequence_number: Option, - pub change_kind: Option, - pub registration_id: Option<&'a str>, - pub data: &'a T, -} - -pub fn write_json_success( - command: &'static str, - data: &T, - writer: &mut dyn Write, -) -> io::Result<()> { - let envelope = SuccessEnvelope { - ok: true, - command, - generated_at_utc: Utc::now(), - data, - }; - serde_json::to_writer_pretty(&mut *writer, &envelope)?; - writeln!(writer) -} - -pub fn write_json_error(error: &CliError, writer: &mut dyn Write) -> io::Result<()> { - let envelope = ErrorEnvelope { - ok: false, - command: error.command, - generated_at_utc: Utc::now(), - error: ErrorPayload { - kind: error.kind, - exit_code: error.exit_code().code(), - message: &error.message, - guidance: error.guidance.as_deref(), - }, - }; - serde_json::to_writer_pretty(&mut *writer, &envelope)?; - writeln!(writer) -} - -pub fn write_jsonl(value: &T, writer: &mut dyn Write) -> io::Result<()> { - serde_json::to_writer(&mut *writer, value)?; - writeln!(writer) -} - -pub fn write_human_error(error: &CliError, writer: &mut dyn Write) -> io::Result<()> { - writeln!(writer, "Error: {}", error.message)?; - if let Some(guidance) = &error.guidance { - writeln!(writer, "Guidance: {guidance}")?; - } - Ok(()) -} - -pub fn write_human_daemon_status( - view: &DaemonStatusView, - writer: &mut dyn Write, -) -> io::Result<()> { - writeln!(writer, "Runtime directory: {}", view.runtime_dir)?; - writeln!(writer, "Connection: {:?}", view.connection_state)?; - writeln!(writer, "Message: {}", view.message)?; - if let Some(guidance) = &view.guidance { - writeln!(writer, "Guidance: {guidance}")?; - } - if let Some(captured) = view.captured_at_utc { - writeln!(writer, "Captured at: {captured}")?; - } - writeln!(writer, "Entrypoints: {}", view.counts.entrypoints)?; - writeln!(writer, "Domains: {}", view.counts.domains)?; - writeln!(writer, "Active domains: {}", view.counts.active_domains)?; - if let Some(runtime) = &view.runtime { - writeln!(writer, "Runtime status: {}", runtime.status)?; - if let Some(process_id) = runtime.process_id { - writeln!(writer, "Runtime process ID: {process_id}")?; - } - if let Some(version) = &runtime.version { - writeln!(writer, "Runtime version: {version}")?; - } - } - if let Some(config) = &view.config { - writeln!(writer, "Config status: {}", config.status)?; - } - Ok(()) -} - -pub fn write_human_daemon_start(view: &DaemonStartView, writer: &mut dyn Write) -> io::Result<()> { - writeln!(writer, "{}", view.message)?; - write_human_daemon_status(&view.status, writer) -} - -pub fn write_human_action(view: &ActionResultView, writer: &mut dyn Write) -> io::Result<()> { - writeln!(writer, "{}", view.message) -} - -pub fn write_human_entrypoints( - view: &EntrypointListView, - writer: &mut dyn Write, -) -> io::Result<()> { - writeln!(writer, "Captured at: {}", view.captured_at_utc)?; - if view.entrypoints.is_empty() { - return writeln!(writer, "No entrypoints registered."); - } - - for entrypoint in &view.entrypoints { - writeln!(writer, "Registration: {}", entrypoint.registration_id)?; - writeln!(writer, " Activation: {:?}", entrypoint.activation_state)?; - writeln!( - writer, - " Working directory: {}", - entrypoint.working_directory - )?; - writeln!(writer, " Config path: {}", entrypoint.config_path)?; - writeln!( - writer, - " Domains: {} total, {} active", - entrypoint.domain_count, entrypoint.active_domain_count - )?; - writeln!(writer, " Process ID: {}", entrypoint.process_id)?; - if let Some(adapter) = &entrypoint.adapter { - writeln!(writer, " Adapter: {adapter}")?; - } - if let Some(command_line) = &entrypoint.command_line { - writeln!(writer, " Command line: {command_line}")?; - } - } - Ok(()) -} - -pub fn write_human_domains(view: &DomainListView, writer: &mut dyn Write) -> io::Result<()> { - writeln!(writer, "Captured at: {}", view.captured_at_utc)?; - if view.domains.is_empty() { - return writeln!(writer, "No domains registered."); - } - - for domain in &view.domains { - writeln!(writer, "Domain: {}", domain.domain)?; - writeln!(writer, " Canonical: {}", domain.canonical_domain)?; - writeln!(writer, " Registration: {}", domain.registration_id)?; - writeln!(writer, " Activation: {:?}", domain.activation_state)?; - writeln!( - writer, - " Entrypoint activation: {:?}", - domain.entrypoint_activation_state - )?; - writeln!(writer, " Working directory: {}", domain.working_directory)?; - writeln!(writer, " Config path: {}", domain.config_path)?; - } - Ok(()) -} - -pub fn write_human_diagnostics(view: &DiagnosticsView, writer: &mut dyn Write) -> io::Result<()> { - writeln!(writer, "Captured at: {}", view.captured_at_utc)?; - writeln!(writer, "Runtime status: {}", view.runtime.status)?; - writeln!(writer, "Config status: {}", view.config.status)?; - if view.runtime_diagnostics.is_empty() && view.config_diagnostics.is_empty() { - return writeln!(writer, "No diagnostics."); - } - - for diagnostic in &view.runtime_diagnostics { - write_runtime_diagnostic(diagnostic, writer)?; - } - for diagnostic in &view.config_diagnostics { - write_config_diagnostic(diagnostic, writer)?; - } - Ok(()) -} - -pub fn write_human_logs(view: &LogsView, writer: &mut dyn Write) -> io::Result<()> { - writeln!(writer, "Stream: {}", view.stream.stream_id)?; - writeln!(writer, "Status: {:?}", view.stream_status)?; - if let Some(cursor) = &view.next_cursor { - writeln!(writer, "Next cursor: {cursor}")?; - } - if view.has_gap { - writeln!( - writer, - "Notice: retained log history has a gap before the newest entries." - )?; - } - if view.truncated_by_retention { - writeln!( - writer, - "Notice: older log entries were dropped by retention." - )?; - } - if view.entries.is_empty() { - return writeln!(writer, "No log entries matched the request."); - } - - for entry in &view.entries { - writeln!( - writer, - "[{}] {:?} {}", - entry.timestamp_utc, entry.severity, entry.raw_message - )?; - } - Ok(()) -} - -fn write_runtime_diagnostic( - diagnostic: &cadder_protocol::RuntimeDiagnostic, - writer: &mut dyn Write, -) -> io::Result<()> { - writeln!(writer, "Runtime diagnostic: {}", diagnostic.code)?; - writeln!(writer, " Message: {}", diagnostic.message)?; - if let Some(operation) = &diagnostic.operation { - writeln!(writer, " Operation: {operation}")?; - } - Ok(()) -} - -fn write_config_diagnostic( - diagnostic: &cadder_protocol::ConfigDiagnostic, - writer: &mut dyn Write, -) -> io::Result<()> { - writeln!(writer, "Config diagnostic: {}", diagnostic.code)?; - writeln!(writer, " Message: {}", diagnostic.message)?; - if let Some(domain) = &diagnostic.domain_key { - writeln!(writer, " Domain: {domain}")?; - } - if !diagnostic.source_config_paths.is_empty() { - writeln!( - writer, - " Source paths: {}", - diagnostic.source_config_paths.join(", ") - )?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - error::{CliError, CliErrorKind}, - view::{ConfigView, ConnectionStateView, CountsView, DomainView, EntrypointView, RuntimeView}, - }; - use cadder_protocol::{ - ActivationState, ConfigDiagnostic, LogAttributionKind, LogEntry, LogEntryKind, LogSeverity, - LogStreamIdentity, LogStreamStatus, RuntimeDiagnostic, - }; - use chrono::{TimeZone, Utc}; - use serde_json::Value; - - fn timestamp() -> chrono::DateTime { - Utc - .with_ymd_and_hms(2026, 6, 16, 18, 45, 0) - .single() - .unwrap() - } - - fn status_view() -> DaemonStatusView { - DaemonStatusView { - runtime_dir: "C:/runtime".to_string(), - connection_state: ConnectionStateView::Connected, - message: "Attached to cadderd.".to_string(), - guidance: Some("No action required.".to_string()), - captured_at_utc: Some(timestamp()), - runtime: Some(RuntimeView { - status: "Running".to_string(), - binary_path: Some("caddy.exe".to_string()), - version: Some("2.9.1".to_string()), - process_id: Some(42), - admin_endpoint: Some("http://127.0.0.1:2019".to_string()), - diagnostics: Vec::new(), - }), - config: Some(ConfigView { - status: "Applied".to_string(), - last_attempted_at_utc: Some(timestamp()), - last_successful_reload_at_utc: Some(timestamp()), - effective_config_hash: Some("abc123".to_string()), - diagnostics: Vec::new(), - }), - counts: CountsView { - entrypoints: 2, - domains: 4, - active_domains: 3, - }, - } - } - - fn entrypoint_list() -> EntrypointListView { - EntrypointListView { - captured_at_utc: timestamp(), - entrypoints: vec![EntrypointView { - registration_id: "shim-1".to_string(), - activation_state: ActivationState::Active, - working_directory: "D:/Projects/App".to_string(), - config_path: "D:/Projects/App/Caddyfile".to_string(), - started_at_utc: timestamp(), - last_heartbeat_utc: timestamp(), - process_id: 17, - executable_path: Some("caddy.exe".to_string()), - domain_count: 2, - active_domain_count: 1, - adapter: Some("caddyfile".to_string()), - command_line: Some("run --adapter caddyfile".to_string()), - }], - } - } - - fn domain_list() -> DomainListView { - DomainListView { - captured_at_utc: timestamp(), - domains: vec![DomainView { - registration_id: "shim-1".to_string(), - domain: "App.Localhost".to_string(), - canonical_domain: "app.localhost".to_string(), - activation_state: ActivationState::Inactive, - entrypoint_activation_state: ActivationState::Active, - working_directory: "D:/Projects/App".to_string(), - config_path: "D:/Projects/App/Caddyfile".to_string(), - log_stream: LogStreamIdentity::domain("app.localhost"), - }], - } - } - - fn diagnostics_view_with_entries() -> DiagnosticsView { - DiagnosticsView { - captured_at_utc: timestamp(), - runtime: RuntimeView { - status: "Running".to_string(), - binary_path: None, - version: None, - process_id: None, - admin_endpoint: None, - diagnostics: vec![RuntimeDiagnostic { - code: "runtime-warning".to_string(), - message: "Backend restart required".to_string(), - operation: Some("reload".to_string()), - }], - }, - config: ConfigView { - status: "Failed".to_string(), - last_attempted_at_utc: Some(timestamp()), - last_successful_reload_at_utc: None, - effective_config_hash: None, - diagnostics: vec![ConfigDiagnostic { - code: "config-error".to_string(), - message: "Duplicate host".to_string(), - domain_key: Some("app.localhost".to_string()), - source_config_paths: vec!["D:/Projects/App/Caddyfile".to_string()], - }], - }, - runtime_diagnostics: vec![RuntimeDiagnostic { - code: "runtime-warning".to_string(), - message: "Backend restart required".to_string(), - operation: Some("reload".to_string()), - }], - config_diagnostics: vec![ConfigDiagnostic { - code: "config-error".to_string(), - message: "Duplicate host".to_string(), - domain_key: Some("app.localhost".to_string()), - source_config_paths: vec!["D:/Projects/App/Caddyfile".to_string()], - }], - } - } - - fn logs_view_with_entry() -> LogsView { - LogsView { - stream: LogStreamIdentity::domain("app.localhost"), - stream_status: LogStreamStatus::Active, - entries: vec![LogEntry { - sequence_number: 7, - timestamp_utc: timestamp(), - severity: LogSeverity::Error, - stream: LogStreamIdentity::domain("app.localhost"), - attribution_kind: LogAttributionKind::Domain, - entry_kind: LogEntryKind::Normal, - raw_message: "backend failed".to_string(), - domain_key: Some("app.localhost".to_string()), - source_registration_id: Some("shim-1".to_string()), - source_instance_id: Some("instance-1".to_string()), - operation: Some("reload".to_string()), - }], - next_cursor: Some("seq:7".to_string()), - has_gap: true, - has_more_before: false, - truncated_by_retention: true, - } - } - - fn parse_json(text: &[u8]) -> Value { - serde_json::from_slice(text).unwrap() - } - - #[test] - fn json_helpers_write_expected_envelopes() { - let mut stdout = Vec::new(); - write_json_success("daemon status", &status_view(), &mut stdout).unwrap(); - let json = parse_json(&stdout); - assert_eq!(json["ok"], true); - assert_eq!(json["command"], "daemon status"); - assert_eq!(json["data"]["runtimeDir"], "C:/runtime"); - - let error = CliError::new( - "domains list", - CliErrorKind::DaemonUnavailable, - "daemon unavailable", - Some("start the daemon".to_string()), - ); - let mut stderr = Vec::new(); - write_json_error(&error, &mut stderr).unwrap(); - let json = parse_json(&stderr); - assert_eq!(json["ok"], false); - assert_eq!(json["error"]["kind"], "daemonUnavailable"); - assert_eq!(json["error"]["exitCode"], 3); - - let mut jsonl = Vec::new(); - write_jsonl(&serde_json::json!({ "event": "tick" }), &mut jsonl).unwrap(); - assert_eq!(String::from_utf8(jsonl).unwrap(), "{\"event\":\"tick\"}\n"); - } - - #[test] - fn human_status_and_actions_include_key_fields() { - let error = CliError::new( - "daemon start", - CliErrorKind::DaemonStartFailure, - "Could not start cadderd.", - Some("Check the path.".to_string()), - ); - let mut output = Vec::new(); - write_human_error(&error, &mut output).unwrap(); - let text = String::from_utf8(output).unwrap(); - assert!(text.contains("Error: Could not start cadderd.")); - assert!(text.contains("Guidance: Check the path.")); - - let mut output = Vec::new(); - write_human_daemon_status(&status_view(), &mut output).unwrap(); - let text = String::from_utf8(output).unwrap(); - assert!(text.contains("Runtime directory: C:/runtime")); - assert!(text.contains("Connection: Connected")); - assert!(text.contains("Runtime process ID: 42")); - assert!(text.contains("Config status: Applied")); - - let start_view = DaemonStartView { - started: false, - message: "cadderd is already running.".to_string(), - status: status_view(), - }; - let mut output = Vec::new(); - write_human_daemon_start(&start_view, &mut output).unwrap(); - let text = String::from_utf8(output).unwrap(); - assert!(text.contains("cadderd is already running.")); - - let mut output = Vec::new(); - write_human_action( - &ActionResultView { - message: "Domain activation updated.".to_string(), - }, - &mut output, - ) - .unwrap(); - assert_eq!( - String::from_utf8(output).unwrap(), - "Domain activation updated.\n" - ); - } - - #[test] - fn human_lists_cover_empty_and_populated_states() { - let mut output = Vec::new(); - write_human_entrypoints( - &EntrypointListView { - captured_at_utc: timestamp(), - entrypoints: Vec::new(), - }, - &mut output, - ) - .unwrap(); - assert!( - String::from_utf8(output) - .unwrap() - .contains("No entrypoints registered.") - ); - - let mut output = Vec::new(); - write_human_entrypoints(&entrypoint_list(), &mut output).unwrap(); - let text = String::from_utf8(output).unwrap(); - assert!(text.contains("Registration: shim-1")); - assert!(text.contains("Adapter: caddyfile")); - assert!(text.contains("Command line: run --adapter caddyfile")); - - let mut output = Vec::new(); - write_human_domains( - &DomainListView { - captured_at_utc: timestamp(), - domains: Vec::new(), - }, - &mut output, - ) - .unwrap(); - assert!( - String::from_utf8(output) - .unwrap() - .contains("No domains registered.") - ); - - let mut output = Vec::new(); - write_human_domains(&domain_list(), &mut output).unwrap(); - let text = String::from_utf8(output).unwrap(); - assert!(text.contains("Domain: App.Localhost")); - assert!(text.contains("Canonical: app.localhost")); - assert!(text.contains("Entrypoint activation: Active")); - } - - #[test] - fn human_diagnostics_cover_empty_and_populated_states() { - let empty = DiagnosticsView { - captured_at_utc: timestamp(), - runtime: RuntimeView { - status: "Idle".to_string(), - binary_path: None, - version: None, - process_id: None, - admin_endpoint: None, - diagnostics: Vec::new(), - }, - config: ConfigView { - status: "Idle".to_string(), - last_attempted_at_utc: None, - last_successful_reload_at_utc: None, - effective_config_hash: None, - diagnostics: Vec::new(), - }, - runtime_diagnostics: Vec::new(), - config_diagnostics: Vec::new(), - }; - - let mut output = Vec::new(); - write_human_diagnostics(&empty, &mut output).unwrap(); - assert!( - String::from_utf8(output) - .unwrap() - .contains("No diagnostics.") - ); - - let mut output = Vec::new(); - write_human_diagnostics(&diagnostics_view_with_entries(), &mut output).unwrap(); - let text = String::from_utf8(output).unwrap(); - assert!(text.contains("Runtime diagnostic: runtime-warning")); - assert!(text.contains("Operation: reload")); - assert!(text.contains("Config diagnostic: config-error")); - assert!(text.contains("Domain: app.localhost")); - assert!(text.contains("Source paths: D:/Projects/App/Caddyfile")); - } - - #[test] - fn human_logs_cover_empty_and_populated_states() { - let mut output = Vec::new(); - write_human_logs( - &LogsView { - stream: LogStreamIdentity::runtime_control(), - stream_status: LogStreamStatus::Empty, - entries: Vec::new(), - next_cursor: Some("seq:0".to_string()), - has_gap: true, - has_more_before: false, - truncated_by_retention: true, - }, - &mut output, - ) - .unwrap(); - let text = String::from_utf8(output).unwrap(); - assert!(text.contains("Stream: runtime-control")); - assert!(text.contains("Notice: retained log history has a gap")); - assert!(text.contains("Notice: older log entries were dropped by retention.")); - assert!(text.contains("No log entries matched the request.")); - - let mut output = Vec::new(); - write_human_logs(&logs_view_with_entry(), &mut output).unwrap(); - let text = String::from_utf8(output).unwrap(); - assert!(text.contains("Next cursor: seq:7")); - assert!(text.contains("backend failed")); - assert!(text.contains("Error")); - } -} diff --git a/crates/cadderctl/src/view.rs b/crates/cadderctl/src/view.rs deleted file mode 100644 index 80cc36b..0000000 --- a/crates/cadderctl/src/view.rs +++ /dev/null @@ -1,20 +0,0 @@ -use crate::cli::SeverityArg; -pub use cadder_operator::{ - ActionResultView, ConfigView, ConnectionStateView, CountsView, DaemonStartView, DaemonStatusView, - DiagnosticsView, DomainListView, DomainResolveError, DomainView, EntrypointListView, - EntrypointView, LogsView, RuntimeView, SelectedDomain, counts, daemon_status_connected, - daemon_status_unavailable, diagnostics_view, domains_view, entrypoints_view, resolve_domain, -}; -use cadder_protocol::LogSeverity; - -pub fn map_severity(value: Option) -> Option { - match value { - None => None, - Some(SeverityArg::Trace) => Some(LogSeverity::Trace), - Some(SeverityArg::Debug) => Some(LogSeverity::Debug), - Some(SeverityArg::Info) => Some(LogSeverity::Info), - Some(SeverityArg::Warn) => Some(LogSeverity::Warn), - Some(SeverityArg::Error) => Some(LogSeverity::Error), - Some(SeverityArg::Fatal) => Some(LogSeverity::Fatal), - } -} diff --git a/crates/cadderctl/tests/cli.rs b/crates/cadderctl/tests/cli.rs deleted file mode 100644 index e53583d..0000000 --- a/crates/cadderctl/tests/cli.rs +++ /dev/null @@ -1,433 +0,0 @@ -mod support; - -use cadder_protocol::{ - ActivationState, LogSeverity, LogStreamIdentity, LogStreamStatus, QueryLogsRequest, - StateChangeKind, message_types, new_request_id, -}; -use cadderctl::{app, cli::CliArgs}; -use clap::Parser; -use serde_json::Value; -use std::{process::Command as ProcessCommand, time::Duration}; -use support::Harness; - -async fn run_cli(args: &[&str]) -> (u8, String, String) { - let cli = CliArgs::parse_from(std::iter::once("cadderctl").chain(args.iter().copied())); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - let code = app::run(cli, &mut stdout, &mut stderr).await.unwrap(); - ( - code, - String::from_utf8(stdout).unwrap(), - String::from_utf8(stderr).unwrap(), - ) -} - -fn parse_json(text: &str) -> Value { - serde_json::from_str(text).unwrap() -} - -fn run_binary(args: &[&str]) -> std::process::Output { - ProcessCommand::new(env!("CARGO_BIN_EXE_cadderctl")) - .args(args) - .output() - .unwrap() -} - -#[tokio::test] -async fn daemon_status_json_reports_not_running_runtime_without_failing() { - let runtime_dir = tempfile::tempdir().unwrap(); - let runtime_dir_arg = runtime_dir.path().to_string_lossy().to_string(); - - let (code, stdout, _stderr) = run_cli(&[ - "--runtime-dir", - &runtime_dir_arg, - "--output", - "json", - "daemon", - "status", - ]) - .await; - - assert_eq!(code, 0); - let json = parse_json(&stdout); - assert_eq!(json["ok"], true); - assert_eq!(json["data"]["connectionState"], "notRunning"); -} - -#[tokio::test] -async fn domains_list_json_returns_typed_error_when_daemon_is_unavailable() { - let runtime_dir = tempfile::tempdir().unwrap(); - let runtime_dir_arg = runtime_dir.path().to_string_lossy().to_string(); - - let (code, stdout, _stderr) = run_cli(&[ - "--runtime-dir", - &runtime_dir_arg, - "--output", - "json", - "domains", - "list", - ]) - .await; - - assert_eq!(code, 3); - let json = parse_json(&stdout); - assert_eq!(json["ok"], false); - assert_eq!(json["error"]["kind"], "daemonUnavailable"); - assert_eq!(json["error"]["exitCode"], 3); -} - -#[tokio::test] -async fn entrypoints_and_domains_list_json_report_registered_state() { - let harness = Harness::start().await; - harness.register_entrypoint("shim-1").await; - let runtime_dir = harness.paths.runtime_dir().display().to_string(); - - let (entrypoints_code, entrypoints_stdout, _entrypoints_stderr) = run_cli(&[ - "--runtime-dir", - &runtime_dir, - "--output", - "json", - "entrypoints", - "list", - ]) - .await; - assert_eq!(entrypoints_code, 0); - let entrypoints_json = parse_json(&entrypoints_stdout); - assert_eq!( - entrypoints_json["data"]["entrypoints"] - .as_array() - .unwrap() - .len(), - 1 - ); - - let (domains_code, domains_stdout, _domains_stderr) = run_cli(&[ - "--runtime-dir", - &runtime_dir, - "--output", - "json", - "domains", - "list", - ]) - .await; - assert_eq!(domains_code, 0); - let domains_json = parse_json(&domains_stdout); - assert_eq!(domains_json["data"]["domains"].as_array().unwrap().len(), 4); - - harness.shutdown().await; -} - -#[tokio::test] -async fn domains_disable_updates_registered_state() { - let harness = Harness::start().await; - harness.register_entrypoint("shim-1").await; - let runtime_dir = harness.paths.runtime_dir().display().to_string(); - - let (code, _stdout, _stderr) = run_cli(&[ - "--runtime-dir", - &runtime_dir, - "--output", - "json", - "domains", - "disable", - "app.smarketing.localhost", - "--registration", - "shim-1", - ]) - .await; - - assert_eq!(code, 0); - let snapshot = harness.snapshot().await; - let domain = snapshot.registrations[0] - .registered_domains - .iter() - .find(|domain| domain.name.canonical == "app.smarketing.localhost") - .unwrap(); - assert_eq!(domain.activation_state, ActivationState::Inactive); - - harness.shutdown().await; -} - -#[tokio::test] -async fn logs_show_json_returns_entries_and_cursor() { - let harness = Harness::start().await; - harness.register_entrypoint("shim-1").await; - harness.append_domain_log("app.smarketing.localhost", LogSeverity::Info, "first log"); - harness.append_domain_log("app.smarketing.localhost", LogSeverity::Error, "second log"); - let runtime_dir = harness.paths.runtime_dir().display().to_string(); - - let (code, stdout, _stderr) = run_cli(&[ - "--runtime-dir", - &runtime_dir, - "--output", - "json", - "logs", - "show", - "--limit", - "1", - "domain", - "app.smarketing.localhost", - "--registration", - "shim-1", - ]) - .await; - - assert_eq!(code, 0); - let json = parse_json(&stdout); - let entries = json["data"]["entries"].as_array().unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0]["rawMessage"], "second log"); - let sequence_number = entries[0]["sequenceNumber"].as_u64().unwrap(); - assert_eq!(json["data"]["nextCursor"], format!("seq:{sequence_number}")); - - harness.shutdown().await; -} - -#[tokio::test] -async fn state_subscription_yields_initial_snapshot_for_watch_transport() { - let harness = Harness::start().await; - harness.register_entrypoint("shim-1").await; - - let mut subscription = harness - .client - .subscribe_state(new_request_id("watch-test")) - .await - .unwrap(); - let event = tokio::time::timeout(Duration::from_secs(2), subscription.next_event()) - .await - .unwrap() - .unwrap(); - - assert_eq!(event.change_kind, StateChangeKind::Snapshot); - assert_eq!(event.snapshot.registrations.len(), 1); - - harness.shutdown().await; -} - -#[tokio::test] -async fn log_cursor_progression_supports_tail_transport() { - let harness = Harness::start().await; - harness.register_entrypoint("shim-1").await; - harness.append_domain_log("app.smarketing.localhost", LogSeverity::Info, "first log"); - harness.append_domain_log("app.smarketing.localhost", LogSeverity::Warn, "second log"); - - let first_page: cadder_protocol::QueryLogsResponse = harness - .client - .request( - message_types::QUERY_LOGS_REQUEST, - message_types::QUERY_LOGS_RESPONSE, - &QueryLogsRequest { - request_id: new_request_id("tail-page-1"), - stream: LogStreamIdentity::domain("app.smarketing.localhost"), - limit: Some(1), - cursor: None, - minimum_severity: None, - }, - ) - .await - .unwrap(); - assert_eq!(first_page.stream_status, LogStreamStatus::Active); - assert_eq!(first_page.entries.len(), 1); - assert_eq!(first_page.entries[0].raw_message, "second log"); - - let next_page: cadder_protocol::QueryLogsResponse = harness - .client - .request( - message_types::QUERY_LOGS_REQUEST, - message_types::QUERY_LOGS_RESPONSE, - &QueryLogsRequest { - request_id: new_request_id("tail-page-2"), - stream: LogStreamIdentity::domain("app.smarketing.localhost"), - limit: Some(10), - cursor: first_page.next_cursor.clone(), - minimum_severity: None, - }, - ) - .await - .unwrap(); - assert_eq!(next_page.stream_status, LogStreamStatus::Active); - assert!(next_page.entries.is_empty()); - - harness.shutdown().await; -} - -#[tokio::test] -async fn human_commands_cover_status_start_shutdown_lists_and_logs() { - let harness = Harness::start().await; - harness.register_entrypoint("shim-1").await; - harness.append_domain_log( - "app.smarketing.localhost", - LogSeverity::Error, - "domain log entry", - ); - let runtime_dir = harness.paths.runtime_dir().display().to_string(); - - let (code, stdout, stderr) = run_cli(&["--runtime-dir", &runtime_dir, "daemon", "status"]).await; - assert_eq!(code, 0); - assert!(stderr.is_empty()); - assert!(stdout.contains("Attached to cadderd.")); - - let (code, stdout, stderr) = run_cli(&["daemon", "start", "--runtime-dir", &runtime_dir]).await; - assert_eq!(code, 0); - assert!(stderr.is_empty()); - assert!(stdout.contains("cadderd is already running.")); - - let (code, stdout, stderr) = - run_cli(&["entrypoints", "list", "--runtime-dir", &runtime_dir]).await; - assert_eq!(code, 0); - assert!(stderr.is_empty()); - assert!(stdout.contains("Registration: shim-1")); - - let (code, stdout, stderr) = run_cli(&[ - "domains", - "list", - "--registration", - "shim-1", - "--runtime-dir", - &runtime_dir, - ]) - .await; - assert_eq!(code, 0); - assert!(stderr.is_empty()); - assert!(stdout.contains("Domain: app.smarketing.localhost")); - - let (code, stdout, stderr) = - run_cli(&["diagnostics", "show", "--runtime-dir", &runtime_dir]).await; - assert_eq!(code, 0); - assert!(stderr.is_empty()); - assert!(stdout.contains("No diagnostics.")); - - let (code, stdout, stderr) = run_cli(&[ - "logs", - "show", - "--limit", - "5", - "domain", - "app.smarketing.localhost", - "--registration", - "shim-1", - "--runtime-dir", - &runtime_dir, - ]) - .await; - assert_eq!(code, 0); - assert!(stderr.is_empty()); - assert!(stdout.contains("domain log entry")); - - let (code, stdout, stderr) = - run_cli(&["daemon", "shutdown", "--runtime-dir", &runtime_dir]).await; - assert_eq!(code, 0); - assert!(stderr.is_empty()); - assert!(stdout.contains("Daemon shutdown requested.")); -} - -#[tokio::test] -async fn toggles_and_invalid_selectors_return_typed_results() { - let harness = Harness::start().await; - harness.register_entrypoint("shim-1").await; - harness.register_entrypoint("shim-2").await; - let runtime_dir = harness.paths.runtime_dir().display().to_string(); - - let (code, stdout, _stderr) = run_cli(&[ - "--runtime-dir", - &runtime_dir, - "--output", - "json", - "entrypoints", - "disable", - "shim-1", - ]) - .await; - assert_eq!(code, 0); - assert_eq!(parse_json(&stdout)["ok"], true); - - let (code, stdout, _stderr) = run_cli(&[ - "--runtime-dir", - &runtime_dir, - "--output", - "json", - "entrypoints", - "enable", - "missing", - ]) - .await; - assert_eq!(code, 5); - assert_eq!(parse_json(&stdout)["error"]["kind"], "targetNotFound"); - - let (code, stdout, _stderr) = run_cli(&[ - "--runtime-dir", - &runtime_dir, - "--output", - "json", - "domains", - "disable", - "app.smarketing.localhost", - ]) - .await; - assert_eq!(code, 6); - assert_eq!(parse_json(&stdout)["error"]["kind"], "conflictOrRejected"); - - let (code, stdout, _stderr) = run_cli(&[ - "--runtime-dir", - &runtime_dir, - "--output", - "json", - "domains", - "enable", - "missing.localhost", - "--registration", - "shim-1", - ]) - .await; - assert_eq!(code, 5); - assert_eq!(parse_json(&stdout)["error"]["kind"], "targetNotFound"); - - harness.shutdown().await; -} - -#[tokio::test] -async fn invalid_output_modes_and_ranges_return_stable_errors() { - let (code, stdout, _stderr) = run_cli(&["--output", "json", "watch", "status"]).await; - assert_eq!(code, 2); - assert_eq!(parse_json(&stdout)["error"]["kind"], "invalidUsage"); - - let (code, stdout, _stderr) = run_cli(&["domains", "list", "--output", "jsonl"]).await; - assert_eq!(code, 2); - let json = parse_json(&stdout); - assert_eq!(json["event"], "error"); - assert_eq!(json["error"]["kind"], "invalidUsage"); - - let (code, stdout, _stderr) = run_cli(&[ - "--output", "json", "logs", "show", "--limit", "0", "runtime", - ]) - .await; - assert_eq!(code, 2); - assert_eq!(parse_json(&stdout)["error"]["kind"], "invalidUsage"); - - let (code, stdout, _stderr) = run_cli(&[ - "--output", - "jsonl", - "logs", - "tail", - "--poll-interval-ms", - "10", - "runtime", - ]) - .await; - assert_eq!(code, 2); - let json = parse_json(&stdout); - assert_eq!(json["event"], "error"); - assert_eq!(json["error"]["exitCode"], 2); -} - -#[test] -fn binary_help_version_and_invalid_usage_exit_codes_are_stable() { - let help = run_binary(&["--help"]); - assert_eq!(help.status.code(), Some(0)); - - let version = run_binary(&["--version"]); - assert_eq!(version.status.code(), Some(0)); - - let invalid = run_binary(&["logs"]); - assert_eq!(invalid.status.code(), Some(2)); -} diff --git a/crates/cadderctl/tests/support/mod.rs b/crates/cadderctl/tests/support/mod.rs deleted file mode 100644 index 47824a0..0000000 --- a/crates/cadderctl/tests/support/mod.rs +++ /dev/null @@ -1,227 +0,0 @@ -use cadder_daemon::{ - CadderClient, CadderSession, CaddyConfigAdapter, CaddyConfigCoordinator, DaemonServer, - DaemonState, ProcessRuntime, RealCaddyResolver, RuntimePaths, -}; -use cadder_protocol::{ - ActivationState, EntrypointInstanceIdentity, EntrypointRegistration, LogAttributionKind, - LogSeverity, LogStreamIdentity, OwnerProcessIdentity, QueryStateRequest, QueryStateResponse, - RegisterEntrypointRequest, RegisterEntrypointResponse, ShimRunMetadata, SourcePath, - message_types, new_request_id, -}; -use chrono::Utc; -use std::{ - fs, - path::{Path, PathBuf}, - sync::Mutex, - time::Duration, -}; -use tokio::{sync::watch, time::sleep}; - -const FIXTURE: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../cadder-daemon/tests/fixtures/SmarketingReverseProxy.Caddyfile" -)); - -pub struct Harness { - pub client: CadderClient, - pub state: DaemonState, - pub paths: RuntimePaths, - pub config_path: PathBuf, - sessions: Mutex>, - shutdown_tx: watch::Sender, - _temp: tempfile::TempDir, -} - -impl Harness { - pub async fn start() -> Self { - let temp = tempfile::tempdir().unwrap(); - let runtime_dir = temp.path().join("runtime"); - let paths = RuntimePaths::resolve(Some(runtime_dir)).unwrap(); - paths.ensure_dirs().unwrap(); - let config_path = temp.path().join("Caddyfile"); - fs::write(&config_path, FIXTURE).unwrap(); - let command_log_path = temp.path().join("fake-caddy-commands.log"); - let fake_caddy_path = write_fake_caddy(temp.path(), &command_log_path); - - let resolver = RealCaddyResolver::new(Some(fake_caddy_path.display().to_string())); - let adapter = CaddyConfigAdapter::new(resolver.clone()); - let runtime = ProcessRuntime::new(resolver, paths.clone()); - let state = DaemonState::new(CaddyConfigCoordinator::new(adapter, runtime)); - let server = DaemonServer::new(paths.clone(), state.clone()); - let (shutdown_tx, shutdown_rx) = watch::channel(false); - tokio::spawn(async move { - let _ = server.run_until(shutdown_rx).await; - }); - - let client = CadderClient::new(paths.clone()); - for _ in 0..50 { - if client - .request::<_, QueryStateResponse>( - message_types::QUERY_STATE_REQUEST, - message_types::QUERY_STATE_RESPONSE, - &QueryStateRequest { - request_id: new_request_id("wait"), - }, - ) - .await - .is_ok() - { - return Self { - client, - state, - paths, - config_path, - sessions: Mutex::new(Vec::new()), - shutdown_tx, - _temp: temp, - }; - } - sleep(Duration::from_millis(20)).await; - } - - panic!("daemon server did not become ready"); - } - - pub async fn register_entrypoint(&self, id: &str) { - let mut session = CadderSession::connect(&self.paths).await.unwrap(); - let response: RegisterEntrypointResponse = session - .request( - message_types::REGISTER_ENTRYPOINT_REQUEST, - message_types::REGISTER_ENTRYPOINT_RESPONSE, - &RegisterEntrypointRequest { - request_id: new_request_id("test-register"), - registration: registration(id, &format!("{id}-nonce"), &self.config_path), - }, - ) - .await - .unwrap(); - assert!(response.accepted, "{}", response.message); - self.sessions.lock().unwrap().push(session); - } - - pub async fn snapshot(&self) -> cadder_protocol::GuiStateSnapshot { - let response: QueryStateResponse = self - .client - .request( - message_types::QUERY_STATE_REQUEST, - message_types::QUERY_STATE_RESPONSE, - &QueryStateRequest { - request_id: new_request_id("test-query"), - }, - ) - .await - .unwrap(); - response.snapshot.unwrap() - } - - pub fn append_domain_log(&self, domain_key: &str, severity: LogSeverity, message: &str) { - self.state.logs().append( - LogStreamIdentity::domain(domain_key), - severity, - message, - LogAttributionKind::Domain, - None, - ); - } - - pub async fn shutdown(self) { - let _ = self.shutdown_tx.send(true); - } -} - -fn registration(id: &str, nonce: &str, config_path: &Path) -> EntrypointRegistration { - let now = Utc::now(); - let identity = EntrypointInstanceIdentity { - instance_id: id.to_string(), - started_at_utc: now, - shim_session_nonce: nonce.to_string(), - }; - EntrypointRegistration { - registration_id: id.to_string(), - entrypoint_instance: identity.clone(), - source_working_directory: SourcePath::new(".", None), - source_config_path: SourcePath::new( - config_path.display().to_string(), - Some(config_path.display().to_string()), - ), - registered_domains: Vec::new(), - activation_state: ActivationState::Active, - owner_process: OwnerProcessIdentity { - process_id: 1, - process_start_time_utc: now, - shim_session_nonce: nonce.to_string(), - executable_path: None, - }, - log_stream: LogStreamIdentity::entrypoint(id), - shim_run: Some(ShimRunMetadata { - adapter: Some("caddyfile".to_string()), - raw_arguments: vec!["run".to_string()], - command_line: "run".to_string(), - }), - created_at_utc: now, - last_heartbeat_utc: now, - } -} - -fn write_fake_caddy(dir: &Path, command_log_path: &Path) -> PathBuf { - const ADAPTED_JSON: &str = r#"{"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["api.smarketing.localhost","app.smarketing.localhost","mailbox.smarketing.localhost","storage.smarketing.localhost"]}],"handle":[{"handler":"static_response","body":"ok"}],"terminal":true}]}}}}}"#; - - #[cfg(windows)] - { - let path = dir.join("fake-caddy.cmd"); - fs::write( - &path, - format!( - r#"@echo off -echo %*>> "{command_log}" -if "%1"=="adapt" ( - echo {adapted_json} - exit 0 -) -if "%1"=="reload" ( - exit 0 -) -if "%1"=="run" ( - echo fake runtime started - exit 0 -) -exit 0 -"#, - command_log = command_log_path.display(), - adapted_json = ADAPTED_JSON, - ), - ) - .unwrap(); - path - } - - #[cfg(not(windows))] - { - use std::os::unix::fs::PermissionsExt; - let path = dir.join("fake-caddy"); - fs::write( - &path, - format!( - r#"#!/usr/bin/env sh -printf '%s\n' "$*" >> '{command_log}' -if [ "$1" = "adapt" ]; then - printf '%s\n' '{adapted_json}' - exit 0 -fi -if [ "$1" = "reload" ]; then - exit 0 -fi -if [ "$1" = "run" ]; then echo fake runtime started; exit 0; fi -exit 0 -"#, - command_log = command_log_path.display(), - adapted_json = ADAPTED_JSON, - ), - ) - .unwrap(); - let mut permissions = fs::metadata(&path).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&path, permissions).unwrap(); - path - } -} diff --git a/crates/cadderd/Cargo.toml b/crates/cadderd/Cargo.toml index 8d0ea09..2dc4d51 100644 --- a/crates/cadderd/Cargo.toml +++ b/crates/cadderd/Cargo.toml @@ -12,3 +12,6 @@ cadder-daemon = { version = "0.8.0", path = "../cadder-daemon" } clap = { version = "4.6.1", features = ["derive"] } tokio = { version = "1.52.3", features = ["full"] } tracing-subscriber = "0.3.23" + +[dev-dependencies] +cadder-protocol = { path = "../cadder-protocol" } diff --git a/crates/cadderd/src/main.rs b/crates/cadderd/src/main.rs index 127a2d3..7b456c6 100644 --- a/crates/cadderd/src/main.rs +++ b/crates/cadderd/src/main.rs @@ -1,7 +1,10 @@ -use anyhow::Result; -use cadder_daemon::{DaemonOptions, run_daemon}; +use anyhow::{Context, Result}; +use cadder_daemon::{ + CaddyBackendMode, DaemonLaunchMode, DaemonLaunchOptions, DaemonOptions, RuntimePaths, + RuntimeProfile, ensure_daemon_running_with_options, run_daemon, +}; use clap::Parser; -use std::path::PathBuf; +use std::{env, path::PathBuf}; use tokio::sync::watch; #[derive(Debug, Parser)] @@ -18,12 +21,32 @@ struct Args { )] runtime_dir: Option, + #[arg( + long, + value_parser = RuntimeProfile::parse_cli, + help = "Runtime profile used when --runtime-dir and CADDER_RUNTIME_DIR are not set" + )] + runtime_profile: Option, + #[arg( long, help = "Command or path used when Cadder starts the real Caddy binary" )] real_caddy_command: Option, + #[arg( + long, + value_parser = CaddyBackendMode::parse_cli, + help = "Caddy backend mode used by the daemon: real or mock" + )] + caddy_backend: Option, + + #[arg( + long, + help = "Start cadderd detached in the background and exit after the daemon socket is ready" + )] + background: bool, + #[arg(long, hide = true)] detach_ready: bool, } @@ -32,6 +55,10 @@ struct Args { async fn main() -> Result<()> { tracing_subscriber::fmt::init(); let args = Args::parse(); + if args.background { + return launch_background_daemon(args).await; + } + let (shutdown_tx, shutdown_rx) = watch::channel(false); tokio::spawn(async move { @@ -42,13 +69,33 @@ async fn main() -> Result<()> { run_daemon( DaemonOptions { runtime_dir: args.runtime_dir, + runtime_profile: args.runtime_profile, real_caddy_command: args.real_caddy_command, + caddy_backend: args.caddy_backend, }, shutdown_rx, ) .await } +async fn launch_background_daemon(args: Args) -> Result<()> { + let paths = RuntimePaths::resolve_with_profile(args.runtime_dir.clone(), args.runtime_profile)?; + let current_exe = env::current_exe().context("resolve current cadderd executable")?; + + ensure_daemon_running_with_options( + &paths, + DaemonLaunchOptions { + explicit_daemon: Some(current_exe), + runtime_profile: args.runtime_profile, + real_caddy_command: args.real_caddy_command, + caddy_backend: args.caddy_backend, + shim_path: None, + launch_mode: DaemonLaunchMode::Background, + }, + ) + .await +} + #[cfg(test)] mod tests { use super::*; @@ -88,5 +135,33 @@ mod tests { help.contains("Command or path used when Cadder starts the real Caddy binary"), "long help output should describe --real-caddy-command: {help}" ); + assert!( + help.contains("Runtime profile used when --runtime-dir"), + "long help output should describe --runtime-profile: {help}" + ); + assert!( + help.contains("Caddy backend mode used by the daemon"), + "long help output should describe --caddy-backend: {help}" + ); + assert!( + help.contains("Start cadderd detached in the background"), + "long help output should describe --background: {help}" + ); + } + + #[test] + fn background_flag_is_explicit_detached_launcher() { + let args = Args::parse_from([ + "cadderd", + "--background", + "--runtime-dir", + "C:/temp/cadder-runtime", + "--caddy-backend", + "mock", + ]); + + assert!(args.background); + assert!(!args.detach_ready); + assert_eq!(args.caddy_backend, Some(CaddyBackendMode::Mock)); } } diff --git a/crates/cadderd/tests/cadderd_binary.rs b/crates/cadderd/tests/cadderd_binary.rs new file mode 100644 index 0000000..bf6b82c --- /dev/null +++ b/crates/cadderd/tests/cadderd_binary.rs @@ -0,0 +1,113 @@ +use cadder_daemon::{CadderClient, RuntimePaths}; +use cadder_protocol::{BasicResponse, QueryStateRequest, QueryStateResponse, message_types}; +use std::{ + path::PathBuf, + process::{Child, Command, Stdio}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +fn unique_runtime_dir(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "cadderd-{name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )) +} + +fn spawn_cadderd(runtime_dir: &PathBuf) -> Child { + Command::new(env!("CARGO_BIN_EXE_cadderd")) + .arg("--runtime-dir") + .arg(runtime_dir) + .arg("--real-caddy-command") + .arg("definitely-missing-real-caddy") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap() +} + +async fn wait_for_state(client: &CadderClient) -> QueryStateResponse { + for _ in 0..100 { + let response = client + .request::<_, QueryStateResponse>( + message_types::QUERY_STATE_REQUEST, + message_types::QUERY_STATE_RESPONSE, + &QueryStateRequest { + request_id: cadder_protocol::new_request_id("test-query"), + }, + ) + .await; + if let Ok(response) = response { + return response; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("cadderd did not become ready"); +} + +#[tokio::test] +async fn cadderd_binary_serves_ipc_and_shuts_down_cleanly() { + let runtime_dir = unique_runtime_dir("ipc"); + let paths = RuntimePaths::resolve(Some(runtime_dir.clone())).unwrap(); + let client = CadderClient::new(paths); + let mut child = spawn_cadderd(&runtime_dir); + + let state = wait_for_state(&client).await; + let shutdown: BasicResponse = client + .request( + message_types::SHUTDOWN_DAEMON_REQUEST, + message_types::SHUTDOWN_DAEMON_RESPONSE, + &cadder_protocol::ShutdownDaemonRequest { + request_id: cadder_protocol::new_request_id("test-shutdown"), + }, + ) + .await + .unwrap(); + let status = child.wait().unwrap(); + + assert!(state.accepted); + assert!(shutdown.accepted, "{}", shutdown.message); + assert!(status.success(), "{status}"); + let _ = std::fs::remove_dir_all(runtime_dir); +} + +#[tokio::test] +async fn cadderd_binary_repeated_start_succeeds_when_runtime_is_already_running() { + let runtime_dir = unique_runtime_dir("repeated-start"); + let paths = RuntimePaths::resolve(Some(runtime_dir.clone())).unwrap(); + let client = CadderClient::new(paths); + let mut child = spawn_cadderd(&runtime_dir); + + let state = wait_for_state(&client).await; + let repeated = Command::new(env!("CARGO_BIN_EXE_cadderd")) + .arg("--runtime-dir") + .arg(&runtime_dir) + .arg("--real-caddy-command") + .arg("definitely-missing-real-caddy") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + let shutdown: BasicResponse = client + .request( + message_types::SHUTDOWN_DAEMON_REQUEST, + message_types::SHUTDOWN_DAEMON_RESPONSE, + &cadder_protocol::ShutdownDaemonRequest { + request_id: cadder_protocol::new_request_id("test-shutdown"), + }, + ) + .await + .unwrap(); + let status = child.wait().unwrap(); + + assert!(state.accepted); + assert!(repeated.success(), "{repeated}"); + assert!(shutdown.accepted, "{}", shutdown.message); + assert!(status.success(), "{status}"); + let _ = std::fs::remove_dir_all(runtime_dir); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 26b4a9e..dd31922 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,28 +1,44 @@ # Cadder Architecture -Cadder is a cross-platform Rust daemon, shim, non-TUI CLI, local MCP server, and terminal UI for routing project-local `caddy run` invocations into one persistent per-user Caddy runtime. +Cadder v1.0 is scoped around a small, testable runtime topology: + +- `cadderd`: per-user daemon and runtime owner. +- `caddy`: PATH-facing Caddy-compatible shim. +- `cadder`: operator executable with CLI and TUI workflows. + +The target product contract is limited to the daemon, shim, operator CLI, and TUI. Future Web and Tauri GUI surfaces require a new OpenSpec change and must reuse the daemon protocol and shared operator view-model contracts. The active OpenSpec change `reset-cadder-architecture` is authoritative when this document and OpenSpec disagree. ## Process Roles -- `cadderd`: per-user daemon. It owns registrations, local IPC, Caddyfile adaptation, effective Caddy config composition, the Cadder-owned real Caddy process, runtime diagnostics, and bounded log storage. -- `caddy`: PATH-facing shim. It intentionally shadows Caddy for managed `caddy run` commands, attaches to an already running `cadderd`, registers the caller's config, heartbeats while alive, and unregisters on exit. -- `cadderctl`: non-TUI CLI. It attaches to the daemon for scriptable inspection, log queries, entrypoint and domain toggles, diagnostics, and explicit daemon lifecycle control. -- `cadder-mcp`: local stdio MCP server. It attaches to the daemon through the same operator layer as `cadderctl`, exposes typed tools for state, diagnostics, logs, and toggles, and keeps daemon launch explicit through `cadder_start_daemon`. -- `cadder-tui`: Ratatui/Crossterm UI. It attaches to the daemon and shows overview, entrypoints, grouped domains, per-domain logs, diagnostics, search/filtering, activation toggles, and explicit backend start or shutdown actions. -- Real Caddy runtime: resolved external binary. Cadder never embeds Caddy and must not recursively execute its own shim. +- `cadderd` owns registrations, local IPC, Caddyfile adaptation, effective Caddy config composition, the Cadder-owned real Caddy process, runtime diagnostics, durable history, native autostart state, IIS handoff metadata, and bounded log storage. +- `caddy` intentionally shadows Caddy for managed `caddy run` commands. It attaches to an already running daemon, registers the caller's config, heartbeats while alive, and unregisters on exit. Non-`run` commands are delegated to the safely resolved real Caddy binary. +- `cadder` is the only operator-facing v1 binary. Its CLI and TUI support daemon lifecycle, runtime status, entrypoints, domains, logs, diagnostics, history, IIS handoff, autostart, settings, and watch flows. Both surfaces attach through the daemon protocol and render from mockable operator view models. +- Real Caddy is an external binary. Cadder never embeds Caddy and must not recursively execute its own shim. -All five Cadder binaries expose `--help` and `--version` through their Clap command definitions. The release-facing command names are `cadderd`, `cadderctl`, `cadder-mcp`, `caddy`, and `cadder-tui`. +All release-facing Cadder binaries expose `--help` and `--version`. Runtime installers and portable archives contain only `cadderd`, `cadder`, `caddy`, and `cadder.toml`. -## Cross-Platform Runtime Model +## Runtime Model -Cadder uses per-user runtime paths from `directories::ProjectDirs`, with `CADDER_RUNTIME_DIR` as an override for tests and custom deployments. The daemon owns: +Cadder uses per-user runtime paths from `directories::ProjectDirs`, with `CADDER_RUNTIME_DIR` as the highest-priority override for tests and custom deployments. `CADDER_RUNTIME_PROFILE=dev` selects a repeatable development profile under the same per-user runtime base, using `CADDER_DEV_WORKSPACE` or `CADDER_DEV_ID` to derive an isolated runtime identity. + +The daemon owns: - a lockfile guarded by `fs4`, preventing multiple daemons for the same runtime directory; - a local IPC socket name derived from the runtime directory; - an effective generated Caddy JSON config file; -- daemon metadata and bounded in-memory state. +- daemon metadata, durable SQLite runtime storage, and bounded in-memory state. + +Direct `cadderd` execution is the foreground diagnostic path. Explicit client-triggered starts use the detached background launch contract, redirect stdio away from the caller, and wait for the runtime socket before reporting success. Restart flows first request shutdown, then wait for the previous owner to release both the socket and runtime lock before launching the next daemon. + +Development workflows can select `CADDER_CADDY_BACKEND=mock` or `--caddy-backend mock`. The mock backend prepares registrations and effective config state without invoking `caddy adapt`, `caddy run`, `reload`, or `stop`, and it never binds HTTP or HTTPS ports. + +## Operator Surfaces + +The CLI is the stable automation surface for people, scripts, and agents. It supports `human`, `json`, and `jsonl` output modes where appropriate. -`cadderd` is a manually started per-user backend. The `caddy` shim is attach-only, while `cadder-tui`, `cadderctl`, and `cadder-mcp` are attach-only except for their explicit daemon start actions. They connect to an existing daemon when it is available, surface explicit unavailable guidance when it is not, and never spawn it as an implicit side effect of opening a dashboard, querying state, running `caddy run`, or serving an MCP tool call. Explicit backend start is limited to direct `cadderd` execution or deliberate client actions such as pressing `s` in `cadder-tui`, running `cadderctl daemon start`, or invoking the MCP `cadder_start_daemon` tool. Explicit launch attempts still use a per-runtime launch lock before spawning `cadderd`, so concurrent start requests wait for the daemon socket instead of spawning another child, while the daemon's own runtime lock remains the final ownership guard. OS services are intentionally deferred; v1 remains a user daemon model. +The TUI is the interactive operator surface. It must render from mockable view models and cover the same daemon status, Caddy status, project/domain state, log access, IIS handoff, autostart, settings, and recovery actions as the CLI. The TUI must remain useful when `cadderd` is offline by showing daemon-unavailable status and a visible start action. + +Web and Tauri GUI are future surfaces. They may return only as clients of the same daemon protocol and shared view-model contracts. Remote pairing, authentication, multi-host management, and update channels are out of scope for the reset. ## IPC Boundary @@ -32,7 +48,7 @@ IPC is versioned newline-delimited JSON over a per-user local socket via `interp { "protocolVersion": 1, "type": "query-state-request", "payload": { "requestId": "..." } } ``` -Supported public messages include: +Supported v1.0 public messages include: - register, unregister, and heartbeat entrypoint; - query current state; @@ -42,10 +58,10 @@ Supported public messages include: - query Windows IIS bindings; - set Windows IIS handoff enabled or disabled; - query Caddy logs; +- query durable history; +- query and set native autostart mode; - request daemon shutdown. -Shim registrations are tied to the IPC session that created them. If the shim process exits without an unregister request, pipe disconnect cleanup removes only registrations owned by that session. - ## Caddy Integration Real Caddy resolution is layered and recursion-safe. The effective command is selected in this order: @@ -63,7 +79,7 @@ The TOML schema is: real_command = "/absolute/path/to/caddy" ``` -Cadder no longer treats `caddy-real` as a built-in default. Users may still configure `caddy-real` explicitly through CLI, TOML, or environment variables. PATH fallback excludes the current executable and the known shim path from `CADDER_CADDY_SHIM_PATH`, so Cadder does not resolve its own shim as real Caddy. +PATH fallback excludes the current executable and the known shim path from `CADDER_CADDY_SHIM_PATH`, so Cadder does not resolve its own shim as real Caddy. For each registration, Cadder runs: @@ -71,74 +87,69 @@ For each registration, Cadder runs: caddy adapt --config --adapter ``` -The adapted JSON is inspected for HTTP host matchers. The adapter resolves real Caddy against the registration's source working directory, so project-local `cadder.toml` files are honored for shim-driven `caddy run` registrations from arbitrary directories. Active domains are composed into a generated effective Caddy JSON document. Domain conflicts are reported before runtime reload. When no active domains remain, the daemon enters idle config/runtime state instead of reloading an empty active config. +The adapted JSON is inspected for HTTP host matchers. Active domains are composed into a generated effective Caddy JSON document. Domain conflicts are reported before runtime reload. When no active domains remain, the daemon enters idle config/runtime state instead of reloading an empty active config. Runtime operations start the owned real Caddy process with the generated config and reload it on subsequent config changes. Captured stdout/stderr and control events are stored in a bounded log store with redaction for token-like values. -## Windows IIS Handoff - -On Windows, the daemon exposes a small IIS provider behind the `query-iis-bindings` and `set-iis-handoff` IPC messages. The provider is platform-gated: non-Windows builds do not expose the TUI view, and the daemon provider returns an IIS-unavailable issue instead of loading Windows-only dependencies. - -IIS discovery is explicit and separate from `query-state`, so the TUI's periodic state refresh does not enumerate IIS bindings while holding daemon state. The Windows provider uses PowerShell `WebAdministration` commands to list, add, remove, and restore bindings. Discovery, route planning, restore metadata writes, Caddy config updates, and daemon/TUI operation stay in the normal user context. Only IIS binding mutations are classified as administrator steps. On Windows those mutations are executed as a short privileged batch through the OS elevation prompt; on non-Windows the same path returns typed unsupported/elevation-unavailable issues. Default automated tests use a fake provider and do not require a local IIS installation or elevated privileges. - -Supported handoff shapes are IIS `http` port 80 and `https` port 443 bindings when Cadder can identify one route host. A concrete IIS host header is used directly. A wildcard or empty IIS host header requires an explicit route host from the caller; `cadder-tui` uses the `/` input for this, and full URLs are accepted by extracting only the DNS host. Unsupported protocols, unsupported ports, duplicate host bindings, active Cadder-domain conflicts, missing HTTPS certificate metadata, and provider privilege errors are returned as typed issues and shown inline by `cadder-tui`. - -Cadder remains the front door during IIS handoff. Before removing an IIS-owned public binding, the daemon persists restore metadata in `daemon.json` under the runtime directory. The record includes the original site name, protocol, IP address, port, host header, binding information, route host, and loopback backend binding. Enabling HTTP handoff creates a deterministic `127.0.0.1:` HTTP binding on the same IIS site, using a port below the default Windows dynamic TCP range. Enabling HTTPS handoff creates a deterministic `127.0.0.1:` HTTPS binding on the same IIS site and copies the discovered IIS TLS certificate metadata to that backend binding. If the certificate metadata is unavailable, Cadder rejects the handoff before writing metadata or mutating IIS. +## Durable Runtime Storage -Caddy routes HTTPS IIS backends with an HTTP transport that enables TLS. The route host is used as the upstream TLS server name. Caddy skips certificate verification for the loopback backend because IIS certificates commonly do not validate for `127.0.0.1`. This keeps IIS applications seeing an HTTPS backend request while limiting the trust tradeoff to the local loopback hop. +Cadder persists runtime history in `runtime.sqlite3` under the runtime directory. The daemon owns schema creation and migration for this database. The initial schema stores history records for registration lifecycle events, activation toggles, daemon shutdown, autostart changes, IIS handoff activity, and other runtime events. History retention keeps the most recent 10,000 records and prunes older rows after successful writes. -After planning the backend binding, Cadder removes the original public binding, injects a Caddy reverse-proxy route for the route host, and applies Caddy. The loopback binding creation and public binding removal are batched together because they are adjacent IIS mutations for the same requested handoff; Caddy route application, registration state, discovery, and metadata writes are not included in the privileged batch. Persisted IIS proxy routes are hydrated when the daemon starts so restart does not drop an active handoff route. If Cadder cannot apply the proxy route after changing IIS, the daemon attempts a privileged rollback batch that restores the original IIS binding and removes the loopback binding, then reports whether rollback succeeded or failed. If rollback fails, restore metadata and the loopback backend binding are kept so the operator can retry recovery. Restoring handoff removes the Caddy IIS proxy route in the user context, then runs one privileged batch to recreate the original IIS binding and remove the backend binding. Metadata is cleared only after restore succeeds. Restore is rejected while other active Cadder routes still need the front-door port, because IIS would reclaim raw `:80` or `:443`. +The durable store complements, but does not replace, in-memory daemon state. Current registrations, active log buffers, process handles, subscriptions, and live Caddy runtime ownership remain in memory. If the database contains a schema version newer than the running daemon supports, Cadder reports storage as unavailable with a diagnostic instead of rewriting the file. -`set-iis-handoff-response` includes typed operation steps, privilege level, approval outcome, step status, issue data, and available follow-up actions such as retry elevation, rollback handoff, retry restore, loopback cleanup, or metadata cleanup. User denial of the administrator prompt is a normal typed outcome. It leaves daemon, TUI, registration, log, and non-IIS operations usable and returns retry guidance instead of silently escalating or failing the daemon. - -## Domain Logs TUI - -`cadder-tui` exposes per-domain logs from the Domains view. Pressing `Enter` or `l` on a domain row opens a log-focused view bound to that domain's `LogStreamIdentity`; the view keeps the selected stream even if registrations change later, so it never silently falls back to an entrypoint or unrelated domain. - -The TUI attempts to attach without spawning the daemon, upgrades successful attaches into a durable `subscribe-state-request` stream, and keeps periodic `query-state-request` probes only for late daemon availability and recovery after backend loss. It also loads a bounded log page through `query-logs-request`, stores the returned cursor, and tails by issuing follow-up requests with that cursor. Auto-tail is enabled by default and can be paused with `p`; while paused, the TUI keeps keyboard handling responsive and avoids automatic log refreshes until the user resumes or manually refreshes with `Enter`. Log severity is primarily controlled from the Settings view; applying a new severity resets the cursor before reloading so entries from different filters are not mixed. - -`cadderctl` reuses the same contracts for non-interactive workflows. One-shot inspection commands use `query-state-request` or `query-logs-request`, `watch` uses `subscribe-state-request`, domain and entrypoint toggles use the existing activation messages, and `logs tail` advances the daemon log cursor through repeated `query-logs-request` calls instead of introducing a new streaming backend protocol. - -`cadder-mcp` reuses the same daemon IPC contracts through a shared operator layer, but serves them over local stdio for MCP-capable hosts. The server is intentionally local-only, returns structured tool payloads instead of terminal-oriented output, applies an extra redaction pass to paths and free-form text, and does not expose daemon event streaming in v1. +## Windows IIS Handoff -On Windows, `cadder-tui` also exposes an IIS Handoff view. The view lists site, protocol, IP address, port, host header, handoff state, and safety details. Pressing `Enter` refreshes IIS discovery; pressing `Space` hands off an available binding or restores a handed-off binding. Before dispatching a mixed-elevation action, the TUI states why administrator approval may be requested. After the daemon responds, the status line summarizes succeeded, approved, denied, failed, and follow-up operation steps. For wildcard or empty-host IIS rows, press `/`, type the route host or URL, press `Enter` to keep the value, then press `Space`. The view is absent on non-Windows platforms rather than shown as a disabled placeholder. +On Windows, the daemon exposes a small IIS provider behind `query-iis-bindings` and `set-iis-handoff`. The provider is platform-gated: non-Windows builds return an IIS-unavailable issue instead of loading Windows-only dependencies. -Log refresh, late-availability probes, IIS discovery, activation toggles, IIS handoff actions, and shutdown requests are dispatched as short background IPC tasks. The TUI accepts at most one active state probe, one active state subscription stream, and one active log refresh for the current stream, and surfaces IPC failures as a read-error state rather than blocking terminal input. +Discovery, route planning, restore metadata writes, Caddy config updates, and daemon/operator operation stay in the normal user context. Only IIS binding mutations are classified as administrator steps and are executed as a short privileged batch through the OS elevation prompt. -When the daemon is unavailable, the Overview view shows the daemon state as not running, connection failed, starting, or start failed. Press `s` to explicitly start `cadderd` with the TUI's runtime directory, daemon path, and real Caddy command options. Press `r` to retry attach or refresh without spawning a daemon. Daemon-dependent actions such as activation toggles, log refreshes, IIS discovery/handoff, and shutdown are gated until a valid state snapshot marks the daemon connected; navigation, retry, explicit start, settings, existing log export, and quit remain responsive. `ShutdownDaemonRequest` stops the owned real Caddy runtime and then terminates the actual `cadderd` process after it replies to the caller. +Supported handoff shapes are IIS `http` port 80 and `https` port 443 bindings when Cadder can identify one route host. Cadder persists restore metadata before mutating IIS, creates deterministic loopback backend bindings, injects a Caddy reverse-proxy route, and supports restore/rollback follow-up actions. -The log store reports stream status and retention metadata in `query-logs-response`, including active, empty, stale, removed, read-error, gap, more-before, and truncated-by-retention states. Diagnostic exports are timestamped text files in the caller's current working directory and contain only the daemon-redacted `LogEntry.raw_message` content plus stream metadata. +Windows Sandbox remains the preferred smoke boundary for installer, autostart, shim, daemon lifecycle, IIS handoff, and cleanup tests because those checks intentionally touch OS-level state. -## Portable Packaging +## Packaging -`cargo run -p xtask -- dist --out ` builds release binaries and copies the current platform's executable names into a portable layout: +`cargo xtask dist --out ` builds the v1.0 portable runtime layout: - `cadderd` -- `cadderctl` -- `cadder-mcp` -- `cadder-tui` +- `cadder` - `caddy` - `cadder.toml` -On Windows the binaries include the `.exe` suffix. `cargo run -p xtask -- verify-dist --dir ` checks the expected files, runs `caddy --cadder-shim-info`, and verifies both `cadderctl --help` and `cadder-mcp --help` from the layout. `cargo run -p xtask -- package --out --platform --target ` builds the target-specific layout, wraps it in a versioned portable archive, and writes a neighboring `.sha256` checksum file. The package version defaults to the root `Cargo.toml` `[workspace.package]` version, while `--version ` remains available for explicit dry-run overrides. Windows artifacts are `.zip` archives; Linux and macOS artifacts are `.tar.gz` archives. +`cargo xtask package --out --platform --target ` wraps that layout in `cadder--` and writes a neighboring `.sha256` checksum file. -The packaging workflow does not modify PATH, shell profiles, package-manager shims, OS services, or other system state. +Native runtime installers are built with `cargo xtask runtime-installer --out --platform --target `. They install only the v1.0 runtime binaries and sample configuration, produce `cadder-runtime--` artifacts, and write manifest/checksum files that record expected install paths. -Image assets under `assets/` are documentation and release artwork only. They are not copied into the runtime layout and are not required by the daemon, shim, TUI, or verification commands. +`cargo xtask verify-release-assets` checks the cross-platform runtime installer and portable archive matrix before upload. -The current runtime model is a single per-user daemon that owns the backend and serves zero-to-many independent dashboards or shim clients. Future UI surfaces, including a desktop `cadder` application, should attach to that same backend instead of changing ownership semantics. +Cadder v1.0 has no in-app updater. Update surfaces route users to manual GitHub Releases downloads. ## Workspace Layout -- `crates/cadder-protocol`: shared DTOs, activation/runtime/log states, IPC envelopes, and request/response contracts. -- `crates/cadder-daemon`: runtime paths, daemon lock, local IPC server/client, registration state, Caddy integration, process runtime, and log store. -- `crates/cadder-operator`: shared operator layer for daemon queries, daemon launch policy, state shaping, and view-building reused across CLIs and MCP surfaces. -- `crates/cadderctl`: non-TUI CLI for daemon status, entrypoints, domains, diagnostics, and logs. -- `crates/cadder-mcp`: stdio MCP server exposing Cadder inspection and control tools for local agents. -- `crates/cadderd`: daemon binary. -- `crates/cadder-shim`: package containing the PATH-facing `caddy` binary. -- `crates/cadder-tui`: terminal UI. -- `xtask`: repository validation task runner. +The workspace topology is intentionally closed around documented product, +library, and tooling responsibilities. `cargo xtask verify-workspace-topology` +checks the Cargo workspace against this contract. + +| Workspace member | Classification | Responsibility | Release-facing package | +| --- | --- | --- | --- | +| `crates/cadder-daemon` | Daemon | Runtime state, daemon lock, local IPC, Caddy integration, process runtime, durable storage, platform providers, and logs. | No | +| `crates/cadderd` | Daemon | Binary entrypoint for the Cadder daemon. | Yes | +| `crates/cadder-shim` | Shim | Package containing the PATH-facing `caddy` binary. | Yes | +| `crates/cadder` | Operator client | Package that builds the `cadder` operator executable for CLI and TUI workflows. | Yes | +| `crates/cadder-operator` | Operator client | Internal operator service, daemon launch policy, state shaping, and reusable view-model boundary shared by CLI and TUI code. | No | +| `crates/cadder-protocol` | Shared protocol/API | Shared DTOs, activation/runtime/log states, IPC envelopes, and request/response contracts. | No | +| `xtask` | Docs/tooling | Repository validation task runner for checks that are Cadder-specific. | No | + +`crates/cadder-operator` remains a separate internal library for now because it +keeps daemon access and view-model construction mockable across CLI and TUI +tests. It may be merged into `crates/cadder` only through a future OpenSpec +change if that boundary stops carrying a testable responsibility. + +Historical product crates such as `crates/cadderctl`, `crates/cadder-tui`, and +`crates/cadder-mcp` are obsolete in this topology. Future Web, Tauri, MCP, or +remote-management surfaces require their own OpenSpec change and must reuse the +daemon protocol and shared operator view-model contracts rather than creating a +second runtime control plane. ## Validation @@ -148,30 +159,15 @@ Use Cargo from the repository root: cargo fmt --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace -cargo run -p xtask -- check +cargo xtask check +cargo xtask coverage ``` -Focused tests are appropriate while iterating. Full validation should pass before closeout. - -The Docker/Testcontainers end-to-end suite is intentionally separate from the default Cargo test -path because it requires a running Docker daemon and the Docker CLI. It runs compiled host Cadder -binaries while real Caddy runs in an official disposable Caddy container: +The Docker/Testcontainers end-to-end suite is intentionally separate from the default Cargo test path because it requires a running Docker daemon and the Docker CLI: ```sh cargo build -p cadderd -p cadder-shim cargo test -p cadder-daemon --features docker-e2e --test testcontainers_e2e -- --ignored --test-threads=1 ``` -The suite uses a unique `CADDER_RUNTIME_DIR`, Testcontainers-managed container lifecycle, dynamic -host port mappings, and a wrapper command that delegates Caddy operations into the container. It -must not require a machine-global Caddy installation. - -Cadder uses `cargo-llvm-cov 0.8.7` as the canonical Rust workspace coverage tool. On Windows, the canonical gate uses `stable-x86_64-pc-windows-msvc` because GNU coverage can fail without the profiler runtime. The executable gate is: - -```sh -cargo run -p xtask -- coverage -``` - -`xtask coverage` runs `cargo +stable-x86_64-pc-windows-msvc llvm-cov --workspace --json --summary-only --fail-under-lines 85 --output-path target/llvm-cov/coverage-summary.json` on Windows and `cargo llvm-cov --workspace --json --summary-only --fail-under-lines 85 --output-path target/llvm-cov/coverage-summary.json` elsewhere. The command fails when total line coverage is below 85% or when coverage cannot be measured. It does not require a machine-global real Caddy installation; tests use repository fixtures. - -No project-specific coverage exclusions are currently configured. Future exclusions must be limited to generated, platform-gated, or intentionally untestable code and documented next to the `xtask coverage` command definition. +Focused tests are appropriate while iterating. Full validation should pass before release closeout. diff --git a/docs/decisions/0001-rescope-v1-daemon-shim-cli-tui.md b/docs/decisions/0001-rescope-v1-daemon-shim-cli-tui.md new file mode 100644 index 0000000..6a6cbe5 --- /dev/null +++ b/docs/decisions/0001-rescope-v1-daemon-shim-cli-tui.md @@ -0,0 +1,49 @@ +# ADR 0001: Rescope Cadder to Daemon, Shim, CLI, and TUI + +## Status + +Accepted. + +## Context + +Cadder accumulated too many surfaces before the daemon and shim contract became +stable. The project had daemon code, PATH shim behavior, CLI/TUI/web/desktop +experiments, MCP assumptions, and Backlog.md task history competing for product +direction. + +The active source of truth is the OpenSpec change +`reset-cadder-architecture`. This ADR records the durable decision in the docs +tree, but OpenSpec remains authoritative for requirements, design details, and +implementation tasks. + +## Decision + +Cadder is reset around three product surfaces: + +- `cadderd`: daemon and owner of runtime state, real Caddy coordination, logs, + and OS integrations. +- `caddy`: PATH-facing Caddy-compatible shim that routes Cadder-managed Caddy + definitions to `cadderd`. +- `cadder`: operator executable with CLI and TUI workflows. + +Web and Tauri GUI surfaces are deferred. They may return only as clients of the +same daemon protocol and reusable view-model contracts used by CLI and TUI. + +MCP and Backlog.md are removed from product and planning scope. Automation for +people, scripts, and agents uses the `cadder` CLI and daemon protocol model. + +Windows remains the primary platform target for now, especially least-privilege +operation, named-pipe security, autostart behavior, shim PATH behavior, Windows +Sandbox smoke tests, and optional IIS handoff. Cross-platform primitives remain +preferred where they do not hide platform security or lifecycle differences. + +## Consequences + +- The daemon is the single source of truth for Cadder-managed runtime state. +- The shim cannot silently start unmanaged Caddy for Cadder-managed commands. +- CLI and TUI must handle daemon-unavailable states as normal user-facing states. +- Logs are a first-class operator workflow across all logs, project logs, + domain logs, and severity filters. +- Multiple Cadder runtimes are limited to explicit dev/debug profiles. +- Large custom orchestration must be reduced or justified against mature tools. +- Future implementation work starts from OpenSpec, not Backlog.md. diff --git a/docs/site/README.md b/docs/site/README.md index 64f0cce..733da35 100644 --- a/docs/site/README.md +++ b/docs/site/README.md @@ -4,7 +4,14 @@ This workspace contains the Astro Starlight documentation source for Cadder. ## Commands -Run commands from `docs/site`. +From the repository root, prefer the canonical task runner: + +```sh +cargo xtask docs-check +cargo xtask docs-build +``` + +When working directly in this package, run commands from `docs/site`. ```sh bun install --frozen-lockfile @@ -22,15 +29,12 @@ The durable architecture notes in `../ARCHITECTURE.md` remain the compact source ## CI handoff -The repository CI builds documentation from source on `master` with Bun: +The repository CI validates documentation from source on `master` through `cargo xtask check`, which includes: ```sh -cd docs/site -bun install --frozen-lockfile -bun run check -bun run build +cargo xtask docs-check ``` -CI may publish or upload `docs/site/dist`, but it should not write generated site output back to the repository. +Release or publishing workflows may additionally run `cargo xtask docs-build` and upload `docs/site/dist`, but they should not write generated site output back to the repository. -Production documentation is published below `https://maxie.dev/Cadder/`. +Production documentation is published below `https://maxie.dev/cadder/`. diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index a0dc747..a487695 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -3,7 +3,7 @@ import starlight from '@astrojs/starlight'; export default defineConfig({ site: 'https://maxie.dev', - base: '/Cadder', + base: '/cadder', integrations: [ starlight({ title: 'Cadder', @@ -27,11 +27,9 @@ export default defineConfig({ label: 'User guide', items: [ { label: 'How to use', slug: 'user-guide/how-to-use' }, - { label: 'cadderctl', slug: 'user-guide/cadderctl' }, - { label: 'cadder-mcp', slug: 'user-guide/cadder-mcp' }, + { label: 'cadder', slug: 'user-guide/cadder' }, { label: 'cadder.toml', slug: 'user-guide/cadder-toml' }, { label: 'PATH and shim strategy', slug: 'user-guide/path-and-shim' }, - { label: 'TUI and diagnostics', slug: 'user-guide/tui-diagnostics' }, ], }, { @@ -51,6 +49,7 @@ export default defineConfig({ { label: 'Reference', items: [ + { label: 'Releases and downloads', slug: 'reference/releases' }, { label: 'Runtime and configuration', slug: 'reference/runtime-configuration' }, { label: 'Real Caddy resolution', slug: 'reference/real-caddy-resolution' }, ], diff --git a/docs/site/public/cadder-downloads.js b/docs/site/public/cadder-downloads.js index 6727845..fc10c41 100644 --- a/docs/site/public/cadder-downloads.js +++ b/docs/site/public/cadder-downloads.js @@ -1,27 +1,52 @@ -const cadderAssetPatterns = { - 'windows-x64': /^cadder-.+-windows-x64\.zip$/, - 'macos-arm64': /^cadder-.+-macos-arm64\.tar\.gz$/, - 'macos-x64': /^cadder-.+-macos-x64\.tar\.gz$/, - 'linux-x64': /^cadder-.+-linux-x64\.tar\.gz$/, +const cadderRuntimeAssetPatterns = { + 'windows-x64': /^cadder-v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?-windows-x64\.zip$/, + 'macos-arm64': /^cadder-v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?-macos-arm64\.tar\.gz$/, + 'macos-x64': /^cadder-v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?-macos-x64\.tar\.gz$/, + 'linux-x64': /^cadder-v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?-linux-x64\.tar\.gz$/, }; +const cadderRuntimeInstallerAssetPatterns = { + 'windows-x64-msi': /^cadder-runtime-.+-windows-x64\.msi$/, + 'macos-arm64-pkg': /^cadder-runtime-.+-macos-arm64\.pkg$/, + 'macos-x64-pkg': /^cadder-runtime-.+-macos-x64\.pkg$/, + 'linux-x64-deb': /^cadder-runtime-.+-linux-x64\.deb$/, + 'linux-x64-rpm': /^cadder-runtime-.+-linux-x64\.rpm$/, +}; + +function updateCadderDownloadGroup(release, selector, assetAttribute, patterns) { + document.querySelectorAll(selector).forEach((link) => { + const key = link.getAttribute(assetAttribute); + const pattern = patterns[key]; + const asset = release.assets.find((candidate) => pattern?.test(candidate.name)); + if (asset?.browser_download_url) { + link.href = asset.browser_download_url; + } + }); +} + async function updateCadderDownloadLinks() { try { - const response = await fetch('https://api.github.com/repos/MrMaxie/Cadder/releases?per_page=1'); + const response = await fetch( + 'https://api.github.com/repos/MrMaxie/cadder/releases?per_page=1' + ); if (!response.ok) return; const releases = await response.json(); const release = Array.isArray(releases) ? releases[0] : null; if (!release?.assets) return; - document.querySelectorAll('[data-cadder-asset]').forEach((link) => { - const key = link.getAttribute('data-cadder-asset'); - const pattern = cadderAssetPatterns[key]; - const asset = release.assets.find((candidate) => pattern?.test(candidate.name)); - if (asset?.browser_download_url) { - link.href = asset.browser_download_url; - } - }); + updateCadderDownloadGroup( + release, + '[data-cadder-asset]', + 'data-cadder-asset', + cadderRuntimeAssetPatterns + ); + updateCadderDownloadGroup( + release, + '[data-cadder-runtime-installer-asset]', + 'data-cadder-runtime-installer-asset', + cadderRuntimeInstallerAssetPatterns + ); } catch { return; } diff --git a/docs/site/public/site.webmanifest b/docs/site/public/site.webmanifest index 45dc8a2..70a5b4c 100644 --- a/docs/site/public/site.webmanifest +++ b/docs/site/public/site.webmanifest @@ -1 +1,19 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{ + "name": "Cadder", + "short_name": "Cadder", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/docs/site/src/content/docs/cookbooks/linux.mdx b/docs/site/src/content/docs/cookbooks/linux.mdx index af1c54f..166bc59 100644 --- a/docs/site/src/content/docs/cookbooks/linux.mdx +++ b/docs/site/src/content/docs/cookbooks/linux.mdx @@ -9,14 +9,14 @@ From the directory that contains Cadder: ```sh ./caddy run -./cadder-tui +./cadder tui ``` From any project directory where Cadder is on `PATH`: ```sh caddy run -cadder-tui +cadder tui ``` ## cadder.toml @@ -30,10 +30,10 @@ If you want `caddy run` to go through Cadder everywhere, put Cadder's shim befor ## TUI diagnostics ```sh -cadder-tui +cadder tui ``` -Use the dashboard for daemon state, domains, logs, diagnostics, and activation toggles. +Use the TUI for daemon state, domains, logs, diagnostics, and activation toggles. ## Isolated runtime directory @@ -42,7 +42,7 @@ Most users do not need to set a runtime directory. For demos, tests, or isolated ```sh CADDER_RUNTIME_DIR=/tmp/cadder-demo cadderd CADDER_RUNTIME_DIR=/tmp/cadder-demo caddy run -CADDER_RUNTIME_DIR=/tmp/cadder-demo cadder-tui +CADDER_RUNTIME_DIR=/tmp/cadder-demo cadder tui ``` ## Recover from common failures diff --git a/docs/site/src/content/docs/cookbooks/macos.mdx b/docs/site/src/content/docs/cookbooks/macos.mdx index 9438da0..5a47ea6 100644 --- a/docs/site/src/content/docs/cookbooks/macos.mdx +++ b/docs/site/src/content/docs/cookbooks/macos.mdx @@ -9,14 +9,14 @@ From the directory that contains Cadder: ```sh ./caddy run -./cadder-tui +./cadder tui ``` From any project directory where Cadder is on `PATH`: ```sh caddy run -cadder-tui +cadder tui ``` ## cadder.toml @@ -32,10 +32,10 @@ Homebrew Caddy is often at `/opt/homebrew/bin/caddy` on Apple Silicon and `/usr/ ## TUI diagnostics ```sh -cadder-tui +cadder tui ``` -Use the dashboard for daemon state, domains, logs, diagnostics, and activation toggles. +Use the TUI for daemon state, domains, logs, diagnostics, and activation toggles. ## Recover from common failures diff --git a/docs/site/src/content/docs/cookbooks/windows/iis.mdx b/docs/site/src/content/docs/cookbooks/windows/iis.mdx index 34e3f55..6cffc9c 100644 --- a/docs/site/src/content/docs/cookbooks/windows/iis.mdx +++ b/docs/site/src/content/docs/cookbooks/windows/iis.mdx @@ -3,84 +3,63 @@ title: Windows IIS handoff description: Move a local Windows IIS binding behind Cadder and restore it later. --- -Cadder can proxy a local IIS site through its Caddy front door. This is useful when a project already depends on IIS, but you still want Cadder to coordinate the local HTTPS host with the rest of your Caddy-backed projects. - -Use a disposable IIS binding first. Do not test this on a production IIS site. +Cadder can proxy a local IIS site through its Caddy front door. Use a disposable IIS binding first. Do not test this on a production IIS site. ## Requirements - Windows with IIS and the PowerShell `WebAdministration` module available. -- A normal user shell for `cadderd`, `cadder-tui`, and any `caddy` shim process used for the smoke test. +- A normal user shell for `cadderd`, `cadder`, and any `caddy` shim process used for the smoke test. - Permission to approve a Windows administrator prompt when Cadder needs to mutate IIS bindings. - A supported IIS binding: `http` on port `80` or `https` on port `443`. -- One route host for the binding. Cadder can use a concrete IIS host header directly. For wildcard or empty-host bindings, you must type the host or full URL in the TUI. -- For `https:443` bindings, IIS discovery must expose the binding certificate hash and store name. If Cadder cannot read usable TLS certificate metadata, it rejects the handoff before changing IIS. - -Cadder keeps discovery, route planning, restore metadata, Caddy config changes, and TUI operation in the normal user context. Only IIS binding changes are elevated. The handoff mutation batch creates the loopback backend binding and removes the public IIS binding in one administrator prompt. Restore uses one administrator prompt to recreate the public binding and remove the loopback binding. Cadder does not include Caddy route application, registration changes, discovery, or unrelated IIS rows in those privileged batches. - -## Hand off a binding - -1. Start `cadderd` from the normal user shell, or start a shim command that connects to the daemon. -2. Run `cadder-tui`. -3. Open the IIS Handoff view with `Tab` or `Right`. -4. Select the IIS row with `Up` and `Down`. -5. If the IIS row has an empty or wildcard host header, press `/`, type the intended host or full URL, then press `Enter`. Cadder keeps only the DNS host. For example, `https://iis-app.localhost/legacy/status` becomes `iis-app.localhost`. -6. Press `Space` to hand off the binding. -7. Review the TUI status line before approving the Windows prompt. It should explain that administrator access is needed for the IIS binding mutation batch. - -Cadder stores restore metadata before it changes IIS. It then creates a loopback backend binding on the same IIS site, removes the public IIS binding, adds a Caddy reverse-proxy route for the route host, and applies the generated Caddy config. +- One route host for the binding. -The backend binding uses `127.0.0.1:`. Cadder chooses a deterministic port below the default Windows dynamic TCP range, which avoids common HTTP.sys excluded-port failures. For `http:80`, the backend remains HTTP. +Cadder keeps discovery, route planning, restore metadata, Caddy config changes, and operator operation in the normal user context. Only IIS binding changes are elevated. -For `https:443`, Cadder creates an HTTPS backend binding and copies the IIS binding certificate metadata. Caddy then uses TLS to the loopback backend with the route host as SNI. Caddy skips certificate verification for that loopback hop because IIS certificates commonly do not validate for `127.0.0.1`; keep IIS handoff limited to local development bindings. +## Hand Off A Binding -If you deny the administrator prompt, Cadder reports the denied steps and a retry-elevation follow-up. The original IIS binding remains available, the daemon and TUI remain usable, and you can retry the same row later. +1. Start `cadderd` from the normal user shell, or run `cadder daemon start`. +2. Inspect IIS bindings: -## Verify routing +```powershell +cadder iis bindings +``` -Use the original host or URL through Caddy: +3. Hand off the selected binding: ```powershell -curl.exe -k -I https://iis-app.localhost/legacy/status +cadder iis handoff --route-host iis-app.localhost ``` -A routing smoke passes when the response goes through Caddy and reaches IIS. For an IIS application that is missing local configuration, an application-level `4xx` or `5xx` can still be a valid routing result. Check headers such as: +You can also use `cadder tui` for the same flow through the interactive operator surface. -- `Via: 1.1 Caddy` -- `Server: Microsoft-IIS/...` +Cadder stores restore metadata before it changes IIS. It then creates a loopback backend binding on the same IIS site, removes the public IIS binding, adds a Caddy reverse-proxy route for the route host, and applies the generated Caddy config. -If the TUI status line shows the backend port for an HTTP handoff, you can also check IIS directly: +If you deny the administrator prompt, Cadder reports the denied steps and leaves the original IIS binding available. -```powershell -$backendPort = 41043 -curl.exe -I "http://127.0.0.1:$backendPort/legacy/status" -``` +## Verify Routing -Set `$backendPort` to the port shown by your TUI. - -For an HTTPS handoff, connect to the loopback port while keeping the route host as the request host and TLS server name: +Use the original host or URL through Caddy: ```powershell -$backendPort = 41043 -curl.exe -k -I --connect-to "iis-app.localhost:$backendPort:127.0.0.1:$backendPort" "https://iis-app.localhost:$backendPort/legacy/status" +curl.exe -k -I https://iis-app.localhost/legacy/status ``` -## Restore a binding - -In the IIS Handoff view, select a handed-off row and press `Space` again. +A routing smoke passes when the response goes through Caddy and reaches IIS. For an IIS application that is missing local configuration, an application-level `4xx` or `5xx` can still be a valid routing result. -Restore removes the Caddy IIS proxy route, recreates the original IIS binding from stored metadata, removes the loopback backend binding, and clears the metadata after the restore succeeds. Cadder rejects restore while other active Cadder routes still need the front-door port, because giving that port back to IIS would break those routes. +## Restore A Binding -If Caddy apply fails during handoff, Cadder tries to roll IIS back to the original binding. If rollback fails, Cadder keeps the restore metadata so the binding can still be recovered from the TUI after the underlying IIS problem is fixed. +```powershell +cadder iis restore +``` -The handoff response reports each operation step with its privilege level, approval outcome, status, and any issue. The TUI summarizes how many steps succeeded, were admin-approved, were denied, or failed, and lists follow-up actions such as retrying elevation, retrying restore, or rolling back a partial handoff. +Restore removes the Caddy IIS proxy route, recreates the original IIS binding from stored metadata, removes the loopback backend binding, and clears metadata after restore succeeds. ## Troubleshooting -- `Elevation denied`: approve the Windows prompt and retry when you are ready. Non-IIS Cadder operations should remain usable. -- `Insufficient privileges`: approve the Windows prompt if one appears. If PowerShell still reports an access problem, verify the account can manage the disposable IIS binding. -- `Missing route host`: press `/`, enter the intended host or URL, press `Enter`, then press `Space` again. -- `Missing TLS certificate`: repair the IIS HTTPS binding certificate, confirm IIS discovery can read the certificate hash and store name, then refresh and retry handoff. -- `Unsupported binding shape`: leave the binding under IIS, or change it to a supported `http:80` or `https:443` binding before handing it off. -- `Conflict`: another IIS binding or active Cadder domain already uses the same route host. Resolve that conflict before handing off the row. -- `Rollback failed` or `Restore failed`: do not delete the runtime metadata. Fix the IIS permission or binding problem, then retry restore from the TUI. +- `Elevation denied`: approve the Windows prompt and retry when you are ready. +- `Insufficient privileges`: verify the account can manage the disposable IIS binding. +- `Missing route host`: pass `--route-host `. +- `Missing TLS certificate`: repair the IIS HTTPS binding certificate, confirm IIS discovery can read it, then retry. +- `Unsupported binding shape`: leave the binding under IIS, or change it to a supported `http:80` or `https:443` binding first. +- `Conflict`: another IIS binding or active Cadder domain already uses the same route host. +- `Rollback failed` or `Restore failed`: keep the runtime metadata, fix the IIS permission or binding problem, then retry restore. diff --git a/docs/site/src/content/docs/cookbooks/windows/overview.mdx b/docs/site/src/content/docs/cookbooks/windows/overview.mdx index fdf42ca..4c14870 100644 --- a/docs/site/src/content/docs/cookbooks/windows/overview.mdx +++ b/docs/site/src/content/docs/cookbooks/windows/overview.mdx @@ -3,47 +3,42 @@ title: Windows description: Windows cookbook for Cadder binaries, user PATH setup, cadder.toml, and diagnostics. --- -## Direct use +## Direct Use From the directory that contains Cadder: ```powershell +.\cadderd +.\cadder daemon status +.\cadder tui .\caddy run -.\cadder-tui ``` From any project directory where Cadder is on `PATH`: ```powershell caddy run -cadder-tui +cadder daemon status +cadder tui ``` ## cadder.toml Point Cadder at the real Caddy executable in [cadder.toml](../../../user-guide/cadder-toml/). On Windows, literal TOML strings let you write normal backslashes. -## Optional user PATH setup +## Optional User PATH Setup If you want `caddy run` to go through Cadder everywhere, put Cadder's shim before real Caddy on your user `PATH`. Keep the real Caddy binary somewhere else and reference it from `cadder.toml`. -## TUI diagnostics - -```powershell -cadder-tui -``` - -Use the dashboard for daemon state, domains, logs, diagnostics, and activation toggles. - -## IIS handoff +## IIS Handoff Use [Windows IIS handoff](../iis/) when a local IIS site needs to sit behind Cadder's Caddy front door. -Cadder does not require the whole daemon or TUI session to run as Administrator for this workflow. Discovery, planning, restore metadata, Caddy config updates, and normal TUI operations stay in the user context. Windows asks for administrator approval only when Cadder must mutate IIS bindings. +Cadder does not require the whole daemon or operator session to run as Administrator for this workflow. Discovery, planning, restore metadata, Caddy config updates, and normal operator actions stay in the user context. Windows asks for administrator approval only when Cadder must mutate IIS bindings. -## Recover from common failures +## Recover From Common Failures - If Cadder cannot find real Caddy, check [cadder.toml](../../../user-guide/cadder-toml/) first. - If `caddy run` starts real Caddy instead of Cadder, your shell is finding real Caddy before the Cadder shim. - If one project needs a different real Caddy binary, put a project-local `cadder.toml` in that project directory. -- If an IIS handoff prompt is denied, retry the same row when ready. Cadder leaves non-IIS daemon and TUI operations usable. +- If an IIS handoff prompt is denied, retry the same action when ready. Cadder leaves non-IIS daemon and operator actions usable. diff --git a/docs/site/src/content/docs/index.mdx b/docs/site/src/content/docs/index.mdx index c7dc7a0..c3a604a 100644 --- a/docs/site/src/content/docs/index.mdx +++ b/docs/site/src/content/docs/index.mdx @@ -10,45 +10,67 @@ import { Icon } from '@astrojs/starlight/components'; Cadder coordinates local Caddy projects that would otherwise fight for the same HTTP and HTTPS ports. Each project can still use a familiar `caddy run` workflow, but Cadder registers those projects with one per-user daemon and applies them through one real Caddy process. -That makes local reverse-proxy setups easier to run side by side. Cadder can compose active project Caddyfiles, report domain conflicts before reload, and show the current state in a terminal dashboard. +Repository: [github.com/MrMaxie/cadder](https://github.com/MrMaxie/cadder) ## Features - One local Caddy runtime can serve domains from many project Caddyfiles. - The `caddy` shim keeps `caddy run` usable for project workflows. -- `cadderctl` gives scripts and agents a non-interactive way to inspect and operate Cadder. -- `cadder-mcp` exposes the same local daemon through MCP tools for agent hosts. +- `cadder` gives scripts and agents a non-interactive CLI. +- `cadder` also provides the interactive TUI operator surface. - `cadder.toml` points Cadder at the real Caddy binary without replacing it. -- `cadder-tui` shows entrypoints, domains, logs, diagnostics, and toggles. - Domains can be enabled or disabled without killing unrelated project sessions. - Cadder avoids owning Caddy processes it did not start. ## Binaries -- `caddy` is the Caddy-compatible shim. For `caddy run`, it attaches to a running `cadderd`, registers the current project, and unregisters on exit. If the backend is unavailable, Cadder reports how to start it instead of spawning it implicitly. Other Caddy commands are delegated to the real Caddy binary. -- `cadderd` is the daemon. It owns registrations, generated Caddy config, the real Caddy process it starts, diagnostics, and bounded logs. -- `cadderctl` is the non-TUI CLI for state, domains, diagnostics, logs, and explicit daemon lifecycle commands. -- `cadder-mcp` is the local stdio MCP server for agents that need state, diagnostics, logs, and explicit toggles without scraping terminal output. -- `cadder-tui` is the terminal UI for inspecting domains, entrypoints, diagnostics, logs, filters, toggles, daemon shutdown, and explicit backend start when needed. +- `caddy` is the Caddy-compatible shim. +- `cadderd` is the daemon. +- `cadder` is the CLI and TUI operator executable. -Each binary supports `--help` and `--version`. +Runtime installers and portable archives contain `caddy`, `cadderd`, `cadder`, and `cadder.toml`. Each binary supports `--help` and `--version`. See [Releases and downloads](reference/releases/) for artifact names and checksums. -## Download +## Runtime Installer + +## Portable Runtime Archive + +
+ Windows x64 - + macOS Apple Silicon - + macOS Intel - + Linux x64 @@ -56,8 +78,7 @@ Each binary supports `--help` and `--version`. -1. Download the release for your operating system. -2. Create `cadder.toml` with the path to the real Caddy binary. See [cadder.toml](user-guide/cadder-toml/) for the supported forms. +1. Download either the runtime installer or the portable runtime archive for your operating system. +2. Create `cadder.toml` with the path to the real Caddy binary. 3. Start `cadderd`, then run Cadder's `caddy` shim instead of the real `caddy` command. - -Use `cadderctl` for automation-friendly inspection and toggles, `cadder-mcp` when an MCP-capable host should drive Cadder locally, or `cadder-tui` when you want the interactive dashboard. +4. Use `cadder` for CLI automation or TUI operation. diff --git a/docs/site/src/content/docs/quick-start/getting-started.mdx b/docs/site/src/content/docs/quick-start/getting-started.mdx index a26798c..46b7e89 100644 --- a/docs/site/src/content/docs/quick-start/getting-started.mdx +++ b/docs/site/src/content/docs/quick-start/getting-started.mdx @@ -1,44 +1,41 @@ --- title: Getting started -description: Download Cadder, configure real Caddy, run a project through the shim, and inspect it in the CLI or TUI. +description: Download Cadder, configure real Caddy, run a project through the shim, and inspect it through CLI or TUI. --- -## What you need +## What You Need - A real Caddy binary installed somewhere on your machine. -- A Cadder release for your OS from [GitHub Releases](https://github.com/MrMaxie/Cadder/releases/latest). +- A Cadder runtime installer or portable runtime archive for your OS from [GitHub Releases](https://github.com/MrMaxie/cadder/releases/latest). - A project with a `Caddyfile`. -## Configure real Caddy +See [Releases and downloads](../../reference/releases/) if you are deciding between the native runtime installer and portable runtime archive. -Create `cadder.toml` next to the Cadder binaries and set the real Caddy command there. See [cadder.toml](../../user-guide/cadder-toml/) for Windows path syntax, project overrides, environment aliases, and the full set of supported command forms. - -## Choose how commands are found - -The cleanest setup is to keep real Caddy out of the `PATH` slot used for day-to-day `caddy run`, point Cadder at real Caddy in `cadder.toml`, and expose Cadder's shim as the `caddy` command. +## Configure Real Caddy -You can also run Cadder by direct path. That is useful for testing or for setups where you do not want to change shell lookup order. +Create `cadder.toml` next to the Cadder binaries and set the real Caddy command there. See [cadder.toml](../../user-guide/cadder-toml/) for Windows path syntax, project overrides, environment aliases, and the full set of supported command forms. -## Run a project +## Run A Project ```sh cadderd -cadderctl daemon status -cadder-mcp --help +cadder daemon status caddy run ``` -The shim attaches to the running `cadderd` backend, registers the project Caddyfile, and keeps that registration alive while the command is running. If the backend is unavailable, Cadder reports how to start it instead of spawning it implicitly. +The shim attaches to `cadderd`, registers the project Caddyfile, and keeps that registration alive while the command is running. If the backend is unavailable, Cadder first tries a safe real Caddy fallback, then tries to start the missing daemon, and finally prints recovery steps. + +Inspect Cadder with CLI or TUI: ```sh -cadderctl entrypoints list --output json -cadderctl logs show --limit 20 domain app.localhost --registration shim-1 -cadder-tui +cadder entrypoints list --output json +cadder logs show --limit 20 domain app.localhost --registration shim-1 +cadder tui ``` -Use `cadderctl` for automation-friendly state, domain, and log commands. Use `cadder-mcp` when a local MCP host should inspect or control Cadder through tools. Use the TUI to inspect current state interactively, browse logs, and use explicit backend controls. If you prefer, you can also open `cadder-tui` first and press `s` to start the backend from inside the dashboard. +`cadder tui` starts the interactive operator surface. -## OS cookbooks +## OS Cookbooks - [Windows](../../cookbooks/windows/overview/) - [macOS](../../cookbooks/macos/) diff --git a/docs/site/src/content/docs/reference/real-caddy-resolution.mdx b/docs/site/src/content/docs/reference/real-caddy-resolution.mdx index 90959a7..aa12d61 100644 --- a/docs/site/src/content/docs/reference/real-caddy-resolution.mdx +++ b/docs/site/src/content/docs/reference/real-caddy-resolution.mdx @@ -9,7 +9,7 @@ Cadder never embeds Caddy. For normal use, configure `[caddy].real_command` in ` The resolver uses this exact order: -1. CLI override: `--real-caddy-command` for `cadderd` and `cadder-tui` daemon launch, or `--cadder-real-caddy-command` for the shim. +1. CLI override: `--real-caddy-command` for `cadderd` and `cadder` daemon launch, or `--cadder-real-caddy-command` for the shim. 2. `[caddy].real_command` in `cadder.toml` in the current working directory. 3. `[caddy].real_command` in `cadder.toml` next to the executable. 4. Environment variables, including `CADDER_CADDY_REAL_COMMAND` and `CADDER_CADDY__REAL_COMMAND`. @@ -17,6 +17,22 @@ The resolver uses this exact order: This differs from the recommended setup order because explicit per-command and per-project overrides must win at runtime. +## Shim fallback for `caddy run` + +When the PATH-facing shim handles `caddy run`, it first tries to attach to the configured Cadder runtime. If no daemon is available, recovery uses this order: + +1. Delegate to the safely resolved real Caddy command when the backend mode is `real`. +2. Start the missing daemon through Cadder's detached background launch contract. +3. Print a terminal error that includes the original attach failure, real Caddy fallback result, daemon-start result, and concrete retry guidance. + +The fallback uses the same resolver described above, including recursion-safety exclusions for the current executable and `CADDER_CADDY_SHIM_PATH`. In mock backend mode, real Caddy fallback is skipped and the shim tries daemon startup directly. + +## Mock backend for development + +Set `CADDER_CADDY_BACKEND=mock` or pass `--caddy-backend mock` to use Cadder's development mock backend. In this mode the daemon does not resolve or execute a real Caddy binary, does not run `caddy adapt`, and does not bind HTTP or HTTPS ports. It records deterministic runtime state and effective config for local UI and operator workflows. + +`--real-caddy-command` cannot be combined with `--caddy-backend mock`. Leave the backend unset for normal use; the default is `real`. + ## TOML schema ```toml diff --git a/docs/site/src/content/docs/reference/releases.mdx b/docs/site/src/content/docs/reference/releases.mdx new file mode 100644 index 0000000..456a913 --- /dev/null +++ b/docs/site/src/content/docs/reference/releases.mdx @@ -0,0 +1,37 @@ +--- +title: Releases and downloads +description: Choose the right Cadder v1.0 release artifact and verify what it contains. +--- + +Cadder v1.0 release assets are intentionally small. + +## Runtime Artifacts + +Choose one of these: + +- `cadder-runtime--` native installer for OS-managed installs. +- `cadder--` portable runtime archive for manual installs, scripts, or unpacked tool directories. + +Both forms provide: + +- `cadderd`, the per-user daemon; +- `caddy`, the Caddy-compatible shim; +- `cadder`, the CLI and TUI operator; +- `cadder.toml`, the sample real Caddy configuration file. + +## v1.0 Release Notes + +- `cadderd` owns local IPC, registrations, generated Caddy config, the real Caddy process it starts, diagnostics, logs, and durable history. +- `caddy run` goes through Cadder's shim. +- `cadder` provides CLI automation and TUI operator workflows. +- Updates are manual in v1.0. Download a newer matching artifact from GitHub Releases and install or replace it according to the artifact type. + +Per-release change notes live in the GitHub Release body. The docs and download buttons on this site point to [github.com/MrMaxie/cadder](https://github.com/MrMaxie/cadder/releases/latest). + +## Checksums + +Release archives and installers are published with neighboring `.sha256` files. Verify the checksum before replacing a working runtime, especially for portable installs copied into a shared tools directory. + +## Asset Checks + +Cadder's release workflow verifies the complete runtime installer and portable runtime matrix before upload. The docs workflow also runs repository asset and release identity checks so stale download metadata, product names, or checked-in docs assets fail before publication. diff --git a/docs/site/src/content/docs/reference/runtime-configuration.mdx b/docs/site/src/content/docs/reference/runtime-configuration.mdx index 04380a4..0677f58 100644 --- a/docs/site/src/content/docs/reference/runtime-configuration.mdx +++ b/docs/site/src/content/docs/reference/runtime-configuration.mdx @@ -5,36 +5,76 @@ description: Runtime directories, daemon state files, configuration sources, and Cadder keeps runtime state per user. Most users never need to choose the runtime directory manually. -## Recommended configuration path - -For normal use, configure real Caddy in [cadder.toml](../../user-guide/cadder-toml/) next to the Cadder binaries. Use a project-local `cadder.toml` when one project needs a different real Caddy command. - -## Runtime override +## Runtime Override Set `CADDER_RUNTIME_DIR` or pass `--runtime-dir` to use a specific directory: ```sh cadderd --runtime-dir -cadderctl --runtime-dir daemon status -cadder-tui --runtime-dir +cadder --runtime-dir daemon status +cadder --runtime-dir web caddy --cadder-runtime-dir run ``` -This is useful for tests, demos, and isolated sessions that should not share daemon state with the normal user runtime. +Use the same runtime override when you start `cadderd`, attach with `cadder`, or run the shim. + +## Development Profile + +Use the development profile when you need live local Cadder tooling without touching your normal daemon or real Caddy process: + +```sh +CADDER_RUNTIME_PROFILE=dev +CADDER_DEV_WORKSPACE=/absolute/path/to/cadder-checkout +CADDER_CADDY_BACKEND=mock +``` + +The repository `xtask` exposes repeatable helpers: + +```sh +cargo xtask dev-env --format powershell +cargo xtask dev-run -- cadder daemon status +``` + +## Privilege Model + +Run `cadderd`, `cadder`, and shim-driven `caddy run` from the normal user account that owns the runtime. If Cadder detects elevated operator execution where the platform exposes that state, it reports a privilege diagnostic through the CLI or web surface. -Use the same runtime override when you start `cadderd`, attach with `cadderctl` or `cadder-tui`, or run `caddy --cadder-runtime-dir run`. The shim and both client surfaces only attach to a backend that is already running for that exact runtime directory unless you explicitly run `cadderctl daemon start` or use the TUI start action. +Read-only inspection, diagnostics, log queries, domain and entrypoint toggles, normal daemon control, and non-IIS workflows do not require administrator privileges. On Windows, IIS handoff requests administrator approval only for the short IIS binding mutation batch. -## Runtime files +## Runtime Files The daemon runtime directory contains: - `cadder.lock`, the daemon ownership lock; - `daemon.json`, daemon metadata used by clients; -- `effective-caddy.json`, the generated Caddy config applied to the owned real Caddy process. +- `effective-caddy.json`, the generated Caddy config applied to the owned real Caddy process; +- `runtime.sqlite3`, the durable SQLite store for selected runtime history and storage metadata. The IPC socket name is derived from the runtime directory, so separate runtime directories do not collide. -## Configuration sources +## Durable Storage + +SQLite is the default durable runtime store. Cadder records selected local history such as registration lifecycle events, activation toggles, daemon shutdown, autostart changes, IIS handoff activity, and other runtime events after daemon-side redaction. + +```sh +cadder history show --limit 50 +``` + +If the store cannot be opened, or if the database was created by a newer unsupported schema version, Cadder keeps the daemon usable with an in-memory unavailable state and reports storage diagnostics. + +## Autostart + +Per-user autostart is configured through Cadder: + +```sh +cadder autostart status +cadder autostart set disabled +cadder autostart set daemon +``` + +Supported platforms use native startup mechanisms and remove Cadder daemon startup entries when the mode changes. Unsupported or partially configured platforms return explicit diagnostics. + +## Configuration Sources Cadder supports real Caddy resolution through: @@ -46,7 +86,7 @@ Cadder supports real Caddy resolution through: The user-facing TOML format is documented in [cadder.toml](../../user-guide/cadder-toml/). -## Environment aliases +## Environment Aliases Cadder accepts both environment variable names below for the same value: diff --git a/docs/site/src/content/docs/user-guide/cadder-mcp.mdx b/docs/site/src/content/docs/user-guide/cadder-mcp.mdx deleted file mode 100644 index 48dbb01..0000000 --- a/docs/site/src/content/docs/user-guide/cadder-mcp.mdx +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: cadder-mcp -description: Run Cadder's local MCP server for agent-friendly inspection and explicit control. ---- - -`cadder-mcp` is Cadder's local stdio MCP server. It exposes structured tools for the same per-user backend used by `cadderctl` and `cadder-tui`, so an MCP-capable host can inspect state, read diagnostics, fetch logs, and toggle managed routes without scraping terminal output. - -## Start the server - -Run the server directly: - -```sh -cadder-mcp -``` - -You can also override the runtime directory, daemon binary, or real Caddy resolution inputs when your host needs an explicit launch context: - -```sh -cadder-mcp --runtime-dir /tmp/cadder-runtime -cadder-mcp --daemon-path /absolute/path/to/cadderd -cadder-mcp --real-caddy-command /absolute/path/to/caddy -``` - -## Available tools - -`cadder-mcp` exposes these operator-facing tools: - -- `cadder_get_overview` -- `cadder_list_entrypoints` -- `cadder_list_domains` -- `cadder_show_diagnostics` -- `cadder_get_logs` -- `cadder_start_daemon` -- `cadder_set_entrypoint_enabled` -- `cadder_set_domain_enabled` - -The server stays attach-only by default. Read-only tools and toggle tools connect to an existing daemon and return typed unavailable errors when `cadderd` is not running. Only `cadder_start_daemon` may launch the backend. - -## Trust boundary - -`cadder-mcp` is intentionally local-only: - -- it serves tools over stdio for the host process that launched it; -- it does not open a network listener; -- it applies an extra redaction pass to returned paths and free-form text; -- it truncates large log payloads instead of streaming the full daemon log store in v1. - -This makes the MCP surface appropriate for local agents and editors that already have workstation access, while keeping daemon ownership and startup policy aligned with the CLI and TUI. - -## When to use it - -- Use `cadder-mcp` when your host already speaks MCP and should call structured local tools. -- Use `cadderctl` when you want shell-friendly commands, JSON output, or scripts outside an MCP host. -- Use `cadder-tui` when you want an interactive dashboard, navigation, filters, and browsing. diff --git a/docs/site/src/content/docs/user-guide/cadder.mdx b/docs/site/src/content/docs/user-guide/cadder.mdx new file mode 100644 index 0000000..d29fe06 --- /dev/null +++ b/docs/site/src/content/docs/user-guide/cadder.mdx @@ -0,0 +1,76 @@ +--- +title: cadder +description: Use the Cadder operator executable for CLI automation and TUI operation. +--- + +`cadder` is the v1 operator executable. It is designed for shell scripts, CI checks, agents, and operators who need daemon state, diagnostics, logs, IIS handoff, autostart, toggles, and interactive TUI operation. + +## Command Groups + +```sh +cadder daemon status +cadder daemon start +cadder daemon shutdown +cadder entrypoints list +cadder entrypoints disable +cadder domains list +cadder domains disable --registration +cadder diagnostics show +cadder logs show --limit 20 domain --registration +cadder logs tail --limit 20 --poll-interval-ms 750 domain --registration +cadder logs export --limit 100 runtime --output-path runtime.log +cadder history show --limit 50 +cadder iis bindings +cadder iis handoff --route-host app.localhost +cadder iis restore +cadder autostart status +cadder autostart set daemon +cadder watch status +cadder tui +``` + +`cadder` stays attach-only by default. Read-only commands, toggles, `watch`, `logs tail`, history, IIS, and autostart queries connect to an already running daemon. `cadder daemon start` is the explicit launch path. + +## Output Modes + +`cadder` supports: + +- `human` for operators running commands by hand. +- `json` for one-shot scripting and machine parsing. +- `jsonl` for streaming commands such as `watch` and `logs tail`. + +Examples: + +```sh +cadder entrypoints list --output json +cadder domains list --output json +cadder watch diagnostics --output jsonl +cadder logs tail --output jsonl --limit 50 domain app.localhost --registration shim-1 +cadder history show --output json +``` + +## TUI + +Run: + +```sh +cadder tui +``` + +The TUI uses the same local daemon contracts and operator view models as the CLI. It covers daemon lifecycle, runtime status, entrypoints, domains, logs, diagnostics, history, IIS handoff, autostart, and settings. + +Remote pairing, authentication, and multi-host control are not part of v1.0. + +## Autostart + +```sh +cadder autostart status +cadder autostart set disabled +cadder autostart set daemon +``` + +Autostart writes per-user startup entries. Configure it from the same normal user account that owns the Cadder runtime. v1.0 supports disabled and daemon autostart modes. + +## Agent Use + +CLI output modes are the stable v1 automation surface for agents. The TUI is the interactive operator surface. diff --git a/docs/site/src/content/docs/user-guide/cadderctl.mdx b/docs/site/src/content/docs/user-guide/cadderctl.mdx deleted file mode 100644 index b3b59e8..0000000 --- a/docs/site/src/content/docs/user-guide/cadderctl.mdx +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: cadderctl -description: Use the non-TUI Cadder CLI for automation, scripting, and operator workflows. ---- - -`cadderctl` is Cadder's non-interactive management CLI. It is designed for shell scripts, CI checks, agents, and operators who want state, diagnostics, and toggles without opening `cadder-tui`. - -## Command groups - -`cadderctl` currently exposes these top-level groups: - -```sh -cadderctl daemon status -cadderctl daemon start -cadderctl daemon shutdown -cadderctl entrypoints list -cadderctl entrypoints disable -cadderctl domains list -cadderctl domains disable --registration -cadderctl diagnostics show -cadderctl logs show --limit 20 domain --registration -cadderctl logs tail --limit 20 --poll-interval-ms 750 domain --registration -cadderctl watch status -``` - -`cadderctl` stays attach-only by default. Read-only commands, toggles, `watch`, and `logs tail` connect to an already running daemon. Only `cadderctl daemon start` may launch `cadderd`. - -## Output modes - -`cadderctl` supports three output modes: - -- `human` is the default for operators running one-shot commands by hand. -- `json` is for one-shot scripting and machine parsing. -- `jsonl` is for streaming commands such as `watch` and `logs tail`. - -Examples: - -```sh -cadderctl entrypoints list --output json -cadderctl domains list --output json -cadderctl watch diagnostics --output jsonl -cadderctl logs tail --output jsonl --limit 50 domain app.localhost --registration shim-1 -``` - -## Common scripting patterns - -Check whether the daemon is reachable without failing the script when it is stopped: - -```sh -cadderctl daemon status --output json -``` - -List current domains and parse them from JSON: - -```sh -cadderctl domains list --output json -``` - -Disable one managed domain explicitly: - -```sh -cadderctl domains disable app.localhost --registration shim-1 -``` - -Fetch recent logs for one domain: - -```sh -cadderctl logs show --limit 20 domain app.localhost --registration shim-1 --output json -``` - -Stream state changes for automation: - -```sh -cadderctl watch status --output jsonl -``` - -## Relation to the TUI - -`cadderctl`, `cadder-mcp`, and `cadder-tui` share the same daemon contracts and runtime model. - -- Use `cadderctl` when you need predictable output, non-interactive toggles, or agent-friendly polling and watches. -- Use `cadder-mcp` when your agent host already speaks MCP and should call structured local tools instead of shelling out. -- Use `cadder-tui` when you need interactive browsing, navigation, filters, and richer operator context. - -Neither tool changes daemon ownership semantics. They are clients of the same per-user backend. - -## Future agent integrations - -The command structure and output modes are intentionally stable so future Cadder-aware agents can: - -- inspect daemon reachability and configuration state; -- enumerate entrypoints and domains; -- request recent logs or stream new ones; -- toggle managed routes explicitly; -- map command failures to stable exit codes and typed machine-readable errors. - -That keeps agent workflows aligned with the same backend used by the interactive TUI instead of creating a parallel automation API. If you want a local MCP surface instead of shell-oriented commands, see [cadder-mcp](../cadder-mcp/). diff --git a/docs/site/src/content/docs/user-guide/how-to-use.mdx b/docs/site/src/content/docs/user-guide/how-to-use.mdx index 10975c4..dcc9cc3 100644 --- a/docs/site/src/content/docs/user-guide/how-to-use.mdx +++ b/docs/site/src/content/docs/user-guide/how-to-use.mdx @@ -1,22 +1,20 @@ --- title: How to use -description: Use Cadder releases, the caddy shim, and the TUI without changing Cadder itself. +description: Use the Cadder runtime, caddy shim, CLI, and TUI. --- -The release contains six files you normally care about: +The runtime installer and portable runtime archive contain: - `caddy` - `cadderd` -- `cadderctl` -- `cadder-mcp` -- `cadder-tui` +- `cadder` - `cadder.toml` The `caddy` command is Cadder's shim. Use it for project `caddy run` workflows that should be coordinated by Cadder. The real Caddy binary stays separate and is configured through [cadder.toml](../cadder-toml/). -## Basic flow +## Basic Flow -1. Get the release for your operating system from [GitHub Releases](https://github.com/MrMaxie/Cadder/releases/latest). +1. Get either the runtime installer or the portable runtime archive for your operating system from [GitHub Releases](https://github.com/MrMaxie/cadder/releases/latest). 2. Point Cadder at the real Caddy binary with [cadder.toml](../cadder-toml/). 3. Start the Cadder backend: @@ -30,20 +28,21 @@ cadderd caddy run ``` -5. Inspect the daemon when needed: +5. Inspect or operate the daemon: ```sh -cadderctl daemon status -cadderctl domains list --output json -cadder-mcp --help -cadder-tui +cadder daemon status +cadder domains list --output json +cadder tui ``` -`caddy run` attaches to the running backend. If `cadderd` is unavailable, Cadder reports how to start it instead of starting it implicitly. +Use `cadder` when you want stable machine-readable output, non-interactive toggles, log queries, IIS handoff, autostart, daemon lifecycle commands, or interactive TUI operation. -Use `cadderctl` when you want stable machine-readable output, non-interactive toggles, or scriptable log queries. Use `cadder-mcp` when an MCP-capable host should drive Cadder through local stdio tools. Use `cadder-tui` when you want an interactive dashboard with navigation and live browsing. +## Updates -## Direct or PATH-based use +Cadder v1.0 does not include an in-app updater or background update checks. To update, download a newer runtime installer or portable runtime archive from [GitHub Releases](https://github.com/MrMaxie/cadder/releases/latest), then install or replace the matching artifact manually. + +## Direct Or PATH-Based Use Both styles are valid. @@ -55,7 +54,7 @@ The practical setup is: - configure its path in [cadder.toml](../cadder-toml/); - expose Cadder's `caddy` shim globally only if that matches your workflow. -## Check the shim +## Check The Shim This command confirms that `caddy` is Cadder's shim: diff --git a/docs/site/src/content/docs/user-guide/path-and-shim.mdx b/docs/site/src/content/docs/user-guide/path-and-shim.mdx index f2c4687..afb118c 100644 --- a/docs/site/src/content/docs/user-guide/path-and-shim.mdx +++ b/docs/site/src/content/docs/user-guide/path-and-shim.mdx @@ -25,7 +25,13 @@ If you want `caddy run` to go through Cadder, copy or symlink the Cadder shim in The real Caddy binary still needs to be resolvable by Cadder. The recommended place is [cadder.toml](../cadder-toml/). -The shim does not start `cadderd` automatically. Start `cadderd` yourself, or open `cadder-tui` and press `s`, before running `caddy run`. +For `caddy run`, the shim is attach-first. If the Cadder daemon is missing, recovery uses this order: + +1. Run the safely resolved real Caddy command as a compatibility fallback. +2. Start `cadderd` through Cadder's detached background launch contract. +3. Return a terminal error that lists the attempted recovery steps and next actions. + +When `CADDER_CADDY_BACKEND=mock` or `--cadder-caddy-backend mock` is active, real Caddy fallback is skipped and the shim tries the daemon path directly. ## Keep a separate real Caddy path diff --git a/docs/site/src/content/docs/user-guide/tui-diagnostics.mdx b/docs/site/src/content/docs/user-guide/tui-diagnostics.mdx deleted file mode 100644 index 8abed42..0000000 --- a/docs/site/src/content/docs/user-guide/tui-diagnostics.mdx +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: TUI and diagnostics -description: Inspect daemon state, domains, per-domain logs, and diagnostics through cadder-tui. ---- - -`cadder-tui` opens the Cadder dashboard. It attaches to an existing `cadderd` backend by default and never starts it implicitly. Use `s` for the explicit backend start action when the daemon is unavailable. - -```sh -cadder-tui -cadder-tui --no-start -cadder-tui --runtime-dir -``` - -`--no-start` remains as a compatibility alias, but attach-only behavior is already the default. - -## Views - -- Overview shows runtime status, config status, entrypoint count, domain count, and active domain count. -- Entrypoints lists active shim registrations with source paths and domain counts. -- Domains groups domains with their source entrypoint and activation state. -- Logs opens a domain-specific log stream. Press `Enter` or `l` on a domain row to open that exact domain. -- Settings controls the minimum log severity shown in Logs. -- Diagnostics shows config and runtime diagnostics, including domain conflicts and runtime apply failures. - -The TUI refreshes daemon state automatically after it attaches and keeps background attach probes running while the daemon is unavailable. Press `r` when you want an explicit attach or refresh immediately. - -## Controls - -- `Tab`, `Shift+Tab`, `Left`, and `Right` switch views with wraparound. -- Arrow keys move selection. -- `s` explicitly starts `cadderd` when the backend is unavailable. -- `r` retries attach or refreshes the current daemon state. -- `Space` toggles the selected entrypoint or domain when the current view supports it. -- `f` enters search mode. -- In IIS Handoff on Windows, `/` enters route-host input for wildcard or empty-host bindings. -- In Settings, `Up` and `Down` choose `All`, `Info and higher`, `Warnings and errors`, or `Errors only`; `Enter` or `Space` applies the selected severity filter. -- `p` pauses or resumes log tailing in Logs. -- `Enter` manually refreshes the open Logs stream. -- `x` exports visible logs from the current stream. -- `d` asks the daemon to shut down. -- `q` exits the TUI. diff --git a/docs/verification/release-profile.md b/docs/verification/release-profile.md new file mode 100644 index 0000000..cb247ef --- /dev/null +++ b/docs/verification/release-profile.md @@ -0,0 +1,81 @@ +# Release Profile Verification + +Cadder release artifacts are built with an explicit workspace release profile. The policy is defined in the root `Cargo.toml` and enforced by `xtask` before release layouts, portable archives, and runtime installers are produced. + +## Policy + +```toml +[profile.release] +opt-level = "s" +lto = "thin" +codegen-units = 1 +debug = false +strip = "symbols" +panic = "unwind" +``` + +Use the profiling profile when inspecting optimized code with debug symbols: + +```sh +cargo build --profile profiling -p cadderd -p cadder -p cadder-shim +``` + +The package `cadder` builds the release binary named `cadder`. + +## Verification Commands + +```sh +cargo xtask verify-release-profile +cargo xtask verify-release-identity +cargo xtask verify-assets +cargo xtask dist --out target/cadder-dist +cargo xtask verify-dist --dir target/cadder-dist +cargo xtask package --out target/cadder-packages --version 1.0.0 --platform windows-x64 --target x86_64-pc-windows-msvc +cargo xtask runtime-installer --out target/cadder-runtime-installers --version 1.0.0 --platform windows-x64 --target x86_64-pc-windows-msvc +cargo xtask verify-runtime-installer-dist --dir target/cadder-runtime-installers --version 1.0.0 --platform windows-x64 +cargo xtask verify-release-assets --dir target/release-assets --version 1.0.0 --mode dry-run +``` + +`verify-dist` checks `cadderd`, `cadder`, `caddy`, and `cadder.toml`. It runs `--help` and `--version` for every included binary, verifies `caddy --cadder-shim-info`, and prints file sizes. + +Runtime installer verification checks that each required runtime installer exists for the selected platform, verifies installer and manifest `.sha256` files, and verifies the manifest installs only `cadderd`, `cadder`, `caddy`, and `cadder.toml`. + +`verify-release-assets` checks portable archive contents, runtime installer manifests, checksums, and the complete platform matrix. + +## Measurement Protocol + +Before accepting a profile change: + +1. Record host metadata: + + ```sh + rustc -Vv + cargo -V + ``` + +2. Build and inspect a native portable layout: + + ```sh + cargo xtask dist --out target/release-profile-dist + cargo xtask verify-dist --dir target/release-profile-dist + ``` + +3. Build and inspect the platform package: + + ```sh + cargo xtask package --out target/release-profile-package --version 1.0.0 --platform windows-x64 --target x86_64-pc-windows-msvc + ``` + +4. Build and inspect the daemon-first native runtime installer when local tooling supports it: + + ```sh + cargo xtask runtime-installer --out target/release-profile-runtime-installer --version 1.0.0 --platform windows-x64 --target x86_64-pc-windows-msvc + ``` + +5. Place the portable archives, runtime installers, and runtime installer manifests in one directory and run `verify-release-assets` in dry-run mode. + +Do not commit generated layouts, archives, bundles, checksums, or ad hoc measurement files. + +## CI Evidence + +The release workflow runs `verify-release-profile`, `verify-assets`, and `verify-release-identity` before portable packaging and runtime installer builds on each release matrix target. `verify-release-assets` downloads all workflow artifacts, rechecks release contracts, checks the complete runtime matrix, and only then allows the public GitHub Release upload job to run. diff --git a/docs/verification/tui-smoke.md b/docs/verification/tui-smoke.md deleted file mode 100644 index 8124062..0000000 --- a/docs/verification/tui-smoke.md +++ /dev/null @@ -1,64 +0,0 @@ -# Cadder TUI Smoke Checklist - -Use this checklist for manual terminal smoke verification of `cadder-tui` on supported terminal backends after lifecycle or TUI-facing changes. Automated tests cover the model and IPC contracts; this checklist verifies the real terminal loop, keyboard handling, and rendering. - -## Prerequisites - -- Build the workspace with `cargo build --workspace`. -- Use a disposable test runtime directory. -- Keep `cadderd` stopped for the initial no-daemon startup smoke. -- For connected-state checks, start `cadderd` with the disposable runtime directory and a fake or local real Caddy command, or launch it from inside `cadder-tui` with `s`. -- Register at least two shim sessions whose adapted Caddyfiles expose multiple domains. -- Keep one domain disabled or conflicting when diagnostics need to be visible. -- For Windows IIS handoff smoke, use a normal user shell first and a disposable IIS site or binding that can be removed and restored safely. Cadder should request elevation only for the IIS mutation batch. Do not use production IIS bindings. Follow the [Windows IIS handoff cookbook](../site/src/content/docs/cookbooks/windows/iis.mdx) for the operator flow. - -## Terminal Backends - -Run the checklist on each backend available for the platform under test: - -- Windows Terminal on Windows. -- A VT-compatible terminal on Linux. -- Terminal.app or iTerm2 on macOS. - -## Checklist - -- Launch `cargo run -p cadder-tui -- --runtime-dir ` while no daemon is running for that runtime and confirm the Overview view renders without layout corruption, panic, immediate exit, or broken terminal echo/cursor behavior. Optionally repeat with `--no-start` and confirm it behaves the same as the default attach-only launch. -- Confirm Overview shows `Daemon: Not running` or `Daemon: Connection failed` with actionable guidance to press `s`, `r`, or `q`. -- Confirm the footer advertises `s start backend`. -- Press `r` and confirm the TUI remains usable if the daemon is still unavailable. -- Relaunch with `--daemon-path ` if `cadderd` is not next to `cadder-tui` or on `PATH`, press `s`, and confirm the Overview state changes to `Starting` while input remains responsive. -- After a successful in-TUI start, confirm Overview changes to `Daemon: Connected`, the dashboard attaches without restarting, and normal state refresh resumes. -- If testing a start failure, pass a missing `--daemon-path`, press `s`, and confirm Overview changes to `Start failed` while retry with `s` and quit with `q` remain available. -- Confirm Overview shows runtime status, config status, entrypoint count, domain count, and active domain count. -- Change daemon state from a shim session and confirm Overview updates automatically within a few seconds; press `r` and confirm manual refresh still works. -- Press `Tab` or `Right` to open Entrypoints and confirm each active shim registration is listed with ID, state, source path, and domain count. -- Press `Tab` or `Right` to open Domains, then use `Shift+Tab` or `Left` to navigate backward and confirm view navigation wraps cleanly. -- Confirm domains remain associated with their source entrypoint in the first table column. -- Confirm the selected Domains row is visibly highlighted, active domains show `[x]`, and disabled domains show `[ ]` with a lower-contrast disabled style. -- Select a domain with arrow keys, press `Space`, and confirm the domain activation state toggles after the automatic or manual refresh. -- On Windows, press `Tab` or `Right` to open IIS Handoff and confirm the view lists IIS site bindings with site, protocol, IP address, port, host header, state, and safety details. On Linux and macOS, confirm the IIS Handoff view is absent. -- In IIS Handoff on Windows, confirm unsupported bindings are visible inline and cannot be handed off: unsupported protocols, unsupported ports, duplicate host bindings, active Cadder-domain conflicts, missing route hosts, HTTPS bindings with missing TLS certificate metadata, elevation-denied results, unsupported elevation paths, and insufficient privilege errors should show a safety reason instead of silently changing IIS. -- Before pressing `Space` on an available IIS row, confirm the status line explains why administrator approval may be requested. The daemon and TUI should still be running in the normal user context. -- For a disposable `http:80` or `https:443` IIS binding with a concrete host header, press `Space` and confirm the binding moves to `HandedOff`, Caddy remains the front door, and the status line reports the loopback IIS backend port. For HTTPS, use a binding with readable certificate metadata and confirm an application with IIS Require SSL enabled still sees an HTTPS backend request through Caddy. -- When Windows shows the elevation prompt, approve it and confirm the status line reports succeeded steps and admin-approved IIS mutation steps. Confirm there was one prompt for the handoff mutation batch, not separate prompts for Caddy config, registration state, or discovery. -- Repeat with another disposable binding and deny the elevation prompt. Confirm the row remains available or recoverable, restore metadata is not treated as complete handoff state, and non-IIS TUI actions such as navigation, refresh, logs, and daemon state remain usable. Confirm the status line reports admin-denied and a retry-elevation follow-up. -- For a disposable wildcard or empty-host IIS binding, press `/`, enter the intended route host or full URL, press `Enter`, then press `Space`; confirm the handed-off row shows the route host and the target URL is served through Caddy to IIS. For HTTPS, confirm direct loopback verification keeps the route host as SNI, for example with `curl.exe -k --connect-to "::127.0.0.1:" "https://:/..."`. Application-level `4xx` or `5xx` responses can be valid smoke results when the target application is intentionally misconfigured; verify routing by checking that the response comes through Caddy and reaches IIS, for example `Via: 1.1 Caddy` with `Server: Microsoft-IIS/...`. -- Press `Space` on the handed-off IIS row again and confirm the original IIS binding is restored, the loopback backend binding is removed, and restore metadata is cleared after success. If other Cadder routes still need `:80` or `:443`, confirm restore is rejected rather than silently breaking those routes. -- If testing a failure path, force Cadder route apply to fail after IIS removal and confirm the UI reports whether rollback restored the IIS binding or rollback failed, including step status and follow-up action details. If rollback fails, confirm the row remains recoverable instead of losing restore metadata. Do not proceed until the disposable binding is back under the expected owner. -- Press `Enter` or `l` on a domain row and confirm Logs opens for that exact domain, not another entrypoint or domain. -- In Logs, press `p` and confirm tailing pauses and resumes. -- With the domain log stream still open, switch to Settings, use `Up` and `Down` to choose `All`, `Info and higher`, `Warnings and errors`, or `Errors only`, then press `Enter` or `Space`; return to Logs and confirm the severity label and displayed page reset without mixing entries from different filters. -- Press `Enter` in Logs and confirm manual refresh remains responsive. -- Press `Tab` to open Diagnostics and confirm config/runtime diagnostics are shown when a conflict or runtime reload failure exists; otherwise confirm the empty diagnostics message is shown. -- Confirm the footer advertises `Tab`/`Shift+Tab`/`Left`/`Right`, manual refresh, explicit backend start, Settings severity selection, log pause/refresh/export, daemon shutdown, and quit controls. -- On Windows, confirm the footer advertises IIS refresh and handoff/restore controls. -- Press `d` and confirm the daemon shutdown request returns a status message and does not freeze terminal input. -- Press `q` and confirm the terminal exits cleanly with normal echo/cursor behavior restored. - -## Evidence To Record - -- Platform and terminal backend. -- Runtime directory used. -- Fake Caddy or real Caddy command used. -- Whether IIS handoff was skipped, tested with a disposable binding, or tested with fake-provider data. -- Any rendering issue, stuck key flow, incorrect status, or daemon error observed. diff --git a/docs/verification/windows-sandbox-smoke.md b/docs/verification/windows-sandbox-smoke.md new file mode 100644 index 0000000..11f6e4f --- /dev/null +++ b/docs/verification/windows-sandbox-smoke.md @@ -0,0 +1,66 @@ +# Windows Sandbox Smoke Checklist + +Cadder system-facing behavior should be smoke-tested in Windows Sandbox before it is trusted on a host workstation. The sandbox pass is intentionally focused on OS effects that are hard to validate safely in normal unit or integration tests. + +## Scope + +Run this checklist for the v1.0 runtime topology: + +- `cadderd` per-user daemon. +- `caddy` PATH-facing shim. +- `cadder` operator executable, including CLI and TUI. + +Use the same v1.0 package layout as release verification: `cadderd.exe`, `cadder.exe`, `caddy.exe`, and `cadder.toml`. + +## Sandbox Inputs + +Prepare a disposable package that contains only: + +- `cadderd.exe` +- `caddy.exe` +- `cadder.exe` +- sample `cadder.toml` +- optional mock real-Caddy fixture for deterministic shim tests +- smoke script and cleanup script + +Use a clean sandbox image for each release candidate. Prefer mock backend tests first, then a real Caddy binary when validating resolver and process behavior. + +## Smoke Steps + +1. Install the package into a disposable directory under the sandbox user profile. +2. Put the Cadder shim directory before real Caddy on the user `PATH`. +3. Run `cadder daemon status` and confirm unavailable-daemon guidance is actionable. +4. Run `cadder daemon start` and confirm the daemon is detached from the launching shell. +5. Run `cadder daemon status` and confirm runtime, storage, and Caddy resolver state. +6. Run `caddy run` from a disposable project and confirm the shim does not recursively execute itself. +7. Open `cadder tui` and confirm it works from a normal user shell. +8. Use the TUI to inspect daemon status, entrypoints, domains, logs, diagnostics, history, IIS handoff state, autostart, and settings. +9. Enable daemon autostart through `cadder autostart set daemon`. +10. Restart the sandbox session or simulate logon where possible, then confirm the daemon autostart target is present and valid. +11. Disable autostart through `cadder autostart set disabled` and confirm the target is removed. +12. If IIS is available in the sandbox image, run a disposable IIS handoff and restore cycle. Confirm Cadder writes metadata before mutation, restores the binding, and does not leave stale handoff state. +13. Run `cadder daemon shutdown` and confirm the daemon exits without killing unrelated Caddy processes. +14. Run uninstall or cleanup and confirm binaries, runtime state, autostart entries, and disposable IIS changes are removed. + +## Pass Criteria + +- All shipped binaries run from the sandbox package without requiring repository sources. +- The package contains exactly the v1.0 runtime files listed above. +- The daemon owns only the real Caddy process it starts. +- The shim never resolves itself as real Caddy. +- Autostart enable and disable are reversible. +- IIS handoff either completes and restores cleanly or reports a clear unsupported/unavailable state. +- Cleanup leaves no Cadder PATH, autostart, runtime, or IIS residue in the sandbox. + +## Evidence To Capture + +Capture the following for release review: + +- `cadder daemon status` before and after daemon startup. +- `cadder diagnostics` after shim registration. +- `cadder logs --limit 50`. +- `cadder history --limit 50`. +- screenshot of `cadder tui` on the connected state. +- autostart query output after enable and after disable. +- IIS handoff/restore output when IIS is part of the pass. +- cleanup script output. diff --git a/openspec/changes/reset-cadder-architecture/design.md b/openspec/changes/reset-cadder-architecture/design.md index 6ad0acb..0585839 100644 --- a/openspec/changes/reset-cadder-architecture/design.md +++ b/openspec/changes/reset-cadder-architecture/design.md @@ -56,12 +56,41 @@ runtime directories, visible labels, and separate locks. This gives normal users predictable behavior while preserving a deliberate path for debugging and integration tests. +### Runtime Locking + +Production daemon startup uses an operating-system file lock for exclusive +ownership and a separate readable lock metadata sidecar for diagnostics. The +lock file is the authority for exclusion. The sidecar records the owning process +id, Cadder version, protocol compatibility range, capability set, runtime +profile, runtime directory, socket name, acquisition time, and executable path. + +Keeping metadata outside the locked file lets clients report the active owner +even on platforms where an exclusive file lock prevents reading the locked byte +range. If the OS lock is free but the sidecar remains, startup treats the +sidecar as stale crash residue, replaces it, and records the recovery in daemon +logs. A clean daemon shutdown removes the sidecar when the lock guard is +released. + ### Local Control Plane Cadder uses a local daemon protocol with typed envelopes, requests, responses, events, and errors. The protocol should tolerate additive compatible changes and report unsupported capabilities explicitly. +Compatibility is capability-first. Daemons, shims, operator clients, and future +surfaces advertise the protocol version they speak and the versioned +capabilities they provide. A version mismatch is diagnostic unless the receiving +side cannot decode the envelope or a required capability/version is missing. +When an older node participates in communication, the newer node should suggest +updating the older participant, but it must not fail an otherwise supported +operation solely because the peer version is older or newer. + +Capabilities are versioned independently from the envelope protocol. Each +capability advertises its current version and the oldest compatible capability +version still supported. This allows Cadder to keep older daemon, shim, and +operator builds working during an intentional support window instead of forcing +the whole local stack to update at once. + Transport is an edge concern. Core protocol types stay transport-neutral. Platform-specific transport security belongs behind a narrow policy layer. @@ -70,9 +99,20 @@ Platform-specific transport security belongs behind a narrow policy layer. Normal operation should run at user privilege. Operations that require higher privilege must be explicit, scoped, and observable from user-level clients. -The implementation must document how user-level clients discover and contact a -more privileged daemon or helper. The security policy must be testable without -requiring real OS mutation in every test. +The daemon publishes local IPC discovery metadata in `cadder-ipc.json` inside +the runtime directory after the listener is bound. The metadata records the +socket name, runtime identity, protocol compatibility range, advertised +capabilities, daemon process id, endpoint privilege status, owning local +principal, and the active security policy version. + +The initial local IPC policy is account-owner based. A client running as the +same local account that owns the runtime may use documented read-only and +state-changing operations, even when the endpoint process is elevated and the +client process is not. A different local account is denied before state-changing +handlers run, and the daemon records a redacted runtime-control log entry for +the denied attempt. Unit tests exercise the policy with fake principals; later +system smoke coverage validates the platform transport and peer-credential +behavior. ### Shim Boundary @@ -113,6 +153,27 @@ setup information, diagnostics, and last-known state where available. State-changing flows must explain that the daemon is unavailable and offer a start/recovery path. +### Workspace Crate Topology + +The Rust workspace keeps a closed set of documented members for the reset: + +- `crates/cadder-daemon`: daemon runtime library. +- `crates/cadderd`: daemon binary. +- `crates/cadder-shim`: PATH-facing `caddy` shim package. +- `crates/cadder`: operator CLI/TUI executable package. +- `crates/cadder-operator`: internal operator service and view-model library. +- `crates/cadder-protocol`: shared protocol/API package. +- `xtask`: repository-specific validation tooling. + +`cadder-operator` remains a separate internal library for this change. It is +not a release-facing product surface; it exists to keep daemon access, daemon +launch policy, state shaping, and operator view models mockable across CLI and +TUI code. A future change may merge it into `cadder` only if the boundary no +longer carries an independently testable responsibility. + +Workspace membership and runtime release packages must be validated against +this topology so undocumented product crates cannot be introduced silently. + ### Logs The daemon owns structured log capture, query, retention, and redaction @@ -155,12 +216,8 @@ do not cover clearly. ## Open Questions - What is the exact CLI/TUI invocation model for the operator executable? -- What local IPC security model should be used for user-level clients talking to - a privileged daemon or helper? - Should privileged platform operations use a narrow helper executable or a privileged daemon mode? - Which exact Caddy command paths belong in each shim policy category? - What file-size/cohesion threshold should validation enforce, and which generated files are exempt? -- Should the existing operator support crate remain separate or become part of - the final operator crate boundary? diff --git a/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md b/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md index 0bf059c..71495d0 100644 --- a/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md +++ b/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md @@ -24,22 +24,55 @@ changes. - **THEN** `cadderd` SHALL process the request even if newer optional fields exist in the current schema - **AND** the response SHALL include capability information needed by the client +#### Scenario: Newer compatible client sends a request +- **WHEN** a newer client sends a request whose envelope can be decoded +- **AND** every required capability and capability version is supported by `cadderd` +- **THEN** `cadderd` SHALL process the request +- **AND** it SHALL NOT reject the request solely because the client's protocol version is newer + +#### Scenario: Older node participates in communication +- **WHEN** one communication participant advertises an older protocol or capability version +- **THEN** the newer participant SHALL suggest updating the older node where useful +- **AND** communication SHALL continue when all required capability versions are covered + #### Scenario: Incompatible client sends a request - **WHEN** a client uses an unsupported protocol or required capability - **THEN** `cadderd` SHALL reject the request with a typed compatibility error - **AND** the error SHALL include upgrade or downgrade guidance where possible +### Requirement: Capabilities are versioned independently +Cadder protocol participants SHALL advertise capabilities with a current +capability version and the oldest compatible capability version still supported. + +#### Scenario: Required capability version is supported +- **WHEN** a client requires a capability version within the advertised compatible range +- **THEN** the receiver SHALL treat the capability as supported +- **AND** it SHALL allow the operation even when the peer protocol version differs + +#### Scenario: Required capability version is unsupported +- **WHEN** a client requires a capability version outside the advertised compatible range +- **THEN** the receiver SHALL reject the operation with a typed unsupported-capability error +- **AND** the error SHALL identify the required capability version and advertised supported versions + ### Requirement: Client access to privileged runtime is explicit -When the daemon or helper runs with higher privilege than the caller, user-level -clients SHALL discover and contact it through a documented local IPC security -policy without requiring the whole client process to run with elevated -privileges. +User-level clients SHALL discover and contact a daemon or helper that runs with +higher privilege than the caller through a documented local IPC security policy +without requiring the whole client process to run with elevated privileges. +The daemon SHALL publish endpoint discovery metadata that includes runtime +identity, socket identity, protocol compatibility, advertised capabilities, +endpoint privilege status, owning principal, and the policy version used for +local authorization. #### Scenario: User client contacts privileged runtime - **WHEN** a same-user non-elevated `cadder` client starts while the runtime endpoint has higher privileges - **THEN** the client SHALL discover the supported endpoint - **AND** the local IPC policy SHALL allow only the documented principals and operations +#### Scenario: Runtime endpoint metadata is published +- **WHEN** `cadderd` binds the local IPC listener for a runtime +- **THEN** it SHALL publish discovery metadata in the runtime directory +- **AND** the metadata SHALL describe the socket name, runtime profile, protocol compatibility range, capabilities, privilege status, owning principal, and security policy version + #### Scenario: Unauthorized user contacts daemon - **WHEN** a different unauthorized user tries to contact the daemon endpoint - **THEN** the IPC layer SHALL deny access before state-changing commands are executed @@ -73,6 +106,11 @@ diagnostics, and stale-lock recovery. - **THEN** `cadderd` SHALL recover or replace the stale lock through a documented process - **AND** it SHALL record the recovery in daemon logs +#### Scenario: Lock owner metadata is readable while lock is active +- **WHEN** a production runtime owns the daemon lock +- **THEN** `cadderd` SHALL publish lock owner metadata outside the locked byte range +- **AND** diagnostics SHALL include the owner process id, Cadder version, protocol compatibility range, runtime profile, runtime directory, socket name, acquisition time, and executable path when available + #### Scenario: Active incompatible runtime - **WHEN** a lock is owned by an active incompatible runtime - **THEN** `cadderd` SHALL refuse to start a competing production runtime diff --git a/openspec/changes/reset-cadder-architecture/tasks.md b/openspec/changes/reset-cadder-architecture/tasks.md index f7d160d..0e8ed4f 100644 --- a/openspec/changes/reset-cadder-architecture/tasks.md +++ b/openspec/changes/reset-cadder-architecture/tasks.md @@ -1,26 +1,26 @@ ## 1. Planning Migration -- [ ] 1.1 Keep `openspec/` initialized and document it as the repository planning source of truth. -- [ ] 1.2 Preserve relevant architecture rationale in OpenSpec proposal, design, and specs. -- [ ] 1.3 Remove obsolete planning artifacts and workflow references from project-facing docs. -- [ ] 1.4 Remove obsolete product-surface assumptions from repo instructions, docs, release metadata, and validation tasks. -- [ ] 1.5 Update `docs/ARCHITECTURE.md` so it follows this OpenSpec change or explicitly points to OpenSpec as authoritative. +- [x] 1.1 Keep `openspec/` initialized and document it as the repository planning source of truth. +- [x] 1.2 Preserve relevant architecture rationale in OpenSpec proposal, design, and specs. +- [x] 1.3 Remove obsolete planning artifacts and workflow references from project-facing docs. +- [x] 1.4 Remove obsolete product-surface assumptions from repo instructions, docs, release metadata, and validation tasks. +- [x] 1.5 Update `docs/ARCHITECTURE.md` so it follows this OpenSpec change or explicitly points to OpenSpec as authoritative. ## 2. Workspace Topology -- [ ] 2.1 Audit every workspace crate and classify it as daemon, shim, operator client, shared protocol/API, test support, docs/tooling, or obsolete. -- [ ] 2.2 Remove, merge, or justify crates that do not map to a current product, library, test, docs, or tooling responsibility. -- [ ] 2.3 Decide whether `cadder-operator` remains as an internal client library or is renamed/merged into the final `cadder` boundary. -- [ ] 2.4 Fix workspace membership and dependency warnings after topology cleanup. -- [ ] 2.5 Add validation that prevents reintroducing undocumented product crates. +- [x] 2.1 Audit every workspace crate and classify it as daemon, shim, operator client, shared protocol/API, test support, docs/tooling, or obsolete. +- [x] 2.2 Remove, merge, or justify crates that do not map to a current product, library, test, docs, or tooling responsibility. +- [x] 2.3 Decide whether `cadder-operator` remains as an internal client library or is renamed/merged into the final `cadder` boundary. +- [x] 2.4 Fix workspace membership and dependency warnings after topology cleanup. +- [x] 2.5 Add validation that prevents reintroducing undocumented product crates. ## 3. Protocol And Daemon -- [ ] 3.1 Split protocol code into envelopes, commands, responses, events, errors, log queries, and client traits. -- [ ] 3.2 Replace hard protocol equality checks with additive compatibility and typed unsupported-capability errors. -- [ ] 3.3 Extract daemon state concerns into focused modules for registrations, config composition, process lifecycle, runtime status, logs, and storage. -- [ ] 3.4 Define and implement production runtime locking with stale-lock recovery. -- [ ] 3.5 Define local IPC discovery and security for user clients contacting privileged runtime endpoints. +- [x] 3.1 Split protocol code into envelopes, commands, responses, events, errors, log queries, and client traits. +- [x] 3.2 Replace hard protocol equality checks with additive compatibility and typed unsupported-capability errors. +- [x] 3.3 Extract daemon state concerns into focused modules for registrations, config composition, process lifecycle, runtime status, logs, and storage. +- [x] 3.4 Define and implement production runtime locking with stale-lock recovery. +- [x] 3.5 Define local IPC discovery and security for user clients contacting privileged runtime endpoints. - [ ] 3.6 Add daemon contract tests with fake Caddy, fake storage, fake IPC clients, and fake IIS providers. ## 4. Shim And Caddy Integration diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 146f405..32e8fac 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,8 +7,13 @@ repository.workspace = true [dependencies] anyhow = "1.0.102" +clap = "=4.6.1" flate2 = "=1.1.9" hex = "=0.4.3" +serde_json = "=1.0.150" sha2 = "=0.11.0" tar = "=0.4.46" +tempfile = "=3.27.0" +tokio = { version = "=1.52.3", features = ["full"] } +toml = "=1.1.2" zip = { version = "=8.6.0", default-features = false, features = ["deflate-flate2"] } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index d6362cc..9bace6f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,7 +1,11 @@ use anyhow::{Context, Result, bail}; -use flate2::{Compression, write::GzEncoder}; +use flate2::{Compression, read::GzDecoder, write::GzEncoder}; +use serde_json::{Value as JsonValue, json}; use sha2::{Digest, Sha256}; +#[cfg(windows)] +use std::os::windows::process::CommandExt; use std::{ + collections::{BTreeMap, BTreeSet}, env, fs, fs::File, io::{self, Read}, @@ -9,12 +13,189 @@ use std::{ process::{Command, Stdio}, }; use tar::Builder; +use tempfile::tempdir; +use toml::{Table, Value}; use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions}; -const COVERAGE_FAIL_UNDER_LINES: &str = "85"; -const DEFAULT_COVERAGE_REPORT_PATH: &str = "target/llvm-cov/coverage-summary.json"; +const COVERAGE_FAIL_UNDER_LINES: f64 = 85.0; +const COVERAGE_EXCLUDED_PACKAGES: [&str; 0] = []; +const COVERAGE_IGNORED_FILENAME_REGEX: &str = ""; +const CADDER_RUNTIME_PROFILE_ENV: &str = "CADDER_RUNTIME_PROFILE"; +const CADDER_DEV_WORKSPACE_ENV: &str = "CADDER_DEV_WORKSPACE"; +const CADDER_CADDY_BACKEND_ENV: &str = "CADDER_CADDY_BACKEND"; +const DOCS_DOWNLOAD_SCRIPT: &str = "docs/site/public/cadder-downloads.js"; +const DOCS_WEBMANIFEST: &str = "docs/site/public/site.webmanifest"; +const DEFAULT_COVERAGE_REPORT_PATH: &str = "target/llvm-cov/coverage-summary.lcov"; +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; +const RUNTIME_INSTALLER_IDENTIFIER: &str = "dev.maxie.cadder.runtime"; +const RUNTIME_INSTALLER_PACKAGE_NAME: &str = "cadder-runtime"; +const RUNTIME_INSTALLER_PRODUCT_NAME: &str = "Cadder Runtime"; +const RUNTIME_INSTALLER_MANUFACTURER: &str = "Maxie"; +const WINDOWS_RUNTIME_INSTALLER_UPGRADE_CODE: &str = "7D7829AF-3F2B-4D5B-B758-1CFF0B31E12D"; +const WINDOWS_SIGNTOOL_CERT_PATH_ENV: &str = "CADDER_WINDOWS_SIGNTOOL_CERT_PATH"; +const WINDOWS_SIGNTOOL_CERT_PASSWORD_ENV: &str = "CADDER_WINDOWS_SIGNTOOL_CERT_PASSWORD"; +const WINDOWS_SIGNTOOL_TIMESTAMP_URL_ENV: &str = "CADDER_WINDOWS_SIGNTOOL_TIMESTAMP_URL"; +const MACOS_INSTALLER_SIGNING_IDENTITY_ENV: &str = "CADDER_MACOS_INSTALLER_SIGNING_IDENTITY"; +const SIGNING_READY_ENV_VARS: [&str; 2] = [ + "CADDER_WINDOWS_SIGNING_READY", + "CADDER_MACOS_NOTARIZATION_READY", +]; +const CADDER_COVERAGE_TOOLCHAIN_ENV: &str = "CADDER_COVERAGE_TOOLCHAIN"; const WINDOWS_COVERAGE_TOOLCHAIN: &str = "stable-x86_64-pc-windows-msvc"; const WORKSPACE_MANIFEST: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../Cargo.toml"); +const DOCS_SITE_DIR: &str = "docs/site"; +const RUNTIME_PORTABLE_BINARIES: [&str; 3] = ["cadderd", "cadder", "caddy"]; +const RUNTIME_RELEASE_PACKAGES: [&str; 3] = ["cadderd", "cadder", "cadder-shim"]; +const WORKSPACE_MEMBER_CONTRACTS: [WorkspaceMemberContract; 7] = [ + WorkspaceMemberContract::new( + "crates/cadder-daemon", + "cadder-daemon", + WorkspaceMemberClassification::Daemon, + false, + ), + WorkspaceMemberContract::new( + "crates/cadderd", + "cadderd", + WorkspaceMemberClassification::Daemon, + true, + ), + WorkspaceMemberContract::new( + "crates/cadder-shim", + "cadder-shim", + WorkspaceMemberClassification::Shim, + true, + ), + WorkspaceMemberContract::new( + "crates/cadder", + "cadder", + WorkspaceMemberClassification::OperatorClient, + true, + ), + WorkspaceMemberContract::new( + "crates/cadder-operator", + "cadder-operator", + WorkspaceMemberClassification::OperatorClient, + false, + ), + WorkspaceMemberContract::new( + "crates/cadder-protocol", + "cadder-protocol", + WorkspaceMemberClassification::SharedProtocolApi, + false, + ), + WorkspaceMemberContract::new( + "xtask", + "xtask", + WorkspaceMemberClassification::DocsTooling, + false, + ), +]; +const RELEASE_PROFILE_SETTINGS: [ProfileSetting; 6] = [ + ProfileSetting::new("release", "opt-level", ExpectedTomlValue::String("s")), + ProfileSetting::new("release", "lto", ExpectedTomlValue::String("thin")), + ProfileSetting::new("release", "codegen-units", ExpectedTomlValue::Integer(1)), + ProfileSetting::new("release", "debug", ExpectedTomlValue::Boolean(false)), + ProfileSetting::new("release", "strip", ExpectedTomlValue::String("symbols")), + ProfileSetting::new("release", "panic", ExpectedTomlValue::String("unwind")), +]; +const PROFILING_PROFILE_SETTINGS: [ProfileSetting; 3] = [ + ProfileSetting::new( + "profiling", + "inherits", + ExpectedTomlValue::String("release"), + ), + ProfileSetting::new("profiling", "debug", ExpectedTomlValue::Boolean(true)), + ProfileSetting::new("profiling", "strip", ExpectedTomlValue::String("none")), +]; +const REQUIRED_DOWNLOAD_METADATA_SNIPPETS: [(&str, &str); 13] = [ + ("runtime pattern group", "const cadderRuntimeAssetPatterns"), + ( + "runtime Windows archive", + r#"'windows-x64': /^cadder-v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?-windows-x64\.zip$/"#, + ), + ( + "runtime macOS Apple Silicon archive", + r#"'macos-arm64': /^cadder-v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?-macos-arm64\.tar\.gz$/"#, + ), + ( + "runtime macOS Intel archive", + r#"'macos-x64': /^cadder-v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?-macos-x64\.tar\.gz$/"#, + ), + ( + "runtime Linux archive", + r#"'linux-x64': /^cadder-v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?-linux-x64\.tar\.gz$/"#, + ), + ( + "runtime installer pattern group", + "const cadderRuntimeInstallerAssetPatterns", + ), + ( + "runtime installer Windows MSI", + r#"'windows-x64-msi': /^cadder-runtime-.+-windows-x64\.msi$/"#, + ), + ( + "runtime installer macOS Apple Silicon PKG", + r#"'macos-arm64-pkg': /^cadder-runtime-.+-macos-arm64\.pkg$/"#, + ), + ( + "runtime installer macOS Intel PKG", + r#"'macos-x64-pkg': /^cadder-runtime-.+-macos-x64\.pkg$/"#, + ), + ( + "runtime installer Linux DEB", + r#"'linux-x64-deb': /^cadder-runtime-.+-linux-x64\.deb$/"#, + ), + ( + "runtime installer Linux RPM", + r#"'linux-x64-rpm': /^cadder-runtime-.+-linux-x64\.rpm$/"#, + ), + ("runtime download attribute", "data-cadder-asset"), + ( + "runtime installer download attribute", + "data-cadder-runtime-installer-asset", + ), +]; +const REQUIRED_VISUAL_ASSETS: [(&str, &str); 13] = [ + ("canonical banner", "assets/banner.webp"), + ("canonical logo", "assets/logo.png"), + ("canonical icon logo", "assets/logo-icon.png"), + ("canonical white logo", "assets/logo-white.png"), + ( + "docs banner pipeline copy", + "docs/site/src/assets/banner.webp", + ), + ("docs logo pipeline copy", "docs/site/src/assets/logo.png"), + ("docs favicon ico", "docs/site/public/favicon.ico"), + ("docs favicon 16", "docs/site/public/favicon-16x16.png"), + ("docs favicon 32", "docs/site/public/favicon-32x32.png"), + ( + "docs android chrome 192", + "docs/site/public/android-chrome-192x192.png", + ), + ( + "docs android chrome 512", + "docs/site/public/android-chrome-512x512.png", + ), + ( + "docs apple touch icon", + "docs/site/public/apple-touch-icon.png", + ), + ("docs web manifest", DOCS_WEBMANIFEST), +]; +const CANONICAL_ASSET_COPIES: [(&str, &str, &str); 2] = [ + ( + "docs banner pipeline copy", + "assets/banner.webp", + "docs/site/src/assets/banner.webp", + ), + ( + "docs logo pipeline copy", + "assets/logo.png", + "docs/site/src/assets/logo.png", + ), +]; +const OBSOLETE_SCAFFOLD_ASSETS: [(&str, &str); 0] = []; const SAMPLE_CADDER_TOML: &str = r#"# Cadder portable configuration. # Uncomment and set this when the real Caddy binary is not the first safe `caddy` on PATH. # @@ -22,554 +203,3110 @@ const SAMPLE_CADDER_TOML: &str = r#"# Cadder portable configuration. # real_command = "/absolute/path/to/caddy" "#; -fn main() -> Result<()> { - let mut args = env::args().skip(1); - let command = args.next().unwrap_or_else(|| "check".to_string()); - match command.as_str() { - "check" => check(), - "coverage" => coverage(CoverageOptions::parse(args.collect())?), - "dist" => dist(DistOptions::parse(args.collect())?), - "verify-dist" => verify_dist(&VerifyDistOptions::parse(args.collect())?), - "package" => package(PackageOptions::parse(args.collect())?), - other => bail!("unknown xtask command `{other}`"), +#[derive(Debug, Clone, Copy)] +struct ProfileSetting { + profile: &'static str, + key: &'static str, + expected: ExpectedTomlValue, +} + +impl ProfileSetting { + const fn new(profile: &'static str, key: &'static str, expected: ExpectedTomlValue) -> Self { + Self { + profile, + key, + expected, + } } } -fn check() -> Result<()> { - run("cargo", &["fmt", "--check"])?; - run( - "cargo", - &[ - "clippy", - "--workspace", - "--all-targets", - "--", - "-D", - "warnings", - ], - )?; - run("cargo", &["test", "--workspace"])?; - Ok(()) +#[derive(Debug, Clone, Copy)] +enum ExpectedTomlValue { + Boolean(bool), + Integer(i64), + String(&'static str), } -fn coverage(options: CoverageOptions) -> Result<()> { - ensure_parent_dir(&options.output_path)?; - let args = coverage_command_args(&options.output_path)?; - let arg_refs = args.iter().map(String::as_str).collect::>(); - run("cargo", &arg_refs) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct WorkspaceMemberContract { + path: &'static str, + package: &'static str, + classification: WorkspaceMemberClassification, + runtime_release_package: bool, } -fn dist(options: DistOptions) -> Result<()> { - build_release_binaries(options.target.as_deref())?; +impl WorkspaceMemberContract { + const fn new( + path: &'static str, + package: &'static str, + classification: WorkspaceMemberClassification, + runtime_release_package: bool, + ) -> Self { + Self { + path, + package, + classification, + runtime_release_package, + } + } +} - fs::create_dir_all(&options.out_dir) - .with_context(|| format!("create {}", options.out_dir.display()))?; - for binary in portable_binaries() { - let source = release_binary_path(binary, options.target.as_deref()); - let target = options - .out_dir - .join(exe_name(binary, options.target.as_deref())); - fs::copy(&source, &target) - .with_context(|| format!("copy {} to {}", source.display(), target.display()))?; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorkspaceMemberClassification { + Daemon, + Shim, + OperatorClient, + SharedProtocolApi, + DocsTooling, +} + +impl WorkspaceMemberClassification { + fn label(self) -> &'static str { + match self { + Self::Daemon => "daemon", + Self::Shim => "shim", + Self::OperatorClient => "operator client", + Self::SharedProtocolApi => "shared protocol/API", + Self::DocsTooling => "docs/tooling", + } } - fs::write(options.out_dir.join("cadder.toml"), SAMPLE_CADDER_TOML) - .with_context(|| format!("write {}", options.out_dir.join("cadder.toml").display()))?; +} - verify_dist(&VerifyDistOptions { - dir: options.out_dir, - target: options.target, - }) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PortableTopology { + Runtime, } -fn verify_dist(options: &VerifyDistOptions) -> Result<()> { - for binary in portable_binaries() { - let path = options - .dir - .join(exe_name(binary, options.target.as_deref())); - if !path.is_file() { - bail!("portable binary missing: {}", path.display()); +impl PortableTopology { + const DEFAULT: Self = Self::Runtime; + + fn parse(value: &str) -> Result { + match value { + "runtime" => Ok(Self::Runtime), + other => bail!("unknown portable topology `{other}`; expected runtime"), } } - let config = options.dir.join("cadder.toml"); - if !config.is_file() { - bail!( - "portable sample configuration missing: {}", - config.display() - ); + fn parse_option(args: &[String]) -> Result { + optional_string_option(args, "--topology")? + .map_or(Ok(Self::DEFAULT), |value| Self::parse(&value)) } - let shim = options - .dir - .join(exe_name("caddy", options.target.as_deref())); - let output = Command::new(&shim) - .arg("--cadder-shim-info") - .stdin(Stdio::null()) - .output() - .with_context(|| format!("run {}", shim.display()))?; - if !output.status.success() { - bail!("caddy --cadder-shim-info failed with {}", output.status); + fn name(self) -> &'static str { + match self { + Self::Runtime => "runtime", + } } - let stdout = String::from_utf8_lossy(&output.stdout); - if !stdout.contains("\"role\":\"caddy-shim\"") && !stdout.contains("\"role\": \"caddy-shim\"") { - bail!("caddy --cadder-shim-info did not report the Cadder shim role"); + + fn binaries(self) -> &'static [&'static str] { + match self { + Self::Runtime => &RUNTIME_PORTABLE_BINARIES, + } } - let ctl = options - .dir - .join(exe_name("cadderctl", options.target.as_deref())); - let ctl_output = Command::new(&ctl) - .arg("--help") - .stdin(Stdio::null()) - .output() - .with_context(|| format!("run {}", ctl.display()))?; - if !ctl_output.status.success() { - bail!("cadderctl --help failed with {}", ctl_output.status); + fn release_packages(self) -> &'static [&'static str] { + match self { + Self::Runtime => &RUNTIME_RELEASE_PACKAGES, + } } - let mcp = options - .dir - .join(exe_name("cadder-mcp", options.target.as_deref())); - let mcp_output = Command::new(&mcp) - .arg("--help") - .stdin(Stdio::null()) - .output() - .with_context(|| format!("run {}", mcp.display()))?; - if !mcp_output.status.success() { - bail!("cadder-mcp --help failed with {}", mcp_output.status); + fn includes_runtime(self) -> bool { + true } - Ok(()) + fn package_archive_stem(self, version: &str, platform: &str) -> Result { + match self { + Self::Runtime => Ok(format!("cadder-{version}-{platform}")), + } + } } -fn package(options: PackageOptions) -> Result<()> { - package_with_dist(options, dist) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReleasePlatform { + WindowsX64, + LinuxX64, + MacosX64, + MacosArm64, } -fn package_with_dist( - options: PackageOptions, - create_dist: impl FnOnce(DistOptions) -> Result<()>, -) -> Result<()> { - let archive_stem = format!("cadder-{}-{}", options.version, options.platform); - let layout_parent = options.out_dir.join("layouts"); - let layout_dir = layout_parent.join(&archive_stem); - if layout_dir.exists() { - fs::remove_dir_all(&layout_dir).with_context(|| format!("remove {}", layout_dir.display()))?; +impl ReleasePlatform { + const ALL: [Self; 4] = [ + Self::WindowsX64, + Self::LinuxX64, + Self::MacosX64, + Self::MacosArm64, + ]; + + fn parse(value: &str) -> Result { + match value { + "windows-x64" => Ok(Self::WindowsX64), + "linux-x64" => Ok(Self::LinuxX64), + "macos-x64" => Ok(Self::MacosX64), + "macos-arm64" => Ok(Self::MacosArm64), + other => bail!( + "unknown release platform `{other}`; expected windows-x64, linux-x64, macos-x64, or macos-arm64" + ), + } } - create_dist(DistOptions { - out_dir: layout_dir, - target: options.target, - })?; + fn parse_option(args: &[String], target: Option<&str>) -> Result { + optional_string_option(args, "--platform")?.map_or_else( + || Self::infer_from_target(target), + |platform| Self::parse(&platform), + ) + } - fs::create_dir_all(&options.out_dir) - .with_context(|| format!("create {}", options.out_dir.display()))?; - let archive_path = options.out_dir.join(format!( - "{archive_stem}.{}", - archive_extension(&options.platform) - )); - if archive_path.exists() { - fs::remove_file(&archive_path).with_context(|| format!("remove {}", archive_path.display()))?; + fn infer_from_target(target: Option<&str>) -> Result { + if let Some(target) = target { + if target == "x86_64-pc-windows-msvc" { + return Ok(Self::WindowsX64); + } + if target == "x86_64-unknown-linux-gnu" { + return Ok(Self::LinuxX64); + } + if target == "x86_64-apple-darwin" { + return Ok(Self::MacosX64); + } + if target == "aarch64-apple-darwin" { + return Ok(Self::MacosArm64); + } + bail!("cannot infer release platform from target `{target}`; pass --platform explicitly"); + } + + if cfg!(target_os = "windows") { + Ok(Self::WindowsX64) + } else if cfg!(target_os = "linux") { + Ok(Self::LinuxX64) + } else if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") { + Ok(Self::MacosArm64) + } else if cfg!(target_os = "macos") { + Ok(Self::MacosX64) + } else { + bail!("cannot infer release platform for this host; pass --platform explicitly") + } } - match archive_kind(&options.platform) { - ArchiveKind::Zip => write_zip_archive(&layout_parent, &archive_stem, &archive_path)?, - ArchiveKind::TarGz => write_tar_gz_archive(&layout_parent, &archive_stem, &archive_path)?, + fn name(self) -> &'static str { + match self { + Self::WindowsX64 => "windows-x64", + Self::LinuxX64 => "linux-x64", + Self::MacosX64 => "macos-x64", + Self::MacosArm64 => "macos-arm64", + } } - let checksum_path = options.out_dir.join(format!( - "{}.sha256", - archive_path - .file_name() - .and_then(|name| name.to_str()) - .context("archive path has no UTF-8 file name")? - )); - write_sha256_file(&archive_path, &checksum_path)?; + fn portable_archive_extension(self) -> &'static str { + match self { + Self::WindowsX64 => "zip", + Self::LinuxX64 | Self::MacosX64 | Self::MacosArm64 => "tar.gz", + } + } - println!("created {}", archive_path.display()); - println!("created {}", checksum_path.display()); - Ok(()) + fn uses_windows_executables(self) -> bool { + matches!(self, Self::WindowsX64) + } + + fn debian_architecture(self) -> Result<&'static str> { + match self { + Self::LinuxX64 => Ok("amd64"), + _ => bail!( + "Debian runtime installer metadata is not defined for {}", + self.name() + ), + } + } + + fn rpm_architecture(self) -> Result<&'static str> { + match self { + Self::LinuxX64 => Ok("x86_64"), + _ => bail!( + "RPM runtime installer metadata is not defined for {}", + self.name() + ), + } + } + + fn required_runtime_installer_artifact_kinds(self) -> &'static [RuntimeInstallerArtifactKind] { + match self { + Self::WindowsX64 => &[RuntimeInstallerArtifactKind::WindowsMsi], + Self::LinuxX64 => &[ + RuntimeInstallerArtifactKind::LinuxDeb, + RuntimeInstallerArtifactKind::LinuxRpm, + ], + Self::MacosX64 | Self::MacosArm64 => &[RuntimeInstallerArtifactKind::MacosPkg], + } + } } -fn portable_binaries() -> [&'static str; 5] { - ["cadderd", "cadderctl", "cadder-mcp", "cadder-tui", "caddy"] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RuntimeInstallerArtifactKind { + WindowsMsi, + LinuxDeb, + LinuxRpm, + MacosPkg, } -fn release_binary_path(name: &str, target: Option<&str>) -> PathBuf { - let release_dir = match target { - Some(target) => PathBuf::from("target").join(target).join("release"), - None => PathBuf::from("target").join("release"), - }; - release_dir.join(exe_name(name, target)) +impl RuntimeInstallerArtifactKind { + fn label(self) -> &'static str { + match self { + Self::WindowsMsi => "Windows MSI runtime installer", + Self::LinuxDeb => "Linux deb runtime package", + Self::LinuxRpm => "Linux rpm runtime package", + Self::MacosPkg => "macOS pkg runtime installer", + } + } + + fn extension(self) -> &'static str { + match self { + Self::WindowsMsi => "msi", + Self::LinuxDeb => "deb", + Self::LinuxRpm => "rpm", + Self::MacosPkg => "pkg", + } + } } -fn exe_name(name: &str, target: Option<&str>) -> String { - if target_uses_windows_executables(target) { - format!("{name}.exe") - } else { - name.to_string() +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SigningMode { + UnsignedDryRun, + SignedRelease, +} + +impl SigningMode { + fn parse(args: &[String]) -> Self { + if has_flag(args, "--sign") { + Self::SignedRelease + } else { + Self::UnsignedDryRun + } } } -fn target_uses_windows_executables(target: Option<&str>) -> bool { - target.map_or(cfg!(windows), |target| target.contains("windows")) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReleaseAssetMode { + DryRun, + Publish, } -fn build_release_binaries(target: Option<&str>) -> Result<()> { - let mut args = vec![ - "build", - "--release", - "-p", - "cadderd", - "-p", - "cadderctl", - "-p", - "cadder-mcp", - "-p", - "cadder-tui", - "-p", - "cadder-shim", - ]; - if let Some(target) = target { - args.push("--target"); - args.push(target); +impl ReleaseAssetMode { + fn parse(args: &[String]) -> Result { + optional_string_option(args, "--mode")?.map_or(Ok(Self::DryRun), |mode| match mode.as_str() { + "dry-run" => Ok(Self::DryRun), + "publish" => Ok(Self::Publish), + other => bail!("unknown release asset mode `{other}`; expected dry-run or publish"), + }) } - run("cargo", &args) } -fn ensure_parent_dir(path: &Path) -> Result<()> { - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum XtaskCommandGroup { + Validation, + Documentation, + Distribution, + Verification, + Development, +} + +impl XtaskCommandGroup { + fn label(self) -> &'static str { + match self { + Self::Validation => "validation", + Self::Documentation => "documentation", + Self::Distribution => "release and packaging", + Self::Verification => "verification", + Self::Development => "development", + } } - Ok(()) } -fn coverage_command_args(output_path: &Path) -> Result> { - let output_path = output_path.to_str().with_context(|| { - format!( - "coverage output path is not UTF-8: {}", - output_path.display() - ) - })?; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct XtaskCommand { + name: &'static str, + usage: &'static str, + summary: &'static str, + group: XtaskCommandGroup, +} - // No project-specific coverage exclusions are applied here. Future exclusions must be limited - // to generated, platform-gated, or intentionally untestable code and documented beside this gate. - let mut args = Vec::new(); - if cfg!(windows) { - args.push(format!("+{WINDOWS_COVERAGE_TOOLCHAIN}")); +const XTASK_COMMANDS: &[XtaskCommand] = &[ + XtaskCommand { + name: "check", + usage: "check", + summary: "Run release profile, identity, Rust, docs, and release validation.", + group: XtaskCommandGroup::Validation, + }, + XtaskCommand { + name: "coverage", + usage: "coverage [--output ]", + summary: "Run cargo-llvm-cov for required crates and enforce the line coverage threshold.", + group: XtaskCommandGroup::Validation, + }, + XtaskCommand { + name: "docs-check", + usage: "docs-check", + summary: "Install docs dependencies and run the Astro type/content check.", + group: XtaskCommandGroup::Documentation, + }, + XtaskCommand { + name: "docs-build", + usage: "docs-build", + summary: "Install docs dependencies and build the Starlight documentation site.", + group: XtaskCommandGroup::Documentation, + }, + XtaskCommand { + name: "dist", + usage: "dist --out [--target ]", + summary: "Build a local portable runtime layout and verify it.", + group: XtaskCommandGroup::Distribution, + }, + XtaskCommand { + name: "package", + usage: "package --out --platform [--version ] [--target ]", + summary: "Build a versioned portable release archive plus checksum.", + group: XtaskCommandGroup::Distribution, + }, + XtaskCommand { + name: "runtime-installer", + usage: "runtime-installer --out [--version ] [--platform ] [--target ] [--sign]", + summary: "Build daemon-first native runtime installer artifacts.", + group: XtaskCommandGroup::Distribution, + }, + XtaskCommand { + name: "verify-dist", + usage: "verify-dist --dir [--target ]", + summary: "Verify a portable layout's files, binary metadata, shim info, and size summary.", + group: XtaskCommandGroup::Verification, + }, + XtaskCommand { + name: "verify-runtime-installer-dist", + usage: "verify-runtime-installer-dist --dir [--version ] [--platform ] [--target ]", + summary: "Verify native runtime installer artifacts, manifests, and checksums.", + group: XtaskCommandGroup::Verification, + }, + XtaskCommand { + name: "verify-assets", + usage: "verify-assets", + summary: "Verify checked-in visual asset mapping and required docs paths.", + group: XtaskCommandGroup::Verification, + }, + XtaskCommand { + name: "verify-release-assets", + usage: "verify-release-assets --dir [--version ] [--mode dry-run|publish]", + summary: "Verify the full public release artifact matrix before upload.", + group: XtaskCommandGroup::Verification, + }, + XtaskCommand { + name: "verify-release-profile", + usage: "verify-release-profile", + summary: "Verify root Cargo release/profile policy.", + group: XtaskCommandGroup::Verification, + }, + XtaskCommand { + name: "verify-release-identity", + usage: "verify-release-identity", + summary: "Verify synchronized version and download metadata.", + group: XtaskCommandGroup::Verification, + }, + XtaskCommand { + name: "verify-workspace-topology", + usage: "verify-workspace-topology", + summary: "Verify workspace members and release packages match the documented topology.", + group: XtaskCommandGroup::Verification, + }, + XtaskCommand { + name: "dev-env", + usage: "dev-env [--format powershell|bash|cmd|json]", + summary: "Print the repeatable dev runtime environment.", + group: XtaskCommandGroup::Development, + }, + XtaskCommand { + name: "dev-run", + usage: "dev-run -- [args...]", + summary: "Run a command inside the repeatable dev runtime environment.", + group: XtaskCommandGroup::Development, + }, +]; + +fn handle_global_help(args: &[String]) -> Result { + let Some(first) = args.first().map(String::as_str) else { + return Ok(false); + }; + + match first { + "-h" | "--help" => { + print!("{}", xtask_help_text()); + Ok(true) + } + "list" => { + print!("{}", xtask_command_list_text()); + Ok(true) + } + "help" => { + if let Some(command) = args.get(1) { + print!("{}", xtask_command_help_text(command)?); + } else { + print!("{}", xtask_help_text()); + } + Ok(true) + } + _ => Ok(false), } - args.extend([ - "llvm-cov".to_string(), - "--workspace".to_string(), - "--json".to_string(), - "--summary-only".to_string(), - "--fail-under-lines".to_string(), - COVERAGE_FAIL_UNDER_LINES.to_string(), - "--output-path".to_string(), - output_path.to_string(), - ]); - Ok(args) } -fn archive_kind(platform: &str) -> ArchiveKind { - if platform.starts_with("windows") { - ArchiveKind::Zip - } else { - ArchiveKind::TarGz +fn xtask_help_text() -> String { + let mut output = String::from( + "Cadder repository task runner\n\n\ +Usage:\n\ + cargo xtask [args...]\n\ + cargo run -p xtask -- [args...]\n\n\ +Default command:\n\ + check\n\n\ +Commands:\n", + ); + + let groups = [ + XtaskCommandGroup::Validation, + XtaskCommandGroup::Documentation, + XtaskCommandGroup::Distribution, + XtaskCommandGroup::Verification, + XtaskCommandGroup::Development, + ]; + for group in groups { + output.push_str(&format!(" {}:\n", group.label())); + for command in XTASK_COMMANDS + .iter() + .filter(|command| command.group == group) + { + output.push_str(&format!(" {:<31} {}\n", command.name, command.summary)); + } } + + output.push_str("\nRun `cargo xtask help ` for command usage.\n"); + output } -fn archive_extension(platform: &str) -> &'static str { - match archive_kind(platform) { - ArchiveKind::Zip => "zip", - ArchiveKind::TarGz => "tar.gz", +fn xtask_command_list_text() -> String { + let mut output = String::new(); + for command in XTASK_COMMANDS { + output.push_str(command.name); + output.push('\n'); } + output } -fn write_zip_archive(layout_parent: &Path, root_dir: &str, archive_path: &Path) -> Result<()> { - let file = - File::create(archive_path).with_context(|| format!("create {}", archive_path.display()))?; - let mut zip = ZipWriter::new(file); - let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); +fn xtask_command_help_text(command_name: &str) -> Result { + let command = XTASK_COMMANDS + .iter() + .find(|command| command.name == command_name) + .ok_or_else(|| anyhow::anyhow!("unknown xtask command `{command_name}`"))?; - append_zip_dir( - &mut zip, - &layout_parent.join(root_dir), - Path::new(root_dir), - options, - )?; - zip - .finish() - .with_context(|| format!("finish {}", archive_path.display()))?; - Ok(()) + Ok(format!( + "{}\n\nUsage:\n cargo xtask {}\n\n{}\n", + command.name, command.usage, command.summary + )) } -fn append_zip_dir( - zip: &mut ZipWriter, - dir: &Path, - archive_dir: &Path, - options: SimpleFileOptions, -) -> Result<()> { - zip - .add_directory(path_to_archive_name(archive_dir)?, options) - .with_context(|| format!("add ZIP directory {}", archive_dir.display()))?; +fn main() -> Result<()> { + let mut args = env::args().skip(1).collect::>(); + if handle_global_help(&args)? { + return Ok(()); + } - for entry in sorted_dir_entries(dir)? { - let entry_path = entry.path(); - let archive_path = archive_dir.join(entry.file_name()); - if entry_path.is_dir() { - append_zip_dir(zip, &entry_path, &archive_path, options)?; - } else { - zip - .start_file(path_to_archive_name(&archive_path)?, options) - .with_context(|| format!("add ZIP file {}", archive_path.display()))?; - let mut source = - File::open(&entry_path).with_context(|| format!("open {}", entry_path.display()))?; - io::copy(&mut source, zip) - .with_context(|| format!("write ZIP file {}", archive_path.display()))?; + let command = if args.is_empty() { + "check".to_string() + } else { + args.remove(0) + }; + match command.as_str() { + "check" => check(), + "coverage" => coverage(CoverageOptions::parse(args)?), + "docs-check" => docs_check(), + "docs-build" => docs_build(), + "dev-env" => dev_env_command(args), + "dev-run" => dev_run(args), + "dist" => dist(DistOptions::parse(args)?), + "verify-dist" => verify_dist(&VerifyDistOptions::parse(args)?), + "verify-assets" => verify_assets(), + "verify-release-assets" => verify_release_assets(&ReleaseAssetsOptions::parse(args)?), + "verify-release-identity" => verify_release_identity(), + "verify-release-profile" => verify_release_profile(), + "verify-workspace-topology" => verify_workspace_topology(), + "verify-runtime-installer-dist" => { + verify_runtime_installer_dist(&VerifyRuntimeInstallerDistOptions::parse(args)?) } + "package" => package(PackageOptions::parse(args)?), + "runtime-installer" => runtime_installer(RuntimeInstallerOptions::parse(args)?), + other => bail!("unknown xtask command `{other}`; run `cargo xtask --help` for commands"), } - - Ok(()) } -fn write_tar_gz_archive(layout_parent: &Path, root_dir: &str, archive_path: &Path) -> Result<()> { - let file = - File::create(archive_path).with_context(|| format!("create {}", archive_path.display()))?; - let encoder = GzEncoder::new(file, Compression::default()); - let mut tar = Builder::new(encoder); - tar - .append_dir_all(root_dir, layout_parent.join(root_dir)) - .with_context(|| { - format!( - "write TAR layout {}", - layout_parent.join(root_dir).display() - ) - })?; - tar - .into_inner() - .context("finish TAR stream")? - .finish() - .with_context(|| format!("finish {}", archive_path.display()))?; +fn check() -> Result<()> { + verify_workspace_topology()?; + verify_release_profile()?; + verify_release_identity()?; + run("cargo", &["fmt", "--check"])?; + run( + "cargo", + &[ + "clippy", + "--workspace", + "--all-targets", + "--", + "-D", + "warnings", + ], + )?; + run("cargo", &workspace_test_args())?; + docs_check()?; Ok(()) } -fn write_sha256_file(archive_path: &Path, checksum_path: &Path) -> Result<()> { - let mut file = - File::open(archive_path).with_context(|| format!("open {}", archive_path.display()))?; - let mut hasher = Sha256::new(); - let mut buffer = [0; 64 * 1024]; - loop { - let bytes_read = file - .read(&mut buffer) - .with_context(|| format!("read {}", archive_path.display()))?; - if bytes_read == 0 { - break; - } - hasher.update(&buffer[..bytes_read]); - } +fn docs_check() -> Result<()> { + run_docs_script("check") +} - let file_name = archive_path - .file_name() - .and_then(|name| name.to_str()) - .context("archive path has no UTF-8 file name")?; - let checksum = hex::encode(hasher.finalize()); - fs::write(checksum_path, format!("{checksum} {file_name}\n")) - .with_context(|| format!("write {}", checksum_path.display()))?; - Ok(()) +fn docs_build() -> Result<()> { + run_docs_script("build") } -fn path_to_archive_name(path: &Path) -> Result { - let mut parts = Vec::new(); - for component in path.components() { - let component = component.as_os_str().to_str().with_context(|| { - format!( - "archive path contains non-UTF-8 component: {}", - path.display() - ) - })?; - parts.push(component); - } - Ok(parts.join("/")) +fn run_docs_script(script: &str) -> Result<()> { + let docs_dir = docs_site_dir(); + run_in("bun", &["install", "--frozen-lockfile"], &docs_dir)?; + run_in("bun", &["run", script], &docs_dir) } -fn sorted_dir_entries(dir: &Path) -> Result> { - let mut entries = fs::read_dir(dir) - .with_context(|| format!("read {}", dir.display()))? - .collect::, _>>() - .with_context(|| format!("read entries from {}", dir.display()))?; - entries.sort_by_key(|entry| entry.file_name()); - Ok(entries) +fn coverage(options: CoverageOptions) -> Result<()> { + ensure_parent_dir(&options.output_path)?; + let args = coverage_command_args(&options.output_path)?; + let arg_refs = args.iter().map(String::as_str).collect::>(); + run("cargo", &arg_refs)?; + enforce_lcov_line_threshold(&options.output_path, COVERAGE_FAIL_UNDER_LINES) } -fn run(program: &str, args: &[&str]) -> Result<()> { - let status = Command::new(program) - .args(args) +fn dev_run(args: Vec) -> Result<()> { + let args = strip_leading_separator(args); + let Some((program, program_args)) = args.split_first() else { + bail!("dev-run requires a program after `--`"); + }; + let mut command = Command::new(program); + configure_hidden_child(&mut command); + command + .args(program_args) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) + .stderr(Stdio::inherit()); + DevEnvironment::for_workspace().apply_to(&mut command); + let status = command .status() - .with_context(|| format!("run {program} {}", args.join(" ")))?; + .with_context(|| format!("run dev command {program} {}", program_args.join(" ")))?; if status.success() { Ok(()) } else { - bail!("{program} {} failed with {status}", args.join(" ")) + bail!( + "dev command {program} {} failed with {status}", + program_args.join(" ") + ) } } -#[derive(Debug, PartialEq, Eq)] -struct CoverageOptions { - output_path: PathBuf, +fn strip_leading_separator(mut args: Vec) -> Vec { + if args.first().is_some_and(|arg| arg == "--") { + args.remove(0); + } + args } -impl CoverageOptions { - fn parse(args: Vec) -> Result { - Ok(Self { - output_path: optional_path_option(&args, "--output")? - .unwrap_or_else(|| PathBuf::from(DEFAULT_COVERAGE_REPORT_PATH)), - }) - } +fn dev_env_command(args: Vec) -> Result<()> { + let format = DevEnvFormat::parse(&args)?; + DevEnvironment::for_workspace().print(format) } -#[derive(Debug, PartialEq, Eq)] -struct DistOptions { - out_dir: PathBuf, - target: Option, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DevEnvFormat { + Powershell, + Bash, + Cmd, + Json, } -impl DistOptions { - fn parse(args: Vec) -> Result { - Ok(Self { - out_dir: required_path_option(&args, "--out")?, - target: optional_string_option(&args, "--target")?, - }) +impl DevEnvFormat { + fn parse(args: &[String]) -> Result { + let format = optional_string_option(args, "--format")?.unwrap_or_else(default_dev_env_format); + match format.as_str() { + "powershell" | "pwsh" => Ok(Self::Powershell), + "bash" | "sh" => Ok(Self::Bash), + "cmd" => Ok(Self::Cmd), + "json" => Ok(Self::Json), + other => bail!("unknown dev-env format `{other}`; expected powershell, bash, cmd, or json"), + } } } -#[derive(Debug, PartialEq, Eq)] -struct VerifyDistOptions { - dir: PathBuf, - target: Option, +fn default_dev_env_format() -> String { + if cfg!(windows) { + "powershell".to_string() + } else { + "bash".to_string() + } } -impl VerifyDistOptions { - fn parse(args: Vec) -> Result { - Ok(Self { - dir: required_path_option(&args, "--dir")?, - target: optional_string_option(&args, "--target")?, - }) +#[derive(Debug, Clone)] +struct DevEnvironment { + values: Vec<(&'static str, String)>, +} + +impl DevEnvironment { + fn for_workspace() -> Self { + let workspace_root = workspace_root(); + let workspace = workspace_root.canonicalize().unwrap_or(workspace_root); + Self { + values: vec![ + (CADDER_RUNTIME_PROFILE_ENV, "dev".to_string()), + (CADDER_DEV_WORKSPACE_ENV, workspace.display().to_string()), + (CADDER_CADDY_BACKEND_ENV, "mock".to_string()), + ], + } + } + + fn apply_to(&self, command: &mut Command) { + for (key, value) in &self.values { + command.env(key, value); + } + } + + fn print(&self, format: DevEnvFormat) -> Result<()> { + match format { + DevEnvFormat::Powershell => { + for (key, value) in &self.values { + println!("$env:{key} = '{}'", escape_powershell_single_quoted(value)); + } + } + DevEnvFormat::Bash => { + for (key, value) in &self.values { + println!("export {key}='{}'", escape_bash_single_quoted(value)); + } + } + DevEnvFormat::Cmd => { + for (key, value) in &self.values { + println!("set {key}={value}"); + } + } + DevEnvFormat::Json => { + let object = self + .values + .iter() + .map(|(key, value)| ((*key).to_string(), json!(value))) + .collect::>(); + println!( + "{}", + serde_json::to_string_pretty(&JsonValue::Object(object))? + ); + } + } + Ok(()) } } -#[derive(Debug, PartialEq, Eq)] -struct PackageOptions { - out_dir: PathBuf, - version: String, - platform: String, - target: Option, +fn escape_powershell_single_quoted(value: &str) -> String { + value.replace('\'', "''") } -impl PackageOptions { - fn parse(args: Vec) -> Result { - Ok(Self { - out_dir: required_path_option(&args, "--out")?, - version: optional_string_option(&args, "--version")? - .map_or_else(workspace_package_version, Ok)?, - platform: required_string_option(&args, "--platform")?, - target: optional_string_option(&args, "--target")?, - }) - } +fn escape_bash_single_quoted(value: &str) -> String { + value.replace('\'', "'\\''") } -fn workspace_package_version() -> Result { - workspace_package_version_from_manifest(Path::new(WORKSPACE_MANIFEST)) +fn docs_site_dir() -> PathBuf { + workspace_root().join(DOCS_SITE_DIR) } -fn workspace_package_version_from_manifest(manifest: &Path) -> Result { - let contents = - fs::read_to_string(manifest).with_context(|| format!("read {}", manifest.display()))?; - let mut in_workspace_package = false; +fn workspace_test_args() -> [&'static str; 2] { + ["test", "--workspace"] +} - for line in contents.lines() { - let trimmed = line.split('#').next().unwrap_or_default().trim(); - if trimmed.is_empty() { - continue; - } +fn workspace_root() -> PathBuf { + Path::new(WORKSPACE_MANIFEST) + .parent() + .expect("workspace manifest has a parent") + .to_path_buf() +} - if trimmed.starts_with('[') && trimmed.ends_with(']') { - in_workspace_package = trimmed == "[workspace.package]"; - continue; +fn verify_release_profile() -> Result<()> { + verify_release_profile_manifest(Path::new(WORKSPACE_MANIFEST))?; + println!("verified release profile policy"); + Ok(()) +} + +fn verify_release_identity() -> Result<()> { + verify_assets()?; + verify_release_identity_files( + Path::new(WORKSPACE_MANIFEST), + &workspace_root().join(DOCS_DOWNLOAD_SCRIPT), + )?; + println!("verified release identity metadata"); + Ok(()) +} + +fn verify_assets() -> Result<()> { + verify_assets_at(&workspace_root())?; + println!("verified visual asset contract"); + Ok(()) +} + +fn verify_workspace_topology() -> Result<()> { + verify_workspace_topology_manifest(Path::new(WORKSPACE_MANIFEST))?; + println!("verified workspace topology contract"); + Ok(()) +} + +fn verify_assets_at(root: &Path) -> Result<()> { + verify_required_asset_paths(root, &REQUIRED_VISUAL_ASSETS)?; + verify_canonical_asset_copies(root, &CANONICAL_ASSET_COPIES)?; + verify_obsolete_assets_absent(root, &OBSOLETE_SCAFFOLD_ASSETS)?; + verify_docs_webmanifest(root, &root.join(DOCS_WEBMANIFEST)) +} + +fn verify_release_identity_files(workspace_manifest: &Path, download_script: &Path) -> Result<()> { + workspace_package_version_from_manifest(workspace_manifest)?; + verify_docs_download_metadata(download_script) +} + +fn verify_docs_download_metadata(download_script: &Path) -> Result<()> { + let contents = fs::read_to_string(download_script) + .with_context(|| format!("read {}", download_script.display()))?; + for (label, snippet) in REQUIRED_DOWNLOAD_METADATA_SNIPPETS { + if !contents.contains(snippet) { + bail!( + "download metadata for {label} is missing expected snippet {snippet:?} in {}", + download_script.display() + ); } + } + Ok(()) +} - if in_workspace_package - && let Some((key, value)) = trimmed.split_once('=') - && key.trim() == "version" - { - let version = value.trim().trim_matches('"'); - if version.is_empty() { - bail!( - "workspace package version is empty in {}", - manifest.display() - ); - } - return Ok(version.to_string()); +fn verify_required_asset_paths(root: &Path, assets: &[(&str, &str)]) -> Result<()> { + for (label, relative_path) in assets { + let path = root.join(relative_path); + if !path.is_file() { + bail!( + "{label} is missing at {}", + normalize_repo_path(relative_path) + ); } } + Ok(()) +} - bail!( - "workspace package version not found in {}", - manifest.display() - ) +fn verify_canonical_asset_copies(root: &Path, copies: &[(&str, &str, &str)]) -> Result<()> { + for (label, canonical_path, copy_path) in copies { + let canonical = root.join(canonical_path); + let copy = root.join(copy_path); + let canonical_hash = compute_sha256(&canonical)?; + let copy_hash = compute_sha256(©)?; + if canonical_hash != copy_hash { + bail!( + "{label} at {} differs from canonical asset {}", + normalize_repo_path(copy_path), + normalize_repo_path(canonical_path) + ); + } + } + Ok(()) } -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -enum ArchiveKind { - Zip, - TarGz, +fn verify_obsolete_assets_absent(root: &Path, assets: &[(&str, &str)]) -> Result<()> { + for (label, relative_path) in assets { + if root.join(relative_path).exists() { + bail!( + "obsolete scaffold asset {label} must not be checked in at {}", + normalize_repo_path(relative_path) + ); + } + } + Ok(()) } -fn required_path_option(args: &[String], option: &str) -> Result { - required_string_option(args, option).map(PathBuf::from) +fn verify_docs_webmanifest(root: &Path, manifest_path: &Path) -> Result<()> { + let manifest = read_json_value(manifest_path)?; + ensure_json_string( + manifest_path, + &manifest, + &["name"], + "Cadder", + "web manifest name", + )?; + ensure_json_string( + manifest_path, + &manifest, + &["short_name"], + "Cadder", + "web manifest short_name", + )?; + + let icons = manifest + .get("icons") + .and_then(JsonValue::as_array) + .with_context(|| format!("icons missing in {}", manifest_path.display()))?; + if icons.is_empty() { + bail!("icons is empty in {}", manifest_path.display()); + } + + let public_root = root.join("docs/site/public"); + for icon in icons { + let src = json_path_string(manifest_path, icon, &["src"], "web manifest icon src")?; + let relative_path = src.strip_prefix('/').unwrap_or(src); + let path = public_root.join(relative_path); + if !path.is_file() { + bail!( + "web manifest icon {} is missing at {}", + src, + normalize_repo_path(&path_relative_to(root, &path)) + ); + } + } + Ok(()) } -fn optional_path_option(args: &[String], option: &str) -> Result> { - optional_string_option(args, option).map(|value| value.map(PathBuf::from)) +fn normalize_repo_path(path: &str) -> String { + path.replace('\\', "/") } -fn required_string_option(args: &[String], option: &str) -> Result { - optional_string_option(args, option)?.ok_or_else(|| anyhow::anyhow!("{option} is required")) +fn path_relative_to(root: &Path, path: &Path) -> String { + path + .strip_prefix(root) + .unwrap_or(path) + .display() + .to_string() } -fn optional_string_option(args: &[String], option: &str) -> Result> { - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - if arg == option { - return iter - .next() - .cloned() - .map(Some) - .ok_or_else(|| anyhow::anyhow!("{option} requires a value")); - } - } - Ok(None) +fn read_json_value(path: &Path) -> Result { + let contents = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&contents).with_context(|| format!("parse JSON {}", path.display())) } -#[cfg(test)] -mod tests { - use super::*; +fn ensure_json_string( + path: &Path, + root: &JsonValue, + keys: &[&str], + expected: &str, + label: &str, +) -> Result<()> { + let value = json_path_string(path, root, keys, label)?; + if value == expected { + return Ok(()); + } - #[test] - fn parse_path_option_reads_value_after_option() { - let path = - required_path_option(&["--out".to_string(), "target/dist".to_string()], "--out").unwrap(); + bail!( + "{label} expected {expected:?}, found {value:?} in {}", + path.display() + ) +} - assert_eq!(path, PathBuf::from("target/dist")); +fn json_path_string<'a>( + path: &Path, + root: &'a JsonValue, + keys: &[&str], + label: &str, +) -> Result<&'a str> { + let mut value = root; + for key in keys { + value = value.get(*key).with_context(|| { + format!( + "{label} missing key `{}` in {}", + keys.join("."), + path.display() + ) + })?; } + value + .as_str() + .with_context(|| format!("{label} is not a string in {}", path.display())) +} - #[test] +fn verify_release_profile_manifest(manifest: &Path) -> Result<()> { + let manifest_table = read_toml_table(manifest)?; + for setting in RELEASE_PROFILE_SETTINGS + .iter() + .chain(PROFILING_PROFILE_SETTINGS.iter()) + { + ensure_profile_setting(manifest, &manifest_table, setting)?; + } + Ok(()) +} + +fn verify_workspace_topology_manifest(manifest: &Path) -> Result<()> { + let root = manifest + .parent() + .with_context(|| format!("workspace manifest has no parent: {}", manifest.display()))?; + let manifest_table = read_toml_table(manifest)?; + let actual_members = workspace_members_from_manifest(manifest, &manifest_table)?; + + ensure_workspace_members_match(manifest, &actual_members)?; + ensure_workspace_member_manifests_match(root)?; + ensure_runtime_release_packages_match_contract()?; + Ok(()) +} + +fn workspace_members_from_manifest(manifest: &Path, root: &Table) -> Result> { + let workspace = root + .get("workspace") + .and_then(Value::as_table) + .with_context(|| format!("workspace table missing in {}", manifest.display()))?; + let members = workspace + .get("members") + .and_then(Value::as_array) + .with_context(|| format!("workspace.members array missing in {}", manifest.display()))?; + + let mut result = BTreeSet::new(); + for member in members { + let member = member + .as_str() + .with_context(|| format!("workspace member is not a string in {}", manifest.display()))?; + let normalized = normalize_repo_path(member); + if !result.insert(normalized.clone()) { + bail!( + "duplicate workspace member {normalized:?} in {}", + manifest.display() + ); + } + } + Ok(result) +} + +fn ensure_workspace_members_match(manifest: &Path, actual: &BTreeSet) -> Result<()> { + let expected = expected_workspace_members(); + if actual == &expected { + return Ok(()); + } + + bail!( + "workspace members in {} do not match documented topology; missing: {}; unexpected: {}; documented topology: {}", + manifest.display(), + format_string_set(expected.difference(actual)), + format_string_set(actual.difference(&expected)), + documented_workspace_topology() + ) +} + +fn ensure_workspace_member_manifests_match(root: &Path) -> Result<()> { + for contract in WORKSPACE_MEMBER_CONTRACTS { + let manifest = root.join(contract.path).join("Cargo.toml"); + let package_name = workspace_member_package_name(&manifest)?; + if package_name != contract.package { + bail!( + "{} is classified as {} but package name must be {:?}, found {:?}", + contract.path, + contract.classification.label(), + contract.package, + package_name + ); + } + } + Ok(()) +} + +fn workspace_member_package_name(manifest: &Path) -> Result { + let manifest_table = read_toml_table(manifest)?; + manifest_table + .get("package") + .and_then(Value::as_table) + .and_then(|package| package.get("name")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .with_context(|| format!("package.name missing in {}", manifest.display())) +} + +fn ensure_runtime_release_packages_match_contract() -> Result<()> { + let actual = RUNTIME_RELEASE_PACKAGES + .iter() + .map(|package| (*package).to_string()) + .collect::>(); + let expected = WORKSPACE_MEMBER_CONTRACTS + .iter() + .filter(|contract| contract.runtime_release_package) + .map(|contract| contract.package.to_string()) + .collect::>(); + + if actual == expected { + return Ok(()); + } + + bail!( + "runtime release packages do not match documented topology; missing: {}; unexpected: {}", + format_string_set(expected.difference(&actual)), + format_string_set(actual.difference(&expected)) + ) +} + +fn expected_workspace_members() -> BTreeSet { + WORKSPACE_MEMBER_CONTRACTS + .iter() + .map(|contract| contract.path.to_string()) + .collect() +} + +fn documented_workspace_topology() -> String { + WORKSPACE_MEMBER_CONTRACTS + .iter() + .map(|contract| { + format!( + "{}={} ({})", + contract.path, + contract.package, + contract.classification.label() + ) + }) + .collect::>() + .join(", ") +} + +fn format_string_set<'a>(items: impl Iterator) -> String { + let items = items.map(String::as_str).collect::>(); + if items.is_empty() { + "none".to_string() + } else { + items.join(", ") + } +} + +fn read_toml_table(path: &Path) -> Result { + let contents = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + contents + .parse::
() + .with_context(|| format!("parse TOML {}", path.display())) +} + +fn ensure_profile_setting(manifest: &Path, root: &Table, setting: &ProfileSetting) -> Result<()> { + let profile = root + .get("profile") + .and_then(Value::as_table) + .with_context(|| format!("profile table missing in {}", manifest.display()))?; + let profile_table = profile + .get(setting.profile) + .and_then(Value::as_table) + .with_context(|| { + format!( + "profile.{} table missing in {}", + setting.profile, + manifest.display() + ) + })?; + let value = profile_table.get(setting.key).with_context(|| { + format!( + "profile.{}.{} missing in {}", + setting.profile, + setting.key, + manifest.display() + ) + })?; + + if setting.expected.matches(value) { + return Ok(()); + } + + bail!( + "profile.{}.{} expected {}, found {} in {}", + setting.profile, + setting.key, + setting.expected.describe(), + describe_toml_value(value), + manifest.display() + ) +} + +impl ExpectedTomlValue { + fn matches(self, value: &Value) -> bool { + match self { + Self::Boolean(expected) => value.as_bool() == Some(expected), + Self::Integer(expected) => value.as_integer() == Some(expected), + Self::String(expected) => value.as_str() == Some(expected), + } + } + + fn describe(self) -> String { + match self { + Self::Boolean(value) => value.to_string(), + Self::Integer(value) => value.to_string(), + Self::String(value) => format!("{value:?}"), + } + } +} + +fn describe_toml_value(value: &Value) -> String { + match value { + Value::String(value) => format!("{value:?}"), + Value::Integer(value) => value.to_string(), + Value::Float(value) => value.to_string(), + Value::Boolean(value) => value.to_string(), + Value::Datetime(value) => value.to_string(), + Value::Array(_) => "array".to_string(), + Value::Table(_) => "table".to_string(), + } +} + +fn dist(options: DistOptions) -> Result<()> { + verify_release_profile()?; + dist_with_builder(options, build_release_binaries) +} + +fn dist_with_builder( + options: DistOptions, + build_binaries: impl FnOnce(Option<&str>, PortableTopology) -> Result<()>, +) -> Result<()> { + build_binaries(options.target.as_deref(), options.topology)?; + prepare_dist_dir(&options.out_dir, options.target.as_deref())?; + for binary in options.topology.binaries() { + let source = release_binary_path(binary, options.target.as_deref()); + let target = options + .out_dir + .join(exe_name(binary, options.target.as_deref())); + fs::copy(&source, &target) + .with_context(|| format!("copy {} to {}", source.display(), target.display()))?; + } + if options.topology.includes_runtime() { + fs::write(options.out_dir.join("cadder.toml"), SAMPLE_CADDER_TOML) + .with_context(|| format!("write {}", options.out_dir.join("cadder.toml").display()))?; + } + + verify_dist(&VerifyDistOptions { + dir: options.out_dir, + target: options.target, + topology: options.topology, + }) +} + +fn prepare_dist_dir(dir: &Path, target: Option<&str>) -> Result<()> { + fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + for binary in RUNTIME_PORTABLE_BINARIES { + remove_file_if_exists(&dir.join(exe_name(binary, target)))?; + } + remove_file_if_exists(&dir.join("cadder.toml")) +} + +fn remove_file_if_exists(path: &Path) -> Result<()> { + match fs::metadata(path) { + Ok(metadata) if metadata.is_file() => { + fs::remove_file(path).with_context(|| format!("remove {}", path.display())) + } + Ok(_) => bail!( + "expected removable portable file, found non-file {}", + path.display() + ), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())), + } +} + +fn verify_dist(options: &VerifyDistOptions) -> Result<()> { + for binary in options.topology.binaries() { + let path = portable_binary_path(options, binary); + if !path.is_file() { + bail!("portable binary missing: {}", path.display()); + } + } + if options.topology.includes_runtime() { + let config = options.dir.join("cadder.toml"); + if !config.is_file() { + bail!( + "portable sample configuration missing: {}", + config.display() + ); + } + } + + verify_dist_file_set(options)?; + + for binary in options.topology.binaries() { + let path = portable_binary_path(options, binary); + verify_portable_binary_command(binary, &path, "--help")?; + verify_portable_binary_command(binary, &path, "--version")?; + } + + if options.topology.includes_runtime() { + let shim = portable_binary_path(options, "caddy"); + let stdout = run_portable_binary_command("caddy", &shim, "--cadder-shim-info")?; + if !stdout.contains("\"role\":\"caddy-shim\"") && !stdout.contains("\"role\": \"caddy-shim\"") { + bail!("caddy --cadder-shim-info did not report the Cadder shim role"); + } + } + + let summary_label = format!("portable {} dist", options.topology.name()); + print_artifact_summary(&summary_label, &options.dir)?; + + Ok(()) +} + +fn verify_dist_file_set(options: &VerifyDistOptions) -> Result<()> { + let mut expected = BTreeSet::new(); + for binary in options.topology.binaries() { + expected.insert(exe_name(binary, options.target.as_deref())); + } + if options.topology.includes_runtime() { + expected.insert("cadder.toml".to_string()); + } + + let mut actual = BTreeSet::new(); + for entry in sorted_dir_entries(&options.dir)? { + if entry.file_type()?.is_file() { + actual.insert(entry.file_name().to_string_lossy().to_string()); + } + } + + if actual != expected { + bail!( + "portable dist {} contains unexpected file set: expected {:?}, found {:?}", + options.dir.display(), + expected, + actual + ); + } + + Ok(()) +} + +fn portable_binary_path(options: &VerifyDistOptions, binary: &str) -> PathBuf { + options + .dir + .join(exe_name(binary, options.target.as_deref())) +} + +fn verify_portable_binary_command(binary: &str, path: &Path, argument: &str) -> Result<()> { + let stdout = run_portable_binary_command(binary, path, argument)?; + if stdout.trim().is_empty() { + bail!("{binary} {argument} produced empty stdout"); + } + Ok(()) +} + +fn run_portable_binary_command(binary: &str, path: &Path, argument: &str) -> Result { + let mut command = Command::new(path); + configure_hidden_child(&mut command); + let output = command + .arg(argument) + .stdin(Stdio::null()) + .output() + .with_context(|| format!("run {} {argument}", path.display()))?; + if !output.status.success() { + bail!("{binary} {argument} failed with {}", output.status); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn package(options: PackageOptions) -> Result<()> { + verify_release_profile()?; + package_with_dist(options, |dist_options| { + dist_with_builder(dist_options, build_release_binaries) + }) +} + +fn package_with_dist( + options: PackageOptions, + create_dist: impl FnOnce(DistOptions) -> Result<()>, +) -> Result<()> { + let archive_stem = options + .topology + .package_archive_stem(&options.version, &options.platform)?; + let layout_parent = options.out_dir.join("layouts"); + let layout_dir = layout_parent.join(&archive_stem); + if layout_dir.exists() { + fs::remove_dir_all(&layout_dir).with_context(|| format!("remove {}", layout_dir.display()))?; + } + + create_dist(DistOptions { + out_dir: layout_dir, + target: options.target, + topology: options.topology, + })?; + + fs::create_dir_all(&options.out_dir) + .with_context(|| format!("create {}", options.out_dir.display()))?; + let archive_path = options.out_dir.join(format!( + "{archive_stem}.{}", + archive_extension(&options.platform) + )); + if archive_path.exists() { + fs::remove_file(&archive_path).with_context(|| format!("remove {}", archive_path.display()))?; + } + + match archive_kind(&options.platform) { + ArchiveKind::Zip => write_zip_archive(&layout_parent, &archive_stem, &archive_path)?, + ArchiveKind::TarGz => write_tar_gz_archive(&layout_parent, &archive_stem, &archive_path)?, + } + + let checksum_path = options.out_dir.join(format!( + "{}.sha256", + archive_path + .file_name() + .and_then(|name| name.to_str()) + .context("archive path has no UTF-8 file name")? + )); + let checksum = write_sha256_file(&archive_path, &checksum_path)?; + + println!("created {}", archive_path.display()); + println!("created {}", checksum_path.display()); + println!("archive sha256 {checksum}"); + let summary_label = format!("portable {} package", options.topology.name()); + print_artifact_summary(&summary_label, &options.out_dir)?; + Ok(()) +} + +fn runtime_installer(options: RuntimeInstallerOptions) -> Result<()> { + verify_release_profile()?; + if options.signing_mode == SigningMode::SignedRelease { + verify_runtime_installer_signing_inputs(options.platform)?; + } + + let work_dir = tempdir().context("create runtime installer work directory")?; + let layout_dir = work_dir.path().join("runtime-layout"); + dist_with_builder( + DistOptions { + out_dir: layout_dir.clone(), + target: options.target.clone(), + topology: PortableTopology::Runtime, + }, + build_release_binaries, + )?; + + let payload_root = work_dir.path().join("payload-root"); + prepare_runtime_installer_payload( + &layout_dir, + &payload_root, + options.target.as_deref(), + options.platform, + )?; + + remove_dir_if_exists(&options.out_dir)?; + fs::create_dir_all(&options.out_dir) + .with_context(|| format!("create {}", options.out_dir.display()))?; + + let artifacts = build_runtime_installer_artifacts(&payload_root, &layout_dir, &options)?; + for artifact in &artifacts { + let checksum_path = checksum_path_for(artifact)?; + write_sha256_file(artifact, &checksum_path)?; + write_runtime_installer_manifest(artifact, &options)?; + } + + verify_runtime_installer_dist(&VerifyRuntimeInstallerDistOptions { + dir: options.out_dir, + version: options.version, + platform: options.platform, + }) +} + +fn prepare_runtime_installer_payload( + layout_dir: &Path, + payload_root: &Path, + target: Option<&str>, + platform: ReleasePlatform, +) -> Result<()> { + remove_dir_if_exists(payload_root)?; + match platform { + ReleasePlatform::WindowsX64 => copy_dir_contents( + layout_dir, + &payload_root.join("ProgramFiles").join("Cadder"), + ), + ReleasePlatform::LinuxX64 | ReleasePlatform::MacosX64 | ReleasePlatform::MacosArm64 => { + let bin_dir = payload_root.join("usr").join("local").join("bin"); + let config_dir = payload_root + .join("usr") + .join("local") + .join("etc") + .join("cadder"); + fs::create_dir_all(&bin_dir).with_context(|| format!("create {}", bin_dir.display()))?; + fs::create_dir_all(&config_dir) + .with_context(|| format!("create {}", config_dir.display()))?; + for binary in RUNTIME_PORTABLE_BINARIES { + let source = layout_dir.join(exe_name(binary, target)); + let target_path = bin_dir.join(binary); + fs::copy(&source, &target_path) + .with_context(|| format!("copy {} to {}", source.display(), target_path.display()))?; + } + fs::copy( + layout_dir.join("cadder.toml"), + config_dir.join("cadder.toml"), + ) + .context("copy runtime installer sample config")?; + Ok(()) + } + } +} + +fn build_runtime_installer_artifacts( + payload_root: &Path, + windows_layout_dir: &Path, + options: &RuntimeInstallerOptions, +) -> Result> { + let mut artifacts = Vec::new(); + for kind in options.platform.required_runtime_installer_artifact_kinds() { + let artifact = options.out_dir.join(runtime_installer_artifact_name( + &options.version, + options.platform, + *kind, + )); + match kind { + RuntimeInstallerArtifactKind::WindowsMsi => { + build_windows_runtime_msi(windows_layout_dir, &artifact, options)? + } + RuntimeInstallerArtifactKind::LinuxDeb => { + build_linux_runtime_deb(payload_root, &artifact, options)? + } + RuntimeInstallerArtifactKind::LinuxRpm => { + build_linux_runtime_rpm(payload_root, &artifact, options)? + } + RuntimeInstallerArtifactKind::MacosPkg => { + build_macos_runtime_pkg(payload_root, &artifact, options)? + } + } + if !artifact.is_file() { + bail!("{} did not create {}", kind.label(), artifact.display()); + } + artifacts.push(artifact); + } + Ok(artifacts) +} + +fn build_windows_runtime_msi( + layout_dir: &Path, + artifact: &Path, + options: &RuntimeInstallerOptions, +) -> Result<()> { + let wxs_path = artifact.with_extension("wxs"); + fs::write( + &wxs_path, + windows_runtime_wxs(layout_dir, &options.version, options.target.as_deref())?, + ) + .with_context(|| format!("write {}", wxs_path.display()))?; + let wxs = path_argument(&wxs_path)?; + let output = path_argument(artifact)?; + run("wix", &["build", &wxs, "-arch", "x64", "-o", &output])?; + if options.signing_mode == SigningMode::SignedRelease { + sign_windows_artifact(artifact)?; + } + fs::remove_file(&wxs_path).with_context(|| format!("remove {}", wxs_path.display()))?; + Ok(()) +} + +fn windows_runtime_wxs(layout_dir: &Path, version: &str, target: Option<&str>) -> Result { + let mut components = String::new(); + let mut component_refs = String::new(); + for binary in RUNTIME_PORTABLE_BINARIES { + let file_name = exe_name(binary, target); + let source = xml_escape(&path_argument(&layout_dir.join(&file_name))?); + let component_id = windows_installer_id(&format!("cmp_{file_name}")); + let file_id = windows_installer_id(&format!("file_{file_name}")); + components.push_str(&format!( + r#" + + + "# + )); + component_refs.push_str(&format!(r#""#)); + } + let config_source = xml_escape(&path_argument(&layout_dir.join("cadder.toml"))?); + components.push_str(&format!( + r#" + + + "# + )); + component_refs.push_str(r#""#); + + Ok(format!( + r#" + + + + + + + + + + + +{components} + + + + + + {component_refs} + + + +"#, + product = RUNTIME_INSTALLER_PRODUCT_NAME, + manufacturer = RUNTIME_INSTALLER_MANUFACTURER, + upgrade_code = WINDOWS_RUNTIME_INSTALLER_UPGRADE_CODE, + )) +} + +fn build_linux_runtime_deb( + payload_root: &Path, + artifact: &Path, + options: &RuntimeInstallerOptions, +) -> Result<()> { + let control_dir = payload_root.join("DEBIAN"); + fs::create_dir_all(&control_dir).with_context(|| format!("create {}", control_dir.display()))?; + let control = format!( + "Package: {package}\nVersion: {version}\nSection: devel\nPriority: optional\nArchitecture: {arch}\nMaintainer: Cadder Maintainers \nDescription: Cadder daemon-first runtime\n Cadder coordinates local Caddy reverse proxies through a per-user daemon, CLI, and PATH-facing Caddy shim.\n", + package = RUNTIME_INSTALLER_PACKAGE_NAME, + version = options.version, + arch = options.platform.debian_architecture()?, + ); + fs::write(control_dir.join("control"), control) + .with_context(|| format!("write {}", control_dir.join("control").display()))?; + let payload = path_argument(payload_root)?; + let output = path_argument(artifact)?; + run( + "dpkg-deb", + &["--build", "--root-owner-group", &payload, &output], + ) +} + +fn build_linux_runtime_rpm( + payload_root: &Path, + artifact: &Path, + options: &RuntimeInstallerOptions, +) -> Result<()> { + let top_dir = artifact.with_extension("rpmbuild"); + remove_dir_if_exists(&top_dir)?; + for name in ["BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"] { + fs::create_dir_all(top_dir.join(name)) + .with_context(|| format!("create {}", top_dir.join(name).display()))?; + } + let spec_path = top_dir.join("SPECS").join("cadder-runtime.spec"); + fs::write( + &spec_path, + linux_runtime_rpm_spec(payload_root, &options.version, options.platform)?, + ) + .with_context(|| format!("write {}", spec_path.display()))?; + let top = path_argument(&top_dir)?; + let spec = path_argument(&spec_path)?; + run( + "rpmbuild", + &[ + "-bb", + "--define", + &format!("_topdir {top}"), + "--define", + "_build_id_links none", + &spec, + ], + )?; + let built_rpm = top_dir + .join("RPMS") + .join(options.platform.rpm_architecture()?) + .join(format!( + "{package}-{version}-1.{arch}.rpm", + package = RUNTIME_INSTALLER_PACKAGE_NAME, + version = options.version, + arch = options.platform.rpm_architecture()?, + )); + fs::copy(&built_rpm, artifact) + .with_context(|| format!("copy {} to {}", built_rpm.display(), artifact.display()))?; + remove_dir_if_exists(&top_dir) +} + +fn linux_runtime_rpm_spec( + payload_root: &Path, + version: &str, + platform: ReleasePlatform, +) -> Result { + let payload = path_argument(payload_root)?; + Ok(format!( + r#"Name: {package} +Version: {version} +Release: 1 +Summary: Cadder daemon-first runtime +License: MIT +BuildArch: {arch} +AutoReqProv: no + +%description +Cadder coordinates local Caddy reverse proxies through a per-user daemon, CLI, +and PATH-facing Caddy shim. + +%prep + +%build + +%install +mkdir -p "%{{buildroot}}/usr/local/bin" +mkdir -p "%{{buildroot}}/usr/local/etc/cadder" +cp -a "{payload}/usr/local/bin/." "%{{buildroot}}/usr/local/bin/" +cp -a "{payload}/usr/local/etc/cadder/." "%{{buildroot}}/usr/local/etc/cadder/" + +%files +/usr/local/bin/cadderd +/usr/local/bin/cadder +/usr/local/bin/caddy +/usr/local/etc/cadder/cadder.toml +"#, + package = RUNTIME_INSTALLER_PACKAGE_NAME, + arch = platform.rpm_architecture()?, + )) +} + +fn build_macos_runtime_pkg( + payload_root: &Path, + artifact: &Path, + options: &RuntimeInstallerOptions, +) -> Result<()> { + let payload = path_argument(payload_root)?; + let output = path_argument(artifact)?; + let mut args = vec![ + "--root".to_string(), + payload, + "--identifier".to_string(), + RUNTIME_INSTALLER_IDENTIFIER.to_string(), + "--version".to_string(), + options.version.clone(), + "--install-location".to_string(), + "/".to_string(), + ]; + if options.signing_mode == SigningMode::SignedRelease { + args.push("--sign".to_string()); + args.push(required_env(MACOS_INSTALLER_SIGNING_IDENTITY_ENV)?); + } + args.push(output); + let refs = args.iter().map(String::as_str).collect::>(); + run("pkgbuild", &refs) +} + +fn sign_windows_artifact(artifact: &Path) -> Result<()> { + let cert = required_env(WINDOWS_SIGNTOOL_CERT_PATH_ENV)?; + let password = required_env(WINDOWS_SIGNTOOL_CERT_PASSWORD_ENV)?; + let timestamp = env::var(WINDOWS_SIGNTOOL_TIMESTAMP_URL_ENV) + .unwrap_or_else(|_| "http://timestamp.digicert.com".to_string()); + let artifact = path_argument(artifact)?; + run( + "signtool", + &[ + "sign", "/fd", "sha256", "/td", "sha256", "/tr", ×tamp, "/f", &cert, "/p", &password, + &artifact, + ], + ) +} + +fn verify_runtime_installer_signing_inputs(platform: ReleasePlatform) -> Result<()> { + match platform { + ReleasePlatform::WindowsX64 => { + required_env(WINDOWS_SIGNTOOL_CERT_PATH_ENV)?; + required_env(WINDOWS_SIGNTOOL_CERT_PASSWORD_ENV)?; + } + ReleasePlatform::MacosX64 | ReleasePlatform::MacosArm64 => { + required_env(MACOS_INSTALLER_SIGNING_IDENTITY_ENV)?; + } + ReleasePlatform::LinuxX64 => {} + } + Ok(()) +} + +fn required_env(name: &str) -> Result { + let value = env::var(name).with_context(|| format!("{name} is required"))?; + if value.trim().is_empty() { + bail!("{name} must not be empty"); + } + Ok(value) +} + +fn verify_runtime_installer_dist(options: &VerifyRuntimeInstallerDistOptions) -> Result<()> { + verify_runtime_installer_release_assets_for_platform( + &options.dir, + &options.version, + options.platform, + )?; + print_selected_artifact_summary( + "runtime installer", + &options.dir, + &runtime_installer_dist_artifacts(&options.dir, &options.version, options.platform)?, + ) +} + +fn runtime_installer_dist_artifacts( + dir: &Path, + version: &str, + platform: ReleasePlatform, +) -> Result> { + let mut artifacts = Vec::new(); + for kind in platform.required_runtime_installer_artifact_kinds() { + let artifact = dir.join(runtime_installer_artifact_name(version, platform, *kind)); + artifacts.push(artifact.clone()); + artifacts.push(checksum_path_for(&artifact)?); + let manifest = runtime_installer_manifest_path_for(&artifact)?; + artifacts.push(manifest.clone()); + artifacts.push(checksum_path_for(&manifest)?); + } + Ok(artifacts) +} + +fn runtime_installer_artifact_name( + version: &str, + platform: ReleasePlatform, + kind: RuntimeInstallerArtifactKind, +) -> String { + format!( + "{package}-{version}-{platform}.{extension}", + package = RUNTIME_INSTALLER_PACKAGE_NAME, + platform = platform.name(), + extension = kind.extension(), + ) +} + +fn runtime_installer_manifest_path_for(artifact_path: &Path) -> Result { + let file_name = artifact_path + .file_name() + .and_then(|name| name.to_str()) + .context("runtime installer artifact path has no UTF-8 file name")?; + Ok(artifact_path.with_file_name(format!("{file_name}.manifest.json"))) +} + +fn write_runtime_installer_manifest( + artifact: &Path, + options: &RuntimeInstallerOptions, +) -> Result<()> { + let file_name = artifact + .file_name() + .and_then(|name| name.to_str()) + .context("runtime installer artifact path has no UTF-8 file name")?; + let kind = runtime_installer_artifact_kind(options.platform, artifact) + .context("runtime installer artifact kind is not recognized")?; + let manifest_path = runtime_installer_manifest_path_for(artifact)?; + let manifest = json!({ + "artifact": file_name, + "component": "daemon-runtime", + "platform": options.platform.name(), + "packageKind": kind.label(), + "version": options.version, + "installPaths": runtime_installer_expected_install_paths(options.platform), + }); + fs::write( + &manifest_path, + serde_json::to_string_pretty(&manifest).context("serialize runtime installer manifest")?, + ) + .with_context(|| format!("write {}", manifest_path.display()))?; + let checksum_path = checksum_path_for(&manifest_path)?; + write_sha256_file(&manifest_path, &checksum_path)?; + Ok(()) +} + +fn verify_release_assets(options: &ReleaseAssetsOptions) -> Result<()> { + verify_release_identity()?; + if options.mode == ReleaseAssetMode::Publish { + verify_release_signing_inputs()?; + } + + reject_raw_app_bundles(&options.dir)?; + for platform in ReleasePlatform::ALL { + verify_portable_release_asset( + &options.dir, + &options.version, + platform, + PortableTopology::Runtime, + )?; + verify_runtime_installer_release_assets_for_platform(&options.dir, &options.version, platform)?; + } + + reject_unmatched_runtime_installer_release_assets(&options.dir, &options.version)?; + print_artifact_summary("combined release assets", &options.dir) +} + +fn verify_release_signing_inputs() -> Result<()> { + for variable in SIGNING_READY_ENV_VARS { + if env::var(variable).ok().as_deref() != Some("true") { + bail!( + "publish release requires {variable}=true after platform signing/notarization inputs are configured" + ); + } + } + Ok(()) +} + +fn verify_portable_release_asset( + dir: &Path, + version: &str, + platform: ReleasePlatform, + topology: PortableTopology, +) -> Result<()> { + let archive_stem = topology.package_archive_stem(version, platform.name())?; + let artifact = dir.join(format!( + "{archive_stem}.{}", + platform.portable_archive_extension() + )); + if !artifact.is_file() { + bail!("release asset missing: {}", artifact.display()); + } + verify_sha256_file(&artifact)?; + verify_portable_archive_contents(&artifact, &archive_stem, topology, platform) +} + +fn verify_portable_archive_contents( + artifact: &Path, + archive_stem: &str, + topology: PortableTopology, + platform: ReleasePlatform, +) -> Result<()> { + let entries = portable_archive_entries(artifact, platform)?; + for binary in topology.binaries() { + let expected = format!( + "{archive_stem}/{}", + release_platform_exe_name(binary, platform) + ); + if !entries.iter().any(|entry| entry == &expected) { + bail!( + "portable archive {} is missing expected entry {expected}", + artifact.display() + ); + } + } + let config_entry = format!("{archive_stem}/cadder.toml"); + let has_config = entries.iter().any(|entry| entry == &config_entry); + if topology.includes_runtime() && !has_config { + bail!( + "portable archive {} is missing expected entry {config_entry}", + artifact.display() + ); + } + verify_portable_archive_file_set(artifact, archive_stem, topology, platform, entries)?; + + Ok(()) +} + +fn verify_portable_archive_file_set( + artifact: &Path, + archive_stem: &str, + topology: PortableTopology, + platform: ReleasePlatform, + entries: Vec, +) -> Result<()> { + let mut expected = BTreeSet::new(); + for binary in topology.binaries() { + expected.insert(format!( + "{archive_stem}/{}", + release_platform_exe_name(binary, platform) + )); + } + if topology.includes_runtime() { + expected.insert(format!("{archive_stem}/cadder.toml")); + } + + let actual = entries + .into_iter() + .filter(|entry| !entry.ends_with('/')) + .collect::>(); + if actual != expected { + bail!( + "portable archive {} contains unexpected file set: expected {:?}, found {:?}", + artifact.display(), + expected, + actual + ); + } + + Ok(()) +} + +fn portable_archive_entries(artifact: &Path, platform: ReleasePlatform) -> Result> { + if platform == ReleasePlatform::WindowsX64 { + return zip_archive_entries(artifact); + } + tar_gz_archive_entries(artifact) +} + +fn zip_archive_entries(artifact: &Path) -> Result> { + let file = File::open(artifact).with_context(|| format!("open {}", artifact.display()))?; + let mut archive = + zip::ZipArchive::new(file).with_context(|| format!("read ZIP {}", artifact.display()))?; + let mut entries = Vec::new(); + for index in 0..archive.len() { + let file = archive + .by_index(index) + .with_context(|| format!("read ZIP entry {index} from {}", artifact.display()))?; + if file.is_file() { + entries.push(file.name().trim_end_matches('/').to_string()); + } + } + entries.sort(); + Ok(entries) +} + +fn tar_gz_archive_entries(artifact: &Path) -> Result> { + let file = File::open(artifact).with_context(|| format!("open {}", artifact.display()))?; + let decoder = GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + let mut entries = Vec::new(); + for entry in archive + .entries() + .with_context(|| format!("read TAR {}", artifact.display()))? + { + let entry = entry.with_context(|| format!("read TAR entry from {}", artifact.display()))?; + if entry.header().entry_type().is_file() { + let path = entry.path()?; + entries.push(path_to_archive_name(path.as_ref())?); + } + } + entries.sort(); + Ok(entries) +} + +fn release_platform_exe_name(name: &str, platform: ReleasePlatform) -> String { + if platform.uses_windows_executables() { + format!("{name}.exe") + } else { + name.to_string() + } +} + +fn verify_runtime_installer_release_assets_for_platform( + dir: &Path, + version: &str, + platform: ReleasePlatform, +) -> Result<()> { + for kind in platform.required_runtime_installer_artifact_kinds() { + let artifact = dir.join(runtime_installer_artifact_name(version, platform, *kind)); + if !artifact.is_file() { + bail!( + "runtime installer release asset missing: {}", + artifact.display() + ); + } + verify_sha256_file(&artifact)?; + verify_runtime_installer_manifest(&artifact, version, platform, *kind)?; + } + Ok(()) +} + +fn verify_runtime_installer_manifest( + artifact: &Path, + version: &str, + platform: ReleasePlatform, + kind: RuntimeInstallerArtifactKind, +) -> Result<()> { + let manifest_path = runtime_installer_manifest_path_for(artifact)?; + if !manifest_path.is_file() { + bail!( + "runtime installer manifest missing: {}", + manifest_path.display() + ); + } + verify_sha256_file(&manifest_path)?; + let manifest = read_json_value(&manifest_path)?; + let artifact_name = artifact + .file_name() + .and_then(|name| name.to_str()) + .context("runtime installer artifact path has no UTF-8 file name")?; + ensure_json_string( + &manifest_path, + &manifest, + &["artifact"], + artifact_name, + "runtime installer artifact", + )?; + ensure_json_string( + &manifest_path, + &manifest, + &["component"], + "daemon-runtime", + "runtime installer component", + )?; + ensure_json_string( + &manifest_path, + &manifest, + &["platform"], + platform.name(), + "runtime installer platform", + )?; + ensure_json_string( + &manifest_path, + &manifest, + &["packageKind"], + kind.label(), + "runtime installer package kind", + )?; + ensure_json_string( + &manifest_path, + &manifest, + &["version"], + version, + "runtime installer version", + )?; + let install_paths = json_path_array( + &manifest_path, + &manifest, + &["installPaths"], + "runtime installer install paths", + )?; + let expected_paths = runtime_installer_expected_install_paths(platform); + for expected in &expected_paths { + if !install_paths + .iter() + .any(|value| value.as_str() == Some(expected.as_str())) + { + bail!( + "runtime installer manifest {} is missing install path {expected}", + manifest_path.display() + ); + } + } + for value in install_paths { + let Some(actual) = value.as_str() else { + bail!( + "runtime installer manifest {} contains non-string install path {}", + manifest_path.display(), + value + ); + }; + if !expected_paths.iter().any(|expected| expected == actual) { + bail!( + "runtime installer manifest {} contains unexpected install path {actual}", + manifest_path.display() + ); + } + } + if install_paths.len() != expected_paths.len() { + bail!( + "runtime installer manifest {} expected {} install paths, found {}", + manifest_path.display(), + expected_paths.len(), + install_paths.len() + ); + } + Ok(()) +} + +fn json_path_array<'a>( + path: &Path, + root: &'a JsonValue, + keys: &[&str], + label: &str, +) -> Result<&'a Vec> { + json_path_value(path, root, keys, label)? + .as_array() + .with_context(|| format!("{label} is not an array in {}", path.display())) +} + +fn json_path_value<'a>( + path: &Path, + root: &'a JsonValue, + keys: &[&str], + label: &str, +) -> Result<&'a JsonValue> { + let mut value = root; + for key in keys { + value = value.get(*key).with_context(|| { + format!( + "{label} missing key `{}` in {}", + keys.join("."), + path.display() + ) + })?; + } + Ok(value) +} + +fn runtime_installer_expected_install_paths(platform: ReleasePlatform) -> Vec { + match platform { + ReleasePlatform::WindowsX64 => RUNTIME_PORTABLE_BINARIES + .iter() + .map(|binary| format!(r"C:\Program Files\Cadder\{}.exe", binary)) + .chain(std::iter::once( + r"C:\Program Files\Cadder\cadder.toml".to_string(), + )) + .collect(), + ReleasePlatform::LinuxX64 | ReleasePlatform::MacosX64 | ReleasePlatform::MacosArm64 => { + RUNTIME_PORTABLE_BINARIES + .iter() + .map(|binary| format!("/usr/local/bin/{binary}")) + .chain(std::iter::once( + "/usr/local/etc/cadder/cadder.toml".to_string(), + )) + .collect() + } + } +} + +fn runtime_installer_artifact_kind( + platform: ReleasePlatform, + path: &Path, +) -> Option { + let name = path.file_name()?.to_str()?.to_ascii_lowercase(); + match platform { + ReleasePlatform::WindowsX64 if name.ends_with(".msi") => { + Some(RuntimeInstallerArtifactKind::WindowsMsi) + } + ReleasePlatform::LinuxX64 if name.ends_with(".deb") => { + Some(RuntimeInstallerArtifactKind::LinuxDeb) + } + ReleasePlatform::LinuxX64 if name.ends_with(".rpm") => { + Some(RuntimeInstallerArtifactKind::LinuxRpm) + } + ReleasePlatform::MacosX64 | ReleasePlatform::MacosArm64 if name.ends_with(".pkg") => { + Some(RuntimeInstallerArtifactKind::MacosPkg) + } + _ => None, + } +} + +fn reject_unmatched_runtime_installer_release_assets(dir: &Path, version: &str) -> Result<()> { + let mut expected_names = BTreeSet::new(); + for platform in ReleasePlatform::ALL { + for kind in platform.required_runtime_installer_artifact_kinds() { + let artifact_name = runtime_installer_artifact_name(version, platform, *kind); + expected_names.insert(artifact_name.clone()); + expected_names.insert(format!("{artifact_name}.sha256")); + let manifest_name = format!("{artifact_name}.manifest.json"); + expected_names.insert(manifest_name.clone()); + expected_names.insert(format!("{manifest_name}.sha256")); + } + } + + let mut paths = Vec::new(); + collect_file_paths(dir, &mut paths)?; + for path in paths { + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if file_name.starts_with("cadder-runtime-") && !expected_names.contains(file_name) { + bail!( + "unexpected runtime installer release asset: {}", + path.display() + ); + } + } + Ok(()) +} + +fn reject_raw_app_bundles(dir: &Path) -> Result<()> { + for entry in sorted_dir_entries(dir)? { + let path = entry.path(); + if path.is_dir() { + if is_macos_app_bundle(&path) { + bail!( + "raw macOS .app bundle is not a publishable release asset: {}", + path.display() + ); + } + reject_raw_app_bundles(&path)?; + } + } + Ok(()) +} + +fn release_binary_path(name: &str, target: Option<&str>) -> PathBuf { + let release_dir = match target { + Some(target) => PathBuf::from("target").join(target).join("release"), + None => PathBuf::from("target").join("release"), + }; + release_dir.join(exe_name(name, target)) +} + +fn exe_name(name: &str, target: Option<&str>) -> String { + if target_uses_windows_executables(target) { + format!("{name}.exe") + } else { + name.to_string() + } +} + +fn target_uses_windows_executables(target: Option<&str>) -> bool { + target.map_or(cfg!(windows), |target| target.contains("windows")) +} + +fn build_release_binaries(target: Option<&str>, topology: PortableTopology) -> Result<()> { + let mut args = vec!["build", "--release"]; + for package in topology.release_packages() { + args.push("-p"); + args.push(package); + } + if let Some(target) = target { + args.push("--target"); + args.push(target); + } + run("cargo", &args) +} + +fn ensure_parent_dir(path: &Path) -> Result<()> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + Ok(()) +} + +fn coverage_command_args(output_path: &Path) -> Result> { + let output_path = output_path.to_str().with_context(|| { + format!( + "coverage output path is not UTF-8: {}", + output_path.display() + ) + })?; + + let mut args = Vec::new(); + if let Some(toolchain) = coverage_toolchain_argument_from_env()? { + args.push(toolchain); + } + args.extend(["llvm-cov".to_string(), "--workspace".to_string()]); + for package in COVERAGE_EXCLUDED_PACKAGES { + args.push("--exclude".to_string()); + args.push(package.to_string()); + } + if !COVERAGE_IGNORED_FILENAME_REGEX.is_empty() { + args.push("--ignore-filename-regex".to_string()); + args.push(COVERAGE_IGNORED_FILENAME_REGEX.to_string()); + } + args.extend([ + "--lcov".to_string(), + "--output-path".to_string(), + output_path.to_string(), + ]); + Ok(args) +} + +fn coverage_toolchain_argument_from_env() -> Result> { + let override_toolchain = env::var(CADDER_COVERAGE_TOOLCHAIN_ENV).ok(); + let active_toolchain = env::var("RUSTUP_TOOLCHAIN").ok(); + coverage_toolchain_argument( + override_toolchain.as_deref(), + active_toolchain.as_deref(), + cfg!(windows), + cfg!(all(windows, target_env = "gnu")), + ) +} + +fn coverage_toolchain_argument( + override_toolchain: Option<&str>, + active_toolchain: Option<&str>, + is_windows: bool, + is_windows_gnu_host: bool, +) -> Result> { + if let Some(toolchain) = override_toolchain.and_then(non_empty_str) { + return normalize_rustup_toolchain_arg(toolchain); + } + + if is_windows { + let active_toolchain = active_toolchain.and_then(non_empty_str); + if windows_coverage_needs_msvc_fallback(active_toolchain, is_windows_gnu_host) { + return normalize_rustup_toolchain_arg(WINDOWS_COVERAGE_TOOLCHAIN); + } + return Ok(None); + } + + if active_toolchain.and_then(non_empty_str).is_some() { + return Ok(None); + } + + Ok(None) +} + +fn windows_coverage_needs_msvc_fallback( + active_toolchain: Option<&str>, + is_windows_gnu_host: bool, +) -> bool { + let Some(active_toolchain) = active_toolchain else { + return true; + }; + if active_toolchain.contains("windows-msvc") { + return false; + } + active_toolchain.contains("windows-gnu") || is_windows_gnu_host +} + +fn normalize_rustup_toolchain_arg(toolchain: &str) -> Result> { + let Some(trimmed) = non_empty_str(toolchain) else { + return Ok(None); + }; + let trimmed = trimmed.strip_prefix('+').unwrap_or(trimmed).trim(); + if trimmed.is_empty() { + bail!("coverage toolchain must not be only `+`"); + } + Ok(Some(format!("+{trimmed}"))) +} + +fn non_empty_str(value: &str) -> Option<&str> { + let trimmed = value.trim(); + (!trimmed.is_empty()).then_some(trimmed) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct LcovLineCoverage { + covered: u64, + total: u64, +} + +impl LcovLineCoverage { + fn percent(self) -> f64 { + if self.total == 0 { + 0.0 + } else { + self.covered as f64 * 100.0 / self.total as f64 + } + } +} + +fn enforce_lcov_line_threshold(report_path: &Path, required_percent: f64) -> Result<()> { + let coverage = read_lcov_line_coverage(report_path)?; + let percent = coverage.percent(); + if percent < required_percent { + bail!( + "line coverage {:.2}% is below required {:.2}% ({}/{} lines covered)", + percent, + required_percent, + coverage.covered, + coverage.total + ); + } + + println!( + "line coverage {:.2}% meets required {:.2}% ({}/{} lines covered)", + percent, required_percent, coverage.covered, coverage.total + ); + Ok(()) +} + +fn read_lcov_line_coverage(report_path: &Path) -> Result { + let report = fs::read_to_string(report_path) + .with_context(|| format!("read coverage report {}", report_path.display()))?; + let mut current_file = None; + let mut line_counts = BTreeMap::<(String, u64), u64>::new(); + + for (index, line) in report.lines().enumerate() { + let line_number = index + 1; + if let Some(file) = line.strip_prefix("SF:") { + current_file = Some(file.to_string()); + } else if let Some(value) = line.strip_prefix("DA:") { + let file = current_file.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "DA record appeared before SF on line {line_number} in {}", + report_path.display() + ) + })?; + let (source_line, execution_count) = parse_lcov_da(report_path, line_number, value)?; + line_counts + .entry((file.clone(), source_line)) + .and_modify(|count| *count = count.saturating_add(execution_count)) + .or_insert(execution_count); + } else if line == "end_of_record" { + current_file = None; + } + } + + let coverage = LcovLineCoverage { + covered: line_counts.values().filter(|count| **count > 0).count() as u64, + total: line_counts.len() as u64, + }; + if coverage.total == 0 { + bail!( + "coverage report {} did not contain any DA line records", + report_path.display() + ); + } + if coverage.covered > coverage.total { + bail!( + "coverage report {} has more covered lines than total lines ({}/{})", + report_path.display(), + coverage.covered, + coverage.total + ); + } + + Ok(coverage) +} + +fn parse_lcov_da(report_path: &Path, line_number: usize, value: &str) -> Result<(u64, u64)> { + let mut parts = value.split(','); + let source_line = parts + .next() + .filter(|value| !value.trim().is_empty()) + .context("missing DA source line")? + .trim() + .parse::() + .with_context(|| { + format!( + "parse DA source line on line {line_number} in {}", + report_path.display() + ) + })?; + let execution_count = parts + .next() + .filter(|value| !value.trim().is_empty()) + .context("missing DA execution count")? + .trim() + .parse::() + .with_context(|| { + format!( + "parse DA execution count on line {line_number} in {}", + report_path.display() + ) + })?; + Ok((source_line, execution_count)) +} + +fn archive_kind(platform: &str) -> ArchiveKind { + if platform.starts_with("windows") { + ArchiveKind::Zip + } else { + ArchiveKind::TarGz + } +} + +fn archive_extension(platform: &str) -> &'static str { + match archive_kind(platform) { + ArchiveKind::Zip => "zip", + ArchiveKind::TarGz => "tar.gz", + } +} + +fn write_zip_archive(layout_parent: &Path, root_dir: &str, archive_path: &Path) -> Result<()> { + let file = + File::create(archive_path).with_context(|| format!("create {}", archive_path.display()))?; + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + + append_zip_dir( + &mut zip, + &layout_parent.join(root_dir), + Path::new(root_dir), + options, + )?; + zip + .finish() + .with_context(|| format!("finish {}", archive_path.display()))?; + Ok(()) +} + +fn append_zip_dir( + zip: &mut ZipWriter, + dir: &Path, + archive_dir: &Path, + options: SimpleFileOptions, +) -> Result<()> { + zip + .add_directory(path_to_archive_name(archive_dir)?, options) + .with_context(|| format!("add ZIP directory {}", archive_dir.display()))?; + + for entry in sorted_dir_entries(dir)? { + let entry_path = entry.path(); + let archive_path = archive_dir.join(entry.file_name()); + if entry_path.is_dir() { + append_zip_dir(zip, &entry_path, &archive_path, options)?; + } else { + zip + .start_file(path_to_archive_name(&archive_path)?, options) + .with_context(|| format!("add ZIP file {}", archive_path.display()))?; + let mut source = + File::open(&entry_path).with_context(|| format!("open {}", entry_path.display()))?; + io::copy(&mut source, zip) + .with_context(|| format!("write ZIP file {}", archive_path.display()))?; + } + } + + Ok(()) +} + +fn write_tar_gz_archive(layout_parent: &Path, root_dir: &str, archive_path: &Path) -> Result<()> { + let file = + File::create(archive_path).with_context(|| format!("create {}", archive_path.display()))?; + let encoder = GzEncoder::new(file, Compression::default()); + let mut tar = Builder::new(encoder); + tar + .append_dir_all(root_dir, layout_parent.join(root_dir)) + .with_context(|| { + format!( + "write TAR layout {}", + layout_parent.join(root_dir).display() + ) + })?; + tar + .into_inner() + .context("finish TAR stream")? + .finish() + .with_context(|| format!("finish {}", archive_path.display()))?; + Ok(()) +} + +fn write_sha256_file(archive_path: &Path, checksum_path: &Path) -> Result { + let file_name = archive_path + .file_name() + .and_then(|name| name.to_str()) + .context("archive path has no UTF-8 file name")?; + let checksum = compute_sha256(archive_path)?; + fs::write(checksum_path, format!("{checksum} {file_name}\n")) + .with_context(|| format!("write {}", checksum_path.display()))?; + Ok(checksum) +} + +fn checksum_path_for(artifact_path: &Path) -> Result { + let file_name = artifact_path + .file_name() + .and_then(|name| name.to_str()) + .context("artifact path has no UTF-8 file name")?; + Ok(artifact_path.with_file_name(format!("{file_name}.sha256"))) +} + +fn verify_sha256_file(artifact_path: &Path) -> Result<()> { + let checksum_path = checksum_path_for(artifact_path)?; + let expected = compute_sha256(artifact_path)?; + let file_name = artifact_path + .file_name() + .and_then(|name| name.to_str()) + .context("artifact path has no UTF-8 file name")?; + let expected_line = format!("{expected} {file_name}"); + let actual = fs::read_to_string(&checksum_path) + .with_context(|| format!("read {}", checksum_path.display()))?; + if actual.trim() != expected_line { + bail!( + "checksum file {} does not match {}", + checksum_path.display(), + artifact_path.display() + ); + } + Ok(()) +} + +fn compute_sha256(path: &Path) -> Result { + let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 8192]; + loop { + let bytes_read = file + .read(&mut buffer) + .with_context(|| format!("read {}", path.display()))?; + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + Ok(hex::encode(hasher.finalize())) +} + +fn print_artifact_summary(label: &str, root: &Path) -> Result<()> { + let mut paths = Vec::new(); + collect_file_paths(root, &mut paths)?; + print_selected_artifact_summary(label, root, &paths) +} + +fn collect_file_paths(dir: &Path, paths: &mut Vec) -> Result<()> { + for entry in sorted_dir_entries(dir)? { + let path = entry.path(); + if path.is_dir() { + collect_file_paths(&path, paths)?; + } else { + paths.push(path); + } + } + paths.sort(); + Ok(()) +} + +fn print_selected_artifact_summary(label: &str, root: &Path, paths: &[PathBuf]) -> Result<()> { + println!("{label} artifact summary:"); + if paths.is_empty() { + println!(" no files"); + return Ok(()); + } + + for path in paths { + let size = path_size(path)?; + let display_path = relative_display_path(root, path); + println!(" {display_path}\t{size} bytes"); + } + Ok(()) +} + +fn path_size(path: &Path) -> Result { + let metadata = fs::metadata(path).with_context(|| format!("inspect {}", path.display()))?; + if metadata.is_file() { + return Ok(metadata.len()); + } + + let mut size = 0_u64; + collect_dir_size(path, &mut size)?; + Ok(size) +} + +fn collect_dir_size(dir: &Path, size: &mut u64) -> Result<()> { + for entry in sorted_dir_entries(dir)? { + let path = entry.path(); + let metadata = fs::metadata(&path).with_context(|| format!("inspect {}", path.display()))?; + if metadata.is_dir() { + collect_dir_size(&path, size)?; + } else { + *size = size.saturating_add(metadata.len()); + } + } + Ok(()) +} + +fn relative_display_path(root: &Path, path: &Path) -> String { + path + .strip_prefix(root) + .unwrap_or(path) + .display() + .to_string() +} + +fn path_to_archive_name(path: &Path) -> Result { + let mut parts = Vec::new(); + for component in path.components() { + let component = component.as_os_str().to_str().with_context(|| { + format!( + "archive path contains non-UTF-8 component: {}", + path.display() + ) + })?; + parts.push(component); + } + Ok(parts.join("/")) +} + +fn path_argument(path: &Path) -> Result { + path + .to_str() + .map(ToOwned::to_owned) + .with_context(|| format!("path contains non-UTF-8 data: {}", path.display())) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +fn windows_installer_id(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '_' + } + }) + .collect() +} + +fn sorted_dir_entries(dir: &Path) -> Result> { + let mut entries = fs::read_dir(dir) + .with_context(|| format!("read {}", dir.display()))? + .collect::, _>>() + .with_context(|| format!("read entries from {}", dir.display()))?; + entries.sort_by_key(|entry| entry.file_name()); + Ok(entries) +} + +fn run(program: &str, args: &[&str]) -> Result<()> { + let mut command = Command::new(program); + configure_hidden_child(&mut command); + let status = command + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .with_context(|| format!("run {program} {}", args.join(" ")))?; + if status.success() { + Ok(()) + } else { + bail!("{program} {} failed with {status}", args.join(" ")) + } +} + +fn run_in(program: &str, args: &[&str], current_dir: &Path) -> Result<()> { + run_in_with_env(program, args, current_dir, &[]) +} + +fn run_in_with_env( + program: &str, + args: &[&str], + current_dir: &Path, + envs: &[(&str, &Path)], +) -> Result<()> { + let mut command = Command::new(program); + configure_hidden_child(&mut command); + command + .args(args) + .current_dir(current_dir) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + for (key, value) in envs { + command.env(key, value); + } + let status = command.status().with_context(|| { + format!( + "run {program} {} in {}", + args.join(" "), + current_dir.display() + ) + })?; + if status.success() { + Ok(()) + } else { + bail!("{program} {} failed with {status}", args.join(" ")) + } +} + +#[cfg(windows)] +fn configure_hidden_child(command: &mut Command) { + command.creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(windows))] +fn configure_hidden_child(_command: &mut Command) {} + +fn remove_dir_if_exists(path: &Path) -> Result<()> { + if path.exists() { + fs::remove_dir_all(path).with_context(|| format!("remove {}", path.display()))?; + } + Ok(()) +} + +#[cfg(test)] +fn replace_dir_from(source: &Path, target: &Path) -> Result<()> { + remove_dir_if_exists(target)?; + copy_dir_contents(source, target) +} + +fn copy_dir_contents(source: &Path, target: &Path) -> Result<()> { + fs::create_dir_all(target).with_context(|| format!("create {}", target.display()))?; + for entry in sorted_dir_entries(source)? { + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + if source_path.is_dir() { + copy_dir_contents(&source_path, &target_path)?; + } else { + fs::copy(&source_path, &target_path).with_context(|| { + format!( + "copy {} to {}", + source_path.display(), + target_path.display() + ) + })?; + } + } + Ok(()) +} + +fn is_macos_app_bundle(path: &Path) -> bool { + path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.to_ascii_lowercase().ends_with(".app")) +} + +#[derive(Debug, PartialEq, Eq)] +struct CoverageOptions { + output_path: PathBuf, +} + +impl CoverageOptions { + fn parse(args: Vec) -> Result { + Ok(Self { + output_path: optional_path_option(&args, "--output")? + .unwrap_or_else(|| PathBuf::from(DEFAULT_COVERAGE_REPORT_PATH)), + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct DistOptions { + out_dir: PathBuf, + target: Option, + topology: PortableTopology, +} + +impl DistOptions { + fn parse(args: Vec) -> Result { + Ok(Self { + out_dir: required_path_option(&args, "--out")?, + target: optional_string_option(&args, "--target")?, + topology: PortableTopology::parse_option(&args)?, + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct RuntimeInstallerOptions { + out_dir: PathBuf, + version: String, + target: Option, + platform: ReleasePlatform, + signing_mode: SigningMode, +} + +impl RuntimeInstallerOptions { + fn parse(args: Vec) -> Result { + let target = optional_string_option(&args, "--target")?; + let platform = ReleasePlatform::parse_option(&args, target.as_deref())?; + Ok(Self { + out_dir: required_path_option(&args, "--out")?, + version: optional_string_option(&args, "--version")? + .map_or_else(workspace_package_version, Ok)?, + target, + platform, + signing_mode: SigningMode::parse(&args), + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct VerifyDistOptions { + dir: PathBuf, + target: Option, + topology: PortableTopology, +} + +impl VerifyDistOptions { + fn parse(args: Vec) -> Result { + Ok(Self { + dir: required_path_option(&args, "--dir")?, + target: optional_string_option(&args, "--target")?, + topology: PortableTopology::parse_option(&args)?, + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct VerifyRuntimeInstallerDistOptions { + dir: PathBuf, + version: String, + platform: ReleasePlatform, +} + +impl VerifyRuntimeInstallerDistOptions { + fn parse(args: Vec) -> Result { + let target = optional_string_option(&args, "--target")?; + let platform = ReleasePlatform::parse_option(&args, target.as_deref())?; + Ok(Self { + dir: required_path_option(&args, "--dir")?, + version: optional_string_option(&args, "--version")? + .map_or_else(workspace_package_version, Ok)?, + platform, + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct ReleaseAssetsOptions { + dir: PathBuf, + version: String, + mode: ReleaseAssetMode, +} + +impl ReleaseAssetsOptions { + fn parse(args: Vec) -> Result { + Ok(Self { + dir: required_path_option(&args, "--dir")?, + version: optional_string_option(&args, "--version")? + .map_or_else(workspace_package_version, Ok)?, + mode: ReleaseAssetMode::parse(&args)?, + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct PackageOptions { + out_dir: PathBuf, + version: String, + platform: String, + target: Option, + topology: PortableTopology, +} + +impl PackageOptions { + fn parse(args: Vec) -> Result { + Ok(Self { + out_dir: required_path_option(&args, "--out")?, + version: optional_string_option(&args, "--version")? + .map_or_else(workspace_package_version, Ok)?, + platform: required_string_option(&args, "--platform")?, + target: optional_string_option(&args, "--target")?, + topology: PortableTopology::parse_option(&args)?, + }) + } +} + +fn workspace_package_version() -> Result { + workspace_package_version_from_manifest(Path::new(WORKSPACE_MANIFEST)) +} + +fn workspace_package_version_from_manifest(manifest: &Path) -> Result { + let contents = + fs::read_to_string(manifest).with_context(|| format!("read {}", manifest.display()))?; + let mut in_workspace_package = false; + + for line in contents.lines() { + let trimmed = line.split('#').next().unwrap_or_default().trim(); + if trimmed.is_empty() { + continue; + } + + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_workspace_package = trimmed == "[workspace.package]"; + continue; + } + + if in_workspace_package + && let Some((key, value)) = trimmed.split_once('=') + && key.trim() == "version" + { + let version = value.trim().trim_matches('"'); + if version.is_empty() { + bail!( + "workspace package version is empty in {}", + manifest.display() + ); + } + return Ok(version.to_string()); + } + } + + bail!( + "workspace package version not found in {}", + manifest.display() + ) +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum ArchiveKind { + Zip, + TarGz, +} + +fn required_path_option(args: &[String], option: &str) -> Result { + required_string_option(args, option).map(PathBuf::from) +} + +fn optional_path_option(args: &[String], option: &str) -> Result> { + optional_string_option(args, option).map(|value| value.map(PathBuf::from)) +} + +fn required_string_option(args: &[String], option: &str) -> Result { + optional_string_option(args, option)?.ok_or_else(|| anyhow::anyhow!("{option} is required")) +} + +fn optional_string_option(args: &[String], option: &str) -> Result> { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == option { + return iter + .next() + .cloned() + .map(Some) + .ok_or_else(|| anyhow::anyhow!("{option} requires a value")); + } + } + Ok(None) +} + +fn has_flag(args: &[String], flag: &str) -> bool { + args.iter().any(|arg| arg == flag) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xtask_help_lists_canonical_entrypoint_and_command_groups() { + let help = xtask_help_text(); + + assert!(help.contains("cargo xtask [args...]"), "{help}"); + assert!(help.contains("validation:"), "{help}"); + assert!(help.contains("documentation:"), "{help}"); + assert!(help.contains("release and packaging:"), "{help}"); + assert!(help.contains("docs-check"), "{help}"); + assert!(help.contains("verify-assets"), "{help}"); + assert!(help.contains("verify-release-assets"), "{help}"); + assert!(help.contains("verify-release-identity"), "{help}"); + assert!(help.contains("verify-workspace-topology"), "{help}"); + assert!(help.contains("dev-run"), "{help}"); + } + + #[test] + fn xtask_command_help_and_list_are_stable() { + let list = xtask_command_list_text(); + assert!(list.lines().any(|line| line == "check"), "{list}"); + assert!(list.lines().any(|line| line == "docs-build"), "{list}"); + assert!( + list.lines().any(|line| line == "runtime-installer"), + "{list}" + ); + assert!( + list.lines().any(|line| line == "verify-release-identity"), + "{list}" + ); + assert!( + list.lines().any(|line| line == "verify-workspace-topology"), + "{list}" + ); + assert!(list.lines().any(|line| line == "verify-assets"), "{list}"); + + let docs_help = xtask_command_help_text("docs-check").unwrap(); + assert!(docs_help.contains("cargo xtask docs-check"), "{docs_help}"); + assert!(docs_help.contains("Astro"), "{docs_help}"); + } + + #[test] + fn cargo_xtask_alias_is_configured() { + let config_path = workspace_root().join(".cargo").join("config.toml"); + let config = fs::read_to_string(&config_path).unwrap(); + + assert!(config.contains("[alias]"), "{config}"); + assert!(config.contains("xtask = \"run -p xtask --\""), "{config}"); + } + + #[test] + fn docs_site_dir_points_to_docs_package() { + assert!(docs_site_dir().join("package.json").is_file()); + assert!(docs_site_dir().join("bun.lock").is_file()); + } + + #[test] + fn coverage_toolchain_selection_respects_override_active_and_windows_fallback() { + assert_eq!( + coverage_toolchain_argument(Some("nightly"), Some("stable"), true, true).unwrap(), + Some("+nightly".to_string()) + ); + assert_eq!( + coverage_toolchain_argument(Some("+beta"), None, false, false).unwrap(), + Some("+beta".to_string()) + ); + assert_eq!( + coverage_toolchain_argument(None, Some("stable-x86_64-pc-windows-msvc"), true, true).unwrap(), + None + ); + assert_eq!( + coverage_toolchain_argument(None, Some("stable-x86_64-pc-windows-gnu"), true, true).unwrap(), + Some(format!("+{WINDOWS_COVERAGE_TOOLCHAIN}")) + ); + assert_eq!( + coverage_toolchain_argument(None, Some("stable"), true, true).unwrap(), + Some(format!("+{WINDOWS_COVERAGE_TOOLCHAIN}")) + ); + assert_eq!( + coverage_toolchain_argument(None, None, true, false).unwrap(), + Some(format!("+{WINDOWS_COVERAGE_TOOLCHAIN}")) + ); + assert_eq!( + coverage_toolchain_argument(None, None, false, false).unwrap(), + None + ); + } + + #[test] + fn parse_path_option_reads_value_after_option() { + let path = + required_path_option(&["--out".to_string(), "target/dist".to_string()], "--out").unwrap(); + + assert_eq!(path, PathBuf::from("target/dist")); + } + + #[test] fn optional_string_option_reads_value_after_option() { let value = optional_string_option( &[ @@ -580,145 +3317,783 @@ mod tests { ) .unwrap(); - assert_eq!(value, Some("x86_64-unknown-linux-gnu".to_string())); + assert_eq!(value, Some("x86_64-unknown-linux-gnu".to_string())); + } + + #[test] + fn dist_options_parse_target() { + let options = DistOptions::parse(vec![ + "--out".to_string(), + "target/dist".to_string(), + "--target".to_string(), + "x86_64-unknown-linux-gnu".to_string(), + ]) + .unwrap(); + + assert_eq!( + options, + DistOptions { + out_dir: PathBuf::from("target/dist"), + target: Some("x86_64-unknown-linux-gnu".to_string()), + topology: PortableTopology::DEFAULT + } + ); + } + + #[test] + fn dist_options_rejects_unknown_topology() { + let error = DistOptions::parse(vec![ + "--out".to_string(), + "target/dist".to_string(), + "--topology".to_string(), + "alternate".to_string(), + ]) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("unknown portable topology `alternate`") + ); + } + + #[test] + fn portable_topology_rejects_unknown_value() { + let error = DistOptions::parse(vec![ + "--out".to_string(), + "target/dist".to_string(), + "--topology".to_string(), + "everything".to_string(), + ]) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("unknown portable topology `everything`") + ); + } + + #[test] + fn package_options_parse_required_values() { + let options = PackageOptions::parse(vec![ + "--out".to_string(), + "target/artifacts".to_string(), + "--version".to_string(), + "0.1.0".to_string(), + "--platform".to_string(), + "linux-x64".to_string(), + "--target".to_string(), + "x86_64-unknown-linux-gnu".to_string(), + ]) + .unwrap(); + + assert_eq!( + options, + PackageOptions { + out_dir: PathBuf::from("target/artifacts"), + version: "0.1.0".to_string(), + platform: "linux-x64".to_string(), + target: Some("x86_64-unknown-linux-gnu".to_string()), + topology: PortableTopology::DEFAULT + } + ); + } + + #[test] + fn package_options_default_to_workspace_package_version() { + let options = PackageOptions::parse(vec![ + "--out".to_string(), + "target/artifacts".to_string(), + "--platform".to_string(), + "linux-x64".to_string(), + ]) + .unwrap(); + + assert_eq!(options.version, workspace_package_version().unwrap()); + } + + #[test] + fn workspace_package_version_from_manifest_reads_workspace_package_version() { + let dir = unique_temp_dir("workspace-version"); + let manifest = dir.join("Cargo.toml"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + &manifest, + r#" +[package] +name = "sample" +version = "0.1.0" + +[workspace.package] +version = "1.2.3" +edition = "2024" +"#, + ) + .unwrap(); + + let version = workspace_package_version_from_manifest(&manifest).unwrap(); + + assert_eq!(version, "1.2.3"); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn workspace_package_version_from_manifest_reports_missing_and_empty_versions() { + let dir = unique_temp_dir("workspace-version-errors"); + fs::create_dir_all(&dir).unwrap(); + + let missing = dir.join("missing-version.toml"); + fs::write( + &missing, + r#" +[package] +name = "sample" +version = "0.1.0" + +[workspace.package] +edition = "2024" +"#, + ) + .unwrap(); + let missing_error = workspace_package_version_from_manifest(&missing).unwrap_err(); + assert!(missing_error.to_string().contains("version not found")); + + let empty = dir.join("empty-version.toml"); + fs::write( + &empty, + r#" +[workspace.package] +version = "" +"#, + ) + .unwrap(); + let empty_error = workspace_package_version_from_manifest(&empty).unwrap_err(); + assert!(empty_error.to_string().contains("version is empty")); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_workspace_topology_manifest_accepts_expected_members() { + let dir = unique_temp_dir("workspace-topology"); + fs::create_dir_all(&dir).unwrap(); + write_workspace_topology_fixture(&dir); + + verify_workspace_topology_manifest(&dir.join("Cargo.toml")).unwrap(); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_workspace_topology_manifest_rejects_undocumented_member() { + let dir = unique_temp_dir("workspace-topology-extra"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("Cargo.toml"), + workspace_topology_manifest(&["crates/cadder2"]), + ) + .unwrap(); + + let error = verify_workspace_topology_manifest(&dir.join("Cargo.toml")).unwrap_err(); + + assert!(error.to_string().contains("unexpected: crates/cadder2")); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_workspace_topology_manifest_rejects_wrong_member_package_name() { + let dir = unique_temp_dir("workspace-topology-wrong-package"); + fs::create_dir_all(&dir).unwrap(); + write_workspace_topology_fixture(&dir); + let cadder = WORKSPACE_MEMBER_CONTRACTS + .iter() + .find(|contract| contract.path == "crates/cadder") + .copied() + .unwrap(); + write_workspace_member_manifest(&dir, cadder, "cadder-web"); + + let error = verify_workspace_topology_manifest(&dir.join("Cargo.toml")).unwrap_err(); + + assert!( + error + .to_string() + .contains("package name must be \"cadder\"") + ); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_release_identity_accepts_current_repo_metadata() { + verify_release_identity_files( + Path::new(WORKSPACE_MANIFEST), + &workspace_root().join(DOCS_DOWNLOAD_SCRIPT), + ) + .unwrap(); + } + + #[test] + fn verify_assets_accepts_current_repo_contract() { + verify_assets_at(&workspace_root()).unwrap(); + } + + #[test] + fn verify_canonical_asset_copies_rejects_drifted_docs_copy() { + let dir = unique_temp_dir("asset-copy-drift"); + fs::create_dir_all(dir.join("assets")).unwrap(); + fs::create_dir_all(dir.join("docs/site/src/assets")).unwrap(); + fs::write(dir.join("assets/logo.png"), b"canonical").unwrap(); + fs::write(dir.join("docs/site/src/assets/logo.png"), b"copy").unwrap(); + + let error = verify_canonical_asset_copies( + &dir, + &[( + "docs logo pipeline copy", + "assets/logo.png", + "docs/site/src/assets/logo.png", + )], + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("docs logo pipeline copy at docs/site/src/assets/logo.png differs") + ); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_obsolete_assets_absent_rejects_scaffold_asset() { + let dir = unique_temp_dir("obsolete-asset"); + fs::create_dir_all(dir.join("scaffold/public")).unwrap(); + fs::write(dir.join("scaffold/public/vite.svg"), "").unwrap(); + + let error = + verify_obsolete_assets_absent(&dir, &[("Vite starter logo", "scaffold/public/vite.svg")]) + .unwrap_err(); + + assert!(error.to_string().contains("obsolete scaffold asset")); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_docs_webmanifest_rejects_empty_product_name() { + let dir = unique_temp_dir("webmanifest-product-name"); + let public_dir = dir.join("docs/site/public"); + fs::create_dir_all(&public_dir).unwrap(); + fs::write(public_dir.join("android-chrome-192x192.png"), b"icon").unwrap(); + let manifest = public_dir.join("site.webmanifest"); + fs::write( + &manifest, + serde_json::to_string_pretty(&json!({ + "name": "", + "short_name": "Cadder", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + }, + ], + })) + .unwrap(), + ) + .unwrap(); + + let error = verify_docs_webmanifest(&dir, &manifest).unwrap_err(); + + assert!(error.to_string().contains("web manifest name")); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_docs_download_metadata_rejects_missing_asset_pattern() { + let dir = unique_temp_dir("download-metadata-drift"); + let script = dir.join("cadder-downloads.js"); + fs::create_dir_all(&dir).unwrap(); + fs::write(&script, "const cadderRuntimeAssetPatterns = {};\n").unwrap(); + + let error = verify_docs_download_metadata(&script).unwrap_err(); + + assert!(error.to_string().contains("runtime Windows archive")); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_release_profile_manifest_accepts_expected_policy() { + let dir = unique_temp_dir("release-profile"); + let manifest = dir.join("Cargo.toml"); + fs::create_dir_all(&dir).unwrap(); + fs::write(&manifest, expected_release_profile_manifest()).unwrap(); + + verify_release_profile_manifest(&manifest).unwrap(); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_release_profile_manifest_rejects_drifted_release_setting() { + let dir = unique_temp_dir("release-profile-drift"); + let manifest = dir.join("Cargo.toml"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + &manifest, + expected_release_profile_manifest().replace("opt-level = \"s\"", "opt-level = 3"), + ) + .unwrap(); + + let error = verify_release_profile_manifest(&manifest).unwrap_err(); + + assert!( + error + .to_string() + .contains("profile.release.opt-level expected \"s\", found 3") + ); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_release_profile_manifest_rejects_missing_profiling_setting() { + let dir = unique_temp_dir("release-profile-missing"); + let manifest = dir.join("Cargo.toml"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + &manifest, + expected_release_profile_manifest().replace("strip = \"none\"\n", ""), + ) + .unwrap(); + + let error = verify_release_profile_manifest(&manifest).unwrap_err(); + + assert!( + error + .to_string() + .contains("profile.profiling.strip missing") + ); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn coverage_options_uses_default_report_path() { + let options = CoverageOptions::parse(Vec::new()).unwrap(); + + assert_eq!( + options, + CoverageOptions { + output_path: PathBuf::from(DEFAULT_COVERAGE_REPORT_PATH) + } + ); + } + + #[test] + fn coverage_options_parse_output_path() { + let options = CoverageOptions::parse(vec![ + "--output".to_string(), + "target/custom/summary.lcov".to_string(), + ]) + .unwrap(); + + assert_eq!( + options, + CoverageOptions { + output_path: PathBuf::from("target/custom/summary.lcov") + } + ); + } + + #[test] + fn runtime_installer_options_parse_target_platform_and_signing() { + let options = RuntimeInstallerOptions::parse(vec![ + "--out".to_string(), + "target/runtime-installers".to_string(), + "--version".to_string(), + "1.2.3".to_string(), + "--target".to_string(), + "x86_64-pc-windows-msvc".to_string(), + "--sign".to_string(), + ]) + .unwrap(); + + assert_eq!( + options, + RuntimeInstallerOptions { + out_dir: PathBuf::from("target/runtime-installers"), + version: "1.2.3".to_string(), + target: Some("x86_64-pc-windows-msvc".to_string()), + platform: ReleasePlatform::WindowsX64, + signing_mode: SigningMode::SignedRelease + } + ); + } + + #[test] + fn verify_runtime_installer_dist_options_parse_required_values() { + let options = VerifyRuntimeInstallerDistOptions::parse(vec![ + "--dir".to_string(), + "target/runtime-installers".to_string(), + "--version".to_string(), + "1.2.3".to_string(), + "--platform".to_string(), + "linux-x64".to_string(), + ]) + .unwrap(); + + assert_eq!( + options, + VerifyRuntimeInstallerDistOptions { + dir: PathBuf::from("target/runtime-installers"), + version: "1.2.3".to_string(), + platform: ReleasePlatform::LinuxX64 + } + ); + } + + #[test] + fn release_platform_parses_and_infers_targets() { + assert_eq!( + ReleasePlatform::parse("macos-arm64").unwrap(), + ReleasePlatform::MacosArm64 + ); + assert_eq!( + ReleasePlatform::infer_from_target(Some("x86_64-unknown-linux-gnu")).unwrap(), + ReleasePlatform::LinuxX64 + ); + + let error = ReleasePlatform::parse("freebsd-x64").unwrap_err(); + assert!( + error + .to_string() + .contains("unknown release platform `freebsd-x64`") + ); + } + + #[test] + fn release_platform_defines_runtime_installer_artifact_kinds() { + assert_eq!( + ReleasePlatform::WindowsX64.required_runtime_installer_artifact_kinds(), + &[RuntimeInstallerArtifactKind::WindowsMsi] + ); + assert_eq!( + ReleasePlatform::LinuxX64.required_runtime_installer_artifact_kinds(), + &[ + RuntimeInstallerArtifactKind::LinuxDeb, + RuntimeInstallerArtifactKind::LinuxRpm + ] + ); + assert_eq!( + ReleasePlatform::MacosArm64.required_runtime_installer_artifact_kinds(), + &[RuntimeInstallerArtifactKind::MacosPkg] + ); + } + + #[test] + fn runtime_installer_artifact_names_use_canonical_release_platforms() { + assert_eq!( + runtime_installer_artifact_name( + "1.2.3", + ReleasePlatform::WindowsX64, + RuntimeInstallerArtifactKind::WindowsMsi + ), + "cadder-runtime-1.2.3-windows-x64.msi" + ); + assert_eq!( + runtime_installer_artifact_name( + "1.2.3", + ReleasePlatform::LinuxX64, + RuntimeInstallerArtifactKind::LinuxDeb + ), + "cadder-runtime-1.2.3-linux-x64.deb" + ); + } + + #[test] + fn verify_release_assets_accepts_complete_dry_run_matrix() { + let dir = unique_temp_dir("release-assets"); + fs::create_dir_all(&dir).unwrap(); + write_complete_release_asset_matrix(&dir, "1.2.3"); + + verify_release_assets(&ReleaseAssetsOptions { + dir: dir.clone(), + version: "1.2.3".to_string(), + mode: ReleaseAssetMode::DryRun, + }) + .unwrap(); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_release_assets_rejects_missing_portable_checksum() { + let dir = unique_temp_dir("release-assets-missing-checksum"); + fs::create_dir_all(&dir).unwrap(); + write_complete_release_asset_matrix(&dir, "1.2.3"); + fs::remove_file(dir.join("cadder-1.2.3-windows-x64.zip.sha256")).unwrap(); + + let error = verify_release_assets(&ReleaseAssetsOptions { + dir: dir.clone(), + version: "1.2.3".to_string(), + mode: ReleaseAssetMode::DryRun, + }) + .unwrap_err(); + assert!( + error + .to_string() + .contains("cadder-1.2.3-windows-x64.zip.sha256") + ); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_release_assets_rejects_portable_archive_with_wrong_contents() { + let dir = unique_temp_dir("release-assets-wrong-archive"); + fs::create_dir_all(&dir).unwrap(); + write_complete_release_asset_matrix(&dir, "1.2.3"); + let archive = dir.join("cadder-1.2.3-windows-x64.zip"); + fs::remove_file(&archive).unwrap(); + fs::remove_file(dir.join("cadder-1.2.3-windows-x64.zip.sha256")).unwrap(); + write_release_asset(&dir, "cadder-1.2.3-windows-x64.zip"); + + let error = verify_release_assets(&ReleaseAssetsOptions { + dir: dir.clone(), + version: "1.2.3".to_string(), + mode: ReleaseAssetMode::DryRun, + }) + .unwrap_err(); + assert!(error.to_string().contains("read ZIP")); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_release_assets_rejects_missing_runtime_installer() { + let dir = unique_temp_dir("release-assets-missing-runtime-installer"); + fs::create_dir_all(&dir).unwrap(); + write_complete_release_asset_matrix(&dir, "1.2.3"); + fs::remove_file(dir.join("cadder-runtime-1.2.3-windows-x64.msi")).unwrap(); + + let error = verify_release_assets(&ReleaseAssetsOptions { + dir: dir.clone(), + version: "1.2.3".to_string(), + mode: ReleaseAssetMode::DryRun, + }) + .unwrap_err(); + assert!( + error + .to_string() + .contains("cadder-runtime-1.2.3-windows-x64.msi") + ); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_release_assets_rejects_runtime_installer_manifest_with_extra_install_path() { + let dir = unique_temp_dir("release-assets-runtime-manifest-extra-path"); + fs::create_dir_all(&dir).unwrap(); + write_complete_release_asset_matrix(&dir, "1.2.3"); + let manifest = dir.join("cadder-runtime-1.2.3-windows-x64.msi.manifest.json"); + let mut value = read_json_value(&manifest).unwrap(); + value["installPaths"] + .as_array_mut() + .unwrap() + .push(JsonValue::String( + r"C:\Program Files\Cadder\extra-tool.exe".to_string(), + )); + fs::write(&manifest, serde_json::to_string_pretty(&value).unwrap()).unwrap(); + write_sha256_file(&manifest, &checksum_path_for(&manifest).unwrap()).unwrap(); + + let error = verify_release_assets(&ReleaseAssetsOptions { + dir: dir.clone(), + version: "1.2.3".to_string(), + mode: ReleaseAssetMode::DryRun, + }) + .unwrap_err(); + assert!( + error + .to_string() + .contains("contains unexpected install path"), + "{error}" + ); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_release_assets_rejects_unexpected_runtime_installer_asset() { + let dir = unique_temp_dir("release-assets-unexpected-runtime-installer"); + fs::create_dir_all(&dir).unwrap(); + write_complete_release_asset_matrix(&dir, "1.2.3"); + fs::write(dir.join("cadder-runtime-1.2.3-linux-x64.AppImage"), b"bad").unwrap(); + + let error = verify_release_assets(&ReleaseAssetsOptions { + dir: dir.clone(), + version: "1.2.3".to_string(), + mode: ReleaseAssetMode::DryRun, + }) + .unwrap_err(); + assert!( + error + .to_string() + .contains("unexpected runtime installer release asset") + ); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn coverage_command_args_emit_workspace_lcov_report() { + let args = coverage_command_args(Path::new("target/custom/summary.lcov")).unwrap(); + + let expected_tail = [ + "llvm-cov".to_string(), + "--workspace".to_string(), + "--lcov".to_string(), + "--output-path".to_string(), + "target/custom/summary.lcov".to_string(), + ]; + let tail_start = args.len() - expected_tail.len(); + + assert_eq!(&args[tail_start..], expected_tail); + assert!(tail_start <= 1, "{args:?}"); + if tail_start == 1 { + assert!(args[0].starts_with('+'), "{args:?}"); + } } #[test] - fn dist_options_parse_target() { - let options = DistOptions::parse(vec![ - "--out".to_string(), - "target/dist".to_string(), - "--target".to_string(), - "x86_64-unknown-linux-gnu".to_string(), - ]) + fn read_lcov_line_coverage_sums_file_records() { + let dir = unique_temp_dir("lcov-line-coverage"); + fs::create_dir_all(&dir).unwrap(); + let report = dir.join("coverage.lcov"); + fs::write( + &report, + "TN:\nSF:first.rs\nDA:10,1\nDA:11,0\nDA:11,3\nend_of_record\nTN:\nSF:second.rs\nDA:20,5\nDA:21,0\nend_of_record\n", + ) .unwrap(); + let coverage = read_lcov_line_coverage(&report).unwrap(); + assert_eq!( - options, - DistOptions { - out_dir: PathBuf::from("target/dist"), - target: Some("x86_64-unknown-linux-gnu".to_string()) + coverage, + LcovLineCoverage { + covered: 3, + total: 4 } ); + assert_eq!(coverage.percent(), 75.0); + fs::remove_dir_all(&dir).unwrap(); } #[test] - fn package_options_parse_required_values() { - let options = PackageOptions::parse(vec![ - "--out".to_string(), - "target/artifacts".to_string(), - "--version".to_string(), - "0.1.0".to_string(), - "--platform".to_string(), - "linux-x64".to_string(), - "--target".to_string(), - "x86_64-unknown-linux-gnu".to_string(), - ]) + fn read_lcov_line_coverage_accepts_da_checksum_fields() { + let dir = unique_temp_dir("lcov-line-coverage-checksum"); + fs::create_dir_all(&dir).unwrap(); + let report = dir.join("coverage.lcov"); + fs::write( + &report, + "TN:\nSF:lib.rs\nDA:10,1,0123456789abcdef\nDA:11,0,abcdef0123456789\nend_of_record\n", + ) .unwrap(); + let coverage = read_lcov_line_coverage(&report).unwrap(); + assert_eq!( - options, - PackageOptions { - out_dir: PathBuf::from("target/artifacts"), - version: "0.1.0".to_string(), - platform: "linux-x64".to_string(), - target: Some("x86_64-unknown-linux-gnu".to_string()) + coverage, + LcovLineCoverage { + covered: 1, + total: 2 } ); + fs::remove_dir_all(&dir).unwrap(); } #[test] - fn package_options_default_to_workspace_package_version() { - let options = PackageOptions::parse(vec![ - "--out".to_string(), - "target/artifacts".to_string(), - "--platform".to_string(), - "linux-x64".to_string(), - ]) - .unwrap(); + fn read_lcov_line_coverage_rejects_empty_reports() { + let dir = unique_temp_dir("empty-lcov-line-coverage"); + fs::create_dir_all(&dir).unwrap(); + let report = dir.join("coverage.lcov"); + fs::write(&report, "TN:\nend_of_record\n").unwrap(); - assert_eq!(options.version, workspace_package_version().unwrap()); + let error = read_lcov_line_coverage(&report).unwrap_err(); + + assert!(error.to_string().contains("did not contain any DA")); + fs::remove_dir_all(&dir).unwrap(); } #[test] - fn workspace_package_version_from_manifest_reads_workspace_package_version() { - let dir = unique_temp_dir("workspace-version"); - let manifest = dir.join("Cargo.toml"); + fn read_lcov_line_coverage_rejects_da_before_source_file() { + let dir = unique_temp_dir("lcov-da-before-source-file"); fs::create_dir_all(&dir).unwrap(); - fs::write( - &manifest, - r#" -[package] -name = "sample" -version = "0.1.0" - -[workspace.package] -version = "1.2.3" -edition = "2024" -"#, - ) - .unwrap(); + let report = dir.join("coverage.lcov"); + fs::write(&report, "TN:\nDA:1,1\nend_of_record\n").unwrap(); - let version = workspace_package_version_from_manifest(&manifest).unwrap(); + let error = read_lcov_line_coverage(&report).unwrap_err(); - assert_eq!(version, "1.2.3"); + assert!(error.to_string().contains("DA record appeared before SF")); fs::remove_dir_all(&dir).unwrap(); } #[test] - fn coverage_options_uses_default_report_path() { - let options = CoverageOptions::parse(Vec::new()).unwrap(); + fn read_lcov_line_coverage_rejects_malformed_da_records() { + let dir = unique_temp_dir("malformed-lcov-da"); + fs::create_dir_all(&dir).unwrap(); + let report = dir.join("coverage.lcov"); + fs::write(&report, "TN:\nSF:lib.rs\nDA:abc,1\nend_of_record\n").unwrap(); - assert_eq!( - options, - CoverageOptions { - output_path: PathBuf::from(DEFAULT_COVERAGE_REPORT_PATH) - } - ); + let error = read_lcov_line_coverage(&report).unwrap_err(); + + assert!(error.to_string().contains("parse DA source line")); + fs::remove_dir_all(&dir).unwrap(); } #[test] - fn coverage_options_parse_output_path() { - let options = CoverageOptions::parse(vec![ - "--output".to_string(), - "target/custom/summary.json".to_string(), - ]) - .unwrap(); + fn enforce_lcov_line_threshold_accepts_exact_threshold() { + let dir = unique_temp_dir("threshold-lcov-line-coverage"); + fs::create_dir_all(&dir).unwrap(); + let report = dir.join("coverage.lcov"); + fs::write(&report, lcov_report_with_line_counts(85, 100)).unwrap(); - assert_eq!( - options, - CoverageOptions { - output_path: PathBuf::from("target/custom/summary.json") - } - ); + enforce_lcov_line_threshold(&report, 85.0).unwrap(); + + fs::remove_dir_all(&dir).unwrap(); } #[test] - fn coverage_command_args_enforce_workspace_line_threshold() { - let args = coverage_command_args(Path::new("target/custom/summary.json")).unwrap(); + fn enforce_lcov_line_threshold_reports_low_coverage() { + let dir = unique_temp_dir("low-lcov-line-coverage"); + fs::create_dir_all(&dir).unwrap(); + let report = dir.join("coverage.lcov"); + fs::write(&report, lcov_report_with_line_counts(84, 100)).unwrap(); + + let error = enforce_lcov_line_threshold(&report, 85.0).unwrap_err(); + + assert!(error.to_string().contains("below required 85.00%")); + assert!(error.to_string().contains("84/100")); + fs::remove_dir_all(&dir).unwrap(); + } - let mut expected = Vec::new(); - if cfg!(windows) { - expected.push(format!("+{WINDOWS_COVERAGE_TOOLCHAIN}")); + fn lcov_report_with_line_counts(covered: u64, total: u64) -> String { + let mut report = "TN:\nSF:lib.rs\n".to_string(); + for line in 1..=total { + let count = u64::from(line <= covered); + report.push_str(&format!("DA:{line},{count}\n")); } - expected.extend([ - "llvm-cov".to_string(), - "--workspace".to_string(), - "--json".to_string(), - "--summary-only".to_string(), - "--fail-under-lines".to_string(), - "85".to_string(), - "--output-path".to_string(), - "target/custom/summary.json".to_string(), - ]); + report.push_str("end_of_record\n"); + report + } - assert_eq!(args, expected); + #[test] + fn coverage_command_args_use_workspace_lcov_without_package_exclusions() { + assert!(COVERAGE_EXCLUDED_PACKAGES.is_empty()); + assert!(COVERAGE_IGNORED_FILENAME_REGEX.is_empty()); } #[test] fn ensure_parent_dir_creates_report_directory() { let dir = unique_temp_dir("coverage-report-parent"); - let report_path = dir.join("nested").join("summary.json"); + let report_path = dir.join("nested").join("summary.lcov"); ensure_parent_dir(&report_path).unwrap(); @@ -736,8 +4111,18 @@ edition = "2024" #[test] fn portable_layout_includes_expected_binaries() { assert_eq!( - portable_binaries(), - ["cadderd", "cadderctl", "cadder-mcp", "cadder-tui", "caddy"] + PortableTopology::Runtime.binaries(), + &["cadderd", "cadder", "caddy"] + ); + } + + #[test] + fn portable_topology_archive_stem_uses_runtime_name() { + assert_eq!( + PortableTopology::Runtime + .package_archive_stem("1.2.3", "windows-x64") + .unwrap(), + "cadder-1.2.3-windows-x64" ); } @@ -811,6 +4196,7 @@ edition = "2024" let error = verify_dist(&VerifyDistOptions { dir: dir.clone(), target: None, + topology: PortableTopology::Runtime, }) .unwrap_err(); @@ -822,13 +4208,14 @@ edition = "2024" fn verify_dist_rejects_missing_sample_config_after_binaries_exist() { let dir = unique_temp_dir("missing-sample-config"); fs::create_dir_all(&dir).unwrap(); - for binary in portable_binaries() { + for binary in PortableTopology::Runtime.binaries() { fs::write(dir.join(exe_name(binary, None)), b"not executable").unwrap(); } let error = verify_dist(&VerifyDistOptions { dir: dir.clone(), target: None, + topology: PortableTopology::Runtime, }) .unwrap_err(); @@ -840,6 +4227,103 @@ edition = "2024" fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn verify_dist_accepts_fake_portable_layout_and_binary_contract() { + let dir = unique_temp_dir("portable-layout"); + fs::create_dir_all(&dir).unwrap(); + write_fake_portable_executable_layout(&dir, PortableTopology::Runtime); + + verify_dist(&VerifyDistOptions { + dir: dir.clone(), + target: None, + topology: PortableTopology::Runtime, + }) + .unwrap(); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_dist_rejects_extra_portable_files() { + let dir = unique_temp_dir("portable-extra-files"); + fs::create_dir_all(&dir).unwrap(); + write_fake_portable_executable_layout(&dir, PortableTopology::Runtime); + fs::write(dir.join("extra.txt"), b"extra").unwrap(); + + let error = verify_dist(&VerifyDistOptions { + dir: dir.clone(), + target: None, + topology: PortableTopology::Runtime, + }) + .unwrap_err(); + + assert!(error.to_string().contains("contains unexpected file set")); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn dist_with_builder_copies_target_release_binaries_and_sample_config() { + let unique = unique_suffix(); + let target = format!("cadder-test-windows-{unique}"); + let release_dir = PathBuf::from("target").join(&target).join("release"); + let out_dir = unique_temp_dir("dist-layout"); + fs::create_dir_all(&release_dir).unwrap(); + fs::create_dir_all(&out_dir).unwrap(); + fs::write(out_dir.join("cadder.exe"), b"stale operator").unwrap(); + + let result = dist_with_builder( + DistOptions { + out_dir: out_dir.clone(), + target: Some(target.clone()), + topology: PortableTopology::Runtime, + }, + |requested_target, topology| { + assert_eq!(requested_target, Some(target.as_str())); + assert_eq!(topology, PortableTopology::Runtime); + write_fake_portable_executable_layout(&release_dir, topology); + Ok(()) + }, + ); + + result.unwrap(); + for binary in PortableTopology::Runtime.binaries() { + assert!(out_dir.join(format!("{binary}.exe")).is_file()); + } + assert_ne!( + fs::read(out_dir.join("cadder.exe")).unwrap(), + b"stale operator" + ); + assert_eq!( + fs::read_to_string(out_dir.join("cadder.toml")).unwrap(), + SAMPLE_CADDER_TOML + ); + + fs::remove_dir_all(&out_dir).unwrap(); + fs::remove_dir_all(PathBuf::from("target").join(&target)).unwrap(); + } + + #[test] + fn replace_dir_from_recursively_overwrites_existing_target_tree() { + let dir = unique_temp_dir("replace-tree"); + let source = dir.join("source"); + let target = dir.join("target"); + fs::create_dir_all(source.join("nested")).unwrap(); + fs::create_dir_all(target.join("stale")).unwrap(); + fs::write(source.join("root.txt"), "root").unwrap(); + fs::write(source.join("nested").join("child.txt"), "child").unwrap(); + fs::write(target.join("stale").join("old.txt"), "old").unwrap(); + + replace_dir_from(&source, &target).unwrap(); + + assert_eq!(fs::read_to_string(target.join("root.txt")).unwrap(), "root"); + assert_eq!( + fs::read_to_string(target.join("nested").join("child.txt")).unwrap(), + "child" + ); + assert!(!target.join("stale").exists()); + fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn write_sha256_file_writes_hash_and_file_name() { let dir = unique_temp_dir("checksum"); @@ -855,6 +4339,61 @@ edition = "2024" fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn artifact_summary_helpers_collect_file_paths_and_directory_sizes() { + let dir = unique_temp_dir("artifact-summary"); + let nested = dir.join("nested"); + fs::create_dir_all(&nested).unwrap(); + fs::write(dir.join("root.bin"), [1, 2, 3]).unwrap(); + fs::write(nested.join("child.bin"), [4, 5]).unwrap(); + + let mut paths = Vec::new(); + collect_file_paths(&dir, &mut paths).unwrap(); + + assert_eq!( + paths, + vec![dir.join("nested").join("child.bin"), dir.join("root.bin")] + ); + assert_eq!(path_size(&nested).unwrap(), 2); + assert_eq!( + relative_display_path(&dir, &nested.join("child.bin")), + PathBuf::from("nested") + .join("child.bin") + .display() + .to_string() + ); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn run_helpers_report_process_success_failure_cwd_and_env() { + let dir = unique_temp_dir("process-helper"); + let cwd = dir.join("work"); + let env_value = dir.join("env-value"); + fs::create_dir_all(&cwd).unwrap(); + let helper = build_process_helper(&dir); + let helper = helper.to_str().unwrap(); + let cwd_arg = cwd.to_str().unwrap(); + let env_arg = env_value.to_str().unwrap(); + + run(helper, &["ok"]).unwrap(); + run_in(helper, &["ok"], &cwd).unwrap(); + run_in_with_env( + helper, + &["cwd-env", cwd_arg, env_arg], + &cwd, + &[("CADDER_XTASK_PROCESS_HELPER", &env_value)], + ) + .unwrap(); + + let run_error = run(helper, &["fail"]).unwrap_err(); + assert!(run_error.to_string().contains("failed with")); + let run_in_error = run_in(helper, &["fail"], &cwd).unwrap_err(); + assert!(run_in_error.to_string().contains("failed with")); + + fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn package_with_dist_writes_windows_zip_and_checksum() { let out_dir = unique_temp_dir("windows-package"); @@ -866,6 +4405,7 @@ edition = "2024" version: "1.2.3".to_string(), platform: "windows-x64".to_string(), target: Some("x86_64-pc-windows-msvc".to_string()), + topology: PortableTopology::Runtime, }, |dist_options| { assert_eq!(dist_options.out_dir, expected_layout); @@ -873,15 +4413,11 @@ edition = "2024" dist_options.target, Some("x86_64-pc-windows-msvc".to_string()) ); + assert_eq!(dist_options.topology, PortableTopology::Runtime); write_fake_portable_layout( &dist_options.out_dir, - &[ - "cadderd.exe", - "cadderctl.exe", - "cadder-mcp.exe", - "cadder-tui.exe", - "caddy.exe", - ], + &["cadderd.exe", "cadder.exe", "caddy.exe"], + true, ) }, ) @@ -895,9 +4431,7 @@ edition = "2024" &archive_path, &[ "cadder-1.2.3-windows-x64/", - "cadder-1.2.3-windows-x64/cadderctl.exe", - "cadder-1.2.3-windows-x64/cadder-mcp.exe", - "cadder-1.2.3-windows-x64/cadder-tui.exe", + "cadder-1.2.3-windows-x64/cadder.exe", "cadder-1.2.3-windows-x64/cadder.toml", "cadder-1.2.3-windows-x64/cadderd.exe", "cadder-1.2.3-windows-x64/caddy.exe", @@ -923,6 +4457,7 @@ edition = "2024" version: "1.2.3".to_string(), platform: "linux-x64".to_string(), target: Some("x86_64-unknown-linux-gnu".to_string()), + topology: PortableTopology::Runtime, }, |dist_options| { assert_eq!(dist_options.out_dir, expected_layout); @@ -930,10 +4465,8 @@ edition = "2024" dist_options.target, Some("x86_64-unknown-linux-gnu".to_string()) ); - write_fake_portable_layout( - &dist_options.out_dir, - &["cadderd", "cadderctl", "cadder-mcp", "cadder-tui", "caddy"], - ) + assert_eq!(dist_options.topology, PortableTopology::Runtime); + write_fake_portable_layout(&dist_options.out_dir, &["cadderd", "cadder", "caddy"], true) }, ) .unwrap(); @@ -946,9 +4479,7 @@ edition = "2024" &archive_path, &[ "cadder-1.2.3-linux-x64/", - "cadder-1.2.3-linux-x64/cadderctl", - "cadder-1.2.3-linux-x64/cadder-mcp", - "cadder-1.2.3-linux-x64/cadder-tui", + "cadder-1.2.3-linux-x64/cadder", "cadder-1.2.3-linux-x64/cadder.toml", "cadder-1.2.3-linux-x64/cadderd", "cadder-1.2.3-linux-x64/caddy", @@ -963,16 +4494,190 @@ edition = "2024" fs::remove_dir_all(&out_dir).unwrap(); } - fn write_fake_portable_layout(dir: &Path, binaries: &[&str]) -> Result<()> { + fn write_fake_portable_layout(dir: &Path, binaries: &[&str], include_config: bool) -> Result<()> { fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; for binary in binaries { fs::write(dir.join(binary), binary).with_context(|| format!("write {binary}"))?; } - fs::write(dir.join("cadder.toml"), SAMPLE_CADDER_TOML) - .with_context(|| format!("write {}", dir.join("cadder.toml").display()))?; + if include_config { + fs::write(dir.join("cadder.toml"), SAMPLE_CADDER_TOML) + .with_context(|| format!("write {}", dir.join("cadder.toml").display()))?; + } Ok(()) } + fn write_complete_release_asset_matrix(dir: &Path, version: &str) { + for platform in ReleasePlatform::ALL { + write_portable_release_asset(dir, version, platform, PortableTopology::Runtime); + write_runtime_installer_release_assets(dir, version, platform); + } + } + + fn write_portable_release_asset( + dir: &Path, + version: &str, + platform: ReleasePlatform, + topology: PortableTopology, + ) { + let archive_stem = topology + .package_archive_stem(version, platform.name()) + .unwrap(); + let layout_parent = unique_temp_dir("release-archive-layout"); + let layout_dir = layout_parent.join(&archive_stem); + let binaries = topology + .binaries() + .iter() + .map(|binary| release_platform_exe_name(binary, platform)) + .collect::>(); + let binary_refs = binaries.iter().map(String::as_str).collect::>(); + write_fake_portable_layout(&layout_dir, &binary_refs, topology.includes_runtime()).unwrap(); + + let archive = dir.join(format!( + "{archive_stem}.{}", + platform.portable_archive_extension() + )); + match platform { + ReleasePlatform::WindowsX64 => { + write_zip_archive(&layout_parent, &archive_stem, &archive).unwrap(); + } + ReleasePlatform::LinuxX64 | ReleasePlatform::MacosX64 | ReleasePlatform::MacosArm64 => { + write_tar_gz_archive(&layout_parent, &archive_stem, &archive).unwrap(); + } + } + write_sha256_file(&archive, &checksum_path_for(&archive).unwrap()).unwrap(); + fs::remove_dir_all(&layout_parent).unwrap(); + } + + fn write_runtime_installer_release_assets(dir: &Path, version: &str, platform: ReleasePlatform) { + let options = RuntimeInstallerOptions { + out_dir: dir.to_path_buf(), + version: version.to_string(), + target: None, + platform, + signing_mode: SigningMode::UnsignedDryRun, + }; + for kind in platform.required_runtime_installer_artifact_kinds() { + let artifact = dir.join(runtime_installer_artifact_name(version, platform, *kind)); + fs::write(&artifact, kind.label()).unwrap(); + write_sha256_file(&artifact, &checksum_path_for(&artifact).unwrap()).unwrap(); + write_runtime_installer_manifest(&artifact, &options).unwrap(); + } + } + + fn write_release_asset(dir: &Path, file_name: &str) { + let artifact = dir.join(file_name); + fs::write(&artifact, file_name).unwrap(); + write_sha256_file(&artifact, &checksum_path_for(&artifact).unwrap()).unwrap(); + } + + fn write_fake_portable_executable_layout(dir: &Path, topology: PortableTopology) { + let helper_dir = tempfile::tempdir().unwrap(); + let helper = build_fake_portable_tool(helper_dir.path()); + for binary in topology.binaries() { + fs::copy(&helper, dir.join(exe_name(binary, None))) + .with_context(|| format!("copy fake executable for {binary}")) + .unwrap(); + } + if topology.includes_runtime() { + fs::write(dir.join("cadder.toml"), SAMPLE_CADDER_TOML) + .with_context(|| format!("write {}", dir.join("cadder.toml").display())) + .unwrap(); + } + } + + fn build_fake_portable_tool(dir: &Path) -> PathBuf { + let source = dir.join("fake_portable_tool.rs"); + let helper = dir.join(exe_name("fake-portable-tool", None)); + fs::write( + &source, + r#" +fn main() { + let args = std::env::args().collect::>(); + let exe_name = std::env::current_exe() + .ok() + .and_then(|path| path.file_name().map(|name| name.to_string_lossy().to_string())) + .unwrap_or_default(); + if exe_name.starts_with("caddy") + && args.get(1).map(String::as_str) == Some("--cadder-shim-info") + { + println!("{{\"role\":\"caddy-shim\"}}"); + return; + } + if args.get(1).map(String::as_str) == Some("--help") { + println!("fake help"); + return; + } + if args.get(1).map(String::as_str) == Some("--version") { + println!("fake version"); + return; + } + eprintln!("unexpected fake portable tool invocation: {args:?}"); + std::process::exit(2); +} +"#, + ) + .unwrap(); + let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string()); + let mut command = Command::new(rustc); + configure_hidden_child(&mut command); + let status = command + .arg(&source) + .arg("-o") + .arg(&helper) + .status() + .with_context(|| format!("compile {}", source.display())) + .unwrap(); + assert!(status.success(), "failed to compile fake portable tool"); + helper + } + + fn build_process_helper(dir: &Path) -> PathBuf { + fs::create_dir_all(dir).unwrap(); + let source = dir.join("process_helper.rs"); + let helper = dir.join(exe_name("process-helper", None)); + fs::write( + &source, + r#" +use std::path::PathBuf; + +fn main() { + let args = std::env::args().collect::>(); + match args.get(1).map(String::as_str) { + Some("ok") => {} + Some("fail") => std::process::exit(7), + Some("cwd-env") => { + let expected_cwd = args.get(2).map(PathBuf::from).expect("missing cwd"); + let expected_env = args.get(3).expect("missing env"); + if std::env::current_dir().ok().as_ref() != Some(&expected_cwd) { + std::process::exit(8); + } + if std::env::var("CADDER_XTASK_PROCESS_HELPER").ok().as_ref() != Some(expected_env) { + std::process::exit(9); + } + } + other => { + eprintln!("unexpected process helper argument: {other:?}"); + std::process::exit(10); + } + } +} +"#, + ) + .unwrap(); + let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string()); + let mut command = Command::new(rustc); + configure_hidden_child(&mut command); + let status = command + .arg(&source) + .arg("-o") + .arg(&helper) + .status() + .with_context(|| format!("compile {}", source.display())) + .unwrap(); + assert!(status.success(), "failed to compile process helper"); + helper + } + fn assert_zip_entries(archive_path: &Path, expected: &[&str]) { let file = File::open(archive_path).unwrap(); let mut archive = zip::ZipArchive::new(file).unwrap(); @@ -1018,13 +4723,85 @@ edition = "2024" } fn unique_temp_dir(name: &str) -> PathBuf { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); env::temp_dir().join(format!( - "cadder-xtask-{name}-{}-{unique}", - std::process::id() + "cadder-xtask-{name}-{}-{}", + std::process::id(), + unique_suffix() )) } + + fn unique_suffix() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + } + + fn expected_release_profile_manifest() -> &'static str { + r#" +[workspace] +members = [] + +[profile.release] +opt-level = "s" +lto = "thin" +codegen-units = 1 +debug = false +strip = "symbols" +panic = "unwind" + +[profile.profiling] +inherits = "release" +debug = true +strip = "none" +"# + } + + fn write_workspace_topology_fixture(root: &Path) { + fs::write(root.join("Cargo.toml"), workspace_topology_manifest(&[])).unwrap(); + for contract in WORKSPACE_MEMBER_CONTRACTS { + write_workspace_member_manifest(root, contract, contract.package); + } + } + + fn write_workspace_member_manifest( + root: &Path, + contract: WorkspaceMemberContract, + package_name: &str, + ) { + let crate_dir = root.join(contract.path); + fs::create_dir_all(&crate_dir).unwrap(); + fs::write( + crate_dir.join("Cargo.toml"), + format!( + r#" +[package] +name = {package_name:?} +version = "0.1.0" +edition = "2024" +"# + ), + ) + .unwrap(); + } + + fn workspace_topology_manifest(extra_members: &[&str]) -> String { + let mut manifest = String::from( + r#" +[workspace] +members = [ +"#, + ); + for contract in WORKSPACE_MEMBER_CONTRACTS { + manifest.push_str(&format!(" {:?},\n", contract.path)); + } + for member in extra_members { + manifest.push_str(&format!(" {member:?},\n")); + } + manifest.push_str( + r#"] +"#, + ); + manifest + } } diff --git a/xtask/tests/cli.rs b/xtask/tests/cli.rs new file mode 100644 index 0000000..0184c66 --- /dev/null +++ b/xtask/tests/cli.rs @@ -0,0 +1,818 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +#[test] +fn verify_dist_command_accepts_fake_portable_layout() { + let dir = unique_temp_dir("verify-dist"); + fs::create_dir_all(&dir).unwrap(); + write_fake_portable_executable_layout(&dir, runtime_portable_binaries(), true); + + let output = run_xtask(["verify-dist", "--dir"], Some(&dir)); + + assert_success(output); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn help_and_list_commands_are_lightweight_and_discover_docs_release_and_dev_tasks() { + let help = Command::new(xtask_bin()).arg("--help").output().unwrap(); + let help_stdout = help.stdout.clone(); + assert_success(help); + let help_text = String::from_utf8_lossy(&help_stdout); + assert!(help_text.contains("cargo xtask "), "{help_text}"); + assert!(help_text.contains("documentation:"), "{help_text}"); + assert!(help_text.contains("release and packaging:"), "{help_text}"); + assert!(help_text.contains("docs-check"), "{help_text}"); + assert!(help_text.contains("verify-assets"), "{help_text}"); + assert!(help_text.contains("verify-release-assets"), "{help_text}"); + assert!( + help_text.contains("verify-workspace-topology"), + "{help_text}" + ); + assert!(help_text.contains("dev-run"), "{help_text}"); + + let list = Command::new(xtask_bin()).arg("list").output().unwrap(); + let list_stdout = list.stdout.clone(); + assert_success(list); + let list_text = String::from_utf8_lossy(&list_stdout); + assert!(list_text.lines().any(|line| line == "check"), "{list_text}"); + assert!( + list_text.lines().any(|line| line == "docs-build"), + "{list_text}" + ); + assert!( + list_text.lines().any(|line| line == "runtime-installer"), + "{list_text}" + ); + assert!( + list_text.lines().any(|line| line == "verify-assets"), + "{list_text}" + ); + assert!( + list_text + .lines() + .any(|line| line == "verify-workspace-topology"), + "{list_text}" + ); +} + +#[test] +fn verify_assets_command_accepts_current_repo_contract() { + let output = Command::new(xtask_bin()) + .arg("verify-assets") + .output() + .unwrap(); + + assert_success(output); +} + +#[test] +fn verify_workspace_topology_command_accepts_current_repo_contract() { + let output = Command::new(xtask_bin()) + .arg("verify-workspace-topology") + .output() + .unwrap(); + + assert_success(output); +} + +#[test] +fn docs_commands_install_dependencies_and_run_docs_scripts_from_docs_site() { + let dir = unique_temp_dir("docs-command"); + fs::create_dir_all(&dir).unwrap(); + let fake_bin = dir.join("bin"); + fs::create_dir_all(&fake_bin).unwrap(); + let log = dir.join("fake-bun.log"); + let cwd_log = dir.join("fake-bun-cwd.log"); + write_fake_command(&fake_bin, "bun", 0); + + let check = Command::new(xtask_bin()) + .arg("docs-check") + .env("PATH", &fake_bin) + .env("CADDER_FAKE_TOOL_LOG", &log) + .env("CADDER_FAKE_TOOL_CWD_LOG", &cwd_log) + .output() + .unwrap(); + assert_success(check); + + let build = Command::new(xtask_bin()) + .arg("docs-build") + .env("PATH", &fake_bin) + .env("CADDER_FAKE_TOOL_LOG", &log) + .env("CADDER_FAKE_TOOL_CWD_LOG", &cwd_log) + .output() + .unwrap(); + assert_success(build); + + let invocations = fs::read_to_string(&log).unwrap(); + assert!( + invocations.matches("install --frozen-lockfile").count() >= 2, + "{invocations}" + ); + assert!(invocations.contains("run check"), "{invocations}"); + assert!(invocations.contains("run build"), "{invocations}"); + let cwd = fs::read_to_string(&cwd_log).unwrap().replace('\\', "/"); + assert!(cwd.contains("docs/site"), "{cwd}"); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn check_command_runs_validation_pipeline_with_fake_cargo() { + let dir = unique_temp_dir("check-command"); + fs::create_dir_all(&dir).unwrap(); + let fake_bin = dir.join("bin"); + fs::create_dir_all(&fake_bin).unwrap(); + let log = dir.join("fake-tool.log"); + write_fake_command(&fake_bin, "cargo", 0); + write_fake_command(&fake_bin, "bun", 0); + + let output = Command::new(xtask_bin()) + .arg("check") + .current_dir(workspace_root()) + .env("PATH", &fake_bin) + .env("CADDER_FAKE_TOOL_LOG", &log) + .output() + .unwrap(); + + assert_success(output); + let invocations = fs::read_to_string(&log).unwrap(); + assert!(invocations.contains("fmt --check"), "{invocations}"); + assert!( + invocations.contains("clippy --workspace --all-targets -- -D warnings"), + "{invocations}" + ); + assert!(invocations.contains("test --workspace"), "{invocations}"); + assert!( + invocations.contains("install --frozen-lockfile"), + "{invocations}" + ); + assert!(invocations.contains("run check"), "{invocations}"); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn coverage_command_invokes_cargo_llvm_cov_and_checks_lcov_threshold() { + let dir = unique_temp_dir("coverage-command"); + fs::create_dir_all(&dir).unwrap(); + let fake_bin = dir.join("bin"); + fs::create_dir_all(&fake_bin).unwrap(); + let log = dir.join("fake-cargo.log"); + write_fake_command(&fake_bin, "bun", 0); + write_fake_coverage_cargo( + &fake_bin, + 0, + "TN:\nSF:lib.rs\nDA:1,1\nDA:2,1\nDA:3,1\nend_of_record\n", + ); + + let report = dir.join("reports").join("summary.lcov"); + let output = Command::new(xtask_bin()) + .args(["coverage", "--output"]) + .arg(&report) + .env("PATH", &fake_bin) + .env("CADDER_FAKE_TOOL_LOG", &log) + .output() + .unwrap(); + + assert_success(output); + assert!(report.parent().unwrap().is_dir()); + let invocation = fs::read_to_string(&log).unwrap(); + assert!(invocation.contains("llvm-cov"), "{invocation}"); + assert!(invocation.contains("--workspace"), "{invocation}"); + assert!(invocation.contains("--lcov"), "{invocation}"); + assert!(invocation.contains("--output-path"), "{invocation}"); + assert!( + invocation.contains(&report.display().to_string()), + "{invocation}" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn coverage_command_reports_lcov_below_threshold_after_cargo_success() { + let dir = unique_temp_dir("coverage-command-low-report"); + fs::create_dir_all(&dir).unwrap(); + let fake_bin = dir.join("bin"); + fs::create_dir_all(&fake_bin).unwrap(); + write_fake_command(&fake_bin, "bun", 0); + write_fake_coverage_cargo( + &fake_bin, + 0, + "TN:\nSF:lib.rs\nDA:1,1\nDA:2,1\nDA:3,0\nend_of_record\n", + ); + + let output = Command::new(xtask_bin()) + .args(["coverage", "--output"]) + .arg(dir.join("summary.lcov")) + .env("PATH", &fake_bin) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("below required 85.00%"), "{stderr}"); + assert!(stderr.contains("2/3"), "{stderr}"); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn coverage_command_reports_cargo_failure() { + let dir = unique_temp_dir("coverage-command-failure"); + fs::create_dir_all(&dir).unwrap(); + let fake_bin = dir.join("bin"); + fs::create_dir_all(&fake_bin).unwrap(); + write_fake_command(&fake_bin, "bun", 0); + write_fake_command(&fake_bin, "cargo", 7); + + let output = Command::new(xtask_bin()) + .args(["coverage", "--output"]) + .arg(dir.join("summary.lcov")) + .env("PATH", &fake_bin) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("cargo"), "{stderr}"); + assert!(stderr.contains("failed"), "{stderr}"); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn dev_env_command_outputs_repeatable_json_without_local_workspace_contract() { + let output = Command::new(xtask_bin()) + .args(["dev-env", "--format", "json"]) + .output() + .unwrap(); + + let stdout = output.stdout.clone(); + assert_success(output); + let value: serde_json::Value = serde_json::from_slice(&stdout).unwrap(); + assert_eq!(value["CADDER_RUNTIME_PROFILE"], "dev"); + assert_eq!(value["CADDER_CADDY_BACKEND"], "mock"); + assert!(value.get("CADDER_DESKTOP_DEV_WINDOWS").is_none()); + assert!( + !value["CADDER_DEV_WORKSPACE"] + .as_str() + .unwrap() + .contains(".local") + ); +} + +#[test] +fn dev_run_command_invokes_program_with_dev_environment() { + let dir = unique_temp_dir("dev-run-command"); + fs::create_dir_all(&dir).unwrap(); + let fake_bin = dir.join("bin"); + fs::create_dir_all(&fake_bin).unwrap(); + let log = dir.join("fake-tool.log"); + let env_log = dir.join("fake-tool-env.log"); + write_fake_command(&fake_bin, "dev-helper", 0); + + let output = Command::new(xtask_bin()) + .args(["dev-run", "--", "dev-helper", "status"]) + .env("PATH", &fake_bin) + .env("CADDER_FAKE_TOOL_LOG", &log) + .env("CADDER_FAKE_TOOL_ENV_LOG", &env_log) + .output() + .unwrap(); + + assert_success(output); + assert!(fs::read_to_string(&log).unwrap().contains("status")); + let env_value = fs::read_to_string(&env_log).unwrap(); + assert!( + env_value.contains("CADDER_RUNTIME_PROFILE=dev"), + "{env_value}" + ); + assert!( + env_value.contains("CADDER_CADDY_BACKEND=mock"), + "{env_value}" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn dist_command_builds_release_binaries_and_verifies_layout() { + let dir = unique_temp_dir("dist-command"); + fs::create_dir_all(&dir).unwrap(); + let fake_bin = dir.join("bin"); + fs::create_dir_all(&fake_bin).unwrap(); + let log = dir.join("fake-cargo.log"); + write_fake_command(&fake_bin, "cargo", 0); + write_fake_command(&fake_bin, "bun", 0); + + let target = fake_target_name("dist-command"); + let release_dir = workspace_root() + .join("target") + .join(&target) + .join("release"); + fs::create_dir_all(&release_dir).unwrap(); + write_fake_portable_executable_layout_for_target( + &release_dir, + &target, + runtime_portable_binaries(), + ); + + let out_dir = dir.join("dist"); + let output = Command::new(xtask_bin()) + .args(["dist", "--out"]) + .arg(&out_dir) + .args(["--target", &target]) + .current_dir(workspace_root()) + .env("PATH", &fake_bin) + .env("CADDER_FAKE_TOOL_LOG", &log) + .output() + .unwrap(); + + assert_success(output); + let invocation = fs::read_to_string(&log).unwrap(); + assert!(invocation.contains("build --release"), "{invocation}"); + assert!(invocation.contains("-p cadder-shim"), "{invocation}"); + assert!(invocation.contains("--target"), "{invocation}"); + assert!(invocation.contains(&target), "{invocation}"); + for binary in runtime_portable_binaries() { + assert!(out_dir.join(exe_name_for_target(binary, &target)).is_file()); + } + assert!(out_dir.join("cadder.toml").is_file()); + + fs::remove_dir_all(workspace_root().join("target").join(&target)).unwrap(); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn package_command_builds_dist_and_writes_archive_checksum() { + let dir = unique_temp_dir("package-command"); + fs::create_dir_all(&dir).unwrap(); + let fake_bin = dir.join("bin"); + fs::create_dir_all(&fake_bin).unwrap(); + let log = dir.join("fake-cargo.log"); + write_fake_command(&fake_bin, "cargo", 0); + write_fake_command(&fake_bin, "bun", 0); + + let target = format!("fake-windows-package-command-{}", unique_suffix()); + let release_dir = workspace_root() + .join("target") + .join(&target) + .join("release"); + fs::create_dir_all(&release_dir).unwrap(); + write_fake_portable_executable_layout_for_target( + &release_dir, + &target, + runtime_portable_binaries(), + ); + + let out_dir = dir.join("package"); + let output = Command::new(xtask_bin()) + .args(["package", "--out"]) + .arg(&out_dir) + .args([ + "--platform", + "windows-x64", + "--target", + &target, + "--version", + "1.2.3", + ]) + .current_dir(workspace_root()) + .env("PATH", &fake_bin) + .env("CADDER_FAKE_TOOL_LOG", &log) + .output() + .unwrap(); + + assert_success(output); + let invocation = fs::read_to_string(&log).unwrap(); + assert!(invocation.contains("build --release"), "{invocation}"); + assert!(invocation.contains(&target), "{invocation}"); + assert!(out_dir.join("cadder-1.2.3-windows-x64.zip").is_file()); + assert!( + out_dir + .join("cadder-1.2.3-windows-x64.zip.sha256") + .is_file() + ); + assert!( + out_dir + .join("layouts") + .join("cadder-1.2.3-windows-x64") + .join("cadder.toml") + .is_file() + ); + + fs::remove_dir_all(workspace_root().join("target").join(&target)).unwrap(); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn runtime_installer_command_builds_runtime_only_windows_msi_and_manifest() { + let dir = unique_temp_dir("runtime-installer-command"); + fs::create_dir_all(&dir).unwrap(); + let fake_bin = dir.join("bin"); + fs::create_dir_all(&fake_bin).unwrap(); + let cargo_log = dir.join("fake-cargo.log"); + let wix_log = dir.join("fake-wix.log"); + write_fake_command(&fake_bin, "cargo", 0); + write_fake_command(&fake_bin, "bun", 0); + write_fake_wix(&fake_bin); + + let target = format!("fake-windows-runtime-installer-{}", unique_suffix()); + let release_dir = workspace_root() + .join("target") + .join(&target) + .join("release"); + fs::create_dir_all(&release_dir).unwrap(); + write_fake_portable_executable_layout_for_target( + &release_dir, + &target, + runtime_portable_binaries(), + ); + + let out_dir = dir.join("runtime-installer"); + let output = Command::new(xtask_bin()) + .args(["runtime-installer", "--out"]) + .arg(&out_dir) + .args([ + "--platform", + "windows-x64", + "--target", + &target, + "--version", + "1.2.3", + ]) + .current_dir(workspace_root()) + .env("PATH", &fake_bin) + .env("CADDER_FAKE_TOOL_LOG", &cargo_log) + .env("CADDER_FAKE_WIX_LOG", &wix_log) + .output() + .unwrap(); + + assert_success(output); + let cargo_invocation = fs::read_to_string(&cargo_log).unwrap(); + assert!( + cargo_invocation.contains("build --release"), + "{cargo_invocation}" + ); + let wix_invocation = fs::read_to_string(&wix_log).unwrap(); + assert!(wix_invocation.contains("build"), "{wix_invocation}"); + assert!(wix_invocation.contains("-arch x64"), "{wix_invocation}"); + + let installer = out_dir.join("cadder-runtime-1.2.3-windows-x64.msi"); + let manifest = out_dir.join("cadder-runtime-1.2.3-windows-x64.msi.manifest.json"); + assert!(installer.is_file()); + assert!( + out_dir + .join("cadder-runtime-1.2.3-windows-x64.msi.sha256") + .is_file() + ); + assert!(manifest.is_file()); + assert!( + fs::read_to_string(&manifest) + .unwrap() + .contains(r#""component": "daemon-runtime""#) + ); + fs::remove_dir_all(workspace_root().join("target").join(&target)).unwrap(); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn lightweight_command_parse_errors_do_not_run_heavy_build_steps() { + let cases: &[&[&str]] = &[ + &["coverage", "--output"], + &["definitely-not-a-heavy-command"], + &["dist"], + &["package", "--out", "target/package-without-platform"], + &["runtime-installer", "--out"], + &["verify-runtime-installer-dist", "--dir"], + ]; + + for args in cases { + let output = Command::new(xtask_bin()).args(*args).output().unwrap(); + + assert!(!output.status.success(), "{args:?} unexpectedly succeeded"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("requires a value") + || stderr.contains("is required") + || stderr.contains("Error:"), + "{args:?} stderr did not describe the parse failure: {stderr}" + ); + } +} + +#[test] +fn unknown_command_reports_xtask_error() { + let output = Command::new(xtask_bin()) + .arg("definitely-not-a-command") + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("unknown xtask command")); +} + +fn run_xtask(args: [&str; N], path_arg: Option<&Path>) -> Output { + let mut command = Command::new(xtask_bin()); + command.args(args); + if let Some(path) = path_arg { + command.arg(path); + } + command.output().unwrap() +} + +fn xtask_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_xtask")) +} + +fn assert_success(output: Output) { + assert!( + output.status.success(), + "xtask failed\nstatus: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn write_fake_portable_executable_layout(dir: &Path, binaries: &[&str], include_config: bool) { + let helper_dir = unique_temp_dir("fake-portable-tool"); + fs::create_dir_all(&helper_dir).unwrap(); + let helper = build_fake_portable_tool(&helper_dir); + for binary in binaries { + fs::copy(&helper, dir.join(exe_name(binary))).unwrap(); + } + fs::remove_dir_all(&helper_dir).unwrap(); + if include_config { + fs::write(dir.join("cadder.toml"), SAMPLE_CADDER_TOML).unwrap(); + } +} + +fn write_fake_portable_executable_layout_for_target(dir: &Path, target: &str, binaries: &[&str]) { + let helper_dir = unique_temp_dir("fake-portable-tool-target"); + fs::create_dir_all(&helper_dir).unwrap(); + let helper = build_fake_portable_tool(&helper_dir); + for binary in binaries { + fs::copy(&helper, dir.join(exe_name_for_target(binary, target))).unwrap(); + } + fs::remove_dir_all(&helper_dir).unwrap(); +} + +fn build_fake_portable_tool(dir: &Path) -> PathBuf { + let source = dir.join("fake_portable_tool.rs"); + let helper = dir.join(exe_name("fake-portable-tool")); + fs::write( + &source, + r#" +fn main() { + let args = std::env::args().collect::>(); + let exe_name = std::env::current_exe() + .ok() + .and_then(|path| path.file_name().map(|name| name.to_string_lossy().to_string())) + .unwrap_or_default(); + if exe_name.starts_with("caddy") + && args.get(1).map(String::as_str) == Some("--cadder-shim-info") + { + println!("{{\"role\":\"caddy-shim\"}}"); + return; + } + if args.get(1).map(String::as_str) == Some("--help") { + println!("fake help"); + return; + } + if args.get(1).map(String::as_str) == Some("--version") { + println!("fake version"); + return; + } + eprintln!("unexpected fake portable tool invocation: {args:?}"); + std::process::exit(2); +} +"#, + ) + .unwrap(); + let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string()); + let status = Command::new(rustc) + .arg(&source) + .arg("-o") + .arg(&helper) + .status() + .unwrap(); + assert!(status.success(), "failed to compile fake portable tool"); + helper +} + +fn write_fake_command(dir: &Path, name: &str, exit_code: i32) -> PathBuf { + let path = dir.join(command_file_name(name)); + let source = dir.join(format!("{name}_fake_command.rs")); + fs::write( + &source, + format!( + r#" +use std::io::Write; + +fn append_env_log(key: &str, line: String) {{ + if let Ok(path) = std::env::var(key) {{ + if !path.is_empty() {{ + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!(file, "{{line}}").unwrap(); + }} + }} +}} + +fn main() {{ + let args = std::env::args().skip(1).collect::>().join(" "); + append_env_log("CADDER_FAKE_TOOL_LOG", args); + append_env_log( + "CADDER_FAKE_TOOL_CWD_LOG", + std::env::current_dir().unwrap().display().to_string(), + ); + append_env_log( + "CADDER_FAKE_TOOL_ENV_LOG", + [ + "CADDER_RUNTIME_PROFILE", + "CADDER_DEV_WORKSPACE", + "CADDER_CADDY_BACKEND", + ] + .iter() + .map(|key| format!("{{key}}={{}}", std::env::var(key).unwrap_or_default())) + .collect::>() + .join("\n"), + ); + std::process::exit({exit_code}); +}} +"# + ), + ) + .unwrap(); + let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string()); + let status = Command::new(rustc) + .arg(&source) + .arg("-o") + .arg(&path) + .status() + .unwrap(); + assert!(status.success(), "failed to compile fake {name}"); + path +} + +fn write_fake_wix(dir: &Path) -> PathBuf { + let path = dir.join(command_file_name("wix")); + let source = dir.join("wix_fake_command.rs"); + fs::write( + &source, + r#" +use std::io::Write; +use std::path::PathBuf; + +fn append_log(line: String) { + if let Ok(path) = std::env::var("CADDER_FAKE_WIX_LOG") { + if !path.is_empty() { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!(file, "{line}").unwrap(); + } + } +} + +fn main() { + let args = std::env::args().skip(1).collect::>(); + append_log(args.join(" ")); + if let Some(index) = args.iter().position(|arg| arg == "-o") { + let output = PathBuf::from(&args[index + 1]); + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(output, b"fake msi").unwrap(); + } +} +"#, + ) + .unwrap(); + let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string()); + let status = Command::new(rustc) + .arg(&source) + .arg("-o") + .arg(&path) + .status() + .unwrap(); + assert!(status.success(), "failed to compile fake wix"); + path +} + +fn write_fake_coverage_cargo(dir: &Path, exit_code: i32, report_text: &str) -> PathBuf { + let path = dir.join(command_file_name("cargo")); + let source = dir.join("cargo_fake_coverage.rs"); + let report_literal = format!("{report_text:?}"); + fs::write( + &source, + format!( + r#" +use std::io::Write; +use std::path::PathBuf; + +fn append_log(line: &str) {{ + if let Ok(path) = std::env::var("CADDER_FAKE_TOOL_LOG") {{ + if !path.is_empty() {{ + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!(file, "{{line}}").unwrap(); + }} + }} +}} + +fn main() {{ + let args = std::env::args().skip(1).collect::>(); + append_log(&args.join(" ")); + if {exit_code} == 0 {{ + if let Some(index) = args.iter().position(|arg| arg == "--output-path") {{ + let report = PathBuf::from(&args[index + 1]); + if let Some(parent) = report.parent() {{ + std::fs::create_dir_all(parent).unwrap(); + }} + std::fs::write(&report, {report_literal}.as_bytes()).unwrap(); + }} + }} + std::process::exit({exit_code}); +}} +"# + ), + ) + .unwrap(); + let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string()); + let status = Command::new(rustc) + .arg(&source) + .arg("-o") + .arg(&path) + .status() + .unwrap(); + assert!(status.success(), "failed to compile fake coverage cargo"); + path +} + +fn command_file_name(name: &str) -> String { + if cfg!(windows) { + format!("{name}.exe") + } else { + name.to_string() + } +} + +fn runtime_portable_binaries() -> &'static [&'static str] { + &["cadderd", "cadder", "caddy"] +} + +fn exe_name(name: &str) -> String { + if cfg!(windows) { + format!("{name}.exe") + } else { + name.to_string() + } +} + +fn exe_name_for_target(name: &str, target: &str) -> String { + if target.contains("windows") { + format!("{name}.exe") + } else { + name.to_string() + } +} + +fn fake_target_name(name: &str) -> String { + let platform = if cfg!(windows) { "windows" } else { "linux" }; + format!("fake-{platform}-{name}-{}", unique_suffix()) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") +} + +fn unique_temp_dir(name: &str) -> PathBuf { + let unique = unique_suffix(); + env::temp_dir().join(format!( + "cadder-xtask-cli-{name}-{}-{unique}", + std::process::id() + )) +} + +fn unique_suffix() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() +} + +const SAMPLE_CADDER_TOML: &str = r#"# Cadder portable configuration. +# Uncomment and set this when the real Caddy binary is not the first safe `caddy` on PATH. +# +# [caddy] +# real_command = "/absolute/path/to/caddy" +"#; From 9829b5c20755841aadcc0f802ea825118951508d Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Wed, 8 Jul 2026 10:35:01 +0200 Subject: [PATCH 07/29] test: add daemon contract coverage and shim policy Cover raw IPC registration history and fake IIS provider binding queries through daemon IPC. Add the explicit caddy shim command policy table, unsupported command rejection, and docs. Mark OpenSpec tasks 3.6 and 4.1 complete. --- crates/cadder-daemon/src/ipc.rs | 69 +++++- crates/cadder-daemon/src/state/lifecycle.rs | 2 +- crates/cadder-daemon/tests/ipc_lifecycle.rs | 34 +++ crates/cadder-shim/src/main.rs | 227 +++++++++++++++++- crates/cadder-shim/tests/shim_binary.rs | 21 ++ .../content/docs/user-guide/path-and-shim.mdx | 12 + .../reset-cadder-architecture/tasks.md | 4 +- 7 files changed, 358 insertions(+), 11 deletions(-) diff --git a/crates/cadder-daemon/src/ipc.rs b/crates/cadder-daemon/src/ipc.rs index 5bdf0a2..38b335c 100644 --- a/crates/cadder-daemon/src/ipc.rs +++ b/crates/cadder-daemon/src/ipc.rs @@ -876,10 +876,14 @@ fn prepend_path_dir(command: &mut Command, dir: &std::path::Path) { #[cfg(test)] mod tests { use super::*; - use crate::{CaddyConfigCoordinator, PrivilegeStatus, discover_ipc_endpoint, logs::LogQuery}; + use crate::{ + CaddyConfigCoordinator, IisBindingRecord, IisProvider, PrivilegeStatus, discover_ipc_endpoint, + logs::LogQuery, + }; use cadder_protocol::{ - AutostartMode, BasicResponse, IpcEnvelope, ProtocolErrorKind, ProtocolErrorResponse, - QueryStateRequest, QueryStateResponse, message_types, new_request_id, + AutostartMode, BasicResponse, IisHandoffState, IpcEnvelope, ProtocolErrorKind, + ProtocolErrorResponse, QueryIisBindingsRequest, QueryIisBindingsResponse, QueryStateRequest, + QueryStateResponse, message_types, new_request_id, }; use std::{env, ffi::OsString, fs, future::Future}; use tokio::{ @@ -1260,6 +1264,61 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn daemon_server_query_iis_bindings_uses_fake_provider_over_ipc() { + let temp = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); + paths.ensure_dirs().unwrap(); + let available = iis_binding("Default Web Site", "http", "*:80:app.localhost"); + let missing_route = iis_binding("Default Web Site", "https", "*:443:"); + let available_id = available.binding_id(); + let missing_route_id = missing_route.binding_id(); + let state = DaemonState::with_iis_provider( + CaddyConfigCoordinator::new_mock(paths.clone()), + IisProvider::fake(vec![available, missing_route]), + ); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let daemon = tokio::spawn(DaemonServer::new(paths.clone(), state).run_until(shutdown_rx)); + wait_for_ready(&paths).await; + let client = CadderClient::new(paths.clone()); + + let response: QueryIisBindingsResponse = client + .request( + message_types::QUERY_IIS_BINDINGS_REQUEST, + message_types::QUERY_IIS_BINDINGS_RESPONSE, + &QueryIisBindingsRequest { + request_id: "query-fake-iis".to_string(), + }, + ) + .await + .unwrap(); + + assert!(response.accepted, "{response:?}"); + assert_eq!(response.request_id, "query-fake-iis"); + assert_eq!(response.bindings.len(), 2); + let available = response + .bindings + .iter() + .find(|binding| binding.identity.binding_id == available_id) + .unwrap(); + let missing_route = response + .bindings + .iter() + .find(|binding| binding.identity.binding_id == missing_route_id) + .unwrap(); + assert_eq!(available.handoff_state, IisHandoffState::Available); + assert!(available.issue.is_none()); + assert_eq!(missing_route.handoff_state, IisHandoffState::MissingRoute); + assert!(missing_route.issue.is_some()); + + shutdown_tx.send(true).unwrap(); + timeout(Duration::from_secs(2), daemon) + .await + .unwrap() + .unwrap() + .unwrap(); + } + #[tokio::test] async fn ensure_daemon_running_returns_when_socket_already_available() { let server = ScriptedIpcServer::start(|_conn| async move {}); @@ -1537,6 +1596,10 @@ mod tests { panic!("daemon server did not become ready"); } + fn iis_binding(site: &str, protocol: &str, binding: &str) -> IisBindingRecord { + IisBindingRecord::from_binding_information(site, protocol, binding).unwrap() + } + async fn write_basic_response(writer: &mut tokio::io::WriteHalf, message_type: &str) { write_envelope( writer, diff --git a/crates/cadder-daemon/src/state/lifecycle.rs b/crates/cadder-daemon/src/state/lifecycle.rs index d06860d..fc64c34 100644 --- a/crates/cadder-daemon/src/state/lifecycle.rs +++ b/crates/cadder-daemon/src/state/lifecycle.rs @@ -51,7 +51,7 @@ impl DaemonState { } #[cfg(test)] - pub(super) fn with_iis_provider( + pub(crate) fn with_iis_provider( coordinator: CaddyConfigCoordinator, iis_provider: IisProvider, ) -> Self { diff --git a/crates/cadder-daemon/tests/ipc_lifecycle.rs b/crates/cadder-daemon/tests/ipc_lifecycle.rs index dd3d1fe..2d7f810 100644 --- a/crates/cadder-daemon/tests/ipc_lifecycle.rs +++ b/crates/cadder-daemon/tests/ipc_lifecycle.rs @@ -139,6 +139,40 @@ async fn ipc_lifecycle_registers_one_shim_and_applies_config() { harness.shutdown().await; } +#[tokio::test] +async fn raw_ipc_registration_records_history_in_memory_storage() { + let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); + let harness = Harness::start(FakeCaddy::new(fixture)).await; + let (mut reader, mut writer) = raw_ipc_session(&harness.paths).await; + + write_raw_envelope( + &mut writer, + message_types::REGISTER_ENTRYPOINT_REQUEST, + &RegisterEntrypointRequest { + request_id: new_request_id("raw-register-contract"), + registration: registration("shim-raw", "nonce-raw", &harness.config_path), + }, + ) + .await; + let response: RegisterEntrypointResponse = read_raw_envelope(&mut reader).await.decode().unwrap(); + let snapshot = query_state(&harness.client).await; + let history = query_history(&harness.client, cadder_protocol::HistoryKind::Registration).await; + let storage = history.storage.as_ref().unwrap(); + + assert!(response.accepted, "{}", response.message); + assert_eq!(snapshot.registrations.len(), 1); + assert_eq!(snapshot.config.status, ConfigApplyStatus::Applied); + assert_eq!(storage.backend, "sqlite"); + assert!(storage.path.is_none()); + assert!(history.records.iter().any(|record| { + record.registration_id.as_deref() == Some("shim-raw") + && record.summary == "Registered entrypoint `shim-raw`." + })); + drop(writer); + drop(reader); + harness.shutdown().await; +} + #[tokio::test] async fn ipc_local_operator_requests_cover_autostart_status() { let fixture = include_str!("fixtures/SmarketingReverseProxy.Caddyfile"); diff --git a/crates/cadder-shim/src/main.rs b/crates/cadder-shim/src/main.rs index 072437e..9092a80 100644 --- a/crates/cadder-shim/src/main.rs +++ b/crates/cadder-shim/src/main.rs @@ -26,7 +26,7 @@ use tokio::{process::Command, sync::Mutex, time::interval}; name = "caddy", version, about = "Cadder PATH-facing Caddy shim", - long_about = "Acts as the Cadder-managed caddy command. `caddy run` requires a running cadderd backend and registers the current project; other commands are delegated to the safely resolved real Caddy binary." + long_about = "Acts as the Cadder-managed caddy command. `caddy run` requires a running cadderd backend and registers the current project; other commands are classified by the shim policy table before they are delegated to the safely resolved real Caddy binary or rejected." )] struct ShimArgs { #[arg(long = "cadder-runtime-dir", hide = true)] @@ -56,11 +56,153 @@ struct ShimArgs { value_name = "CADDY_ARGS", trailing_var_arg = true, allow_hyphen_values = true, - help = "Arguments for the caddy command; `run` is managed by Cadder and other commands are delegated to real Caddy" + help = "Arguments for the caddy command; `run` is managed by Cadder and other commands must have an explicit shim policy" )] caddy_args: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ShimCommandPolicyKind { + Managed, + ReadOnlyInspection, + ExplicitPassthrough, + Unsupported, +} + +#[derive(Debug, Clone, Copy)] +struct ShimCommandPolicyEntry { + command: &'static str, + kind: ShimCommandPolicyKind, + rationale: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ClassifiedShimCommand<'a> { + command: &'a str, + kind: ShimCommandPolicyKind, + rationale: &'static str, +} + +const fn policy_entry( + command: &'static str, + kind: ShimCommandPolicyKind, + rationale: &'static str, +) -> ShimCommandPolicyEntry { + ShimCommandPolicyEntry { + command, + kind, + rationale, + } +} + +const SHIM_COMMAND_POLICY_TABLE: &[ShimCommandPolicyEntry] = &[ + policy_entry( + "run", + ShimCommandPolicyKind::Managed, + "Registers the project definition with cadderd and keeps Cadder as runtime owner.", + ), + policy_entry( + "adapt", + ShimCommandPolicyKind::ReadOnlyInspection, + "Reads a Caddy config and prints adapted JSON without mutating runtime state.", + ), + policy_entry( + "build-info", + ShimCommandPolicyKind::ReadOnlyInspection, + "Reports real Caddy build metadata.", + ), + policy_entry( + "environ", + ShimCommandPolicyKind::ReadOnlyInspection, + "Reports real Caddy environment information.", + ), + policy_entry( + "help", + ShimCommandPolicyKind::ReadOnlyInspection, + "Displays command help.", + ), + policy_entry( + "list-modules", + ShimCommandPolicyKind::ReadOnlyInspection, + "Reports installed real Caddy modules.", + ), + policy_entry( + "validate", + ShimCommandPolicyKind::ReadOnlyInspection, + "Validates config input without applying it to Cadder-managed runtime state.", + ), + policy_entry( + "version", + ShimCommandPolicyKind::ReadOnlyInspection, + "Reports real Caddy version metadata.", + ), + policy_entry( + "completion", + ShimCommandPolicyKind::ExplicitPassthrough, + "Generates shell completion output without touching Cadder-managed state.", + ), + policy_entry( + "file-server", + ShimCommandPolicyKind::ExplicitPassthrough, + "Starts an unmanaged one-shot real Caddy file server by explicit command.", + ), + policy_entry( + "fmt", + ShimCommandPolicyKind::ExplicitPassthrough, + "Formats user-provided config files without touching Cadder-managed runtime state.", + ), + policy_entry( + "manpage", + ShimCommandPolicyKind::ExplicitPassthrough, + "Generates manual page output without touching Cadder-managed state.", + ), + policy_entry( + "reverse-proxy", + ShimCommandPolicyKind::ExplicitPassthrough, + "Starts an unmanaged one-shot real Caddy reverse proxy by explicit command.", + ), + policy_entry( + "add-package", + ShimCommandPolicyKind::Unsupported, + "Mutates the real Caddy binary/module set outside Cadder release ownership.", + ), + policy_entry( + "reload", + ShimCommandPolicyKind::Unsupported, + "Mutates real Caddy runtime state outside Cadder's generated config model.", + ), + policy_entry( + "remove-package", + ShimCommandPolicyKind::Unsupported, + "Mutates the real Caddy binary/module set outside Cadder release ownership.", + ), + policy_entry( + "start", + ShimCommandPolicyKind::Unsupported, + "Starts an unmanaged real Caddy runtime that can drift from cadderd ownership.", + ), + policy_entry( + "stop", + ShimCommandPolicyKind::Unsupported, + "Stops real Caddy outside Cadder's runtime ownership boundary.", + ), + policy_entry( + "trust", + ShimCommandPolicyKind::Unsupported, + "Mutates local trust stores outside the current Cadder shim contract.", + ), + policy_entry( + "untrust", + ShimCommandPolicyKind::Unsupported, + "Mutates local trust stores outside the current Cadder shim contract.", + ), + policy_entry( + "upgrade", + ShimCommandPolicyKind::Unsupported, + "Mutates the real Caddy binary outside Cadder release ownership.", + ), +]; + #[tokio::main] async fn main() -> Result { let args = ShimArgs::parse(); @@ -83,9 +225,12 @@ async fn main() -> Result { let caddy_backend = args .caddy_backend .map_or_else(CaddyBackendMode::from_env, Ok)?; + let command_policy = classify_caddy_command(&args.caddy_args); - if args.caddy_args.first().is_some_and(|arg| arg == "run") { + if command_policy.kind == ShimCommandPolicyKind::Managed { run_managed(args).await + } else if command_policy.kind == ShimCommandPolicyKind::Unsupported { + Ok(reject_unsupported_caddy_command(command_policy)) } else if caddy_backend == CaddyBackendMode::Mock { run_mock_caddy_command(&args.caddy_args).await } else { @@ -93,6 +238,42 @@ async fn main() -> Result { } } +fn classify_caddy_command(args: &[String]) -> ClassifiedShimCommand<'_> { + let command = normalized_caddy_command(args); + if let Some(entry) = SHIM_COMMAND_POLICY_TABLE + .iter() + .find(|entry| entry.command == command) + { + return ClassifiedShimCommand { + command, + kind: entry.kind, + rationale: entry.rationale, + }; + } + + ClassifiedShimCommand { + command, + kind: ShimCommandPolicyKind::Unsupported, + rationale: "No explicit Cadder shim policy entry exists for this Caddy command.", + } +} + +fn normalized_caddy_command(args: &[String]) -> &str { + match args.first().map(String::as_str) { + None | Some("--help" | "-h" | "help") => "help", + Some("--version" | "-v" | "version") => "version", + Some(command) => command, + } +} + +fn reject_unsupported_caddy_command(command: ClassifiedShimCommand<'_>) -> ExitCode { + eprintln!( + "Cadder shim does not support `caddy {}`. {}", + command.command, command.rationale + ); + ExitCode::FAILURE +} + async fn run_managed(args: ShimArgs) -> Result { write_shim_privilege_warning(); run_managed_until(args, tokio::signal::ctrl_c()).await @@ -498,8 +679,44 @@ mod tests { "long help output should describe managed caddy run behavior: {help}" ); assert!( - help.contains("delegated to real Caddy"), - "long help output should describe delegation behavior: {help}" + help.contains("delegated to the safely resolved real Caddy binary or rejected"), + "long help output should describe command policy behavior: {help}" + ); + } + + #[test] + fn command_policy_table_classifies_core_caddy_command_paths() { + let run = vec!["run".to_string()]; + let adapt = vec!["adapt".to_string()]; + let fmt = vec!["fmt".to_string()]; + let start = vec!["start".to_string()]; + let version = vec!["--version".to_string()]; + let unknown = vec!["frobnicate".to_string()]; + + assert_eq!( + classify_caddy_command(&run).kind, + ShimCommandPolicyKind::Managed + ); + assert_eq!( + classify_caddy_command(&adapt).kind, + ShimCommandPolicyKind::ReadOnlyInspection + ); + assert_eq!( + classify_caddy_command(&fmt).kind, + ShimCommandPolicyKind::ExplicitPassthrough + ); + assert_eq!( + classify_caddy_command(&start).kind, + ShimCommandPolicyKind::Unsupported + ); + assert_eq!(classify_caddy_command(&version).command, "version"); + assert_eq!( + classify_caddy_command(&unknown), + ClassifiedShimCommand { + command: "frobnicate", + kind: ShimCommandPolicyKind::Unsupported, + rationale: "No explicit Cadder shim policy entry exists for this Caddy command.", + } ); } diff --git a/crates/cadder-shim/tests/shim_binary.rs b/crates/cadder-shim/tests/shim_binary.rs index fa470f5..96ff2b6 100644 --- a/crates/cadder-shim/tests/shim_binary.rs +++ b/crates/cadder-shim/tests/shim_binary.rs @@ -119,6 +119,27 @@ fn delegated_command_propagates_real_caddy_exit_code() { let _ = fs::remove_dir_all(fake_caddy.parent().unwrap()); } +#[test] +fn unsupported_command_does_not_delegate_to_real_caddy() { + let fake_caddy = write_fake_real_caddy("unsupported-start", 0); + let fake_caddy_arg = fake_caddy.display().to_string(); + + let output = run_shim(&["--cadder-real-caddy-command", &fake_caddy_arg, "start"]); + + assert_eq!(output.status.code(), Some(1)); + assert!( + !String::from_utf8(output.stdout) + .unwrap() + .contains("delegated start") + ); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("Cadder shim does not support `caddy start`"), + "{stderr}" + ); + let _ = fs::remove_dir_all(fake_caddy.parent().unwrap()); +} + #[test] fn managed_run_reports_missing_backend_for_runtime_dir() { let runtime_dir = unique_runtime_dir("missing-backend"); diff --git a/docs/site/src/content/docs/user-guide/path-and-shim.mdx b/docs/site/src/content/docs/user-guide/path-and-shim.mdx index afb118c..f192a37 100644 --- a/docs/site/src/content/docs/user-guide/path-and-shim.mdx +++ b/docs/site/src/content/docs/user-guide/path-and-shim.mdx @@ -33,6 +33,18 @@ For `caddy run`, the shim is attach-first. If the Cadder daemon is missing, reco When `CADDER_CADDY_BACKEND=mock` or `--cadder-caddy-backend mock` is active, real Caddy fallback is skipped and the shim tries the daemon path directly. +## Command policy + +The shim classifies the first Caddy command before it decides what may run: + +| Command path | Policy | Result | +| --- | --- | --- | +| `run` | Managed | Registers the project with `cadderd`; Cadder owns the effective runtime state. | +| `adapt`, `build-info`, `environ`, `help`, `list-modules`, `validate`, `version`, `--version` | Read-only inspection | Delegates to the safely resolved real Caddy command when real backend mode is active. | +| `completion`, `file-server`, `fmt`, `manpage`, `reverse-proxy` | Explicit passthrough | Delegates to real Caddy as an explicit unmanaged command path. | +| `add-package`, `reload`, `remove-package`, `start`, `stop`, `trust`, `untrust`, `upgrade` | Unsupported | Fails before real Caddy runs because the command can mutate runtime, trust, or binary state outside Cadder ownership. | +| Any unlisted command | Unsupported | Fails until the command has an explicit Cadder shim policy. | + ## Keep a separate real Caddy path The practical idea is simple: diff --git a/openspec/changes/reset-cadder-architecture/tasks.md b/openspec/changes/reset-cadder-architecture/tasks.md index 0e8ed4f..5393d75 100644 --- a/openspec/changes/reset-cadder-architecture/tasks.md +++ b/openspec/changes/reset-cadder-architecture/tasks.md @@ -21,11 +21,11 @@ - [x] 3.3 Extract daemon state concerns into focused modules for registrations, config composition, process lifecycle, runtime status, logs, and storage. - [x] 3.4 Define and implement production runtime locking with stale-lock recovery. - [x] 3.5 Define local IPC discovery and security for user clients contacting privileged runtime endpoints. -- [ ] 3.6 Add daemon contract tests with fake Caddy, fake storage, fake IPC clients, and fake IIS providers. +- [x] 3.6 Add daemon contract tests with fake Caddy, fake storage, fake IPC clients, and fake IIS providers. ## 4. Shim And Caddy Integration -- [ ] 4.1 Write the shim command policy table for managed, read-only, passthrough, and unsupported Caddy commands. +- [x] 4.1 Write the shim command policy table for managed, read-only, passthrough, and unsupported Caddy commands. - [ ] 4.2 Implement managed shim commands as requests to `cadderd` without starting unmanaged Caddy. - [ ] 4.3 Implement daemon-unavailable diagnostics and recovery guidance for shim commands. - [ ] 4.4 Harden real Caddy resolution against recursive shim execution. From f1d1afef34dc29d3c4c90f8c541d7a7605914f4c Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Wed, 8 Jul 2026 10:48:49 +0200 Subject: [PATCH 08/29] fix: keep managed shim runs on daemon path Remove the real Caddy fallback from managed caddy run recovery so the shim only registers through cadderd. Update shim tests and docs to reflect daemon-owned recovery, and mark OpenSpec task 4.2 complete. --- crates/cadder-shim/src/main.rs | 63 ++++--------------- crates/cadder-shim/tests/shim_binary.rs | 38 +++++++++++ .../docs/quick-start/getting-started.mdx | 2 +- .../docs/reference/real-caddy-resolution.mdx | 12 ++-- .../content/docs/user-guide/path-and-shim.mdx | 10 +-- .../reset-cadder-architecture/tasks.md | 2 +- 6 files changed, 63 insertions(+), 64 deletions(-) diff --git a/crates/cadder-shim/src/main.rs b/crates/cadder-shim/src/main.rs index 9092a80..b4f65bd 100644 --- a/crates/cadder-shim/src/main.rs +++ b/crates/cadder-shim/src/main.rs @@ -393,49 +393,26 @@ where } Err(error) => { let attach_error = format_error_chain(&error); - let caddy_backend = args - .caddy_backend - .map_or_else(CaddyBackendMode::from_env, Ok)?; - let real_caddy_result = if caddy_backend == CaddyBackendMode::Real { - match run_real_caddy_fallback(args.real_caddy_command.clone(), &args.caddy_args).await { - Ok(code) => return Ok(ManagedRunTarget::Exit(code)), - Err(error) => RecoveryStepResult::Failed(format_error_chain(&error)), - } - } else { - RecoveryStepResult::Skipped(format!( - "Caddy backend mode is `{}`", - caddy_backend.as_str() - )) - }; - match start_daemon(args.clone(), paths.clone()).await { Ok(()) => match CadderSession::connect(paths).await { Ok(session) => Ok(ManagedRunTarget::Cadder(Arc::new(Mutex::new(session)))), Err(error) => { + let daemon_error = format!( + "started cadderd, but attach failed: {}", + format_error_chain(&error) + ); eprintln!( "{}", - managed_recovery_failed_message( - paths, - &attach_error, - &real_caddy_result, - &RecoveryStepResult::Failed(format!( - "started cadderd, but attach failed: {}", - format_error_chain(&error) - )), - ) + managed_recovery_failed_message(paths, &attach_error, &daemon_error) ); Ok(ManagedRunTarget::Exit(ExitCode::FAILURE)) } }, Err(error) => { + let daemon_error = format_error_chain(&error); eprintln!( "{}", - managed_recovery_failed_message( - paths, - &attach_error, - &real_caddy_result, - &RecoveryStepResult::Failed(format_error_chain(&error)), - ) + managed_recovery_failed_message(paths, &attach_error, &daemon_error) ); Ok(ManagedRunTarget::Exit(ExitCode::FAILURE)) } @@ -463,12 +440,6 @@ async fn start_missing_daemon_owned(args: ShimArgs, paths: RuntimePaths) -> Resu start_missing_daemon(&args, &paths).await } -#[derive(Debug, Clone, PartialEq, Eq)] -enum RecoveryStepResult { - Failed(String), - Skipped(String), -} - async fn run_mock_caddy_command(args: &[String]) -> Result { match args.first().map(String::as_str) { None | Some("--version" | "version") => { @@ -492,29 +463,19 @@ async fn run_mock_caddy_command(args: &[String]) -> Result { fn managed_recovery_failed_message( paths: &RuntimePaths, attach_error: &str, - real_caddy_result: &RecoveryStepResult, - daemon_result: &RecoveryStepResult, + daemon_error: &str, ) -> String { format!( "Cadder could not recover `caddy run` for backend runtime `{}`.\n\ Initial attach failed: {attach_error}.\n\ - Real Caddy fallback: {}.\n\ - Daemon startup: {}.\n\ + Daemon recovery failed: {daemon_error}.\n\ + Managed `caddy run` was not delegated to real Caddy because Cadder must update runtime state through `cadderd`.\n\ Next: configure a safe real Caddy command, start `cadderd --background --runtime-dir \"{}\"`, or run `cadder daemon start` for the same runtime and retry.", paths.runtime_dir().display(), - recovery_step_summary(real_caddy_result), - recovery_step_summary(daemon_result), paths.runtime_dir().display(), ) } -fn recovery_step_summary(result: &RecoveryStepResult) -> String { - match result { - RecoveryStepResult::Failed(message) => format!("failed: {message}"), - RecoveryStepResult::Skipped(message) => format!("skipped: {message}"), - } -} - fn managed_backend_unavailable_message(paths: &RuntimePaths, error: &anyhow::Error) -> String { let runtime_dir = paths.runtime_dir().display(); let retry = format!( @@ -884,7 +845,7 @@ mod tests { } #[tokio::test] - async fn run_managed_uses_real_caddy_fallback_when_backend_is_missing() { + async fn run_managed_does_not_delegate_to_real_caddy_when_backend_is_missing() { let temp = tempfile::tempdir().unwrap(); let paths = RuntimePaths::resolve(Some(temp.path().join("runtime"))).unwrap(); let fake_caddy = temp.path().join(fake_caddy_name_for_test()); @@ -901,7 +862,7 @@ mod tests { .await .unwrap(); - assert_eq!(code, ExitCode::SUCCESS); + assert_eq!(code, ExitCode::FAILURE); } #[tokio::test] diff --git a/crates/cadder-shim/tests/shim_binary.rs b/crates/cadder-shim/tests/shim_binary.rs index 96ff2b6..cf06409 100644 --- a/crates/cadder-shim/tests/shim_binary.rs +++ b/crates/cadder-shim/tests/shim_binary.rs @@ -169,3 +169,41 @@ fn managed_run_reports_missing_backend_for_runtime_dir() { ); let _ = fs::remove_dir_all(runtime_dir); } + +#[test] +fn managed_run_does_not_delegate_to_real_caddy_when_daemon_is_missing() { + let runtime_dir = unique_runtime_dir("managed-no-delegate"); + let runtime_dir_arg = runtime_dir.display().to_string(); + let missing_daemon = runtime_dir.join(if cfg!(windows) { + "missing-cadderd.exe" + } else { + "missing-cadderd" + }); + let missing_daemon_arg = missing_daemon.display().to_string(); + let fake_caddy = write_fake_real_caddy("managed-no-delegate", 0); + let fake_caddy_arg = fake_caddy.display().to_string(); + + let output = run_shim(&[ + "--cadder-runtime-dir", + &runtime_dir_arg, + "--cadder-daemon-path", + &missing_daemon_arg, + "--cadder-real-caddy-command", + &fake_caddy_arg, + "run", + ]); + + assert_eq!(output.status.code(), Some(1)); + assert!( + !String::from_utf8(output.stdout) + .unwrap() + .contains("delegated run") + ); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("Managed `caddy run` was not delegated to real Caddy"), + "{stderr}" + ); + let _ = fs::remove_dir_all(runtime_dir); + let _ = fs::remove_dir_all(fake_caddy.parent().unwrap()); +} diff --git a/docs/site/src/content/docs/quick-start/getting-started.mdx b/docs/site/src/content/docs/quick-start/getting-started.mdx index 46b7e89..4ee558b 100644 --- a/docs/site/src/content/docs/quick-start/getting-started.mdx +++ b/docs/site/src/content/docs/quick-start/getting-started.mdx @@ -23,7 +23,7 @@ cadder daemon status caddy run ``` -The shim attaches to `cadderd`, registers the project Caddyfile, and keeps that registration alive while the command is running. If the backend is unavailable, Cadder first tries a safe real Caddy fallback, then tries to start the missing daemon, and finally prints recovery steps. +The shim attaches to `cadderd`, registers the project Caddyfile, and keeps that registration alive while the command is running. If the backend is unavailable, Cadder tries to start the missing daemon and then prints recovery steps if startup or attach fails. It does not run `caddy run` as an unmanaged real Caddy fallback. Inspect Cadder with CLI or TUI: diff --git a/docs/site/src/content/docs/reference/real-caddy-resolution.mdx b/docs/site/src/content/docs/reference/real-caddy-resolution.mdx index aa12d61..b17e4d9 100644 --- a/docs/site/src/content/docs/reference/real-caddy-resolution.mdx +++ b/docs/site/src/content/docs/reference/real-caddy-resolution.mdx @@ -17,15 +17,15 @@ The resolver uses this exact order: This differs from the recommended setup order because explicit per-command and per-project overrides must win at runtime. -## Shim fallback for `caddy run` +## Shim recovery for `caddy run` -When the PATH-facing shim handles `caddy run`, it first tries to attach to the configured Cadder runtime. If no daemon is available, recovery uses this order: +When the PATH-facing shim handles `caddy run`, it first tries to attach to the configured Cadder runtime. If no daemon is available, recovery stays inside Cadder's daemon ownership boundary: -1. Delegate to the safely resolved real Caddy command when the backend mode is `real`. -2. Start the missing daemon through Cadder's detached background launch contract. -3. Print a terminal error that includes the original attach failure, real Caddy fallback result, daemon-start result, and concrete retry guidance. +1. Start the missing daemon through Cadder's detached background launch contract. +2. Attach to `cadderd` and register the project definition through the Cadder protocol. +3. Print a terminal error that includes the original attach failure, daemon-start result, and concrete retry guidance. -The fallback uses the same resolver described above, including recursion-safety exclusions for the current executable and `CADDER_CADDY_SHIM_PATH`. In mock backend mode, real Caddy fallback is skipped and the shim tries daemon startup directly. +The shim does not run `caddy run` as an unmanaged real Caddy fallback. The resolver described above is used by `cadderd` to find the real Caddy execution target after the daemon accepts managed state. ## Mock backend for development diff --git a/docs/site/src/content/docs/user-guide/path-and-shim.mdx b/docs/site/src/content/docs/user-guide/path-and-shim.mdx index f192a37..c5ab4fe 100644 --- a/docs/site/src/content/docs/user-guide/path-and-shim.mdx +++ b/docs/site/src/content/docs/user-guide/path-and-shim.mdx @@ -25,13 +25,13 @@ If you want `caddy run` to go through Cadder, copy or symlink the Cadder shim in The real Caddy binary still needs to be resolvable by Cadder. The recommended place is [cadder.toml](../cadder-toml/). -For `caddy run`, the shim is attach-first. If the Cadder daemon is missing, recovery uses this order: +For `caddy run`, the shim is attach-first. If the Cadder daemon is missing, recovery stays inside Cadder's daemon ownership boundary: -1. Run the safely resolved real Caddy command as a compatibility fallback. -2. Start `cadderd` through Cadder's detached background launch contract. -3. Return a terminal error that lists the attempted recovery steps and next actions. +1. Start `cadderd` through Cadder's detached background launch contract. +2. Attach to `cadderd` and register the project definition through the Cadder protocol. +3. Return a terminal error with next actions if daemon startup or attach fails. -When `CADDER_CADDY_BACKEND=mock` or `--cadder-caddy-backend mock` is active, real Caddy fallback is skipped and the shim tries the daemon path directly. +The shim does not run `caddy run` as an unmanaged real Caddy fallback. Real Caddy is started only by `cadderd` as the managed execution target after the daemon accepts the registration. ## Command policy diff --git a/openspec/changes/reset-cadder-architecture/tasks.md b/openspec/changes/reset-cadder-architecture/tasks.md index 5393d75..faef0dd 100644 --- a/openspec/changes/reset-cadder-architecture/tasks.md +++ b/openspec/changes/reset-cadder-architecture/tasks.md @@ -26,7 +26,7 @@ ## 4. Shim And Caddy Integration - [x] 4.1 Write the shim command policy table for managed, read-only, passthrough, and unsupported Caddy commands. -- [ ] 4.2 Implement managed shim commands as requests to `cadderd` without starting unmanaged Caddy. +- [x] 4.2 Implement managed shim commands as requests to `cadderd` without starting unmanaged Caddy. - [ ] 4.3 Implement daemon-unavailable diagnostics and recovery guidance for shim commands. - [ ] 4.4 Harden real Caddy resolution against recursive shim execution. - [ ] 4.5 Implement config composition and atomic apply behavior with last-known-good recovery. From 2ace61ad432ffe966feb66d0db5e32d3345bcded Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Wed, 8 Jul 2026 10:52:35 +0200 Subject: [PATCH 09/29] fix: label read-only shim inspection output Warn when read-only caddy shim commands delegate to real Caddy without a reachable cadderd backend. Document the distinction between real-Caddy inspection and Cadder runtime state, and mark OpenSpec task 4.3 complete. --- crates/cadder-shim/src/main.rs | 69 +++++++++++++++++++ crates/cadder-shim/tests/shim_binary.rs | 31 +++++++++ .../docs/reference/real-caddy-resolution.mdx | 2 + .../content/docs/user-guide/path-and-shim.mdx | 2 + .../reset-cadder-architecture/tasks.md | 2 +- 5 files changed, 105 insertions(+), 1 deletion(-) diff --git a/crates/cadder-shim/src/main.rs b/crates/cadder-shim/src/main.rs index b4f65bd..681140c 100644 --- a/crates/cadder-shim/src/main.rs +++ b/crates/cadder-shim/src/main.rs @@ -234,6 +234,9 @@ async fn main() -> Result { } else if caddy_backend == CaddyBackendMode::Mock { run_mock_caddy_command(&args.caddy_args).await } else { + if command_policy.kind == ShimCommandPolicyKind::ReadOnlyInspection { + write_read_only_real_caddy_inspection_notice(&args, command_policy).await; + } delegate_to_real_caddy(args.real_caddy_command, &args.caddy_args).await } } @@ -274,6 +277,52 @@ fn reject_unsupported_caddy_command(command: ClassifiedShimCommand<'_>) -> ExitC ExitCode::FAILURE } +async fn write_read_only_real_caddy_inspection_notice( + args: &ShimArgs, + command: ClassifiedShimCommand<'_>, +) { + let Ok(paths) = + RuntimePaths::resolve_with_profile(args.runtime_dir.clone(), args.runtime_profile) + else { + return; + }; + + match CadderSession::connect(&paths).await { + Ok(_) => {} + Err(error) => eprintln!( + "{}", + read_only_real_caddy_inspection_message( + &paths, + command, + daemon_error_indicates_not_running(&error), + &format_error_chain(&error), + ) + ), + } +} + +fn read_only_real_caddy_inspection_message( + paths: &RuntimePaths, + command: ClassifiedShimCommand<'_>, + daemon_not_running: bool, + daemon_error: &str, +) -> String { + let runtime_dir = paths.runtime_dir().display(); + let daemon_status = if daemon_not_running { + "is not running".to_string() + } else { + format!("could not be contacted: {daemon_error}") + }; + + format!( + "Cadder backend `cadderd` {daemon_status} for runtime `{runtime_dir}`. \ + Running read-only `caddy {}` against the safely resolved real Caddy binary. \ + Output is real-Caddy inspection, not Cadder runtime state. \ + To inspect Cadder runtime state, run `cadder daemon start` and retry.", + command.command + ) +} + async fn run_managed(args: ShimArgs) -> Result { write_shim_privilege_warning(); run_managed_until(args, tokio::signal::ctrl_c()).await @@ -798,6 +847,26 @@ mod tests { assert!(message.contains("retry `caddy run`")); } + #[test] + fn read_only_real_caddy_inspection_message_labels_output_and_recovery() { + let paths = + RuntimePaths::resolve(Some(std::env::temp_dir().join("cadder-read-only-test"))).unwrap(); + let args = ["version".to_string()]; + let command = classify_caddy_command(&args); + + let message = read_only_real_caddy_inspection_message( + &paths, + command, + true, + "connect to Cadder daemon socket failed", + ); + + assert!(message.contains("cadderd` is not running")); + assert!(message.contains("read-only `caddy version`")); + assert!(message.contains("real-Caddy inspection, not Cadder runtime state")); + assert!(message.contains("cadder daemon start")); + } + #[test] fn backend_error_helpers_classify_transport_and_protocol_failures() { let paths = diff --git a/crates/cadder-shim/tests/shim_binary.rs b/crates/cadder-shim/tests/shim_binary.rs index cf06409..516625d 100644 --- a/crates/cadder-shim/tests/shim_binary.rs +++ b/crates/cadder-shim/tests/shim_binary.rs @@ -207,3 +207,34 @@ fn managed_run_does_not_delegate_to_real_caddy_when_daemon_is_missing() { let _ = fs::remove_dir_all(runtime_dir); let _ = fs::remove_dir_all(fake_caddy.parent().unwrap()); } + +#[test] +fn read_only_command_labels_real_caddy_inspection_when_daemon_is_missing() { + let runtime_dir = unique_runtime_dir("read-only-no-daemon"); + let runtime_dir_arg = runtime_dir.display().to_string(); + let fake_caddy = write_fake_real_caddy("read-only-no-daemon", 0); + let fake_caddy_arg = fake_caddy.display().to_string(); + + let output = run_shim(&[ + "--cadder-runtime-dir", + &runtime_dir_arg, + "--cadder-real-caddy-command", + &fake_caddy_arg, + "version", + ]); + + assert_eq!(output.status.code(), Some(0)); + assert!( + String::from_utf8(output.stdout) + .unwrap() + .contains("delegated version") + ); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("real-Caddy inspection, not Cadder runtime state"), + "{stderr}" + ); + assert!(stderr.contains("cadder daemon start"), "{stderr}"); + let _ = fs::remove_dir_all(runtime_dir); + let _ = fs::remove_dir_all(fake_caddy.parent().unwrap()); +} diff --git a/docs/site/src/content/docs/reference/real-caddy-resolution.mdx b/docs/site/src/content/docs/reference/real-caddy-resolution.mdx index b17e4d9..e3ae69f 100644 --- a/docs/site/src/content/docs/reference/real-caddy-resolution.mdx +++ b/docs/site/src/content/docs/reference/real-caddy-resolution.mdx @@ -27,6 +27,8 @@ When the PATH-facing shim handles `caddy run`, it first tries to attach to the c The shim does not run `caddy run` as an unmanaged real Caddy fallback. The resolver described above is used by `cadderd` to find the real Caddy execution target after the daemon accepts managed state. +Read-only inspection commands such as `caddy version`, `caddy adapt`, and `caddy validate` may still delegate to the safely resolved real Caddy binary. If `cadderd` is unavailable, the shim labels stderr so the command output is understood as real-Caddy inspection, not Cadder runtime state. + ## Mock backend for development Set `CADDER_CADDY_BACKEND=mock` or pass `--caddy-backend mock` to use Cadder's development mock backend. In this mode the daemon does not resolve or execute a real Caddy binary, does not run `caddy adapt`, and does not bind HTTP or HTTPS ports. It records deterministic runtime state and effective config for local UI and operator workflows. diff --git a/docs/site/src/content/docs/user-guide/path-and-shim.mdx b/docs/site/src/content/docs/user-guide/path-and-shim.mdx index c5ab4fe..fa13067 100644 --- a/docs/site/src/content/docs/user-guide/path-and-shim.mdx +++ b/docs/site/src/content/docs/user-guide/path-and-shim.mdx @@ -45,6 +45,8 @@ The shim classifies the first Caddy command before it decides what may run: | `add-package`, `reload`, `remove-package`, `start`, `stop`, `trust`, `untrust`, `upgrade` | Unsupported | Fails before real Caddy runs because the command can mutate runtime, trust, or binary state outside Cadder ownership. | | Any unlisted command | Unsupported | Fails until the command has an explicit Cadder shim policy. | +When a read-only inspection command delegates to real Caddy while `cadderd` is unavailable, the shim writes a stderr notice that the output is real-Caddy inspection, not Cadder runtime state. + ## Keep a separate real Caddy path The practical idea is simple: diff --git a/openspec/changes/reset-cadder-architecture/tasks.md b/openspec/changes/reset-cadder-architecture/tasks.md index faef0dd..8e264da 100644 --- a/openspec/changes/reset-cadder-architecture/tasks.md +++ b/openspec/changes/reset-cadder-architecture/tasks.md @@ -27,7 +27,7 @@ - [x] 4.1 Write the shim command policy table for managed, read-only, passthrough, and unsupported Caddy commands. - [x] 4.2 Implement managed shim commands as requests to `cadderd` without starting unmanaged Caddy. -- [ ] 4.3 Implement daemon-unavailable diagnostics and recovery guidance for shim commands. +- [x] 4.3 Implement daemon-unavailable diagnostics and recovery guidance for shim commands. - [ ] 4.4 Harden real Caddy resolution against recursive shim execution. - [ ] 4.5 Implement config composition and atomic apply behavior with last-known-good recovery. - [ ] 4.6 Add shim tests for command policy, no-daemon behavior, fallback behavior, and recursion prevention. From a10c63af6a6ef5d0c8971e982bb7e28f1ed7caf8 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Wed, 8 Jul 2026 10:57:05 +0200 Subject: [PATCH 10/29] test: cover real Caddy shim exclusion Exercise CADDER_CADDY_SHIM_PATH through the RealCaddyResolver path lookup so PATH prefers a later real Caddy binary over the shim. Mark OpenSpec task 4.4 complete. --- crates/cadder-daemon/src/caddy.rs | 35 +++++++++++++++++++ .../reset-cadder-architecture/tasks.md | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/cadder-daemon/src/caddy.rs b/crates/cadder-daemon/src/caddy.rs index 64108b4..440d9a3 100644 --- a/crates/cadder-daemon/src/caddy.rs +++ b/crates/cadder-daemon/src/caddy.rs @@ -1936,6 +1936,41 @@ app.localhost, http://api.localhost:8080 { assert_eq!(resolved, canonical(&real)); } + #[test] + fn resolver_honors_caddy_shim_path_exclusion_when_searching_path() { + let _guard = lock_env(); + let _snapshot = EnvSnapshot::capture(&[ + "PATH", + "CADDER_CADDY_SHIM_PATH", + "CADDER_CADDY_REAL_COMMAND", + "CADDER_CADDY__REAL_COMMAND", + ]); + let dir = tempfile::tempdir().unwrap(); + let shim_dir = dir.path().join("shim"); + let real_dir = dir.path().join("real"); + fs::create_dir_all(&shim_dir).unwrap(); + fs::create_dir_all(&real_dir).unwrap(); + let shim = shim_dir.join(exe_name_for_test("caddy")); + let real = real_dir.join(exe_name_for_test("caddy")); + write_file(&shim); + write_file(&real); + let path_var = env::join_paths([shim_dir.as_path(), real_dir.as_path()]).unwrap(); + unsafe { + env::remove_var("CADDER_CADDY_REAL_COMMAND"); + env::remove_var("CADDER_CADDY__REAL_COMMAND"); + env::set_var("CADDER_CADDY_SHIM_PATH", shim.display().to_string()); + env::set_var("PATH", path_var); + } + let resolver = RealCaddyResolver::with_executable_path( + None, + Some(dir.path().join(exe_name_for_test("cadderd"))), + ); + + let resolved = resolver.resolve_for_working_directory(dir.path()).unwrap(); + + assert_eq!(resolved, canonical(&real)); + } + #[cfg(windows)] fn exe_name_for_test(name: &str) -> String { format!("{name}.exe") diff --git a/openspec/changes/reset-cadder-architecture/tasks.md b/openspec/changes/reset-cadder-architecture/tasks.md index 8e264da..4c009bf 100644 --- a/openspec/changes/reset-cadder-architecture/tasks.md +++ b/openspec/changes/reset-cadder-architecture/tasks.md @@ -28,7 +28,7 @@ - [x] 4.1 Write the shim command policy table for managed, read-only, passthrough, and unsupported Caddy commands. - [x] 4.2 Implement managed shim commands as requests to `cadderd` without starting unmanaged Caddy. - [x] 4.3 Implement daemon-unavailable diagnostics and recovery guidance for shim commands. -- [ ] 4.4 Harden real Caddy resolution against recursive shim execution. +- [x] 4.4 Harden real Caddy resolution against recursive shim execution. - [ ] 4.5 Implement config composition and atomic apply behavior with last-known-good recovery. - [ ] 4.6 Add shim tests for command policy, no-daemon behavior, fallback behavior, and recursion prevention. From 5f4ca9eba5b539c62a8591b71303d365e89c4995 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Mon, 5 Oct 2026 16:05:34 +0200 Subject: [PATCH 11/29] feat: add authenticated Node runtime migration foundation --- .github/workflows/node-runtime.yml | 59 + .gitignore | 4 + .prettierrc.json | 4 + eslint.config.ts | 4 + nub.lock | 2389 +++++++++++++++++ .../behavior-baseline.md | 20 + .../reset-cadder-architecture/design.md | 5 + .../node-migration.md | 71 + .../reset-cadder-architecture/proposal.md | 5 + .../specs/daemon-control-plane/spec.md | 39 +- .../specs/runtime-topology/spec.md | 34 +- .../reset-cadder-architecture/tasks.md | 21 +- .../reset-cadder-architecture/verification.md | 54 + openspec/config.yaml | 8 +- package.json | 46 + scripts/prepare-runtime-gate.ts | 20 + src/client/connection.ts | 54 + src/daemon/ipc-server.ts | 121 + src/daemon/local-runtime.ts | 50 + src/daemon/paths.ts | 75 + src/daemon/runtime-lock.ts | 83 + src/platform/powershell.ts | 38 + src/platform/runtime-security.ts | 134 + src/protocol/authentication.ts | 121 + src/protocol/channel.ts | 88 + src/protocol/errors.ts | 16 + src/protocol/rpc.ts | 16 + src/protocol/version.ts | 5 + test/authentication.test.ts | 158 ++ test/channel.test.ts | 61 + test/fixtures/runtime-child.ts | 26 + test/fixtures/runtime-smoke.ts | 69 + test/ipc-server.test.ts | 61 + test/local-runtime.test.ts | 53 + test/paths.test.ts | 22 + test/runtime-lock.test.ts | 40 + test/runtime-security.test.ts | 53 + test/runtime-system.test.ts | 81 + test/unix-security-policy.test.ts | 64 + tsconfig.json | 25 + vitest.config.ts | 18 + 41 files changed, 4297 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/node-runtime.yml create mode 100644 .prettierrc.json create mode 100644 eslint.config.ts create mode 100644 nub.lock create mode 100644 openspec/changes/reset-cadder-architecture/behavior-baseline.md create mode 100644 openspec/changes/reset-cadder-architecture/node-migration.md create mode 100644 openspec/changes/reset-cadder-architecture/verification.md create mode 100644 package.json create mode 100644 scripts/prepare-runtime-gate.ts create mode 100644 src/client/connection.ts create mode 100644 src/daemon/ipc-server.ts create mode 100644 src/daemon/local-runtime.ts create mode 100644 src/daemon/paths.ts create mode 100644 src/daemon/runtime-lock.ts create mode 100644 src/platform/powershell.ts create mode 100644 src/platform/runtime-security.ts create mode 100644 src/protocol/authentication.ts create mode 100644 src/protocol/channel.ts create mode 100644 src/protocol/errors.ts create mode 100644 src/protocol/rpc.ts create mode 100644 src/protocol/version.ts create mode 100644 test/authentication.test.ts create mode 100644 test/channel.test.ts create mode 100644 test/fixtures/runtime-child.ts create mode 100644 test/fixtures/runtime-smoke.ts create mode 100644 test/ipc-server.test.ts create mode 100644 test/local-runtime.test.ts create mode 100644 test/paths.test.ts create mode 100644 test/runtime-lock.test.ts create mode 100644 test/runtime-security.test.ts create mode 100644 test/runtime-system.test.ts create mode 100644 test/unix-security-policy.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.github/workflows/node-runtime.yml b/.github/workflows/node-runtime.yml new file mode 100644 index 0000000..b63d773 --- /dev/null +++ b/.github/workflows/node-runtime.yml @@ -0,0 +1,59 @@ +name: Node runtime migration gate + +on: + push: + branches: + - master + - feat/node-runtime-migration + pull_request: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +jobs: + runtime: + name: Node runtime (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + env: + CADDER_COVERAGE_DIR: ${{ runner.temp }}/cadder-node-coverage + steps: + - uses: actions/checkout@v6.0.3 + - uses: actions/setup-node@v7.0.0 + with: + node-version: 24.18.0 + - name: Bootstrap pinned repository manager + run: npm install --global nub@0.7.5 + - name: Install exact locked dependencies + run: nub ci + - name: Check TypeScript + run: nub run typecheck + - name: Check lints + run: nub run lint + - name: Check formatting + run: nub run format:check + - name: Test runtime and coverage + run: nub run coverage + - name: Pack native runtime fixtures outside checkout + id: fixtures + shell: bash + run: echo "stage=$(node scripts/prepare-runtime-gate.ts)" >> "$GITHUB_OUTPUT" + - name: Native compiled-JS runtime smoke without TS loader + run: node "${{ steps.fixtures.outputs.stage }}/runtime-smoke.mjs" "${{ steps.fixtures.outputs.stage }}/runtime-child.mjs" + - name: Verify no tracked build output + run: git diff --exit-code + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: node-coverage-${{ matrix.os }} + path: ${{ env.CADDER_COVERAGE_DIR }} + if-no-files-found: ignore + retention-days: 14 diff --git a/.gitignore b/.gitignore index c7cd270..910bdb4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Rust / Cargo target/ +# Node dependencies and transient test output +node_modules/ +.astro/ + # Build and release outputs artifacts/ dist/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..554f2a3 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "printWidth": 100 +} diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..07b1e7f --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,4 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended); diff --git a/nub.lock b/nub.lock new file mode 100644 index 0000000..9a437ee --- /dev/null +++ b/nub.lock @@ -0,0 +1,2389 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@peculiar/x509': + specifier: 2.1.0 + version: 2.1.0 + commander: + specifier: 15.0.0 + version: 15.0.0 + ink: + specifier: 8.0.0 + version: 8.0.0(@types/react@19.3.0)(react@19.3.0) + react: + specifier: 19.3.0 + version: 19.3.0 + reflect-metadata: + specifier: 0.2.2 + version: 0.2.2 + smol-toml: + specifier: 1.9.0 + version: 1.9.0 + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@eslint/js': + specifier: 10.0.1 + version: 10.0.1(eslint@10.0.3) + '@types/node': + specifier: 24.19.1 + version: 24.19.1 + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@vitest/coverage-v8': + specifier: 5.0.3 + version: 5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0))(vitest@5.0.3) + esbuild: + specifier: 0.28.2 + version: 0.28.2 + eslint: + specifier: 10.0.3 + version: 10.0.3 + ink-testing-library: + specifier: 4.0.0 + version: 4.0.0(@types/react@19.3.0) + prettier: + specifier: 3.8.3 + version: 3.8.3 + tsx: + specifier: 4.23.0 + version: 4.23.0 + typescript: + specifier: 6.0.3 + version: 6.0.3 + typescript-eslint: + specifier: 8.57.0 + version: 8.57.0(eslint@10.0.3)(typescript@6.0.3) + vitest: + specifier: 5.0.3 + version: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0)) + +packages: + + '@alcalzone/ansi-tokenize@0.3.1': + resolution: {integrity: sha512-cUD0PT0YX+rImRhfn7BfMRduP/tX/Zqt/N0dHlAyn2RlcJssCb5KsCJkhln61eh9vuJDkH8wKn3L8zJzzWXPFg==} + engines: {node: '>=18'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + + '@peculiar/asn1-asym-key@2.10.0': + resolution: {integrity: sha512-1R8xUTeqcUTSMLFTTCzJyFuKiq3y25EQKs9DezUetJ8qMqbJyxo3XkzykiwwT/Xzlt5/vRzS/sW4M1r+pq1FTg==} + engines: {node: '>=14'} + + '@peculiar/asn1-cms@2.10.0': + resolution: {integrity: sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==} + engines: {node: '>=14'} + + '@peculiar/asn1-csr@2.10.0': + resolution: {integrity: sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==} + engines: {node: '>=14'} + + '@peculiar/asn1-ecc@2.10.0': + resolution: {integrity: sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==} + engines: {node: '>=14'} + + '@peculiar/asn1-pfx@2.10.0': + resolution: {integrity: sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==} + engines: {node: '>=14'} + + '@peculiar/asn1-pkcs8@2.10.0': + resolution: {integrity: sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==} + engines: {node: '>=14'} + + '@peculiar/asn1-pkcs9@2.10.0': + resolution: {integrity: sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==} + engines: {node: '>=14'} + + '@peculiar/asn1-rsa@2.10.0': + resolution: {integrity: sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==} + engines: {node: '>=14'} + + '@peculiar/asn1-schema@2.10.0': + resolution: {integrity: sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509-attr@2.10.0': + resolution: {integrity: sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509-post-quantum@2.10.0': + resolution: {integrity: sha512-cXKcZ30wKbUkRK5RK4Z3W1imewXvHAB9apEAuIqJ0OOkOQCpJrwLOuN38QlsEXqOI+3gtUv8Kf3UZohUmCbf3w==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509@2.10.0': + resolution: {integrity: sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==} + engines: {node: '>=14'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@2.1.0': + resolution: {integrity: sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==} + engines: {node: '>=20.0.0'} + + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.12': + resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.12': + resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.12': + resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.12': + resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.12': + resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.12': + resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.12': + resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@typescript-eslint/eslint-plugin@8.57.0': + resolution: {integrity: sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.57.0': + resolution: {integrity: sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.57.0': + resolution: {integrity: sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.57.0': + resolution: {integrity: sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.57.0': + resolution: {integrity: sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.57.0': + resolution: {integrity: sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.57.0': + resolution: {integrity: sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.57.0': + resolution: {integrity: sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.57.0': + resolution: {integrity: sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.57.0': + resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/coverage-v8@5.0.3': + resolution: {integrity: sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==} + peerDependencies: + '@vitest/browser': 5.0.3 + vitest: 5.0.3 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/istanbul-lib-coverage@1.0.2': + resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.2': + resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} + engines: {node: '>=22'} + + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansi-styles@7.0.0: + resolution: {integrity: sha512-kKvt3m4uwzqL0wlkPd09CmljPJGOZZ4D0fP65sqFSvPkMRKhNi+74MgIJ5QxE6SxqB4t4KyUFGg8+n5zjo6hew==} + engines: {node: '>=22'} + + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + chalk@6.0.1: + resolution: {integrity: sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==} + engines: {node: '>=22'} + + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cli-truncate@6.1.1: + resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} + engines: {node: '>=22'} + + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.0.3: + resolution: {integrity: sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.12: + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + ink-testing-library@4.0.0: + resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + + ink@8.0.0: + resolution: {integrity: sha512-M2aFwZOqmkFxCwedy6xrP4E+DGoK57iahAFnfTqNyOyJ54O7DZzu027z/NI8EGy44Sw9O9BcbTF4QEUS2GHAtg==} + engines: {node: '>=22'} + peerDependencies: + '@types/react': '>=19.3.0' + react: '>=19.3.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + + quick-lru@7.3.0: + resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==} + engines: {node: '>=18'} + + react-reconciler@0.34.0: + resolution: {integrity: sha512-eVibvB1fc9j5Qf1KRkiM3GzRPBdjg2P6G6QV2f3P6Eb3OGMxRBhdg2rrUwjbV5JrQaJj8nOa8uajmmJA5tJWjQ==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.3.0 + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + rolldown@1.2.12: + resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + slice-ansi@9.0.1: + resolution: {integrity: sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==} + engines: {node: '>=22'} + + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.0: + resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} + engines: {node: '>=18.0.0'} + hasBin: true + + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} + engines: {node: '>=20'} + + typescript-eslint@8.57.0: + resolution: {integrity: sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@8.3.2: + resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: {} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@10.0.2: + resolution: {integrity: sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==} + engines: {node: '>=20'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@alcalzone/ansi-tokenize@0.3.1': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@10.0.3)': + dependencies: + eslint: 10.0.3 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.0.3)': + dependencies: + eslint: 10.0.3 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.152.0': {} + + '@peculiar/asn1-asym-key@2.10.0': + dependencies: + '@peculiar/asn1-pkcs8': 2.10.0 + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + '@peculiar/asn1-x509-attr': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.10.0': + dependencies: + '@peculiar/asn1-cms': 2.10.0 + '@peculiar/asn1-pkcs8': 2.10.0 + '@peculiar/asn1-rsa': 2.10.0 + '@peculiar/asn1-schema': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.10.0': + dependencies: + '@peculiar/asn1-cms': 2.10.0 + '@peculiar/asn1-pfx': 2.10.0 + '@peculiar/asn1-pkcs8': 2.10.0 + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + '@peculiar/asn1-x509-attr': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.10.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-post-quantum@2.10.0': + dependencies: + '@peculiar/asn1-asym-key': 2.10.0 + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@2.1.0': + dependencies: + '@peculiar/asn1-cms': 2.10.0 + '@peculiar/asn1-csr': 2.10.0 + '@peculiar/asn1-ecc': 2.10.0 + '@peculiar/asn1-pkcs9': 2.10.0 + '@peculiar/asn1-rsa': 2.10.0 + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + '@peculiar/asn1-x509-post-quantum': 2.10.0 + pvtsutils: 1.3.6 + tslib: 2.8.1 + tsyringe: 4.10.0 + + '@rolldown/binding-android-arm-eabi@1.2.12': + optional: true + + '@rolldown/binding-android-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-x64@1.2.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.12': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.12': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.0.3)(typescript@6.0.3))(eslint@10.0.3)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.0(eslint@10.0.3)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/type-utils': 8.57.0(eslint@10.0.3)(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.0.3)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.57.0 + eslint: 10.0.3 + ignore: 7.0.12 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.0(eslint@10.0.3)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3 + eslint: 10.0.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.57.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@6.0.3) + '@typescript-eslint/types': 8.57.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + + '@typescript-eslint/tsconfig-utils@8.57.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.57.0(eslint@10.0.3)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.0.3)(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.0.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.57.0': {} + + '@typescript-eslint/typescript-estree@8.57.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@6.0.3) + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.0(eslint@10.0.3)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.0.3) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3) + eslint: 10.0.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + eslint-visitor-keys: 5.0.1 + + '@vitest/coverage-v8@5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0))(vitest@5.0.3)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/istanbul-lib-report': 1.0.2 + ast-v8-to-istanbul: 1.0.7 + magicast: 0.5.5 + obug: 2.2.1 + std-env: 4.3.0 + tinyrainbow: 3.2.0 + vitest: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0)) + + '@vitest/istanbul-lib-coverage@1.0.2': {} + + '@vitest/istanbul-lib-report@1.0.2': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.2 + + '@vitest/mocker@5.0.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.2 + vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0) + + '@vitest/spy@5.0.3': {} + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@6.4.0: {} + + ansi-styles@6.2.3: {} + + ansi-styles@7.0.0: {} + + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + auto-bind@5.0.1: {} + + balanced-match@4.0.4: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + chai@6.3.0: {} + + chalk@6.0.1: {} + + cli-boxes@4.0.1: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + + cli-truncate@6.1.1: + dependencies: + slice-ansi: 9.0.1 + string-width: 8.3.0 + + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + + commander@15.0.0: {} + + convert-to-spaces@2.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + environment@1.1.0: {} + + es-module-lexer@2.3.2: {} + + es-toolkit@1.52.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.0.3: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.0.3) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.5.5 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + 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.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.7): + dependencies: + picomatch: 4.0.7 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + fsevents@2.3.3: + optional: true + + get-east-asian-width@1.7.0: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + ignore@5.3.2: {} + + ignore@7.0.12: {} + + imurmurhash@0.1.4: {} + + indent-string@5.0.0: {} + + ink-testing-library@4.0.0(@types/react@19.3.0): + dependencies: + '@types/react': 19.3.0 + + ink@8.0.0(@types/react@19.3.0)(react@19.3.0): + dependencies: + '@alcalzone/ansi-tokenize': 0.3.1 + '@types/react': 19.3.0 + ansi-escapes: 7.3.0 + ansi-styles: 7.0.0 + auto-bind: 5.0.1 + chalk: 6.0.1 + cli-boxes: 4.0.1 + cli-cursor: 4.0.0 + cli-truncate: 6.1.1 + code-excerpt: 4.0.0 + es-toolkit: 1.52.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + quick-lru: 7.3.0 + react: 19.3.0 + react-reconciler: 0.34.0(react@19.3.0) + signal-exit: 3.0.7 + stack-utils: 2.0.6 + string-width: 8.3.0 + terminal-size: 4.0.1 + type-fest: 5.10.0 + widest-line: 6.0.0 + wrap-ansi: 10.0.2 + yoga-layout: 3.2.1 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.7.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ci@2.0.0: {} + + isexe@2.0.0: {} + + js-tokens@10.0.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.2 + + mimic-fn@2.1.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + natural-compare@1.4.0: {} + + obug@2.2.1: {} + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + patch-console@2.0.0: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + prelude-ls@1.2.1: {} + + prettier@3.8.3: {} + + punycode@2.3.1: {} + + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + + quick-lru@7.3.0: {} + + react-reconciler@0.34.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react@19.3.0: {} + + reflect-metadata@0.2.2: {} + + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + rolldown@1.2.12: + dependencies: + '@oxc-project/types': 0.152.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.12 + '@rolldown/binding-android-arm64': 1.2.12 + '@rolldown/binding-darwin-arm64': 1.2.12 + '@rolldown/binding-darwin-x64': 1.2.12 + '@rolldown/binding-freebsd-x64': 1.2.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 + '@rolldown/binding-linux-arm64-gnu': 1.2.12 + '@rolldown/binding-linux-arm64-musl': 1.2.12 + '@rolldown/binding-linux-ppc64-gnu': 1.2.12 + '@rolldown/binding-linux-s390x-gnu': 1.2.12 + '@rolldown/binding-linux-x64-gnu': 1.2.12 + '@rolldown/binding-linux-x64-musl': 1.2.12 + '@rolldown/binding-openharmony-arm64': 1.2.12 + '@rolldown/binding-win32-arm64-msvc': 1.2.12 + '@rolldown/binding-win32-x64-msvc': 1.2.12 + + scheduler@0.28.0: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + slice-ansi@9.0.1: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + smol-toml@1.9.0: {} + + source-map-js@1.2.2: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + std-env@4.3.0: {} + + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + + tagged-tag@1.0.0: {} + + terminal-size@4.0.1: {} + + tinybench@6.2.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.2.0: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@1.14.1: {} + + tslib@2.8.1: {} + + tsx@4.23.0: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 + + typescript-eslint@8.57.0(eslint@10.0.3)(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.0.3)(typescript@6.0.3))(eslint@10.0.3)(typescript@6.0.3) + '@typescript-eslint/parser': 8.57.0(eslint@10.0.3)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.0.3)(typescript@6.0.3) + eslint: 10.0.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0): + dependencies: + '@types/node': 24.19.1 + esbuild: 0.28.2 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.12 + tinyglobby: 0.2.17 + tsx: 4.23.0 + optionalDependencies: + fsevents: 2.3.3 + + vitest@5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0)): + dependencies: + '@types/chai': 5.2.3 + '@types/node': 24.19.1 + '@vitest/coverage-v8': 5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0))(vitest@5.0.3) + '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0) + why-is-node-running: 3.2.1 + transitivePeerDependencies: + - msw + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@3.2.1: {} + + widest-line@6.0.0: + dependencies: + string-width: 8.3.0 + + word-wrap@1.2.5: {} + + wrap-ansi@10.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.3.0 + + yocto-queue@0.1.0: {} + + yoga-layout@3.2.1: {} + + zod@4.6.5: {} diff --git a/openspec/changes/reset-cadder-architecture/behavior-baseline.md b/openspec/changes/reset-cadder-architecture/behavior-baseline.md new file mode 100644 index 0000000..a15799c --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/behavior-baseline.md @@ -0,0 +1,20 @@ +# Behavior baseline and migration evidence + +Rust task completion is not Node implementation evidence. These source paths are +the baseline for fixtures and verification; Node tests are listed separately. + +| Contract | Rust source/test evidence | Node evidence | +| --- | --- | --- | +| Runtime override and default/dev profile aliases | `crates/cadder-daemon/src/paths.rs`, `resolve_override_derives_stable_socket_and_runtime_paths`, `runtime_profile_parser_rejects_unknown_values` | `test/paths.test.ts`; v2 isolation is an intentional change | +| Exclusive ownership despite stale/misleading PID metadata | `crates/cadder-daemon/src/runtime_lock.rs`, `raw_lock_rejects_second_owner`, `runtime_lock_recovers_stale_owner_metadata` | `test/runtime-lock.test.ts`, `test/runtime-system.test.ts`; SQLite replaces fs4 | +| Same-owner account can contact elevated endpoint | `crates/cadder-daemon/src/ipc_security.rs`, `IpcSecurityPolicy::evaluate` | Owner ACL plus HMAC replaces native peer inspection; elevated system acceptance pending | +| Config file/env/CLI precedence | `crates/cadder-daemon/src/config.rs`, `crates/cadder-daemon/src/caddy.rs`, `RealCaddyResolver::selected_command` | Pending Node port | +| Managed shim starts missing daemon, never unmanaged fallback | `crates/cadder-shim/src/main.rs`, `run_managed_does_not_delegate_to_real_caddy_when_backend_is_missing`, `open_managed_run_target_starts_missing_daemon_when_fallback_is_skipped` | Pending Node port | +| Registration heartbeat and unregister on shutdown | `crates/cadder-shim/src/main.rs`, `run_managed_registers_heartbeats_and_unregisters_on_shutdown` | Pending Node port | +| Multiple-project composition and last-known-good | `crates/cadder-daemon/src/caddy.rs`, `crates/cadder-daemon/src/state/config_apply.rs` | Serial transaction and ambiguity reconciliation are new acceptance requirements; pending | +| Operator views use real daemon state | `crates/cadder-operator/src/`; current `crates/cadder` TUI contains mocks | Node CLI/TUI must use the shared client service; pending | +| IIS preview, explicit handoff and restore | `crates/cadder-daemon/src/iis.rs` and daemon IIS state modules | Pending Node provider and Sandbox acceptance | + +`test/fixtures/runtime-child.ts` is a real child-process fixture, not a production +daemon. Its status/shutdown handlers exist solely to prove runtime exclusion, +crash recovery and authenticated OS transport before porting product handlers. diff --git a/openspec/changes/reset-cadder-architecture/design.md b/openspec/changes/reset-cadder-architecture/design.md index 0585839..f737fe4 100644 --- a/openspec/changes/reset-cadder-architecture/design.md +++ b/openspec/changes/reset-cadder-architecture/design.md @@ -1,5 +1,10 @@ ## Context +The approved target is now TypeScript on Node, not a Rust workspace. Read +`node-migration.md` for the current implementation decisions. Rust-specific +sections below describe the migration baseline, not the 2.0 target. Existing +behavioral requirements remain unless explicitly changed by the migration. + Cadder coordinates Caddy-backed local development runtimes. The reset defines a target architecture before implementation continues, so each part has one clear reason to exist and one clear owner. diff --git a/openspec/changes/reset-cadder-architecture/node-migration.md b/openspec/changes/reset-cadder-architecture/node-migration.md new file mode 100644 index 0000000..5a68c1e --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/node-migration.md @@ -0,0 +1,71 @@ +# Cadder 2.0 Node migration + +## Preserved contracts + +The daemon owns state, configuration, logs, history and only the Caddy process it +starts. Preserve Caddyfiles, `cadder.toml`, command policy, output modes, project +registration, heartbeat, domain controls, both privilege models, IIS preview and +restore, and explicit autostart. CLI attaches without starting the daemon except +for `daemon start`; managed `caddy run` may start it. CLI and TUI share a client +service. Bare `cadder` opens TUI on a TTY and help otherwise; offline TUI exposes +an explicit start action. There are no mock production statuses. + +## Changed contracts + +- One product with protocol, daemon, Caddy, platform, client, CLI and TUI modules. +- Sources stay `.ts`/`.tsx`, ESM; `tsc --noEmit` checks types. Vitest/V8 covers at + least 85% of own lines including runtime modules. `tsx` is development-only. +- npm is a single `cadder` tarball with three bins, Node 24 LTS >=24.18.0 <25. +- GitHub Releases supplies equal standalone SEA archives: Windows x64, Linux + x64, macOS x64 and arm64. All contain `cadder`, `cadderd`, `caddy`, assets and + checksums. Both channels have the same version and functionality. +- npm uses `tsc` with relative import extension rewriting; SEA uses esbuild and + pinned Node 26. All emission and packaging occurs in system/runner temporary + staging outside checkout. No install-time compilation, binary download, + platform optional packages, or bundled real Caddy. +- Nub manages exact dependency versions and lockfile, including Astro/Starlight + docs. Commander, Zod, smol-toml, Ink/React, @peculiar/x509/Node WebCrypto and + node:sqlite replace custom glue. No tsup. +- Runtime is isolated in `v2`, protocol 3, security policy 2. Unix sockets and + Windows named pipes carry bounded NDJSON. Owner-protected secret material + supports mutual HMAC authentication with fresh challenges and direction-bound + transcripts. Secret bytes never travel over IPC; RPC follows authentication. +- Refuse unsafe permissions/ACLs rather than silently weakening security. + Unix root startup requires an explicit runtime owner and runtime directory. + Same-owner user clients can contact an elevated daemon; other users cannot. +- A separate SQLite database holds `BEGIN EXCLUSIVE` for process lifetime. + Metadata is diagnostic only. Crash recovery starts after acquiring this lock, + never from PID checks or expiry. History uses one dedicated SQLite worker. +- One mutation queue prepares, validates, applies, reconciles and commits config. + Rejection keeps last-known-good; ambiguous outcomes block further mutations + until active Caddy state is reconciled. +- Caddy local plaintext admin is disabled. Loopback mTLS uses an internal CA and + protected client certificate; no system trust-store installation. Project + configuration cannot override admin policy. Shutdown is graceful then forced + against the owned process only. +- IIS mutation uses a short elevated PowerShell helper for a normal daemon; + explicitly elevated daemon mode remains supported. Preserve cancellation. + +## Deferred and excluded + +No mixed Rust/Node IPC, history import, Bun, custom WASM, native runtime addons, +Web UI, Tauri, or new system installers. SEA experimental status concerns its +packaging mechanism, not distribution priority. + +## Verification and transition gates + +Follow migration tasks in order. Runtime/IPC system tests gate Caddy porting. +Cover concurrent starts, crash recovery, authentication failures, replay, fake +endpoints, unsafe ACLs and same-owner elevated access. Later cover multi-project +reload/rollback, heartbeat, Ctrl+C, shutdown, IIS/UAC/autostart in Windows Sandbox, +and identical npm/SEA consumer scenarios. Test SEA without Node/npm on PATH on +each native platform, including TUI, SQLite workers and subprocesses. Packaging +must leave tracked files unchanged and no generated JS in checkout. + +Retain Cargo, Rust code and existing release tooling until functionality and both +distributions pass their gates. Do not delete the pre-existing untracked `npm/`. +Before user transition, restore old IIS handoff, disable old autostart and stop +the Rust daemon. Do not delete old runtime/history; re-register existing projects. +Prepare 2.0.0-rc.1 in both channels, then 2.0.0 after acceptance. Old releases stay +available for rollback. Commit, push, tag and publication require separate +authorization. Checkboxes record progress, not independent implementation proof. diff --git a/openspec/changes/reset-cadder-architecture/proposal.md b/openspec/changes/reset-cadder-architecture/proposal.md index 49e876f..afecaaf 100644 --- a/openspec/changes/reset-cadder-architecture/proposal.md +++ b/openspec/changes/reset-cadder-architecture/proposal.md @@ -1,5 +1,10 @@ ## Why +The approved 2.0 migration replaces the Rust workspace with one TypeScript/Node +product. npm and Node SEA archives on GitHub Releases are equal, required release +channels. The concrete migration contract is in `node-migration.md`; it +supersedes earlier Rust-specific implementation choices in this change. + Cadder needs a smaller and more explicit target architecture before more code is added. The current direction must make ownership obvious: one runtime service owns external state, while compatibility shims and operator clients attach to it diff --git a/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md b/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md index 71495d0..df99b74 100644 --- a/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md +++ b/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md @@ -6,8 +6,8 @@ generated Caddy config, Caddy process lifecycle, and OS integration state. #### Scenario: Applying project definitions - **WHEN** a project definition changes through the shim or operator client -- **THEN** `cadderd` SHALL update Cadder state first -- **AND** `cadderd` SHALL regenerate and apply the effective Caddy config from that state +- **THEN** `cadderd` SHALL serialize preparation, validation, apply and commit +- **AND** `cadderd` SHALL keep last-known-good state after rejection and reconcile ambiguous results before another mutation #### Scenario: External Caddy process exists - **WHEN** an unrelated Caddy process is already running @@ -102,10 +102,15 @@ Production daemon startup SHALL use a lock mechanism with explicit ownership, diagnostics, and stale-lock recovery. #### Scenario: Stale lock after crash -- **WHEN** a daemon lock exists but the recorded owner is no longer alive -- **THEN** `cadderd` SHALL recover or replace the stale lock through a documented process +- **WHEN** a daemon acquires the exclusive SQLite runtime lock and stale metadata remains +- **THEN** `cadderd` SHALL recover metadata and endpoint residue only after acquiring that lock - **AND** it SHALL record the recovery in daemon logs +#### Scenario: Metadata does not grant runtime ownership +- **WHEN** PID or timestamp metadata suggests an expired owner but SQLite remains locked +- **THEN** a competing daemon SHALL refuse startup +- **AND** it SHALL NOT delete an endpoint or override exclusion based on diagnostic metadata + #### Scenario: Lock owner metadata is readable while lock is active - **WHEN** a production runtime owns the daemon lock - **THEN** `cadderd` SHALL publish lock owner metadata outside the locked byte range @@ -115,3 +120,29 @@ diagnostics, and stale-lock recovery. - **WHEN** a lock is owned by an active incompatible runtime - **THEN** `cadderd` SHALL refuse to start a competing production runtime - **AND** it SHALL report the active runtime identity and recovery options + +### Requirement: Protocol 3 uses authenticated owner-local transport +The v2 runtime SHALL use protocol 3 over bounded NDJSON on Unix sockets or Windows +named pipes, with security policy 2. A cryptographic secret SHALL be protected by +owner-only permissions or Windows ACLs. Both peers SHALL authenticate with fresh, +direction-bound HMAC challenges before any RPC. Rust protocol is not supported. + +#### Scenario: Fake endpoint or wrong secret +- **WHEN** a peer cannot prove knowledge of the protected secret +- **THEN** the connection SHALL close before RPC handlers execute +- **AND** neither peer SHALL transmit secret bytes + +#### Scenario: Replay or reflected proof +- **WHEN** an earlier handshake proof or authenticated frame is replayed +- **THEN** authentication or sequence validation SHALL reject it +- **AND** runtime state SHALL remain unchanged + +#### Scenario: Unsafe runtime permissions +- **WHEN** existing runtime permissions, ownership or ACLs allow another account +- **THEN** startup and client contact SHALL fail closed +- **AND** startup SHALL NOT repair or weaken the existing security boundary silently + +#### Scenario: Root starts a user-owned runtime +- **WHEN** root starts the daemon on Unix +- **THEN** startup SHALL require an explicit runtime directory and owner UID +- **AND** protected files and the socket SHALL remain accessible to that owner without elevating the client diff --git a/openspec/changes/reset-cadder-architecture/specs/runtime-topology/spec.md b/openspec/changes/reset-cadder-architecture/specs/runtime-topology/spec.md index f767577..f55a2a9 100644 --- a/openspec/changes/reset-cadder-architecture/specs/runtime-topology/spec.md +++ b/openspec/changes/reset-cadder-architecture/specs/runtime-topology/spec.md @@ -24,19 +24,33 @@ reusable view-model contracts used by the initial operator clients. - **AND** it SHALL NOT create an independent runtime state model or Caddy control plane ### Requirement: Workspace boundaries are minimal and explainable -The Rust workspace SHALL keep product crates and library crates aligned with the -target topology and SHALL require each crate to have a documented, testable -responsibility. +The TypeScript product SHALL keep protocol, daemon, Caddy adapter, platform +adapters, client service, CLI and TUI modules aligned with the target topology. +Rust crates remain only as a migration baseline until the release gates pass. -#### Scenario: Contributor evaluates a crate -- **WHEN** a crate is reviewed during the reset +#### Scenario: Contributor evaluates a module +- **WHEN** a module is reviewed during the reset - **THEN** it SHALL be classified as daemon, shim, operator client, shared protocol/API, test support, documentation/tooling, or obsolete -- **AND** crates without a current responsibility SHALL be removed, merged, or explicitly justified +- **AND** modules without a current responsibility SHALL be removed, merged, or explicitly justified -#### Scenario: New crate proposed -- **WHEN** a new crate is proposed -- **THEN** the proposal SHALL explain why a module inside an existing crate is insufficient -- **AND** the crate SHALL have independently testable responsibilities +#### Scenario: New module proposed +- **WHEN** a new module is proposed +- **THEN** the proposal SHALL explain its independent responsibility +- **AND** it SHALL NOT introduce a new public product or native runtime addon + +### Requirement: Distribution channels are equal +Cadder SHALL ship one npm package on Node 24 LTS >=24.18.0 and standalone Node SEA +archives through GitHub Releases. Both SHALL provide the same three entrypoints +and CLI/TUI functionality. Both channels SHALL pass before migration completion. + +#### Scenario: Packaging release sources +- **WHEN** npm or SEA artifacts are prepared +- **THEN** TypeScript sources SHALL compile only into staging outside checkout +- **AND** generated JavaScript and binaries SHALL NOT be committed + +#### Scenario: Standalone consumer has no Node +- **WHEN** a supported standalone archive is used without Node/npm on PATH +- **THEN** all entrypoints, TUI, SQLite and owned subprocess flows SHALL work ### Requirement: Production runtime identity is singular Production Cadder SHALL run one stable runtime identity per user or per explicit diff --git a/openspec/changes/reset-cadder-architecture/tasks.md b/openspec/changes/reset-cadder-architecture/tasks.md index 4c009bf..ee353ce 100644 --- a/openspec/changes/reset-cadder-architecture/tasks.md +++ b/openspec/changes/reset-cadder-architecture/tasks.md @@ -1,4 +1,23 @@ -## 1. Planning Migration +## Node migration implementation + +Earlier completed tasks below refer to the Rust baseline. They do not prove the +Node migration. The approved target and gates are in `node-migration.md`. + +- [x] N1. Update OpenSpec target, behavior evidence and TS/Nub tooling with exact dependencies. +- [ ] N2. Implement v2 owner-protected runtime, SQLite exclusion, mutual HMAC IPC and system gate tests. + - [x] N2.1 Implement runtime/IPC foundation and run Windows unit, regression and native child-process tests. + - [x] N2.2 Run the compiled-JS native runtime gate on Linux x64 with real Unix sockets and permissions. + - [ ] N2.3 Pass the native Windows/Linux/macOS CI matrix on the committed code. + - [ ] N2.4 Verify same-owner non-elevated contact with elevated daemon and different-account denial in system tests, including Windows Sandbox. +- [ ] N3. Port Caddy composition, transaction queue, loopback mTLS and owned-process lifecycle. +- [ ] N4. Port shim policy, safe resolution, heartbeat, autostart and scoped IIS helpers. +- [ ] N5. Implement history worker, bounded/redacted logs, shared client service and real CLI/TUI. +- [ ] N6. Verify npm staging and all four SEA releases, native consumer smoke and documentation. +- [ ] N7. Pass >=85% coverage, three-platform integration and Windows Sandbox acceptance. +- [ ] N8. Remove Rust/Cargo and legacy packaging only after N1-N7 gates pass. +- [ ] N9. Prepare matching rc.1 artifacts without publishing; await separate release authorization. + +## 1. Planning Migration (Rust baseline) - [x] 1.1 Keep `openspec/` initialized and document it as the repository planning source of truth. - [x] 1.2 Preserve relevant architecture rationale in OpenSpec proposal, design, and specs. diff --git a/openspec/changes/reset-cadder-architecture/verification.md b/openspec/changes/reset-cadder-architecture/verification.md new file mode 100644 index 0000000..0ae9929 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/verification.md @@ -0,0 +1,54 @@ +# Node migration verification + +## Scope implemented + +The Node implementation currently covers the runtime/IPC foundation, not a +shippable 2.0 application. Product handlers, Caddy, shim, history, CLI/TUI, IIS, +autostart and npm/SEA distributions are still pending. Rust remains intact. + +Implemented: isolated v2 paths and profiles, owner-only Unix modes and Windows +ACLs, SQLite `BEGIN EXCLUSIVE` lifetime lock, crash residue recovery only after +lock acquisition, protected persistent secret, protocol 3/security policy 2 +mutual HMAC, fresh directional challenges and sequenced authenticated RPC. +Framing, connection count and timeouts are bounded. A local runtime coordinator +owns startup, listener cleanup and lock release. Diagnostic metadata is not lock +authority and cannot authorize another account. + +## Current local evidence + +- Node 24.18.0 on Windows x64: typecheck, ESLint, formatting and strict OpenSpec + validation pass. +- Vitest: 29 tests pass, V8 own-code line coverage 90.30%. Runtime modules are + included; subprocess coverage is not used to inflate the percentage. +- Packed ESM fixtures run on plain Node, with `NODE_OPTIONS` removed and without + a TS loader. Windows x64 native gate passes. +- Linux x64 native gate passes in a local Linux environment: real Unix socket, + real file permissions, child-process exclusion, forced crash and recovery, + authenticated contact and orderly shutdown. +- Authentication tests cover fake server proof, wrong/reflected client proof, + proof replay, RPC replay/tampering and RPC before authentication. +- Unsafe ACLs are rejected instead of repaired. A failed discovery publication + leaves the pre-existing unsafe file unchanged and releases the listener/lock. + +## Required remaining runtime gate + +The committed CI matrix must pass on Windows, Linux and macOS. Real same-owner +user-to-elevated contact and different-user rejection remain system acceptance +items, including Windows Sandbox. Fake Unix policy tests do not prove these +privilege transitions. Do not mark N2 complete or port Caddy before these gates. + +## Commands + +```sh +nub ci +nub run typecheck +nub run lint +nub run format:check +nub run coverage +node scripts/prepare-runtime-gate.ts +``` + +The last command prints an outside-checkout staging directory containing test +fixtures. Run `node /runtime-smoke.mjs /runtime-child.mjs` with the +native platform Node 24.18.0. These fixtures are not npm or SEA release artifacts. +Coverage output also stays in system/runner temp. No publication is performed. diff --git a/openspec/config.yaml b/openspec/config.yaml index 0a8f2c6..787fa5f 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -1,13 +1,15 @@ schema: spec-driven context: | - Cadder is a Rust workspace for coordinating Caddy-backed local development - runtimes. OpenSpec is the canonical source for product requirements, design + Cadder 2.0 is a TypeScript/Node product for coordinating Caddy-backed local + development runtimes, with equal npm and Node SEA release channels. Rust is + retained as a migration baseline until both distribution gates pass. + OpenSpec is the canonical source for product requirements, design decisions, and implementation plans. The codebase should favor small modules, explicit ownership, independently testable boundaries, and clear contracts between runtime, integration, and - presentation layers. Prefer proven Rust crates and established tooling over + presentation layers. Prefer proven libraries and established tooling over custom infrastructure when they fit the job. Cadder is cross-platform by default. Platform-specific behavior must live diff --git a/package.json b/package.json new file mode 100644 index 0000000..187970a --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "cadder", + "version": "2.0.0-rc.1", + "description": "Daemon-backed Caddy coordination with CLI and TUI clients", + "type": "module", + "private": true, + "packageManager": "nub@0.7.5", + "engines": { + "node": ">=24.18.0 <25" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "coverage": "vitest run --coverage", + "lint": "eslint --flag unstable_native_nodejs_ts_config src test scripts", + "format:check": "prettier --check src test scripts package.json tsconfig.json vitest.config.ts eslint.config.ts", + "format": "prettier --write src test scripts package.json tsconfig.json vitest.config.ts eslint.config.ts", + "check": "nub run typecheck && nub run lint && nub run format:check && nub run coverage" + }, + "dependencies": { + "@peculiar/x509": "2.1.0", + "commander": "15.0.0", + "ink": "8.0.0", + "react": "19.3.0", + "reflect-metadata": "0.2.2", + "smol-toml": "1.9.0", + "zod": "4.6.5" + }, + "allowBuilds": { + "esbuild": true + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@types/node": "24.19.1", + "@types/react": "19.3.0", + "@vitest/coverage-v8": "5.0.3", + "esbuild": "0.28.2", + "eslint": "10.0.3", + "ink-testing-library": "4.0.0", + "prettier": "3.8.3", + "tsx": "4.23.0", + "typescript": "6.0.3", + "typescript-eslint": "8.57.0", + "vitest": "5.0.3" + } +} diff --git a/scripts/prepare-runtime-gate.ts b/scripts/prepare-runtime-gate.ts new file mode 100644 index 0000000..92a4e7b --- /dev/null +++ b/scripts/prepare-runtime-gate.ts @@ -0,0 +1,20 @@ +import { build } from 'esbuild'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const stage = await mkdtemp(join(tmpdir(), 'cadder-runtime-gate-')); +await build({ + entryPoints: { + 'runtime-child': fileURLToPath(new URL('../test/fixtures/runtime-child.ts', import.meta.url)), + 'runtime-smoke': fileURLToPath(new URL('../test/fixtures/runtime-smoke.ts', import.meta.url)), + }, + outdir: stage, + outExtension: { '.js': '.mjs' }, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node24.18', +}); +process.stdout.write(`${stage}\n`); diff --git a/src/client/connection.ts b/src/client/connection.ts new file mode 100644 index 0000000..ce59d19 --- /dev/null +++ b/src/client/connection.ts @@ -0,0 +1,54 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { createConnection } from 'node:net'; +import type { RuntimePaths } from '../daemon/paths.ts'; +import { assertProtected, runtimeOwner } from '../platform/runtime-security.ts'; +import { JsonChannel } from '../protocol/channel.ts'; +import { authenticateClient } from '../protocol/authentication.ts'; +import { responseSchema } from '../protocol/rpc.ts'; +import { PROTOCOL_VERSION } from '../protocol/version.ts'; +import { CadderError, errorCode } from '../protocol/errors.ts'; + +export async function rpc(paths: RuntimePaths, method: string, params?: unknown): Promise { + const owner = await runtimeOwner(process.getuid?.()); + let secret: Buffer; + try { + await assertProtected(paths.directory, owner, true); + await assertProtected(paths.secret, owner); + secret = await readFile(paths.secret); + } catch (error) { + if (errorCode(error) === 'ENOENT') throw unavailable(); + throw error; + } + if (secret.length !== 32) + throw new CadderError('invalid-runtime-secret', 'Invalid runtime secret.'); + const socket = createConnection(paths.endpoint); + const channel = new JsonChannel(socket); + try { + const session = await authenticateClient(channel, secret, paths.instance); + try { + const requestId = randomUUID(); + session.send({ protocolVersion: PROTOCOL_VERSION, requestId, method, params }); + const response = responseSchema.parse(await session.receive()); + if (response.requestId !== requestId) + throw new CadderError('invalid-response', 'Daemon response does not match this request.'); + if (response.error) throw new CadderError(response.error.code, response.error.message); + return response.result; + } finally { + session.close(); + } + } catch (error) { + if (['ENOENT', 'ECONNREFUSED'].includes(errorCode(error) ?? '')) throw unavailable(); + throw error; + } finally { + secret.fill(0); + channel.close(); + } +} + +function unavailable(): CadderError { + return new CadderError( + 'daemon-unavailable', + 'cadderd is not running. Start it with cadder daemon start.', + ); +} diff --git a/src/daemon/ipc-server.ts b/src/daemon/ipc-server.ts new file mode 100644 index 0000000..5d6cc28 --- /dev/null +++ b/src/daemon/ipc-server.ts @@ -0,0 +1,121 @@ +import { createServer, type Socket } from 'node:net'; +import { chmod, lstat, unlink, writeFile } from 'node:fs/promises'; +import { once } from 'node:events'; +import { JsonChannel } from '../protocol/channel.ts'; +import { authenticateServer } from '../protocol/authentication.ts'; +import { requestSchema, type RpcRequest } from '../protocol/rpc.ts'; +import { CadderError, errorCode } from '../protocol/errors.ts'; +import { PROTOCOL_VERSION, SECURITY_POLICY_VERSION, VERSION } from '../protocol/version.ts'; +import { + createProtectedFile, + protectCreated, + type RuntimeOwner, +} from '../platform/runtime-security.ts'; +import type { RuntimePaths } from './paths.ts'; + +export async function serveIpc( + paths: RuntimePaths, + owner: RuntimeOwner, + secret: Buffer, + handler: (request: RpcRequest) => Promise, + onDenied: () => void = () => {}, +): Promise<{ close(): Promise }> { + // Caller must hold the SQLite runtime lock before removing crash residue. + if (process.platform !== 'win32') { + try { + const info = await lstat(paths.endpoint); + if (!info.isSocket() || info.uid !== owner.uid) + throw new CadderError('unsafe-endpoint', 'Refusing to replace a non-owned socket path.'); + await unlink(paths.endpoint); + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error; + } + } + const sockets = new Set(); + let closing: Promise | undefined; + let published = false; + const server = createServer((socket) => { + if (sockets.size >= 64) { + socket.destroy(); + return; + } + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + const channel = new JsonChannel(socket); + void (async () => { + const session = await authenticateServer(channel, secret, paths.instance); + try { + const request = requestSchema.parse(await session.receive()); + let response; + try { + response = { result: await handler(request) }; + } catch (error) { + response = { + error: { + code: errorCode(error) ?? 'request-failed', + message: + error instanceof CadderError + ? error.message + : 'Daemon request failed. See diagnostics.', + }, + }; + } + session.send({ + protocolVersion: PROTOCOL_VERSION, + requestId: request.requestId, + ...response, + }); + await new Promise((resolve) => socket.end(resolve)); + } finally { + session.close(); + } + })().catch(() => { + onDenied(); + channel.close(); + }); + }); + server.listen(paths.endpoint); + await once(server, 'listening'); + try { + if (process.platform !== 'win32') { + await chmod(paths.endpoint, 0o600); + await protectCreated(paths.endpoint, owner); + } + await createProtectedFile(paths.discovery, owner); + await writeFile( + paths.discovery, + JSON.stringify({ + version: VERSION, + protocolVersion: PROTOCOL_VERSION, + securityPolicyVersion: SECURITY_POLICY_VERSION, + runtimeDir: paths.directory, + profile: paths.profile, + instance: paths.instance, + endpoint: paths.endpoint, + processId: process.pid, + owner: owner.id, + elevated: owner.elevated, + }), + ); + published = true; + } catch (error) { + await close(); + throw error; + } + function close(): Promise { + closing ??= closeListener(); + return closing; + } + async function closeListener(): Promise { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + if (published) { + await unlink(paths.discovery).catch((error: unknown) => { + if (errorCode(error) !== 'ENOENT') throw error; + }); + } + } + return { close }; +} diff --git a/src/daemon/local-runtime.ts b/src/daemon/local-runtime.ts new file mode 100644 index 0000000..284613b --- /dev/null +++ b/src/daemon/local-runtime.ts @@ -0,0 +1,50 @@ +import { resolvePaths, type RuntimePaths } from './paths.ts'; +import { acquireRuntimeLock } from './runtime-lock.ts'; +import { serveIpc } from './ipc-server.ts'; +import { prepareRuntime, runtimeOwner } from '../platform/runtime-security.ts'; +import type { RpcRequest } from '../protocol/rpc.ts'; +import { CadderError } from '../protocol/errors.ts'; + +export interface LocalRuntime { + paths: RuntimePaths; + recovered: boolean; + stop(): Promise; +} + +export async function startLocalRuntime( + options: { runtimeDir?: string; profile?: string; runtimeOwner?: number }, + handler: (request: RpcRequest) => Promise, + onDenied?: () => void, +): Promise { + if (process.getuid?.() === 0 && !options.runtimeDir) { + throw new CadderError( + 'runtime-owner-required', + 'Root must specify --runtime-owner and --runtime-dir.', + ); + } + const owner = await runtimeOwner(options.runtimeOwner); + const paths = resolvePaths(options); + await prepareRuntime(paths.directory, owner); + const lock = await acquireRuntimeLock(paths, owner); + try { + const listener = await serveIpc(paths, owner, lock.secret, handler, onDenied); + let stopping: Promise | undefined; + return { + paths, + recovered: lock.recovered, + stop() { + stopping ??= (async () => { + try { + await listener.close(); + } finally { + await lock.release(); + } + })(); + return stopping; + }, + }; + } catch (error) { + await lock.release(); + throw error; + } +} diff --git a/src/daemon/paths.ts b/src/daemon/paths.ts new file mode 100644 index 0000000..8616e86 --- /dev/null +++ b/src/daemon/paths.ts @@ -0,0 +1,75 @@ +import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { CadderError } from '../protocol/errors.ts'; + +export interface RuntimePaths { + directory: string; + profile: 'default' | 'dev'; + instance: string; + endpoint: string; + secret: string; + lock: string; + metadata: string; + discovery: string; + history: string; +} + +export function resolvePaths( + options: { runtimeDir?: string; profile?: string } = {}, +): RuntimePaths { + const override = options.runtimeDir ?? process.env.CADDER_RUNTIME_DIR; + const value = ( + options.profile ?? + (override ? 'default' : process.env.CADDER_RUNTIME_PROFILE) ?? + 'default' + ) + .trim() + .toLowerCase(); + let profile: 'default' | 'dev'; + if (['', 'default', 'prod', 'production'].includes(value)) profile = 'default'; + else if (['dev', 'development'].includes(value)) profile = 'dev'; + else throw new CadderError('invalid-profile', 'Expected runtime profile default or dev.'); + let base: string; + if (process.platform === 'win32') + base = join( + process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), + 'Cadder', + 'Cadder', + 'run', + ); + else if (process.platform === 'darwin') + base = join(homedir(), 'Library', 'Application Support', 'dev.Cadder.Cadder', 'run'); + else if (process.env.XDG_RUNTIME_DIR) base = join(process.env.XDG_RUNTIME_DIR, 'cadder'); + else + base = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'cadder', 'run'); + let directory = join(resolve(override ?? base), 'v2'); + if (!override && profile === 'dev') { + const id = + process.env.CADDER_DEV_ID ?? + `workspace-${digest(resolve(process.env.CADDER_DEV_WORKSPACE ?? process.cwd()))}`; + if (!/^(?!\.{1,2}$)[a-zA-Z0-9._-]+$/.test(id)) + throw new CadderError('invalid-profile', 'Invalid CADDER_DEV_ID.'); + directory = join(directory, 'profiles', 'dev', id); + } + const instance = digest(directory); + const endpoint = + process.platform === 'win32' + ? `\\\\.\\pipe\\cadder-v2-${instance}` + : join(directory, 'cadder.sock'); + return { + directory, + profile, + instance, + endpoint, + secret: join(directory, 'ipc-secret'), + lock: join(directory, 'lock.sqlite3'), + metadata: join(directory, 'cadder.lock.json'), + discovery: join(directory, 'cadder-ipc.json'), + history: join(directory, 'runtime.sqlite3'), + }; +} + +function digest(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 16); +} diff --git a/src/daemon/runtime-lock.ts b/src/daemon/runtime-lock.ts new file mode 100644 index 0000000..56a9c6e --- /dev/null +++ b/src/daemon/runtime-lock.ts @@ -0,0 +1,83 @@ +import { randomBytes } from 'node:crypto'; +import { readFile, unlink, writeFile } from 'node:fs/promises'; +import { DatabaseSync } from 'node:sqlite'; +import type { RuntimePaths } from './paths.ts'; +import type { RuntimeOwner } from '../platform/runtime-security.ts'; +import { assertProtected, createProtectedFile } from '../platform/runtime-security.ts'; +import { CadderError, errorCode } from '../protocol/errors.ts'; +import { PROTOCOL_VERSION, SECURITY_POLICY_VERSION, VERSION } from '../protocol/version.ts'; + +export interface RuntimeLock { + recovered: boolean; + secret: Buffer; + release(): Promise; +} + +export async function acquireRuntimeLock( + paths: RuntimePaths, + owner: RuntimeOwner, +): Promise { + await assertProtected(paths.directory, owner, true); + await createProtectedFile(paths.lock, owner); + const database = new DatabaseSync(paths.lock, { timeout: 0 }); + try { + database.exec('PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;'); + } catch { + database.close(); + throw new CadderError( + 'runtime-already-running', + 'Runtime is already locked. Use cadder daemon status or cadder daemon shutdown.', + ); + } + try { + let recovered = false; + try { + await readFile(paths.metadata); + recovered = true; + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error; + } + await createProtectedFile(paths.secret, owner, randomBytes(32)); + const secret = await readFile(paths.secret); + if (secret.length !== 32) + throw new CadderError('invalid-runtime-secret', 'Runtime secret must contain 32 bytes.'); + await createProtectedFile(paths.metadata, owner); + await writeFile( + paths.metadata, + JSON.stringify({ + version: VERSION, + protocolVersion: PROTOCOL_VERSION, + securityPolicyVersion: SECURITY_POLICY_VERSION, + processId: process.pid, + acquiredAt: new Date().toISOString(), + owner: owner.id, + elevated: owner.elevated, + runtimeDir: paths.directory, + profile: paths.profile, + endpoint: paths.endpoint, + executable: process.execPath, + }), + ); + let released = false; + return { + recovered, + secret, + async release() { + if (released) return; + released = true; + try { + await unlink(paths.metadata).catch((error: unknown) => { + if (errorCode(error) !== 'ENOENT') throw error; + }); + } finally { + database.exec('ROLLBACK;'); + database.close(); + secret.fill(0); + } + }, + }; + } catch (error) { + database.close(); + throw error; + } +} diff --git a/src/platform/powershell.ts b/src/platform/powershell.ts new file mode 100644 index 0000000..cdda8d7 --- /dev/null +++ b/src/platform/powershell.ts @@ -0,0 +1,38 @@ +import { execFile } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; + +const execute = promisify(execFile); + +export function psLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +export async function powershell(script: string): Promise { + const encoded = Buffer.from(`$ErrorActionPreference = 'Stop'; ${script}`, 'utf16le').toString( + 'base64', + ); + const executable = join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); + // Windows worker environments can contain differently cased duplicate keys. + const environment = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'psmodulepath'), + ); + environment.PSModulePath = join(dirname(executable), 'Modules'); + const result = await execute( + executable, + ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], + { + windowsHide: true, + env: environment, + timeout: 20000, + maxBuffer: 1024 * 1024, + }, + ); + return result.stdout.trim(); +} diff --git a/src/platform/runtime-security.ts b/src/platform/runtime-security.ts new file mode 100644 index 0000000..637d30f --- /dev/null +++ b/src/platform/runtime-security.ts @@ -0,0 +1,134 @@ +import { constants } from 'node:fs'; +import { chown, lstat, mkdir, open } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { CadderError, errorCode } from '../protocol/errors.ts'; +import { powershell, psLiteral } from './powershell.ts'; + +export interface RuntimeOwner { + id: string; + uid?: number; + elevated: boolean; +} + +export async function runtimeOwner(explicitUid?: number): Promise { + if (process.platform === 'win32') { + return JSON.parse( + await powershell(` + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent(); + $principal = New-Object System.Security.Principal.WindowsPrincipal($identity); + @{ id = $identity.User.Value; elevated = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) } | ConvertTo-Json -Compress + `), + ) as RuntimeOwner; + } + const uid = process.getuid!(); + if (uid === 0 && explicitUid === undefined) + throw new CadderError( + 'runtime-owner-required', + 'Root must specify --runtime-owner and --runtime-dir.', + ); + if ( + explicitUid !== undefined && + (!Number.isSafeInteger(explicitUid) || explicitUid < 0 || (uid !== 0 && explicitUid !== uid)) + ) { + throw new CadderError( + 'invalid-runtime-owner', + 'Runtime owner must be the current user unless the daemon runs as root.', + ); + } + const ownerUid = explicitUid ?? uid; + return { id: `uid:${ownerUid}`, uid: ownerUid, elevated: uid === 0 }; +} + +export async function prepareRuntime(directory: string, owner: RuntimeOwner): Promise { + try { + await lstat(directory); + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error; + try { + await mkdir(dirname(directory), { mode: 0o700, recursive: true }); + await mkdir(directory, { mode: 0o700 }); + await protectCreated(directory, owner, true); + } catch (error) { + if (errorCode(error) !== 'EEXIST') throw error; + } + } + await assertProtected(directory, owner, true); +} + +export async function protectCreated( + path: string, + owner: RuntimeOwner, + directory = false, +): Promise { + if (process.platform === 'win32') { + const type = directory ? 'DirectorySecurity' : 'FileSecurity'; + const inheritance = directory ? "'ContainerInherit,ObjectInherit'" : "'None'"; + await powershell(` + $sid = New-Object System.Security.Principal.SecurityIdentifier(${psLiteral(owner.id)}); + $acl = New-Object System.Security.AccessControl.${type}; + $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true, $false); + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', ${inheritance}, 'None', 'Allow'); + $acl.AddAccessRule($rule); + [System.IO.${directory ? 'Directory' : 'File'}]::SetAccessControl(${psLiteral(path)}, $acl); + `); + } else if (process.getuid!() === 0 && owner.uid !== undefined) { + await chown(path, owner.uid, owner.uid); + } +} + +export async function assertProtected( + path: string, + owner: RuntimeOwner, + directory = false, +): Promise { + const info = await lstat(path); + if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) { + throw new CadderError( + 'unsafe-runtime-permissions', + 'Runtime paths must be real directories or regular files, not links.', + ); + } + if (process.platform === 'win32') { + await powershell(` + $acl = Get-Acl -LiteralPath ${psLiteral(path)}; + if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne ${psLiteral(owner.id)} -or !$acl.AreAccessRulesProtected) { throw 'Unsafe runtime owner or ACL inheritance' }; + $ownerAllowed = $false; + foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { + if ($rule.IdentityReference.Value -ne ${psLiteral(owner.id)} -or $rule.AccessControlType -ne 'Allow') { throw 'Runtime ACL permits a different account' }; + if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl) { $ownerAllowed = $true }; + }; + if (!$ownerAllowed) { throw 'Runtime owner lacks full access' }; + `).catch(() => { + throw new CadderError('unsafe-runtime-permissions', 'Unsafe runtime ACL; refusing to start.'); + }); + } else if (info.uid !== owner.uid || (info.mode & 0o777) !== (directory ? 0o700 : 0o600)) { + throw new CadderError( + 'unsafe-runtime-permissions', + 'Runtime owner and permissions must be 0700 for directories and 0600 for files.', + ); + } +} + +export async function createProtectedFile( + path: string, + owner: RuntimeOwner, + data: string | Uint8Array = '', +): Promise { + let handle; + try { + handle = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); + } catch (error) { + if (errorCode(error) !== 'EEXIST') throw error; + await assertProtected(path, owner); + return false; + } + try { + await protectCreated(path, owner); + await handle.writeFile(data); + await handle.sync(); + } finally { + await handle.close(); + } + await assertProtected(path, owner); + return true; +} diff --git a/src/protocol/authentication.ts b/src/protocol/authentication.ts new file mode 100644 index 0000000..46b3156 --- /dev/null +++ b/src/protocol/authentication.ts @@ -0,0 +1,121 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { z } from 'zod'; +import { CadderError } from './errors.ts'; +import { JsonChannel } from './channel.ts'; +import { PROTOCOL_VERSION, SECURITY_POLICY_VERSION } from './version.ts'; + +const nonce = z.string().regex(/^[a-f0-9]{64}$/); +const hello = z.object({ + type: z.literal('hello'), + nonce, + protocolVersion: z.literal(PROTOCOL_VERSION), + policyVersion: z.literal(SECURITY_POLICY_VERSION), +}); +const challenge = z.object({ type: z.literal('challenge'), nonce, proof: nonce }); +const proof = z.object({ type: z.literal('proof'), proof: nonce }); +const ready = z.object({ type: z.literal('ready'), proof: nonce }); +const message = z.object({ + sequence: z.number().int().nonnegative(), + payload: z.string(), + mac: nonce, +}); + +function mac(secret: Buffer, value: string): string { + return createHmac('sha256', secret).update(value).digest('hex'); +} + +function verify(secret: Buffer, value: string, received: string): void { + if (!timingSafeEqual(Buffer.from(mac(secret, value), 'hex'), Buffer.from(received, 'hex'))) { + throw new CadderError('authentication-failed', 'IPC authentication failed.'); + } +} + +function transcript(instance: string, clientNonce: string, serverNonce: string): string { + return JSON.stringify([ + 'cadder-ipc', + PROTOCOL_VERSION, + SECURITY_POLICY_VERSION, + instance, + clientNonce, + serverNonce, + ]); +} + +export async function authenticateServer( + channel: JsonChannel, + secret: Buffer, + instance: string, +): Promise { + const client = hello.parse(await channel.next()); + const serverNonce = randomBytes(32).toString('hex'); + const context = transcript(instance, client.nonce, serverNonce); + channel.send({ type: 'challenge', nonce: serverNonce, proof: mac(secret, `server:${context}`) }); + const response = proof.parse(await channel.next()); + verify(secret, `client:${context}`, response.proof); + channel.send({ type: 'ready', proof: mac(secret, `ready:${context}`) }); + return new AuthenticatedSession( + channel, + Buffer.from(mac(secret, `session:${context}`), 'hex'), + 'server', + ); +} + +export async function authenticateClient( + channel: JsonChannel, + secret: Buffer, + instance: string, +): Promise { + const clientNonce = randomBytes(32).toString('hex'); + channel.send({ + type: 'hello', + nonce: clientNonce, + protocolVersion: PROTOCOL_VERSION, + policyVersion: SECURITY_POLICY_VERSION, + }); + const server = challenge.parse(await channel.next()); + const context = transcript(instance, clientNonce, server.nonce); + verify(secret, `server:${context}`, server.proof); + channel.send({ type: 'proof', proof: mac(secret, `client:${context}`) }); + const response = ready.parse(await channel.next()); + verify(secret, `ready:${context}`, response.proof); + return new AuthenticatedSession( + channel, + Buffer.from(mac(secret, `session:${context}`), 'hex'), + 'client', + ); +} + +export class AuthenticatedSession { + private sent = 0; + private received = 0; + constructor( + private channel: JsonChannel, + private key: Buffer, + private role: 'client' | 'server', + ) {} + + send(value: unknown): void { + const payload = JSON.stringify(value); + const sequence = this.sent++; + this.channel.send({ + sequence, + payload, + mac: mac(this.key, JSON.stringify([this.role, sequence, payload])), + }); + } + + async receive(timeout = 15000): Promise { + const frame = message.parse(await this.channel.next(timeout)); + if (frame.sequence !== this.received) + throw new CadderError('replayed-frame', 'Out-of-order or replayed IPC frame.'); + const peer = this.role === 'client' ? 'server' : 'client'; + verify(this.key, JSON.stringify([peer, frame.sequence, frame.payload]), frame.mac); + this.received++; + return JSON.parse(frame.payload); + } + + close(): void { + this.key.fill(0); + this.channel.close(); + } +} diff --git a/src/protocol/channel.ts b/src/protocol/channel.ts new file mode 100644 index 0000000..796dbaa --- /dev/null +++ b/src/protocol/channel.ts @@ -0,0 +1,88 @@ +import type { Socket } from 'node:net'; +import { CadderError } from './errors.ts'; +import { AUTH_TIMEOUT_MS, MAX_FRAME_BYTES } from './version.ts'; + +export class JsonChannel { + private buffer: Buffer = Buffer.alloc(0); + private queue: unknown[] = []; + private failure: Error | undefined; + private pending: { resolve(value: unknown): void; reject(error: Error): void } | undefined; + + constructor(public readonly socket: Socket) { + socket.on('data', (data: Buffer) => this.consume(data)); + socket.on('error', (error) => this.fail(error)); + socket.on('close', () => + this.fail(new CadderError('connection-closed', 'Daemon connection closed.')), + ); + } + + send(value: unknown): void { + const frame = Buffer.from(`${JSON.stringify(value)}\n`); + if (frame.length > MAX_FRAME_BYTES) + throw new CadderError('frame-too-large', 'IPC frame exceeds the size limit.'); + if (this.failure) throw this.failure; + this.socket.write(frame); + } + + async next(timeout = AUTH_TIMEOUT_MS): Promise { + if (this.queue.length) return this.queue.shift(); + if (this.failure) throw this.failure; + if (this.pending) throw new CadderError('concurrent-read', 'Only one IPC read can be pending.'); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const error = new CadderError('ipc-timeout', 'Daemon did not respond before the deadline.'); + this.fail(error); + this.socket.destroy(); + }, timeout); + this.pending = { + resolve(value) { + clearTimeout(timer); + resolve(value); + }, + reject(error) { + clearTimeout(timer); + reject(error); + }, + }; + }); + } + + close(): void { + this.socket.destroy(); + } + + private consume(data: Buffer): void { + this.buffer = Buffer.concat([this.buffer, data]); + let newline: number; + while ((newline = this.buffer.indexOf(10)) !== -1) { + if (newline > MAX_FRAME_BYTES || this.queue.length >= 8) return this.rejectFrame(); + let frame: unknown; + try { + frame = JSON.parse(this.buffer.subarray(0, newline).toString('utf8')); + } catch { + return this.rejectFrame(); + } + this.buffer = this.buffer.subarray(newline + 1); + if (this.pending) { + const pending = this.pending; + this.pending = undefined; + pending.resolve(frame); + } else this.queue.push(frame); + } + if (this.buffer.length > MAX_FRAME_BYTES) this.rejectFrame(); + } + + private rejectFrame(): void { + this.fail(new CadderError('invalid-frame', 'Invalid or oversized IPC frame.')); + this.socket.destroy(); + } + + private fail(error: Error): void { + this.failure ??= error; + if (this.pending) { + const pending = this.pending; + this.pending = undefined; + pending.reject(error); + } + } +} diff --git a/src/protocol/errors.ts b/src/protocol/errors.ts new file mode 100644 index 0000000..5afc60e --- /dev/null +++ b/src/protocol/errors.ts @@ -0,0 +1,16 @@ +export class CadderError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'CadderError'; + } +} + +export function errorCode(error: unknown): string | undefined { + if (error && typeof error === 'object' && 'code' in error) { + return String(error.code); + } + return undefined; +} diff --git a/src/protocol/rpc.ts b/src/protocol/rpc.ts new file mode 100644 index 0000000..f7bb3af --- /dev/null +++ b/src/protocol/rpc.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { PROTOCOL_VERSION } from './version.ts'; + +export const requestSchema = z.object({ + protocolVersion: z.literal(PROTOCOL_VERSION), + requestId: z.string().uuid(), + method: z.string().min(1).max(100), + params: z.unknown().optional(), +}); +export type RpcRequest = z.infer; +export const responseSchema = z.object({ + protocolVersion: z.literal(PROTOCOL_VERSION), + requestId: z.string().uuid(), + result: z.unknown().optional(), + error: z.object({ code: z.string(), message: z.string() }).optional(), +}); diff --git a/src/protocol/version.ts b/src/protocol/version.ts new file mode 100644 index 0000000..c775499 --- /dev/null +++ b/src/protocol/version.ts @@ -0,0 +1,5 @@ +export const VERSION = '2.0.0-rc.1'; +export const PROTOCOL_VERSION = 3; +export const SECURITY_POLICY_VERSION = 2; +export const MAX_FRAME_BYTES = 1024 * 1024; +export const AUTH_TIMEOUT_MS = 5000; diff --git a/test/authentication.test.ts b/test/authentication.test.ts new file mode 100644 index 0000000..4edd555 --- /dev/null +++ b/test/authentication.test.ts @@ -0,0 +1,158 @@ +import { createServer, createConnection } from 'node:net'; +import { createHmac, randomBytes } from 'node:crypto'; +import { once } from 'node:events'; +import { expect, it } from 'vitest'; +import { JsonChannel } from '../src/protocol/channel.ts'; +import { authenticateClient, authenticateServer } from '../src/protocol/authentication.ts'; + +async function channels() { + const server = createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing fixture port'); + const connected = once(server, 'connection'); + const socket = createConnection(address.port, '127.0.0.1'); + const [accepted] = await connected; + const client = new JsonChannel(socket); + const daemon = new JsonChannel(accepted); + return { + client, + daemon, + close() { + client.close(); + daemon.close(); + server.close(); + }, + }; +} + +it('authenticates both directions without sending the secret, then protects RPC frames', async () => { + const pair = await channels(); + const secret = randomBytes(32); + try { + const [client, daemon] = await Promise.all([ + authenticateClient(pair.client, secret, 'fixture'), + authenticateServer(pair.daemon, secret, 'fixture'), + ]); + client.send({ method: 'status' }); + expect(await daemon.receive()).toEqual({ method: 'status' }); + daemon.send({ online: true }); + expect(await client.receive()).toEqual({ online: true }); + client.close(); + daemon.close(); + } finally { + pair.close(); + } +}); + +it('rejects a fake endpoint before sending proof or RPC', async () => { + const pair = await channels(); + try { + const result = authenticateClient(pair.client, randomBytes(32), 'fixture'); + await pair.daemon.next(); + pair.daemon.send({ type: 'challenge', nonce: 'a'.repeat(64), proof: '0'.repeat(64) }); + await expect(result).rejects.toThrow('authentication failed'); + } finally { + pair.close(); + } +}); + +it('rejects an RPC before the handshake', async () => { + const pair = await channels(); + try { + const result = authenticateServer(pair.daemon, randomBytes(32), 'fixture'); + pair.client.send({ method: 'shutdown' }); + await expect(result).rejects.toThrow(); + } finally { + pair.close(); + } +}); + +it('rejects a wrong client secret and direction-reflected proofs', async () => { + const pair = await channels(); + try { + const result = authenticateServer(pair.daemon, randomBytes(32), 'fixture'); + pair.client.send({ + type: 'hello', + nonce: 'b'.repeat(64), + protocolVersion: 3, + policyVersion: 2, + }); + const challenge = (await pair.client.next()) as { proof: string }; + pair.client.send({ type: 'proof', proof: challenge.proof }); + await expect(result).rejects.toThrow('authentication failed'); + } finally { + pair.close(); + } +}); + +it('rejects replayed proofs even if the client challenge is reused', async () => { + const secret = randomBytes(32); + const clientNonce = 'b'.repeat(64); + const first = await channels(); + let oldProof: string; + try { + const authenticated = authenticateServer(first.daemon, secret, 'fixture'); + first.client.send({ type: 'hello', nonce: clientNonce, protocolVersion: 3, policyVersion: 2 }); + const challenge = (await first.client.next()) as { nonce: string }; + const context = JSON.stringify(['cadder-ipc', 3, 2, 'fixture', clientNonce, challenge.nonce]); + oldProof = createHmac('sha256', secret).update(`client:${context}`).digest('hex'); + first.client.send({ type: 'proof', proof: oldProof }); + await first.client.next(); + await authenticated; + } finally { + first.close(); + } + const second = await channels(); + try { + const authenticated = authenticateServer(second.daemon, secret, 'fixture'); + second.client.send({ type: 'hello', nonce: clientNonce, protocolVersion: 3, policyVersion: 2 }); + await second.client.next(); + second.client.send({ type: 'proof', proof: oldProof }); + await expect(authenticated).rejects.toThrow('authentication failed'); + } finally { + second.close(); + } +}); + +it('rejects replayed authenticated RPC frames', async () => { + const pair = await channels(); + const secret = randomBytes(32); + try { + const [client, daemon] = await Promise.all([ + authenticateClient(pair.client, secret, 'fixture'), + authenticateServer(pair.daemon, secret, 'fixture'), + ]); + let captured: Buffer = Buffer.alloc(0); + pair.daemon.socket.on('data', (data: Buffer) => { + captured = data; + }); + client.send({ method: 'status' }); + await daemon.receive(); + pair.client.socket.write(captured); + await expect(daemon.receive()).rejects.toThrow('replayed IPC frame'); + } finally { + pair.close(); + } +}); + +it('rejects modified RPC payloads', async () => { + const pair = await channels(); + const secret = randomBytes(32); + try { + const [client, daemon] = await Promise.all([ + authenticateClient(pair.client, secret, 'fixture'), + authenticateServer(pair.daemon, secret, 'fixture'), + ]); + pair.client.send({ + sequence: 0, + payload: JSON.stringify({ method: 'shutdown' }), + mac: '0'.repeat(64), + }); + await expect(daemon.receive()).rejects.toThrow('authentication failed'); + client.close(); + } finally { + pair.close(); + } +}); diff --git a/test/channel.test.ts b/test/channel.test.ts new file mode 100644 index 0000000..2a365c3 --- /dev/null +++ b/test/channel.test.ts @@ -0,0 +1,61 @@ +import { createConnection, createServer } from 'node:net'; +import { once } from 'node:events'; +import { expect, it } from 'vitest'; +import { JsonChannel } from '../src/protocol/channel.ts'; +import { MAX_FRAME_BYTES } from '../src/protocol/version.ts'; + +async function fixture() { + const server = createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing fixture port'); + const incoming = once(server, 'connection'); + const socket = createConnection(address.port, '127.0.0.1'); + const [peer] = await incoming; + const channel = new JsonChannel(socket); + return { + peer, + channel, + close() { + peer.destroy(); + channel.close(); + server.close(); + }, + }; +} + +it('parses split and batched NDJSON frames', async () => { + const pair = await fixture(); + try { + pair.peer.write('{"a":'); + pair.peer.write('1}\n{"b":2}\n'); + expect(await pair.channel.next()).toEqual({ a: 1 }); + expect(await pair.channel.next()).toEqual({ b: 2 }); + } finally { + pair.close(); + } +}); +it('bounds incomplete frames and refuses malformed JSON', async () => { + for (const frame of ['{invalid}\n', 'x'.repeat(MAX_FRAME_BYTES + 1)]) { + const pair = await fixture(); + try { + const result = pair.channel.next(); + pair.peer.write(frame); + await expect(result).rejects.toThrow('Invalid or oversized'); + } finally { + pair.close(); + } + } +}); +it('bounds outgoing frames and rejects concurrent reads and timeouts', async () => { + const pair = await fixture(); + try { + expect(() => pair.channel.send('x'.repeat(MAX_FRAME_BYTES))).toThrow('size limit'); + const pending = pair.channel.next(10); + await expect(pair.channel.next()).rejects.toThrow('Only one IPC read'); + await expect(pending).rejects.toThrow('deadline'); + } finally { + pair.close(); + } +}); diff --git a/test/fixtures/runtime-child.ts b/test/fixtures/runtime-child.ts new file mode 100644 index 0000000..2da7234 --- /dev/null +++ b/test/fixtures/runtime-child.ts @@ -0,0 +1,26 @@ +import { startLocalRuntime } from '../../src/daemon/local-runtime.ts'; + +const options: { runtimeDir?: string; runtimeOwner?: number } = {}; +if (process.argv[2] !== undefined) options.runtimeDir = process.argv[2]; +if (process.argv[3] !== undefined) options.runtimeOwner = Number(process.argv[3]); +const runtime = await startLocalRuntime(options, async (request) => { + if (request.method === 'status') return { processId: process.pid, recovered: runtime.recovered }; + if (request.method === 'shutdown') { + setTimeout(() => { + void shutdown(); + }, 30); + return { stopped: true }; + } + throw new Error('Unknown fixture request'); +}); +process.stdout.write('READY\n'); +async function shutdown() { + await runtime.stop(); + process.exitCode = 0; +} +process.on('SIGTERM', () => { + void shutdown(); +}); +setTimeout(() => { + void shutdown(); +}, 30000).unref(); diff --git a/test/fixtures/runtime-smoke.ts b/test/fixtures/runtime-smoke.ts new file mode 100644 index 0000000..95f3017 --- /dev/null +++ b/test/fixtures/runtime-smoke.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { resolvePaths } from '../../src/daemon/paths.ts'; +import { rpc } from '../../src/client/connection.ts'; + +// Native-platform gate for the packed runtime fixture, not a product release. +const entry = process.argv[2]; +if (!entry) throw new Error('Pass the absolute path to packed runtime-child.mjs.'); +const root = await mkdtemp(join(tmpdir(), 'cadder-native-')); +const paths = resolvePaths({ runtimeDir: root }); +const environment = { ...process.env }; +delete environment.NODE_OPTIONS; +let child: ChildProcessWithoutNullStreams | undefined; + +async function start(): Promise { + const processHandle = spawn( + process.execPath, + [entry!, root, ...(process.getuid ? [String(process.getuid())] : [])], + { windowsHide: true, env: environment }, + ); + let stderr = ''; + processHandle.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + processHandle.kill(); + reject(new Error(`Startup timeout: ${stderr}`)); + }, 12000); + processHandle.stdout.once('data', () => { + clearTimeout(timer); + resolve(); + }); + processHandle.once('exit', () => { + clearTimeout(timer); + reject(new Error(stderr)); + }); + processHandle.once('error', reject); + }); + return processHandle; +} + +try { + child = await start(); + assert.deepEqual(await rpc(paths, 'status'), { processId: child.pid, recovered: false }); + await assert.rejects(start, /already locked/); + let exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; + child = await start(); + assert.deepEqual(await rpc(paths, 'status'), { processId: child.pid, recovered: true }); + exited = once(child, 'exit'); + await rpc(paths, 'shutdown'); + await exited; + child = undefined; + await assert.rejects(() => rpc(paths, 'status'), /Start it with cadder daemon start/); + process.stdout.write(`Native runtime gate passed on ${process.platform}/${process.arch}.\n`); +} finally { + if (child?.exitCode === null) { + const exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; + } + await rm(root, { recursive: true, force: true }); +} diff --git a/test/ipc-server.test.ts b/test/ipc-server.test.ts new file mode 100644 index 0000000..37be8b0 --- /dev/null +++ b/test/ipc-server.test.ts @@ -0,0 +1,61 @@ +import { randomBytes } from 'node:crypto'; +import { createConnection } from 'node:net'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { rpc } from '../src/client/connection.ts'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; +import { serveIpc } from '../src/daemon/ipc-server.ts'; +import { prepareRuntime, runtimeOwner } from '../src/platform/runtime-security.ts'; +import { CadderError } from '../src/protocol/errors.ts'; +import { JsonChannel } from '../src/protocol/channel.ts'; + +it('serves only authenticated RPC and reports typed handler failures', async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-ipc-')); + const owner = await runtimeOwner(); + const paths = resolvePaths({ runtimeDir: root }); + await prepareRuntime(paths.directory, owner); + const lock = await acquireRuntimeLock(paths, owner); + let denied = 0; + let called = 0; + const server = await serveIpc( + paths, + owner, + lock.secret, + async (request) => { + called++; + if (request.method === 'status') return { online: true }; + if (request.method === 'typed') + throw new CadderError('fixture-error', 'A safe fixture error'); + throw new Error('Do not expose internal error text'); + }, + () => { + denied++; + }, + ); + try { + expect(await rpc(paths, 'status')).toEqual({ online: true }); + await expect(rpc(paths, 'typed')).rejects.toMatchObject({ + code: 'fixture-error', + message: 'A safe fixture error', + }); + await expect(rpc(paths, 'generic')).rejects.toThrow('See diagnostics'); + const calledBefore = called; + const socket = createConnection(paths.endpoint); + const channel = new JsonChannel(socket); + channel.send({ method: 'shutdown' }); + await expect(channel.next()).rejects.toThrow('closed'); + channel.close(); + expect(called).toBe(calledBefore); + expect(denied).toBe(1); + await writeFile(paths.secret, randomBytes(32)); + await expect(rpc(paths, 'status')).rejects.toThrow('authentication failed'); + expect(called).toBe(calledBefore); + } finally { + await server.close(); + await lock.release(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/local-runtime.test.ts b/test/local-runtime.test.ts new file mode 100644 index 0000000..3614142 --- /dev/null +++ b/test/local-runtime.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { startLocalRuntime } from '../src/daemon/local-runtime.ts'; +import { rpc } from '../src/client/connection.ts'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { prepareRuntime, runtimeOwner } from '../src/platform/runtime-security.ts'; + +it('owns the full bind/lock lifetime and releases it idempotently', async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-local-')); + const options = { + runtimeDir: root, + ...(process.getuid ? { runtimeOwner: process.getuid() } : {}), + }; + try { + const runtime = await startLocalRuntime(options, async () => ({ online: true })); + try { + expect(await rpc(runtime.paths, 'status')).toEqual({ online: true }); + await expect(startLocalRuntime(options, async () => null)).rejects.toThrow('already locked'); + } finally { + await Promise.all([runtime.stop(), runtime.stop()]); + } + const restarted = await startLocalRuntime(options, async () => null); + expect(restarted.recovered).toBe(false); + await restarted.stop(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it('closes the listener and releases the lock when protected discovery publication fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-local-failure-')); + const options = { + runtimeDir: root, + ...(process.getuid ? { runtimeOwner: process.getuid() } : {}), + }; + const paths = resolvePaths(options); + const owner = await runtimeOwner(options.runtimeOwner); + try { + await prepareRuntime(paths.directory, owner); + await writeFile(paths.discovery, 'unsafe-existing-metadata', { mode: 0o644 }); + await expect(startLocalRuntime(options, async () => null)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await readFile(paths.discovery, 'utf8')).toBe('unsafe-existing-metadata'); + await unlink(paths.discovery); + const runtime = await startLocalRuntime(options, async () => null); + await runtime.stop(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/paths.test.ts b/test/paths.test.ts new file mode 100644 index 0000000..5cab018 --- /dev/null +++ b/test/paths.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { join, resolve } from 'node:path'; +import { resolvePaths } from '../src/daemon/paths.ts'; + +describe('v2 runtime identity', () => { + it('isolates explicitly configured runtime without changing the old runtime', () => { + const paths = resolvePaths({ runtimeDir: 'fixture-runtime' }); + expect(paths.directory).toBe(join(resolve('fixture-runtime'), 'v2')); + expect(resolvePaths({ runtimeDir: 'fixture-runtime' })).toEqual(paths); + expect(paths.lock).toContain('lock.sqlite3'); + expect(paths.profile).toBe('default'); + }); + it.each(['default', 'prod', 'production', ''])('accepts default profile %s', (profile) => { + expect(resolvePaths({ runtimeDir: 'fixture', profile }).profile).toBe('default'); + }); + it.each(['dev', 'development'])('accepts dev profile %s', (profile) => { + expect(resolvePaths({ runtimeDir: 'fixture', profile }).profile).toBe('dev'); + }); + it('rejects unrecognized profiles', () => { + expect(() => resolvePaths({ profile: 'other' })).toThrow('Expected runtime profile'); + }); +}); diff --git a/test/runtime-lock.test.ts b/test/runtime-lock.test.ts new file mode 100644 index 0000000..8b93832 --- /dev/null +++ b/test/runtime-lock.test.ts @@ -0,0 +1,40 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; +import { + createProtectedFile, + prepareRuntime, + runtimeOwner, +} from '../src/platform/runtime-security.ts'; + +it('uses actual SQLite exclusion rather than diagnostic PID or expiry', async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-lock-')); + const paths = resolvePaths({ runtimeDir: root }); + const owner = await runtimeOwner(); + try { + await prepareRuntime(paths.directory, owner); + const first = await acquireRuntimeLock(paths, owner); + try { + expect(first.recovered).toBe(false); + expect(first.secret.length).toBe(32); + await writeFile(paths.metadata, JSON.stringify({ processId: -1, acquiredAt: '1970-01-01' })); + await expect(acquireRuntimeLock(paths, owner)).rejects.toThrow('already locked'); + } finally { + await first.release(); + await first.release(); + } + await createProtectedFile(paths.metadata, owner, 'stale metadata'); + const second = await acquireRuntimeLock(paths, owner); + try { + expect(second.recovered).toBe(true); + expect(JSON.parse(await readFile(paths.metadata, 'utf8')).processId).toBe(process.pid); + } finally { + await second.release(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/runtime-security.test.ts b/test/runtime-security.test.ts new file mode 100644 index 0000000..a826193 --- /dev/null +++ b/test/runtime-security.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, it } from 'vitest'; +import { + assertProtected, + createProtectedFile, + prepareRuntime, + runtimeOwner, +} from '../src/platform/runtime-security.ts'; +import { powershell, psLiteral } from '../src/platform/powershell.ts'; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +it('creates protected runtime and secret and validates existing paths', async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-security-')); + directories.push(root); + const owner = await runtimeOwner(); + const directory = join(root, 'v2'); + await prepareRuntime(directory, owner); + await prepareRuntime(directory, owner); + const path = join(directory, 'secret'); + expect(await createProtectedFile(path, owner, 'secret')).toBe(true); + expect(await createProtectedFile(path, owner)).toBe(false); + await assertProtected(path, owner); + await expect(assertProtected(directory, owner)).rejects.toThrow('regular files'); +}); + +it( + 'rejects insecure Windows ACL rather than repairing it', + { skip: process.platform !== 'win32' }, + async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-security-')); + directories.push(root); + const owner = await runtimeOwner(); + await expect(prepareRuntime(root, owner)).rejects.toThrow('ACL'); + const directory = join(root, 'v2'); + await prepareRuntime(directory, owner); + await powershell( + `$acl=New-Object System.Security.AccessControl.DirectorySecurity; $owner=New-Object System.Security.Principal.SecurityIdentifier(${psLiteral(owner.id)}); $acl.SetOwner($owner); $acl.SetAccessRuleProtection($true,$false); $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($owner,'FullControl','Allow'))); $sid=New-Object System.Security.Principal.SecurityIdentifier('S-1-1-0'); $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'Read','Allow'))); [System.IO.Directory]::SetAccessControl(${psLiteral(directory)}, $acl)`, + ); + await expect(prepareRuntime(directory, owner)).rejects.toThrow('ACL'); + }, +); + +it('quotes PowerShell literals without allowing interpolation', () => { + expect(psLiteral("a'b$()")).toBe("'a''b$()'"); +}); diff --git a/test/runtime-system.test.ts b/test/runtime-system.test.ts new file mode 100644 index 0000000..ef59af9 --- /dev/null +++ b/test/runtime-system.test.ts @@ -0,0 +1,81 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; +import { expect, it } from 'vitest'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { rpc } from '../src/client/connection.ts'; + +async function start(root: string, entry: string): Promise { + const environment = { ...process.env }; + delete environment.NODE_OPTIONS; + const child = spawn( + process.execPath, + [entry, root, ...(process.getuid ? [String(process.getuid())] : [])], + { windowsHide: true, env: environment }, + ); + let error = ''; + child.stderr.on('data', (data: Buffer) => { + error += data.toString(); + }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`Fixture startup timed out: ${error}`)); + }, 12000); + child.stdout.once('data', () => { + clearTimeout(timer); + resolve(); + }); + child.once('exit', () => { + clearTimeout(timer); + reject(new Error(error)); + }); + child.once('error', reject); + }); + return child; +} + +it( + 'system gate: real child process lock, authenticated local transport and crash recovery', + { timeout: 30000 }, + async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-system-')); + const paths = resolvePaths({ runtimeDir: root }); + let child: ChildProcessWithoutNullStreams | undefined; + try { + const entry = join(root, 'runtime-child.mjs'); + await build({ + entryPoints: [fileURLToPath(new URL('./fixtures/runtime-child.ts', import.meta.url))], + outfile: entry, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node24.18', + }); + child = await start(root, entry); + expect(await rpc(paths, 'status')).toMatchObject({ processId: child.pid, recovered: false }); + await expect(start(root, entry)).rejects.toThrow('already locked'); + const crashed = once(child, 'exit'); + child.kill('SIGKILL'); + await crashed; + child = await start(root, entry); + expect(await rpc(paths, 'status')).toMatchObject({ processId: child.pid, recovered: true }); + const exited = once(child, 'exit'); + expect(await rpc(paths, 'shutdown')).toEqual({ stopped: true }); + await exited; + child = undefined; + await expect(rpc(paths, 'status')).rejects.toThrow('Start it with cadder daemon start'); + } finally { + if (child?.exitCode === null) { + const exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; + } + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/test/unix-security-policy.test.ts b/test/unix-security-policy.test.ts new file mode 100644 index 0000000..1cdcbe5 --- /dev/null +++ b/test/unix-security-policy.test.ts @@ -0,0 +1,64 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import type { Stats } from 'node:fs'; +import { lstat, chown } from 'node:fs/promises'; +import { assertProtected, protectCreated, runtimeOwner } from '../src/platform/runtime-security.ts'; + +vi.mock('node:fs/promises', async (original) => ({ + ...(await original()), + lstat: vi.fn(), + chown: vi.fn(), +})); +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetAllMocks(); +}); + +function unix(uid: number) { + vi.stubGlobal('process', { ...process, platform: 'linux', getuid: () => uid }); +} +function info(uid: number, mode: number, directory = false, link = false): Stats { + return { + uid, + mode, + isDirectory: () => directory, + isFile: () => !directory, + isSymbolicLink: () => link, + } as Stats; +} + +it('requires an explicit root owner and rejects invalid owner UIDs', async () => { + unix(0); + await expect(runtimeOwner()).rejects.toMatchObject({ code: 'runtime-owner-required' }); + expect(await runtimeOwner(1234)).toEqual({ id: 'uid:1234', uid: 1234, elevated: true }); + await expect(runtimeOwner(-1)).rejects.toMatchObject({ code: 'invalid-runtime-owner' }); + unix(1234); + await expect(runtimeOwner(1235)).rejects.toMatchObject({ code: 'invalid-runtime-owner' }); + await expect(runtimeOwner(1.5)).rejects.toMatchObject({ code: 'invalid-runtime-owner' }); + expect(await runtimeOwner()).toEqual({ id: 'uid:1234', uid: 1234, elevated: false }); +}); + +it('fails closed on broad permissions, another owner and symlinks', async () => { + unix(1234); + const owner = { id: 'uid:1234', uid: 1234, elevated: false }; + vi.mocked(lstat).mockResolvedValue(info(1234, 0o700, true)); + await assertProtected('fixture', owner, true); + vi.mocked(lstat).mockResolvedValue(info(1234, 0o600)); + await assertProtected('fixture', owner); + for (const value of [info(1234, 0o644), info(1235, 0o600), info(1234, 0o600, false, true)]) { + vi.mocked(lstat).mockResolvedValue(value); + await expect(assertProtected('fixture', owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + } +}); + +it('sets the explicit user ownership for newly created root-owned paths', async () => { + unix(0); + const owner = { id: 'uid:1234', uid: 1234, elevated: true }; + await protectCreated('fixture', owner); + expect(chown).toHaveBeenCalledWith('fixture', 1234, 1234); + vi.mocked(chown).mockClear(); + unix(1234); + await protectCreated('fixture', owner); + expect(chown).not.toHaveBeenCalled(); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c7f25a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "rewriteRelativeImportExtensions": true, + "jsx": "react-jsx", + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "test/**/*.ts", + "test/**/*.tsx", + "scripts/**/*.ts", + "vitest.config.ts", + "eslint.config.ts" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a5db37b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.{ts,tsx}'], + testTimeout: 15000, + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + thresholds: { lines: 85 }, + reporter: ['text', 'json-summary', 'lcov'], + reportsDirectory: + process.env.CADDER_COVERAGE_DIR ?? join(tmpdir(), `cadder-coverage-${process.pid}`), + }, + }, +}); From 963c77db8d176a65403190879150abc1e0090fbd Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Mon, 5 Oct 2026 16:14:58 +0200 Subject: [PATCH 12/29] fix: run native runtime gates in CI Use runner paths only in step contexts and verify Unix owner access to an elevated runtime and denial for a different account. --- .github/workflows/node-runtime.yml | 9 +- .../reset-cadder-architecture/verification.md | 13 ++- scripts/prepare-runtime-gate.ts | 10 ++- test/fixtures/runtime-denied-client.ts | 17 ++++ test/fixtures/runtime-privilege-smoke.ts | 82 +++++++++++++++++++ 5 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 test/fixtures/runtime-denied-client.ts create mode 100644 test/fixtures/runtime-privilege-smoke.ts diff --git a/.github/workflows/node-runtime.yml b/.github/workflows/node-runtime.yml index b63d773..8d5ab6e 100644 --- a/.github/workflows/node-runtime.yml +++ b/.github/workflows/node-runtime.yml @@ -22,8 +22,6 @@ jobs: fail-fast: false matrix: os: [windows-latest, ubuntu-latest, macos-latest] - env: - CADDER_COVERAGE_DIR: ${{ runner.temp }}/cadder-node-coverage steps: - uses: actions/checkout@v6.0.3 - uses: actions/setup-node@v7.0.0 @@ -40,6 +38,8 @@ jobs: - name: Check formatting run: nub run format:check - name: Test runtime and coverage + env: + CADDER_COVERAGE_DIR: ${{ runner.temp }}/cadder-node-coverage run: nub run coverage - name: Pack native runtime fixtures outside checkout id: fixtures @@ -47,6 +47,9 @@ jobs: run: echo "stage=$(node scripts/prepare-runtime-gate.ts)" >> "$GITHUB_OUTPUT" - name: Native compiled-JS runtime smoke without TS loader run: node "${{ steps.fixtures.outputs.stage }}/runtime-smoke.mjs" "${{ steps.fixtures.outputs.stage }}/runtime-child.mjs" + - name: Native same-owner elevated daemon and different-account denial + if: runner.os != 'Windows' + run: node "${{ steps.fixtures.outputs.stage }}/runtime-privilege-smoke.mjs" "${{ steps.fixtures.outputs.stage }}/runtime-child.mjs" "${{ steps.fixtures.outputs.stage }}/runtime-denied-client.mjs" - name: Verify no tracked build output run: git diff --exit-code - name: Upload coverage @@ -54,6 +57,6 @@ jobs: uses: actions/upload-artifact@v7.0.1 with: name: node-coverage-${{ matrix.os }} - path: ${{ env.CADDER_COVERAGE_DIR }} + path: ${{ runner.temp }}/cadder-node-coverage if-no-files-found: ignore retention-days: 14 diff --git a/openspec/changes/reset-cadder-architecture/verification.md b/openspec/changes/reset-cadder-architecture/verification.md index 0ae9929..ca34aac 100644 --- a/openspec/changes/reset-cadder-architecture/verification.md +++ b/openspec/changes/reset-cadder-architecture/verification.md @@ -25,6 +25,9 @@ authority and cannot authorize another account. - Linux x64 native gate passes in a local Linux environment: real Unix socket, real file permissions, child-process exclusion, forced crash and recovery, authenticated contact and orderly shutdown. +- Linux x64 privilege gate passes with a root daemon explicitly assigned to the + normal user's runtime. The normal user authenticates successfully; an existing + different account is denied; authenticated shutdown releases the runtime. - Authentication tests cover fake server proof, wrong/reflected client proof, proof replay, RPC replay/tampering and RPC before authentication. - Unsafe ACLs are rejected instead of repaired. A failed discovery publication @@ -33,9 +36,10 @@ authority and cannot authorize another account. ## Required remaining runtime gate The committed CI matrix must pass on Windows, Linux and macOS. Real same-owner -user-to-elevated contact and different-user rejection remain system acceptance -items, including Windows Sandbox. Fake Unix policy tests do not prove these -privilege transitions. Do not mark N2 complete or port Caddy before these gates. +user-to-elevated contact and different-user rejection remain unverified on +macOS and Windows, including Windows Sandbox. Fake Unix policy tests do not +prove these privilege transitions. Do not mark N2 complete or port Caddy before +these gates. ## Commands @@ -51,4 +55,7 @@ node scripts/prepare-runtime-gate.ts The last command prints an outside-checkout staging directory containing test fixtures. Run `node /runtime-smoke.mjs /runtime-child.mjs` with the native platform Node 24.18.0. These fixtures are not npm or SEA release artifacts. +On Unix, run `node /runtime-privilege-smoke.mjs /runtime-child.mjs +/runtime-denied-client.mjs` as a non-root user with noninteractive sudo. +Only disposable test runtime files and fixture processes are used. Coverage output also stays in system/runner temp. No publication is performed. diff --git a/scripts/prepare-runtime-gate.ts b/scripts/prepare-runtime-gate.ts index 92a4e7b..83a3834 100644 --- a/scripts/prepare-runtime-gate.ts +++ b/scripts/prepare-runtime-gate.ts @@ -1,5 +1,5 @@ import { build } from 'esbuild'; -import { mkdtemp } from 'node:fs/promises'; +import { chmod, mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,6 +9,12 @@ await build({ entryPoints: { 'runtime-child': fileURLToPath(new URL('../test/fixtures/runtime-child.ts', import.meta.url)), 'runtime-smoke': fileURLToPath(new URL('../test/fixtures/runtime-smoke.ts', import.meta.url)), + 'runtime-denied-client': fileURLToPath( + new URL('../test/fixtures/runtime-denied-client.ts', import.meta.url), + ), + 'runtime-privilege-smoke': fileURLToPath( + new URL('../test/fixtures/runtime-privilege-smoke.ts', import.meta.url), + ), }, outdir: stage, outExtension: { '.js': '.mjs' }, @@ -17,4 +23,6 @@ await build({ format: 'esm', target: 'node24.18', }); +// This directory contains shared source bundles only, never runtime secrets. +if (process.platform !== 'win32') await chmod(stage, 0o755); process.stdout.write(`${stage}\n`); diff --git a/test/fixtures/runtime-denied-client.ts b/test/fixtures/runtime-denied-client.ts new file mode 100644 index 0000000..5e3ab9d --- /dev/null +++ b/test/fixtures/runtime-denied-client.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { rpc } from '../../src/client/connection.ts'; +import { resolvePaths } from '../../src/daemon/paths.ts'; +import { errorCode } from '../../src/protocol/errors.ts'; + +const runtimeDir = process.argv[2]; +if (!runtimeDir) throw new Error('Pass the owner runtime directory.'); +try { + await rpc(resolvePaths({ runtimeDir }), 'status'); + throw new Error('An unauthorized account contacted the runtime.'); +} catch (error) { + assert.ok( + ['unsafe-runtime-permissions', 'EACCES', 'EPERM'].includes(errorCode(error) ?? ''), + 'The account must be denied by the runtime owner boundary.', + ); + process.stdout.write('DENIED\n'); +} diff --git a/test/fixtures/runtime-privilege-smoke.ts b/test/fixtures/runtime-privilege-smoke.ts new file mode 100644 index 0000000..b92674c --- /dev/null +++ b/test/fixtures/runtime-privilege-smoke.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { rpc } from '../../src/client/connection.ts'; +import { resolvePaths } from '../../src/daemon/paths.ts'; +import { prepareRuntime, runtimeOwner } from '../../src/platform/runtime-security.ts'; + +if (process.platform === 'win32' || !process.getuid || process.getuid() === 0) + throw new Error('Run this Unix privilege gate as a non-root user with sudo -n available.'); +const daemonEntry = process.argv[2]; +const deniedEntry = process.argv[3]; +if (!daemonEntry || !deniedEntry) + throw new Error('Pass packed runtime-child and runtime-denied-client entrypoints.'); +const root = await mkdtemp(join(tmpdir(), 'cadder-privilege-')); +const paths = resolvePaths({ runtimeDir: root }); +await prepareRuntime(paths.directory, await runtimeOwner()); +const environment = { ...process.env }; +delete environment.NODE_OPTIONS; +let daemon: ChildProcessWithoutNullStreams | undefined; + +try { + daemon = spawn('sudo', ['-n', process.execPath, daemonEntry, root, String(process.getuid())], { + env: environment, + }); + let error = ''; + daemon.stderr.on('data', (data: Buffer) => { + error += data.toString(); + }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Elevated daemon startup timeout: ${error}`)); + }, 12000); + daemon!.stdout.once('data', () => { + clearTimeout(timer); + resolve(); + }); + daemon!.once('exit', () => { + clearTimeout(timer); + reject(new Error(error)); + }); + daemon!.once('error', reject); + }); + const status = (await rpc(paths, 'status')) as { processId: number }; + assert.ok(status.processId > 0); + const discovery = JSON.parse(await readFile(paths.discovery, 'utf8')) as { + elevated: boolean; + owner: string; + }; + assert.equal(discovery.elevated, true); + assert.equal(discovery.owner, `uid:${process.getuid()}`); + const outsider = spawn('sudo', ['-n', '-u', 'nobody', process.execPath, deniedEntry, root], { + env: environment, + }); + let deniedOutput = ''; + let deniedError = ''; + outsider.stdout.on('data', (data: Buffer) => { + deniedOutput += data.toString(); + }); + outsider.stderr.on('data', (data: Buffer) => { + deniedError += data.toString(); + }); + const [code] = await once(outsider, 'exit'); + assert.equal(code, 0, deniedError); + assert.equal(deniedOutput.trim(), 'DENIED'); + await rpc(paths, 'status'); + const exited = once(daemon, 'exit'); + await rpc(paths, 'shutdown'); + await exited; + daemon = undefined; + process.stdout.write(`Native privilege gate passed on ${process.platform}/${process.arch}.\n`); +} finally { + if (daemon?.exitCode === null) { + const exited = once(daemon, 'exit'); + await rpc(paths, 'shutdown').catch(() => {}); + // The fixture's fixed 30-second lifetime bounds cleanup if authentication failed. + await exited; + } + await rm(root, { recursive: true, force: true }); +} From 198c1a81037a90da1a31c202b6c849cbce8c092d Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Mon, 5 Oct 2026 16:16:18 +0200 Subject: [PATCH 13/29] fix: bootstrap the published Nub package in CI --- .github/workflows/node-runtime.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node-runtime.yml b/.github/workflows/node-runtime.yml index 8d5ab6e..aec4148 100644 --- a/.github/workflows/node-runtime.yml +++ b/.github/workflows/node-runtime.yml @@ -28,7 +28,7 @@ jobs: with: node-version: 24.18.0 - name: Bootstrap pinned repository manager - run: npm install --global nub@0.7.5 + run: npm install --global @nubjs/nub@0.7.5 - name: Install exact locked dependencies run: nub ci - name: Check TypeScript From e1c400e16bf65bd7c9c1b876daaeed664f1488bb Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Mon, 5 Oct 2026 16:19:21 +0200 Subject: [PATCH 14/29] fix: make runtime gates portable across native runners Keep TypeScript checkout line endings stable and run the outsider fixture from a shared temporary executable instead of the private macOS tool cache. --- .gitattributes | 2 ++ test/fixtures/runtime-privilege-smoke.ts | 21 ++++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.gitattributes b/.gitattributes index 5906df7..5f7a1c2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,5 @@ *.md text eol=lf *.yml text eol=lf *.json text eol=lf +*.ts text eol=lf +*.tsx text eol=lf diff --git a/test/fixtures/runtime-privilege-smoke.ts b/test/fixtures/runtime-privilege-smoke.ts index b92674c..bb15080 100644 --- a/test/fixtures/runtime-privilege-smoke.ts +++ b/test/fixtures/runtime-privilege-smoke.ts @@ -1,8 +1,7 @@ import assert from 'node:assert/strict'; import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { once } from 'node:events'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { chmod, copyFile, mkdtemp, readFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { rpc } from '../../src/client/connection.ts'; import { resolvePaths } from '../../src/daemon/paths.ts'; @@ -14,14 +13,25 @@ const daemonEntry = process.argv[2]; const deniedEntry = process.argv[3]; if (!daemonEntry || !deniedEntry) throw new Error('Pass packed runtime-child and runtime-denied-client entrypoints.'); -const root = await mkdtemp(join(tmpdir(), 'cadder-privilege-')); +// macOS runner tool caches and per-user temp directories are not traversable by nobody. +// Publish only the executable and test bundle in a disposable shared directory. +const inputs = await mkdtemp('/tmp/cadder-privilege-inputs-'); +const root = await mkdtemp('/tmp/cadder-privilege-runtime-'); const paths = resolvePaths({ runtimeDir: root }); -await prepareRuntime(paths.directory, await runtimeOwner()); const environment = { ...process.env }; delete environment.NODE_OPTIONS; let daemon: ChildProcessWithoutNullStreams | undefined; try { + const outsiderNode = join(inputs, 'node'); + const outsiderEntry = join(inputs, 'runtime-denied-client.mjs'); + await copyFile(process.execPath, outsiderNode); + await copyFile(deniedEntry, outsiderEntry); + await chmod(outsiderNode, 0o755); + await chmod(outsiderEntry, 0o644); + await chmod(inputs, 0o755); + await chmod(root, 0o755); + await prepareRuntime(paths.directory, await runtimeOwner()); daemon = spawn('sudo', ['-n', process.execPath, daemonEntry, root, String(process.getuid())], { env: environment, }); @@ -51,7 +61,7 @@ try { }; assert.equal(discovery.elevated, true); assert.equal(discovery.owner, `uid:${process.getuid()}`); - const outsider = spawn('sudo', ['-n', '-u', 'nobody', process.execPath, deniedEntry, root], { + const outsider = spawn('sudo', ['-n', '-u', 'nobody', outsiderNode, outsiderEntry, root], { env: environment, }); let deniedOutput = ''; @@ -79,4 +89,5 @@ try { await exited; } await rm(root, { recursive: true, force: true }); + await rm(inputs, { recursive: true, force: true }); } From 8246d2e6ae9b01dd829c75595aac71db12a2074d Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Mon, 5 Oct 2026 16:23:35 +0200 Subject: [PATCH 15/29] test: budget for native Windows ACL checks --- test/ipc-server.test.ts | 99 ++++++++++++++++++++++------------------- 1 file changed, 52 insertions(+), 47 deletions(-) diff --git a/test/ipc-server.test.ts b/test/ipc-server.test.ts index 37be8b0..b27a1b4 100644 --- a/test/ipc-server.test.ts +++ b/test/ipc-server.test.ts @@ -12,50 +12,55 @@ import { prepareRuntime, runtimeOwner } from '../src/platform/runtime-security.t import { CadderError } from '../src/protocol/errors.ts'; import { JsonChannel } from '../src/protocol/channel.ts'; -it('serves only authenticated RPC and reports typed handler failures', async () => { - const root = await mkdtemp(join(tmpdir(), 'cadder-ipc-')); - const owner = await runtimeOwner(); - const paths = resolvePaths({ runtimeDir: root }); - await prepareRuntime(paths.directory, owner); - const lock = await acquireRuntimeLock(paths, owner); - let denied = 0; - let called = 0; - const server = await serveIpc( - paths, - owner, - lock.secret, - async (request) => { - called++; - if (request.method === 'status') return { online: true }; - if (request.method === 'typed') - throw new CadderError('fixture-error', 'A safe fixture error'); - throw new Error('Do not expose internal error text'); - }, - () => { - denied++; - }, - ); - try { - expect(await rpc(paths, 'status')).toEqual({ online: true }); - await expect(rpc(paths, 'typed')).rejects.toMatchObject({ - code: 'fixture-error', - message: 'A safe fixture error', - }); - await expect(rpc(paths, 'generic')).rejects.toThrow('See diagnostics'); - const calledBefore = called; - const socket = createConnection(paths.endpoint); - const channel = new JsonChannel(socket); - channel.send({ method: 'shutdown' }); - await expect(channel.next()).rejects.toThrow('closed'); - channel.close(); - expect(called).toBe(calledBefore); - expect(denied).toBe(1); - await writeFile(paths.secret, randomBytes(32)); - await expect(rpc(paths, 'status')).rejects.toThrow('authentication failed'); - expect(called).toBe(calledBefore); - } finally { - await server.close(); - await lock.release(); - await rm(root, { recursive: true, force: true }); - } -}); +// This scenario performs several real PowerShell ACL checks on Windows runners. +it( + 'serves only authenticated RPC and reports typed handler failures', + { timeout: 45000 }, + async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-ipc-')); + const owner = await runtimeOwner(); + const paths = resolvePaths({ runtimeDir: root }); + await prepareRuntime(paths.directory, owner); + const lock = await acquireRuntimeLock(paths, owner); + let denied = 0; + let called = 0; + const server = await serveIpc( + paths, + owner, + lock.secret, + async (request) => { + called++; + if (request.method === 'status') return { online: true }; + if (request.method === 'typed') + throw new CadderError('fixture-error', 'A safe fixture error'); + throw new Error('Do not expose internal error text'); + }, + () => { + denied++; + }, + ); + try { + expect(await rpc(paths, 'status')).toEqual({ online: true }); + await expect(rpc(paths, 'typed')).rejects.toMatchObject({ + code: 'fixture-error', + message: 'A safe fixture error', + }); + await expect(rpc(paths, 'generic')).rejects.toThrow('See diagnostics'); + const calledBefore = called; + const socket = createConnection(paths.endpoint); + const channel = new JsonChannel(socket); + channel.send({ method: 'shutdown' }); + await expect(channel.next()).rejects.toThrow('closed'); + channel.close(); + expect(called).toBe(calledBefore); + expect(denied).toBe(1); + await writeFile(paths.secret, randomBytes(32)); + await expect(rpc(paths, 'status')).rejects.toThrow('authentication failed'); + expect(called).toBe(calledBefore); + } finally { + await server.close(); + await lock.release(); + await rm(root, { recursive: true, force: true }); + } + }, +); From 1670f35088b03c3bfa2532172e0b25e41d9f869c Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Mon, 5 Oct 2026 16:31:00 +0200 Subject: [PATCH 16/29] test: prepare isolated Windows privilege acceptance gate Provide read-only Sandbox inputs and explicit client privilege assertions while recording the passing native CI matrix separately from pending interactive UAC evidence. --- .github/workflows/node-runtime.yml | 3 + .../reset-cadder-architecture/tasks.md | 2 +- .../reset-cadder-architecture/verification.md | 18 +++- .../windows-runtime-gate.md | 100 ++++++++++++++++++ scripts/prepare-runtime-gate.ts | 3 + scripts/prepare-windows-runtime-sandbox.ts | 53 ++++++++++ test/fixtures/runtime-child.ts | 11 +- test/fixtures/runtime-windows-contact.ts | 35 ++++++ 8 files changed, 215 insertions(+), 10 deletions(-) create mode 100644 openspec/changes/reset-cadder-architecture/windows-runtime-gate.md create mode 100644 scripts/prepare-windows-runtime-sandbox.ts create mode 100644 test/fixtures/runtime-windows-contact.ts diff --git a/.github/workflows/node-runtime.yml b/.github/workflows/node-runtime.yml index aec4148..f609a38 100644 --- a/.github/workflows/node-runtime.yml +++ b/.github/workflows/node-runtime.yml @@ -50,6 +50,9 @@ jobs: - name: Native same-owner elevated daemon and different-account denial if: runner.os != 'Windows' run: node "${{ steps.fixtures.outputs.stage }}/runtime-privilege-smoke.mjs" "${{ steps.fixtures.outputs.stage }}/runtime-child.mjs" "${{ steps.fixtures.outputs.stage }}/runtime-denied-client.mjs" + - name: Prepare interactive Windows gate without launching Sandbox + if: runner.os == 'Windows' + run: node scripts/prepare-windows-runtime-sandbox.ts - name: Verify no tracked build output run: git diff --exit-code - name: Upload coverage diff --git a/openspec/changes/reset-cadder-architecture/tasks.md b/openspec/changes/reset-cadder-architecture/tasks.md index ee353ce..467c785 100644 --- a/openspec/changes/reset-cadder-architecture/tasks.md +++ b/openspec/changes/reset-cadder-architecture/tasks.md @@ -7,7 +7,7 @@ Node migration. The approved target and gates are in `node-migration.md`. - [ ] N2. Implement v2 owner-protected runtime, SQLite exclusion, mutual HMAC IPC and system gate tests. - [x] N2.1 Implement runtime/IPC foundation and run Windows unit, regression and native child-process tests. - [x] N2.2 Run the compiled-JS native runtime gate on Linux x64 with real Unix sockets and permissions. - - [ ] N2.3 Pass the native Windows/Linux/macOS CI matrix on the committed code. + - [x] N2.3 Pass the native Windows/Linux/macOS CI matrix on the committed code. - [ ] N2.4 Verify same-owner non-elevated contact with elevated daemon and different-account denial in system tests, including Windows Sandbox. - [ ] N3. Port Caddy composition, transaction queue, loopback mTLS and owned-process lifecycle. - [ ] N4. Port shim policy, safe resolution, heartbeat, autostart and scoped IIS helpers. diff --git a/openspec/changes/reset-cadder-architecture/verification.md b/openspec/changes/reset-cadder-architecture/verification.md index ca34aac..05a5290 100644 --- a/openspec/changes/reset-cadder-architecture/verification.md +++ b/openspec/changes/reset-cadder-architecture/verification.md @@ -35,11 +35,19 @@ authority and cannot authorize another account. ## Required remaining runtime gate -The committed CI matrix must pass on Windows, Linux and macOS. Real same-owner -user-to-elevated contact and different-user rejection remain unverified on -macOS and Windows, including Windows Sandbox. Fake Unix policy tests do not -prove these privilege transitions. Do not mark N2 complete or port Caddy before -these gates. +The [native CI matrix](https://github.com/MrMaxie/cadder/actions/runs/37324338555) +passes on Windows x64, Linux x64 and macOS arm64. It verifies typechecking, +linting, formatting, native compiled-JS IPC/SQLite/process smoke and coverage. +Own-code line coverage is 90.30% on Windows and 87.95% on Linux and macOS, with +runtime modules included. Unix runners also pass real same-owner normal-user +contact with a root daemon and different-account denial. + +Real same-owner user-to-elevated contact and different-user rejection remain +unverified on Windows, including Windows Sandbox. The interactive fixture and +read-only Sandbox package are prepared; preparation is not execution evidence. +Follow `windows-runtime-gate.md`. Do not mark N2 complete or port Caddy before +the Windows privilege gate passes. No host UAC policy, accounts, IIS bindings +or autostart settings are changed by preparing the package. ## Commands diff --git a/openspec/changes/reset-cadder-architecture/windows-runtime-gate.md b/openspec/changes/reset-cadder-architecture/windows-runtime-gate.md new file mode 100644 index 0000000..a5325e6 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/windows-runtime-gate.md @@ -0,0 +1,100 @@ +# Interactive Windows runtime gate + +This package tests the Node runtime foundation only. It is not an npm or SEA +release and does not contain product commands, Caddy, IIS or autostart mutations. +The host package is mounted read-only, networking is disabled, and all runtime +files and account changes below stay inside the disposable Sandbox. + +Prepare on the host with Node 24.18.0: + +```powershell +node scripts/prepare-windows-runtime-sandbox.ts +``` + +Open the printed `.wsb` file when ready to interact with UAC. Do not run the +account-creation commands below on the host. If this Sandbox image cannot +provide a non-elevated shell and a real UAC prompt, record the gate as blocked, +not passed. Do not disable or bypass UAC to obtain a passing result. + +## Same owner, different privilege + +In a non-elevated PowerShell inside Sandbox: + +```powershell +Set-Location C:\CadderRuntimeGate +$cadderGateRuntime = Join-Path $env:TEMP ("cadder-uac-" + [guid]::NewGuid().ToString('N')) +.\node.exe .\runtime-windows-contact.mjs prepare $cadderGateRuntime +Start-Process -FilePath C:\CadderRuntimeGate\node.exe -Verb RunAs -WindowStyle Hidden -ArgumentList @('C:\CadderRuntimeGate\runtime-child.mjs', ('"' + $cadderGateRuntime + '"'), '--interactive-gate') +``` + +First cancel UAC. Verify that no daemon starts and `v2/cadder-ipc.json` does not +exist. Repeat `Start-Process`, approve the real prompt, then run: + +```powershell +.\node.exe .\runtime-windows-contact.mjs elevated-status $cadderGateRuntime +``` + +The expected marker is `SAME_OWNER_ELEVATED_CONTACT_PASSED`. The fixture verifies +that the client is not elevated, the daemon is elevated, both have the same SID, +and an authenticated RPC succeeds. An elevated client does not pass this test. +The daemon fixture stops automatically after five minutes. + +## Different account + +Open an elevated PowerShell inside Sandbox, set `$cadderGateRuntime` to the exact +disposable path used above, and execute this block. It creates one temporary +standard account, grants access only to public test inputs and report files, +checks denial, and removes that account and its test inputs in `finally`. + +```powershell +if ($env:USERNAME -ne 'WDAGUtilityAccount') { throw 'Run only inside the default disposable Windows Sandbox.' } +$ErrorActionPreference = 'Stop' +$cadderAccount = $null +$cadderOutsider = 'cadder-gate-' + [guid]::NewGuid().ToString('N').Substring(0, 6) +$cadderPassword = ConvertTo-SecureString ('A9!' + [guid]::NewGuid().ToString('N')) -AsPlainText -Force +$cadderInputs = Join-Path $env:PUBLIC ('cadder-gate-' + [guid]::NewGuid().ToString('N')) +try { + $cadderAccount = New-LocalUser -Name $cadderOutsider -Password $cadderPassword + New-Item -ItemType Directory -Path $cadderInputs | Out-Null + Copy-Item -LiteralPath C:\CadderRuntimeGate\node.exe -Destination $cadderInputs + Copy-Item -LiteralPath C:\CadderRuntimeGate\runtime-denied-client.mjs -Destination $cadderInputs + $cadderInputAcl = Get-Acl -LiteralPath $cadderInputs + $cadderReadRule = New-Object System.Security.AccessControl.FileSystemAccessRule($cadderAccount.SID, 'ReadAndExecute', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $cadderInputAcl.AddAccessRule($cadderReadRule) + [System.IO.Directory]::SetAccessControl($cadderInputs, $cadderInputAcl) + foreach ($cadderReportName in @('out.txt', 'err.txt')) { + $cadderReport = Join-Path $cadderInputs $cadderReportName + New-Item -ItemType File -Path $cadderReport | Out-Null + $cadderReportAcl = Get-Acl -LiteralPath $cadderReport + $cadderReportAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($cadderAccount.SID, 'Write', 'Allow'))) + [System.IO.File]::SetAccessControl($cadderReport, $cadderReportAcl) + } + $cadderCredential = New-Object System.Management.Automation.PSCredential(($env:COMPUTERNAME + '\' + $cadderOutsider), $cadderPassword) + $cadderDeniedProcess = Start-Process -FilePath (Join-Path $cadderInputs 'node.exe') -ArgumentList @((Join-Path $cadderInputs 'runtime-denied-client.mjs'), ('"' + $cadderGateRuntime + '"')) -Credential $cadderCredential -LoadUserProfile -WorkingDirectory $cadderInputs -WindowStyle Hidden -RedirectStandardOutput (Join-Path $cadderInputs 'out.txt') -RedirectStandardError (Join-Path $cadderInputs 'err.txt') -Wait -PassThru + if ($cadderDeniedProcess.ExitCode -ne 0 -or (Get-Content -LiteralPath (Join-Path $cadderInputs 'out.txt') -Raw).Trim() -ne 'DENIED') { + throw ('Different-account gate failed: ' + (Get-Content -LiteralPath (Join-Path $cadderInputs 'err.txt') -Raw)) + } + 'DIFFERENT_ACCOUNT_DENIED' +} finally { + if ($null -ne $cadderAccount) { Remove-LocalUser -SID $cadderAccount.SID } + $cadderResolvedInputs = [System.IO.Path]::GetFullPath($cadderInputs) + $cadderPublicRoot = [System.IO.Path]::GetFullPath($env:PUBLIC).TrimEnd('\') + '\' + if (!$cadderResolvedInputs.StartsWith($cadderPublicRoot, [StringComparison]::OrdinalIgnoreCase) -or [System.IO.Path]::GetFileName($cadderResolvedInputs) -notmatch '^cadder-gate-[0-9a-f]{32}$') { throw 'Unsafe test cleanup path.' } + if (Test-Path -LiteralPath $cadderResolvedInputs) { Remove-Item -LiteralPath $cadderResolvedInputs -Recurse -Force } +} +``` + +A failure to launch Node under the other account is a failed gate, not evidence +that IPC denied the account. The expected denial must come from the test client. + +Back in the original non-elevated shell: + +```powershell +.\node.exe .\runtime-windows-contact.mjs elevated-status $cadderGateRuntime +.\node.exe .\runtime-windows-contact.mjs shutdown $cadderGateRuntime +``` + +Record the three markers, UAC cancellation/approval, and any errors. Close +Sandbox to discard its runtime, test account profile and other disposable state. +Only after reviewing actual results can N2.4 be marked complete. This interactive +gate has not been verified merely by preparing the package. diff --git a/scripts/prepare-runtime-gate.ts b/scripts/prepare-runtime-gate.ts index 83a3834..7c11230 100644 --- a/scripts/prepare-runtime-gate.ts +++ b/scripts/prepare-runtime-gate.ts @@ -15,6 +15,9 @@ await build({ 'runtime-privilege-smoke': fileURLToPath( new URL('../test/fixtures/runtime-privilege-smoke.ts', import.meta.url), ), + 'runtime-windows-contact': fileURLToPath( + new URL('../test/fixtures/runtime-windows-contact.ts', import.meta.url), + ), }, outdir: stage, outExtension: { '.js': '.mjs' }, diff --git a/scripts/prepare-windows-runtime-sandbox.ts b/scripts/prepare-windows-runtime-sandbox.ts new file mode 100644 index 0000000..06dd9d6 --- /dev/null +++ b/scripts/prepare-windows-runtime-sandbox.ts @@ -0,0 +1,53 @@ +import { execFile } from 'node:child_process'; +import { copyFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +if (process.platform !== 'win32') throw new Error('Prepare this test package on Windows.'); +if (process.versions.node !== '24.18.0') throw new Error('Use Node 24.18.0 for this runtime gate.'); +const environment = { ...process.env }; +delete environment.NODE_OPTIONS; +const result = await promisify(execFile)( + process.execPath, + [fileURLToPath(new URL('./prepare-runtime-gate.ts', import.meta.url))], + { env: environment }, +); +const stage = result.stdout.trim(); +await copyFile(process.execPath, join(stage, 'node.exe')); +await copyFile( + fileURLToPath( + new URL( + '../openspec/changes/reset-cadder-architecture/windows-runtime-gate.md', + import.meta.url, + ), + ), + join(stage, 'README.md'), +); +const configuration = join(stage, 'runtime-gate.wsb'); +await writeFile( + configuration, + ` + Disable + Disable + Disable + Disable + Disable + + + ${escapeXml(stage)} + C:\\CadderRuntimeGate + true + + + + powershell.exe -NoProfile -NoExit -Command "Set-Location 'C:\\CadderRuntimeGate'; Get-Content README.md" + + +`, +); +process.stdout.write(`${configuration}\n`); + +function escapeXml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} diff --git a/test/fixtures/runtime-child.ts b/test/fixtures/runtime-child.ts index 2da7234..3b2249d 100644 --- a/test/fixtures/runtime-child.ts +++ b/test/fixtures/runtime-child.ts @@ -2,7 +2,7 @@ import { startLocalRuntime } from '../../src/daemon/local-runtime.ts'; const options: { runtimeDir?: string; runtimeOwner?: number } = {}; if (process.argv[2] !== undefined) options.runtimeDir = process.argv[2]; -if (process.argv[3] !== undefined) options.runtimeOwner = Number(process.argv[3]); +if (process.getuid && process.argv[3] !== undefined) options.runtimeOwner = Number(process.argv[3]); const runtime = await startLocalRuntime(options, async (request) => { if (request.method === 'status') return { processId: process.pid, recovered: runtime.recovered }; if (request.method === 'shutdown') { @@ -21,6 +21,9 @@ async function shutdown() { process.on('SIGTERM', () => { void shutdown(); }); -setTimeout(() => { - void shutdown(); -}, 30000).unref(); +setTimeout( + () => { + void shutdown(); + }, + process.argv.includes('--interactive-gate') ? 300000 : 30000, +).unref(); diff --git a/test/fixtures/runtime-windows-contact.ts b/test/fixtures/runtime-windows-contact.ts new file mode 100644 index 0000000..63633b1 --- /dev/null +++ b/test/fixtures/runtime-windows-contact.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { rpc } from '../../src/client/connection.ts'; +import { resolvePaths } from '../../src/daemon/paths.ts'; +import { prepareRuntime, runtimeOwner } from '../../src/platform/runtime-security.ts'; + +if (process.platform !== 'win32') throw new Error('This interactive gate requires Windows.'); +const [action, runtimeDir] = process.argv.slice(2); +if (!runtimeDir) + throw new Error('Pass prepare, elevated-status or shutdown and a test runtime directory.'); +const paths = resolvePaths({ runtimeDir }); +const owner = await runtimeOwner(); + +if (action === 'prepare' || action === 'elevated-status') { + assert.equal(owner.elevated, false, 'Run the client from a non-elevated shell.'); +} +if (action === 'prepare') { + await prepareRuntime(paths.directory, owner); + process.stdout.write('PREPARED\n'); +} else if (action === 'elevated-status') { + const discovery = JSON.parse(await readFile(paths.discovery, 'utf8')) as { + elevated: boolean; + owner: string; + }; + assert.equal(discovery.elevated, true, 'The fixture daemon must be elevated.'); + assert.equal(discovery.owner, owner.id, 'The daemon must have the same owner SID.'); + const result = (await rpc(paths, 'status')) as { processId: number }; + assert.ok(result.processId > 0); + process.stdout.write('SAME_OWNER_ELEVATED_CONTACT_PASSED\n'); +} else if (action === 'shutdown') { + assert.deepEqual(await rpc(paths, 'shutdown'), { stopped: true }); + process.stdout.write('STOPPED\n'); +} else { + throw new Error('Expected prepare, elevated-status or shutdown.'); +} From 93942af84f555eab2bd68e0a4a9db487ff82d040 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Mon, 5 Oct 2026 19:37:03 +0200 Subject: [PATCH 17/29] docs: align Node migration plan with released product scope Define requirement deltas, ordered migration tasks and acceptance gates for the TypeScript/Node replacement with equal npm and SEA distributions. Preserve the released CLI/TUI and eight-operation IPC scope instead of restoring history, IIS, autostart or machine output. Keep runtime acceptance and Rust removal evidence-gated. --- openspec/README.md | 18 +- .../reset-cadder-architecture/.openspec.yaml | 5 +- .../reset-cadder-architecture/README.md | 39 +- .../reset-cadder-architecture/acceptance.md | 129 +++++ .../behavior-baseline.md | 36 +- .../reset-cadder-architecture/design.md | 456 ++++++++++-------- .../node-migration.md | 51 +- .../reset-cadder-architecture/proposal.md | 123 +++-- .../reset-cadder-architecture/release.md | 43 ++ .../specs/caddy-runtime/spec.md | 37 ++ .../specs/caddy-shim-integration/spec.md | 75 +-- .../specs/daemon-control-plane/spec.md | 148 ------ .../specs/daemon-lifecycle/spec.md | 35 ++ .../specs/distribution-and-upgrades/spec.md | 45 ++ .../specs/documentation-experience/spec.md | 25 + .../specs/local-control-plane/spec.md | 30 ++ .../specs/observability-logs/spec.md | 53 -- .../specs/operator-cli/spec.md | 21 + .../specs/operator-clients/spec.md | 66 --- .../specs/operator-tui/spec.md | 17 + .../specs/product-topology/spec.md | 22 + .../specs/project-registration/spec.md | 18 + .../specs/quality-tooling/spec.md | 116 ++--- .../specs/runtime-storage/spec.md | 39 ++ .../specs/runtime-topology/spec.md | 68 --- .../specs/windows-iis-handoff/spec.md | 52 -- .../reset-cadder-architecture/tasks.md | 192 ++++---- .../reset-cadder-architecture/verification.md | 91 +++- 28 files changed, 1128 insertions(+), 922 deletions(-) create mode 100644 openspec/changes/reset-cadder-architecture/acceptance.md create mode 100644 openspec/changes/reset-cadder-architecture/release.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/caddy-runtime/spec.md delete mode 100644 openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/daemon-lifecycle/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/distribution-and-upgrades/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/documentation-experience/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/local-control-plane/spec.md delete mode 100644 openspec/changes/reset-cadder-architecture/specs/observability-logs/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/operator-cli/spec.md delete mode 100644 openspec/changes/reset-cadder-architecture/specs/operator-clients/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/operator-tui/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/product-topology/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/project-registration/spec.md create mode 100644 openspec/changes/reset-cadder-architecture/specs/runtime-storage/spec.md delete mode 100644 openspec/changes/reset-cadder-architecture/specs/runtime-topology/spec.md delete mode 100644 openspec/changes/reset-cadder-architecture/specs/windows-iis-handoff/spec.md diff --git a/openspec/README.md b/openspec/README.md index 74888b3..26f8345 100644 --- a/openspec/README.md +++ b/openspec/README.md @@ -1,11 +1,19 @@ # Cadder OpenSpec -> Last reviewed: 2026-07-12 +> Last reviewed: 2026-10-05 OpenSpec records the accepted Cadder 1.0 contract and the evidence that the implementation satisfies it. Product requirements live here; public docs explain how people use the finished product. +The active `reset-cadder-architecture` now defines the approved 2.0 Node migration, +not another Rust reset. Its functional scope preserves the released Rust 1.0.5 +CLI/TUI, not superseded history/IIS/autostart or machine-output workflows. Its +`acceptance.md` maps the approved scope to stable +requirement IDs, ordered gates and evidence; `tasks.md` counts only Node work. +Main specs retain the 1.0 baseline until the reviewed deltas are synced. Planning +completion is not product readiness or release authorization. + ## Contract model `openspec/specs/` describes the accepted target behavior for Cadder 1.0. A main @@ -52,12 +60,12 @@ Run these commands from the repository root: openspec doctor openspec schema validate implementation openspec validate --specs --strict -cargo xtask openspec-check +mise run openspec-check ``` -The repository-specific check verifies requirement identifiers, references, -verification coverage, and documentation boundaries that OpenSpec does not -validate itself. +Also review stable requirement IDs, task-to-acceptance references, verification +coverage and documentation boundaries explicitly; CLI schema validation does not +prove that a plan covers the requested product or that implementation exists. After every implementation change is archived, the final release gate also runs `openspec validate --all --strict` against the remaining contract changes diff --git a/openspec/changes/reset-cadder-architecture/.openspec.yaml b/openspec/changes/reset-cadder-architecture/.openspec.yaml index ccd7490..f534b2b 100644 --- a/openspec/changes/reset-cadder-architecture/.openspec.yaml +++ b/openspec/changes/reset-cadder-architecture/.openspec.yaml @@ -1,4 +1,5 @@ schema: spec-driven created: 2026-07-07 -goal: Adopt OpenSpec as the source of truth and define the target architecture - before implementation. +goal: Define the complete Cadder 2.0 TypeScript/Node migration contract, ordered + acceptance gates and equal npm/Node SEA distributions before further product + implementation, including verified removal of the Rust product. diff --git a/openspec/changes/reset-cadder-architecture/README.md b/openspec/changes/reset-cadder-architecture/README.md index f178eb8..ff4380e 100644 --- a/openspec/changes/reset-cadder-architecture/README.md +++ b/openspec/changes/reset-cadder-architecture/README.md @@ -1,8 +1,35 @@ -# reset-cadder-architecture +# Cadder 2.0 migration roadmap -Rebaseline Cadder around daemon-owned runtime state, a Caddy-compatible shim, operator clients, explicit platform boundaries, and OpenSpec-based planning. +Start with [acceptance.md](acceptance.md) for the approved-scope mapping and gate +order, then [tasks.md](tasks.md) for the current implementation task. This change +must replace the Rust product, not leave two permanent implementations. Functional +scope is the current released Rust 1.0.5 product in TypeScript, not superseded +history/IIS/autostart or machine-output workflows. Bare cadder retains help; +cadder tui retains the routes workspace and explicit --start-daemon option. -This active change now tracks the approved [Node migration](node-migration.md). -The original Rust reset is archived separately. The current released Rust 1.0.5 -workspace remains the operational baseline; the historical 0.8 behavior evidence -does not replace it or reduce the approved 2.0 scope. +| Artifact | Responsibility | +| --- | --- | +| proposal.md | User journey, full replacement scope, affected capabilities | +| node-migration.md | Approved migration constraints and exclusions | +| design.md | Node module contracts, ownership, security, packaging and cutover | +| specs/ | Stable-ID normative changes relative to current main specs | +| tasks.md | Only Node implementation checklist, grouped by dependency gate | +| acceptance.md | Approved-plan coverage, evidence levels and final completion | +| release.md | Planned consumer outcomes and major release impact | +| verification.md | Executed evidence and remaining acceptance gaps | +| behavior-baseline.md | Current and pinned historical behavior provenance | +| windows-runtime-gate.md | Isolated real Windows privilege-gate procedure | + +The original Rust reset is archived separately. Its earlier active task snapshot +is recoverable at `f850418:openspec/changes/reset-cadder-architecture/tasks.md`. +Historical Rust completions must not count as Node progress. + +G2 remains open pending product-contract completion and actual Windows privilege +acceptance. The runtime foundation is not a shippable 2.0 application. Both npm +and every standalone SEA variant must pass before Rust/Cargo/legacy packaging +removal, followed by a complete Node-only recheck. + +Follow the repository's Arcantry/OpenSpec contract -> reviewed delta sync -> +focused implementation slice -> evidence -> closeout workflow. Completing these +planning artifacts does not sync main specs, close implementation gates, update +release manifests/changelog, or authorize commit/push/tag/publication. diff --git a/openspec/changes/reset-cadder-architecture/acceptance.md b/openspec/changes/reset-cadder-architecture/acceptance.md new file mode 100644 index 0000000..418b3d5 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/acceptance.md @@ -0,0 +1,129 @@ +# Acceptance and scope ledger + +This is the implementation navigation map, not test evidence. `tasks.md` owns +checkboxes; `verification.md` owns executed results. Read this alongside +`node-migration.md`, `design.md` and the delta specs. No completion percentage +substitutes for a missing requirement. + +## Source of truth and dependencies + +The current released Rust 1.0.5 product and main specs define functional scope. +The user corrected the migration to preserve that product in TypeScript, not +restore superseded history, IIS, autostart or machine-output workflows. Older +snapshots may explain retained behavior but cannot expand the accepted scope. + +Main specs remain the 1.0 baseline until the reviewed migration deltas are synced +through the contract archive workflow before further product implementation. +Archiving those deltas accepts the target, not the unfinished implementation +roadmap. Preserve open tasks as planning. Future implementation slices reference +stable requirement IDs and record evidence rather than duplicating this roadmap. + +| Approved scope | Contract and design owner | Tasks / exit | +| --- | --- | --- | +| One TS product; no Rust/Node IPC; daemon state owner | TOP-001/002, IPC-002/003; module table | 1.3-1.5, 5.10, 8.3-8.7 | +| Installation-specific, owner-protected v2 endpoints/databases | TOP-001, RUN-001; runtime identity | 1.3, 2.5, 7.2 | +| Equal npm and standalone SEA, three entries, same version | DIST-001/002/004/005 | 6.1-6.7, 7.6, 9.2-9.4 | +| npm Node 24 LTS >=24.18.0, separate pinned Node 26 SEA builder | DIST-004, QT-001; build policy | 1.2, 6.1-6.4 | +| TS/TSX ESM; no tracked generated JS; tsc/npm and esbuild/SEA in external staging | QT-003/006 | 6.1-6.5, 7.5, 8.6 | +| Exact dependencies, Nub, TS 6.0.3, Vitest/V8, Ink tests, ESLint/Prettier | QT-001/002/003; library decisions | 1.2, 6.8, 7.1 | +| Preserve released TOML/Caddyfiles, precedence and trusted selectors | REG-004, CADDY-001/005, RUN-004; behavior catalog | 1.3, 3.1-3.2, 5.6 | +| Owner-protected secret, protocol 3/security 2, HMAC, no unsafe ACL repair | IPC-001/002, RUN-005 | 2.1-2.5, 2.7-2.9, 7.3 | +| Lifetime SQLite exclusion, diagnostic metadata, recovery after actual lock | RUN-001; lock design | 2.2, 2.5-2.9 | +| Same-owner ordinary client/elevated daemon; explicit Unix owner; deny other users/remote | RUN-005, IPC-001 | 2.7-2.9, 7.3-7.4 | +| Retain exactly eight product operations with closed Zod schemas | IPC-003; current IPC catalog | 1.3, 1.5, 2.4, 5.5 | +| Serial config queue, last-known-good and ambiguous-apply reconciliation | CADDY-003, REG-002 | 3.3-3.5, 3.9, 7.2 | +| Loopback mTLS admin, internal CA, no OS trust/plain admin/project override | CADDY-006 | 3.6-3.7, 7.3 | +| Owned Caddy only, graceful then bounded forced shutdown | CADDY-002, RUN-003 | 3.8-3.9, 7.2 | +| Retained shim command policy, argv/streams/status and heartbeat cleanup | SHIM-001/002/003, REG-001/003 | 4.1-4.3, 4.10, 6.7, 7.2 | +| Operator port/Caddyfile inspection and explicit PID revalidation | INSPECT-001..004, CLI-001 | 1.3, 4.4, 4.10, 5.6 | +| One SQLite state/log worker; no old-data import or live-session restoration | STO-001..004, REG-003 | 5.1-5.2, 9.1 | +| Bounded redaction-before-write logs, one canonical stream, CLI-only diagnostics | OBS-001..004 | 5.3-5.4, 7.1 | +| Shared client/models; real state; current lifecycle/projects/domains/inspection | TOP-002, CLI-001, TUI-001/003 | 5.5-5.10, 6.7 | +| Human-readable output; state/inspection attach-only; explicit bounded launch paths | CLI-001, RUN-002 | 4.2, 5.6-5.7 | +| Bare help with/without TTY; cadder tui and --start-daemon; offline start | CLI-001, TUI-003/004 | 5.7-5.9 | +| Routes-first TUI, tree rows, quiet header, Space/Enter and terminal recovery | TUI-001..004 | 5.8-5.9 | +| Reject profiles, machine output, history, export, tail, watch, IIS and autostart | CLI-001, TOP-002; explicit non-goals | 1.3, 5.6, 7.4-7.6 | +| Single tarball; no optional native packages/install build/download/real Caddy | DIST-004, QT-006 | 6.1-6.2, 8.2-8.4 | +| Four SEA variants, native CLI/TUI/state-log worker/child smoke without Node/npm PATH | DIST-001/002, QT-006 | 6.3-6.7 | +| Astro/Starlight docs, Nub workflow, equal verified installation guidance | DOC-001..004 | 6.8-6.9, 8.5 | +| >=85% own line coverage; contracts/fakes and native runtime/product integration | QT-003/004 | 7.1-7.3 | +| Real Windows Sandbox UAC and account-boundary acceptance, no IIS/autostart mutation | RUN-005, IPC-001 | 2.8, 7.4 | +| Both channels pass identical current-scope scenarios; clean packaging | DIST-005, QT-006 | 6.7, 7.5-7.6, 8.6 | +| Remove Rust/Cargo/old packaging after parity; preserve unrelated untracked npm content | QT-005, TOP-001 | 8.1-8.7 | +| Stop old daemon, re-register; preserve old data/releases; RC then final; separate publication authority | DIST-003/005, STO-004, QT-007 | 9.1-9.4 | +| No Bun/WASM/native runtime addons/Web/Tauri/new installers | TOP-002; non-goals | 7.5, 8.7 | + +Unmodified requirements remain binding: RUN-004, CADDY-002/004/005, REG-002/003, +OBS-001..004, TUI-002/004, DOC-003, INSPECT-001..004 and conditional QT-008. +Changing a runtime does not waive current security, provenance or audience rules. +Former IIS/AUTO/OBS-005 additions are withdrawn, not accepted product contracts. + +## Gate order and stopping rules + +| Gate | Requires | Evidence required to proceed | +| --- | --- | --- | +| G1 | Reviewed corrected plan | Complete contracts/design, released-behavior fixtures and reproducible TS tooling | +| G2 | G1 foundation | Native exclusion/recovery/authentication and owner/elevated/other-account checks, including actual Windows Sandbox | +| G3 | G2 | Real Caddy multi-project transactions, mTLS denial, rollback/reconcile and owned teardown | +| G4 | G3 | Shim fidelity, session cleanup and native operator inspection/PID revalidation | +| G5 | G4 | Real Node application journey, state/log persistence and current routes CLI/TUI parity | +| G6 | G5 | Actual packed npm and every native SEA target, matching functionality/version and verified docs | +| G7 | G6 | Complete coverage, security/system matrix and one-revision requirements ledger | +| G8 | G7 | Node-only checkout/consumers pass again after removing Rust and obsolete packaging | +| G9 | G8 | Matched RC artifacts and transition evidence; final version only after RC acceptance | + +Fake adapters prove policy/contract behavior. Native child fixtures prove runtime +mechanics. Actual packed-product tests prove distribution behavior. Interactive +Sandbox proves UAC/account boundaries. None may be relabeled as another. +If a gate lacks execution authority or a native runner, record the gap and do +safe work within that gate; do not bypass dependencies or mark it complete. +Material contract changes return for an explicit user decision. + +## Reusable functional catalog + +For npm and SEA, run the same versioned scenario data and compare observable +results, exit status, streams and final daemon state. Both must cover: + +1. Three entrypoint help/version and correct packaged daemon launch. +2. Bare help on TTY/non-TTY, cadder tui with/without --start-daemon, offline start + and terminal cleanup, with no implicit TUI on bare invocation. +3. Multi-project managed run, heartbeat, domain conflict/activation and detach. +4. Invalid update, verified reload, last-known-good, post-apply storage failure + and ambiguous apply reconciled before a later mutation. +5. Shared CLI/TUI state, human-readable output, routes-only TUI and bounded CLI + diagnostic logs; removed command/option rejection. +6. Trusted Caddy resolution with wrappers/SEA on PATH and no recursion. +7. Owner/elevated access, wrong proofs/replay/spoofed endpoint, unsafe permissions + and Caddy admin refusal without an authorized certificate. +8. Independent installation runtimes and state/log persistence without restoring + live leases or importing/deleting old Rust data. +9. Concurrent start/crash recovery, Ctrl+C, bounded stop/restart and no unrelated + process termination, including guarded operator PID semantics. +10. Node-free SEA workers/children and clean local/global npm consumer installs. + +## Evidence format and closeout + +Record requirement IDs and task/gate, exact source revision, OS/architecture, +runtime/builder/package version, command or manual scenario, artifact checksum, +actual result and unresolved/skipped cases in verification.md or a linked +implementation artifact. Do not copy private workstation/account details into +shared evidence. Recheck stale evidence after affected source changes. + +The final gate requires no unresolved current-scope parity/security/distribution +case, no Cargo runtime/build dependency and no tracked generated JS. OpenSpec +artifact status "done" means the document exists, not that the application works. +No automatic sync, archive, release-version bump or publication occurs while +correcting this plan. + +## Source-to-release outcome ledger + +| Planned outcome | Category / visibility | Components / impact | Source | +| --- | --- | --- | --- | +| Equal single-package npm/Node and Node-free downloads | Changed / developers and operators | distribution; major installation contract | DIST-001/002/004/005 | +| Explicit isolated-runtime transition without old-data import | Changed / operators | daemon/storage; major IPC/data transition | IPC-002, STO-004, DIST-003 | +| Protected Caddy administration and verified rejected-update recovery | Fixed / developers and operators | Caddy/runtime | CADDY-003/006 | + +Current CLI/TUI behavior is retained, not an Added outcome. Internal Rust +replacement is not a separate consumer changelog entry. release.md describes +these planned outcomes; release/version/changelog writes remain deferred to G9. +Current Cargo and legacy npm version sources remain unchanged. diff --git a/openspec/changes/reset-cadder-architecture/behavior-baseline.md b/openspec/changes/reset-cadder-architecture/behavior-baseline.md index 23ef184..883662d 100644 --- a/openspec/changes/reset-cadder-architecture/behavior-baseline.md +++ b/openspec/changes/reset-cadder-architecture/behavior-baseline.md @@ -1,13 +1,31 @@ # Behavior baseline and migration evidence Rust task completion is not Node implementation evidence. The source paths and -test names below refer to the historical Rust snapshot at `a10c63a`, accessible -with `git show a10c63a:`, not necessarily the current checkout. Node tests -are listed separately. After integrating `master`, the released Rust 1.0.5 -workspace is retained unchanged as the operational baseline, including its -security fixes and release tooling. Its superseding reset is archived under -`openspec/changes/archive/`; this active change retains the approved 2.0 scope, -including IIS, autostart, history and both distribution channels. +test names in the historical table below refer to Rust snapshot `a10c63a`, +accessible with `git show a10c63a:`, not necessarily the current checkout. +That snapshot is reference evidence only, not authority for functional scope. +The released Rust 1.0.5 workspace and current main specs define retained behavior, +including security fixes and release tooling. The superseded reset is archived +under `openspec/changes/archive/`; IIS, autostart, history and machine output +from older intent must not be restored by this migration. + +## Current product authority + +- `openspec/specs/operator-cli/spec.md` and + `crates/cadder-client/src/cli/mod.rs`: current grammar, bare help, human output, + explicit `tui --start-daemon` and removed-command rejection. +- `openspec/specs/operator-tui/spec.md`: routes-first tree table, no activity/log + view, quiet daemon/Caddy header, explicit lifecycle and keyboard behavior. +- `openspec/specs/local-control-plane/spec.md`: eight operations, not an expanded + history/platform RPC catalog. +- `openspec/specs/observability/spec.md`: bounded canonical-stream diagnostic logs, + without history, export, subscriptions or new all-log/source/severity filters. +- `openspec/specs/product-topology/spec.md`: independent installation runtimes. + +Task 1.3 must freeze fixtures from these current sources and the released Rust +implementation. Historical evidence below is usable only for retained behavior. + +## Historical evidence (not current scope) | Contract | Rust source/test evidence | Node evidence | | --- | --- | --- | @@ -18,8 +36,8 @@ including IIS, autostart, history and both distribution channels. | Managed shim starts missing daemon, never unmanaged fallback | `crates/cadder-shim/src/main.rs`, `run_managed_does_not_delegate_to_real_caddy_when_backend_is_missing`, `open_managed_run_target_starts_missing_daemon_when_fallback_is_skipped` | Pending Node port | | Registration heartbeat and unregister on shutdown | `crates/cadder-shim/src/main.rs`, `run_managed_registers_heartbeats_and_unregisters_on_shutdown` | Pending Node port | | Multiple-project composition and last-known-good | `crates/cadder-daemon/src/caddy.rs`, `crates/cadder-daemon/src/state/config_apply.rs` | Serial transaction and ambiguity reconciliation are new acceptance requirements; pending | -| Operator views use real daemon state | `crates/cadder-operator/src/`; current `crates/cadder` TUI contains mocks | Node CLI/TUI must use the shared client service; pending | -| IIS preview, explicit handoff and restore | `crates/cadder-daemon/src/iis.rs` and daemon IIS state modules | Pending Node provider and Sandbox acceptance | +| Operator views use real daemon state | `crates/cadder-operator/src/`; snapshot `crates/cadder` TUI contains mocks | Node CLI/TUI must use the shared client service and current routes-only scope; pending | +| Historical IIS preview, handoff and restore (superseded) | `crates/cadder-daemon/src/iis.rs` and daemon IIS state modules | Excluded; not a Node parity task | `test/fixtures/runtime-child.ts` is a real child-process fixture, not a production daemon. Its status/shutdown handlers exist solely to prove runtime exclusion, diff --git a/openspec/changes/reset-cadder-architecture/design.md b/openspec/changes/reset-cadder-architecture/design.md index f737fe4..0ab8467 100644 --- a/openspec/changes/reset-cadder-architecture/design.md +++ b/openspec/changes/reset-cadder-architecture/design.md @@ -1,228 +1,264 @@ ## Context -The approved target is now TypeScript on Node, not a Rust workspace. Read -`node-migration.md` for the current implementation decisions. Rust-specific -sections below describe the migration baseline, not the 2.0 target. Existing -behavioral requirements remain unless explicitly changed by the migration. - -Cadder coordinates Caddy-backed local development runtimes. The reset defines a -target architecture before implementation continues, so each part has one clear -reason to exist and one clear owner. - -The desired shape is a daemon-backed tool: - -- the daemon owns runtime state and external process coordination; -- compatibility shims translate familiar workflows into daemon requests; -- operator clients inspect and control the daemon through a stable local - protocol; -- optional platform integrations sit behind narrow providers. - -OpenSpec captures the product contract. Code, documentation, and release -artifacts should follow the accepted specs rather than becoming competing -sources of truth. +This is the executable design for the approved Cadder 2.0 migration. The approved +scope is summarized in `node-migration.md`; normative deltas live in `specs/`. +`tasks.md` is the only implementation checklist and `acceptance.md` is the +requirements-to-evidence ledger. `verification.md` contains actual evidence, not +future acceptance claims. + +The retained operational baseline is Rust 1.0.5 from master. The released CLI/TUI +and main specs define functional scope. `behavior-baseline.md` distinguishes +current evidence from superseded historical behavior; the historical snapshot +is not authority to restore removed features. The original Rust reset and its +checkboxes remain recoverable in the archived change and Git snapshot `f850418`; +they are not Node progress. Current Node code supplies runtime/IPC fixtures, +not product handlers, CLI/TUI or releasable distributions. ## Goals / Non-Goals -**Goals:** - -- Define a small runtime topology with explicit ownership. -- Keep runtime, integration, and presentation boundaries independently testable. -- Make daemon-offline behavior part of the normal operator contract. -- Keep platform-specific behavior isolated behind testable abstractions. -- Prefer mature libraries and tools over custom orchestration when they fit. -- Make future client surfaces reuse existing protocol and view-model contracts. - -**Non-Goals:** +**Goals:** Replace the product, not add a permanent second implementation; +preserve project and operator workflows; have one daemon state owner; keep both +distribution channels equally functional; simplify by using focused libraries +and Node standard APIs; record proof at each end-to-end gate. -- This change does not implement the architecture. -- This change does not pick every crate/module name. -- This change does not make every future user interface part of the initial - product surface. -- This change does not require Cadder to manage external processes it did not - start. +**Non-Goals:** Rust/Node interoperability, old-data import, Bun, custom WASM, +native runtime addons, Web/Tauri surfaces, new system installers, history, IIS, +autostart, machine output, profiles, export, watch, continuous tail, rewriting +unrelated tools or publishing during implementation. No fixture is a substitute +for the actual application or real Windows privilege acceptance. ## Decisions -### Runtime Ownership - -The daemon is the only Cadder component that owns durable runtime state, -generated Caddy configuration, Caddy process coordination, logs, and platform -integration state. Other components are clients or adapters. - -This keeps process ownership, recovery, and diagnostics in one place. It avoids -state drift between shims, operator clients, and real Caddy. - -### Runtime Identity - -Production mode uses one stable runtime identity per user or explicit system -installation. Multiple runtimes are a dev/debug feature and require isolated -runtime directories, visible labels, and separate locks. - -This gives normal users predictable behavior while preserving a deliberate path -for debugging and integration tests. - -### Runtime Locking - -Production daemon startup uses an operating-system file lock for exclusive -ownership and a separate readable lock metadata sidecar for diagnostics. The -lock file is the authority for exclusion. The sidecar records the owning process -id, Cadder version, protocol compatibility range, capability set, runtime -profile, runtime directory, socket name, acquisition time, and executable path. - -Keeping metadata outside the locked file lets clients report the active owner -even on platforms where an exclusive file lock prevents reading the locked byte -range. If the OS lock is free but the sidecar remains, startup treats the -sidecar as stale crash residue, replaces it, and records the recovery in daemon -logs. A clean daemon shutdown removes the sidecar when the lock guard is -released. - -### Local Control Plane - -Cadder uses a local daemon protocol with typed envelopes, requests, responses, -events, and errors. The protocol should tolerate additive compatible changes and -report unsupported capabilities explicitly. - -Compatibility is capability-first. Daemons, shims, operator clients, and future -surfaces advertise the protocol version they speak and the versioned -capabilities they provide. A version mismatch is diagnostic unless the receiving -side cannot decode the envelope or a required capability/version is missing. -When an older node participates in communication, the newer node should suggest -updating the older participant, but it must not fail an otherwise supported -operation solely because the peer version is older or newer. - -Capabilities are versioned independently from the envelope protocol. Each -capability advertises its current version and the oldest compatible capability -version still supported. This allows Cadder to keep older daemon, shim, and -operator builds working during an intentional support window instead of forcing -the whole local stack to update at once. - -Transport is an edge concern. Core protocol types stay transport-neutral. -Platform-specific transport security belongs behind a narrow policy layer. - -### Privilege Boundaries - -Normal operation should run at user privilege. Operations that require higher -privilege must be explicit, scoped, and observable from user-level clients. - -The daemon publishes local IPC discovery metadata in `cadder-ipc.json` inside -the runtime directory after the listener is bound. The metadata records the -socket name, runtime identity, protocol compatibility range, advertised -capabilities, daemon process id, endpoint privilege status, owning local -principal, and the active security policy version. - -The initial local IPC policy is account-owner based. A client running as the -same local account that owns the runtime may use documented read-only and -state-changing operations, even when the endpoint process is elevated and the -client process is not. A different local account is denied before state-changing -handlers run, and the daemon records a redacted runtime-control log entry for -the denied attempt. Unit tests exercise the policy with fake principals; later -system smoke coverage validates the platform transport and peer-credential -behavior. - -### Shim Boundary - -The Caddy-compatible shim is a routing layer, not a second daemon and not a -full Caddy clone. It must classify commands as managed, read-only inspection, -explicit passthrough, or unsupported. - -Managed commands go to the daemon. Passthrough is allowed only when it cannot -mutate Cadder-managed state silently. Unknown commands fail with useful -diagnostics. - -### Caddy Adapter - -Cadder owns the generated runtime model. Real Caddy is the execution target. -Caddy's Admin API and process commands are adapter mechanisms, not public -Cadder state. - -The adapter must prevent recursive shim execution, apply generated config -atomically where possible, and report drift when real Caddy no longer matches -Cadder's last applied model. - -### Platform Integrations - -Platform integrations, including IIS handoff, live behind provider traits or -equivalent boundaries. The daemon depends on provider contracts, not directly on -platform command scripts or OS APIs. - -This keeps policy decisions testable with fakes and reserves real OS smoke tests -for behavior that genuinely requires the platform. - -### Operator Clients - -The operator executable provides non-interactive CLI workflows and interactive -TUI workflows. Both render from daemon protocol data and shared view models. - -Clients must remain useful when the daemon is offline. Read-only flows can show -setup information, diagnostics, and last-known state where available. -State-changing flows must explain that the daemon is unavailable and offer a -start/recovery path. - -### Workspace Crate Topology - -The Rust workspace keeps a closed set of documented members for the reset: - -- `crates/cadder-daemon`: daemon runtime library. -- `crates/cadderd`: daemon binary. -- `crates/cadder-shim`: PATH-facing `caddy` shim package. -- `crates/cadder`: operator CLI/TUI executable package. -- `crates/cadder-operator`: internal operator service and view-model library. -- `crates/cadder-protocol`: shared protocol/API package. -- `xtask`: repository-specific validation tooling. - -`cadder-operator` remains a separate internal library for this change. It is -not a release-facing product surface; it exists to keep daemon access, daemon -launch policy, state shaping, and operator view models mockable across CLI and -TUI code. A future change may merge it into `cadder` only if the boundary no -longer carries an independently testable responsibility. - -Workspace membership and runtime release packages must be validated against -this topology so undocumented product crates cannot be introduced silently. - -### Logs - -The daemon owns structured log capture, query, retention, and redaction -semantics. Clients request logs by dimensions such as runtime, project, domain, -source, and severity; presentation differences must not change query semantics. - -Per-domain logs must be real domain-filtered records, not a UI label over a -broader Caddy stream. - -### Tooling - -Custom tooling should remain small. Use mature tools for testing, coverage, -release, documentation, and command orchestration when they fit the project. -Custom Rust tooling is reserved for Cadder-specific checks that external tools -do not cover clearly. +### Module ownership and contracts + +| Module | Owns | Boundary and tests | +| --- | --- | --- | +| protocol | Zod request/result/error schemas, protocol 3, HMAC framing | Pure schema/authentication tests; no storage or process imports | +| daemon | Runtime exclusion, registries, mutation queue, snapshots, RPC handlers | Fake Caddy/platform/storage ports plus real local IPC tests | +| caddy adapter | Trusted executable resolution, adapt/validate, composition, mTLS admin, owned child | Fake process/admin fixtures, followed by real Caddy integration | +| platform adapters | Filesystem permissions, Windows ACL, Unix owner and operator socket/PID inspection | Small OS-specific adapters; fake policies then native acceptance | +| storage | One application-state/diagnostic-log SQLite worker and separate lifetime lock connection | Transaction, recovery, retention and close tests | +| client service | Typed RPC, shared models, selectors and lifecycle coordination | Fake transport/model tests; no database or Caddy admin access | +| CLI | Commander grammar, formatting, attach-only policy, exit status | Consumer argument/stream/output tests | +| TUI | Ink/React rendering and user interaction through the client service | ink-testing-library fixtures and real terminal smoke | +| packaging | Staging manifest, tsc npm emission, esbuild/SEA, archives/checksums | Packed-consumer verification; no application policy duplicated here | + +Freeze method names, typed payloads, error categories, command arguments and +exit codes in a behavior/contract fixture catalog before adding product handlers. +Retain current status, daemon, projects, domains, port, Caddyfile, diagnostics, +bounded logs and TUI commands from released Rust 1.0.5. Preserve bare help on +TTY and non-TTY, explicit cadder tui --start-daemon, human-readable output and +unsupported-command rejection. The eight-operation released IPC catalog remains +the product boundary. Historical history/IIS/autostart and machine-output code +is superseded, not compatibility authority. Unknown or ambiguous current behavior +requires a recorded decision before implementation rather than an undocumented +choice. + +Zod validates all method payloads and results. Responses correlate request IDs +and contain exactly one result or typed error. The existing generic runtime +fixture schema must be tightened into the product catalog before it is treated +as a completed RPC contract. Authentication, transport, unsupported protocol, +config/conflict and storage failures remain distinct. + +### Source and dependency policy + +Track TS/TSX, tests and tool configuration. Keep ESM; do not confuse it with a +second JS application. `tsc --noEmit` checks types; Vitest/V8 measures own source +lines; `tsx` is development-only for TSX/TUI. Consumer installs need no loader, +compiler, postinstall download or build. + +Nub manages the root and documentation dependencies with exact versions and +locks. TypeScript is 6.0.3. Use Commander, Zod, smol-toml, Ink/React and +ink-testing-library for their narrow CLI/schema/config/rendering jobs instead of +custom equivalents. Use @peculiar/x509 with Node WebCrypto for certificate +creation and verification: Node provides crypto/TLS, while the library avoids +hand-written ASN.1/X.509 generation. Use node:sqlite instead of a native addon +or custom durable format; its release-candidate status is accepted. ESLint, +Prettier and Vitest/V8 own their respective checks. Do not add tsup. + +The npm runtime is Node 24 LTS, minimum 24.18.0. The SEA builder is a separately +pinned Node 26 patch. Verify and record that patch when the SEA packaging task +is executed; do not guess a patch or change the npm minimum to match it. The +same source tree and runtime schemas feed both builds. No new framework or +general-purpose task runner is needed. Retain current mise/Rust tooling while +the baseline is needed, then remove obsolete responsibilities, not blindly +translate them into custom JS. + +### Runtime ownership and security + +Use an owner-protected, installation-specific v2 directory separate from the +Rust runtime and a separate SQLite lock database. Keep separate installation +identities and verify independent endpoints/databases; owner-specific protection +must not collapse distinct installations into one runtime. A dedicated lock +connection holds `BEGIN EXCLUSIVE` for the daemon lifetime; it is not the state/log +worker. Diagnostic PID metadata, +timestamps or leases never grant ownership. Acquire the actual lock before +crash-residue recovery, then validate/create protected runtime files, bind the +endpoint and publish readiness. Track resources acquired during startup so any +failure closes only those resources and releases the lock. + +The secret comes from a cryptographic generator and stays in owner-protected +storage. Unix uses owner-only modes; Windows validates owner SID and protected +ACLs through the small PowerShell/.NET adapter. Reject unsafe existing paths, +links, owner or ACL rather than weakening/repairing security implicitly. Root on +Unix must receive an explicit owner UID and runtime directory. Same-owner +ordinary clients can authenticate to an elevated daemon; other accounts cannot. +No remote clients or cross-owner access are supported. + +NDJSON travels over Unix sockets or Windows named pipes. Enforce bounded frames, +connections and timeouts, exact protocol 3/security policy 2 and mutual HMAC +using fresh direction-bound challenges. Secret bytes never cross IPC. Validate +authenticated frame sequences and reject replay/reflection/tampering or RPC +before authentication. Do not add capability negotiation or Rust compatibility. + +G2 must prove native exclusion/crash recovery and both privilege models before +Caddy porting. The existing Windows Sandbox guide/package is preparation only; +UAC approval, cancellation, same-owner elevated contact and different-account +denial require real isolated execution, not mocked claims or host policy changes. + +### Configuration transactions and Caddy + +Preserve `cadder.toml`, Caddyfiles, argument parsing and documented precedence; +classify older executable selectors against the released trusted-source policy +in the fixture catalog. Do not reintroduce a project-controlled Caddy executable +override to emulate obsolete code. Resolve the real executable from trusted +explicit daemon configuration or safe PATH. Detect npm wrappers, SEA entrypoints +and aliases/file identity to prevent recursion. Pin the selected executable for +the daemon lifetime. Keep adaptation bounds and exact active-host/loopback guards +from CADDY-004/005; do not lose master's security fixes during the port. + +One queue owns prepare -> adapt/compose -> validate -> apply -> verify -> persist +-> publish. Project/domain activation, registration changes and reload must enter +that ownership boundary. Pure reads may use the last committed snapshot; +they cannot publish a pending state. +Validation/conflict rejection leaves the previous committed model/config intact. +If apply may have succeeded but its result is unknown, fence subsequent changes, +query the active protected Caddy state and reconcile before accepting another +mutation. If persistence fails after apply, restore and verify last-known-good; +a failed restore stays fenced and reports a recoverable diagnostic. + +Create an internal CA, server certificate and protected client certificate using +WebCrypto/X.509. Bind the admin channel to loopback with mTLS, validate server +identity and require the authorized client. Do not install this CA in OS trust. +Override/reject project admin settings before validation; no plaintext admin +listener may remain. Verify no-cert/wrong-cert denial with real Caddy. Lifecycle +control uses only the daemon's owned child handle: graceful stop, bounded wait, +then force only that child. Never scan for or kill unrelated Caddy processes. + +### Shim and platform boundaries + +Keep managed/read-only/passthrough/unsupported command policy. Preserve argument +arrays, stdin/stdout/stderr and exit codes; use direct spawning, not shell-built +commands. Managed `caddy run` may start a missing daemon, but never independent +Caddy. Session nonces own registration, heartbeat/reconnect and cleanup on +Ctrl+C, lost connection, clean exit or daemon restart. Stale sessions cannot +detach or mutate a replacement owner. + +Platform adapters are limited to runtime permissions/identity and current +operator socket/PID inspection. Same-owner contact with an explicitly elevated +daemon remains a runtime-security requirement, not an IIS feature. Real UAC, +cancellation and other-account denial acceptance runs in Windows Sandbox, never +by silently changing host accounts or policy. No IIS mutation helper, restore +metadata or autostart provider is part of this migration. + +Preserve INSPECT-001 through INSPECT-004: operator port/socket inspection and +explicit PID-revalidated termination stay in the operator process, behind small +platform adapters without native runtime addons. This does not grant the daemon +ownership of unrelated processes. + +### Storage, logs and operator surfaces + +One worker owns the Node SQLite application-state/diagnostic-log connection, +migrations, bounded jobs and orderly close. Validate schema/integrity before +mutations; +rollback transactions on failure, settle pending jobs before teardown and keep +the exclusion connection alive until resources are released. Store stable +intent and bounded diagnostic logs, never resurrect sessions or process ownership +as live. Do not read, migrate, delete or clean up the Rust database/runtime +implicitly. + +Redact before persistence or response and retain deterministic bounded streams +(baseline: 1,000 per stream, 5,000 global; queries bounded to 200). Each query +selects one canonical runtime, entrypoint/project or domain stream and returns +the newest requested rows in ascending sequence order. Preserve +actual domain attribution, not presentation-only labels. Do not add all-log, +source/severity, cursor, paging, tail, subscription, history or export filters. +Bounded logs remain explicit CLI diagnostics, not a TUI view. Terminal rendering +neutralizes control characters. + +Commander CLI and Ink/React TUI share the client service and models. Production +never uses mock data or random status. Implement current lifecycle, projects, +domains, bounded CLI logs/diagnostics and inspection commands. Preserve +human-readable output and stable error/exit behavior; +reject machine output and the other removed commands. State/inspection commands +are attach-only. Explicit daemon start/restart, managed run and cadder tui +--start-daemon retain their bounded launch paths. Bare cadder prints help without +starting TUI or daemon, on TTY and non-TTY. Ordinary cadder tui opens the routes +workspace with explicit offline start. Preserve project/domain tree rows, separate +daemon/Caddy header state, Space/Enter toggles, offline Enter-to-start, confirmed +stop and ordered restart. No separate activity, logs, history, IIS or autostart +views are added. Screens stay quiet, keyboard-operated and resize/exit safe. + +### Packaging and CI + +Every Node artifact is generated into system/runner temp outside checkout. +For npm, `tsc` emits JS with relative import-extension rewriting and a staging +manifest exposes the three bins. Pack one tarball with no platform +optionalDependencies, install scripts, generated source-side JS or real Caddy. +Test the actual tarball via local/global installs and PATH fixtures outside +checkout; publication must consume the exact artifact that passed this process. + +For SEA, esbuild bundles the same modules/entrypoints and a pinned Node 26 builds +three standalone entries plus required assets. If dispatch/shared assets are +used, prove all three invocation names and owned child/worker launches from the +archive. No spawned Node/npm lookup may be required by standalone runtime. Test +SQLite state/log workers, TUI and child processes on Windows x64, Linux x64 and +both macOS architectures with Node/npm absent from PATH. Write checksums and +retain release provenance/attestation and least-privilege staging safeguards. + +CI must reuse non-publishing pack commands for verification, enforce >=85% own +line coverage including hard runtime modules, run Windows/Linux/macOS native +integration, and gate all four SEA variants. Use one functional scenario catalog +for npm and SEA. Astro/Starlight docs move from Bun/legacy workflows to Nub and +describe both channels equally only after the documented flows pass. Artifact +identity includes exact source commit/version; npm and SEA versions match. ## Risks / Trade-offs -- Local IPC security differs by platform -> keep transport policy explicit and - test policy separately from OS smoke tests. -- Future client surfaces can drift -> require shared protocol and view-model - contracts before adding them. -- Direct real-Caddy changes can create drift -> keep generated config ownership - in the daemon and surface drift as state. -- Domain-level logs may require extra labeling or mapping -> define that mapping - in daemon-owned log semantics. -- Tooling cleanup can break CI or releases -> replace custom steps only when an - equivalent verification path exists. +- Node SQLite and experimental SEA are accepted dependencies, not permission + to waive coverage, native worker or distribution parity gates. +- Uncertain Caddy apply needs active-state reconciliation; retries alone are + unsafe and must not turn a lost response into a second uncoordinated mutation. +- Windows runtime ACL behavior needs real UAC and another account. Fake adapter + tests cannot close that gate. Missing execution authority blocks that test, + not permission to weaken policy or mark it complete. +- Removing Rust too early would leave no working product. Keeping it permanently + would fail the migration. G8 requires both complete Node channels, then proves + operation from a checkout and consumer environment without Cargo/Rust. ## Migration Plan -1. Adopt OpenSpec as the source for requirements, design decisions, and tasks. -2. Reconcile workspace crates against the accepted topology. -3. Split protocol, daemon, shim, operator, platform, and log code into testable - seams. -4. Rebuild behavior in independently verifiable slices. -5. Replace or shrink custom tooling after equivalent validation exists. -6. Update docs and release metadata after the runtime topology is implemented. +G1 establishes complete contracts/fixtures and tooling. G2 closes runtime/security +acceptance. G3 ports and verifies transactional Caddy. G4 ports shim and current +inspection/platform boundaries. G5 completes persistence and real CLI/TUI. G6 +builds and verifies both channels and docs. G7 closes cross-platform/security/parity +acceptance. G8 removes +Rust and legacy packaging and reruns the Node-only gates. G9 prepares matching +release candidates without publishing. See task dependencies and evidence in +`acceptance.md`; a checkpoint does not close the whole migration. + +Before a user switches, stop the Rust daemon. Re-register projects from existing +files. Leave old runtime data and releases intact. No IIS/autostart transition +steps apply to the released product. Downgrade uses the old matched release set, +with the Node daemon stopped; no automatic old-data import or deprecation occurs. +Commit, push, tag, registry publication and GitHub release need separate authority. ## Open Questions -- What is the exact CLI/TUI invocation model for the operator executable? -- Should privileged platform operations use a narrow helper executable or a - privileged daemon mode? -- Which exact Caddy command paths belong in each shim policy category? -- What file-size/cohesion threshold should validation enforce, and which - generated files are exempt? +There is no unresolved choice about language, ESM/compilation, Node minimum, +equal distribution rank, privilege models or the final removal of Rust. Remaining +execution-time discoveries are the exact released 1.0.5 command/DTO fixture catalog, +the verified Node 26 builder patch and native SEA asset/worker layout. Tasks 1.3, +6.3 and 6.4 own these decisions and their evidence. A discovery that changes the +approved public contract or requires a different runtime/dependency policy must +return for a user decision before implementation. diff --git a/openspec/changes/reset-cadder-architecture/node-migration.md b/openspec/changes/reset-cadder-architecture/node-migration.md index 5a68c1e..40f4274 100644 --- a/openspec/changes/reset-cadder-architecture/node-migration.md +++ b/openspec/changes/reset-cadder-architecture/node-migration.md @@ -1,21 +1,31 @@ # Cadder 2.0 Node migration +This is the approved scope summary. `design.md` defines module contracts and +decisions, `specs/` contains normative deltas, `tasks.md` is the Node-only roadmap, +and `acceptance.md` maps the approved plan to requirements, gates and evidence. +`release.md` records planned consumer outcomes; `verification.md` records only +executed results. The original Rust design is historical, not the active target. + ## Preserved contracts -The daemon owns state, configuration, logs, history and only the Caddy process it -starts. Preserve Caddyfiles, `cadder.toml`, command policy, output modes, project -registration, heartbeat, domain controls, both privilege models, IIS preview and -restore, and explicit autostart. CLI attaches without starting the daemon except -for `daemon start`; managed `caddy run` may start it. CLI and TUI share a client -service. Bare `cadder` opens TUI on a TTY and help otherwise; offline TUI exposes -an explicit start action. There are no mock production statuses. +The daemon owns state, configuration, diagnostic logs and only the Caddy process it +starts. Preserve the current released Rust 1.0.5 scope: Caddyfiles, `cadder.toml`, +command policy, human-readable output, project registration, heartbeat, domain +controls, diagnostics, bounded CLI logs and port/Caddyfile inspection. Preserve +separate installation runtimes and the current eight-operation IPC catalog. +State/inspection CLI commands attach without starting the daemon; explicit daemon +start/restart, managed `caddy run` and `cadder tui --start-daemon` retain their +bounded launch paths. CLI and TUI share a client service. Bare `cadder` prints +help on TTY and non-TTY; `cadder tui` opens the routes-first workspace with explicit +offline start and no separate activity/log views. There are no mock production +statuses. Superseded historical behavior is not the migration target. ## Changed contracts - One product with protocol, daemon, Caddy, platform, client, CLI and TUI modules. - Sources stay `.ts`/`.tsx`, ESM; `tsc --noEmit` checks types. Vitest/V8 covers at least 85% of own lines including runtime modules. `tsx` is development-only. -- npm is a single `cadder` tarball with three bins, Node 24 LTS >=24.18.0 <25. +- npm is a single `cadder` tarball with three bins, Node 24 LTS minimum 24.18.0. - GitHub Releases supplies equal standalone SEA archives: Windows x64, Linux x64, macOS x64 and arm64. All contain `cadder`, `cadderd`, `caddy`, assets and checksums. Both channels have the same version and functionality. @@ -35,7 +45,8 @@ an explicit start action. There are no mock production statuses. Same-owner user clients can contact an elevated daemon; other users cannot. - A separate SQLite database holds `BEGIN EXCLUSIVE` for process lifetime. Metadata is diagnostic only. Crash recovery starts after acquiring this lock, - never from PID checks or expiry. History uses one dedicated SQLite worker. + never from PID checks or expiry. Application state and diagnostic logs use one + dedicated SQLite worker. - One mutation queue prepares, validates, applies, reconciles and commits config. Rejection keeps last-known-good; ambiguous outcomes block further mutations until active Caddy state is reconciled. @@ -43,13 +54,14 @@ an explicit start action. There are no mock production statuses. protected client certificate; no system trust-store installation. Project configuration cannot override admin policy. Shutdown is graceful then forced against the owned process only. -- IIS mutation uses a short elevated PowerShell helper for a normal daemon; - explicitly elevated daemon mode remains supported. Preserve cancellation. ## Deferred and excluded -No mixed Rust/Node IPC, history import, Bun, custom WASM, native runtime addons, -Web UI, Tauri, or new system installers. SEA experimental status concerns its +No history, IIS, autostart, machine-output (JSON/JSONL), profiles, export, watch, +continuous tail, expanded log filters or separate TUI activity/log views. These +are not missing parity tasks; they are outside the current product scope. +No mixed Rust/Node IPC, old-data import, Bun, custom WASM, native runtime addons, +Web UI, Tauri or new system installers. SEA experimental status concerns its packaging mechanism, not distribution priority. ## Verification and transition gates @@ -57,15 +69,16 @@ packaging mechanism, not distribution priority. Follow migration tasks in order. Runtime/IPC system tests gate Caddy porting. Cover concurrent starts, crash recovery, authentication failures, replay, fake endpoints, unsafe ACLs and same-owner elevated access. Later cover multi-project -reload/rollback, heartbeat, Ctrl+C, shutdown, IIS/UAC/autostart in Windows Sandbox, -and identical npm/SEA consumer scenarios. Test SEA without Node/npm on PATH on -each native platform, including TUI, SQLite workers and subprocesses. Packaging -must leave tracked files unchanged and no generated JS in checkout. +reload/rollback, heartbeat, Ctrl+C, shutdown, native privilege boundaries in +Windows Sandbox and identical npm/SEA consumer scenarios. Test SEA without +Node/npm on PATH on each native platform, including the routes TUI, state/log +SQLite worker and subprocesses. Packaging must leave tracked files unchanged +and no generated JS in checkout. Retain Cargo, Rust code and existing release tooling until functionality and both distributions pass their gates. Do not delete the pre-existing untracked `npm/`. -Before user transition, restore old IIS handoff, disable old autostart and stop -the Rust daemon. Do not delete old runtime/history; re-register existing projects. +Before user transition, stop the Rust daemon. Do not delete old runtime data; +re-register existing projects. Prepare 2.0.0-rc.1 in both channels, then 2.0.0 after acceptance. Old releases stay available for rollback. Commit, push, tag and publication require separate authorization. Checkboxes record progress, not independent implementation proof. diff --git a/openspec/changes/reset-cadder-architecture/proposal.md b/openspec/changes/reset-cadder-architecture/proposal.md index ce4c5cc..953da33 100644 --- a/openspec/changes/reset-cadder-architecture/proposal.md +++ b/openspec/changes/reset-cadder-architecture/proposal.md @@ -1,80 +1,77 @@ ## Why -The approved 2.0 migration replaces the Rust workspace with one TypeScript/Node -product. npm and Node SEA archives on GitHub Releases are equal, required release -channels. The concrete migration contract is in `node-migration.md`; it -supersedes earlier Rust-specific implementation choices in this change. +Cadder's approved migration replaces the Rust product with TypeScript/Node to +reduce language-boundary and implementation overhead. Preserve the existing +project Caddyfile -> managed shim -> daemon -> operator journey, with the current +released Rust 1.0.5 command scope and routes-first TUI, while delivering the same +product through npm and standalone downloads. -Cadder needs a smaller and more explicit target architecture before more code is -added. The current direction must make ownership obvious: one runtime service -owns external state, while compatibility shims and operator clients attach to it -through stable local contracts. - -The target should be familiar to users of daemon-backed developer tools. A -durable daemon manages the runtime. Thin clients inspect it, configure it, and -recover from offline states without becoming independent sources of truth. +The next end-to-end capability is an authenticated, exclusively owned Node +runtime that passes the real privilege-boundary gate. That is a prerequisite, +not the finished migration. ## What Changes -- Define the product topology around: - - a daemon that owns runtime state and external process coordination; - - a Caddy-compatible shim that routes managed Caddy workflows into the daemon; - - an operator executable that exposes CLI and TUI workflows. -- Define the local control plane, runtime identity, privilege boundaries, and - daemon-unavailable behavior as product contracts. -- Define Caddy integration as an adapter boundary: Cadder owns the generated - runtime model, and real Caddy is the execution target. -- Define optional platform integrations behind narrow, testable providers. -- Treat logs as a first-class operator workflow across runtime, project, domain, - source, and severity dimensions. -- Require future operator surfaces to reuse the same daemon protocol and view - models instead of creating another state model. -- Replace ad hoc planning with OpenSpec requirements, design notes, and - implementation tasks. -- Reconcile workspace crates and tooling around small, independently testable - modules and mature external tools where they fit. +- **BREAKING** Replace the Rust application, Cargo workspace and native npm + launcher packages with one TypeScript product after parity and release gates. +- npm requires Node 24 LTS >=24.18.0; GitHub Releases provides equally required + Node SEA standalone archives without an installed Node/npm prerequisite. +- Keep the three entrypoints, project configuration, Caddyfiles, shim policy, + owner/elevated access models and installation isolation. Preserve the current + CLI/TUI scope and human-readable output through one client service, including + bare help and explicit cadder tui --start-daemon; do not restore removed features. +- **BREAKING** Isolate runtime v2 and protocol 3/security policy 2. Use mutual + HMAC over local NDJSON and a lifetime SQLite exclusion lock. Do not support + Rust/Node IPC or import old runtime data. +- Apply Caddy updates transactionally through an owned process and a protected + loopback mTLS admin channel, with last-known-good and ambiguity reconciliation. +- Build only into outside-checkout staging. Both channels must pass the same + consumer scenarios before Rust or its existing release path is removed. ## Capabilities ### New Capabilities -- `runtime-topology`: product surfaces, package boundaries, runtime ownership, - future-surface constraints, and production/dev runtime identity. -- `daemon-control-plane`: daemon lifecycle, local IPC, state ownership, - protocol compatibility, privilege boundaries, and offline handling. -- `caddy-shim-integration`: shim command policy, registration lifecycle, config - composition, real Caddy resolution, fallback policy, and drift detection. -- `windows-iis-handoff`: optional IIS discovery, handoff, restore, privilege - boundaries, provider abstraction, and system smoke coverage. -- `observability-logs`: structured log capture, retention, redaction, and query - semantics by runtime, project, domain, source, and severity. -- `operator-clients`: CLI/TUI contracts, offline UX, daemon start/setup flows, - reusable view models, and future client boundaries. -- `quality-tooling`: project workflow, code organization, dependency selection, - test strategy, coverage, CI, release, and documentation expectations. +- `caddy-shim-integration`: explicit command policy, transport/exit fidelity and + npm/SEA-aware recursion prevention (SHIM-001 through SHIM-003). ### Modified Capabilities -The current 1.0 main specs and archived Rust reset remain the released baseline. -The active 2.0 target in `node-migration.md` supersedes the earlier Rust-specific -choices in this change without marking pending Node behavior as implemented. +- `product-topology`: one Node product and daemon-owned state (TOP-001/002). +- `daemon-lifecycle`: SQLite ownership, managed launch, bounded teardown and + explicit owner/elevated access (RUN-001/002/003 and RUN-005). +- `local-control-plane`: mutual authentication and the retained eight-operation + RPC catalog without Rust compatibility (IPC-001/002/003). +- `caddy-runtime`: verified transactions and protected admin transport while + retaining trusted resolution, local routes and bounded adaptation + (CADDY-001/003/006; CADDY-002/004/005 remain required). +- `project-registration`: live session ownership and preserved project formats + (REG-001/004; REG-002/003 remain required). +- `runtime-storage`: Node SQLite state/log worker, schema validation and no + old-data migration or cleanup (STO-001 through STO-004). +- `operator-cli`: released command, human-output and invocation parity (CLI-001). +- `operator-tui`: real shared-model workflows and explicit offline start + (TUI-001/003; TUI-002/004 remain required). +- `distribution-and-upgrades`: single npm tarball, four equal SEA downloads and + explicit transition (DIST-001 through DIST-005). +- `quality-tooling`: Nub/TS tooling, native gates, release security and verified + Rust removal (QT-001 through QT-007; conditional QT-008 remains required). +- `documentation-experience`: verified equal installation paths and shared + contributor workflow (DOC-001/002/004; DOC-003 remains required). ## Impact -- Planning: - - `openspec/` becomes the canonical place for requirements, design decisions, - and implementation plans. -- Runtime architecture: - - The daemon owns Cadder-managed runtime state. - - Shims and operator clients are protocol clients. - - Platform-specific behavior is explicit and isolated behind provider seams. -- Rust workspace: - - Product crates, shared protocol/API code, test support, and tooling must be - classified and simplified against the target topology. -- Testing: - - Daemon logic, shim policy, protocol contracts, operator rendering, platform - providers, log filtering, and system smoke behavior must be independently - testable. -- Tooling: - - Large custom orchestration should shrink or move to mature tools when those - tools cover the job clearly. +The implementation touches protocol, daemon, Caddy adapter, platform adapters, +client service, CLI/TUI, packaging, CI and Astro/Starlight documentation. It +removes Cargo, Rust crates, legacy native platform packages and old release +orchestration only at the final cutover. Existing 1.0.5 releases, runtime and +database remain available for rollback. + +No history, IIS, autostart, machine-output, profiles, export, watch, continuous +tail, expanded log filters or separate TUI activity/log views are included. +No Bun, custom WASM, native runtime addons, Web UI, Tauri, new system installers +or unrelated feature expansion is included. OBS-001 through OBS-004 remain +unchanged; older historical code is evidence, not authority to restore features. +Port/Caddyfile inspection and guarded operator process control retain INSPECT-001 +through INSPECT-004. This planning update does not sync/archive specs, bump released versions, change runtime code, +commit, push, tag or publish. diff --git a/openspec/changes/reset-cadder-architecture/release.md b/openspec/changes/reset-cadder-architecture/release.md new file mode 100644 index 0000000..4a26e6c --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/release.md @@ -0,0 +1,43 @@ +--- +impact: major +components: + - distribution-and-upgrades + - daemon-lifecycle + - operator-cli + - operator-tui +audiences: + - developers + - operators +observable_impact: user-felt +changelog: include +--- + +These are planned 2.0 outcomes, not shipped functionality. Both channels target +2.0.0-rc.1 first and 2.0.0 only after acceptance and separate publication approval. + +## Changed + +### Install one application through npm or standalone downloads + +Cadder 2.0 will provide the same operator, daemon and Caddy shim through one npm +package requiring Node 24 LTS >=24.18.0 or standalone GitHub downloads requiring +no installed Node/npm. Upstream Caddy will remain a separate prerequisite. + +### Switch runtimes without deleting the previous installation's data + +Before switching, users will stop the previous daemon. Projects will register +again from their existing configuration files. Previous runtime data and releases +will remain available, but the new daemon will not import the old database or +communicate with the Rust daemon. + +The current command scope will remain: bare `cadder` prints help, `cadder tui` +opens the routes workspace, and `cadder tui --start-daemon` starts or attaches +before opening it. Logs and diagnostics remain explicit CLI commands. The +migration will not add history, IIS, autostart or machine-output commands. + +## Fixed + +### Keep Caddy administration within the authenticated daemon boundary + +Cadder 2.0 will protect its local Caddy administration channel with mutual TLS +and retain the previous verified configuration when an update is rejected. diff --git a/openspec/changes/reset-cadder-architecture/specs/caddy-runtime/spec.md b/openspec/changes/reset-cadder-architecture/specs/caddy-runtime/spec.md new file mode 100644 index 0000000..02f24d3 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/caddy-runtime/spec.md @@ -0,0 +1,37 @@ +## MODIFIED Requirements + +### Requirement: CADDY-001: Real Caddy comes only from trusted sources +The daemon SHALL select real Caddy from a trusted explicit override/configuration or safe PATH, pin the executable for its lifetime and reject untrusted project/shim selectors. Resolution SHALL recognize npm wrappers, SEA entries, aliases and file identity to avoid recursive Cadder execution. + +#### Scenario: PATH starts with the npm or SEA shim +- **WHEN** an executable candidate resolves to a Cadder wrapper or entrypoint +- **THEN** resolution SHALL skip or reject it and continue safely or fail clearly +- **AND** it SHALL NOT recursively launch the shim + +### Requirement: CADDY-003: Runtime transitions are verified +One queue SHALL serialize preparation, adaptation/composition, validation, apply, active-state verification, SQLite commit and publication. Rejection SHALL retain last-known-good. Persistence failure after apply SHALL restore and verify the previous configuration; failed rollback SHALL fence further mutations. An ambiguous apply outcome SHALL require active-state reconciliation before the next mutation. Pending state SHALL NOT become authoritative prematurely. + +#### Scenario: Apply response is lost +- **WHEN** the daemon cannot determine whether Caddy applied a candidate +- **THEN** it SHALL fence later changes and reconcile active state +- **AND** only verified, persisted state SHALL be published as committed + +#### Scenario: Database commit fails after successful apply +- **WHEN** Caddy accepted a candidate but persistence fails +- **THEN** the daemon SHALL restore last-known-good and retain prior state +- **AND** failed restore SHALL remain fenced with a recoverable diagnostic + +## ADDED Requirements + +### Requirement: CADDY-006: Administration uses protected loopback mutual TLS +The daemon-Caddy admin channel SHALL use loopback mTLS with an internal CA, validated server identity and an owner-protected authorized client certificate. The CA SHALL NOT be installed in system trust. Plaintext local admin SHALL be disabled, and project input SHALL NOT override the administration policy. + +#### Scenario: Caller has no authorized certificate +- **WHEN** a caller contacts Caddy admin without the correct client certificate +- **THEN** Caddy SHALL refuse administrative access +- **AND** there SHALL be no unprotected listener fallback + +#### Scenario: Project supplies admin settings +- **WHEN** project configuration attempts to replace admin transport or trust +- **THEN** the daemon SHALL reject or override it with the protected policy +- **AND** no system trust-store mutation SHALL be needed diff --git a/openspec/changes/reset-cadder-architecture/specs/caddy-shim-integration/spec.md b/openspec/changes/reset-cadder-architecture/specs/caddy-shim-integration/spec.md index ff3af86..e76a0ae 100644 --- a/openspec/changes/reset-cadder-architecture/specs/caddy-shim-integration/spec.md +++ b/openspec/changes/reset-cadder-architecture/specs/caddy-shim-integration/spec.md @@ -1,65 +1,24 @@ ## ADDED Requirements -### Requirement: Shim command policy is explicit -The `caddy` shim SHALL classify every supported Caddy command path as managed, -read-only inspection, explicit passthrough, or unsupported. +### Requirement: SHIM-001: Shim policy preserves command fidelity +The shim SHALL classify commands as managed, read-only inspection, explicit passthrough or unsupported and preserve argument arrays, standard streams and exit codes. Unknown commands SHALL fail rather than silently pass through. -#### Scenario: Managed registration command -- **WHEN** a command provides a Caddyfile, config, or project definition that Cadder manages -- **THEN** the shim SHALL send the definition to `cadderd` -- **AND** it SHALL NOT start a separate real Caddy process +#### Scenario: Explicit passthrough command exits +- **WHEN** a supported passthrough command completes +- **THEN** the shim SHALL preserve its output streams and exit status +- **AND** it SHALL NOT mutate daemon-managed state through an undocumented path -#### Scenario: Unsupported command -- **WHEN** a user invokes a Caddy command that has no policy entry -- **THEN** the shim SHALL return a clear unsupported-command diagnostic -- **AND** it SHALL NOT silently pass the command to real Caddy +### Requirement: SHIM-002: Managed run never starts independent Caddy +Managed caddy run SHALL use daemon registration/heartbeat and MAY start a missing packaged daemon. Failure SHALL not become an independent Caddy run. Read-only inspection MAY use safely resolved real Caddy and SHALL identify its result as inspection rather than daemon state. -### Requirement: Shim handles daemon unavailable states -The shim SHALL give useful diagnostics when `cadderd` is unavailable and SHALL -avoid state drift during fallback behavior. +#### Scenario: Managed startup fails +- **WHEN** the daemon cannot become ready for managed run +- **THEN** the shim SHALL fail with recovery guidance without unmanaged fallback -#### Scenario: Managed command while daemon is down -- **WHEN** the shim receives a managed command and cannot contact `cadderd` -- **THEN** it SHALL explain that Cadder cannot update managed runtime state -- **AND** it SHALL suggest the supported `cadder daemon start` recovery path +### Requirement: SHIM-003: Both distributions avoid recursive resolution +Real Caddy resolution SHALL detect npm wrappers, SEA entries and equivalent file/alias identities, preventing recursion in local/global npm and standalone PATH layouts. The real upstream server SHALL remain separately installed. -#### Scenario: Read-only command while daemon is down -- **WHEN** the shim receives a read-only inspection command and cannot contact `cadderd` -- **THEN** it MAY call real Caddy only if the command cannot mutate Cadder-managed state -- **AND** it SHALL label the result as real-Caddy inspection rather than Cadder runtime state - -### Requirement: Shim prevents recursive real-Caddy execution -The shim SHALL resolve real Caddy without recursively executing itself. - -#### Scenario: Real Caddy command configured -- **WHEN** `CADDER_CADDY_REAL_COMMAND` is set -- **THEN** the shim SHALL use that command as the first real-Caddy candidate -- **AND** it SHALL reject candidates that resolve to the shim binary - -#### Scenario: PATH contains shim before real Caddy -- **WHEN** PATH lookup finds the shim binary before real Caddy -- **THEN** the shim SHALL skip itself -- **AND** it SHALL continue searching or return a clear real-Caddy-not-found diagnostic - -### Requirement: Cadder owns effective Caddy config composition -`cadderd` SHALL compose the effective Caddy configuration from Cadder runtime -state and SHALL apply it atomically to real Caddy. - -#### Scenario: Multiple projects register definitions -- **WHEN** multiple projects register Caddy definitions -- **THEN** `cadderd` SHALL merge them into one effective runtime model -- **AND** it SHALL apply the resulting Caddy config as a single coherent update - -#### Scenario: Caddy rejects generated config -- **WHEN** real Caddy rejects a generated config update -- **THEN** `cadderd` SHALL keep or restore the last known good runtime state -- **AND** it SHALL report the validation error to clients and logs - -### Requirement: Direct Caddy Admin API drift is detectable -Cadder SHALL treat Caddy Admin API state as adapter state and SHALL detect or -repair drift when possible. - -#### Scenario: Caddy config changes outside Cadder -- **WHEN** real Caddy active config no longer matches Cadder's last applied config identity -- **THEN** `cadderd` SHALL mark runtime state as drifted -- **AND** it SHALL offer a controlled reapply or reconcile action +#### Scenario: Globally installed shim precedes upstream Caddy +- **WHEN** PATH resolves Cadder before upstream Caddy +- **THEN** the shim SHALL skip its own wrappers/entries and find a trusted candidate +- **AND** failure SHALL be bounded without recursively spawning Cadder diff --git a/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md b/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md deleted file mode 100644 index df99b74..0000000 --- a/openspec/changes/reset-cadder-architecture/specs/daemon-control-plane/spec.md +++ /dev/null @@ -1,148 +0,0 @@ -## ADDED Requirements - -### Requirement: Daemon owns external state -`cadderd` SHALL be the only Cadder component that owns durable runtime state, -generated Caddy config, Caddy process lifecycle, and OS integration state. - -#### Scenario: Applying project definitions -- **WHEN** a project definition changes through the shim or operator client -- **THEN** `cadderd` SHALL serialize preparation, validation, apply and commit -- **AND** `cadderd` SHALL keep last-known-good state after rejection and reconcile ambiguous results before another mutation - -#### Scenario: External Caddy process exists -- **WHEN** an unrelated Caddy process is already running -- **THEN** `cadderd` SHALL NOT enumerate, terminate, or mutate that process -- **AND** diagnostics SHALL distinguish unmanaged Caddy from Cadder-owned Caddy - -### Requirement: Local IPC is stable and version tolerant -The daemon control plane SHALL use a local protocol with typed envelopes, -request/response messages, events, and errors that tolerate additive compatible -changes. - -#### Scenario: Older compatible client sends a request -- **WHEN** an older compatible client sends a request with known required fields -- **THEN** `cadderd` SHALL process the request even if newer optional fields exist in the current schema -- **AND** the response SHALL include capability information needed by the client - -#### Scenario: Newer compatible client sends a request -- **WHEN** a newer client sends a request whose envelope can be decoded -- **AND** every required capability and capability version is supported by `cadderd` -- **THEN** `cadderd` SHALL process the request -- **AND** it SHALL NOT reject the request solely because the client's protocol version is newer - -#### Scenario: Older node participates in communication -- **WHEN** one communication participant advertises an older protocol or capability version -- **THEN** the newer participant SHALL suggest updating the older node where useful -- **AND** communication SHALL continue when all required capability versions are covered - -#### Scenario: Incompatible client sends a request -- **WHEN** a client uses an unsupported protocol or required capability -- **THEN** `cadderd` SHALL reject the request with a typed compatibility error -- **AND** the error SHALL include upgrade or downgrade guidance where possible - -### Requirement: Capabilities are versioned independently -Cadder protocol participants SHALL advertise capabilities with a current -capability version and the oldest compatible capability version still supported. - -#### Scenario: Required capability version is supported -- **WHEN** a client requires a capability version within the advertised compatible range -- **THEN** the receiver SHALL treat the capability as supported -- **AND** it SHALL allow the operation even when the peer protocol version differs - -#### Scenario: Required capability version is unsupported -- **WHEN** a client requires a capability version outside the advertised compatible range -- **THEN** the receiver SHALL reject the operation with a typed unsupported-capability error -- **AND** the error SHALL identify the required capability version and advertised supported versions - -### Requirement: Client access to privileged runtime is explicit -User-level clients SHALL discover and contact a daemon or helper that runs with -higher privilege than the caller through a documented local IPC security policy -without requiring the whole client process to run with elevated privileges. -The daemon SHALL publish endpoint discovery metadata that includes runtime -identity, socket identity, protocol compatibility, advertised capabilities, -endpoint privilege status, owning principal, and the policy version used for -local authorization. - -#### Scenario: User client contacts privileged runtime -- **WHEN** a same-user non-elevated `cadder` client starts while the runtime endpoint has higher privileges -- **THEN** the client SHALL discover the supported endpoint -- **AND** the local IPC policy SHALL allow only the documented principals and operations - -#### Scenario: Runtime endpoint metadata is published -- **WHEN** `cadderd` binds the local IPC listener for a runtime -- **THEN** it SHALL publish discovery metadata in the runtime directory -- **AND** the metadata SHALL describe the socket name, runtime profile, protocol compatibility range, capabilities, privilege status, owning principal, and security policy version - -#### Scenario: Unauthorized user contacts daemon -- **WHEN** a different unauthorized user tries to contact the daemon endpoint -- **THEN** the IPC layer SHALL deny access before state-changing commands are executed -- **AND** the daemon SHALL log the denied attempt without leaking secrets - -#### Scenario: IPC policy is tested without OS mutation -- **WHEN** the IPC security policy is tested -- **THEN** allowed and denied principals SHALL be covered without requiring real platform mutation in every test -- **AND** platform smoke tests SHALL cover the real transport behavior separately - -### Requirement: Daemon lifecycle is user friendly -CLI, TUI, and shim flows SHALL detect daemon-unavailable states and return -actionable recovery guidance instead of opaque connection failures. - -#### Scenario: State-changing command while daemon is down -- **WHEN** a user runs a state-changing `cadder` command and no daemon is reachable -- **THEN** the client SHALL explain that `cadderd` is not running -- **AND** it SHALL offer the supported start command or TUI action - -#### Scenario: Read-only command while daemon is down -- **WHEN** a user runs a read-only command and no daemon is reachable -- **THEN** the client SHALL show daemon-unavailable status -- **AND** it MAY show local setup information or last-known cached state when available - -### Requirement: Runtime locking has stale recovery -Production daemon startup SHALL use a lock mechanism with explicit ownership, -diagnostics, and stale-lock recovery. - -#### Scenario: Stale lock after crash -- **WHEN** a daemon acquires the exclusive SQLite runtime lock and stale metadata remains -- **THEN** `cadderd` SHALL recover metadata and endpoint residue only after acquiring that lock -- **AND** it SHALL record the recovery in daemon logs - -#### Scenario: Metadata does not grant runtime ownership -- **WHEN** PID or timestamp metadata suggests an expired owner but SQLite remains locked -- **THEN** a competing daemon SHALL refuse startup -- **AND** it SHALL NOT delete an endpoint or override exclusion based on diagnostic metadata - -#### Scenario: Lock owner metadata is readable while lock is active -- **WHEN** a production runtime owns the daemon lock -- **THEN** `cadderd` SHALL publish lock owner metadata outside the locked byte range -- **AND** diagnostics SHALL include the owner process id, Cadder version, protocol compatibility range, runtime profile, runtime directory, socket name, acquisition time, and executable path when available - -#### Scenario: Active incompatible runtime -- **WHEN** a lock is owned by an active incompatible runtime -- **THEN** `cadderd` SHALL refuse to start a competing production runtime -- **AND** it SHALL report the active runtime identity and recovery options - -### Requirement: Protocol 3 uses authenticated owner-local transport -The v2 runtime SHALL use protocol 3 over bounded NDJSON on Unix sockets or Windows -named pipes, with security policy 2. A cryptographic secret SHALL be protected by -owner-only permissions or Windows ACLs. Both peers SHALL authenticate with fresh, -direction-bound HMAC challenges before any RPC. Rust protocol is not supported. - -#### Scenario: Fake endpoint or wrong secret -- **WHEN** a peer cannot prove knowledge of the protected secret -- **THEN** the connection SHALL close before RPC handlers execute -- **AND** neither peer SHALL transmit secret bytes - -#### Scenario: Replay or reflected proof -- **WHEN** an earlier handshake proof or authenticated frame is replayed -- **THEN** authentication or sequence validation SHALL reject it -- **AND** runtime state SHALL remain unchanged - -#### Scenario: Unsafe runtime permissions -- **WHEN** existing runtime permissions, ownership or ACLs allow another account -- **THEN** startup and client contact SHALL fail closed -- **AND** startup SHALL NOT repair or weaken the existing security boundary silently - -#### Scenario: Root starts a user-owned runtime -- **WHEN** root starts the daemon on Unix -- **THEN** startup SHALL require an explicit runtime directory and owner UID -- **AND** protected files and the socket SHALL remain accessible to that owner without elevating the client diff --git a/openspec/changes/reset-cadder-architecture/specs/daemon-lifecycle/spec.md b/openspec/changes/reset-cadder-architecture/specs/daemon-lifecycle/spec.md new file mode 100644 index 0000000..d82be92 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/daemon-lifecycle/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: RUN-001: One installation admits one daemon +One owner runtime v2 SHALL admit one daemon through a separate SQLite lock database holding BEGIN EXCLUSIVE for the process lifetime. Metadata SHALL be diagnostic only. Startup SHALL acquire the actual lock before crash recovery, bind/publish readiness only after protected startup succeeds, and release acquired resources on failure. PID checks or expiring leases SHALL NOT grant ownership. + +#### Scenario: Concurrent start after a crash +- **WHEN** two starts encounter stale or misleading owner metadata +- **THEN** only the actual SQLite lock owner SHALL recover residue and become ready +- **AND** the other SHALL observe the existing owner or report bounded contention + +### Requirement: RUN-002: Managed run may start the daemon +Managed caddy run SHALL use the version-matched packaged daemon and MAY start it when missing through the supported npm or SEA path and authenticated readiness. It SHALL NOT discover an unrelated daemon through PATH or start independent Caddy. State/inspection CLI commands SHALL remain attach-only; explicit daemon start/restart and cadder tui --start-daemon SHALL retain the released bounded launch behavior. + +#### Scenario: Packaged daemon cannot start +- **WHEN** the packaged daemon fails to become authenticated and ready +- **THEN** the shim SHALL report the failure without unmanaged Caddy fallback +- **AND** ordinary inspection commands SHALL NOT implicitly start a daemon + +### Requirement: RUN-003: Shutdown is bounded and owned +Shutdown SHALL stop admission, settle or revoke owned work, close the application-state and diagnostic-log worker, gracefully stop the owned Caddy child, then force only that child after bounded deadlines. It SHALL release endpoint resources and the SQLite exclusion connection only after owned teardown. It SHALL NOT enumerate or kill unrelated Caddy processes. + +#### Scenario: Graceful child shutdown stalls +- **WHEN** the owned Caddy child exceeds the graceful deadline +- **THEN** the daemon SHALL terminate only its owned process and complete teardown +- **AND** unrelated Caddy processes SHALL remain untouched + +## ADDED Requirements + +### Requirement: RUN-005: Owner clients can contact explicitly elevated daemons +Normal operation SHALL run at user privilege. A same-owner ordinary client SHALL retain authenticated access to an explicitly elevated daemon. Unix root startup SHALL require an explicit runtime directory and owner UID. Other users and remote clients SHALL be denied without weakening filesystem or ACL policy. + +#### Scenario: Ordinary owner contacts elevated endpoint +- **WHEN** the ordinary owner authenticates to an explicitly elevated daemon +- **THEN** documented operations SHALL remain available under the same policy +- **AND** a different account SHALL be rejected before product handlers execute diff --git a/openspec/changes/reset-cadder-architecture/specs/distribution-and-upgrades/spec.md b/openspec/changes/reset-cadder-architecture/specs/distribution-and-upgrades/spec.md new file mode 100644 index 0000000..5b13a86 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/distribution-and-upgrades/spec.md @@ -0,0 +1,45 @@ +## RENAMED Requirements + +- FROM: `### Requirement: DIST-004: npm installs the coherent native application` +- TO: `### Requirement: DIST-004: npm installs one Node application package` + +## MODIFIED Requirements + +### Requirement: DIST-001: Release binaries are version matched +Cadder SHALL have equal, required npm and Node SEA GitHub Release channels with the same version and cadder/cadderd/caddy functionality. Standalone targets SHALL include Windows x64, Linux x64, macOS x64 and macOS arm64. Both channels SHALL pass acceptance before migration completion or final release readiness. + +#### Scenario: One channel is ready +- **WHEN** npm passes but any supported standalone variant remains unverified +- **THEN** the migration SHALL remain incomplete rather than treating SEA as optional + +### Requirement: DIST-002: Archives are portable and verifiable +Each standalone archive SHALL contain three version-matched Node SEA entries and required accepted assets, a SHA-256 checksum and release-source provenance. Every target SHALL be extracted outside checkout and pass native CLI/TUI, state/log SQLite worker and owned subprocess smoke without Node/npm on PATH. Standalone SHALL NOT require installed Node, download runtime components or bundle real Caddy. Existing release attestation verification SHALL bind assets to their exact tag/source commit/repository/signer before publication. + +#### Scenario: Consumer has no Node installation +- **WHEN** a supported standalone archive is used with Node/npm absent from PATH +- **THEN** all three entries, TUI, SQLite worker and owned child workflows SHALL work +- **AND** checksum/provenance verification SHALL apply to the exact tested archive + +### Requirement: DIST-003: Upgrades replace the coherent executable set +Transition guidance SHALL require stopping the old daemon before switching the coherent release set. Node SHALL re-register projects from existing files without importing or deleting old runtime data. Old releases SHALL remain available for rollback without automatic deprecation. Mixed Rust/Node IPC SHALL not be supported. Transition SHALL NOT require IIS handoff or autostart operations absent from the released product. + +#### Scenario: User switches to Node and later rolls back +- **WHEN** the user follows the documented transition with each daemon stopped +- **THEN** existing configuration and old data/releases SHALL remain available +- **AND** rollback SHALL use the matched old set, not mixed IPC or imported old data + +### Requirement: DIST-004: npm installs one Node application package +npm SHALL deliver one cadder tarball with three bin entries on Node 24 LTS, minimum 24.18.0. It SHALL contain compiled JS and required assets from the shared TS sources, not platform optionalDependencies or native Rust executables. Installation SHALL require no TS loader/compiler, install-time build, postinstall runtime download or bundled upstream Caddy. Local/global installs SHALL preserve arguments, streams, status and non-recursive PATH behavior. + +#### Scenario: Clean consumer installs the packed tarball +- **WHEN** a supported consumer installs locally or globally without build tools +- **THEN** cadder, cadderd and the caddy shim SHALL run from the one package +- **AND** upstream Caddy SHALL remain a separately configured prerequisite + +### Requirement: DIST-005: npm and portable archives share release identity +npm and SEA SHALL be built from the same source revision with one matching release version and pass the same functional catalog. Their artifact bytes need not match because packaging differs. A verified 2.0.0-rc.1 in both channels SHALL precede 2.0.0 acceptance. Tagging and publication SHALL require separate explicit authorization; artifact preparation alone SHALL NOT publish either channel. + +#### Scenario: Candidate channels are compared +- **WHEN** packed npm and extracted SEA are accepted for a candidate +- **THEN** source revision, reported version and observable functional outcomes SHALL match +- **AND** no publication SHALL occur without its separate authority diff --git a/openspec/changes/reset-cadder-architecture/specs/documentation-experience/spec.md b/openspec/changes/reset-cadder-architecture/specs/documentation-experience/spec.md new file mode 100644 index 0000000..ca743d5 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/documentation-experience/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: DOC-001: User documentation follows the retained journey +Astro/Starlight documentation SHALL describe verified Node routes-first TUI, existing CLI lifecycle/project/domain/inspection commands, bounded diagnostic logs and coherent transition workflows in English, without exposing private/test-only context. Published 2.0 instructions SHALL only advertise flows that pass actual product acceptance. + +#### Scenario: User follows a Node guide +- **WHEN** a user follows a documented 2.0 workflow +- **THEN** commands SHALL exist in the tested product rather than fixture handlers +- **AND** old Rust data SHALL remain protected during the documented transition + +### Requirement: DOC-002: Contributor tooling is direct and reproducible +Contributor documentation SHALL use the exact Node/Nub TS, OpenSpec, Astro, coverage and npm/SEA workflow, replacing Bun and obsolete Rust tasks after the cutover gate. Sources SHALL remain TS/TSX and generated artifacts SHALL not be committed. SEA's separate Node builder SHALL not change npm's Node minimum. + +#### Scenario: Contributor prepares artifacts +- **WHEN** a contributor follows the documented pack verification workflow +- **THEN** both channels SHALL use the shared sources and outside-checkout staging +- **AND** no loader/build SHALL be required during consumer npm installation + +### Requirement: DOC-004: Installation guidance distinguishes npm, Cadder, and Caddy +Verified npm and standalone GitHub downloads SHALL be presented as equal channels with the same functionality/version. npm SHALL document Node 24 LTS minimum 24.18.0; standalone SHALL require no installed Node/npm. Both SHALL identify upstream Caddy as separate. Unpublished/unverified versions SHALL not be presented as available, and SEA's mechanism status SHALL not demote its channel. + +#### Scenario: Consumer chooses standalone +- **WHEN** the consumer selects a supported GitHub archive instead of npm +- **THEN** instructions SHALL offer the same product workflow without Node installation +- **AND** neither channel SHALL be described as secondary or optional diff --git a/openspec/changes/reset-cadder-architecture/specs/local-control-plane/spec.md b/openspec/changes/reset-cadder-architecture/specs/local-control-plane/spec.md new file mode 100644 index 0000000..ea9f6b5 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/local-control-plane/spec.md @@ -0,0 +1,30 @@ +## MODIFIED Requirements + +### Requirement: IPC-001: Local transport is owner authenticated and bounded +Cadder SHALL use NDJSON frames no larger than 1 MiB over Unix sockets or Windows named pipes with bounded connections/timeouts, owner-only protected runtime/secret storage and security policy 2. Both peers SHALL authenticate using fresh direction-bound HMAC challenges before RPC. Secret bytes SHALL NOT cross IPC. Unsafe ownership, links, modes or ACLs SHALL fail closed rather than be silently repaired or weakened. + +#### Scenario: Spoofed endpoint or unsafe secret file +- **WHEN** endpoint proof fails or the secret/runtime protection is unsafe +- **THEN** the client or daemon SHALL reject contact without performing RPC +- **AND** it SHALL NOT transmit the secret or repair the unsafe boundary implicitly + +#### Scenario: Replay or reflected proof +- **WHEN** a prior proof or authenticated RPC frame is replayed or reflected +- **THEN** fresh challenge or directional sequence validation SHALL reject it +- **AND** no product mutation SHALL execute + +### Requirement: IPC-002: Handshake requires one exact version +Every connection SHALL validate protocol 3 and security policy 2 during mutual authentication and SHALL reject mismatches before product dispatch. The Node product SHALL NOT communicate with Rust IPC or negotiate legacy capabilities. + +#### Scenario: Rust daemon remains running +- **WHEN** a Node client encounters an old Rust endpoint or incompatible peer +- **THEN** it SHALL NOT attach or mutate it +- **AND** isolated v2 discovery SHALL preserve the old runtime and its data + +### Requirement: IPC-003: One typed envelope carries eight operations +RPC SHALL preserve the eight released operations: register, unregister, heartbeat, query state, set entrypoint activation, set domain activation, query logs and shutdown. Zod SHALL validate their closed method/payload/result/error catalog; unknown methods/fields SHALL be rejected. Each response SHALL correlate the request and contain exactly one result or typed error. CLI/TUI SHALL use one shared client service. Migration SHALL NOT add history, IIS, autostart or presentation-specific RPC methods. + +#### Scenario: Malformed product request +- **WHEN** a request violates its method schema or names an unknown method +- **THEN** it SHALL receive a bounded typed rejection without invoking a handler +- **AND** valid responses SHALL contain exactly one correlated outcome diff --git a/openspec/changes/reset-cadder-architecture/specs/observability-logs/spec.md b/openspec/changes/reset-cadder-architecture/specs/observability-logs/spec.md deleted file mode 100644 index 42de307..0000000 --- a/openspec/changes/reset-cadder-architecture/specs/observability-logs/spec.md +++ /dev/null @@ -1,53 +0,0 @@ -## ADDED Requirements - -### Requirement: Logs are structured and labeled -`cadderd` SHALL capture daemon, shim, and real Caddy logs into a structured log -model with timestamp, source, severity, runtime, project, domain, and redaction -metadata where applicable. - -#### Scenario: Caddy emits an access log -- **WHEN** real Caddy emits an access log for a Cadder-managed route -- **THEN** `cadderd` SHALL store or stream the log with project and domain labels -- **AND** clients SHALL be able to identify it as Caddy-sourced - -#### Scenario: Shim emits a diagnostic -- **WHEN** the shim rejects a command or cannot contact the daemon -- **THEN** the diagnostic SHALL be available in the structured log stream when the daemon can receive it -- **AND** secrets SHALL be redacted before persistence - -### Requirement: Logs can be queried by operator dimensions -Operator clients SHALL be able to query and tail logs by all logs, runtime, -project, domain, source, and severity. - -#### Scenario: User tails all logs at debug level -- **WHEN** a user requests all logs with debug-level visibility -- **THEN** the client SHALL receive daemon, shim, and Caddy logs allowed by the retention policy -- **AND** the daemon SHALL apply one canonical severity filter - -#### Scenario: User tails logs for one domain -- **WHEN** a user requests logs for a specific domain -- **THEN** the daemon SHALL return only log records mapped to that domain -- **AND** the result SHALL be correct even when one Caddy site contains multiple domains - -### Requirement: Log filtering semantics are canonical -The daemon SHALL own severity and dimension filtering semantics so every client -surface displays equivalent results for the same query. - -#### Scenario: CLI and TUI run equivalent queries -- **WHEN** CLI and TUI request the same project logs at the same severity -- **THEN** both clients SHALL receive equivalent records in the same canonical order -- **AND** differences SHALL be limited to presentation - -#### Scenario: Invalid log level requested -- **WHEN** a client requests an unsupported log level -- **THEN** the daemon SHALL reject the query with a typed validation error -- **AND** the client SHALL display supported levels - -### Requirement: Log retention is bounded and configurable -Cadder SHALL bound local log retention and SHALL expose retention status to -operator clients. - -#### Scenario: Retention limit reached -- **WHEN** retained logs exceed the configured storage policy -- **THEN** `cadderd` SHALL prune or rotate logs according to the policy -- **AND** it SHALL preserve recent records needed for diagnostics diff --git a/openspec/changes/reset-cadder-architecture/specs/operator-cli/spec.md b/openspec/changes/reset-cadder-architecture/specs/operator-cli/spec.md new file mode 100644 index 0000000..8374ca1 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/operator-cli/spec.md @@ -0,0 +1,21 @@ +## MODIFIED Requirements + +### Requirement: CLI-001: The operator exposes focused developer workflows +Commander-based cadder SHALL preserve the released 1.0.5 help/version, status, daemon lifecycle, project/domain controls, port/Caddyfile inspection, diagnostics and bounded redacted log commands, with stable arguments, human-readable output and exit status through the shared client service. Bare cadder SHALL print help successfully without starting TUI or the daemon, regardless of TTY. cadder tui SHALL open the routes workspace without implicit daemon startup; its explicit --start-daemon option SHALL use the shared attach-first bounded launch contract. Explicit daemon start/restart and managed caddy run SHALL retain their launch policy. Other state and inspection commands SHALL remain attach-only. Profile, machine-output, history, export, continuous tail, watch, IIS and autostart commands SHALL be rejected as unsupported. + +#### Scenario: Bare invocation +- **WHEN** cadder has no subcommand, with or without a TTY +- **THEN** it SHALL print help successfully without starting a daemon or interactive session + +#### Scenario: Explicit TUI startup option +- **WHEN** the user runs cadder tui --start-daemon +- **THEN** Cadder SHALL start or attach through the bounded shared launch path before opening the TUI + +#### Scenario: CLI state command while daemon is offline +- **WHEN** a state command cannot attach +- **THEN** it SHALL report actionable offline state with the stable exit contract +- **AND** it SHALL NOT start a daemon implicitly + +#### Scenario: Unsupported expansion is requested +- **WHEN** the user requests machine output, history, export, continuous tail, watch, profiles, IIS or autostart +- **THEN** Cadder SHALL reject the unsupported input rather than restoring an older command surface diff --git a/openspec/changes/reset-cadder-architecture/specs/operator-clients/spec.md b/openspec/changes/reset-cadder-architecture/specs/operator-clients/spec.md deleted file mode 100644 index a65a239..0000000 --- a/openspec/changes/reset-cadder-architecture/specs/operator-clients/spec.md +++ /dev/null @@ -1,66 +0,0 @@ -## ADDED Requirements - -### Requirement: `cadder` exposes CLI and TUI contact surfaces -The `cadder` executable SHALL provide CLI and TUI workflows for inspecting, -starting, configuring, and managing Cadder through the daemon protocol. - -#### Scenario: CLI command reads daemon state -- **WHEN** a user runs a CLI status command -- **THEN** `cadder` SHALL request state through the daemon protocol -- **AND** it SHALL render a deterministic, script-friendly result when requested - -#### Scenario: TUI opens runtime overview -- **WHEN** a user opens the TUI -- **THEN** the TUI SHALL render runtime status, project/domain state, logs access, and available actions from a view model -- **AND** the TUI SHALL NOT depend on a real daemon for rendering tests - -### Requirement: Clients remain useful while daemon is down -CLI and TUI SHALL handle daemon-down states as a normal product state. - -#### Scenario: CLI state change while daemon is down -- **WHEN** a user runs a state-changing CLI command and no daemon is reachable -- **THEN** the command SHALL fail with an actionable daemon-unavailable message -- **AND** it SHALL include the supported start command - -#### Scenario: TUI opens while daemon is down -- **WHEN** a user opens the TUI and no daemon is reachable -- **THEN** the TUI SHALL show offline status and setup diagnostics -- **AND** it SHALL offer a visible start action instead of exiting immediately - -### Requirement: Client view models are mockable -Operator clients SHALL render from reusable view models supplied by a mockable -client service boundary. - -#### Scenario: TUI rendering test uses fixture state -- **WHEN** a TUI test supplies fixture runtime, domain, platform-integration, and log state -- **THEN** the TUI SHALL render the expected view through a test backend -- **AND** no real daemon, real Caddy, or real IIS dependency SHALL be required - -#### Scenario: CLI snapshot test uses fake daemon relation -- **WHEN** a CLI test supplies a fake daemon client result -- **THEN** the CLI SHALL render stable output suitable for snapshot or golden-file verification -- **AND** the test SHALL not open real IPC - -### Requirement: Operator clients expose log workflows -CLI and TUI SHALL support log inspection for all logs, one project, one domain, -and selectable severity levels. - -#### Scenario: CLI tails project logs -- **WHEN** a user tails logs for one project at info level -- **THEN** `cadder` SHALL request the corresponding daemon log stream -- **AND** it SHALL render only records matching the daemon's canonical filter - -#### Scenario: TUI filters domain logs -- **WHEN** a user selects a domain and changes severity in the TUI -- **THEN** the TUI SHALL update the log query through the client service -- **AND** it SHALL preserve the surrounding runtime context - -### Requirement: Client invocation model is documented -The project SHALL document whether TUI is launched through an explicit -subcommand, automatic TTY detection, or both before implementation depends on -that behavior. - -#### Scenario: User asks for interactive mode -- **WHEN** a user invokes the documented TUI entrypoint -- **THEN** `cadder` SHALL start the TUI or explain why the terminal does not support it -- **AND** non-interactive CLI behavior SHALL remain predictable for automation diff --git a/openspec/changes/reset-cadder-architecture/specs/operator-tui/spec.md b/openspec/changes/reset-cadder-architecture/specs/operator-tui/spec.md new file mode 100644 index 0000000..2ca23bf --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/operator-tui/spec.md @@ -0,0 +1,17 @@ +## MODIFIED Requirements + +### Requirement: TUI-001: One routes-first workspace exposes current state +Ink/React TUI SHALL render real daemon-backed projects and domains in one routes-first primary table through the same client service used by CLI, without production mocks or random statuses. Domain rows SHALL use tree connectors beneath their project, with no empty separator rows between projects. Collections SHALL support scrolling without fixed lengths. The TUI SHALL NOT add separate activity, logs, history, IIS or autostart views; explicit diagnostics and bounded logs SHALL remain CLI surfaces. + +#### Scenario: Operator changes a domain +- **WHEN** the user changes activation in the routes workspace +- **THEN** the TUI SHALL render committed daemon state without unrelated diagnostic details +- **AND** fixture data SHALL be limited to tests/development, not production + +### Requirement: TUI-003: Lifecycle actions are explicit +TUI SHALL offer idempotent explicit start while offline, confirmed bounded stop and ordered restart with pending/success/failure states. Startup SHALL follow the shared client launch boundary. Opening cadder tui without --start-daemon SHALL NOT imply daemon start; the explicit --start-daemon option SHALL start or attach before rendering. + +#### Scenario: Offline TUI opens +- **WHEN** the user opens cadder tui without --start-daemon and no daemon is running +- **THEN** TUI SHALL display offline state and offer an explicit start action +- **AND** restart SHALL observe prior shutdown before starting the replacement diff --git a/openspec/changes/reset-cadder-architecture/specs/product-topology/spec.md b/openspec/changes/reset-cadder-architecture/specs/product-topology/spec.md new file mode 100644 index 0000000..749b797 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/product-topology/spec.md @@ -0,0 +1,22 @@ +## MODIFIED Requirements + +### Requirement: TOP-001: Three executables form one installation +Cadder SHALL provide version-matched cadder, cadderd and caddy entrypoints from one TypeScript product in both npm and standalone distributions. The daemon SHALL be the sole runtime/state/process owner. Runtime v2 SHALL be owner-protected and installation-specific, with deliberately isolated dev/test runtimes; separate installation directories SHALL retain independent endpoints and application databases. Rust/Cargo SHALL be removed from the final product only after complete Node functionality and both release gates. + +#### Scenario: Distribution channel changes +- **WHEN** the owner uses npm or standalone Cadder of the same version +- **THEN** both SHALL expose the same product contracts and owner runtime policy +- **AND** clients SHALL NOT create independent Caddy or persistent state owners + +#### Scenario: Separate installations +- **WHEN** the same owner runs Cadder from two different installation directories +- **THEN** each installation SHALL use an independent endpoint and application database +- **AND** each installation's three entrypoints SHALL agree on its runtime identity + +### Requirement: TOP-002: Product and diagnostic surfaces remain distinct +Cadder SHALL expose CLI and TUI workflows through one client service/model boundary, with diagnostics requested explicitly. Production state SHALL come from the daemon, not mocks or random values. The current routes-first TUI and bounded CLI diagnostic surfaces SHALL remain distinct. This migration SHALL NOT restore superseded history, IIS, autostart, machine-output, profile, export, watch or continuous-tail surfaces, or include MCP, Web UI, Tauri, Bun, custom WASM, native runtime addons or new installers. + +#### Scenario: Client renders project state +- **WHEN** CLI or TUI renders a project, domain or lifecycle result +- **THEN** both SHALL use the same daemon-backed model and canonical state +- **AND** neither SHALL inspect daemon storage or Caddy administration directly diff --git a/openspec/changes/reset-cadder-architecture/specs/project-registration/spec.md b/openspec/changes/reset-cadder-architecture/specs/project-registration/spec.md new file mode 100644 index 0000000..34394ff --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/project-registration/spec.md @@ -0,0 +1,18 @@ +## MODIFIED Requirements + +### Requirement: REG-001: Registration belongs to one live shim session +Managed run SHALL register a validated entrypoint owned by a session nonce, renew it through heartbeat, reconnect safely and unregister on clean exit or lost ownership. Ctrl+C, expired heartbeat and daemon restart SHALL not leave a stale active route. Another session SHALL NOT alter the current owner's lease. + +#### Scenario: Old shim detaches a replacement session +- **WHEN** a stale shim sends heartbeat or unregister for a replacement owner +- **THEN** the daemon SHALL reject it and preserve current registration + +## ADDED Requirements + +### Requirement: REG-004: Existing project formats and controls remain compatible +The Node product SHALL preserve existing cadder.toml and Caddyfiles, documented configuration precedence, multi-project registration, domain filtering and activation workflows. It SHALL validate inputs through the shared contract and reject active domain conflicts without replacing the last committed owner. + +#### Scenario: Existing project is registered after transition +- **WHEN** the user registers an existing configuration through Node managed run +- **THEN** the daemon SHALL accept its retained format without a project rewrite +- **AND** active domain conflicts SHALL preserve the previous committed state diff --git a/openspec/changes/reset-cadder-architecture/specs/quality-tooling/spec.md b/openspec/changes/reset-cadder-architecture/specs/quality-tooling/spec.md index 022c20f..ce12b10 100644 --- a/openspec/changes/reset-cadder-architecture/specs/quality-tooling/spec.md +++ b/openspec/changes/reset-cadder-architecture/specs/quality-tooling/spec.md @@ -1,89 +1,55 @@ -## ADDED Requirements +## MODIFIED Requirements -### Requirement: OpenSpec is the planning source of truth -The repository SHALL use OpenSpec for product requirements, design decisions, -and implementation planning. +### Requirement: QT-001: Declarative contributor environment +The Node product SHALL declare exact dependencies, tool versions and locks, managed by Nub, without private paths/credentials. TypeScript SHALL be 6.0.3; the npm-tested Node minimum SHALL be 24.18.0 on Node 24 LTS. SEA SHALL use a separately verified, exact Node 26 builder pin. Required tools SHALL work on Windows/Linux/macOS without a custom compiled task runner after cutover. -#### Scenario: New architecture work is proposed -- **WHEN** new architecture or product behavior work is proposed -- **THEN** the work SHALL be captured as an OpenSpec change -- **AND** the change SHALL define affected capabilities before implementation +#### Scenario: Fresh Node-only checkout +- **WHEN** a contributor follows the declared setup after Rust removal +- **THEN** Node tests/build/docs/packaging SHALL work without Cargo/Rust or private setup -#### Scenario: Project documentation conflicts with OpenSpec -- **WHEN** project documentation conflicts with accepted OpenSpec requirements -- **THEN** OpenSpec SHALL be treated as authoritative -- **AND** the documentation SHALL be updated or explicitly marked stale +### Requirement: QT-002: One discoverable task surface +Node validation/build/pack tasks SHALL be discoverable as Nub package scripts, with focused commands and a complete check. Existing tool provisioning MAY remain as a thin adapter during cutover, not a competing policy framework. Custom scripts SHALL be limited to Cadder-specific packaging/acceptance seams. -### Requirement: Release surface follows accepted specs -Release artifacts, docs, and validation SHALL include only product surfaces -defined by accepted OpenSpec requirements or an active approved change. +#### Scenario: Contributor runs complete checks +- **WHEN** the documented Node check/pack verification commands run +- **THEN** each SHALL delegate to its owning maintained tool +- **AND** there SHALL be no replacement general-purpose custom command dispatcher -#### Scenario: Release artifact list generated -- **WHEN** release metadata is generated -- **THEN** every shipped artifact SHALL map to a documented product surface -- **AND** undocumented artifacts SHALL fail validation or require an explicit OpenSpec change +### Requirement: QT-003: Maintained tools own validation and release formats +tsc noEmit, ESLint, Prettier, Vitest/V8 and ink-testing-library SHALL own their checks; own TS/TSX line coverage SHALL be at least 85%, including hard runtime modules without selective exclusion. npm emission SHALL use tsc with relative import rewriting; SEA bundling SHALL use esbuild and the pinned Node builder. All generated Node JS/bundles/archives/coverage SHALL go to system or runner temp outside checkout, never beside source or into tracked files. Astro/Starlight SHALL own documentation checks/builds with Nub-managed dependencies. -#### Scenario: Documentation advertises a workflow -- **WHEN** user documentation advertises a workflow -- **THEN** that workflow SHALL map to an accepted requirement or active change -- **AND** stale workflows SHALL be removed or moved to historical notes +#### Scenario: Pack verification runs +- **WHEN** the same source is checked and packed for either channel +- **THEN** the supported tools SHALL produce only outside-checkout Node artifacts +- **AND** tracked files SHALL remain unchanged with no emitted source-side JS -### Requirement: Files stay small and cohesive -Production source files SHALL stay focused on one concern and SHOULD remain -under the project file-size threshold unless an explicit exception is documented. +### Requirement: QT-004: Local and CI task parity +CI SHALL invoke the same checks and non-publishing packing process as local verification, with native Windows/Linux/macOS system/security integration and every SEA target. PR jobs SHALL not publish or receive publication credentials. Native exact-artifact verification SHALL precede attest/publish stages with least privilege and source/tag validation. Documentation examples SHALL be tested. -#### Scenario: File exceeds threshold -- **WHEN** a source file exceeds the configured line threshold -- **THEN** the change SHALL either split the file by concern or document a narrow exception -- **AND** validation SHALL report the exception visibly +#### Scenario: Candidate PR is validated +- **WHEN** CI verifies a migration or release candidate +- **THEN** it SHALL check contracts, coverage, native product behavior and both channels +- **AND** artifact preparation SHALL NOT create a public release or registry publication -#### Scenario: Generated file exceeds threshold -- **WHEN** a generated file exceeds the threshold -- **THEN** the file SHALL be marked as generated or excluded by policy -- **AND** reviewers SHALL not treat it as a precedent for hand-written modules +### Requirement: QT-005: Evidence-gated task-runner removal +The Rust/Cargo product and obsolete native npm/release packaging SHALL be removed only after complete Node functionality and both distributions pass acceptance at the candidate revision. Each old responsibility SHALL have a verified replacement or explicitly approved removal. Unrelated pre-existing untracked npm content SHALL be inspected and preserved. After removal, the full Node-only gate SHALL pass again without Rust/Cargo installed. -### Requirement: Mature tools are preferred over custom orchestration -The project SHALL prefer proven external tools for testing, coverage, release, -documentation, and command orchestration when they satisfy Cadder's needs. +#### Scenario: Rust removal is proposed +- **WHEN** one required capability, platform or distribution still lacks evidence +- **THEN** Rust removal SHALL wait and migration SHALL remain incomplete +- **AND** a foundation checkpoint SHALL NOT be represented as the finished migration -#### Scenario: Tooling task duplicates external tool -- **WHEN** a custom tooling feature duplicates a mature external tool -- **THEN** the change SHALL either replace it or justify why Cadder-specific logic is required -- **AND** the resulting implementation SHALL remain small and reviewable +### Requirement: QT-006: npm packages pass clean-room verification +The gate SHALL verify the actual single npm tarball and every extracted SEA archive outside checkout using the publication packing process without publishing. It SHALL verify exact allowed files, version/license/repository identity, all three entries, local/global npm installs, argument/stream/exit fidelity and no wrapper recursion. npm SHALL have no platform optional packages or install scripts; SEA SHALL pass real TUI/SQLite/worker/child smoke without Node/npm PATH. -#### Scenario: Dependency proposed -- **WHEN** a new dependency is proposed for tooling or runtime code -- **THEN** the task SHALL state the job it performs, why it is preferable to custom code, and how it will be tested +#### Scenario: Clean consumer verification fails +- **WHEN** either channel fails its native packed-consumer catalog +- **THEN** neither the whole migration nor matching final release SHALL be accepted -### Requirement: Test strategy covers independent seams -Cadder SHALL test domain logic, external adapters, protocol contracts, client -rendering, platform providers, and log filtering independently before relying on -system smoke tests. +### Requirement: QT-007: npm publication uses staged trusted publishing +After separate publication approval, the single npm package SHALL retain short-lived OIDC trusted publishing from the exact protected GitHub workflow, staged publishing and maintainer 2FA review, without long-lived npm tokens. The consumed candidate SHALL match the verified source revision/version and complete npm/SEA evidence. The obsolete native-platform-package approval order SHALL not remain a dependency of the new single package. -#### Scenario: Runtime logic tested without external process -- **WHEN** runtime registration, config composition, or lifecycle logic is tested -- **THEN** the test SHALL use fake adapters unless the test is explicitly marked as integration or smoke coverage -- **AND** failure paths SHALL be covered with typed errors - -#### Scenario: Client rendering tested without daemon -- **WHEN** client screens or output modes are tested -- **THEN** tests SHALL use mock view models or fake client responses -- **AND** no real IPC SHALL be required - -#### Scenario: System behavior needs OS integration -- **WHEN** a behavior depends on OS services, process installation, path resolution, or platform-specific permissions -- **THEN** a system smoke test SHALL cover that behavior -- **AND** unit tests SHALL still cover the decision logic separately - -### Requirement: Coverage and CI are explicit -The project SHALL keep automated coverage and CI expectations visible in -OpenSpec and project documentation. - -#### Scenario: Full validation runs in CI -- **WHEN** CI validates Cadder -- **THEN** it SHALL run the formatting, linting, test, documentation, release metadata, and coverage checks required by the current specs -- **AND** failures SHALL identify which project contract was violated where practical - -#### Scenario: Local verification is skipped -- **WHEN** a change cannot run part of the expected verification locally -- **THEN** the final change notes SHALL state what was skipped and why +#### Scenario: Authorized candidate reaches publishing +- **WHEN** the separately approved matched candidate enters the publishing workflow +- **THEN** it SHALL stage the exact verified single package with trusted provenance +- **AND** a maintainer SHALL approve publication without an embedded persistent token diff --git a/openspec/changes/reset-cadder-architecture/specs/runtime-storage/spec.md b/openspec/changes/reset-cadder-architecture/specs/runtime-storage/spec.md new file mode 100644 index 0000000..20e9ba1 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/runtime-storage/spec.md @@ -0,0 +1,39 @@ +## RENAMED Requirements + +- FROM: `### Requirement: STO-003: Schema compatibility is exact before 1.0` +- TO: `### Requirement: STO-003: The Node schema is validated before mutation` +- FROM: `### Requirement: STO-004: Legacy cleanup is allowlisted` +- TO: `### Requirement: STO-004: Old runtime data remains untouched` + +## MODIFIED Requirements + +### Requirement: STO-001: One owner-protected SQLite database is authoritative +The Node daemon SHALL own a protected v2 application-state and diagnostic-log database using node:sqlite, separate from the lifetime exclusion database. No client SHALL directly access either database. Initial schema creation SHALL be transactional and finish before product operations are accepted. + +#### Scenario: Empty v2 application database +- **WHEN** the Node daemon first creates application-state and diagnostic-log storage +- **THEN** it SHALL create and validate its schema under owner-only protection +- **AND** the separate SQLite exclusion connection SHALL remain held + +### Requirement: STO-002: Database work is single flight and bounded +One worker SHALL serialize application-state and diagnostic-log database work, with bounded jobs, transaction rollback, integrity checks and orderly close. Worker failures SHALL settle pending requests with typed errors and SHALL NOT release daemon exclusion while owned work/resources remain active. + +#### Scenario: Worker or transaction fails +- **WHEN** persistence cannot finish successfully +- **THEN** uncommitted data SHALL be rolled back and pending callers informed +- **AND** shutdown SHALL settle worker work before releasing runtime exclusion + +### Requirement: STO-003: The Node schema is validated before mutation +Startup SHALL initialize only an empty uninitialized Node database, and SHALL reject partial, corrupt or unsupported/newer schemas before runtime mutation. It SHALL NOT delete unrelated entries to recover from schema failure. + +#### Scenario: Partial schema exists +- **WHEN** database objects or integrity do not match the supported Node schema +- **THEN** startup SHALL fail with a storage diagnostic without destructive repair + +### Requirement: STO-004: Old runtime data remains untouched +The Node migration SHALL NOT import, delete or implicitly clean up the Rust runtime or database. Projects SHALL re-register from existing configuration. Old releases and data SHALL remain available for rollback; stable intent SHALL NOT resurrect a live session. Durable logs and state SHALL NOT introduce a history command or view. + +#### Scenario: Node first starts beside old data +- **WHEN** old Rust runtime data exists during Node startup +- **THEN** Node SHALL use its separate v2 storage and leave the old data untouched +- **AND** project routes SHALL become live only through current registration diff --git a/openspec/changes/reset-cadder-architecture/specs/runtime-topology/spec.md b/openspec/changes/reset-cadder-architecture/specs/runtime-topology/spec.md deleted file mode 100644 index f55a2a9..0000000 --- a/openspec/changes/reset-cadder-architecture/specs/runtime-topology/spec.md +++ /dev/null @@ -1,68 +0,0 @@ -## ADDED Requirements - -### Requirement: Daemon-centered product topology -Cadder SHALL be organized around `cadderd` as the runtime owner, `caddy` as the -PATH-facing shim, and `cadder` as the CLI/TUI operator client. - -#### Scenario: Normal runtime ownership -- **WHEN** a project invokes the `caddy` shim to register or update a Caddy definition -- **THEN** the shim SHALL send the request to `cadderd` instead of starting an unmanaged Caddy process -- **AND** `cadderd` SHALL own the resulting runtime state and real Caddy process coordination - -#### Scenario: Operator client inspection -- **WHEN** a user opens `cadder` CLI or TUI -- **THEN** the client SHALL read daemon state through the shared Cadder protocol -- **AND** the client SHALL NOT inspect daemon storage or Caddy Admin API state directly - -### Requirement: Future operator surfaces use the same client boundary -Future operator surfaces SHALL be clients of the same daemon protocol and -reusable view-model contracts used by the initial operator clients. - -#### Scenario: Adding a future operator surface -- **WHEN** a future change introduces another operator surface -- **THEN** it SHALL attach to `cadderd` through the supported local protocol -- **AND** it SHALL NOT create an independent runtime state model or Caddy control plane - -### Requirement: Workspace boundaries are minimal and explainable -The TypeScript product SHALL keep protocol, daemon, Caddy adapter, platform -adapters, client service, CLI and TUI modules aligned with the target topology. -Rust crates remain only as a migration baseline until the release gates pass. - -#### Scenario: Contributor evaluates a module -- **WHEN** a module is reviewed during the reset -- **THEN** it SHALL be classified as daemon, shim, operator client, shared protocol/API, test support, documentation/tooling, or obsolete -- **AND** modules without a current responsibility SHALL be removed, merged, or explicitly justified - -#### Scenario: New module proposed -- **WHEN** a new module is proposed -- **THEN** the proposal SHALL explain its independent responsibility -- **AND** it SHALL NOT introduce a new public product or native runtime addon - -### Requirement: Distribution channels are equal -Cadder SHALL ship one npm package on Node 24 LTS >=24.18.0 and standalone Node SEA -archives through GitHub Releases. Both SHALL provide the same three entrypoints -and CLI/TUI functionality. Both channels SHALL pass before migration completion. - -#### Scenario: Packaging release sources -- **WHEN** npm or SEA artifacts are prepared -- **THEN** TypeScript sources SHALL compile only into staging outside checkout -- **AND** generated JavaScript and binaries SHALL NOT be committed - -#### Scenario: Standalone consumer has no Node -- **WHEN** a supported standalone archive is used without Node/npm on PATH -- **THEN** all entrypoints, TUI, SQLite and owned subprocess flows SHALL work - -### Requirement: Production runtime identity is singular -Production Cadder SHALL run one stable runtime identity per user or per explicit -system installation. Multiple Cadder runtimes SHALL be limited to dev/debug -profiles with separate runtime directories and visible labels. - -#### Scenario: Production daemon already running -- **WHEN** a production `cadderd` instance starts and a compatible production runtime already owns the lock -- **THEN** the new process SHALL attach, report the existing runtime, or exit with a clear diagnostic -- **AND** it SHALL NOT create a second production runtime silently - -#### Scenario: Developer starts isolated runtime -- **WHEN** a developer starts Cadder with an explicit dev/debug runtime profile -- **THEN** Cadder SHALL use an isolated runtime directory and lock identity -- **AND** clients and logs SHALL display the active runtime profile diff --git a/openspec/changes/reset-cadder-architecture/specs/windows-iis-handoff/spec.md b/openspec/changes/reset-cadder-architecture/specs/windows-iis-handoff/spec.md deleted file mode 100644 index 75e060c..0000000 --- a/openspec/changes/reset-cadder-architecture/specs/windows-iis-handoff/spec.md +++ /dev/null @@ -1,52 +0,0 @@ -## ADDED Requirements - -### Requirement: IIS handoff is optional and explicit -Windows IIS handoff SHALL be disabled unless the user explicitly enables, -previews, or applies it. - -#### Scenario: Normal user-level setup -- **WHEN** a user installs or starts Cadder without enabling IIS handoff -- **THEN** Cadder SHALL operate without admin elevation -- **AND** it SHALL NOT mutate IIS bindings - -#### Scenario: User requests IIS handoff -- **WHEN** a user asks Cadder to enable IIS handoff -- **THEN** Cadder SHALL show the required changes and privilege requirement before applying them -- **AND** the user SHALL be able to cancel without mutating IIS - -### Requirement: IIS operations use a platform provider seam -IIS discovery, handoff, restore, and status checks SHALL be implemented behind a -mockable platform provider boundary. - -#### Scenario: Daemon unit test uses fake IIS provider -- **WHEN** daemon logic is tested with a fake IIS provider -- **THEN** tests SHALL cover discovery, apply, restore, and failure paths without real IIS -- **AND** the same daemon logic SHALL be used with the real provider in production - -#### Scenario: IIS provider command fails -- **WHEN** the platform provider fails to apply or restore IIS state -- **THEN** Cadder SHALL return a typed error with recoverable context -- **AND** it SHALL preserve enough state to retry or restore safely - -### Requirement: Elevation is scoped -Cadder SHALL require elevation only for operations that need it, such as IIS -binding mutation or an explicitly elevated daemon mode. - -#### Scenario: Non-admin user views IIS status -- **WHEN** a non-admin user views IIS handoff status -- **THEN** Cadder SHALL show what can be discovered at user privilege -- **AND** it SHALL identify actions that require elevation - -#### Scenario: Elevated operation requested -- **WHEN** an operation requires admin rights -- **THEN** Cadder SHALL use the documented elevated helper or elevated daemon flow -- **AND** it SHALL return control to the user-level client with a clear result - -### Requirement: Real IIS behavior has system smoke coverage -IIS handoff SHALL have focused system smoke coverage for behavior that cannot be -validated through fake providers. - -#### Scenario: Sandbox smoke checks IIS handoff -- **WHEN** the Windows Sandbox smoke harness runs an IIS handoff and restore cycle -- **THEN** Cadder SHALL apply the handoff, preserve restore metadata, restore the binding, and leave no stale handoff state -- **AND** unsupported IIS environments SHALL produce a clear unavailable result diff --git a/openspec/changes/reset-cadder-architecture/tasks.md b/openspec/changes/reset-cadder-architecture/tasks.md index 467c785..c95ca49 100644 --- a/openspec/changes/reset-cadder-architecture/tasks.md +++ b/openspec/changes/reset-cadder-architecture/tasks.md @@ -1,85 +1,107 @@ -## Node migration implementation - -Earlier completed tasks below refer to the Rust baseline. They do not prove the -Node migration. The approved target and gates are in `node-migration.md`. - -- [x] N1. Update OpenSpec target, behavior evidence and TS/Nub tooling with exact dependencies. -- [ ] N2. Implement v2 owner-protected runtime, SQLite exclusion, mutual HMAC IPC and system gate tests. - - [x] N2.1 Implement runtime/IPC foundation and run Windows unit, regression and native child-process tests. - - [x] N2.2 Run the compiled-JS native runtime gate on Linux x64 with real Unix sockets and permissions. - - [x] N2.3 Pass the native Windows/Linux/macOS CI matrix on the committed code. - - [ ] N2.4 Verify same-owner non-elevated contact with elevated daemon and different-account denial in system tests, including Windows Sandbox. -- [ ] N3. Port Caddy composition, transaction queue, loopback mTLS and owned-process lifecycle. -- [ ] N4. Port shim policy, safe resolution, heartbeat, autostart and scoped IIS helpers. -- [ ] N5. Implement history worker, bounded/redacted logs, shared client service and real CLI/TUI. -- [ ] N6. Verify npm staging and all four SEA releases, native consumer smoke and documentation. -- [ ] N7. Pass >=85% coverage, three-platform integration and Windows Sandbox acceptance. -- [ ] N8. Remove Rust/Cargo and legacy packaging only after N1-N7 gates pass. -- [ ] N9. Prepare matching rc.1 artifacts without publishing; await separate release authorization. - -## 1. Planning Migration (Rust baseline) - -- [x] 1.1 Keep `openspec/` initialized and document it as the repository planning source of truth. -- [x] 1.2 Preserve relevant architecture rationale in OpenSpec proposal, design, and specs. -- [x] 1.3 Remove obsolete planning artifacts and workflow references from project-facing docs. -- [x] 1.4 Remove obsolete product-surface assumptions from repo instructions, docs, release metadata, and validation tasks. -- [x] 1.5 Update `docs/ARCHITECTURE.md` so it follows this OpenSpec change or explicitly points to OpenSpec as authoritative. - -## 2. Workspace Topology - -- [x] 2.1 Audit every workspace crate and classify it as daemon, shim, operator client, shared protocol/API, test support, docs/tooling, or obsolete. -- [x] 2.2 Remove, merge, or justify crates that do not map to a current product, library, test, docs, or tooling responsibility. -- [x] 2.3 Decide whether `cadder-operator` remains as an internal client library or is renamed/merged into the final `cadder` boundary. -- [x] 2.4 Fix workspace membership and dependency warnings after topology cleanup. -- [x] 2.5 Add validation that prevents reintroducing undocumented product crates. - -## 3. Protocol And Daemon - -- [x] 3.1 Split protocol code into envelopes, commands, responses, events, errors, log queries, and client traits. -- [x] 3.2 Replace hard protocol equality checks with additive compatibility and typed unsupported-capability errors. -- [x] 3.3 Extract daemon state concerns into focused modules for registrations, config composition, process lifecycle, runtime status, logs, and storage. -- [x] 3.4 Define and implement production runtime locking with stale-lock recovery. -- [x] 3.5 Define local IPC discovery and security for user clients contacting privileged runtime endpoints. -- [x] 3.6 Add daemon contract tests with fake Caddy, fake storage, fake IPC clients, and fake IIS providers. - -## 4. Shim And Caddy Integration - -- [x] 4.1 Write the shim command policy table for managed, read-only, passthrough, and unsupported Caddy commands. -- [x] 4.2 Implement managed shim commands as requests to `cadderd` without starting unmanaged Caddy. -- [x] 4.3 Implement daemon-unavailable diagnostics and recovery guidance for shim commands. -- [x] 4.4 Harden real Caddy resolution against recursive shim execution. -- [ ] 4.5 Implement config composition and atomic apply behavior with last-known-good recovery. -- [ ] 4.6 Add shim tests for command policy, no-daemon behavior, fallback behavior, and recursion prevention. - -## 5. Operator Clients - -- [ ] 5.1 Decide and document the `cadder` invocation model for CLI and TUI. -- [ ] 5.2 Build a mockable operator client service boundary and reusable view models. -- [ ] 5.3 Implement CLI unavailable-daemon behavior with actionable start guidance. -- [ ] 5.4 Implement TUI unavailable-daemon behavior with a visible start action. -- [ ] 5.5 Add CLI tests with fake daemon responses and stable output snapshots. -- [ ] 5.6 Add TUI rendering and interaction tests with Ratatui test backend fixtures. - -## 6. Logs - -- [ ] 6.1 Define the structured log record schema and redaction policy. -- [ ] 6.2 Implement daemon-side log query and tail semantics by all logs, runtime, project, domain, source, and severity. -- [ ] 6.3 Make per-domain log mapping accurate for multi-domain Caddy sites. -- [ ] 6.4 Expose equivalent log workflows in CLI and TUI. -- [ ] 6.5 Add tests proving severity filtering equivalence across CLI and TUI. - -## 7. Windows And IIS - -- [ ] 7.1 Extract IIS discovery, handoff, restore, and status into a mockable provider boundary. -- [ ] 7.2 Choose and implement the scoped elevation model for IIS operations. -- [ ] 7.3 Keep normal Cadder usage fully user-level when IIS handoff is not enabled. -- [ ] 7.4 Add local tests for IIS policy and fake-provider failure recovery. -- [ ] 7.5 Add system smoke coverage for IIS handoff, privileged runtime contact, autostart, and shim PATH behavior. - -## 8. Tooling, CI, And Release - -- [ ] 8.1 Decide which custom tooling responsibilities move to mature external tools and which remain project-specific. -- [ ] 8.2 Shrink `xtask` into small modules or a compatibility wrapper with tests. -- [ ] 8.3 Add or update CI jobs for formatting, linting, tests, coverage, docs, Windows Sandbox documentation, and release metadata. -- [ ] 8.4 Add file-size/cohesion validation with documented exceptions for generated files. -- [ ] 8.5 Verify the docs site builds and advertises only workflows covered by accepted specs or active changes. +# Cadder 2.0 implementation tasks + +Only Node migration work is counted here. Previous N1-N9 map to groups 1-9; +historical Rust checkboxes are available in Git snapshot `f850418` and the +archived Rust reset. Checked foundation tasks cite existing evidence, not a +completed product. Every remaining task needs its stated verification before +its checkbox can be checked. Gate dependencies are in `acceptance.md`. + +## 1. Contract, behavior catalog and TypeScript base (G1) + +- [x] 1.1 Reconcile and strictly validate proposal, Node design, delta specs, release story, tasks and acceptance against the corrected released-1.0.5 functional scope; review that superseded features no longer govern this change. Documentation-only evidence is in verification.md under Scope correction verification; prior broader planning evidence is superseded. +- [x] 1.2 Establish the root TS/TSX ESM workspace with exact Nub dependencies/lock, TypeScript 6.0.3, typecheck, lint, formatter, Vitest/V8 and outside-checkout coverage; foundation evidence is in verification.md. +- [ ] 1.3 Freeze fixtures for released 1.0.5 CLI grammar/exit codes and human output, bare help, tui --start-daemon, unsupported-command rejection, configuration precedence, shim argument streams, project/domain schemas and eight-operation IPC; compare expected Node behavior with current source evidence, not superseded historical scope (CLI-001, REG-004, SHIM-001, INSPECT-001..004). +- [ ] 1.4 Review and archive the contract deltas into main specs before further product implementation; preserve the open roadmap as planning, not completed implementation, and create only one focused implementation slice at a time, referencing stable requirement IDs rather than duplicating contracts. +- [ ] 1.5 Define mockable Caddy, platform, storage and client-service ports, typed method/result/error catalog and ownership/import boundaries; prove with contract tests before handlers depend on them (TOP-001/002, IPC-003). + +G1 exit: reviewed target, reproducible tooling and compatibility fixture catalog. +Planning readiness alone does not close 1.3-1.5 or any runtime gate. + +## 2. Runtime, exclusion and authenticated IPC (G2) + +- [x] 2.1 Implement isolated v2 paths, owner-only secret creation and fail-closed Unix/Windows permission adapters; unit/regression evidence exists (IPC-001, RUN-005). +- [x] 2.2 Hold a separate SQLite BEGIN EXCLUSIVE lifetime lock; recover diagnostic residue only after acquisition and release resources after failed start/shutdown; exclusion and crash tests exist (RUN-001). +- [x] 2.3 Implement protocol 3/security policy 2 mutual HMAC with fresh directional challenges and bounded, sequenced NDJSON transport; fake endpoint, wrong proof, replay and pre-auth RPC tests exist (IPC-001/002). +- [ ] 2.4 Tighten the generic fixture RPC into the closed product catalog with Zod, bounded params/results, unknown-method/field rejection and exactly one correlated result/error; test malformed and incompatible peers (IPC-003). +- [ ] 2.5 Verify startup/teardown phase failure cleanup, runtime link/owner/ACL denial and full disk/storage failure without claiming unsafe state ready; verify independent endpoints/databases for separate installations using targeted fault tests plus native filesystem cases (TOP-001, RUN-001/003, STO-003). +- [x] 2.6 Run compiled-JS child/process/SQLite/socket gates on Windows x64, Linux x64 and macOS arm64 without a TS loader; existing CI evidence is linked in verification.md. +- [x] 2.7 Verify explicit-owner root daemon contact by a normal owner and denial to another account on native Linux/macOS; existing system evidence is linked in verification.md (RUN-005). +- [ ] 2.8 Execute the isolated Windows Sandbox gate for real UAC, same-SID user-to-elevated contact, different-account denial and cancellation; record results without changing host UAC/accounts/IIS (RUN-005, IPC-001). +- [ ] 2.9 Record G2 acceptance for the exact source revision, with concurrency, forced crash/restart, stale/misleading metadata, replay, spoofed endpoint and unsafe ACL cases; require all native/security evidence before group 3. + +G2 is currently open. Prepared Sandbox scripts/packages do not satisfy 2.8. + +## 3. Caddy adapter and transactional updates (G3, requires G2) + +- [ ] 3.1 Port smol-toml configuration and Caddyfile adaptation/validation with fixture-proven precedence and bounded output/process cleanup; test invalid files and retained formats (REG-004, CADDY-001/005). +- [ ] 3.2 Port trusted real-Caddy resolution, executable pinning and npm-wrapper/SEA/file-identity recursion rejection; test aliases, PATH ordering and untrusted selectors (CADDY-001, SHIM-003). +- [ ] 3.3 Port multi-project composition, canonical domain ownership/filtering and exact-host/loopback guards; test disabled domains, conflicting projects and hostless sibling routes (CADDY-002/004, REG-002). +- [ ] 3.4 Implement the single prepare/validate/apply/verify/persist/publish queue and pending-state isolation; use concurrent fake-adapter mutations to prove serial ordering (CADDY-003). +- [ ] 3.5 Keep last-known-good on rejection and restore after post-apply persistence failure; test rollback failure fencing and active-state reconciliation after an ambiguous response before another change (CADDY-003). +- [ ] 3.6 Generate the internal CA/server/client material with X.509/WebCrypto and protected permissions, without system trust installation; test certificate validation and material ownership (CADDY-006). +- [ ] 3.7 Configure loopback mTLS admin and prevent project admin overrides/plaintext fallback; prove real Caddy denies no/wrong certificates and accepts only the authorized client (CADDY-006). +- [ ] 3.8 Port start/reload/inspect/graceful stop and bounded forced teardown using owned child handles only; prove unrelated processes survive success, error, Ctrl+C and shutdown (RUN-003, CADDY-002). +- [ ] 3.9 Pass multi-project registration/reload/rejection/rollback/ambiguity scenarios with fake adapters and real Caddy on supported native platforms; record G3 evidence. + +## 4. Shim and current inspection adapters (G4, requires G3) + +- [ ] 4.1 Freeze and port managed/read-only/passthrough/unsupported command policy; test unknown command refusal, argument arrays, stdin/stdout/stderr and exact exit status (SHIM-001). +- [ ] 4.2 Add managed missing-daemon startup with version-matched packaged entry resolution; prove no independent Caddy fallback and no PATH daemon substitution in npm or SEA (RUN-002, SHIM-002). +- [ ] 4.3 Port registration session nonce, heartbeat, reconnect, expiry and cleanup; test stale owner, simultaneous projects, Ctrl+C and daemon restart (REG-001/003). +- [ ] 4.4 Port operator port/Caddyfile inspection and PID-revalidated termination into small non-native-addon platform adapters; prove attach-only behavior and daemon ownership isolation (INSPECT-001..004). +- [ ] 4.10 Verify native shim/inspection parity and record G4 evidence, including lost-session cleanup and PID revalidation without expanding daemon ownership (SHIM-001..003, REG-001, INSPECT-001..004). + +Former tasks 4.5-4.9 are withdrawn, not completed: IIS and autostart are outside +the corrected migration scope. Retain existing task IDs for evidence continuity. + +## 5. State, diagnostic logs, shared client and routes CLI/TUI (G5, requires G4) + +- [ ] 5.1 Implement the single node:sqlite application-state/diagnostic-log worker with schema creation, integrity validation, bounded jobs and orderly close; test corrupt/newer schemas, rollback and worker errors (STO-001/002/003). +- [ ] 5.2 Persist stable desired state and bounded diagnostic logs without restoring live sessions or introducing history queries; prove the old Rust runtime/database is neither imported nor deleted (STO-004, REG-003, OBS-001/002). +- [ ] 5.3 Port pre-write redaction and deterministic log retention, including supported text/JSON/URL credentials and private keys; test boundaries and terminal-safe presentation (OBS-001/002/004). +- [ ] 5.4 Preserve bounded canonical runtime/project/domain stream queries and correct domain attribution; test 1-200 limits, newest-row selection and ascending order, without source/severity/all-log filters or TUI log views (OBS-003). +- [ ] 5.5 Implement shared client-service methods/models over the eight-operation RPC catalog for current lifecycle, projects, domains, diagnostics and bounded CLI logs; test offline/malformed states without importing daemon storage or adding RPC methods (IPC-003, TOP-002). +- [ ] 5.6 Implement Commander released-command parity and human-readable output/exit contracts against the group 1 catalog; test arguments, snapshots and rejection of removed commands/options from fake client responses (CLI-001). +- [ ] 5.7 Preserve attach-only state/inspection commands and explicit daemon start/restart, managed shim startup, tui --start-daemon and offline-TUI start; test bounded launch behavior and bare help with/without TTY never spawning a daemon (CLI-001, RUN-002). +- [ ] 5.8 Implement the current routes-first Ink/React TUI through the same client service with real state, tree rows and no separate activity/logs/history/platform views; remove production mocks/random statuses and test Space/Enter toggles, offline Enter-to-start, pending actions and small terminals (TUI-001/002/003/004). +- [ ] 5.9 Preserve bare cadder help on TTY/non-TTY and explicit cadder tui with optional --start-daemon; prove terminal restoration on normal exit, error and Ctrl+C with rendering tests plus a real terminal smoke (CLI-001, TUI-004). +- [ ] 5.10 Pass an actual Node daemon/shim/operator journey with multiple projects, domain changes, bounded CLI logs and shutdown; record G5 parity against the catalog, not only fixture-runtime success. + +## 6. Equal npm/SEA distributions and documentation (G6, requires G5) + +- [ ] 6.1 Emit npm JS using tsc import-extension rewriting exclusively into external staging; generate the consumer manifest and three bin entries with matching version/license/repository/assets (DIST-004, QT-003). +- [ ] 6.2 Pack one npm tarball with the publication packer and verify its file allowlist, no optional platform packages/postinstall/download/build/loader/real Caddy; test local/global installation and PATH recursion outside checkout (QT-006, DIST-004). +- [ ] 6.3 Select, verify and pin the Node 26 SEA builder patch independently of the Node 24.18.0 npm minimum; record source/runtime compatibility and builder inputs (QT-001, DIST-001). +- [ ] 6.4 Bundle the same sources with esbuild and build three SEA entries plus assets outside checkout; prove state/log SQLite workers and owned child launch without a system Node executable (DIST-002). +- [ ] 6.5 Build Windows x64, Linux x64, macOS x64 and arm64 archives with checksums/provenance and exact accepted asset sets; reject version/source drift (DIST-001/002/005). +- [ ] 6.6 Run native SEA smoke for every variant with Node/npm absent from PATH, covering all entries, actual CLI/TUI, SQLite workers, Caddy adapter children and platform helper launch (DIST-002, QT-006). +- [ ] 6.7 Run the same current-scope functional catalog through packed npm and extracted SEA on native runners, including local/global npm wrappers and daemon start/restart; record cross-channel parity (DIST-005). +- [ ] 6.8 Move Astro/Starlight dependencies and tasks to Nub with exact locks; replace Bun/legacy instructions and document both channels equally, including separate Caddy prerequisite and safe transition (DOC-001/002/004). +- [ ] 6.9 Build/check docs from the supported workflow and verify each public command/example against real product acceptance, without fixture/private/hidden diagnostic seams (DOC-003/004). +- [ ] 6.10 Make CI validate the same non-publishing pack process used for publication, with native archive verification and least-privilege/provenance safeguards; record G6 only after both channels pass (QT-004/006/007). + +## 7. Whole-product acceptance (G7, requires G6) + +- [ ] 7.1 Enforce >=85% line coverage for own TS/TSX, including hard runtime/platform/queue modules; run contract/config/log/CLI/TUI tests with fake adapters and publish the exact source coverage evidence (QT-003). +- [ ] 7.2 Pass Windows/Linux/macOS native integration for independent installation runtimes, simultaneous starts, forced crash recovery, many projects, reload/rollback, heartbeat, Ctrl+C and bounded shutdown (TOP-001, RUN-001/003, REG-001, CADDY-003). +- [ ] 7.3 Pass wrong-secret, replay, spoofed endpoint, other-user, unsafe ACL and ordinary-owner-to-elevated-daemon cases, plus real Caddy mTLS denial without a client certificate (IPC-001/002, RUN-005, CADDY-006). +- [ ] 7.4 Close real Windows Sandbox owner/elevated access, UAC cancellation and other-account denial for the actual product, not only the runtime fixture; verify that no IIS/autostart operations were introduced (RUN-005, IPC-001, TOP-002). +- [ ] 7.5 Verify pack/build leaves tracked files unchanged and emits no generated Node JS/bundles/archives in checkout; review dependency exactness and prohibited runtime dependencies (QT-003/006). +- [ ] 7.6 Reconcile every requirement and approved-plan row in acceptance.md with actual evidence at one release-candidate revision; any missing channel/platform/capability keeps G7 open. + +## 8. Replace and remove the Rust product (G8, requires G7) + +- [ ] 8.1 Inventory Cargo/workspace/crates, old npm native packages, release scripts/workflows, Rust fixtures and documentation references; map each retained behavior to Node evidence or an approved removal, preserving original Git history (QT-005). +- [ ] 8.2 Inspect and classify the pre-existing npm directory before replacing its tracked legacy content; preserve unrelated untracked files and private operational state (QT-005). +- [ ] 8.3 Remove Rust source/workspace, Cargo manifests/lock/toolchain/tasks and obsolete packaging only after 8.1/8.2; retain necessary non-generated behavioral fixtures in the Node tests (TOP-001, QT-005). +- [ ] 8.4 Replace Cargo CI/release gates with Node/npm/SEA validation and small Cadder-specific packaging scripts; move shared release version-source configuration from retired native manifests to the Node product after verifying the release adapter (QT-003/004/007). +- [ ] 8.5 Update shared contributor/architecture guidance and remove stale Rust/Bun/xtask claims from the current product workflow while preserving historical archives (DOC-002). +- [ ] 8.6 Re-run complete tests, coverage, docs, packed npm and every SEA gate from the Node-only source revision and consumers without Rust/Cargo installed; ensure no package/build/runtime task depends on deleted code (QT-005). +- [ ] 8.7 Record G8: no permanent parallel Rust/Node product, no tracked generated JS, all approved functionality present and both equal distributions ready. Do not mark the migration complete at an earlier foundation checkpoint. + +## 9. Release preparation and explicit publication boundary (G9, requires G8) + +- [ ] 9.1 Verify the consumer transition checklist: stop the Rust daemon and re-register existing configurations; test leaving old runtime/database and rollback releases intact without adding IIS/autostart steps (DIST-003, STO-004). +- [ ] 9.2 Prepare matching 2.0.0-rc.1 npm/SEA artifacts and source-based release outcome/version manifests through the configured Arcantry adapter after its version sources are updated; no tag or publication (DIST-005). +- [ ] 9.3 Review artifact checksums, native evidence, docs and release story; reconcile the requirement ledger and separately request any missing commit/push/tag/npm/GitHub publication authority (QT-004/007). +- [ ] 9.4 Prepare 2.0.0 only after RC acceptance passes in both channels; retain old releases for rollback with no automatic deprecation, and publish only under separate explicit approval. diff --git a/openspec/changes/reset-cadder-architecture/verification.md b/openspec/changes/reset-cadder-architecture/verification.md index 36633fd..d7f31e1 100644 --- a/openspec/changes/reset-cadder-architecture/verification.md +++ b/openspec/changes/reset-cadder-architecture/verification.md @@ -1,11 +1,28 @@ # Node migration verification +## Current scope correction + +The user corrected functional scope to the released Rust 1.0.5 product in +TypeScript. Earlier planning results below describe the superseded broader plan +and are not validation of the corrected contract. Runtime evidence remains +historical implementation evidence only; this documentation change does not +re-run it or close any runtime gate. Current scope and checks are recorded in +`Scope correction verification` below. + +The Node-only roadmap is now in tasks.md and its contract/evidence mapping is in +acceptance.md. Previous N1-N9 correspond to groups 1-9. Historical Rust checkbox +counts are not evidence; original planning/task text remains in Git and archives. +The results below predate this documentation-only planning completion and must +be rechecked for affected implementation changes. New planning validation is +recorded separately rather than being counted as application acceptance. + ## Scope implemented The Node implementation currently covers the runtime/IPC foundation, not a -shippable 2.0 application. Product handlers, Caddy, shim, history, CLI/TUI, IIS, -autostart and npm/SEA distributions are still pending. The released Rust 1.0.5 -workspace and packaging are retained from `master`; older Rust behavior evidence +shippable 2.0 application. Product handlers, Caddy, shim, state/log storage, +current CLI/TUI and npm/SEA distributions are still pending. History, IIS, +autostart and machine output are excluded, not unfinished parity tasks. +The released Rust 1.0.5 workspace and packaging are retained from `master`; older Rust behavior evidence is pinned in `behavior-baseline.md` rather than restored into production code. Implemented: isolated v2 paths and profiles, owner-only Unix modes and Windows @@ -69,3 +86,71 @@ On Unix, run `node /runtime-privilege-smoke.mjs /runtime-child.mjs /runtime-denied-client.mjs` as a non-root user with noninteractive sudo. Only disposable test runtime files and fixture processes are used. Coverage output also stays in system/runner temp. No publication is performed. + +## Prior planning completion - 2026-10-05 (superseded scope) + +This earlier checkpoint changed only OpenSpec planning documents and closed the +then-current task 1.1, not G1, G2 or the migration. Its broader functional scope +has since been withdrawn. Current task 1.1 requires validation of the corrected +plan; the counts and results below are historical, not current-plan acceptance. + +- Compared the approved seven-stage migration and transition rules with the + acceptance ledger, nine dependency groups and 69 uniquely numbered tasks. + Historical Rust completions no longer count as Node implementation progress. +- Reviewed the shared Arcantry configuration and local OpenSpec schema/templates. + The major-impact release artifact records planned consumer outcomes, without + changing released version sources, manifests or the managed changelog. +- Verified requirement references against main and delta specs, exact rename + provenance, absence of duplicate IDs/task numbers and roadmap entry links. +- Verified that `openspec show reset-cadder-architecture --json --deltas-only` + retains all 45 full requirement statements across 15 capabilities, plus four + explicit rename deltas. Requirement paragraphs use one physical line because + the installed parser otherwise exposes only their first line. +- `openspec doctor`, both local schema validations, strict change validation and + `openspec validate --all --strict --no-interactive` pass: 16 items, zero failures. + `git diff --check` passes. Artifact readiness means documents exist, not that + implementation or consumer distributions have passed acceptance. + +The configured local Arcantry launcher cannot run because its Windows platform +package is missing. Configuration/templates were reviewed directly; no Arcantry +CLI release validation, installation or release writes were performed. + +No application tests, typechecks, builds or runtime execution were performed at +this documentation-only checkpoint. Runtime code, dependencies, CI and released +versions are unchanged. No spec sync/archive, commit, push, tag or publication +was performed. + +## Scope correction verification + +This documentation-only correction follows the user's decision to preserve the +current released product, not restore superseded features. It was checked on the +working tree based on `f850418a40991bb54c45e5fb56df1232d286c746`; the corrected +OpenSpec files are uncommitted. There are no new runtime or artifact results. + +- Compared current main CLI/TUI, IPC, observability, topology and storage specs + with the migration deltas and the released CLI grammar. Preserved bare help, + explicit tui --start-daemon, routes-only TUI, human output, eight operations, + bounded canonical log streams and independent installation runtimes. +- Withdrew IIS/AUTO/OBS-005 deltas and former tasks 4.5-4.9; removed their parity, + documentation and transition claims. Historical planning evidence above is + explicitly superseded rather than relabeled as current acceptance. +- Reviewed this correction against a pre-edit working-tree snapshot, preserving + unrelated existing edits. Updated design, roadmap, acceptance and planned + release outcomes consistently; no main-spec sync or archive was performed. +- `openspec doctor`, `openspec schema validate implementation` and + `openspec schema validate spec-driven` pass. +- `openspec validate reset-cadder-architecture --strict --no-interactive` and + `openspec validate --all --strict --no-interactive` pass; the whole view reports + 16 items, zero failures. `git diff --check` passes. +- `openspec show reset-cadder-architecture --json --deltas-only` exposes 39 + requirement statements across 12 capabilities and three rename deltas, with + nonempty requirement text and scenarios. An in-memory scope check confirms + unique requirement IDs and 65 unique task IDs, no withdrawn delta directories + and no IIS/AUTO/OBS-005 task references. +- Active Markdown LSP probes were unavailable because no server was ready; + they are not a clean diagnostic result. OpenSpec validation and manual review + are the completed documentation checks. + +This closes corrected planning task 1.1 only. Tasks 1.3-1.5, G1/G2 and all product +acceptance gates remain open. No application tests, builds, Sandbox execution, +version changes, commits, pushes, tags or publication were performed. From 76bec637081471eca3e58e8e4ca15e3b57c26ec2 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Thu, 8 Oct 2026 14:14:04 +0200 Subject: [PATCH 18/29] docs: record accepted Node contracts and migration evidence --- AGENTS.md | 5 +- openspec/README.md | 22 +- .../reset-cadder-architecture/README.md | 15 +- .../reset-cadder-architecture/acceptance.md | 29 +- .../reset-cadder-architecture/design.md | 33 +- .../node-migration.md | 12 +- .../reset-cadder-architecture/proposal.md | 14 +- .../reset-cadder-architecture/tasks.md | 37 +- .../reset-cadder-architecture/verification.md | 590 ++++++++++++++++++ .../windows-runtime-gate.md | 11 +- openspec/specs/caddy-runtime/spec.md | 36 +- openspec/specs/caddy-shim-integration/spec.md | 29 + openspec/specs/daemon-lifecycle/spec.md | 35 +- .../specs/distribution-and-upgrades/spec.md | 84 +-- .../specs/documentation-experience/spec.md | 43 +- openspec/specs/local-control-plane/spec.md | 32 +- openspec/specs/operator-cli/spec.md | 42 +- openspec/specs/operator-tui/spec.md | 18 +- openspec/specs/product-topology/spec.md | 25 +- openspec/specs/project-registration/spec.md | 16 +- openspec/specs/quality-tooling/spec.md | 127 +--- openspec/specs/runtime-storage/spec.md | 39 +- 22 files changed, 948 insertions(+), 346 deletions(-) create mode 100644 openspec/specs/caddy-shim-integration/spec.md diff --git a/AGENTS.md b/AGENTS.md index 7647f54..5e0c9e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,10 @@ Use `nub ci` and `nub run check` for the root TypeScript workspace. Sources and runtime fixtures live in `src/`, `test/`, and `scripts/*.ts`; generated outputs and coverage belong in system or runner temp outside the checkout. The Node runtime gate supplements, rather than replaces, the existing Rust, docs, npm -and cargo-dist checks. Windows privilege acceptance still gates Caddy porting. +and cargo-dist checks. Under the approved migration sequencing, Windows Sandbox +privilege acceptance runs on the completed product before G7 acceptance, Rust +removal or release. It does not block Caddy integration implementation; deferred +evidence must remain open rather than be recorded as passed. ### Released Rust baseline diff --git a/openspec/README.md b/openspec/README.md index 26f8345..bcf920c 100644 --- a/openspec/README.md +++ b/openspec/README.md @@ -2,8 +2,8 @@ > Last reviewed: 2026-10-05 -OpenSpec records the accepted Cadder 1.0 contract and the evidence that the -implementation satisfies it. Product requirements live here; public docs +OpenSpec records the accepted Cadder target contract and its implementation +evidence. Product requirements live here; public docs explain how people use the finished product. The active `reset-cadder-architecture` now defines the approved 2.0 Node migration, @@ -11,14 +11,18 @@ not another Rust reset. Its functional scope preserves the released Rust 1.0.5 CLI/TUI, not superseded history/IIS/autostart or machine-output workflows. Its `acceptance.md` maps the approved scope to stable requirement IDs, ordered gates and evidence; `tasks.md` counts only Node work. -Main specs retain the 1.0 baseline until the reviewed deltas are synced. Planning -completion is not product readiness or release authorization. +Main specs now include the reviewed Node migration deltas. The released Rust +1.0.5 product remains the operational baseline until the migration gates pass. +Contract synchronization is not product readiness or release authorization. +The approved continuation schedules Windows Sandbox against the completed product; +open G1/G2 acceptance does not block groups 3-6 implementation against delivered +boundaries. Final G7 acceptance, Rust removal and release still require that proof. ## Contract model -`openspec/specs/` describes the accepted target behavior for Cadder 1.0. A main -spec states what the released product does, even while its implementation is in -progress. The active changes under `openspec/changes/` show which parts still +`openspec/specs/` describes accepted target behavior, including the Node migration. +A main spec states the product contract; it does not claim that its implementation +has shipped. The active changes under `openspec/changes/` show which parts still need implementation or verification. This target-contract model is deliberate. It differs from the default OpenSpec @@ -51,6 +55,10 @@ refer to these identifiers. Renaming prose does not change an identifier. Keep only one implementation change active at a time unless two changes touch independent subsystems and their dependency order is explicit. +The completed `close-node-rpc-catalog` protocol/port slice is retained pending its +known archive/validator blocker. `migrate-caddy-preparation` consumes those leaf +contracts in a separate Caddy source boundary; it does not reopen protocol work. +No overlapping source writers or duplicate requirements are authorized. ## Validation diff --git a/openspec/changes/reset-cadder-architecture/README.md b/openspec/changes/reset-cadder-architecture/README.md index ff4380e..5df9960 100644 --- a/openspec/changes/reset-cadder-architecture/README.md +++ b/openspec/changes/reset-cadder-architecture/README.md @@ -12,7 +12,7 @@ cadder tui retains the routes workspace and explicit --start-daemon option. | proposal.md | User journey, full replacement scope, affected capabilities | | node-migration.md | Approved migration constraints and exclusions | | design.md | Node module contracts, ownership, security, packaging and cutover | -| specs/ | Stable-ID normative changes relative to current main specs | +| specs/ | Reviewed stable-ID deltas synchronized into main specs; retained provenance | | tasks.md | Only Node implementation checklist, grouped by dependency gate | | acceptance.md | Approved-plan coverage, evidence levels and final completion | | release.md | Planned consumer outcomes and major release impact | @@ -24,12 +24,17 @@ The original Rust reset is archived separately. Its earlier active task snapshot is recoverable at `f850418:openspec/changes/reset-cadder-architecture/tasks.md`. Historical Rust completions must not count as Node progress. -G2 remains open pending product-contract completion and actual Windows privilege -acceptance. The runtime foundation is not a shippable 2.0 application. Both npm +G1/G2 acceptance remains open. The user-approved sequence permits groups 3-6 +implementation against delivered contract/runtime boundaries and schedules Windows +Sandbox for the completed product under 2.8/7.4. G1/G2 must still close before +G7 acceptance, Rust removal or release. The runtime foundation is not a shippable +2.0 application. Both npm and every standalone SEA variant must pass before Rust/Cargo/legacy packaging removal, followed by a complete Node-only recheck. Follow the repository's Arcantry/OpenSpec contract -> reviewed delta sync -> focused implementation slice -> evidence -> closeout workflow. Completing these -planning artifacts does not sync main specs, close implementation gates, update -release manifests/changelog, or authorize commit/push/tag/publication. +planning artifacts does not close implementation gates, update release +manifests/changelog, or authorize commit/push/tag/publication. The reviewed +spec-only synchronization is recorded separately in verification.md; this +roadmap remains active. diff --git a/openspec/changes/reset-cadder-architecture/acceptance.md b/openspec/changes/reset-cadder-architecture/acceptance.md index 418b3d5..898f5c4 100644 --- a/openspec/changes/reset-cadder-architecture/acceptance.md +++ b/openspec/changes/reset-cadder-architecture/acceptance.md @@ -12,11 +12,12 @@ The user corrected the migration to preserve that product in TypeScript, not restore superseded history, IIS, autostart or machine-output workflows. Older snapshots may explain retained behavior but cannot expand the accepted scope. -Main specs remain the 1.0 baseline until the reviewed migration deltas are synced -through the contract archive workflow before further product implementation. -Archiving those deltas accepts the target, not the unfinished implementation -roadmap. Preserve open tasks as planning. Future implementation slices reference -stable requirement IDs and record evidence rather than duplicating this roadmap. +The reviewed migration deltas are synchronized into main specs; verification.md +records the contract-sync checks. The released Rust 1.0.5 product remains the +operational baseline. Synchronization accepts the target, not the unfinished +implementation roadmap. Open tasks remain planning. Focused implementation slices +reference stable requirement IDs and record evidence rather than duplicating this +roadmap. | Approved scope | Contract and design owner | Tasks / exit | | --- | --- | --- | @@ -64,20 +65,28 @@ Former IIS/AUTO/OBS-005 additions are withdrawn, not accepted product contracts. | --- | --- | --- | | G1 | Reviewed corrected plan | Complete contracts/design, released-behavior fixtures and reproducible TS tooling | | G2 | G1 foundation | Native exclusion/recovery/authentication and owner/elevated/other-account checks, including actual Windows Sandbox | -| G3 | G2 | Real Caddy multi-project transactions, mTLS denial, rollback/reconcile and owned teardown | +| G3 | Implemented contract/runtime boundaries (1.2, 1.5, 2.1-2.4), with open G1/G2 evidence tracked | Real Caddy multi-project transactions, mTLS denial, rollback/reconcile and owned teardown | | G4 | G3 | Shim fidelity, session cleanup and native operator inspection/PID revalidation | | G5 | G4 | Real Node application journey, state/log persistence and current routes CLI/TUI parity | | G6 | G5 | Actual packed npm and every native SEA target, matching functionality/version and verified docs | -| G7 | G6 | Complete coverage, security/system matrix and one-revision requirements ledger | +| G7 | G6 plus completed G1/G2 acceptance | Complete coverage, security/system matrix, actual product Windows Sandbox and one-revision requirements ledger | | G8 | G7 | Node-only checkout/consumers pass again after removing Rust and obsolete packaging | | G9 | G8 | Matched RC artifacts and transition evidence; final version only after RC acceptance | +The user approved completing the product before Windows Sandbox execution. +Groups 3-6 may be implemented against the delivered contract/runtime boundaries; +open G1/G2 acceptance does not block that implementation. Actual TTY and remaining +native runtime evidence stay tracked, and Sandbox runs against the completed +product under 2.8/7.4. G1/G2 must be accepted before G7, Rust removal or release. +This changes execution order, not owner/elevated access or other security criteria. + Fake adapters prove policy/contract behavior. Native child fixtures prove runtime mechanics. Actual packed-product tests prove distribution behavior. Interactive Sandbox proves UAC/account boundaries. None may be relabeled as another. -If a gate lacks execution authority or a native runner, record the gap and do -safe work within that gate; do not bypass dependencies or mark it complete. -Material contract changes return for an explicit user decision. +If a check lacks execution authority or a native runner, record the gap and keep +its acceptance open. Continue only implementation permitted by the dependency +rules above; deferred checks are not passes. Material contract changes return +for an explicit user decision. ## Reusable functional catalog diff --git a/openspec/changes/reset-cadder-architecture/design.md b/openspec/changes/reset-cadder-architecture/design.md index 0ab8467..64f00d7 100644 --- a/openspec/changes/reset-cadder-architecture/design.md +++ b/openspec/changes/reset-cadder-architecture/design.md @@ -111,10 +111,14 @@ using fresh direction-bound challenges. Secret bytes never cross IPC. Validate authenticated frame sequences and reject replay/reflection/tampering or RPC before authentication. Do not add capability negotiation or Rust compatibility. -G2 must prove native exclusion/crash recovery and both privilege models before -Caddy porting. The existing Windows Sandbox guide/package is preparation only; -UAC approval, cancellation, same-owner elevated contact and different-account -denial require real isolated execution, not mocked claims or host policy changes. +The delivered contract/runtime boundaries support Caddy integration implementation +while G1/G2 acceptance evidence remains open. Under the user-approved sequencing, +Windows Sandbox runs on the completed product, not midway through implementation. +G2 must still prove native exclusion/crash recovery and both privilege models +before G7 acceptance, Rust removal or release. The existing Windows Sandbox +guide/package is preparation only; UAC approval, cancellation, same-owner elevated +contact and different-account denial require real isolated execution, not mocked +claims or host policy changes. ### Configuration transactions and Caddy @@ -137,9 +141,13 @@ query the active protected Caddy state and reconcile before accepting another mutation. If persistence fails after apply, restore and verify last-known-good; a failed restore stays fenced and reports a recoverable diagnostic. -Create an internal CA, server certificate and protected client certificate using -WebCrypto/X.509. Bind the admin channel to loopback with mTLS, validate server -identity and require the authorized client. Do not install this CA in OS trust. +Create protected root/intermediate CA and authorized client material using +WebCrypto/X.509. Caddy's native internal issuer obtains and renews the admin +server certificate from that CA; do not inject a server leaf into CertMagic +storage or add a proxy/custom issuer. Keep Caddy's identity storage inside the +owner-protected runtime boundary. Bind the admin channel to loopback with mTLS, +validate server identity and require the authorized client. Do not install this +CA in OS trust. Override/reject project admin settings before validation; no plaintext admin listener may remain. Verify no-cert/wrong-cert denial with real Caddy. Lifecycle control uses only the daemon's owned child handle: graceful stop, bounded wait, @@ -238,11 +246,12 @@ identity includes exact source commit/version; npm and SEA versions match. ## Migration Plan -G1 establishes complete contracts/fixtures and tooling. G2 closes runtime/security -acceptance. G3 ports and verifies transactional Caddy. G4 ports shim and current -inspection/platform boundaries. G5 completes persistence and real CLI/TUI. G6 -builds and verifies both channels and docs. G7 closes cross-platform/security/parity -acceptance. G8 removes +G1 establishes contracts/fixtures and tooling. Implemented contract/runtime +boundaries permit G3 Caddy work while remaining G1/G2 evidence stays open. G3 +ports and verifies transactional Caddy. G4 ports shim and current inspection/platform +boundaries. G5 completes persistence and real CLI/TUI. G6 builds and verifies both +channels and docs. Complete G1/G2 acceptance, including actual-product Windows +Sandbox, before G7 closes cross-platform/security/parity acceptance. G8 removes Rust and legacy packaging and reruns the Node-only gates. G9 prepares matching release candidates without publishing. See task dependencies and evidence in `acceptance.md`; a checkpoint does not close the whole migration. diff --git a/openspec/changes/reset-cadder-architecture/node-migration.md b/openspec/changes/reset-cadder-architecture/node-migration.md index 40f4274..d0df184 100644 --- a/openspec/changes/reset-cadder-architecture/node-migration.md +++ b/openspec/changes/reset-cadder-architecture/node-migration.md @@ -66,11 +66,15 @@ packaging mechanism, not distribution priority. ## Verification and transition gates -Follow migration tasks in order. Runtime/IPC system tests gate Caddy porting. +Follow implemented module dependencies while tracking open acceptance evidence. +The user approved groups 3-6 implementation against the delivered contract/runtime +boundaries without first executing Windows Sandbox. Sandbox tests the completed +product under tasks 2.8/7.4; G1/G2 acceptance remains mandatory before G7, Rust +removal or release. Deferred execution is not a security waiver or a passed gate. Cover concurrent starts, crash recovery, authentication failures, replay, fake -endpoints, unsafe ACLs and same-owner elevated access. Later cover multi-project -reload/rollback, heartbeat, Ctrl+C, shutdown, native privilege boundaries in -Windows Sandbox and identical npm/SEA consumer scenarios. Test SEA without +endpoints, unsafe ACLs and same-owner elevated access. Also cover multi-project +reload/rollback, heartbeat, Ctrl+C, shutdown, actual-product native privilege +boundaries and identical npm/SEA consumer scenarios. Test SEA without Node/npm on PATH on each native platform, including the routes TUI, state/log SQLite worker and subprocesses. Packaging must leave tracked files unchanged and no generated JS in checkout. diff --git a/openspec/changes/reset-cadder-architecture/proposal.md b/openspec/changes/reset-cadder-architecture/proposal.md index 953da33..be554d3 100644 --- a/openspec/changes/reset-cadder-architecture/proposal.md +++ b/openspec/changes/reset-cadder-architecture/proposal.md @@ -6,9 +6,11 @@ project Caddyfile -> managed shim -> daemon -> operator journey, with the curren released Rust 1.0.5 command scope and routes-first TUI, while delivering the same product through npm and standalone downloads. -The next end-to-end capability is an authenticated, exclusively owned Node -runtime that passes the real privilege-boundary gate. That is a prerequisite, -not the finished migration. +The delivered foundation supplies authenticated, exclusively owned Node runtime +boundaries. The next capability selects trusted real Caddy and adapts/validates an +existing project Caddyfile with bounded child cleanup. The user approved testing +Windows Sandbox on the completed product; its acceptance remains mandatory before +G7, Rust removal or release, not before Caddy integration implementation. ## What Changes @@ -73,5 +75,7 @@ No Bun, custom WASM, native runtime addons, Web UI, Tauri, new system installers or unrelated feature expansion is included. OBS-001 through OBS-004 remain unchanged; older historical code is evidence, not authority to restore features. Port/Caddyfile inspection and guarded operator process control retain INSPECT-001 -through INSPECT-004. This planning update does not sync/archive specs, bump released versions, change runtime code, -commit, push, tag or publish. +through INSPECT-004. The reviewed contract deltas are synchronized into main +specs without archiving this open roadmap. That accepts the target, not migration +completion. Released versions, commits, pushes, tags and publication remain +separate actions. diff --git a/openspec/changes/reset-cadder-architecture/tasks.md b/openspec/changes/reset-cadder-architecture/tasks.md index c95ca49..48c41a7 100644 --- a/openspec/changes/reset-cadder-architecture/tasks.md +++ b/openspec/changes/reset-cadder-architecture/tasks.md @@ -11,36 +11,39 @@ its checkbox can be checked. Gate dependencies are in `acceptance.md`. - [x] 1.1 Reconcile and strictly validate proposal, Node design, delta specs, release story, tasks and acceptance against the corrected released-1.0.5 functional scope; review that superseded features no longer govern this change. Documentation-only evidence is in verification.md under Scope correction verification; prior broader planning evidence is superseded. - [x] 1.2 Establish the root TS/TSX ESM workspace with exact Nub dependencies/lock, TypeScript 6.0.3, typecheck, lint, formatter, Vitest/V8 and outside-checkout coverage; foundation evidence is in verification.md. - [ ] 1.3 Freeze fixtures for released 1.0.5 CLI grammar/exit codes and human output, bare help, tui --start-daemon, unsupported-command rejection, configuration precedence, shim argument streams, project/domain schemas and eight-operation IPC; compare expected Node behavior with current source evidence, not superseded historical scope (CLI-001, REG-004, SHIM-001, INSPECT-001..004). -- [ ] 1.4 Review and archive the contract deltas into main specs before further product implementation; preserve the open roadmap as planning, not completed implementation, and create only one focused implementation slice at a time, referencing stable requirement IDs rather than duplicating contracts. -- [ ] 1.5 Define mockable Caddy, platform, storage and client-service ports, typed method/result/error catalog and ownership/import boundaries; prove with contract tests before handlers depend on them (TOP-001/002, IPC-003). +- [x] 1.4 Review and archive the contract deltas into main specs before further product implementation; preserve the open roadmap as planning, not completed implementation, and create only one focused implementation slice at a time, referencing stable requirement IDs rather than duplicating contracts. Spec-only synchronization, independent delta-fidelity review and strict validation are recorded in verification.md; close-node-rpc-catalog references IPC-001..003 without duplicating requirements. +- [x] 1.5 Define mockable Caddy, platform, storage and client-service ports, typed method/result/error catalog and ownership/import boundaries; prove with contract tests before handlers depend on them (TOP-001/002, IPC-003). Parent 130-test/96.85% check and independent MiMo Flash PASS are recorded in verification.md under Ports and lifecycle cleanup verification. G1 exit: reviewed target, reproducible tooling and compatibility fixture catalog. -Planning readiness alone does not close 1.3-1.5 or any runtime gate. +Task 1.3 and runtime gates remain open; contract-test evidence closes 1.5 only. ## 2. Runtime, exclusion and authenticated IPC (G2) - [x] 2.1 Implement isolated v2 paths, owner-only secret creation and fail-closed Unix/Windows permission adapters; unit/regression evidence exists (IPC-001, RUN-005). - [x] 2.2 Hold a separate SQLite BEGIN EXCLUSIVE lifetime lock; recover diagnostic residue only after acquisition and release resources after failed start/shutdown; exclusion and crash tests exist (RUN-001). - [x] 2.3 Implement protocol 3/security policy 2 mutual HMAC with fresh directional challenges and bounded, sequenced NDJSON transport; fake endpoint, wrong proof, replay and pre-auth RPC tests exist (IPC-001/002). -- [ ] 2.4 Tighten the generic fixture RPC into the closed product catalog with Zod, bounded params/results, unknown-method/field rejection and exactly one correlated result/error; test malformed and incompatible peers (IPC-003). +- [x] 2.4 Tighten the generic fixture RPC into the closed product catalog with Zod, bounded params/results, unknown-method/field rejection and exactly one correlated result/error; test malformed and incompatible peers (IPC-003). Parent check, Windows compiled smoke and independent MiMo reviews are recorded in verification.md under Closed RPC verification; this does not close G2. - [ ] 2.5 Verify startup/teardown phase failure cleanup, runtime link/owner/ACL denial and full disk/storage failure without claiming unsafe state ready; verify independent endpoints/databases for separate installations using targeted fault tests plus native filesystem cases (TOP-001, RUN-001/003, STO-003). - [x] 2.6 Run compiled-JS child/process/SQLite/socket gates on Windows x64, Linux x64 and macOS arm64 without a TS loader; existing CI evidence is linked in verification.md. - [x] 2.7 Verify explicit-owner root daemon contact by a normal owner and denial to another account on native Linux/macOS; existing system evidence is linked in verification.md (RUN-005). -- [ ] 2.8 Execute the isolated Windows Sandbox gate for real UAC, same-SID user-to-elevated contact, different-account denial and cancellation; record results without changing host UAC/accounts/IIS (RUN-005, IPC-001). -- [ ] 2.9 Record G2 acceptance for the exact source revision, with concurrency, forced crash/restart, stale/misleading metadata, replay, spoofed endpoint and unsafe ACL cases; require all native/security evidence before group 3. +- [ ] 2.8 Execute the isolated Windows Sandbox gate on the completed product for real UAC, same-SID user-to-elevated contact, different-account denial and cancellation; share final product evidence with 7.4 and record results without changing host UAC/accounts/IIS (RUN-005, IPC-001). User-approved sequencing defers execution until the product is ready; this is not a passed or waived requirement. +- [ ] 2.9 Record G2 acceptance for the exact final product source revision, with concurrency, forced crash/restart, stale/misleading metadata, replay, spoofed endpoint and unsafe ACL cases; require all native/security evidence before G7 acceptance, Rust removal or release, not before group 3 implementation. G2 is currently open. Prepared Sandbox scripts/packages do not satisfy 2.8. - -## 3. Caddy adapter and transactional updates (G3, requires G2) - -- [ ] 3.1 Port smol-toml configuration and Caddyfile adaptation/validation with fixture-proven precedence and bounded output/process cleanup; test invalid files and retained formats (REG-004, CADDY-001/005). -- [ ] 3.2 Port trusted real-Caddy resolution, executable pinning and npm-wrapper/SEA/file-identity recursion rejection; test aliases, PATH ordering and untrusted selectors (CADDY-001, SHIM-003). -- [ ] 3.3 Port multi-project composition, canonical domain ownership/filtering and exact-host/loopback guards; test disabled domains, conflicting projects and hostless sibling routes (CADDY-002/004, REG-002). -- [ ] 3.4 Implement the single prepare/validate/apply/verify/persist/publish queue and pending-state isolation; use concurrent fake-adapter mutations to prove serial ordering (CADDY-003). -- [ ] 3.5 Keep last-known-good on rejection and restore after post-apply persistence failure; test rollback failure fencing and active-state reconciliation after an ambiguous response before another change (CADDY-003). -- [ ] 3.6 Generate the internal CA/server/client material with X.509/WebCrypto and protected permissions, without system trust installation; test certificate validation and material ownership (CADDY-006). +The approved continuation implements groups 3-6 against delivered contract/runtime +boundaries while tracking open G1/G2 evidence. Deferred acceptance is not permission +to weaken security or skip checks for changed behavior. + +## 3. Caddy adapter and transactional updates (G3, requires implemented contract/runtime boundaries) + +- [x] 3.1 Port smol-toml configuration and Caddyfile adaptation/validation with fixture-proven precedence and bounded output/process cleanup; test invalid files and retained formats (REG-004, CADDY-001/005). Focused migrate-caddy-preparation evidence: final 352-test/96.34% Node check, native Windows owned Job and bounded staging tests; native Unix/real-Caddy acceptance remains 3.9/G3, not inferred. +- [x] 3.2 Port trusted real-Caddy resolution, executable pinning and npm-wrapper/SEA/file-identity recursion rejection; test aliases, PATH ordering and untrusted selectors (CADDY-001, SHIM-003). Focused migrate-caddy-preparation evidence includes hardlink/reparse, mutation/replacement, quoted/relative PATH and shared-pin cancellation regressions; built distribution proof remains G6. +- [x] 3.3 Port multi-project composition, canonical domain ownership/filtering and exact-host/loopback guards; test disabled domains, conflicting projects and hostless sibling routes (CADDY-002/004, REG-002). Focused migrate-caddy-composition evidence: 63 pure JSON tests, final 415-test/96.81% Node check and independent MiMo review with terminal-chain repair re-review; this delivers a route plan, not apply/server or whole G3 acceptance. +- [x] 3.4 Implement the single prepare/validate/apply/verify/persist/publish queue and pending-state isolation; use concurrent fake-adapter mutations to prove serial ordering (CADDY-003). Focused migrate-caddy-transactions evidence: 65 fake-adapter tests, final 480-test/96.97% Node check, immutable committed snapshots and independent readback before DesiredState-only persistence/publication. Actual secure Caddy/storage/handler integration remains later work. +- [x] 3.5 Keep last-known-good on rejection and restore after post-apply persistence failure; test rollback failure fencing and active-state reconciliation after an ambiguous response before another change (CADDY-003). Focused evidence covers initial idle rollback, recovery failures, FIFO fenced refusal and explicit queued reconciliation; MiMo Pro found no P0/P1, and its null-observation contract note was clarified and regression-tested. Whole CADDY-003/G3 acceptance remains open. +- [ ] 3.6 Generate protected root/intermediate CA and authorized client material with X.509/WebCrypto; let Caddy's native internal issuer obtain and renew the admin server certificate, with its identity storage inside the protected runtime boundary. Test certificate validation and material ownership without system trust installation (CADDY-006). - [ ] 3.7 Configure loopback mTLS admin and prevent project admin overrides/plaintext fallback; prove real Caddy denies no/wrong certificates and accepts only the authorized client (CADDY-006). -- [ ] 3.8 Port start/reload/inspect/graceful stop and bounded forced teardown using owned child handles only; prove unrelated processes survive success, error, Ctrl+C and shutdown (RUN-003, CADDY-002). +- [ ] 3.8 Port start/reload/inspect/graceful stop and bounded forced teardown using owned child handles only; prove unrelated processes survive success, error, Ctrl+C and shutdown (RUN-003, CADDY-002). Focused migrate-caddy-lifecycle evidence: owned backend, existing-endpoint refusal, explicit queue recovery, Windows Node ownership fixtures and parent 780-test/96.14% Node check; independent F1/F2 corrections accepted. Native real-Caddy lifecycle, Unix execution and full daemon/shim Ctrl+C/shutdown acceptance remain open; see the focused verification.md. - [ ] 3.9 Pass multi-project registration/reload/rejection/rollback/ambiguity scenarios with fake adapters and real Caddy on supported native platforms; record G3 evidence. ## 4. Shim and current inspection adapters (G4, requires G3) @@ -85,7 +88,7 @@ the corrected migration scope. Retain existing task IDs for evidence continuity. - [ ] 7.1 Enforce >=85% line coverage for own TS/TSX, including hard runtime/platform/queue modules; run contract/config/log/CLI/TUI tests with fake adapters and publish the exact source coverage evidence (QT-003). - [ ] 7.2 Pass Windows/Linux/macOS native integration for independent installation runtimes, simultaneous starts, forced crash recovery, many projects, reload/rollback, heartbeat, Ctrl+C and bounded shutdown (TOP-001, RUN-001/003, REG-001, CADDY-003). - [ ] 7.3 Pass wrong-secret, replay, spoofed endpoint, other-user, unsafe ACL and ordinary-owner-to-elevated-daemon cases, plus real Caddy mTLS denial without a client certificate (IPC-001/002, RUN-005, CADDY-006). -- [ ] 7.4 Close real Windows Sandbox owner/elevated access, UAC cancellation and other-account denial for the actual product, not only the runtime fixture; verify that no IIS/autostart operations were introduced (RUN-005, IPC-001, TOP-002). +- [ ] 7.4 Close real Windows Sandbox owner/elevated access, UAC cancellation and other-account denial for the completed actual product, not only the runtime fixture; use the same final product evidence to close 2.8, and verify that no IIS/autostart operations were introduced (RUN-005, IPC-001, TOP-002). This is mandatory final acceptance, deferred from intermediate implementation by the user's decision. - [ ] 7.5 Verify pack/build leaves tracked files unchanged and emits no generated Node JS/bundles/archives in checkout; review dependency exactness and prohibited runtime dependencies (QT-003/006). - [ ] 7.6 Reconcile every requirement and approved-plan row in acceptance.md with actual evidence at one release-candidate revision; any missing channel/platform/capability keeps G7 open. diff --git a/openspec/changes/reset-cadder-architecture/verification.md b/openspec/changes/reset-cadder-architecture/verification.md index d7f31e1..4764cad 100644 --- a/openspec/changes/reset-cadder-architecture/verification.md +++ b/openspec/changes/reset-cadder-architecture/verification.md @@ -54,6 +54,10 @@ authority and cannot authorize another account. ## Required remaining runtime gate +The execution-order restriction in this historical checkpoint is superseded by +`Approved final-product Sandbox sequencing` below. Its results and missing +privilege evidence are unchanged; no deferred test is recorded as passed. + The [native CI matrix](https://github.com/MrMaxie/cadder/actions/runs/37324338555) passes on Windows x64, Linux x64 and macOS arm64. It verifies typechecking, linting, formatting, native compiled-JS IPC/SQLite/process smoke and coverage. @@ -154,3 +158,589 @@ OpenSpec files are uncommitted. There are no new runtime or artifact results. This closes corrected planning task 1.1 only. Tasks 1.3-1.5, G1/G2 and all product acceptance gates remain open. No application tests, builds, Sandbox execution, version changes, commits, pushes, tags or publication were performed. + +## Reviewed contract synchronization and compatibility draft + +Source baseline: `93942af84f555eab2bd68e0a4a9db487ff82d040` on +`feat/node-runtime-migration`, with uncommitted spec/catalog changes. These are +working-tree checks, not exact-revision G2 acceptance or a released artifact. + +- Applied all 39 accepted delta blocks into main specs: 33 modified and six + added requirements, including three title renames. Main specs now contain 60 + requirements. The active migration roadmap was not archived. +- Independent MiMo review compared every changed block to its delta and confirmed + unchanged requirement content. Appending new requirements adds only a blank + separator after three previously final blocks; their content is unchanged. +- `openspec validate --specs --strict --no-interactive` passes: 15 specs. + `openspec validate --all --strict --no-interactive` passes: 17 items before + creation of the focused RPC implementation slice. +- Parent `nub run check` passes: 40 tests, 90.3% own-code line coverage, + typecheck/lint/format checks pass. The synchronized CLI text anchor was updated + without changing the retained behavior catalog. +- Built current untouched Rust 1.0.5 with + `cargo build --locked -p cadder-client -p cadder-shim`. No Rust source or Cargo + manifest/lock changes occurred. Redirected Windows x64 process captures show + bare help and missing nested subcommands exit 0 on stderr; explicit help and + version exit 0 on stdout; removed inputs exit 2 on stderr. Seven exact output + snapshots are retained in `test/compatibility/cli-output.json`. +- After correcting those measured stream/exit expectations, the focused catalog + suite passes 12 tests; typecheck, fixture formatting and git diff --check pass. + No TTY capture, populated process-output snapshots, real shim stream test or + Caddyfile adaptation result is claimed by these source-backed fixtures. +- `close-node-rpc-catalog` is the focused IPC-001..003 implementation slice. + It does not duplicate normative requirements or modify the separate retained + Rust release implementation change. + +After the focused slice was created, `mise run openspec-check` reports 17 passes +and one failure: OpenSpec 1.5 requires at least one delta for the configured +spec-free implementation schema. `cargo xtask openspec-check` is unavailable in +this checkout (no cargo alias or xtask manifest). No fake delta or new validator +was added. This is an open tooling limitation, not a failed main-spec sync. +The latest parent `nub run check` passes 41 tests with 90.3% line coverage. + +Task 1.4 is delivered. Task 1.3 remains open for missing compatibility evidence; +1.5, 2.4, 2.5, 2.8, 2.9 and G1/G2 remained open at that checkpoint. No Caddy port, +Sandbox privilege execution, version change, commit, push, tag or publication was +included. + +## Closed RPC verification + +Source baseline remains `93942af84f555eab2bd68e0a4a9db487ff82d040`; Node/spec +changes are uncommitted. Windows x64 uses Node 24.18.0 and Nub 0.7.5. This is +working-tree evidence, not one-revision native G2 acceptance. + +- The catalog admits exactly eight operations with strict Zod payload/result/error + DTOs and method-indexed client types. Unknown fields/methods do not invoke + handlers. Requests, nested results/errors and envelopes correlate IDs, with + exactly one result or error. Business rejection remains accepted:false. +- Decode accepts missing/null serde Option inputs. Canonical skip-serializing + fields omit null; other nullable fields are normalized as designed. Log input + default/clamping policy remains separate from the CLI default. +- Runtime fixtures use query-state and shutdown. Recovery is fixture stdout, not + a product snapshot property. No product handler, Caddy adapter, storage worker, + Rust interoperability or production mock was introduced. +- Independent MiMo Pro review passed the complete RPC seam. A subsequent parent + review found oversized valid responses could lose the correlated outcome. + The repair preserves send sequence on synchronous failure and sends one small + correlated frame error. MiMo Flash's resumed narrow review passed the repair; + its earlier timed-out attempt produced no acceptance evidence. +- Parent `nub run check` passes 67 tests in 12 files; own-code line coverage is + 91.78%. Typecheck, lint and formatting pass. The typecheck resolves the + reviewer's speculative unused-ts-expect-error concern: no TS2578 is reported. +- Parent `node scripts/prepare-runtime-gate.ts`, followed by + `node /runtime-smoke.mjs /runtime-child.mjs`, passes on win32/x64 + without a TS loader. Emission and disposable runtime files remain outside + checkout. This smoke is a fixture runtime gate, not npm/SEA product acceptance. +- Parent `openspec validate --specs --strict --no-interactive` passes 15 specs; + git diff --check passes. The spec-free implementation validation mismatch is + unchanged and is not hidden by adding duplicate deltas. + +This delivers task 2.4 only. Four port declarations exist but task 1.5 awaits +final review. Catalog gaps in 1.3, runtime failures/isolation in 2.5, real Sandbox +in 2.8, exact-revision/native reconciliation in 2.9 and G1/G2 remain open. A +non-frame send-failure test remains a review-noted coverage gap; source inspection +shows it fails closed. No Caddy port or external delivery action is authorized. + +## Ports and lifecycle cleanup verification + +At the same source baseline with intentional working-tree changes, the parent +reported `nub run check` passing 130 tests with 96.85% own-code line coverage. +Independent MiMo Flash review passed the repaired four-port contract: project +and domain activation round-trip, full adapted JSON carrier, method/result/error +types and import allowlist. This closes task 1.5, not the pending handler or +adapter implementation. + +Independent MiMo Pro review passed the lifecycle cleanup repairs. Parent tests +cover failed startup and shutdown cleanup for the runtime lock, protected files, +listener and discovery publication. These results are supplied review/parent +evidence; they are not a new native privilege or full-disk gate. + +## Installation identity and native filesystem boundaries + +The working tree still starts from `93942af84f555eab2bd68e0a4a9db487ff82d040`. +Before the identity change, a Windows source reproducer supplied two real +installation directories and measured identical directory, endpoint, lock and +application-database paths. The repaired default retains the per-user OS base +and adds `v2/<16-hex digest>` of the canonical physical installation root. +Physical aliases agree. npm derives the nearest cadder package anchor above the +shared module, not the shared Node executable, argv shim or working directory. +SEA identity follows the real executable's directory. Missing roots/anchors fail +closed. Explicit runtime-directory precedence, empty overrides and profile +behavior remain unchanged; the root-owner startup policy is untouched. + +Windows x64 source tests prove distinct endpoint, secret, SQLite lock, metadata, +discovery and application-database paths for two roots. Two real SQLite lifetime +locks and local listeners run simultaneously with authenticated clients; stopping +and restarting one leaves the other's client contact and exclusion intact. +Secret bytes and published diagnostics remain installation-specific. Application +storage independence is **path-only** until task 5.1; the two live databases in +this proof are lock databases, not application storage. + +Native Windows tests reject direct directory-junction/reparse substitutions at +the runtime directory, secret, lock, metadata and discovery boundaries. File +artifact substitutions use directory junctions, not privileged file symlinks. +Direct protection checks classify each link as unsafe; startup rejects the +metadata-directory substitution earlier with native EISDIR. Broad and inherited +DACLs are denied at all five boundaries. Existing sentinel content and ACL +snapshots are unchanged after denial. Owner checks read real ACLs against a +mismatched expected SID without creating accounts or transferring ownership; +this is not different-account privilege acceptance. Unix mode/link/UID branches +are present in the tests but were not executed on native Unix in this run. + +The first focused attempt reported 27 passes and 12 failures: ten test-fixture +Set-Acl operations requested SeSecurityPrivilege, one empty-environment setup +selected the retained cwd override semantics, and one junction assertion +expected the protection error rather than the earlier native EISDIR. After +approval, fixture-only DACL updates use System.IO access/owner sections without +SACL operations or privilege changes. One broad and one inherited ACL case then +passed; the focused suite passed all 39 tests before the empty-override regression +was added. Final focused validation passes 40 tests in four files. Final +`nub run check` passes 162 tests in 20 files, with 98.85% own-code line coverage, +typecheck, lint and formatting passing. Fresh-eyes review repaired the initial +empty-override append condition and added a regression; simplification removed +redundant test assertions. `git diff --check` passes. No production ACL adapter +change was needed for these direct cases. + +Plain-Node source reproduction initially hit unsupported TypeScript parameter +property syntax. Installed tsx was used for that source reproducer; native gates +use esbuild-compiled ESM without a loader and with NODE_OPTIONS removed. The +existing crash/restart/shutdown smoke and new simultaneous-installation smoke +both pass on win32/x64. Bundles and disposable runtimes are outside checkout and +were removed afterward. These are fixture gates, not npm/SEA release artifacts. + +Remaining blockers keep task 2.5, 2.8, 2.9 and G2 open: + +- The reproduced ancestor-junction escape is denied by the ancestor preflight + follow-up below. This is a snapshot link check, not atomic path identity or + arbitrary ancestor ACL hardening. Check-to-use races and same-owner path swaps + remain residual risks. +- Unix sockets reject paths over 103 UTF-8 bytes before runtime creation, with an + actionable shorter-runtime-directory error. Tests retain 103/104-byte and + multibyte coverage. The socket-budget follow-up removes only the cosmetic + `install-` prefix on every platform, retaining the full 16-hex digest, per-user + base, v2 and dev suffix. The modeled normal macOS default drops from 107 to 99 + bytes; long/multibyte homes and long dev-profile paths still fail closed. The + placement-only Darwin fixture skip is removed, but native macOS default + usability remains unproven, not inferred from Windows tests. +- Native Unix reruns, all-native isolation matrices, actual npm/SEA distributions, + application storage behavior, full-disk cases and Windows Sandbox same-owner + elevation/different-account privilege acceptance remain unproven here. No host + accounts, UAC, IIS, security policy or privileged host operations were changed. + +### Socket-budget follow-up + +MiMo Pro passed the delivered identity/boundary slice without closing task 7 or +G2. This follow-up removes eight cosmetic bytes, not hash entropy, and declares +`installationRoot` in the internal runtime-start options. Overrides are unchanged; +there is no public flag, socket relocation or platform-specific production namespace. + +Windows x64 focused validation passes 42 tests in four files. Final +`nub run check` passes 164 tests in 20 files with 98.85% own-code line coverage; +typecheck, lint and formatting pass. Actual-platform path coherence, sibling +resolver agreement, normal modeled macOS placement and long/multibyte denial are +covered. Both external compiled Windows smokes pass again, including simultaneous +locks/listeners, authenticated contact and independent stop/restart. Emitted +bundles and disposable runtimes were removed. `git diff --check` passes. + +The Darwin integration fixture is prepared with an exclusively created short +synthetic home, restored environment and cleanup after all runtime finalizers. +It never writes to the real user's home. This test-only setup is not native +macOS user-default acceptance or an exception to unresolved ancestor-link policy. +One initial full check rejected a cleanup throw under no-unsafe-finally; the +fixture now awaits all finalizers before propagating rejection and removing its +owned root. Fresh-eyes/simplifier review found no further scoped defect at that +checkpoint. Ancestor escape and task 2.5/2.8/2.9 were still open then; the ancestor +follow-up below delivers only the reproduced link-boundary repair. + +## Compatibility fixture follow-up — released 1.0.5 source and executable evidence + +Baseline: `93942af84f555eab2bd68e0a4a9db487ff82d040` on +`feat/node-runtime-migration`, Windows x64. This follow-up changes only +`test/compatibility/**` plus this evidence ledger; no product implementation, +Caddy port, Sandbox/UAC/account setup, or host runtime settings were changed. + +The compatibility catalog now includes concrete `cadder.toml` TOML cases, +multiline supported and malformed Caddyfile cases, captured CLI snapshots, +eight reusable request/result payload pairs for the eight-operation Node DTO +catalog, one concrete fixture per operation, nested registration identity, +optional fields, RPC correlation, CLI/RPC log defaults, and populated output +rows. Supported TOML snippets are parsed with installed `smol-toml`; all eight +request/result fixtures are parsed with the existing closed `src/protocol/rpc.ts` +Zod schemas. No duplicate Caddyfile parser or precedence engine was added. + +Focused released Rust 1.0.5 tests (all passed; exactly 67 tests) were run as +source/parser/fixture authority: + +- `cargo test -p cadder-ipc`: 17 tests (13 unit + 4 wire compatibility). +- `cargo test -p cadder-daemon --lib config::tests`: 6 tests. +- `cargo test -p cadder-shim --bin cadder-shim command_policy::tests`: 1 test. +- `cargo test -p cadder-shim --bin cadder-shim registration::tests`: 4 tests. +- `cargo test -p cadder-client --bin cadder-client cli::output::tests`: 6 tests. +- `cargo test -p cadder-daemon --lib caddy::tests::trusted_caddy_source`: 18 tests. +- `cargo test -p cadder-daemon --lib caddy::tests::mock_adapter`: 2 tests. +- `cargo test -p cadder-daemon --lib caddy::tests::adapter_`: 5 tests. +- `cargo test -p cadder-shim --test shim_binary`: 8 tests, including success and nonzero released-shim passthrough. + +The initial package-target attempts using `--lib` for `cadder-shim` and the +wrong `cadder` bin name for `cadder-client` were target-selection errors, not +zero-test evidence; corrected commands above supplied the evidence. No filtered +zero-test run is counted. + +Node fixture checks passed with Nub: `nub exec vitest run + test/compatibility/catalog.test.ts` (14 tests), `nub run typecheck`, `nub run +lint`, and Prettier. Final `nub run check` passes 227 tests in 22 files with +98.92% own-code line coverage; typecheck, lint and formatting pass. Full +`cargo test --locked --workspace` passes 300 tests; workspace clippy with +`-D warnings`, Rust formatting, and `git diff --check` pass. + +Actual CLI evidence remains the seven redirected non-TTY captures in +`test/compatibility/cli-output.json`, built from fresh 1.0.5 `cadder-client` +and `cadder-shim` targets: bare invocation and missing nested groups exit 0 on +stderr, explicit help/version exit 0 on stdout, and rejected arguments exit 2 +on stderr. This follow-up adds released-shim passthrough proof through the +existing native `cadder-test-process` fixture: copied fixture argv preserves +`fmt`, spaces, Unicode and quotes; piped stdin bytes are recorded; stdout and +stderr markers remain independent; and child exit codes 0 and 23 are returned +unchanged. The test uses an isolated copied released shim plus trusted portable +`cadder.toml`, with a hermetic child-only PATH and temp paths; stdin is +explicitly closed before a bounded ten-second child wait with kill/wait cleanup; +no daemon or real Caddy is launched. No TTY execution is claimed. The Windows Sandbox +package and launcher were prepared and launched before the user's deferral; +UAC, account-boundary acceptance, task 2.8 and G2 remain explicitly deferred +and open. Task 1.3 remains open pending actual TTY evidence. Tasks 1.4 and 1.5, +2.4 and 2.5 are not changed by this entry. + +## Runtime ancestor preflight + +The IPC-001 repair preserves installation-root alias identity while denying +arbitrary linked runtime ancestors before directory creation, protected-file +open or leaf trust. Only actual Darwin top-level /tmp, /var and /etc links owned +by UID 0 and resolving to their corresponding fixed /private directories are +recognized. Nested, user-owned, wrong-target or other root-owned links are denied. +Ordinary real parents retain their existing owner/mode/ACL policy; there is no +new anchor, port input, namespace, persistent trust state or metadata authority. +The inspection cursor may follow only those verified OS aliases; original +runtime, endpoint and diagnostic path spelling remains unchanged. + +Native Windows red testing first showed a protected leaf through an ancestor +junction was accepted. The initial guard then passed 63 focused cases, but +fresh-eyes review reproduced a missing/../junction escape in owned temp paths: +both directory and file creation incorrectly succeeded. After approval, the same +helper was replaced by a finite component cursor that inspects each prefix before +consuming later dot/dot-dot components and continues after missing prefixes. +Both regressions are now green, with no target content/ACL change, no listener or +publication, and no mkdir/open through the linked parent. Ordinary real-directory +dot/dot-dot paths still work. Direct leaf link/ACL denial is retained. + +Five existing fault tests initially failed because once-only lstat mocks injected +leaf-phase errors into new ancestor checks; a sixth case passed prematurely. +Authorized path-specific corrections in protected-file-faults.test.ts and +unix-security-policy.test.ts preserve the original failure/cleanup expectations +and explicitly pin the intended leaf, write/sync/close and mkdir/protection phases. +No production bypass was added to accommodate mock ordering. + +Final Windows x64 focused validation passes 95 tests in eight files. Final +`nub run check` passes 209 tests in 22 files, with 98.91% own-code line coverage; +typecheck, lint and formatting pass. Modeled Unix/Darwin alias cases and modeled +Windows drive/UNC/mixed-separator cases are not native macOS/Linux or network-share +acceptance. External compiled Windows gates pass for crash/restart/shutdown, +simultaneous installation locks/listeners/authentication and ancestor denial, +without a TS loader. Disposable runtimes and compiled bundles were removed. +Fresh-eyes/simplifier review found no further scoped defect; git diff --check passes. + +This does not close task 2.5/2.9 or G2. Native Unix matrices, full-disk cases and +actual npm/SEA distributions remain unproven; application database independence +is still path-only until task 5.1. Windows Sandbox privilege acceptance remains +user-deferred, not executed. Check-to-use races, including same-owner swaps, remain; +no atomic openat or inode/metadata binding is claimed. No host accounts, UAC, +security policy, privileges, Rust or normative requirements were changed. + +### Trailing-separator leaf correction + +MiMo Pro passed the ancestor slice and identified a trailing-separator leaf issue: +POSIX lstat can follow a directory link when the leaf ends in a slash. Modeled +POSIX prepareRuntime/assertProtected regressions both failed before correction; +native Windows junction cases already denied the same spellings, so no Windows +escape or native Unix execution is claimed from that red run. + +The correction trims only trailing leaf separators for parent computation and +leaf lstat, preserving POSIX, drive and UNC roots and every dot/dot-dot component. +Caller, ACL and diagnostic spelling remains unchanged. Reconsideration after the +local defects retained the existing necessary finite link preflight; this is a +small syntax correction, not another trust layer or algorithm redesign. Darwin +alias recognition and missing/dot-dot denial remain intact. + +Final Windows x64 focused validation passes 113 tests in eight files; full +`nub run check` passes 227 tests in 22 files with 98.92% own-code line coverage, +typecheck, lint and formatting passing. Native junction target content/ACLs are +unchanged after denial; ordinary real directories still work. Root controls are +modeled, without modifying host root permissions. External compiled Windows +runtime, installation and ancestor/trailing-leaf smokes pass without a TS loader; +disposable runtimes/bundles were removed. Fresh-eyes/simplifier review found no +further scoped defect; git diff --check passes. TOCTOU/same-owner swaps, native +Unix, application-storage/distribution proof and the remaining task7/G2 gates +remain open; Windows Sandbox remains user-deferred. + +## Integrated nine-task checkpoint + +The parent verified the final working-tree source/test set over base HEAD +`93942af84f555eab2bd68e0a4a9db487ff82d040`. This is uncommitted evidence, +not acceptance of a new committed revision. The SHA-256 fingerprint is +`fe62f295286fd9af23425ad3d16bde28da884f219329faf51c3bc67afbcc4141`. +It covers 198 source, test, script, main-spec and manifest/configuration files. +The receipt lists each repository-relative path and its file SHA-256; the +aggregate hashes sorted `pathlowercase SHA256` entries as UTF-8. +Planning/evidence documents, generated artifacts and private files are excluded. +The receipt is retained outside checkout and introduces no project tooling. + +Final parent checks for this source set: + +- `nub run check`: 227 tests in 22 files; 98.92% own-code line coverage; + typecheck, lint and formatting pass. +- `cargo test --locked --workspace`: 300 tests pass. `cargo fmt --check` and + workspace/all-target Clippy with `-D warnings` pass. Rust production behavior + and release manifests remain unchanged; two additional passthrough tests and + modes in the existing native fixture supply baseline evidence. +- Final `cargo test --locked -p cadder-shim --test shim_binary`: 8 tests pass + after the stdin/deadline/hermetic-PATH correction, including exact argument + arrays, input bytes, separate output streams and child exit codes 0 and 23. + These are actual redirected binary runs, not TTY acceptance. An earlier long + tool notification was not counted as a successful execution by itself. +- A newly compiled plain-JS runtime smoke passes on win32/x64 without a TS + loader. Its disposable staging directory was removed. +- Strict main-spec validation: 15 pass. Strict all-item validation: 17 pass, + one fails for the existing spec-free implementation change's missing-delta + mismatch. No duplicate requirements or validation-policy workaround was added. +- `git diff --check` passes; nothing is staged. An active LSP probe did not + conclusively certify all 12 source files; auxiliary AST findings are not a + replacement for the passing TypeScript compiler and maintained lint checks. + +The nine approved outcomes are reconciled individually: + +| Task | Outcome at this checkpoint | Remaining evidence or decision | +| --- | --- | --- | +| 1: CLI/shim catalog | Partial: argument/output fixtures and native passthrough proof exist; the follow-up below adds populated Windows renderer captures | Actual TTY remains unproven; renderer captures are not full operator journeys; 1.3 stays open | +| 2: configuration/model catalog | Partial: TOML/Caddyfile examples, released parser/resolver tests and all eight typed payload fixtures exist | Caddyfile examples are not a claim of a newly implemented Node parser or full adaptation parity; full 1.3 closure remains open | +| 3: spec synchronization | Delivered; strict main specs and independent delta-fidelity review pass | Roadmap remains open, not archived as completed migration | +| 4: four ports | Delivered; both material contract defects were repaired and independently reviewed | Production handlers/adapters are later work | +| 5: closed RPC | Delivered; eight methods, strict schemas, correlated outcomes and oversized-response handling are tested/reviewed | No product-handler or Rust/Node interoperability claim | +| 6: startup/teardown cleanup | Delivered for current runtime phases, including SQLite/file I/O faults and subsequent finalizers | Application-storage worker and native full-disk proof are not supplied by this slice | +| 7: installation/filesystem boundaries | Partial: live independent lock DBs/listeners and Windows leaf/ancestor/ACL denial pass | Application DB independence is path-only; current-source native Unix matrices and remaining storage evidence are missing | +| 8: Windows Sandbox privilege gate | Explicitly deferred by the user, not passed | A package and launcher were prepared/started before deferral; no real UAC or different-account acceptance was obtained | +| 9: one-revision G1/G2 reconciliation | Evidence collected and gaps identified, not gate acceptance | Compatibility catalog, native/security/storage proof and a finalized source revision remain incomplete | + +G1 and G2 remain open. The user-deferred Sandbox is not silently waived from the +accepted gate. Caddy porting, staging, commits, tags, pushes and publication were +not performed. This checkpoint does not declare the nine-task stage or the Node +migration complete. + +Final independent MiMo Pro review passes the integrated closed-RPC/four-port +slice and the nine-row evidence ledger without completion inflation. Only +`close-node-rpc-catalog` final verification task 3.1 is now complete; its own +verification artifact retains the archive/tooling blockers. The review also +records that teardown may close a received-but-undispatched request without a +correlated shutdown envelope. This is transport cancellation, not a guaranteed +RPC delivery result; no shutdown redesign was introduced. Broader tasks and +G1/G2 remain open. + +### Current-source Linux fixture evidence + +The parent additionally ran the existing compiled runtime smoke and exported +installation-runtime smoke on linux/x64 using the already-cached +`node:24.18.0-bookworm-slim` image. Node version 24.18.0 was checked for the +installation run. No image was pulled and no tools or packages were installed. +Each disposable container used UID/GID 1000, disabled networking, dropped all +capabilities, enabled no-new-privileges and a read-only root/input mount, and +kept runtime files in a writable temporary `/tmp`. Containers and external +compiled staging were removed after execution. + +Both gates pass: real Linux Unix sockets, SQLite exclusion, child crash/restart, +authenticated catalog RPC and shutdown; and two simultaneous installation +locks/listeners with independent authenticated contact and stop/restart. This +is current-source Linux fixture evidence, not npm/SEA product acceptance. +It does not supply the full Unix security/fault matrix, privileged-owner gate, +macOS native execution or application-storage behavior. The local WSL runner +had no available Node and the cached container had no sudo; neither environment +was modified to manufacture privilege evidence. The source fingerprint is +unchanged. G1/G2 and the remaining approved-task gaps stay open. + +## Executed Windows human-output catalog + +The continuation preserves the released operator's human-readable output; it +freezes renderer results for later Node parity without changing production Rust +or implementing a Node operator. Base HEAD remains +`93942af84f555eab2bd68e0a4a9db487ff82d040`; all migration work is uncommitted. + +`test/compatibility/human-output.json` contains four executed renderer captures: + +| Exact Rust test name | Covered output | UTF-8 bytes, including final LF | +| --- | --- | --- | +| list_and_status_outputs_cover_empty_and_populated_snapshots | Empty/populated status, projects and domains | 702 | +| port_output_covers_socket_and_registration_availability | Socket owners, matched routes and unavailable/empty states | 805 | +| route_outputs_cover_registered_unregistered_and_socket_notes | Registered/unregistered Caddyfiles, domains and socket notes | 2058 | +| diagnostics_and_logs_cover_empty_and_detailed_reports | Empty/populated diagnostics and bounded logs | 757 | + +The capture command for each name is: + +```sh +cargo test --locked -p cadder-client --bin cadder-client cli::output::tests:: -- --exact --nocapture --test-threads=1 --color never +``` + +The parent repeated all four commands on Windows x64 and compared the renderer +stdout between libtest markers with each fixture, preserving whitespace and the +final LF. All four comparisons pass. The initial worker captures omitted that +last LF; this was corrected before acceptance. The fixture file SHA-256 is +`eafad435c510eccb77febd4046dcbd1cbbd69f2d557e3328870832a6c3ab3bfa`. +These are renderer-test process captures, not operator IPC journeys or real TTY +runs. Windows path separators are part of the goldens; cross-platform parity +requires native captures or explicit path normalization, not reuse as portable +byte-exact output. + +The only Rust change in this continuation fixes existing test-input timestamps +to 2026-01-01; it is inside cfg(test). The Node catalog consumes the captures, +checks their final LF and absence of actual CR characters, and ties status/log +expectations to captured output. Independent MiMo Pro review identified the CR +guard and unverified status/log entries; the parent repaired them. The parent +also added explicit citations for precedence and Caddyfile examples. The retained +hostless sibling composition test proves adapted-route composition, not acceptance +of the example Caddyfile. Mock malformed-input tests and local/remote upstream +inspection tests likewise prove their named aspects, not real-Caddy parser parity. +No duplicate parser, renderer, terminal harness or validation-policy workaround +was added. + +Parent validation after these repairs: + +- `cargo test --locked -p cadder-client --bin cadder-client cli::output::tests -- --nocapture --test-threads=1`: six tests pass. +- Four exact capture commands above: one selected test passes per command and all fixture comparisons pass. +- `nub run check`: 227 tests in 22 files, including 14 catalog tests; own-code line coverage 98.92%; typecheck, lint and formatting pass. +- `cargo test --locked --workspace --quiet`: 300 tests pass; Rust formatting and workspace/all-target Clippy with `-D warnings` pass. +- Strict main-spec validation: 15 pass. Strict all-item validation: 17 pass and the same spec-free implementation missing-delta failure remains. +- Active LSP checks on the three changed code files were inconclusive, not certified clean; compiler/lint results above supply the completed checks. + +The updated source/test fingerprint uses the same receipt format as the integrated +checkpoint, adding the new human-output fixture: 199 files, SHA-256 +`cf86d7bfbf49057b24552304cd08c265dc872468f6a9fea432d2c95ec02181aa`. +The receipt stays outside checkout. Earlier runtime smokes were not rerun for +these test-only changes and are not relabeled as new native acceptance. + +MiMo Flash's independent scope assessment separates G1 catalog freezing from +later Node product execution. No bounded existing Windows TTY capture mechanism +was available for this run; source inspection is not a substitute for actual +terminal execution. Actual bare-help TTY acceptance remains missing, so 1.3/G1 +stay open. Full Node operator journeys and platform/channel parity remain later +gate obligations. G2 still lacks its remaining native/security/storage evidence; +Windows Sandbox remains user-deferred, not waived. No Caddy integration migration, +staging, commit, publication or host privilege/account changes were performed. + +Final retained MiMo Pro re-review: PASS. All five earlier findings are resolved; +the reviewer independently checked the fixture hash, byte lengths, CR/LF endings, +source citations and test-only Rust boundary and found no new defect. This closes +the rendered-catalog repair slice only. The complete evidence carrier is this +ledger entry, not the incomplete superseded worker report. Actual TTY, 1.3/G1 +and G2 remain open. + +## Approved final-product Sandbox sequencing + +The user approved completing the product before testing Windows Sandbox, and +explicitly authorized the next configuration/Caddy-preparation implementation. +This is a planning-order change, not executed security evidence or a change to +owner/elevated access requirements. Actual TTY and incomplete native/storage +checks remain open; G1/G2 have not passed. + +Updated roadmap, acceptance, design, Node scope, proposal, roadmap README and +agent guidance permit groups 3-6 implementation against delivered contract/runtime +boundaries. Tasks 2.8 and 7.4 share actual completed-product Sandbox evidence; +2.9 records final-source G2 acceptance before G7, Rust removal or release. The +retained Sandbox guide is labeled as a reference runtime-fixture procedure, +not a current implementation prerequisite or final-product proof. + +The next bounded slice preserves existing project Caddyfiles and trusted +cadder.toml selectors: resolve real Caddy, adapt project configuration and +validate JSON through bounded owned child processes. Composition, apply/rollback, +mTLS server setup and Caddy server lifecycle remain later tasks. No Sandbox, +UAC/account action, requirement waiver, public command, dependency, version, +commit or publication change is included in this sequencing decision. + +## Node Caddy preparation implementation (3.1-3.2) + +The focused `migrate-caddy-preparation/verification.md` records configuration, +trusted image pinning, bounded adaptation/validation, Windows Job ownership and +independent MiMo review followed by parent repairs. Roadmap 3.1-3.2 implementation +checks pass: final `nub run check` has 352 passing tests, two Unix-native skips and +96.34% own TS/TSX lines; the Caddy/owned-command slice has 93.75% lines. Final LCOV +includes adapter/preparation even though the compact text table hides those fully +covered files. Embedded C# has behavioral evidence, not V8 statement coverage. + +The shared-pin caller-cancellation defect and quoted Windows PATH regression +were repaired; bounded stream settlement, complete owned Job termination and +transient staging retries were verified on Windows. Root-relative Windows path +qualification was also corrected. No apply/server/CLI integration is claimed. + +`mise run check` passes Rust formatting, Clippy and 300 tests, then remains blocked +by the known OpenSpec 1.5 missing-delta failure for the two spec-free implementation +changes. Strict main specs (15), roadmap and implementation schema pass; downstream +docs/npm/cargo-dist steps were not reached. Both completed source slices remain +unarchived; no fake deltas or validation-policy workaround were introduced. + +G1/G2/G3, RUN-003 server shutdown and whole CADDY-005 acceptance remain open. +Native Unix/group-escape behavior, real Caddy, built npm/SEA and final-product +Sandbox proof remain pending. Existing dirty work is preserved and nothing was +staged, committed, published or cut over from Rust. + +## Node Caddy route composition implementation (3.3) + +The focused `migrate-caddy-composition/verification.md` records pure canonical +ownership and guarded route-plan composition in src/caddy/domains.ts and +composition.ts, with 63 dedicated JSON tests. Final `nub run check` passes +415 tests in 27 files, with two existing Unix-native skips and 96.81% own TS/TSX +line coverage; the new modules cover 167/168 lines (99.40%). + +MiMo Flash checked retained behavior and boundary risks. MiMo Pro independently +reviewed source/tests and identified two P2 notes, no P0/P1. The parent reproduced +and repaired terminal-parent pruning so it preserves chain position after its +subroute handlers disappear; a second fixture confirms opaque proxy-shaped +payloads do not contribute upstream metadata. The same reviewer rechecked those +changes and returned OK. Original upstream/timeout failures remained failures; +exact-run native resumes supplied the completed opinions without protocol fallback. + +Roadmap 3.3 closes its composition checks only. A route plan is not runnable +CaddyConfig: project admin/listener/TLS policy cannot enter it, and no validation, +apply, server or active-state integration is added. Real Caddy terminal execution +and exhaustive Node/Rust IDNA parity are not inferred from these fixtures. + +Strict main specs (15), roadmap, schema and whitespace checks pass. The maintained +OpenSpec gate still reports 17 pass and three spec-free implementation changes +rejected by the known missing-delta validator; no fake deltas or tooling-policy +workaround was added. Focused changes remain unarchived. G1/G2/G3, transactional +queue/rollback, protected mTLS, lifecycle, native/distribution acceptance and +final-product Sandbox remain open. Nothing was staged, committed or published. + +## Node configuration transaction orchestration (3.4-3.5) + +The focused `migrate-caddy-transactions/verification.md` records one daemon-owned +queue in src/daemon/configuration-transactions.ts: preparation against the latest +committed snapshot, validation, explicit apply outcomes, independent observation, +DesiredState-only atomic persistence and internal publication. Pending data remains +isolated. Persistence failure restores and verifies last-known-good, including +idle; uncertainty/failed rollback fences mutations until explicit queued recovery. + +The final parent `nub run check` passes 480 tests in 28 files, with two existing +Unix-native skips and 1155/1191 own TS/TSX lines (96.97%). The dedicated suite has +65 passing tests; the new module has 92/93 lines (98.92%) and 50/50 branches. +MiMo Flash critiqued the plan and MiMo Pro independently reviewed actual source/tests: +no P0/P1, one P2 wording item. The parent clarified that null observation means +positively observed idle, never unknown/unavailable state, and added a first-apply +regression proving failed readback stays fenced while a candidate is live. + +Roadmap 3.4-3.5 closes orchestration checks, not real runtime/storage integration. +Required production adapters have no successful mock defaults. Secure Caddy +readback/idle transitions, the SQLite worker's atomic failure contract, independently +observed initial state and registration/heartbeat ownership integration remain +later dependencies. Whole CADDY-003 and G1/G2/G3 acceptance remain open. + +Strict main specs (15), roadmap, implementation schema and whitespace checks pass. +The maintained OpenSpec gate reports 17 pass and four spec-free implementation +changes failing the known missing-delta validator. No fake delta or checker +workaround was introduced; focused changes remain unarchived. mTLS, owned server +lifecycle, native/distribution and final-product Sandbox proof remain pending. +No stage, commit, publication, real Caddy or host privilege/account action occurred. diff --git a/openspec/changes/reset-cadder-architecture/windows-runtime-gate.md b/openspec/changes/reset-cadder-architecture/windows-runtime-gate.md index a5325e6..f6ee80d 100644 --- a/openspec/changes/reset-cadder-architecture/windows-runtime-gate.md +++ b/openspec/changes/reset-cadder-architecture/windows-runtime-gate.md @@ -1,5 +1,11 @@ # Interactive Windows runtime gate +Execution is deferred by the user's approved sequencing until the actual product +is complete. Tasks 2.8 and 7.4 require final product evidence before G7 acceptance, +Rust removal or release. The runtime-fixture procedure below is retained for +reference; it does not replace actual-product acceptance and must not be launched +during the current implementation stage. + This package tests the Node runtime foundation only. It is not an npm or SEA release and does not contain product commands, Caddy, IIS or autostart mutations. The host package is mounted read-only, networking is disabled, and all runtime @@ -96,5 +102,6 @@ Back in the original non-elevated shell: Record the three markers, UAC cancellation/approval, and any errors. Close Sandbox to discard its runtime, test account profile and other disposable state. -Only after reviewing actual results can N2.4 be marked complete. This interactive -gate has not been verified merely by preparing the package. +Only reviewed actual-product results can close tasks 2.8 and 7.4. Runtime-fixture +results alone do not satisfy those tasks. This interactive gate has not been +verified merely by preparing the package. diff --git a/openspec/specs/caddy-runtime/spec.md b/openspec/specs/caddy-runtime/spec.md index 78b89a6..833d4c7 100644 --- a/openspec/specs/caddy-runtime/spec.md +++ b/openspec/specs/caddy-runtime/spec.md @@ -6,11 +6,12 @@ Define trusted real-Caddy resolution, route composition, and process ownership. ## Requirements ### Requirement: CADDY-001: Real Caddy comes only from trusted sources -Cadder MUST resolve real Caddy from an explicit foreground override, trusted configuration, or safe native PATH discovery that excludes the shim by file identity. +The daemon SHALL select real Caddy from a trusted explicit override/configuration or safe PATH, pin the executable for its lifetime and reject untrusted project/shim selectors. Resolution SHALL recognize npm wrappers, SEA entries, aliases and file identity to avoid recursive Cadder execution. -#### Scenario: Project attempts selection -- **WHEN** a project file, working directory, environment value, or shim flag attempts to select real Caddy -- **THEN** the daemon ignores or rejects that untrusted selector +#### Scenario: PATH starts with the npm or SEA shim +- **WHEN** an executable candidate resolves to a Cadder wrapper or entrypoint +- **THEN** resolution SHALL skip or reject it and continue safely or fail clearly +- **AND** it SHALL NOT recursively launch the shim ### Requirement: CADDY-002: One owned process serves composed routes The daemon SHALL adapt validated project Caddyfiles, compose active non-conflicting routes, and apply them through the one real Caddy child it owns. @@ -20,11 +21,17 @@ The daemon SHALL adapt validated project Caddyfiles, compose active non-conflict - **THEN** Cadder neither adopts nor terminates it ### Requirement: CADDY-003: Runtime transitions are verified -A desired-state mutation MUST publish only after Caddy accepts the candidate configuration and SQLite commits it; failure SHALL retain or restore the previous verified runtime. +One queue SHALL serialize preparation, adaptation/composition, validation, apply, active-state verification, SQLite commit and publication. Rejection SHALL retain last-known-good. Persistence failure after apply SHALL restore and verify the previous configuration; failed rollback SHALL fence further mutations. An ambiguous apply outcome SHALL require active-state reconciliation before the next mutation. Pending state SHALL NOT become authoritative prematurely. -#### Scenario: Persistence fails after apply -- **WHEN** Caddy accepted a candidate but SQLite cannot commit it -- **THEN** Cadder rolls Caddy back and keeps the previous authoritative state +#### Scenario: Apply response is lost +- **WHEN** the daemon cannot determine whether Caddy applied a candidate +- **THEN** it SHALL fence later changes and reconcile active state +- **AND** only verified, persisted state SHALL be published as committed + +#### Scenario: Database commit fails after successful apply +- **WHEN** Caddy accepted a candidate but persistence fails +- **THEN** the daemon SHALL restore last-known-good and retain prior state +- **AND** failed restore SHALL remain fenced with a recoverable diagnostic ### Requirement: CADDY-004: Owned listeners and routes remain local Cadder SHALL bind its owned HTTP and HTTPS servers only to IPv4 and IPv6 loopback addresses on the existing ports. Each registration's complete adapted route tree MUST remain behind an outer matcher containing only that registration's active canonical hosts, and project input MUST NOT widen either boundary. @@ -43,3 +50,16 @@ Cadder MUST capture at most 32 MiB from each `caddy adapt` output stream, reject #### Scenario: Adapter exceeds an output limit - **WHEN** `caddy adapt` writes more than 32 MiB to stdout or stderr - **THEN** Cadder reports a bounded failure without applying configuration and cleans up the owned adapter process tree if it remains active + +### Requirement: CADDY-006: Administration uses protected loopback mutual TLS +The daemon-Caddy admin channel SHALL use loopback mTLS with an internal CA, validated server identity and an owner-protected authorized client certificate. The CA SHALL NOT be installed in system trust. Plaintext local admin SHALL be disabled, and project input SHALL NOT override the administration policy. + +#### Scenario: Caller has no authorized certificate +- **WHEN** a caller contacts Caddy admin without the correct client certificate +- **THEN** Caddy SHALL refuse administrative access +- **AND** there SHALL be no unprotected listener fallback + +#### Scenario: Project supplies admin settings +- **WHEN** project configuration attempts to replace admin transport or trust +- **THEN** the daemon SHALL reject or override it with the protected policy +- **AND** no system trust-store mutation SHALL be needed diff --git a/openspec/specs/caddy-shim-integration/spec.md b/openspec/specs/caddy-shim-integration/spec.md new file mode 100644 index 0000000..fefc4ce --- /dev/null +++ b/openspec/specs/caddy-shim-integration/spec.md @@ -0,0 +1,29 @@ +# Caddy shim integration + +## Purpose +Define the PATH-facing Caddy shim command and managed runtime contract. + +## Requirements + +### Requirement: SHIM-001: Shim policy preserves command fidelity +The shim SHALL classify commands as managed, read-only inspection, explicit passthrough or unsupported and preserve argument arrays, standard streams and exit codes. Unknown commands SHALL fail rather than silently pass through. + +#### Scenario: Explicit passthrough command exits +- **WHEN** a supported passthrough command completes +- **THEN** the shim SHALL preserve its output streams and exit status +- **AND** it SHALL NOT mutate daemon-managed state through an undocumented path + +### Requirement: SHIM-002: Managed run never starts independent Caddy +Managed caddy run SHALL use daemon registration/heartbeat and MAY start a missing packaged daemon. Failure SHALL not become an independent Caddy run. Read-only inspection MAY use safely resolved real Caddy and SHALL identify its result as inspection rather than daemon state. + +#### Scenario: Managed startup fails +- **WHEN** the daemon cannot become ready for managed run +- **THEN** the shim SHALL fail with recovery guidance without unmanaged fallback + +### Requirement: SHIM-003: Both distributions avoid recursive resolution +Real Caddy resolution SHALL detect npm wrappers, SEA entries and equivalent file/alias identities, preventing recursion in local/global npm and standalone PATH layouts. The real upstream server SHALL remain separately installed. + +#### Scenario: Globally installed shim precedes upstream Caddy +- **WHEN** PATH resolves Cadder before upstream Caddy +- **THEN** the shim SHALL skip its own wrappers/entries and find a trusted candidate +- **AND** failure SHALL be bounded without recursively spawning Cadder diff --git a/openspec/specs/daemon-lifecycle/spec.md b/openspec/specs/daemon-lifecycle/spec.md index 69a0e2f..a10d388 100644 --- a/openspec/specs/daemon-lifecycle/spec.md +++ b/openspec/specs/daemon-lifecycle/spec.md @@ -6,25 +6,28 @@ Define singleton ownership, startup, and bounded shutdown for one installation r ## Requirements ### Requirement: RUN-001: One installation admits one daemon -`cadderd` MUST claim the owner-protected IPC endpoint before initializing storage or Caddy, and a second start SHALL attach to the healthy owner rather than creating competing state. +One owner runtime v2 SHALL admit one daemon through a separate SQLite lock database holding BEGIN EXCLUSIVE for the process lifetime. Metadata SHALL be diagnostic only. Startup SHALL acquire the actual lock before crash recovery, bind/publish readiness only after protected startup succeeds, and release acquired resources on failure. PID checks or expiring leases SHALL NOT grant ownership. -#### Scenario: Concurrent starts -- **WHEN** two starts race for one installation directory -- **THEN** exactly one process becomes the daemon and the other observes its readiness +#### Scenario: Concurrent start after a crash +- **WHEN** two starts encounter stale or misleading owner metadata +- **THEN** only the actual SQLite lock owner SHALL recover residue and become ready +- **AND** the other SHALL observe the existing owner or report bounded contention ### Requirement: RUN-002: Managed run may start the daemon -When attachment reports that no daemon is running, supported `caddy run` MUST launch the version-matched sibling `cadderd`, poll the exact handshake to readiness, and retry attachment. Automatic managed startup MUST NOT discover `cadderd` through PATH. An explicit daemon path MAY remain available to focused tests and foreground diagnostics. +Managed caddy run SHALL use the version-matched packaged daemon and MAY start it when missing through the supported npm or SEA path and authenticated readiness. It SHALL NOT discover an unrelated daemon through PATH or start independent Caddy. State/inspection CLI commands SHALL remain attach-only; explicit daemon start/restart and cadder tui --start-daemon SHALL retain the released bounded launch behavior. -#### Scenario: Startup fails -- **WHEN** the sibling daemon cannot be found or become ready within the bounded deadline -- **THEN** the shim reports actionable diagnostics and never launches an unrelated PATH executable or delegates an unmanaged run +#### Scenario: Packaged daemon cannot start +- **WHEN** the packaged daemon fails to become authenticated and ready +- **THEN** the shim SHALL report the failure without unmanaged Caddy fallback +- **AND** ordinary inspection commands SHALL NOT implicitly start a daemon ### Requirement: RUN-003: Shutdown is bounded and owned -Shutdown SHALL stop accepting work, drain or revoke owned operations, close SQLite, terminate only the Cadder-owned Caddy child, and release the endpoint within bounded phase deadlines. +Shutdown SHALL stop admission, settle or revoke owned work, close the application-state and diagnostic-log worker, gracefully stop the owned Caddy child, then force only that child after bounded deadlines. It SHALL release endpoint resources and the SQLite exclusion connection only after owned teardown. It SHALL NOT enumerate or kill unrelated Caddy processes. -#### Scenario: Work exceeds a phase -- **WHEN** an owned operation exceeds its shutdown phase -- **THEN** Cadder escalates only that owned work and continues teardown +#### Scenario: Graceful child shutdown stalls +- **WHEN** the owned Caddy child exceeds the graceful deadline +- **THEN** the daemon SHALL terminate only its owned process and complete teardown +- **AND** unrelated Caddy processes SHALL remain untouched ### Requirement: RUN-004: Trusted configuration is immutable per start The daemon SHALL load one non-profile configuration snapshot from trusted sources at startup and pin the selected real Caddy executable until Restart. @@ -32,3 +35,11 @@ The daemon SHALL load one non-profile configuration snapshot from trusted source #### Scenario: Configuration changes while running - **WHEN** a trusted file changes after readiness - **THEN** the active daemon retains its existing snapshot until Restart + +### Requirement: RUN-005: Owner clients can contact explicitly elevated daemons +Normal operation SHALL run at user privilege. A same-owner ordinary client SHALL retain authenticated access to an explicitly elevated daemon. Unix root startup SHALL require an explicit runtime directory and owner UID. Other users and remote clients SHALL be denied without weakening filesystem or ACL policy. + +#### Scenario: Ordinary owner contacts elevated endpoint +- **WHEN** the ordinary owner authenticates to an explicitly elevated daemon +- **THEN** documented operations SHALL remain available under the same policy +- **AND** a different account SHALL be rejected before product handlers execute diff --git a/openspec/specs/distribution-and-upgrades/spec.md b/openspec/specs/distribution-and-upgrades/spec.md index d5b50b5..3c50a1d 100644 --- a/openspec/specs/distribution-and-upgrades/spec.md +++ b/openspec/specs/distribution-and-upgrades/spec.md @@ -4,74 +4,40 @@ Define maintained portable releases and coherent manual upgrades. ## Requirements ### Requirement: DIST-001: Release binaries are version matched -Cadder SHALL version and release the `cadder`, `cadderd`, and `caddy` executables together in one application archive for Windows x64, Linux x64, Intel macOS, and Apple Silicon macOS. +Cadder SHALL have equal, required npm and Node SEA GitHub Release channels with the same version and cadder/cadderd/caddy functionality. Standalone targets SHALL include Windows x64, Linux x64, macOS x64 and macOS arm64. Both channels SHALL pass acceptance before migration completion or final release readiness. -#### Scenario: Unified tag is planned -- **WHEN** cargo-dist plans tag `v1.0.0` -- **THEN** it includes one 1.0.0 application with all three executables on all four targets +#### Scenario: One channel is ready +- **WHEN** npm passes but any supported standalone variant remains unverified +- **THEN** the migration SHALL remain incomplete rather than treating SEA as optional ### Requirement: DIST-002: Archives are portable and verifiable -Each cargo-dist archive MUST contain exactly the three expected executables, README, changelog, Apache-2.0 license, and sample configuration and MUST have a SHA-256 checksum. +Each standalone archive SHALL contain three version-matched Node SEA entries and required accepted assets, a SHA-256 checksum and release-source provenance. Every target SHALL be extracted outside checkout and pass native CLI/TUI, state/log SQLite worker and owned subprocess smoke without Node/npm on PATH. Standalone SHALL NOT require installed Node, download runtime components or bundle real Caddy. Existing release attestation verification SHALL bind assets to their exact tag/source commit/repository/signer before publication. -Before publication, each archive MUST be extracted into a fresh directory outside the source checkout on its native operating system. The verifier MUST check the checksum, exact file set, executable permissions where applicable, and successful `--version` and `--help` behavior for all three executables. Published archives, checksums, source archive, cargo-dist manifest MUST carry GitHub artifact attestations from the release workflow. A downstream package workflow MUST bind attestation verification to the selected tag ref, its exact source commit, the Cadder repository, and that signer workflow. - -#### Scenario: User downloads a release -- **WHEN** an archive and checksum are obtained from one release -- **THEN** the checksum can be verified before extraction without an installer -- **AND** a GitHub attestation can associate the asset with the exact release source and workflow - -#### Scenario: Release archive is verified -- **WHEN** a release archive is evaluated before publication or downstream packaging -- **THEN** only the accepted binaries and static release files are present -- **AND** each executable reports the planned version and exposes help outside the source checkout -- **AND** Unix executables retain their executable permissions -- **AND** downstream verification rejects an attestation for a different ref, commit, repository, or signer workflow +#### Scenario: Consumer has no Node installation +- **WHEN** a supported standalone archive is used with Node/npm absent from PATH +- **THEN** all three entries, TUI, SQLite worker and owned child workflows SHALL work +- **AND** checksum/provenance verification SHALL apply to the exact tested archive ### Requirement: DIST-003: Upgrades replace the coherent executable set -Public guidance MUST instruct the user to stop through the TUI, replace all three executables from one release, and restart through managed run or the TUI. - -#### Scenario: Mixed versions connect -- **WHEN** only part of the executable set was replaced -- **THEN** the exact handshake rejects the mismatch before mutation - -### Requirement: DIST-004: npm installs the coherent native application - -Cadder SHALL publish one public `cadder` launcher package and one platform package for each supported release target. Every package in one release MUST use the same version, and the root package MUST depend on the matching platform packages by exact optional dependency versions. - -The root package MUST expose `cadder`, `cadderd`, and the Cadder `caddy` shim. It MUST select only the package matching the current operating system, architecture, and C runtime, launch the native executable without a shell, preserve standard streams and exit status, and report an actionable error for an unsupported platform or omitted optional dependency. - -The npm installation MUST NOT bundle, download, replace, or claim to provide the independently installed upstream Caddy server. +Transition guidance SHALL require stopping the old daemon before switching the coherent release set. Node SHALL re-register projects from existing files without importing or deleting old runtime data. Old releases SHALL remain available for rollback without automatic deprecation. Mixed Rust/Node IPC SHALL not be supported. Transition SHALL NOT require IIS handoff or autostart operations absent from the released product. -#### Scenario: One-off operator invocation +#### Scenario: User switches to Node and later rolls back +- **WHEN** the user follows the documented transition with each daemon stopped +- **THEN** existing configuration and old data/releases SHALL remain available +- **AND** rollback SHALL use the matched old set, not mixed IPC or imported old data -- **WHEN** a user runs `npx cadder` on a supported platform with optional dependencies enabled -- **THEN** the matching native `cadder` executable runs with the requested arguments -- **AND** its version matches the root and platform package versions +### Requirement: DIST-004: npm installs one Node application package +npm SHALL deliver one cadder tarball with three bin entries on Node 24 LTS, minimum 24.18.0. It SHALL contain compiled JS and required assets from the shared TS sources, not platform optionalDependencies or native Rust executables. Installation SHALL require no TS loader/compiler, install-time build, postinstall runtime download or bundled upstream Caddy. Local/global installs SHALL preserve arguments, streams, status and non-recursive PATH behavior. -#### Scenario: Global installation - -- **WHEN** a user installs `cadder` globally on a supported platform -- **THEN** `cadder`, `cadderd`, and the Cadder `caddy` shim are available as commands -- **AND** all three native executables report the same release version - -#### Scenario: Upstream Caddy remains separate - -- **WHEN** the npm-installed Cadder shim needs a real Caddy executable -- **THEN** the npm launcher cannot be selected as upstream Caddy -- **AND** real Caddy resolution continues through Cadder's existing trusted sources - -#### Scenario: Optional package is unavailable - -- **WHEN** the platform package was omitted or the current platform is unsupported -- **THEN** the launcher exits unsuccessfully without downloading or compiling an executable -- **AND** the diagnostic identifies the missing package or unsupported target +#### Scenario: Clean consumer installs the packed tarball +- **WHEN** a supported consumer installs locally or globally without build tools +- **THEN** cadder, cadderd and the caddy shim SHALL run from the one package +- **AND** upstream Caddy SHALL remain a separately configured prerequisite ### Requirement: DIST-005: npm and portable archives share release identity +npm and SEA SHALL be built from the same source revision with one matching release version and pass the same functional catalog. Their artifact bytes need not match because packaging differs. A verified 2.0.0-rc.1 in both channels SHALL precede 2.0.0 acceptance. Tagging and publication SHALL require separate explicit authorization; artifact preparation alone SHALL NOT publish either channel. -Every npm platform package SHALL contain the exact three executable files taken from the corresponding verified cargo-dist release archive. npm packaging MUST preserve executable permissions where applicable and MUST NOT alter the native executable bytes. - -#### Scenario: Release channel comparison - -- **WHEN** a native executable from an npm platform package is compared with the corresponding verified GitHub Release archive -- **THEN** their cryptographic digests match -- **AND** both report the same Cadder release version +#### Scenario: Candidate channels are compared +- **WHEN** packed npm and extracted SEA are accepted for a candidate +- **THEN** source revision, reported version and observable functional outcomes SHALL match +- **AND** no publication SHALL occur without its separate authority diff --git a/openspec/specs/documentation-experience/spec.md b/openspec/specs/documentation-experience/spec.md index cdd01db..1370ec0 100644 --- a/openspec/specs/documentation-experience/spec.md +++ b/openspec/specs/documentation-experience/spec.md @@ -6,18 +6,20 @@ Define accurate, audience-scoped documentation for users and contributors. ## Requirements ### Requirement: DOC-001: User documentation follows the retained journey -User documentation SHALL describe portable extraction, trusted real-Caddy configuration, managed `caddy run`, the TUI, and coherent manual upgrades without removed CLI, profile, alias installer, autostart, history, export, tail, watch, mock, or hidden-flag claims. +Astro/Starlight documentation SHALL describe verified Node routes-first TUI, existing CLI lifecycle/project/domain/inspection commands, bounded diagnostic logs and coherent transition workflows in English, without exposing private/test-only context. Published 2.0 instructions SHALL only advertise flows that pass actual product acceptance. -#### Scenario: New user follows Quick Start -- **WHEN** a user follows the published steps -- **THEN** every command exists in Cadder 1.0 and no repository-only seam is required +#### Scenario: User follows a Node guide +- **WHEN** a user follows a documented 2.0 workflow +- **THEN** commands SHALL exist in the tested product rather than fixture handlers +- **AND** old Rust data SHALL remain protected during the documented transition ### Requirement: DOC-002: Contributor tooling is direct and reproducible -Contributor documentation SHALL use pinned mise tasks and direct maintained tools for Rust, OpenSpec, Astro, coverage, and cargo-dist. +Contributor documentation SHALL use the exact Node/Nub TS, OpenSpec, Astro, coverage and npm/SEA workflow, replacing Bun and obsolete Rust tasks after the cutover gate. Sources SHALL remain TS/TSX and generated artifacts SHALL not be committed. SEA's separate Node builder SHALL not change npm's Node minimum. -#### Scenario: Contributor discovers tasks -- **WHEN** a contributor runs `mise tasks` -- **THEN** formatting, linting, tests, specs, docs, coverage, TUI web preview, and distribution tasks are visible +#### Scenario: Contributor prepares artifacts +- **WHEN** a contributor follows the documented pack verification workflow +- **THEN** both channels SHALL use the shared sources and outside-checkout staging +- **AND** no loader/build SHALL be required during consumer npm installation ### Requirement: DOC-003: Private context does not leak Published documentation MUST NOT contain personal absolute paths, `.local`, credentials, mock-only commands, or agent-only operational details. @@ -27,24 +29,9 @@ Published documentation MUST NOT contain personal absolute paths, `.local`, cred - **THEN** only portable reader-facing content is included ### Requirement: DOC-004: Installation guidance distinguishes npm, Cadder, and Caddy +Verified npm and standalone GitHub downloads SHALL be presented as equal channels with the same functionality/version. npm SHALL document Node 24 LTS minimum 24.18.0; standalone SHALL require no installed Node/npm. Both SHALL identify upstream Caddy as separate. Unpublished/unverified versions SHALL not be presented as available, and SEA's mechanism status SHALL not demote its channel. -Public installation guidance SHALL present `npx cadder`, global npm installation, and portable GitHub archives only when the corresponding channel has passed its release verification. It MUST explain that npm installs the Cadder operator, daemon, and `caddy` shim while the upstream Caddy web server remains a separate prerequisite. - -The hero and getting-started path MUST give users adjacent actions for npm installation or download and documentation without presenting an unpublished package as available. When npm is available, the hero MUST keep the upstream Caddy prerequisite beside a complete first-run Cadder command that starts or attaches to the daemon and opens the operator without an undisclosed interaction. - -#### Scenario: npm channel is not yet public - -- **WHEN** the current Cadder version is not verified in the public npm registry -- **THEN** published documentation does not instruct users to install that version from npm - -#### Scenario: npm channel is verified - -- **WHEN** the exact Cadder version passes clean-room registry installation on every supported platform -- **THEN** the hero and installation guide may present npm as a supported option -- **AND** they retain a direct GitHub download path and the separate upstream Caddy requirement - -#### Scenario: New user follows the hero - -- **WHEN** a new user reads the npm-enabled hero -- **THEN** the user can identify the separate upstream Caddy prerequisite -- **AND** copy one Cadder command that starts or attaches to the daemon and opens the TUI without another required interaction +#### Scenario: Consumer chooses standalone +- **WHEN** the consumer selects a supported GitHub archive instead of npm +- **THEN** instructions SHALL offer the same product workflow without Node installation +- **AND** neither channel SHALL be described as secondary or optional diff --git a/openspec/specs/local-control-plane/spec.md b/openspec/specs/local-control-plane/spec.md index fc299dc..76a6037 100644 --- a/openspec/specs/local-control-plane/spec.md +++ b/openspec/specs/local-control-plane/spec.md @@ -6,22 +6,30 @@ Define the owner-authenticated exact-version IPC contract. ## Requirements ### Requirement: IPC-001: Local transport is owner authenticated and bounded -Cadder MUST use owner-protected local IPC with peer authentication and newline-delimited JSON frames no larger than 1 MiB. +Cadder SHALL use NDJSON frames no larger than 1 MiB over Unix sockets or Windows named pipes with bounded connections/timeouts, owner-only protected runtime/secret storage and security policy 2. Both peers SHALL authenticate using fresh direction-bound HMAC challenges before RPC. Secret bytes SHALL NOT cross IPC. Unsafe ownership, links, modes or ACLs SHALL fail closed rather than be silently repaired or weakened. -#### Scenario: Different user connects -- **WHEN** the peer identity does not match the runtime owner -- **THEN** Cadder rejects the connection without disclosing private account data +#### Scenario: Spoofed endpoint or unsafe secret file +- **WHEN** endpoint proof fails or the secret/runtime protection is unsafe +- **THEN** the client or daemon SHALL reject contact without performing RPC +- **AND** it SHALL NOT transmit the secret or repair the unsafe boundary implicitly + +#### Scenario: Replay or reflected proof +- **WHEN** a prior proof or authenticated RPC frame is replayed or reflected +- **THEN** fresh challenge or directional sequence validation SHALL reject it +- **AND** no product mutation SHALL execute ### Requirement: IPC-002: Handshake requires one exact version -Every connection MUST begin with one exact protocol-version handshake, and a mismatch MUST close before dispatching an operation. +Every connection SHALL validate protocol 3 and security policy 2 during mutual authentication and SHALL reject mismatches before product dispatch. The Node product SHALL NOT communicate with Rust IPC or negotiate legacy capabilities. -#### Scenario: Mixed binaries -- **WHEN** a client and daemon use different protocol versions -- **THEN** the daemon returns a correlated incompatibility error and performs no mutation +#### Scenario: Rust daemon remains running +- **WHEN** a Node client encounters an old Rust endpoint or incompatible peer +- **THEN** it SHALL NOT attach or mutate it +- **AND** isolated v2 discovery SHALL preserve the old runtime and its data ### Requirement: IPC-003: One typed envelope carries eight operations -The protocol SHALL use one typed request envelope and one correlated result-or-error response envelope for register, unregister, heartbeat, query state, set entrypoint activation, set domain activation, query logs, and shutdown. +RPC SHALL preserve the eight released operations: register, unregister, heartbeat, query state, set entrypoint activation, set domain activation, query logs and shutdown. Zod SHALL validate their closed method/payload/result/error catalog; unknown methods/fields SHALL be rejected. Each response SHALL correlate the request and contain exactly one result or typed error. CLI/TUI SHALL use one shared client service. Migration SHALL NOT add history, IIS, autostart or presentation-specific RPC methods. -#### Scenario: Unknown operation or field -- **WHEN** a request names an operation or payload field outside the exact contract -- **THEN** Cadder returns a typed rejection and does not invoke an ad hoc decoder +#### Scenario: Malformed product request +- **WHEN** a request violates its method schema or names an unknown method +- **THEN** it SHALL receive a bounded typed rejection without invoking a handler +- **AND** valid responses SHALL contain exactly one correlated outcome diff --git a/openspec/specs/operator-cli/spec.md b/openspec/specs/operator-cli/spec.md index a4292c6..2c2fa16 100644 --- a/openspec/specs/operator-cli/spec.md +++ b/openspec/specs/operator-cli/spec.md @@ -4,37 +4,21 @@ Define the intentionally small command surface of the operator executable. ## Requirements ### Requirement: CLI-001: The operator exposes focused developer workflows -`cadder` MUST support help, version, `tui`, daemon lifecycle and status, project listing and activation, domain listing and activation, port inspection and guarded process termination, Caddyfile inspection, explicit diagnostics, and bounded redacted log reads. The TUI command MUST support an explicit `--start-daemon` option that reuses the existing attach-first daemon launch contract before opening the operator. It MUST reject profile, machine-output, history, export, continuous tail, watch, and autostart commands. +Commander-based cadder SHALL preserve the released 1.0.5 help/version, status, daemon lifecycle, project/domain controls, port/Caddyfile inspection, diagnostics and bounded redacted log commands, with stable arguments, human-readable output and exit status through the shared client service. Bare cadder SHALL print help successfully without starting TUI or the daemon, regardless of TTY. cadder tui SHALL open the routes workspace without implicit daemon startup; its explicit --start-daemon option SHALL use the shared attach-first bounded launch contract. Explicit daemon start/restart and managed caddy run SHALL retain their launch policy. Other state and inspection commands SHALL remain attach-only. Profile, machine-output, history, export, continuous tail, watch, IIS and autostart commands SHALL be rejected as unsupported. #### Scenario: Bare invocation -- **WHEN** a user runs `cadder` without a subcommand -- **THEN** Cadder prints help successfully without starting the TUI +- **WHEN** cadder has no subcommand, with or without a TTY +- **THEN** it SHALL print help successfully without starting a daemon or interactive session -#### Scenario: TUI invocation -- **WHEN** a user runs `cadder tui` -- **THEN** Cadder opens the interactive operator without changing the existing daemon lifecycle +#### Scenario: Explicit TUI startup option +- **WHEN** the user runs cadder tui --start-daemon +- **THEN** Cadder SHALL start or attach through the bounded shared launch path before opening the TUI -#### Scenario: TUI invocation with daemon startup -- **WHEN** a user runs `cadder tui --start-daemon` -- **THEN** Cadder starts or attaches to `cadderd` through the existing bounded launch path -- **AND** opens the interactive operator after the daemon is ready without requiring another interaction +#### Scenario: CLI state command while daemon is offline +- **WHEN** a state command cannot attach +- **THEN** it SHALL report actionable offline state with the stable exit contract +- **AND** it SHALL NOT start a daemon implicitly -#### Scenario: General state inspection -- **WHEN** a user runs `cadder status`, `cadder projects list`, or `cadder domains list` -- **THEN** Cadder reports current daemon-backed state using developer-facing project and domain terminology - -#### Scenario: Activation management -- **WHEN** a user explicitly enables or disables a selected project or domain -- **THEN** Cadder applies the mutation through the daemon and reports the result - -#### Scenario: Daemon lifecycle management -- **WHEN** a user explicitly starts, stops, or restarts `cadderd` -- **THEN** Cadder uses the existing attach-first, bounded stop, or ordered restart operation and reports the outcome - -#### Scenario: On-demand diagnostics -- **WHEN** a user requests diagnostics or bounded logs for the runtime, a project, or a domain -- **THEN** Cadder reports the selected diagnostic data without adding it back to the primary TUI - -#### Scenario: Removed command -- **WHEN** a user invokes a profile, machine-output, history, export, continuous tail, watch, or autostart command -- **THEN** Cadder rejects it as unsupported CLI input +#### Scenario: Unsupported expansion is requested +- **WHEN** the user requests machine output, history, export, continuous tail, watch, profiles, IIS or autostart +- **THEN** Cadder SHALL reject the unsupported input rather than restoring an older command surface diff --git a/openspec/specs/operator-tui/spec.md b/openspec/specs/operator-tui/spec.md index 2a083ec..35ce9bc 100644 --- a/openspec/specs/operator-tui/spec.md +++ b/openspec/specs/operator-tui/spec.md @@ -6,11 +6,12 @@ Define the keyboard-operated Cadder 1.0 interface. ## Requirements ### Requirement: TUI-001: One routes-first workspace exposes current state -The TUI SHALL render every project and domain in one primary table and SHALL support collections of arbitrary length without fixed row-count assumptions. Domain rows SHALL use tree connectors beneath their project, and projects SHALL follow one another without empty separator rows. The TUI SHALL NOT expose a separate activity or logs view. +Ink/React TUI SHALL render real daemon-backed projects and domains in one routes-first primary table through the same client service used by CLI, without production mocks or random statuses. Domain rows SHALL use tree connectors beneath their project, with no empty separator rows between projects. Collections SHALL support scrolling without fixed lengths. The TUI SHALL NOT add separate activity, logs, history, IIS or autostart views; explicit diagnostics and bounded logs SHALL remain CLI surfaces. -#### Scenario: Many registrations -- **WHEN** more projects or domains exist than fit in the viewport -- **THEN** selection and scrolling remain bounded by the actual collection length +#### Scenario: Operator changes a domain +- **WHEN** the user changes activation in the routes workspace +- **THEN** the TUI SHALL render committed daemon state without unrelated diagnostic details +- **AND** fixture data SHALL be limited to tests/development, not production ### Requirement: TUI-002: Routine state is quiet and exceptional state is actionable The TUI SHALL keep `cadderd` connection state and Caddy runtime state separately visible in the header, SHALL use filled and empty shape markers for route activation, and SHALL omit diagnostic identifiers, redundant counts, and repeated state labels from the primary workspace. Neutral colors SHALL carry routine structure while the green accent is reserved for focus, enabled state, and available keys. @@ -24,11 +25,12 @@ The TUI SHALL keep `cadderd` connection state and Caddy runtime state separately - **THEN** the interface presents the failure and available recovery guidance inline ### Requirement: TUI-003: Lifecycle actions are explicit -The primary workspace MUST provide idempotent Start, confirmed bounded Stop, and ordered Restart actions with pending, offline, failure, and success states. +TUI SHALL offer idempotent explicit start while offline, confirmed bounded stop and ordered restart with pending/success/failure states. Startup SHALL follow the shared client launch boundary. Opening cadder tui without --start-daemon SHALL NOT imply daemon start; the explicit --start-daemon option SHALL start or attach before rendering. -#### Scenario: Restart -- **WHEN** the user confirms Restart -- **THEN** the TUI observes shutdown before launching and confirming the new daemon +#### Scenario: Offline TUI opens +- **WHEN** the user opens cadder tui without --start-daemon and no daemon is running +- **THEN** TUI SHALL display offline state and offer an explicit start action +- **AND** restart SHALL observe prior shutdown before starting the replacement ### Requirement: TUI-004: Rendering remains accessible and recoverable The TUI MUST support keyboard-only navigation, visible focus, shape-based state meaning independent of color, bounded layouts, and terminal restoration after success, error, panic, or cancellation. Space and Enter SHALL both toggle the selected project or domain while Enter SHALL start `cadderd` when the daemon is offline. diff --git a/openspec/specs/product-topology/spec.md b/openspec/specs/product-topology/spec.md index 8eefc13..04ad431 100644 --- a/openspec/specs/product-topology/spec.md +++ b/openspec/specs/product-topology/spec.md @@ -4,19 +4,22 @@ Define the minimal Cadder 1.0 product boundary, executable set, and separation between everyday and diagnostic surfaces. ## Requirements ### Requirement: TOP-001: Three executables form one installation -Cadder SHALL ship version-matched `cadder`, `cadderd`, and `caddy` executables that derive one runtime identity from their installation directory. +Cadder SHALL provide version-matched cadder, cadderd and caddy entrypoints from one TypeScript product in both npm and standalone distributions. The daemon SHALL be the sole runtime/state/process owner. Runtime v2 SHALL be owner-protected and installation-specific, with deliberately isolated dev/test runtimes; separate installation directories SHALL retain independent endpoints and application databases. Rust/Cargo SHALL be removed from the final product only after complete Node functionality and both release gates. + +#### Scenario: Distribution channel changes +- **WHEN** the owner uses npm or standalone Cadder of the same version +- **THEN** both SHALL expose the same product contracts and owner runtime policy +- **AND** clients SHALL NOT create independent Caddy or persistent state owners #### Scenario: Separate installations -- **WHEN** the same user runs executables from two different installation directories -- **THEN** each installation uses an independent endpoint and database +- **WHEN** the same owner runs Cadder from two different installation directories +- **THEN** each installation SHALL use an independent endpoint and application database +- **AND** each installation's three entrypoints SHALL agree on its runtime identity ### Requirement: TOP-002: Product and diagnostic surfaces remain distinct -Managed `caddy run`, the read-oriented and activation-oriented `cadder` CLI, and `cadder tui` SHALL be the user-facing 1.0 surfaces, while foreground `cadderd`, bounded logs, and explicit process details SHALL remain diagnostic surfaces. MCP SHALL NOT be presented as a Cadder product surface. - -#### Scenario: User opens the operator -- **WHEN** the user runs `cadder tui` -- **THEN** Cadder opens the routes workspace with daemon, Caddy, and activation state +Cadder SHALL expose CLI and TUI workflows through one client service/model boundary, with diagnostics requested explicitly. Production state SHALL come from the daemon, not mocks or random values. The current routes-first TUI and bounded CLI diagnostic surfaces SHALL remain distinct. This migration SHALL NOT restore superseded history, IIS, autostart, machine-output, profile, export, watch or continuous-tail surfaces, or include MCP, Web UI, Tauri, Bun, custom WASM, native runtime addons or new installers. -#### Scenario: Developer inspects current state -- **WHEN** the user runs a supported status, project, domain, port, or Caddyfile command -- **THEN** Cadder reports the requested current state without exposing unrelated local diagnostics +#### Scenario: Client renders project state +- **WHEN** CLI or TUI renders a project, domain or lifecycle result +- **THEN** both SHALL use the same daemon-backed model and canonical state +- **AND** neither SHALL inspect daemon storage or Caddy administration directly diff --git a/openspec/specs/project-registration/spec.md b/openspec/specs/project-registration/spec.md index 99548dc..24fa0fb 100644 --- a/openspec/specs/project-registration/spec.md +++ b/openspec/specs/project-registration/spec.md @@ -6,11 +6,11 @@ Define live shim-owned entrypoints and exclusive domains. ## Requirements ### Requirement: REG-001: Registration belongs to one live shim session -Each managed run MUST register a validated entrypoint with a session nonce, renew it by heartbeat, reconnect safely, and unregister it on clean exit or connection loss. +Managed run SHALL register a validated entrypoint owned by a session nonce, renew it through heartbeat, reconnect safely and unregister on clean exit or lost ownership. Ctrl+C, expired heartbeat and daemon restart SHALL not leave a stale active route. Another session SHALL NOT alter the current owner's lease. -#### Scenario: Heartbeat owner differs -- **WHEN** a heartbeat or detach uses the wrong session nonce -- **THEN** Cadder rejects it without changing the registration +#### Scenario: Old shim detaches a replacement session +- **WHEN** a stale shim sends heartbeat or unregister for a replacement owner +- **THEN** the daemon SHALL reject it and preserve current registration ### Requirement: REG-002: Domains have one active owner Canonical domain ownership MUST be exclusive across active entrypoints, regardless of input case or international-domain spelling. @@ -25,3 +25,11 @@ Cadder MAY restore stable entrypoint identity and desired activation after resta #### Scenario: Daemon restarts - **WHEN** SQLite contains a previously committed entrypoint - **THEN** it remains inactive until a current shim registers it + +### Requirement: REG-004: Existing project formats and controls remain compatible +The Node product SHALL preserve existing cadder.toml and Caddyfiles, documented configuration precedence, multi-project registration, domain filtering and activation workflows. It SHALL validate inputs through the shared contract and reject active domain conflicts without replacing the last committed owner. + +#### Scenario: Existing project is registered after transition +- **WHEN** the user registers an existing configuration through Node managed run +- **THEN** the daemon SHALL accept its retained format without a project rewrite +- **AND** active domain conflicts SHALL preserve the previous committed state diff --git a/openspec/specs/quality-tooling/spec.md b/openspec/specs/quality-tooling/spec.md index 647d95e..1804da9 100644 --- a/openspec/specs/quality-tooling/spec.md +++ b/openspec/specs/quality-tooling/spec.md @@ -4,119 +4,58 @@ Define the maintained contributor toolchain, validation gate, and release automation boundary. ## Requirements ### Requirement: QT-001: Declarative contributor environment -Cadder SHALL define its supported development-tool versions, task entrypoints, task dependencies, working directories, and non-secret development environment values in committed `mise` configuration with a committed lockfile. +The Node product SHALL declare exact dependencies, tool versions and locks, managed by Nub, without private paths/credentials. TypeScript SHALL be 6.0.3; the npm-tested Node minimum SHALL be 24.18.0 on Node 24 LTS. SEA SHALL use a separately verified, exact Node 26 builder pin. Required tools SHALL work on Windows/Linux/macOS without a custom compiled task runner after cutover. -The configuration MUST work from a fresh checkout on Windows, Linux, and macOS without requiring a repository-specific compiled task runner. It MUST NOT contain credentials, personal paths, `.local` paths, or values that alter ordinary product behavior outside an explicitly scoped development task. - -#### Scenario: Fresh contributor setup -- **WHEN** a contributor installs `mise` and prepares the declared project environment from a fresh checkout -- **THEN** the pinned Rust, documentation, specification, coverage, and release tools become available -- **AND** no `xtask`, `just`, Nushell, or private workstation configuration is required - -#### Scenario: Development-only environment -- **WHEN** a contributor runs a task that requires the mock Caddy backend -- **THEN** the mock setting applies only to that task and its children -- **AND** an ordinary Cadder command does not inherit the mock setting from committed global configuration +#### Scenario: Fresh Node-only checkout +- **WHEN** a contributor follows the declared setup after Rust removal +- **THEN** Node tests/build/docs/packaging SHALL work without Cargo/Rust or private setup ### Requirement: QT-002: One discoverable task surface -The supported repository task surface SHALL be discoverable through `mise tasks` and SHALL use `mise run ` as its stable documented invocation. - -Tasks MUST remain small adapters to an owning tool. The repository MUST NOT add a second general command runner, embed substantial shell programs in task configuration, or recreate a command-dispatch framework in Rust or another language. +Node validation/build/pack tasks SHALL be discoverable as Nub package scripts, with focused commands and a complete check. Existing tool provisioning MAY remain as a thin adapter during cutover, not a competing policy framework. Custom scripts SHALL be limited to Cadder-specific packaging/acceptance seams. -#### Scenario: Contributor discovers validation commands -- **WHEN** a contributor lists project tasks -- **THEN** the output identifies focused formatting, linting, testing, specification, documentation, coverage, complete-check, and release-verification tasks -- **AND** each task delegates its work to the tool that owns that responsibility - -#### Scenario: New automation is proposed -- **WHEN** a proposed task would parse a maintained tool's format or reproduce its policy -- **THEN** the proposal is rejected in favor of that tool's supported command or configuration -- **AND** any claimed exception requires an explicit Cadder product invariant and a focused verification owner +#### Scenario: Contributor runs complete checks +- **WHEN** the documented Node check/pack verification commands run +- **THEN** each SHALL delegate to its owning maintained tool +- **AND** there SHALL be no replacement general-purpose custom command dispatcher ### Requirement: QT-003: Maintained tools own validation and release formats -Cargo SHALL own Rust formatting, linting, builds, and tests; `cargo-llvm-cov` SHALL own coverage calculation and the configured threshold; the official OpenSpec CLI SHALL own specification validation; npm and Astro SHALL own documentation dependency and build checks; and `cargo-dist` SHALL own release planning, portable archives, included release files, SHA-256 checksums, and generated GitHub Release automation. - -Cadder MAY apply one deterministic, focused postprocessor to cargo-dist's generated workflow only for security invariants that cargo-dist cannot express. The adapter MUST fail when its expected generated structure changes, MUST be verified by the supported repository task surface, and MUST NOT recreate release planning, artifact construction, checksums, or publication policy. +tsc noEmit, ESLint, Prettier, Vitest/V8 and ink-testing-library SHALL own their checks; own TS/TSX line coverage SHALL be at least 85%, including hard runtime modules without selective exclusion. npm emission SHALL use tsc with relative import rewriting; SEA bundling SHALL use esbuild and the pinned Node builder. All generated Node JS/bundles/archives/coverage SHALL go to system or runner temp outside checkout, never beside source or into tracked files. Astro/Starlight SHALL own documentation checks/builds with Nub-managed dependencies. -Cadder-specific executable behavior MUST be verified by tests in the package that owns the executable. Repository checks that only restate a native manifest, generated release plan, or accepted OpenSpec declaration MUST NOT be retained as independent policy engines. - -#### Scenario: Complete repository gate -- **WHEN** a contributor or CI runs the complete supported check task -- **THEN** each required validation runs through its owning maintained tool or focused product test -- **AND** a failure identifies the owning validation boundary - -#### Scenario: Portable release is planned -- **WHEN** release automation plans a Cadder version -- **THEN** `cargo-dist` describes one portable application for every supported target with the accepted binaries, included files, and SHA-256 checksums -- **AND** the committed workflow equals cargo-dist's generated output after the focused security hardening pass +#### Scenario: Pack verification runs +- **WHEN** the same source is checked and packed for either channel +- **THEN** the supported tools SHALL produce only outside-checkout Node artifacts +- **AND** tracked files SHALL remain unchanged with no emitted source-side JS ### Requirement: QT-004: Local and CI task parity -Continuous integration SHALL invoke the same supported task definitions used locally for platform-independent repository validation. Platform-specific jobs MAY add only the runner, target, permissions, or external service evidence required by their operating-system contract. - -Release pull requests MUST build and upload the complete cargo-dist artifact plan without publishing a release. Planning and artifact-build jobs MUST have read-only repository permissions and MUST NOT receive a publication token. Release tag values MUST be validated as data outside generated shell source. Release publication MUST remain separate from general validation, MUST use the source revision and artifact plan accepted by the release gate, MUST verify the exact tagged archives on their native operating-system runners before announce, and MUST grant write permission only to the stages that attest or publish immutable release assets. - -#### Scenario: Pull request validation -- **WHEN** a pull request runs repository validation -- **THEN** CI installs the pinned project environment and invokes the documented `mise run` tasks -- **AND** the jobs do not receive release publication credentials +CI SHALL invoke the same checks and non-publishing packing process as local verification, with native Windows/Linux/macOS system/security integration and every SEA target. PR jobs SHALL not publish or receive publication credentials. Native exact-artifact verification SHALL precede attest/publish stages with least privilege and source/tag validation. Documentation examples SHALL be tested. -#### Scenario: Release candidate pull request -- **WHEN** cargo-dist evaluates a release pull request -- **THEN** it builds and uploads the complete portable artifact set for inspection with read-only repository access -- **AND** it does not create or modify a public GitHub Release - -#### Scenario: Platform-specific evidence -- **WHEN** a requirement needs Windows, Linux, macOS, or Docker evidence -- **THEN** CI runs the focused task on the required runner or service -- **AND** the platform job does not introduce an alternative repository task implementation - -#### Scenario: Tagged release publication -- **WHEN** a validated supported release tag is built -- **THEN** every target archive is verified on its native runner from a fresh directory outside the checkout -- **AND** GitHub Release publication waits for all verification jobs to succeed and receives write permission only in the publishing path +#### Scenario: Candidate PR is validated +- **WHEN** CI verifies a migration or release candidate +- **THEN** it SHALL check contracts, coverage, native product behavior and both channels +- **AND** artifact preparation SHALL NOT create a public release or registry publication ### Requirement: QT-005: Evidence-gated task-runner removal -The `xtask` package and command surface SHALL be removed only after every retained responsibility is mapped to a maintained tool, a focused owning-package test, or an explicit accepted removal rationale. - -The replacement release path MUST demonstrate the complete supported artifact matrix without publishing, and the replacement complete-check path MUST pass on the same source revision before the old task framework is deleted. +The Rust/Cargo product and obsolete native npm/release packaging SHALL be removed only after complete Node functionality and both distributions pass acceptance at the candidate revision. Each old responsibility SHALL have a verified replacement or explicitly approved removal. Unrelated pre-existing untracked npm content SHALL be inspected and preserved. After removal, the full Node-only gate SHALL pass again without Rust/Cargo installed. -#### Scenario: Migration inventory is reviewed -- **WHEN** removal of `xtask` is proposed -- **THEN** every existing command has a recorded replacement owner or removal rationale -- **AND** no command is translated into a new general-purpose script solely to preserve the old structure - -#### Scenario: Replacement release path is incompatible -- **WHEN** cargo-dist cannot produce the accepted one-application artifact contract without substantial custom glue -- **THEN** implementation stops before deleting the existing release path -- **AND** a follow-up OpenSpec decision chooses a new package model or explicitly scoped adapter +#### Scenario: Rust removal is proposed +- **WHEN** one required capability, platform or distribution still lacks evidence +- **THEN** Rust removal SHALL wait and migration SHALL remain incomplete +- **AND** a foundation checkpoint SHALL NOT be represented as the finished migration ### Requirement: QT-006: npm packages pass clean-room verification +The gate SHALL verify the actual single npm tarball and every extracted SEA archive outside checkout using the publication packing process without publishing. It SHALL verify exact allowed files, version/license/repository identity, all three entries, local/global npm installs, argument/stream/exit fidelity and no wrapper recursion. npm SHALL have no platform optional packages or install scripts; SEA SHALL pass real TUI/SQLite/worker/child smoke without Node/npm PATH. -The npm package gate SHALL verify the exact packed file set, platform metadata, exact optional dependency versions, absence of install lifecycle scripts, and matching license and repository identity before any package is staged. - -On every supported native runner, the gate MUST install locally packed packages in a fresh consumer directory outside the checkout and MUST exercise `--version` and `--help` for `cadder`, `cadderd`, and the Cadder `caddy` shim. The gate MUST prove that the npm `caddy` launcher cannot resolve itself as upstream Caddy. - -#### Scenario: Package candidate is verified - -- **WHEN** npm package tarballs are prepared for a release -- **THEN** every supported platform passes native clean-room installation and command checks -- **AND** unexpected files, lifecycle scripts, version drift, or launcher recursion fail the gate before registry staging +#### Scenario: Clean consumer verification fails +- **WHEN** either channel fails its native packed-consumer catalog +- **THEN** neither the whole migration nor matching final release SHALL be accepted ### Requirement: QT-007: npm publication uses staged trusted publishing -Cadder npm publication SHALL use npm trusted publishing from the exact GitHub-hosted workflow and protected release environment through short-lived OIDC credentials. The trusted publisher MUST allow staged publishing only, package publishing access MUST disallow traditional tokens, and no long-lived npm publish credential may be stored in the repository or GitHub Actions. - -The workflow MUST stage packages only after it verifies the corresponding GitHub Release assets, SHA-256 checksums, and attestations. Every accepted asset attestation MUST identify the selected release tag, its exact source commit, and Cadder's release workflow as signer. npm provenance MUST identify the public repository and publishing workflow. A maintainer MUST review and approve each stage with 2FA, approving all platform packages before the root package. - -#### Scenario: Automated staging -- **WHEN** a verified tagged release reaches the npm workflow -- **THEN** GitHub Actions checks out that exact tag and verifies every consumed asset against its ref, commit digest, repository, and release workflow -- **AND** it obtains a short-lived OIDC publishing identity and stages rather than directly publishes all version-matched packages -- **AND** no npm access token is available to the job +After separate publication approval, the single npm package SHALL retain short-lived OIDC trusted publishing from the exact protected GitHub workflow, staged publishing and maintainer 2FA review, without long-lived npm tokens. The consumed candidate SHALL match the verified source revision/version and complete npm/SEA evidence. The obsolete native-platform-package approval order SHALL not remain a dependency of the new single package. -#### Scenario: Human publication approval -- **WHEN** the staged package set is ready for publication -- **THEN** a maintainer reviews the staged artifacts and approves them with 2FA -- **AND** the root package cannot become public before every referenced platform package +#### Scenario: Authorized candidate reaches publishing +- **WHEN** the separately approved matched candidate enters the publishing workflow +- **THEN** it SHALL stage the exact verified single package with trusted provenance +- **AND** a maintainer SHALL approve publication without an embedded persistent token ### Requirement: QT-008: New npm names use an explicit bootstrap diff --git a/openspec/specs/runtime-storage/spec.md b/openspec/specs/runtime-storage/spec.md index e88e339..3392f36 100644 --- a/openspec/specs/runtime-storage/spec.md +++ b/openspec/specs/runtime-storage/spec.md @@ -6,29 +6,32 @@ Define the single SQLite durability boundary, its ownership rules, and the guara ## Requirements ### Requirement: STO-001: One owner-protected SQLite database is authoritative -Cadder MUST store application state only in `data/cadder.sqlite3` beneath the installation runtime using the bundled SQLite engine and owner-only filesystem permissions. +The Node daemon SHALL own a protected v2 application-state and diagnostic-log database using node:sqlite, separate from the lifetime exclusion database. No client SHALL directly access either database. Initial schema creation SHALL be transactional and finish before product operations are accepted. -#### Scenario: Database is created -- **WHEN** the daemon initializes an empty runtime -- **THEN** it creates schema version 1 in one transaction before accepting operations +#### Scenario: Empty v2 application database +- **WHEN** the Node daemon first creates application-state and diagnostic-log storage +- **THEN** it SHALL create and validate its schema under owner-only protection +- **AND** the separate SQLite exclusion connection SHALL remain held ### Requirement: STO-002: Database work is single flight and bounded -One `tokio-rusqlite` connection SHALL serialize database closures behind a pre-call permit with an interrupt handle, rollback journal, full synchronous commits, foreign keys, busy timeout, integrity validation, and bounded close. +One worker SHALL serialize application-state and diagnostic-log database work, with bounded jobs, transaction rollback, integrity checks and orderly close. Worker failures SHALL settle pending requests with typed errors and SHALL NOT release daemon exclusion while owned work/resources remain active. -#### Scenario: Operation deadline expires -- **WHEN** a database closure exceeds its deadline -- **THEN** Cadder interrupts it and waits for a terminal result before releasing the permit +#### Scenario: Worker or transaction fails +- **WHEN** persistence cannot finish successfully +- **THEN** uncommitted data SHALL be rolled back and pending callers informed +- **AND** shutdown SHALL settle worker work before releasing runtime exclusion -### Requirement: STO-003: Schema compatibility is exact before 1.0 -Cadder MUST initialize version zero only when the database is otherwise empty and MUST reject partial, corrupt, or newer schemas before cleanup or runtime mutation. +### Requirement: STO-003: The Node schema is validated before mutation +Startup SHALL initialize only an empty uninitialized Node database, and SHALL reject partial, corrupt or unsupported/newer schemas before runtime mutation. It SHALL NOT delete unrelated entries to recover from schema failure. -#### Scenario: Partial database exists -- **WHEN** required tables or indexes do not match schema version 1 -- **THEN** startup fails with a storage diagnostic without deleting unrelated data +#### Scenario: Partial schema exists +- **WHEN** database objects or integrity do not match the supported Node schema +- **THEN** startup SHALL fail with a storage diagnostic without destructive repair -### Requirement: STO-004: Legacy cleanup is allowlisted -After endpoint ownership and healthy database validation, Cadder MAY delete only the named pre-1.0 storage artifacts beneath `data`, MUST refuse link traversal, and MUST preserve unknown entries and the database. +### Requirement: STO-004: Old runtime data remains untouched +The Node migration SHALL NOT import, delete or implicitly clean up the Rust runtime or database. Projects SHALL re-register from existing configuration. Old releases and data SHALL remain available for rollback; stable intent SHALL NOT resurrect a live session. Durable logs and state SHALL NOT introduce a history command or view. -#### Scenario: Cleanup partially fails -- **WHEN** one allowlisted artifact cannot be removed -- **THEN** startup continues with a redacted diagnostic and retries that artifact on the next start +#### Scenario: Node first starts beside old data +- **WHEN** old Rust runtime data exists during Node startup +- **THEN** Node SHALL use its separate v2 storage and leave the old data untouched +- **AND** project routes SHALL become live only through current registration From 0c1a5e600151c4d7a2e7e35f7ed5fec0e6402cf9 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Thu, 8 Oct 2026 14:37:47 +0200 Subject: [PATCH 19/29] feat: close Node RPC contracts and isolate installation runtimes --- crates/cadder-client/src/cli/output.rs | 16 +- .../tests/fixtures/test_process.rs | 26 +- crates/cadder-shim/tests/shim_binary.rs | 115 ++- .../close-node-rpc-catalog/.openspec.yaml | 2 + .../changes/close-node-rpc-catalog/design.md | 126 +++ .../close-node-rpc-catalog/proposal.md | 44 + .../changes/close-node-rpc-catalog/tasks.md | 15 + .../close-node-rpc-catalog/verification.md | 69 ++ src/client/connection.ts | 44 +- src/contracts/ports.ts | 99 ++ src/daemon/installation-root.ts | 46 + src/daemon/ipc-server.ts | 91 +- src/daemon/local-runtime.ts | 36 +- src/daemon/paths.ts | 9 +- src/daemon/runtime-lock.ts | 120 ++- src/platform/runtime-security.ts | 164 +++- src/protocol/authentication.ts | 3 +- src/protocol/dto.ts | 125 +++ src/protocol/errors.ts | 63 ++ src/protocol/rpc.ts | 181 +++- test/authentication.test.ts | 57 +- test/compatibility/catalog.test.ts | 518 ++++++++++ test/compatibility/catalog.ts | 913 ++++++++++++++++++ test/compatibility/cli-output.json | 50 + test/compatibility/human-output.json | 26 + test/fixtures/installation-runtime-smoke.ts | 107 ++ test/fixtures/rpc-data.ts | 60 ++ test/fixtures/runtime-child.ts | 9 +- test/fixtures/runtime-denied-client.ts | 2 +- test/fixtures/runtime-privilege-smoke.ts | 10 +- test/fixtures/runtime-smoke.ts | 26 +- test/fixtures/runtime-windows-contact.ts | 6 +- test/installation-identity.test.ts | 274 ++++++ test/installation-runtime.test.ts | 17 + test/ipc-publication-faults.test.ts | 187 ++++ test/ipc-server.test.ts | 334 ++++++- test/local-runtime-faults.test.ts | 63 ++ test/local-runtime.test.ts | 15 +- test/ports.test.ts | 221 +++++ test/protected-file-faults.test.ts | 150 +++ test/rpc.test.ts | 420 ++++++++ test/runtime-ancestors.test.ts | 274 ++++++ test/runtime-boundaries.test.ts | 331 +++++++ test/runtime-descendants.test.ts | 295 ++++++ test/runtime-lock-faults.test.ts | 285 ++++++ test/runtime-native-faults.test.ts | 181 ++++ test/runtime-system.test.ts | 27 +- test/runtime-windows-ancestors.test.ts | 118 +++ test/runtime-windows-created-security.test.ts | 158 +++ test/unix-security-policy.test.ts | 13 +- 50 files changed, 6362 insertions(+), 179 deletions(-) create mode 100644 openspec/changes/close-node-rpc-catalog/.openspec.yaml create mode 100644 openspec/changes/close-node-rpc-catalog/design.md create mode 100644 openspec/changes/close-node-rpc-catalog/proposal.md create mode 100644 openspec/changes/close-node-rpc-catalog/tasks.md create mode 100644 openspec/changes/close-node-rpc-catalog/verification.md create mode 100644 src/contracts/ports.ts create mode 100644 src/daemon/installation-root.ts create mode 100644 src/protocol/dto.ts create mode 100644 test/compatibility/catalog.test.ts create mode 100644 test/compatibility/catalog.ts create mode 100644 test/compatibility/cli-output.json create mode 100644 test/compatibility/human-output.json create mode 100644 test/fixtures/installation-runtime-smoke.ts create mode 100644 test/fixtures/rpc-data.ts create mode 100644 test/installation-identity.test.ts create mode 100644 test/installation-runtime.test.ts create mode 100644 test/ipc-publication-faults.test.ts create mode 100644 test/local-runtime-faults.test.ts create mode 100644 test/ports.test.ts create mode 100644 test/protected-file-faults.test.ts create mode 100644 test/rpc.test.ts create mode 100644 test/runtime-ancestors.test.ts create mode 100644 test/runtime-boundaries.test.ts create mode 100644 test/runtime-descendants.test.ts create mode 100644 test/runtime-lock-faults.test.ts create mode 100644 test/runtime-native-faults.test.ts create mode 100644 test/runtime-windows-ancestors.test.ts create mode 100644 test/runtime-windows-created-security.test.ts diff --git a/crates/cadder-client/src/cli/output.rs b/crates/cadder-client/src/cli/output.rs index 27a7948..c27da8c 100644 --- a/crates/cadder-client/src/cli/output.rs +++ b/crates/cadder-client/src/cli/output.rs @@ -432,16 +432,20 @@ mod tests { LogEntry, LogEntryKind, LogSeverity, LogStreamIdentity, LogStreamStatus, OwnerProcessIdentity, RegisteredDomain, RuntimeDiagnostic, RuntimeState, SourcePath, }; - use chrono::Utc; + use chrono::{TimeZone, Utc}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + fn fixture_time() -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap() + } + fn registration( id: &str, project: &str, state: ActivationState, domains: &[(&str, ActivationState, Option<&str>)], ) -> EntrypointRegistration { - let now = Utc::now(); + let now = fixture_time(); let nonce = format!("{id}-nonce"); EntrypointRegistration { registration_id: id.to_string(), @@ -476,7 +480,7 @@ mod tests { } fn populated_snapshot() -> GuiStateSnapshot { - let now = Utc::now(); + let now = fixture_time(); GuiStateSnapshot { captured_at_utc: now, registrations: vec![ @@ -548,7 +552,7 @@ mod tests { #[test] fn list_and_status_outputs_cover_empty_and_populated_snapshots() { let empty = GuiStateSnapshot { - captured_at_utc: Utc::now(), + captured_at_utc: fixture_time(), registrations: Vec::new(), runtime: RuntimeState::idle(), config: ConfigState::idle(), @@ -651,7 +655,7 @@ mod tests { #[test] fn diagnostics_and_logs_cover_empty_and_detailed_reports() { let empty = GuiStateSnapshot { - captured_at_utc: Utc::now(), + captured_at_utc: fixture_time(), registrations: Vec::new(), runtime: RuntimeState::idle(), config: ConfigState::idle(), @@ -671,7 +675,7 @@ mod tests { stream_status: LogStreamStatus::Active, entries: vec![LogEntry { sequence_number: 7, - timestamp_utc: Utc::now(), + timestamp_utc: fixture_time(), severity: LogSeverity::Warn, stream, attribution_kind: LogAttributionKind::Domain, diff --git a/crates/cadder-daemon/tests/fixtures/test_process.rs b/crates/cadder-daemon/tests/fixtures/test_process.rs index 3b1bb8a..97fae4b 100644 --- a/crates/cadder-daemon/tests/fixtures/test_process.rs +++ b/crates/cadder-daemon/tests/fixtures/test_process.rs @@ -1,7 +1,7 @@ use std::{ env, fs::{self, OpenOptions}, - io::{self, Write}, + io::{self, Read, Write}, path::{Path, PathBuf}, process::{Command, ExitCode, Stdio}, thread, @@ -68,6 +68,10 @@ fn fake_caddy(directory: &Path, mode: &str) -> io::Result { let arguments = env::args().skip(1).collect::>(); append_line(directory.join("fake-caddy.log"), &arguments.join(" "))?; + if mode.starts_with("shim-passthrough-") { + return shim_passthrough(directory, mode, &arguments); + } + match arguments.first().map(String::as_str) { Some("adapt") => adapt(mode), Some("reload") => reload(directory, mode), @@ -77,6 +81,26 @@ fn fake_caddy(directory: &Path, mode: &str) -> io::Result { } } +fn shim_passthrough(directory: &Path, mode: &str, arguments: &[String]) -> io::Result { + let mut stdin = Vec::new(); + io::stdin().read_to_end(&mut stdin)?; + let record = serde_json::json!({ + "args": arguments, + "stdin_bytes": stdin, + }); + fs::write( + directory.join("shim-passthrough.json"), + serde_json::to_vec(&record).map_err(io::Error::other)?, + )?; + println!("FAKE_CADDY_PASSTHROUGH_STDOUT"); + eprintln!("FAKE_CADDY_PASSTHROUGH_STDERR"); + Ok(if mode == "shim-passthrough-failure" { + 23 + } else { + 0 + }) +} + fn adapt(mode: &str) -> io::Result { match mode { "adapter-fail-adapt" | "project-adapt-fail" => { diff --git a/crates/cadder-shim/tests/shim_binary.rs b/crates/cadder-shim/tests/shim_binary.rs index 17d2b65..ccdfbe9 100644 --- a/crates/cadder-shim/tests/shim_binary.rs +++ b/crates/cadder-shim/tests/shim_binary.rs @@ -1,7 +1,8 @@ use std::{ - fs, + env, fs, + io::Write, path::PathBuf, - process::{Command, Output}, + process::{Command, Output, Stdio}, }; fn run_shim(args: &[&str]) -> Output { @@ -22,6 +23,116 @@ fn unique_runtime_dir(name: &str) -> PathBuf { )) } +fn native_fixture_path() -> PathBuf { + let shim = PathBuf::from(env!("CARGO_BIN_EXE_cadder-shim")); + let profile = shim.parent().expect("shim binary has a profile directory"); + let path = profile.join(if cfg!(windows) { + "cadder-test-process.exe" + } else { + "cadder-test-process" + }); + assert!( + path.is_file(), + "missing native test process at {}; build it with `cargo build -p cadder-daemon --bin cadder-test-process` before this focused test", + path.display() + ); + path +} + +fn run_passthrough(mode: &str, input: &[u8], args: &[&str]) -> (tempfile::TempDir, Output) { + let temp = tempfile::tempdir().unwrap(); + let fixture_name = if cfg!(windows) { + "caddy-test-process.exe" + } else { + "caddy-test-process" + }; + let fixture = temp.path().join(fixture_name); + fs::copy(native_fixture_path(), &fixture).unwrap(); + fs::write(temp.path().join("cadder-test.mode"), mode).unwrap(); + // Copying the released shim makes its portable trusted config path disposable and deterministic. + let portable_config = format!("[caddy]\nreal_command = \"{fixture_name}\"\n"); + fs::write(temp.path().join("cadder.toml"), portable_config).unwrap(); + let shim_name = if cfg!(windows) { + "cadder-shim.exe" + } else { + "cadder-shim" + }; + let shim = temp.path().join(shim_name); + fs::copy(env!("CARGO_BIN_EXE_cadder-shim"), &shim).unwrap(); + + let path = env::join_paths([temp.path()]).unwrap(); + let mut child = Command::new(&shim) + .args(args) + .env("PATH", path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut input_pipe = child.stdin.take().expect("shim stdin is piped"); + input_pipe.write_all(input).unwrap(); + drop(input_pipe); + (temp, wait_for_output(child)) +} + +fn wait_for_output(mut child: std::process::Child) -> Output { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if child.try_wait().unwrap().is_some() { + return child.wait_with_output().unwrap(); + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let output = child.wait_with_output().unwrap(); + panic!( + "released shim passthrough exceeded 10s; stdout={:?}, stderr={:?}", + output.stdout, output.stderr + ); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + +fn passthrough_record(temp: &tempfile::TempDir) -> serde_json::Value { + serde_json::from_slice(&fs::read(temp.path().join("shim-passthrough.json")).unwrap()).unwrap() +} + +#[test] +fn real_caddy_passthrough_preserves_arguments_stdin_streams_and_success_exit() { + let args = [ + "fmt", + "name with spaces", + "żółć", + "quote\"value", + "apostrophe's", + ]; + let input = b"stdin bytes \xE2\x9C\x93\n"; + let (temp, output) = run_passthrough("shim-passthrough-success", input, &args); + + assert_eq!(output.status.code(), Some(0)); + assert_eq!(output.stdout, b"FAKE_CADDY_PASSTHROUGH_STDOUT\n"); + assert_eq!(output.stderr, b"FAKE_CADDY_PASSTHROUGH_STDERR\n"); + let record = passthrough_record(&temp); + assert_eq!(record["args"], serde_json::json!(args)); + assert_eq!(record["stdin_bytes"], serde_json::json!(input)); +} + +#[test] +fn real_caddy_passthrough_propagates_nonzero_child_exit() { + let (temp, output) = run_passthrough( + "shim-passthrough-failure", + b"failure input", + &["fmt", "--literal"], + ); + + assert_eq!(output.status.code(), Some(23)); + assert_eq!(output.stdout, b"FAKE_CADDY_PASSTHROUGH_STDOUT\n"); + assert_eq!(output.stderr, b"FAKE_CADDY_PASSTHROUGH_STDERR\n"); + let record = passthrough_record(&temp); + assert_eq!(record["args"], serde_json::json!(["fmt", "--literal"])); + assert_eq!(record["stdin_bytes"], serde_json::json!(b"failure input")); +} + #[test] fn shim_info_flag_reports_release_identity_json() { let output = run_shim(&["--cadder-shim-info"]); diff --git a/openspec/changes/close-node-rpc-catalog/.openspec.yaml b/openspec/changes/close-node-rpc-catalog/.openspec.yaml new file mode 100644 index 0000000..79e0856 --- /dev/null +++ b/openspec/changes/close-node-rpc-catalog/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-06 diff --git a/openspec/changes/close-node-rpc-catalog/design.md b/openspec/changes/close-node-rpc-catalog/design.md new file mode 100644 index 0000000..68a5d7f --- /dev/null +++ b/openspec/changes/close-node-rpc-catalog/design.md @@ -0,0 +1,126 @@ +## Context + +The retained behavioral baseline is Rust 1.0.5. The main Node target specs have +been synchronized without archiving the migration roadmap. Current rpc.ts accepts +arbitrary methods/unknown values; response validation allows both or neither +outcome. Existing client/server and compiled fixtures already use mutual HMAC. + +## Requirements + +IPC-001/002 keep authentication and framing authoritative. IPC-003 owns strict +operation, payload, result and error validation at dispatch and client boundaries. + +## Goals and non-goals + +### Goals + +Close the eight released operations and retain their DTO semantics. Provide +method-indexed TypeScript types and Zod schemas. Exercise malformed authenticated +peers and complete fixture shutdown through the catalog. + +### Non-goals + +No product state handlers, Caddy adapter, durable storage, presentation methods, +Rust interoperability or new capability negotiation. + +## Ownership and trust boundaries + +Protocol schemas import no daemon, storage or process implementations. The server +validates before invoking a supplied handler and validates its result before +sending. The client validates before sending and validates the received outcome. +Fixtures own fake state and recovery diagnostics, never production modules. + +The adjacent task 1.5 contract slice adds exactly four leaf interfaces in +`src/contracts/ports.ts`: + +- `CaddyPort` owns typed `validate` and `apply` configuration calls only; its + candidate carries the complete adapted JSON body (`format: 'json'`, `body`) + and effective hash. Existing CADDY-005 validation bounds that body to 32 MiB + before the port is called. The port does not own executable resolution, + process lifecycle or admin transport. +- `PlatformPort` owns current-owner lookup and fail-closed protected-path checks. + `RuntimeOwner` is a type-only contract and is re-exported by the existing + security adapter without making the contract depend on that implementation. +- `StoragePort` owns loading and persisting `DesiredState`, which contains only + stable project/domain intent, including project and domain `enabled` flags, and + no session nonce, process identity, lease or active-route fields. +- `ClientServicePort` owns typed state, bounded log, project/domain activation and + shutdown calls. Its payloads/results are aliases of the closed eight-operation + RPC catalog, not a generic transport proxy. + +All port methods return the small discriminated `PortResult` using the existing +`ProtocolError` category type. The leaf imports only protocol contracts and never +imports daemon, storage, process or platform implementation modules. + +## Data flow and public contracts + +The four port declarations are contract-only and do not add a method to the wire +catalog or change dispatch behavior. + +An authenticated request identifies one of register-entrypoint-request, +unregister-entrypoint-request, heartbeat-entrypoint-request, query-state-request, +set-entrypoint-enabled-request, set-domain-enabled-request, query-logs-request or +shutdown-daemon-request. All DTO objects reject unknown fields. Responses contain +one outcome and correlate their envelope and nested result/error request IDs. +Business rejection remains a result with accepted:false. + +## Decisions + +- Retain the Node envelope names method/params to minimize transport changes; + the eight method values map directly to released behavior. This is not Rust + wire compatibility. Numeric protocol 3 and security policy 2 remain exact. +- Keep UUID request IDs for Node-generated requests. Malformed requests lacking + a valid UUID use a fixed valid UUID for uncorrelated rejection, distinct from + the original request. Clients never accept that fallback as their own response. +- Mirror optional/null DTO values and enum spellings; do not invent a string + encoding for integers. Reject unsafe JavaScript integer representations. +- Registration identity/nonce ownership is business policy: invalid ownership + remains accepted:false, not a schema/transport error. Schemas validate shape; + later handlers enforce ownership semantics. +- RPC omitted/null log limit remains a handler default of 100 with clamping to + 1–200; CLI default 50 is separate. Non-negative safe integer input is allowed. +- Frame size remains the 1 MiB aggregate bound. Logs results cannot exceed 200 + entries. No speculative smaller per-field limits are added. +- Unexpected handler errors use a bounded generic internal diagnostic, not the + original exception text. Existing runtime/security failures remain distinct. +- Recovery state travels over fixture stdout, not a product snapshot field. + +## Failure and recovery + +Unknown methods or malformed payloads receive one bounded typed error without +handler execution. A valid request's result/error is correlated; invalid handler +output becomes an internal protocol error. Connections close after one response. +Authentication/sequence/version violations continue to fail before RPC. + +## Migration and rollback + +Only Node fixture callers change. No Rust endpoint/data negotiation or migration +occurs. Existing runtime data and baseline Rust sources remain untouched. + +## Test strategy + +Pure schema round trips cover all eight payload/results, nullability, enums and +unknown fields. Authenticated fake peers cover malformed requests, unknown +methods, result/error exclusivity, nested/envelope IDs and invalid handler output. +Compiled child/native smoke uses query-state and shutdown catalog methods. Run +focused tests then nub run check and the Windows compiled-JS smoke. Other native +platforms and real Sandbox remain open evidence gates. + +## Risks and trade-offs + +- Rust u64/usize exceed JS safe integers: refuse lossy numbers, record this bound. +- Fixture migration can masquerade as product readiness: keep fake handlers only + under test/ and keep G1/G2 open. +- Response/result nested IDs require one authoritative population point: server + dispatch fills them from the validated request; client checks them. +- OpenSpec 1.5's strict change validator requires a delta even for the configured + spec-free implementation schema. The current checkout has no cargo xtask alias + or manifest for the documented custom check. Strict main-spec validation and + artifact/schema inspection remain available. Full mise openspec-check is blocked + by that tool/schema mismatch; do not add duplicate requirements or change + repository validation policy in this slice. + +## Open questions + +None. These are implementation decisions within accepted IPC-001..003, not new +product requirements. diff --git a/openspec/changes/close-node-rpc-catalog/proposal.md b/openspec/changes/close-node-rpc-catalog/proposal.md new file mode 100644 index 0000000..1fb58d3 --- /dev/null +++ b/openspec/changes/close-node-rpc-catalog/proposal.md @@ -0,0 +1,44 @@ +## Why + +Preserve the released project/shim/daemon/operator journey without porting Caddy +before G2. The Node foundation currently accepts arbitrary fixture RPC methods +and unconstrained results. The next capability is authenticated local IPC with +exactly eight validated operations and one correlated result or error, plus the +small contract-only ports that keep the next product boundaries explicit. + +## Requirement IDs + +- `IPC-001`: Keep authenticated, bounded local transport. +- `IPC-002`: Reject incompatible peers before dispatch. +- `IPC-003`: Close the eight-operation request/result/error catalog. +- `TOP-001`: Keep one daemon-owned installation boundary across the ports. +- `TOP-002`: Keep CLI/TUI access behind one typed client-service boundary. + +## Scope + +Implement reset-cadder-architecture task 2.4 in the protocol module, client +connection and existing dispatcher. Migrate runtime test fixtures to the product +catalog without introducing production handlers or test-only product fields. +Add only four mockable leaf ports—Caddy, platform, storage and client service—using +existing protocol DTO/result/error types; these are contracts, not handlers. + +## Non-goals + +No Caddy porting or lifecycle implementation, CLI/TUI implementation, new RPC +operation, storage worker, installation resolver, privilege acceptance or release +change. The four port declarations do not implement any of those boundaries. The existing +publish-cadder-1-0 implementation change owns separate Rust release work and is +not modified or a dependency of this Node protocol slice. + +## Success criteria + +Every operation has closed payload/result schemas. Unknown methods and fields +receive bounded typed rejection without invoking a handler. Clients reject +uncorrelated, malformed and non-exclusive responses. Runtime fixtures exercise +the same transport without arbitrary status/shutdown methods. + +## Impact + +Only Node protocol/connection/dispatch source, the four-port contract leaf and +focused tests/fixtures change. Dependencies, released Rust code and packaging +remain unchanged. This slice does not close G1, G2 or Windows Sandbox acceptance. diff --git a/openspec/changes/close-node-rpc-catalog/tasks.md b/openspec/changes/close-node-rpc-catalog/tasks.md new file mode 100644 index 0000000..9cba649 --- /dev/null +++ b/openspec/changes/close-node-rpc-catalog/tasks.md @@ -0,0 +1,15 @@ +## 1. Implementation + +- [x] 1.1 `[IPC-003]` Define the closed eight-operation Zod DTO/error catalog and method-indexed types (verification: all eight round trips and strict unknown-field/schema negative tests pass; migration verification.md, Closed RPC verification). +- [x] 1.2 `[IPC-001, IPC-002, IPC-003]` Validate authenticated requests before dispatch and correlate one typed result/error; type and validate client calls (verification: authenticated malformed/unknown-method, malformed-response and oversized-response tests pass without invalid handler invocation; independent MiMo reviews pass). +- [x] 1.3 `[IPC-003]` Migrate runtime tests and compiled fixtures to query-state/shutdown catalog requests without product mock fields (verification: runtime-system tests and compiled-JS Windows smoke pass). +- [x] 1.4 `[TOP-001, TOP-002, IPC-003]` Define exactly four mockable leaf ports (Caddy, platform, storage and client service) with typed method/result/error contracts, stable desired-state storage shape and protocol-only imports (verification: focused contract fakes and static import-direction checks pass). + +## 2. Documentation and migration + +- [x] 2.1 `[IPC-003]` Reconcile catalog fixtures and record only executed evidence in the migration roadmap (verification: compatibility tests pass and missing native/Sandbox gates remain open; Closed RPC verification). +- [x] 2.2 `[IPC-002]` Verify no Rust IPC negotiation, old-data migration, dependency or release change was introduced (verification: parent and independent MiMo scoped diff/import reviews). + +## 3. Final verification + +- [x] 3.1 `[IPC-001, IPC-002, IPC-003]` Run nub run check, focused compiled smoke and strict main-spec checks; independently review the integrated slice (verification: parent checks and final independent MiMo Pro integrated review pass; verification.md records the existing OpenSpec 1.5 spec-free implementation validation limitation and teardown transport cancellation without adding duplicate requirement deltas). diff --git a/openspec/changes/close-node-rpc-catalog/verification.md b/openspec/changes/close-node-rpc-catalog/verification.md new file mode 100644 index 0000000..bff975c --- /dev/null +++ b/openspec/changes/close-node-rpc-catalog/verification.md @@ -0,0 +1,69 @@ +## Summary + +The focused closed-RPC and four-port contract slice is implemented and verified. +Final independent MiMo Pro review passes integrated schemas, client/server types, +ports and evidence boundaries. All tasks in this slice are complete. + +This is not archive-ready under the installed all-item validator: OpenSpec 1.5 +requires a delta for the configured spec-free implementation schema. The +repository's documented `cargo xtask openspec-check` command has no available +alias/manifest in this checkout. Neither validation policy nor normative +requirements were changed to hide these limitations. + +The preserved journey is the released 1.0.5 operation/model contract. The next +capability delivered is typed, validated authenticated Node fixture RPC through +exactly eight operations, with four reusable leaf ports. No product handlers, +Caddy adapter, storage worker, CLI/TUI or release parity is claimed. + +## Evidence + +| Requirement ID | Task | Evidence | Result | +| --- | --- | --- | --- | +| `IPC-003` | `1.1` | `test/rpc.test.ts`, compatibility payload fixtures: strict eight-operation request/result/error schemas, unknown-field and correlation checks | Pass | +| `IPC-001`, `IPC-002`, `IPC-003` | `1.2` | Authenticated IPC/client tests: schema validation before dispatch, one correlated dispatched outcome, bounded oversized-response error; independent MiMo reviews | Pass | +| `IPC-003` | `1.3` | Runtime fixtures use catalog query-state/shutdown operations; parent compiled plain-JS win32/x64 smoke | Pass | +| `TOP-001`, `TOP-002`, `IPC-003` | `1.4` | `test/ports.test.ts`: exact signatures/keys/results/errors, project/domain intent, full adapted JSON carrier and protocol-only imports; MiMo Flash review | Pass | +| `IPC-003` | `2.1` | Migration verification ledger distinguishes fixture tests, released-binary evidence and missing product/native gates | Pass | +| `IPC-002` | `2.2` | Scoped source/import review: no Rust IPC negotiation, old-data migration, dependency or release change | Pass | +| `IPC-001`, `IPC-002`, `IPC-003` | `3.1` | Parent `nub run check`: 227 tests, 98.92% line coverage; compiled Windows smoke; strict main specs: 15 pass; final independent MiMo Pro integrated review | Pass | + +The parent also ran 300 Rust workspace tests, formatting and all-target Clippy +with `-D warnings`. The final redirected shim-binary suite passes eight tests, +including real inherited stdin/stdout/stderr, exact argument arrays and exit +codes 0 and 23. These are baseline fixtures, not Node product or TTY acceptance. +The parent also passed the compiled runtime and two-installation fixture smokes +on linux/x64 in an already-cached Node 24.18.0 container. No image download, +package installation or privilege acceptance was performed; runtime files and +containers were disposable. + +Evidence is tied to the uncommitted source set over base HEAD +`93942af84f555eab2bd68e0a4a9db487ff82d040`. Its 198-file source/test/main-spec/ +manifest fingerprint is +`fe62f295286fd9af23425ad3d16bde28da884f219329faf51c3bc67afbcc4141`. +See the migration verification's Integrated nine-task checkpoint for the receipt +format, exclusions and individual approved-task outcomes. Planning/evidence +changes do not alter that fingerprint; it is not a new committed revision. + +## Gate + +- [x] Every task in `tasks.md` is complete. +- [x] Every in-scope requirement ID has passing focused evidence. +- [x] Focused tests pass. +- [x] Runtime and strict main-spec checks required by the design pass. +- [x] Documentation describes only verified behavior. +- [ ] `cargo xtask openspec-check` passes: command unavailable in this checkout. +- [ ] Strict all-item validation passes: 17 pass, one known missing-delta failure. + +## Residual risk + +- Teardown can revoke a received-but-undispatched authenticated request by + closing its transport. The caller then observes connection failure, not a + correlated `shuttingDown` envelope. The exactly-one outcome claim applies to + dispatch; cleanup does not guarantee delivery on a closing connection. +- Complete current-source Unix security/fault matrices, native macOS, actual + TTY, complete compatibility captures, application-storage behavior and npm/SEA + product evidence remain missing. +- Windows Sandbox privilege acceptance was deferred by the user, not passed. +- Runtime filesystem checks do not eliminate check-to-use or same-owner races. +- G1/G2 and migration tasks 1.3, 2.5, 2.8 and 2.9 remain open. No Caddy port, + staging, commit, archive, tag, push or publication was performed. diff --git a/src/client/connection.ts b/src/client/connection.ts index ce59d19..a04cb02 100644 --- a/src/client/connection.ts +++ b/src/client/connection.ts @@ -5,11 +5,32 @@ import type { RuntimePaths } from '../daemon/paths.ts'; import { assertProtected, runtimeOwner } from '../platform/runtime-security.ts'; import { JsonChannel } from '../protocol/channel.ts'; import { authenticateClient } from '../protocol/authentication.ts'; -import { responseSchema } from '../protocol/rpc.ts'; +import { + requestSchema, + responseSchema, + UNCORRELATED_REQUEST_ID, + type RpcMethod, + type RpcParams, + type RpcResult, +} from '../protocol/rpc.ts'; import { PROTOCOL_VERSION } from '../protocol/version.ts'; -import { CadderError, errorCode } from '../protocol/errors.ts'; +import { CadderError, RpcError, errorCode } from '../protocol/errors.ts'; -export async function rpc(paths: RuntimePaths, method: string, params?: unknown): Promise { +export async function rpc( + paths: RuntimePaths, + method: M, + ...args: Record extends RpcParams + ? [params?: NoInfer>] + : [params: NoInfer>] +): Promise> { + const requestId = randomUUID(); + const request = requestSchema.safeParse({ + protocolVersion: PROTOCOL_VERSION, + requestId, + method, + params: args[0] === undefined ? {} : args[0], + }); + if (!request.success) throw new CadderError('invalid-request', 'Invalid RPC request.'); const owner = await runtimeOwner(process.getuid?.()); let secret: Buffer; try { @@ -27,13 +48,18 @@ export async function rpc(paths: RuntimePaths, method: string, params?: unknown) try { const session = await authenticateClient(channel, secret, paths.instance); try { - const requestId = randomUUID(); - session.send({ protocolVersion: PROTOCOL_VERSION, requestId, method, params }); - const response = responseSchema.parse(await session.receive()); - if (response.requestId !== requestId) + session.send(request.data); + const parsed = responseSchema(method).safeParse(await session.receive()); + if ( + !parsed.success || + parsed.data.requestId !== requestId || + parsed.data.requestId === UNCORRELATED_REQUEST_ID + ) throw new CadderError('invalid-response', 'Daemon response does not match this request.'); - if (response.error) throw new CadderError(response.error.code, response.error.message); - return response.result; + const response = parsed.data; + if ('error' in response) throw new RpcError(response.error); + // The selected schema is exactly catalog[method].result. + return response.result as RpcResult; } finally { session.close(); } diff --git a/src/contracts/ports.ts b/src/contracts/ports.ts new file mode 100644 index 0000000..19fd29c --- /dev/null +++ b/src/contracts/ports.ts @@ -0,0 +1,99 @@ +import type { ProtocolError } from '../protocol/errors.ts'; +import type { RpcParams, RpcResult } from '../protocol/rpc.ts'; + +/** The identity used to protect one installation's runtime boundary. */ +export type RuntimeOwner = Readonly<{ + id: string; + uid?: number; + elevated: boolean; +}>; + +/** + * A complete adapted Caddy configuration candidate. + * CADDY-005 bounds the JSON body to 32 MiB before this port is called. + */ +export type CaddyConfig = Readonly<{ + effectiveConfigHash: string; + adaptedConfig: Readonly<{ + format: 'json'; + body: string; + }>; +}>; + +export type CaddyDiagnostic = Readonly<{ + code: string; + message: string; + domainKey: string | null; +}>; + +export type CaddyValidation = Readonly<{ + effectiveConfigHash: string; + diagnostics: readonly CaddyDiagnostic[]; +}>; + +export type CaddyApplyReceipt = Readonly<{ + effectiveConfigHash: string; +}>; + +/** Stable project/domain intent; it contains no session, process, or live lease data. */ +export type DesiredState = Readonly<{ + projects: readonly Readonly<{ + projectKey: string; + sourceWorkingDirectory: string; + sourceConfigPath: string; + enabled: boolean; + domains: readonly Readonly<{ + canonicalDomain: string; + upstream: string | null; + enabled: boolean; + }>[]; + }>[]; +}>; + +export type ProtectedPathKind = 'directory' | 'file'; + +/** A discriminated result shared by every port without a runtime result engine. */ +export type PortResult = + | Readonly<{ ok: true; value: T }> + | Readonly<{ ok: false; error: ProtocolError }>; + +export type QueryLogsResult = RpcResult<'query-logs-request'>; +export type QueryStateResult = RpcResult<'query-state-request'>; +export type ProjectActivationResult = RpcResult<'set-entrypoint-enabled-request'>; +export type DomainActivationResult = RpcResult<'set-domain-enabled-request'>; +export type ShutdownResult = RpcResult<'shutdown-daemon-request'>; + +/** Caddy configuration validation and application; process lifecycle is out of scope. */ +export interface CaddyPort { + validate(config: CaddyConfig): Promise>; + apply(config: CaddyConfig): Promise>; +} + +/** Current-owner lookup and fail-closed protection checks for runtime paths. */ +export interface PlatformPort { + currentOwner(explicitUid?: number): Promise>; + assertProtected( + path: string, + owner: RuntimeOwner, + kind: ProtectedPathKind, + ): Promise>; +} + +/** Durable stable intent only; live registration/session/process state is not persisted here. */ +export interface StoragePort { + loadDesiredState(): Promise>; + persistDesiredState(state: DesiredState): Promise>; +} + +/** The shared daemon-backed client surface used by CLI and TUI. */ +export interface ClientServicePort { + queryState(): Promise>; + queryLogs(params: RpcParams<'query-logs-request'>): Promise>; + setProjectEnabled( + params: RpcParams<'set-entrypoint-enabled-request'>, + ): Promise>; + setDomainEnabled( + params: RpcParams<'set-domain-enabled-request'>, + ): Promise>; + shutdown(): Promise>; +} diff --git a/src/daemon/installation-root.ts b/src/daemon/installation-root.ts new file mode 100644 index 0000000..c64ef0e --- /dev/null +++ b/src/daemon/installation-root.ts @@ -0,0 +1,46 @@ +import { readFileSync, realpathSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { isSea } from 'node:sea'; +import { fileURLToPath } from 'node:url'; +import { CadderError, errorCode } from '../protocol/errors.ts'; + +export interface InstallationRootInput { + installationRoot?: string; + sea?: boolean; + executable?: string; + moduleUrl?: string; +} + +// Internal inputs let tests exercise distribution layouts without launch wrappers. +export function resolveInstallationRoot(input: InstallationRootInput = {}): string { + try { + if (input.installationRoot !== undefined) { + const root = realpathSync.native(resolve(input.installationRoot)); + if (!statSync(root).isDirectory()) throw new Error('Installation root is not a directory.'); + return root; + } + if (input.sea ?? isSea()) + return dirname(realpathSync.native(input.executable ?? process.execPath)); + let directory = dirname(realpathSync.native(fileURLToPath(input.moduleUrl ?? import.meta.url))); + for (;;) { + try { + const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as { + name?: string; + }; + if (manifest.name === 'cadder') return directory; + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error; + } + const parent = dirname(directory); + if (parent === directory) throw new Error('No cadder package anchor found.'); + directory = parent; + } + } catch (error) { + const failure = new CadderError( + 'installation-root-unavailable', + 'Cannot resolve the physical Cadder installation root; refusing a shared default runtime.', + ); + failure.cause = error; + throw failure; + } +} diff --git a/src/daemon/ipc-server.ts b/src/daemon/ipc-server.ts index 5d6cc28..48387fd 100644 --- a/src/daemon/ipc-server.ts +++ b/src/daemon/ipc-server.ts @@ -3,7 +3,7 @@ import { chmod, lstat, unlink, writeFile } from 'node:fs/promises'; import { once } from 'node:events'; import { JsonChannel } from '../protocol/channel.ts'; import { authenticateServer } from '../protocol/authentication.ts'; -import { requestSchema, type RpcRequest } from '../protocol/rpc.ts'; +import { dispatchRpc, errorResponseSchema, type RpcHandler } from '../protocol/rpc.ts'; import { CadderError, errorCode } from '../protocol/errors.ts'; import { PROTOCOL_VERSION, SECURITY_POLICY_VERSION, VERSION } from '../protocol/version.ts'; import { @@ -17,7 +17,7 @@ export async function serveIpc( paths: RuntimePaths, owner: RuntimeOwner, secret: Buffer, - handler: (request: RpcRequest) => Promise, + handler: RpcHandler, onDenied: () => void = () => {}, ): Promise<{ close(): Promise }> { // Caller must hold the SQLite runtime lock before removing crash residue. @@ -33,9 +33,9 @@ export async function serveIpc( } const sockets = new Set(); let closing: Promise | undefined; - let published = false; + let discoveryOwned = false; const server = createServer((socket) => { - if (sockets.size >= 64) { + if (closing || sockets.size >= 64) { socket.destroy(); return; } @@ -45,26 +45,28 @@ export async function serveIpc( void (async () => { const session = await authenticateServer(channel, secret, paths.instance); try { - const request = requestSchema.parse(await session.receive()); - let response; + const request = await session.receive(); + if (closing) return; + const response = await dispatchRpc(request, handler); try { - response = { result: await handler(request) }; + session.send(response); } catch (error) { - response = { - error: { - code: errorCode(error) ?? 'request-failed', - message: - error instanceof CadderError - ? error.message - : 'Daemon request failed. See diagnostics.', - }, - }; + if (errorCode(error) !== 'frame-too-large') throw error; + session.send( + errorResponseSchema.parse({ + protocolVersion: PROTOCOL_VERSION, + requestId: response.requestId, + error: { + kind: 'frame', + code: 'frame', + message: 'Daemon response exceeds the IPC frame size limit.', + guidance: null, + retryable: false, + requestId: response.requestId, + }, + }), + ); } - session.send({ - protocolVersion: PROTOCOL_VERSION, - requestId: request.requestId, - ...response, - }); await new Promise((resolve) => socket.end(resolve)); } finally { session.close(); @@ -74,14 +76,16 @@ export async function serveIpc( channel.close(); }); }); - server.listen(paths.endpoint); - await once(server, 'listening'); try { + server.listen(paths.endpoint); + await once(server, 'listening'); if (process.platform !== 'win32') { await chmod(paths.endpoint, 0o600); await protectCreated(paths.endpoint, owner); } await createProtectedFile(paths.discovery, owner); + // The lock owner may also replace validated, safe crash residue. + discoveryOwned = true; await writeFile( paths.discovery, JSON.stringify({ @@ -97,9 +101,15 @@ export async function serveIpc( elevated: owner.elevated, }), ); - published = true; } catch (error) { - await close(); + const failures = [error]; + try { + await close(); + } catch (cleanupError) { + failures.push(cleanupError); + } + if (failures.length > 1) + throw new AggregateError(failures, 'IPC startup and cleanup failed.', { cause: error }); throw error; } function close(): Promise { @@ -107,15 +117,30 @@ export async function serveIpc( return closing; } async function closeListener(): Promise { - for (const socket of sockets) socket.destroy(); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - if (published) { - await unlink(paths.discovery).catch((error: unknown) => { - if (errorCode(error) !== 'ENOENT') throw error; - }); + const failures: unknown[] = []; + for (const socket of sockets) { + try { + socket.destroy(); + } catch (error) { + failures.push(error); + } + } + try { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } catch (error) { + if (errorCode(error) !== 'ERR_SERVER_NOT_RUNNING') failures.push(error); + } + if (discoveryOwned) { + try { + await unlink(paths.discovery); + } catch (error) { + if (errorCode(error) !== 'ENOENT') failures.push(error); + } } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, 'IPC listener cleanup failed.'); } return { close }; } diff --git a/src/daemon/local-runtime.ts b/src/daemon/local-runtime.ts index 284613b..a14233d 100644 --- a/src/daemon/local-runtime.ts +++ b/src/daemon/local-runtime.ts @@ -2,7 +2,7 @@ import { resolvePaths, type RuntimePaths } from './paths.ts'; import { acquireRuntimeLock } from './runtime-lock.ts'; import { serveIpc } from './ipc-server.ts'; import { prepareRuntime, runtimeOwner } from '../platform/runtime-security.ts'; -import type { RpcRequest } from '../protocol/rpc.ts'; +import type { RpcHandler } from '../protocol/rpc.ts'; import { CadderError } from '../protocol/errors.ts'; export interface LocalRuntime { @@ -12,8 +12,13 @@ export interface LocalRuntime { } export async function startLocalRuntime( - options: { runtimeDir?: string; profile?: string; runtimeOwner?: number }, - handler: (request: RpcRequest) => Promise, + options: { + runtimeDir?: string; + profile?: string; + runtimeOwner?: number; + installationRoot?: string; + }, + handler: RpcHandler, onDenied?: () => void, ): Promise { if (process.getuid?.() === 0 && !options.runtimeDir) { @@ -34,17 +39,32 @@ export async function startLocalRuntime( recovered: lock.recovered, stop() { stopping ??= (async () => { - try { - await listener.close(); - } finally { - await lock.release(); + const failures: unknown[] = []; + for (const finalize of [() => listener.close(), () => lock.release()]) { + try { + await finalize(); + } catch (error) { + failures.push(error); + } } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) + throw new AggregateError(failures, 'Local runtime shutdown failed.'); })(); return stopping; }, }; } catch (error) { - await lock.release(); + const failures = [error]; + try { + await lock.release(); + } catch (cleanupError) { + failures.push(cleanupError); + } + if (failures.length > 1) + throw new AggregateError(failures, 'Local runtime startup and cleanup failed.', { + cause: error, + }); throw error; } } diff --git a/src/daemon/paths.ts b/src/daemon/paths.ts index 8616e86..3e26a5a 100644 --- a/src/daemon/paths.ts +++ b/src/daemon/paths.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import { CadderError } from '../protocol/errors.ts'; +import { resolveInstallationRoot } from './installation-root.ts'; export interface RuntimePaths { directory: string; @@ -16,7 +17,7 @@ export interface RuntimePaths { } export function resolvePaths( - options: { runtimeDir?: string; profile?: string } = {}, + options: { runtimeDir?: string; profile?: string; installationRoot?: string } = {}, ): RuntimePaths { const override = options.runtimeDir ?? process.env.CADDER_RUNTIME_DIR; const value = ( @@ -44,6 +45,7 @@ export function resolvePaths( else base = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'cadder', 'run'); let directory = join(resolve(override ?? base), 'v2'); + if (override === undefined) directory = join(directory, digest(resolveInstallationRoot(options))); if (!override && profile === 'dev') { const id = process.env.CADDER_DEV_ID ?? @@ -57,6 +59,11 @@ export function resolvePaths( process.platform === 'win32' ? `\\\\.\\pipe\\cadder-v2-${instance}` : join(directory, 'cadder.sock'); + if (process.platform !== 'win32' && Buffer.byteLength(endpoint, 'utf8') > 103) + throw new CadderError( + 'runtime-endpoint-too-long', + 'Runtime socket path exceeds 103 UTF-8 bytes. Select a shorter CADDER_RUNTIME_DIR.', + ); return { directory, profile, diff --git a/src/daemon/runtime-lock.ts b/src/daemon/runtime-lock.ts index 56a9c6e..ce88265 100644 --- a/src/daemon/runtime-lock.ts +++ b/src/daemon/runtime-lock.ts @@ -3,7 +3,11 @@ import { readFile, unlink, writeFile } from 'node:fs/promises'; import { DatabaseSync } from 'node:sqlite'; import type { RuntimePaths } from './paths.ts'; import type { RuntimeOwner } from '../platform/runtime-security.ts'; -import { assertProtected, createProtectedFile } from '../platform/runtime-security.ts'; +import { + assertProtected, + assertRuntimeDescendant, + createProtectedFile, +} from '../platform/runtime-security.ts'; import { CadderError, errorCode } from '../protocol/errors.ts'; import { PROTOCOL_VERSION, SECURITY_POLICY_VERSION, VERSION } from '../protocol/version.ts'; @@ -19,17 +23,75 @@ export async function acquireRuntimeLock( ): Promise { await assertProtected(paths.directory, owner, true); await createProtectedFile(paths.lock, owner); + const journal = `${paths.lock}-journal`; + async function assertJournal(): Promise { + try { + await assertRuntimeDescendant(paths.directory, journal, owner); + } catch (error) { + // Only this known leaf may be absent; root/chain/inspection failures stay fatal. + if ( + errorCode(error) !== 'ENOENT' || + !(error && typeof error === 'object' && 'path' in error && error.path === journal) + ) + throw error; + } + } + await assertJournal(); const database = new DatabaseSync(paths.lock, { timeout: 0 }); - try { - database.exec('PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;'); - } catch { - database.close(); - throw new CadderError( - 'runtime-already-running', - 'Runtime is already locked. Use cadder daemon status or cadder daemon shutdown.', - ); + let transaction = false; + let metadataOwned = false; + let secretCreated = false; + let secret: Buffer | undefined; + + async function finalize(failedStart: boolean): Promise { + const failures: unknown[] = []; + for (const path of [ + ...(metadataOwned ? [paths.metadata] : []), + ...(failedStart && secretCreated ? [paths.secret] : []), + ]) { + try { + await unlink(path); + } catch (error) { + if (errorCode(error) !== 'ENOENT') failures.push(error); + } + } + try { + if (transaction) database.exec('ROLLBACK;'); + } catch (error) { + failures.push(error); + } + try { + database.close(); + } catch (error) { + failures.push(error); + } + secret?.fill(0); + return failures; } + try { + try { + database.exec('PRAGMA journal_mode=DELETE;'); + database.exec('BEGIN EXCLUSIVE;'); + transaction = true; + } catch (error) { + const sqliteCode = + error && + typeof error === 'object' && + 'errcode' in error && + typeof error.errcode === 'number' + ? error.errcode & 0xff + : undefined; + if (sqliteCode === 5 || sqliteCode === 6) { + throw new CadderError( + 'runtime-already-running', + 'Runtime is already locked. Use cadder daemon status or cadder daemon shutdown.', + ); + } + throw error; + } + // SQLite may have created the DELETE journal; exclusion remains held until release. + await assertJournal(); let recovered = false; try { await readFile(paths.metadata); @@ -37,11 +99,18 @@ export async function acquireRuntimeLock( } catch (error) { if (errorCode(error) !== 'ENOENT') throw error; } - await createProtectedFile(paths.secret, owner, randomBytes(32)); - const secret = await readFile(paths.secret); + const generated = randomBytes(32); + try { + secretCreated = await createProtectedFile(paths.secret, owner, generated); + } finally { + generated.fill(0); + } + secret = await readFile(paths.secret); if (secret.length !== 32) throw new CadderError('invalid-runtime-secret', 'Runtime secret must contain 32 bytes.'); await createProtectedFile(paths.metadata, owner); + // Safe stale metadata becomes ours only after lock acquisition and path validation. + metadataOwned = true; await writeFile( paths.metadata, JSON.stringify({ @@ -58,26 +127,25 @@ export async function acquireRuntimeLock( executable: process.execPath, }), ); - let released = false; + let releasing: Promise | undefined; return { recovered, secret, - async release() { - if (released) return; - released = true; - try { - await unlink(paths.metadata).catch((error: unknown) => { - if (errorCode(error) !== 'ENOENT') throw error; - }); - } finally { - database.exec('ROLLBACK;'); - database.close(); - secret.fill(0); - } + release() { + releasing ??= (async () => { + const failures = await finalize(false); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) + throw new AggregateError(failures, 'Runtime lock cleanup failed.'); + })(); + return releasing; }, }; } catch (error) { - database.close(); - throw error; + const failures = await finalize(true); + if (failures.length === 0) throw error; + throw new AggregateError([error, ...failures], 'Runtime lock acquisition and cleanup failed.', { + cause: error, + }); } } diff --git a/src/platform/runtime-security.ts b/src/platform/runtime-security.ts index 637d30f..cde685c 100644 --- a/src/platform/runtime-security.ts +++ b/src/platform/runtime-security.ts @@ -1,14 +1,11 @@ import { constants } from 'node:fs'; -import { chown, lstat, mkdir, open } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { chown, lstat, mkdir, open, realpath, unlink } from 'node:fs/promises'; +import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path'; import { CadderError, errorCode } from '../protocol/errors.ts'; import { powershell, psLiteral } from './powershell.ts'; +import type { RuntimeOwner } from '../contracts/ports.ts'; -export interface RuntimeOwner { - id: string; - uid?: number; - elevated: boolean; -} +export type { RuntimeOwner } from '../contracts/ports.ts'; export async function runtimeOwner(explicitUid?: number): Promise { if (process.platform === 'win32') { @@ -39,9 +36,68 @@ export async function runtimeOwner(explicitUid?: number): Promise return { id: `uid:${ownerUid}`, uid: ownerUid, elevated: uid === 0 }; } +function runtimeLeaf(path: string): string { + const rootLength = parse(path).root.length; + let end = path.length; + while (end > rootLength && (path[end - 1] === sep || (sep === '\\' && path[end - 1] === '/'))) + end -= 1; + return path.slice(0, end); +} + +async function assertRuntimeAncestors(path: string): Promise { + // Preserve lexical components: resolve(path) could erase a linked prefix before '..'. + const leaf = runtimeLeaf(path); + const root = parse(leaf).root; + const absolute = isAbsolute(leaf) + ? leaf + : `${root ? resolve(root) : process.cwd()}${sep}${leaf.slice(root.length)}`; + const parent = dirname(absolute); + let ancestor = parse(parent).root; + const components = parent.slice(ancestor.length).split(sep === '\\' ? /[\\/]+/ : /\/+/); + for (const component of ['', ...components]) { + if (component === '..') ancestor = dirname(ancestor); + else if (component && component !== '.') ancestor = join(ancestor, component); + let info; + try { + info = await lstat(ancestor); + } catch (error) { + // A later '..' can re-enter an existing linked prefix; do not stop at missing parents. + if (errorCode(error) === 'ENOENT') continue; + throw error; + } + if (!info.isSymbolicLink()) continue; + // Only these standard Darwin top-level OS aliases are permitted, never + // arbitrary root-owned or same-named nested links. Parent ACLs are unchanged. + if ( + process.platform === 'darwin' && + info.uid === 0 && + ['/tmp', '/var', '/etc'].includes(ancestor) + ) { + const target = `/private${ancestor}`; + try { + if ((await realpath(ancestor)) === target) { + const physical = await lstat(target); + if (physical.isDirectory() && !physical.isSymbolicLink()) { + // Inspection follows only this verified OS alias; caller paths stay unchanged. + ancestor = target; + continue; + } + } + } catch { + // An unverified OS alias is unsafe, including missing or inaccessible targets. + } + } + throw new CadderError( + 'unsafe-runtime-permissions', + 'Runtime ancestors must not be links or junctions.', + ); + } +} + export async function prepareRuntime(directory: string, owner: RuntimeOwner): Promise { + await assertRuntimeAncestors(directory); try { - await lstat(directory); + await lstat(runtimeLeaf(directory)); } catch (error) { if (errorCode(error) !== 'ENOENT') throw error; try { @@ -65,8 +121,11 @@ export async function protectCreated( const inheritance = directory ? "'ContainerInherit,ObjectInherit'" : "'None'"; await powershell(` $sid = New-Object System.Security.Principal.SecurityIdentifier(${psLiteral(owner.id)}); + $actualOwner = (Get-Acl -LiteralPath ${psLiteral(path)}).GetOwner([System.Security.Principal.SecurityIdentifier]).Value; $acl = New-Object System.Security.AccessControl.${type}; - $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true, $false); + # An already-owned new path needs WRITE_DAC, not a redundant WRITE_OWNER request. + if ($actualOwner -ne $sid.Value) { $acl.SetOwner($sid) }; + $acl.SetAccessRuleProtection($true, $false); $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', ${inheritance}, 'None', 'Allow'); $acl.AddAccessRule($rule); [System.IO.${directory ? 'Directory' : 'File'}]::SetAccessControl(${psLiteral(path)}, $acl); @@ -81,7 +140,59 @@ export async function assertProtected( owner: RuntimeOwner, directory = false, ): Promise { - const info = await lstat(path); + await assertRuntimeAncestors(path); + await assertRuntimePath(path, owner, directory, true); +} + +/** Read-only native storage check beneath a strictly protected product directory. */ +export async function assertRuntimeDescendant( + root: string, + path: string, + owner: RuntimeOwner, + directory = false, +): Promise { + const separators = sep === '\\' ? /[\\/]+/ : /\/+/; + // Reject lexical aliases before normalization could erase a linked component. + if ( + !isAbsolute(root) || + !isAbsolute(path) || + (!directory && path !== runtimeLeaf(path)) || + [root, path].some((value) => + value + .slice(parse(value).root.length) + .split(separators) + .some((part) => part === '.' || part === '..'), + ) + ) + throw new CadderError( + 'unsafe-runtime-permissions', + 'Expected an unaliased runtime descendant.', + ); + const base = runtimeLeaf(root); + const leaf = runtimeLeaf(path); + const inside = relative(base, leaf); + if (!inside || isAbsolute(inside) || inside.split(separators)[0] === '..') + throw new CadderError( + 'unsafe-runtime-permissions', + 'Runtime descendant must be inside its root.', + ); + await assertProtected(base, owner, true); + const components = inside.split(separators); + let current = base; + for (let index = 0; index < components.length; index++) { + current = join(current, components[index]!); + await assertRuntimePath(current, owner, index < components.length - 1 || directory, false); + } +} + +async function assertRuntimePath( + path: string, + owner: RuntimeOwner, + directory: boolean, + strict: boolean, +): Promise { + // A final separator can make lstat follow a directory link instead of inspecting it. + const info = await lstat(runtimeLeaf(path)); if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) { throw new CadderError( 'unsafe-runtime-permissions', @@ -91,11 +202,11 @@ export async function assertProtected( if (process.platform === 'win32') { await powershell(` $acl = Get-Acl -LiteralPath ${psLiteral(path)}; - if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne ${psLiteral(owner.id)} -or !$acl.AreAccessRulesProtected) { throw 'Unsafe runtime owner or ACL inheritance' }; + if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne ${psLiteral(owner.id)}${strict ? ' -or !$acl.AreAccessRulesProtected' : ''}) { throw 'Unsafe runtime owner or ACL inheritance' }; $ownerAllowed = $false; foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { if ($rule.IdentityReference.Value -ne ${psLiteral(owner.id)} -or $rule.AccessControlType -ne 'Allow') { throw 'Runtime ACL permits a different account' }; - if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl) { $ownerAllowed = $true }; + if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl${strict ? '' : ' -and ($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -eq 0'}) { $ownerAllowed = $true }; }; if (!$ownerAllowed) { throw 'Runtime owner lacks full access' }; `).catch(() => { @@ -114,6 +225,7 @@ export async function createProtectedFile( owner: RuntimeOwner, data: string | Uint8Array = '', ): Promise { + await assertRuntimeAncestors(path); let handle; try { handle = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); @@ -122,13 +234,35 @@ export async function createProtectedFile( await assertProtected(path, owner); return false; } + const failures: unknown[] = []; try { await protectCreated(path, owner); await handle.writeFile(data); await handle.sync(); - } finally { + } catch (error) { + failures.push(error); + } + try { await handle.close(); + } catch (error) { + failures.push(error); + } + if (failures.length === 0) { + try { + await assertProtected(path, owner); + } catch (error) { + failures.push(error); + } + } + if (failures.length === 0) return true; + // Only the successful exclusive open above grants ownership of this new path. + try { + await unlink(path); + } catch (error) { + if (errorCode(error) !== 'ENOENT') failures.push(error); } - await assertProtected(path, owner); - return true; + if (failures.length === 1) throw failures[0]; + throw new AggregateError(failures, 'Protected file creation and cleanup failed.', { + cause: failures[0], + }); } diff --git a/src/protocol/authentication.ts b/src/protocol/authentication.ts index 46b3156..17ca4a7 100644 --- a/src/protocol/authentication.ts +++ b/src/protocol/authentication.ts @@ -96,12 +96,13 @@ export class AuthenticatedSession { send(value: unknown): void { const payload = JSON.stringify(value); - const sequence = this.sent++; + const sequence = this.sent; this.channel.send({ sequence, payload, mac: mac(this.key, JSON.stringify([this.role, sequence, payload])), }); + this.sent++; } async receive(timeout = 15000): Promise { diff --git a/src/protocol/dto.ts b/src/protocol/dto.ts new file mode 100644 index 0000000..635ea75 --- /dev/null +++ b/src/protocol/dto.ts @@ -0,0 +1,125 @@ +import { z } from 'zod'; + +export const safeUnsignedIntegerSchema = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); +const u32 = safeUnsignedIntegerSchema.max(0xffffffff); +const timestamp = z.string().datetime({ offset: true }); +const nullableString = z.string().nullable(); +const activation = z.enum(['unknown', 'registered', 'activating', 'active', 'inactive', 'faulted']); + +export const logStreamSchema = z.strictObject({ + streamId: z.string(), + domainKey: nullableString, + channel: z.string(), +}); +const sourcePathSchema = z.strictObject({ raw: z.string(), canonical: nullableString }); +export const registrationSchema = z.strictObject({ + registrationId: z.string(), + entrypointInstance: z.strictObject({ + instanceId: z.string(), + startedAtUtc: timestamp, + shimSessionNonce: z.string(), + }), + sourceWorkingDirectory: sourcePathSchema, + sourceConfigPath: sourcePathSchema, + registeredDomains: z.array( + z.strictObject({ + name: z.strictObject({ raw: z.string(), canonical: z.string() }), + activationState: activation, + upstream: nullableString.transform((value) => value ?? undefined).optional(), + logStream: logStreamSchema, + }), + ), + activationState: activation, + ownerProcess: z.strictObject({ + processId: u32, + processStartTimeUtc: timestamp, + shimSessionNonce: z.string(), + executablePath: nullableString, + }), + logStream: logStreamSchema, + shimRun: z + .strictObject({ + adapter: nullableString, + rawArguments: z.array(z.string()), + commandLine: z.string(), + }) + .nullable(), + createdAtUtc: timestamp, + lastHeartbeatUtc: timestamp, +}); +// Serde Option inputs accept omission; wire output without skip_serializing_if emits null. +export const logStreamInputSchema = logStreamSchema.extend({ + domainKey: nullableString.default(null), +}); +const sourcePathInputSchema = sourcePathSchema.extend({ canonical: nullableString.default(null) }); +export const registrationInputSchema = registrationSchema.extend({ + sourceWorkingDirectory: sourcePathInputSchema, + sourceConfigPath: sourcePathInputSchema, + registeredDomains: z.array( + registrationSchema.shape.registeredDomains.element.extend({ logStream: logStreamInputSchema }), + ), + ownerProcess: registrationSchema.shape.ownerProcess.extend({ + executablePath: nullableString.default(null), + }), + logStream: logStreamInputSchema, + shimRun: registrationSchema.shape.shimRun + .unwrap() + .extend({ adapter: nullableString.default(null) }) + .nullable() + .default(null), +}); + +const runtimeDiagnosticSchema = z.strictObject({ + code: z.string(), + message: z.string(), + operation: nullableString, +}); +export const snapshotSchema = z.strictObject({ + capturedAtUtc: timestamp, + registrations: z.array(registrationSchema), + runtime: z.strictObject({ + status: z.enum(['unknown', 'notResolved', 'resolved', 'running', 'unhealthy', 'idle']), + binaryPath: nullableString, + version: nullableString, + processId: u32.nullable(), + adminEndpoint: nullableString, + diagnostics: z.array(runtimeDiagnosticSchema), + }), + config: z.strictObject({ + status: z.enum(['unknown', 'notApplied', 'applied', 'failed', 'idle']), + lastAttemptedAtUtc: timestamp.nullable(), + lastSuccessfulReloadAtUtc: timestamp.nullable(), + effectiveConfigHash: nullableString, + diagnostics: z.array( + z.strictObject({ + code: z.string(), + message: z.string(), + domainKey: nullableString, + sourceConfigPaths: z.array(z.string()), + }), + ), + }), + storage: z + .strictObject({ + backend: z.string(), + path: nullableString, + schemaVersion: u32, + diagnostics: z.array(runtimeDiagnosticSchema), + }) + .nullable(), +}); +export const logEntrySchema = z.strictObject({ + sequenceNumber: safeUnsignedIntegerSchema, + timestampUtc: timestamp, + severity: z.enum(['unknown', 'trace', 'debug', 'info', 'warn', 'error', 'fatal']), + stream: logStreamSchema, + attributionKind: z.enum(['unknown', 'runtime', 'runtimeControl', 'entrypoint', 'domain']), + entryKind: z.enum(['normal', 'lifecycle', 'ingestionOverflow', 'retentionGap']), + rawMessage: z.string(), + domainKey: nullableString, + sourceRegistrationId: nullableString, + sourceInstanceId: nullableString, + operation: nullableString, +}); +export type StateSnapshot = z.infer; +export type EntrypointRegistration = z.infer; diff --git a/src/protocol/errors.ts b/src/protocol/errors.ts index 5afc60e..fb20ad0 100644 --- a/src/protocol/errors.ts +++ b/src/protocol/errors.ts @@ -1,3 +1,41 @@ +import { z } from 'zod'; + +export const requestIdSchema = z.string().uuid(); +export const protocolErrorSchema = z.strictObject({ + kind: z.enum([ + 'incompatibleProtocolVersion', + 'unsupportedOperation', + 'payloadDecodeFailed', + 'accessDenied', + 'invalidInput', + 'conflict', + 'configuration', + 'caddyRuntime', + 'storage', + 'busy', + 'frame', + 'timeout', + 'protocolViolation', + 'shuttingDown', + 'staleInstance', + 'internal', + ]), + code: z + .string() + .max(64) + .regex(/^[a-z](?:[a-z0-9_]*[a-z0-9])?$/), + message: z.string(), + guidance: z.string().nullable(), + retryable: z.boolean(), + requestId: requestIdSchema.nullable(), + deniedOperation: z + .string() + .nullable() + .transform((value) => value ?? undefined) + .optional(), +}); +export type ProtocolError = z.infer; + export class CadderError extends Error { constructor( public readonly code: string, @@ -8,6 +46,31 @@ export class CadderError extends Error { } } +export class RpcError extends CadderError { + readonly protocolError: ProtocolError; + + constructor(error: z.input) { + const parsed = protocolErrorSchema.parse(error); + super(parsed.code, parsed.message); + this.protocolError = parsed; + } +} + +export function toWireError(error: unknown, requestId: string): ProtocolError { + if (error instanceof RpcError) { + const parsed = protocolErrorSchema.safeParse({ ...error.protocolError, requestId }); + if (parsed.success) return parsed.data; + } + return { + kind: 'internal', + code: 'internal', + message: 'Daemon request failed. See diagnostics.', + guidance: null, + retryable: false, + requestId, + }; +} + export function errorCode(error: unknown): string | undefined { if (error && typeof error === 'object' && 'code' in error) { return String(error.code); diff --git a/src/protocol/rpc.ts b/src/protocol/rpc.ts index f7bb3af..96f7ad0 100644 --- a/src/protocol/rpc.ts +++ b/src/protocol/rpc.ts @@ -1,16 +1,175 @@ import { z } from 'zod'; +import { + logEntrySchema, + logStreamInputSchema, + logStreamSchema, + registrationInputSchema, + safeUnsignedIntegerSchema, + snapshotSchema, +} from './dto.ts'; +import { protocolErrorSchema, requestIdSchema, toWireError } from './errors.ts'; import { PROTOCOL_VERSION } from './version.ts'; -export const requestSchema = z.object({ - protocolVersion: z.literal(PROTOCOL_VERSION), - requestId: z.string().uuid(), - method: z.string().min(1).max(100), - params: z.unknown().optional(), +const basicResultSchema = z.strictObject({ + requestId: requestIdSchema, + accepted: z.boolean(), + message: z.string(), }); -export type RpcRequest = z.infer; -export const responseSchema = z.object({ - protocolVersion: z.literal(PROTOCOL_VERSION), - requestId: z.string().uuid(), - result: z.unknown().optional(), - error: z.object({ code: z.string(), message: z.string() }).optional(), +const ownerParamsSchema = z.strictObject({ + registrationId: z.string(), + shimSessionNonce: z.string(), }); +const emptyParamsSchema = z.strictObject({}); +export const catalog = { + 'register-entrypoint-request': { + params: z.strictObject({ registration: registrationInputSchema }), + result: basicResultSchema.extend({ registrationId: z.string().nullable() }), + }, + 'unregister-entrypoint-request': { params: ownerParamsSchema, result: basicResultSchema }, + 'heartbeat-entrypoint-request': { params: ownerParamsSchema, result: basicResultSchema }, + 'query-state-request': { + params: emptyParamsSchema, + result: basicResultSchema.extend({ snapshot: snapshotSchema.nullable() }), + }, + 'set-entrypoint-enabled-request': { + params: z.strictObject({ + registrationId: z.string(), + shimSessionNonce: z.string().nullable().default(null), + enabled: z.boolean(), + }), + result: basicResultSchema, + }, + 'set-domain-enabled-request': { + params: z.strictObject({ + registrationId: z.string(), + domainKey: z.string(), + enabled: z.boolean(), + }), + result: basicResultSchema, + }, + 'query-logs-request': { + params: z.strictObject({ + stream: logStreamInputSchema, + limit: safeUnsignedIntegerSchema.nullable().default(null), + }), + result: basicResultSchema.extend({ + stream: logStreamSchema, + streamStatus: z.enum(['unknown', 'empty', 'active', 'stale', 'removed', 'readError']), + entries: z.array(logEntrySchema).max(200), + }), + }, + 'shutdown-daemon-request': { params: emptyParamsSchema, result: basicResultSchema }, +} as const; +export type RpcMethod = keyof typeof catalog; +export type RpcParams = z.input<(typeof catalog)[M]['params']>; +export type RpcResult = z.infer<(typeof catalog)[M]['result']>; +export type RpcRequest = { + [M in RpcMethod]: { + protocolVersion: typeof PROTOCOL_VERSION; + requestId: string; + method: M; + params: z.output<(typeof catalog)[M]['params']>; + }; +}[RpcMethod]; +export type HandlerResult = { + [M in RpcMethod]: Omit, 'requestId'> & { + requestId?: string; + }; +}[RpcMethod]; +export type RpcHandler = (request: RpcRequest) => Promise; + +function methodRequestSchema(method: M, params: S) { + return z.strictObject({ + protocolVersion: z.literal(PROTOCOL_VERSION), + requestId: requestIdSchema, + method: z.literal(method), + params, + }); +} +export const requestSchema = z.discriminatedUnion('method', [ + methodRequestSchema('register-entrypoint-request', catalog['register-entrypoint-request'].params), + methodRequestSchema( + 'unregister-entrypoint-request', + catalog['unregister-entrypoint-request'].params, + ), + methodRequestSchema( + 'heartbeat-entrypoint-request', + catalog['heartbeat-entrypoint-request'].params, + ), + methodRequestSchema('query-state-request', catalog['query-state-request'].params), + methodRequestSchema( + 'set-entrypoint-enabled-request', + catalog['set-entrypoint-enabled-request'].params, + ), + methodRequestSchema('set-domain-enabled-request', catalog['set-domain-enabled-request'].params), + methodRequestSchema('query-logs-request', catalog['query-logs-request'].params), + methodRequestSchema('shutdown-daemon-request', catalog['shutdown-daemon-request'].params), +]); +const responseHeader = { protocolVersion: z.literal(PROTOCOL_VERSION), requestId: requestIdSchema }; +export const errorResponseSchema = z + .strictObject({ ...responseHeader, error: protocolErrorSchema }) + .refine( + (response) => response.error.requestId === response.requestId, + 'Error request ID must match its envelope.', + ); + +export function responseSchema(method: M) { + const success = z + .strictObject({ ...responseHeader, result: catalog[method].result }) + .refine( + (response) => response.result.requestId === response.requestId, + 'Result request ID must match its envelope.', + ); + return z.union([success, errorResponseSchema]); +} + +export type RpcResponse = + | { protocolVersion: typeof PROTOCOL_VERSION; requestId: string; result: RpcResult } + | z.infer; + +export const UNCORRELATED_REQUEST_ID = '00000000-0000-4000-8000-000000000000'; + +// Authentication and framing are enforced before this boundary. +export async function dispatchRpc(input: unknown, handler: RpcHandler): Promise { + const parsed = requestSchema.safeParse(input); + if (!parsed.success) { + const envelope = z.object({ method: z.string().optional() }).safeParse(input); + let requestId = UNCORRELATED_REQUEST_ID; + // Correlate only validated IDs; never reflect unbounded peer diagnostic text. + if (input && typeof input === 'object' && 'requestId' in input) { + const id = requestIdSchema.safeParse(input.requestId); + if (id.success) requestId = id.data; + } + const unknownMethod = + envelope.success && + envelope.data.method !== undefined && + !Object.hasOwn(catalog, envelope.data.method); + return errorResponseSchema.parse({ + protocolVersion: PROTOCOL_VERSION, + requestId, + error: { + kind: unknownMethod ? 'unsupportedOperation' : 'payloadDecodeFailed', + code: unknownMethod ? 'unsupported_operation' : 'invalid_payload', + message: unknownMethod ? 'Unsupported RPC operation.' : 'Invalid RPC request.', + guidance: null, + retryable: false, + requestId, + }, + }); + } + const request: RpcRequest = parsed.data; + try { + const result = await handler(request); + return responseSchema(request.method).parse({ + protocolVersion: PROTOCOL_VERSION, + requestId: request.requestId, + result: { ...result, requestId: request.requestId }, + }); + } catch (error) { + return errorResponseSchema.parse({ + protocolVersion: PROTOCOL_VERSION, + requestId: request.requestId, + error: toWireError(error, request.requestId), + }); + } +} diff --git a/test/authentication.test.ts b/test/authentication.test.ts index 4edd555..db96371 100644 --- a/test/authentication.test.ts +++ b/test/authentication.test.ts @@ -4,6 +4,15 @@ import { once } from 'node:events'; import { expect, it } from 'vitest'; import { JsonChannel } from '../src/protocol/channel.ts'; import { authenticateClient, authenticateServer } from '../src/protocol/authentication.ts'; +import { fakeStateResult } from './fixtures/rpc-data.ts'; +import { MAX_FRAME_BYTES } from '../src/protocol/version.ts'; + +const request = { + protocolVersion: 3, + requestId: '00000000-0000-4000-8000-000000000001', + method: 'query-state-request', + params: {}, +}; async function channels() { const server = createServer(); @@ -35,10 +44,15 @@ it('authenticates both directions without sending the secret, then protects RPC authenticateClient(pair.client, secret, 'fixture'), authenticateServer(pair.daemon, secret, 'fixture'), ]); - client.send({ method: 'status' }); - expect(await daemon.receive()).toEqual({ method: 'status' }); - daemon.send({ online: true }); - expect(await client.receive()).toEqual({ online: true }); + client.send(request); + expect(await daemon.receive()).toEqual(request); + const response = { + protocolVersion: 3, + requestId: request.requestId, + result: { ...fakeStateResult(), requestId: request.requestId }, + }; + daemon.send(response); + expect(await client.receive()).toEqual(response); client.close(); daemon.close(); } finally { @@ -46,6 +60,35 @@ it('authenticates both directions without sending the secret, then protects RPC } }); +it.each(['client', 'server'] as const)( + 'keeps the %s send sequence when an oversized authenticated frame is rejected', + async (role) => { + const pair = await channels(); + const secret = randomBytes(32); + try { + const [client, daemon] = await Promise.all([ + authenticateClient(pair.client, secret, 'fixture'), + authenticateServer(pair.daemon, secret, 'fixture'), + ]); + const sender = role === 'client' ? client : daemon; + const receiver = role === 'client' ? daemon : client; + expect(() => sender.send({ message: 'x'.repeat(MAX_FRAME_BYTES + 1) })).toThrow( + 'IPC frame exceeds the size limit.', + ); + sender.send(request); + // Real authenticated receives enforce sequence 0 for fallback and 1 for the next send. + expect(await receiver.receive()).toEqual(request); + expect(() => sender.send({ message: 'x'.repeat(MAX_FRAME_BYTES + 1) })).toThrow( + 'IPC frame exceeds the size limit.', + ); + sender.send(request); + expect(await receiver.receive()).toEqual(request); + } finally { + pair.close(); + } + }, +); + it('rejects a fake endpoint before sending proof or RPC', async () => { const pair = await channels(); try { @@ -62,7 +105,7 @@ it('rejects an RPC before the handshake', async () => { const pair = await channels(); try { const result = authenticateServer(pair.daemon, randomBytes(32), 'fixture'); - pair.client.send({ method: 'shutdown' }); + pair.client.send({ ...request, method: 'shutdown-daemon-request' }); await expect(result).rejects.toThrow(); } finally { pair.close(); @@ -128,7 +171,7 @@ it('rejects replayed authenticated RPC frames', async () => { pair.daemon.socket.on('data', (data: Buffer) => { captured = data; }); - client.send({ method: 'status' }); + client.send(request); await daemon.receive(); pair.client.socket.write(captured); await expect(daemon.receive()).rejects.toThrow('replayed IPC frame'); @@ -147,7 +190,7 @@ it('rejects modified RPC payloads', async () => { ]); pair.client.send({ sequence: 0, - payload: JSON.stringify({ method: 'shutdown' }), + payload: JSON.stringify({ ...request, method: 'shutdown-daemon-request' }), mac: '0'.repeat(64), }); await expect(daemon.receive()).rejects.toThrow('authentication failed'); diff --git a/test/compatibility/catalog.test.ts b/test/compatibility/catalog.test.ts new file mode 100644 index 0000000..8a4c406 --- /dev/null +++ b/test/compatibility/catalog.test.ts @@ -0,0 +1,518 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse as parseToml } from 'smol-toml'; +import { describe, expect, it } from 'vitest'; +import { errorResponseSchema, requestSchema, responseSchema } from '../../src/protocol/rpc.ts'; +import { + caddyResolutionAuthority, + caddyResolutionPrecedence, + caddyfileExamples, + cliCases, + compatibilityCatalogVersion, + configurationCases, + exitCodes, + humanOutput, + humanOutputFixture, + operationCatalog, + registrationFixture, + responseContract, + rpcExamples, + rpcOperationFixtures, + rpcSnapshotFixture, + shimAliases, + shimCommandCases, + shimRunMetadata, + sourceAnchors, + stateModelFields, +} from './catalog.ts'; + +function findRepositoryRoot(): string { + let candidate = resolve(dirname(fileURLToPath(import.meta.url))); + while (candidate !== dirname(candidate)) { + if (existsSync(join(candidate, 'crates')) && existsSync(join(candidate, 'openspec'))) { + return candidate; + } + candidate = dirname(candidate); + } + throw new Error('Could not locate the Cadder repository root'); +} + +const repositoryRoot = findRepositoryRoot(); + +function sourceText(path: string): string { + return readFileSync(join(repositoryRoot, path), 'utf8'); +} + +function hasMarker(path: string, marker: string): boolean { + return sourceText(path).includes(marker); +} + +function snakeCase(name: string): string { + return name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); +} + +function regexEscape(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function sourcePolicyKind(command: string): string | undefined { + const pattern = new RegExp( + `policy_entry\\(\\s*"${regexEscape(command)}",\\s*ShimCommandPolicyKind::(\\w+)`, + 'm', + ); + return pattern.exec(sourceText('crates/cadder-shim/src/command_policy.rs'))?.[1]; +} + +function operationConstantName(operation: string): string { + return operation.replaceAll('-', '_').toUpperCase(); +} + +function sourceOperationAccess(operation: string): string | undefined { + const pattern = new RegExp( + `message_types::${operationConstantName(operation)},\\s*OperationAccess::(\\w+)`, + 'm', + ); + return pattern.exec(sourceText('crates/cadder-ipc/src/operations.rs'))?.[1]; +} + +const policyKinds = { + managed: 'Managed', + 'read-only-inspection': 'ReadOnlyInspection', + 'explicit-passthrough': 'ExplicitPassthrough', + unsupported: 'Unsupported', +} as const; + +describe('released 1.0.5 compatibility catalog', () => { + it('is versioned and anchored to the complete current authority set', () => { + expect(compatibilityCatalogVersion).toBe('1.0.5-rust-baseline'); + for (const anchor of sourceAnchors) { + for (const marker of anchor.markers) { + expect(hasMarker(anchor.path, marker), `${anchor.path} is missing ${marker}`).toBe(true); + } + } + }); + + it('freezes CLI grammar, nested help, exits, and observable streams', () => { + const successful = cliCases.filter(({ expected }) => expected === 'success'); + const rejected = cliCases.filter(({ expected }) => expected === 'invalid-usage'); + expect(successful.length).toBeGreaterThan(20); + expect(rejected.length).toBeGreaterThan(10); + expect(new Set(cliCases.map(({ argv }) => argv.join('\u0000'))).size).toBe(cliCases.length); + + for (const testCase of cliCases) { + expect(testCase.argv[0]).toBe('cadder'); + expect(testCase.exitCode).toBe( + testCase.expected === 'success' ? exitCodes.success : exitCodes.invalidUsage, + ); + if (testCase.expected === 'invalid-usage') expect(testCase.stream).toBe('stderr'); + } + expect(cliCases.find(({ argv }) => argv.length === 1)).toMatchObject({ + expected: 'success', + exitCode: 0, + stream: 'stderr', + }); + for (const group of ['daemon', 'projects', 'domains', 'port', 'caddyfile', 'logs']) { + expect(cliCases.find(({ argv }) => argv.join(' ') === `cadder ${group}`)).toMatchObject({ + expected: 'success', + exitCode: 0, + stream: 'stderr', + }); + } + expect(cliCases.slice(1, 5).every(({ stream }) => stream === 'stdout')).toBe(true); + expect(cliCases.some(({ argv }) => argv.join(' ') === 'cadder daemon')).toBe(true); + expect(cliCases.some(({ argv }) => argv.join(' ') === 'cadder projects')).toBe(true); + expect(cliCases.some(({ argv }) => argv.join(' ') === 'cadder logs')).toBe(true); + expect( + cliCases.some( + ({ argv }) => + argv.join(' ') === 'cadder domains inspect app.localhost --caddyfile Caddyfile', + ), + ).toBe(true); + expect( + cliCases.some( + ({ argv }) => + argv.join(' ') === 'cadder logs domain app.localhost --caddyfile Caddyfile --limit 1', + ), + ).toBe(true); + expect( + cliCases + .filter(({ notes }) => notes.includes('removed')) + .every(({ exitCode, stream }) => exitCode === 2 && stream === 'stderr'), + ).toBe(true); + expect(sourceText('crates/cadder-client/src/main.rs')).toContain('let _ = error.print();'); + }); + + it('agrees with captured released non-TTY exit codes and output streams', () => { + const capture = JSON.parse( + readFileSync(new URL('./cli-output.json', import.meta.url), 'utf8'), + ) as { + version: string; + cases: { argv: string[]; exitCode: number; stdout: string; stderr: string }[]; + }; + expect(capture.version).toBe('1.0.5'); + for (const captured of capture.cases) { + const catalogCase = cliCases.find( + ({ argv }) => argv.slice(1).join('\u0000') === captured.argv.join('\u0000'), + ); + expect(catalogCase, `Missing captured argv: ${captured.argv.join(' ')}`).toBeDefined(); + expect(catalogCase?.exitCode).toBe(captured.exitCode); + expect(catalogCase?.stream).toBe(captured.stdout ? 'stdout' : 'stderr'); + expect(Boolean(captured.stdout)).not.toBe(Boolean(captured.stderr)); + } + }); + + it('freezes the released exit-code classes without introducing machine output', () => { + expect(exitCodes).toEqual({ + success: 0, + invalidUsage: 2, + daemonUnavailable: 3, + daemonStartFailure: 4, + targetNotFound: 5, + conflictOrRejected: 6, + permissionOrElevation: 7, + unsupportedOperation: 8, + ipcFailure: 9, + }); + expect(sourceText('crates/cadder-api/src/error.rs')).toContain('pub enum AppExit'); + }); + + it('freezes exact human output captures from the retained Rust renderer', () => { + const outputSource = sourceText('crates/cadder-client/src/cli/output.rs'); + expect(humanOutputFixture).toMatchObject({ + sourceRevision: '93942af84f555eab2bd68e0a4a9db487ff82d040', + version: '1.0.5', + platform: 'win32', + }); + expect(humanOutputFixture.capture).toContain('cargo test --locked -p cadder-client'); + expect(humanOutputFixture.capture).toContain('including its final LF'); + expect(humanOutputFixture.sourceState).toContain('fixed-time test inputs'); + expect(humanOutputFixture.portability).toContain('path separators are platform-specific'); + expect(humanOutputFixture.cases.map(({ name }) => name)).toEqual(humanOutput.captureNames); + expect(new Set(humanOutputFixture.cases.map(({ name }) => name)).size).toBe( + humanOutputFixture.cases.length, + ); + for (const { name, output } of humanOutputFixture.cases) { + expect(output, `${name} must contain rendered output`).not.toBe(''); + expect(output, `${name} must use stable LF captures`).not.toContain('\r'); + expect(output.endsWith('\n'), `${name} must preserve its final LF`).toBe(true); + expect(outputSource).toContain(name); + } + + const captures = new Map(humanOutputFixture.cases.map(({ name, output }) => [name, output])); + expect(captures.get(humanOutput.captureNames[0])).toContain( + `${humanOutput.status.join('\n')}\n`, + ); + expect(captures.get(humanOutput.captureNames[0])).toContain('No projects registered.'); + expect(captures.get(humanOutput.captureNames[0])).toContain('active.localhost'); + expect(captures.get(humanOutput.captureNames[1])).toContain('No matching routes.'); + expect(captures.get(humanOutput.captureNames[1])).toContain('socket inspection unavailable'); + expect(captures.get(humanOutput.captureNames[2])).toContain('Registered yes'); + expect(captures.get(humanOutput.captureNames[2])).toContain('Local upstream sockets'); + expect(captures.get(humanOutput.captureNames[3])).toContain('runtime-warning'); + expect(captures.get(humanOutput.captureNames[3])).toContain('2026-01-01T00:00:00+00:00'); + expect(captures.get(humanOutput.captureNames[3])).toContain( + `Status ${humanOutput.populatedLogs.status}\nChannel ${humanOutput.populatedLogs.channel}\nDomain ${humanOutput.populatedLogs.domain}\n`, + ); + + for (const line of [ + ...humanOutput.emptyProjects, + ...humanOutput.emptyDomains, + ...humanOutput.emptyLogs, + ...humanOutput.logHeader, + ...humanOutput.diagnosticHeaders, + ...humanOutput.populatedProjects.headers, + ...humanOutput.populatedDomains.headers, + ...humanOutput.populatedPort.sections, + ...humanOutput.populatedPort.ownerHeaders, + ...humanOutput.populatedCaddyfile.sections, + ...humanOutput.populatedCaddyfile.headers, + ]) { + expect(outputSource, `output.rs is missing ${line}`).toContain(line); + } + }); + + it('freezes shim classifications independently against each Rust policy entry', () => { + expect(shimCommandCases.filter(({ policy }) => policy === 'managed')).toHaveLength(1); + expect(shimCommandCases.filter(({ policy }) => policy === 'read-only-inspection')).toHaveLength( + 7, + ); + expect(shimCommandCases.filter(({ policy }) => policy === 'explicit-passthrough')).toHaveLength( + 5, + ); + expect(shimCommandCases.filter(({ policy }) => policy === 'unsupported')).toHaveLength(9); + for (const { command, policy } of shimCommandCases) { + if (command === 'not-a-caddy-command') { + expect(sourcePolicyKind(command)).toBeUndefined(); + } else { + expect(sourcePolicyKind(command)).toBe(policyKinds[policy]); + } + } + const policySource = sourceText('crates/cadder-shim/src/command_policy.rs'); + for (const alias of shimAliases) { + for (const argument of alias.argv) { + expect(policySource).toContain(`"${argument}"`); + } + } + expect(policySource).toContain('None | Some("--help" | "-h" | "help")'); + expect(policySource).toContain('Some("--version" | "-v" | "version")'); + expect(shimRunMetadata.delegation.classes).toEqual([ + 'read-only-inspection', + 'explicit-passthrough', + ]); + }); + + it('separates managed-run lifecycle outcomes from delegated child fidelity', () => { + const shimSource = sourceText('crates/cadder-shim/src/main.rs'); + expect(shimRunMetadata.managedRun).toMatchObject({ + streams: 'no child delegation; registration session owns no real-Caddy child', + exitCodes: { clean: 0, backendOrRecoveryFailure: 1 }, + missingDaemon: 'start managed daemon; never delegate to independent Caddy', + }); + expect(shimRunMetadata.delegation).toMatchObject({ + streams: ['stdin', 'stdout', 'stderr'], + exitStatus: 'status.code() as u8, or 1 when no status exists', + }); + expect(shimSource).toContain('run_managed_until'); + expect(shimSource).toContain('Ok(ExitCode::SUCCESS)'); + expect(shimSource).toContain('ExitCode::FAILURE'); + expect(shimSource).toContain('.stdin(Stdio::inherit())'); + expect(shimSource).toContain('status.code().unwrap_or(1) as u8'); + }); + + it('freezes config cases and verifies resolver precedence from source order', () => { + const configSource = sourceText('crates/cadder-daemon/src/config.rs'); + for (const configurationCase of configurationCases) { + expect(configSource).toContain(configurationCase.sourceMarker); + } + const resolverSource = sourceText('crates/cadder-daemon/src/caddy/resolver.rs'); + const uncachedSource = resolverSource.slice( + resolverSource.indexOf('fn resolve_uncached'), + resolverSource.indexOf('pub fn resolution_help'), + ); + expect(uncachedSource.indexOf('ExplicitDaemonOverride')).toBeGreaterThanOrEqual(0); + expect(uncachedSource.indexOf('self.configured_sources()')).toBeGreaterThan( + uncachedSource.indexOf('ExplicitDaemonOverride'), + ); + expect(uncachedSource.indexOf('resolve_caddy_on_path')).toBeGreaterThan( + uncachedSource.indexOf('self.configured_sources()'), + ); + const configuredSource = resolverSource.slice( + resolverSource.indexOf('fn configured_sources'), + resolverSource.indexOf('fn portable_config_path'), + ); + const configuredPositions = [ + 'PortableConfiguration', + 'UserConfiguration', + 'SystemConfiguration', + ].map((marker) => configuredSource.indexOf(marker)); + expect(configuredPositions.every((position) => position >= 0)).toBe(true); + expect(configuredPositions).toEqual( + [...configuredPositions].sort((left, right) => left - right), + ); + expect(caddyResolutionPrecedence).toHaveLength(5); + for (const test of caddyResolutionAuthority.tests) { + expect(sourceText(caddyResolutionAuthority.path)).toContain(`fn ${test}(`); + } + }); + + it('parses concrete supported TOML fixtures and rejects malformed syntax', () => { + expect(parseToml(configurationCases[0].toml)).toEqual({ + caddy: { real_command: 'caddy-real' }, + }); + expect(parseToml(configurationCases[1].toml)).toEqual({ caddy: { real_path: '/opt/caddy' } }); + expect(parseToml(configurationCases[2].toml)).toEqual({ + caddy: { real_command: 'caddy-real', real_path: '/opt/caddy' }, + }); + expect(parseToml(configurationCases[3].toml)).toEqual({ + defaults: { real_caddy: '/opt/caddy' }, + }); + expect(parseToml(configurationCases[4].toml)).toEqual({ caddy: { unknown: 'caddy' } }); + expect(() => parseToml(configurationCases[5].toml)).toThrow(); + }); + + it('freezes real Caddyfile newlines and inspection correlation boundaries', () => { + expect(caddyfileExamples).toHaveLength(4); + expect(caddyfileExamples.filter(({ expected }) => expected === 'accepted')).toHaveLength(3); + expect(caddyfileExamples.filter(({ expected }) => expected === 'rejected')).toHaveLength(1); + expect(caddyfileExamples.every(({ source }) => source.includes('\n'))).toBe(true); + expect(caddyfileExamples.every(({ source }) => !source.includes('\\n'))).toBe(true); + for (const { evidence } of caddyfileExamples) { + expect(evidence.aspect).not.toBe(''); + expect(sourceText(evidence.path)).toContain(`fn ${evidence.test}(`); + } + expect(caddyfileExamples[2].evidence.aspect).toContain('not acceptance of this Caddyfile'); + expect(caddyfileExamples[0]).toMatchObject({ + domain: 'app.localhost', + upstream: '127.0.0.1:3000', + localPort: 3000, + }); + expect(caddyfileExamples[1]).toMatchObject({ + domain: 'remote.example', + upstream: 'api.example.com:3000', + localPort: null, + }); + expect(caddyfileExamples[2]).toMatchObject({ domain: '', upstream: null, localPort: null }); + expect(caddyfileExamples[3]).toMatchObject({ domain: null, upstream: null, localPort: null }); + const adapterSource = sourceText('crates/cadder-daemon/src/caddy/tests.rs'); + expect(adapterSource).toContain('mock_adapter_reports_invalid_caddyfile'); + expect(adapterSource).toContain('app.localhost {\\n'); + const inspectionSource = sourceText('crates/cadder-client/src/inspection.rs'); + expect(inspectionSource).toContain('routes_for_port'); + expect(inspectionSource).toContain('local_upstream'); + expect(inspectionSource).toContain('canonical_domain'); + }); + + it('freezes nested registration identity, owner/session constraints, and log attribution', () => { + expect(registrationFixture.registrationId).toBe( + registrationFixture.entrypointInstance.instanceId, + ); + expect(registrationFixture.entrypointInstance.shimSessionNonce).toBe( + registrationFixture.ownerProcess.shimSessionNonce, + ); + expect(registrationFixture.registeredDomains[0].name.canonical).toBe('app.localhost'); + expect(registrationFixture.registeredDomains[0].logStream).toEqual({ + streamId: 'domain-app.localhost', + domainKey: 'app.localhost', + channel: 'caddy', + }); + const invalidInstance = { ...registrationFixture, registrationId: 'different-registration' }; + expect(invalidInstance.registrationId).not.toBe(invalidInstance.entrypointInstance.instanceId); + const invalidNonce = { + ...registrationFixture, + ownerProcess: { ...registrationFixture.ownerProcess, shimSessionNonce: 'different-session' }, + }; + expect(invalidNonce.entrypointInstance.shimSessionNonce).not.toBe( + invalidNonce.ownerProcess.shimSessionNonce, + ); + const stateSource = sourceText('crates/cadder-ipc/src/state.rs'); + expect(stateSource).toContain('registration_id must match instance_id'); + expect(stateSource).toContain('entrypoint and owner shim session nonce values must match'); + expect(stateModelFields.entrypointInstance).toEqual([ + 'instanceId', + 'startedAtUtc', + 'shimSessionNonce', + ]); + }); + + it('freezes exactly eight operation payload/result shapes against defining Rust types', () => { + const operationSource = sourceText('crates/cadder-ipc/src/message_types.rs'); + const payloadSources: Record = { + RegisterEntrypointPayload: 'crates/cadder-ipc/src/mutations.rs', + UnregisterEntrypointPayload: 'crates/cadder-ipc/src/mutations.rs', + HeartbeatEntrypointPayload: 'crates/cadder-ipc/src/mutations.rs', + QueryStatePayload: 'crates/cadder-ipc/src/commands.rs', + SetEntrypointEnabledPayload: 'crates/cadder-ipc/src/mutations.rs', + SetDomainEnabledPayload: 'crates/cadder-ipc/src/mutations.rs', + QueryLogsPayload: 'crates/cadder-ipc/src/logs.rs', + ShutdownDaemonPayload: 'crates/cadder-ipc/src/mutations.rs', + }; + const resultSources: Record = { + RegisterEntrypointResponse: 'crates/cadder-ipc/src/responses.rs', + BasicResponse: 'crates/cadder-ipc/src/responses.rs', + QueryStateResponse: 'crates/cadder-ipc/src/responses.rs', + QueryLogsResponse: 'crates/cadder-ipc/src/logs.rs', + }; + expect(operationCatalog).toHaveLength(8); + expect(new Set(operationCatalog.map(({ operation }) => operation)).size).toBe(8); + for (const operation of operationCatalog) { + expect(operationSource).toContain(`"${operation.operation}"`); + expect(sourceOperationAccess(operation.operation)).toBe( + operation.access === 'read-only' ? 'ReadOnly' : 'Mutation', + ); + const payloadPath = payloadSources[operation.payloadType]; + if (!payloadPath) { + throw new Error(`Missing payload source mapping for ${operation.payloadType}`); + } + const payloadSource = sourceText(payloadPath); + expect(payloadSource).toContain(`pub struct ${operation.payloadType}`); + for (const field of operation.payloadFields) { + const fieldPattern = new RegExp(`pub\\s+${snakeCase(field.name)}\\s*:`); + expect(payloadSource).toMatch(fieldPattern); + if (field.optional) { + expect(payloadSource).toMatch( + new RegExp(`pub\\s+${snakeCase(field.name)}\\s*:\\s*Option<`), + ); + } + } + const resultPath = resultSources[operation.resultType]; + if (!resultPath) { + throw new Error(`Missing result source mapping for ${operation.resultType}`); + } + const resultSource = sourceText(resultPath); + expect(resultSource).toContain(`pub struct ${operation.resultType}`); + for (const field of operation.resultFields) { + expect(resultSource).toMatch(new RegExp(`pub\\s+${snakeCase(field.name)}\\s*:`)); + if (field.optional) { + expect(resultSource).toMatch( + new RegExp(`pub\\s+${snakeCase(field.name)}\\s*:\\s*Option<`), + ); + } + } + } + expect( + operationCatalog.find(({ operation }) => operation === 'query-logs-request')?.payloadFields, + ).toEqual([ + { name: 'stream', optional: false }, + { name: 'limit', optional: true }, + ]); + }); + + it('round trips every concrete request/result fixture through the closed Node DTO parser', () => { + for (const fixture of rpcOperationFixtures) { + const request = { + protocolVersion: 3, + requestId: '00000000-0000-4000-8000-000000000001', + method: fixture.method, + params: fixture.params, + }; + expect(requestSchema.parse(request)).toEqual(request); + const response = { + protocolVersion: 3, + requestId: request.requestId, + result: fixture.result, + }; + expect(responseSchema(fixture.method).parse(response)).toEqual(response); + expect(responseSchema(fixture.method).safeParse({ ...response, extra: true }).success).toBe( + false, + ); + } + expect(rpcSnapshotFixture.registrations[0]?.registrationId).toBe('shim-fixture-01'); + }); + + it('freezes correlated RPC examples, protocol errors, streams, and both log defaults', () => { + expect(responseContract).toMatchObject({ + requestFields: ['protocolVersion', 'operation', 'requestId', 'payload'], + successFields: ['protocolVersion', 'operation', 'requestId', 'result'], + errorFields: ['protocolVersion', 'operation', 'requestId', 'error'], + protocolErrorResponse: { name: 'protocol-error-response', fields: ['requestId', 'error'] }, + errorRequestIdRule: 'response and protocol error request IDs must match', + exactlyOneOutcome: true, + unknownFieldsRejected: true, + logLimit: { minimum: 1, maximum: 200, cliDefault: 50, rpcDefault: 100 }, + }); + expect(responseContract.streamStatuses).toEqual([ + 'unknown', + 'empty', + 'active', + 'stale', + 'removed', + 'readError', + ]); + expect(rpcExamples.queryLogsRequest.requestId).toBe(rpcExamples.queryLogsSuccess.requestId); + expect(rpcExamples.queryLogsSuccess).not.toHaveProperty('error'); + expect(rpcExamples.protocolError).not.toHaveProperty('result'); + expect(rpcExamples.protocolError.requestId).toBe(rpcExamples.protocolError.error.requestId); + expect( + errorResponseSchema.parse({ + protocolVersion: 3, + requestId: rpcExamples.protocolError.requestId, + error: rpcExamples.protocolError.error, + }), + ).toMatchObject({ requestId: rpcExamples.protocolError.requestId }); + expect(sourceText('crates/cadder-daemon/src/state/log_queries.rs')).toContain( + 'unwrap_or(100).clamp(1, 200)', + ); + expect(sourceText('crates/cadder-client/src/cli/mod.rs')).toContain('default_value_t = 50'); + }); +}); diff --git a/test/compatibility/catalog.ts b/test/compatibility/catalog.ts new file mode 100644 index 0000000..a35f99e --- /dev/null +++ b/test/compatibility/catalog.ts @@ -0,0 +1,913 @@ +import { readFileSync } from 'node:fs'; + +export const compatibilityCatalogVersion = '1.0.5-rust-baseline'; + +export type SourceAnchor = { + path: string; + markers: readonly string[]; +}; + +export const sourceAnchors: readonly SourceAnchor[] = [ + { + path: 'crates/cadder-client/src/cli/mod.rs', + markers: [ + 'arg_required_else_help = true', + 'pub(crate) enum Command', + 'start_daemon: bool', + 'value_parser = parse_log_limit', + 'Status,', + 'Daemon {', + 'Projects {', + 'Domains {', + 'Port {', + 'Caddyfile {', + 'Diagnostics,', + 'Logs {', + 'Tui {', + 'default_value_t = 50', + ], + }, + { + path: 'crates/cadder-client/src/main.rs', + markers: [ + 'DisplayHelpOnMissingArgumentOrSubcommand', + 'AppExit::InvalidUsage', + 'let _ = error.print();', + ], + }, + { + path: 'crates/cadder-api/src/error.rs', + markers: [ + 'Success = 0', + 'InvalidUsage = 2', + 'DaemonUnavailable = 3', + 'DaemonStartFailure = 4', + 'TargetNotFound = 5', + 'ConflictOrRejected = 6', + 'PermissionOrElevation = 7', + 'UnsupportedOperation = 8', + 'IpcFailure = 9', + ], + }, + { + path: 'crates/cadder-client/src/cli/output.rs', + markers: [ + 'cadderd running', + 'Caddy {}', + 'Config {}', + 'No projects registered.', + 'No domains registered.', + 'No log entries.', + 'PROJECT STATE', + 'STATE", "DOMAIN", "UPSTREAM", "PROJECT', + 'TIME (UTC)', + 'terminal_safe', + 'list_and_status_outputs_cover_empty_and_populated_snapshots', + 'port_output_covers_socket_and_registration_availability', + 'route_outputs_cover_registered_unregistered_and_socket_notes', + 'diagnostics_and_logs_cover_empty_and_detailed_reports', + ], + }, + { + path: 'crates/cadder-shim/src/command_policy.rs', + markers: [ + 'SHIM_COMMAND_POLICY_TABLE', + 'normalized_caddy_command', + 'None | Some("--help" | "-h" | "help")', + 'Some("--version" | "-v" | "version")', + '"run"', + '"adapt"', + '"build-info"', + '"environ"', + '"help"', + '"list-modules"', + '"validate"', + '"version"', + '"completion"', + '"file-server"', + '"fmt"', + '"manpage"', + '"reverse-proxy"', + '"add-package"', + '"reload"', + '"remove-package"', + '"start"', + '"stop"', + '"trust"', + '"untrust"', + '"upgrade"', + ], + }, + { + path: 'crates/cadder-shim/src/main.rs', + markers: [ + 'allow_hyphen_values', + '--cadder-shim-info', + 'Stdio::inherit()', + 'status.code().unwrap_or(1) as u8', + 'ExitCode::FAILURE', + 'run_managed_until', + 'delegate_to_real_caddy', + ], + }, + { + path: 'crates/cadder-daemon/tests/fixtures/test_process.rs', + markers: [ + 'shim-passthrough-', + 'FAKE_CADDY_PASSTHROUGH_STDOUT', + 'FAKE_CADDY_PASSTHROUGH_STDERR', + '"stdin_bytes"', + ], + }, + { + path: 'crates/cadder-shim/tests/shim_binary.rs', + markers: [ + 'real_caddy_passthrough_preserves_arguments_stdin_streams_and_success_exit', + 'real_caddy_passthrough_propagates_nonzero_child_exit', + 'CARGO_BIN_EXE_cadder-shim', + ], + }, + { + path: 'crates/cadder-shim/src/registration.rs', + markers: [ + 'cwd.join("Caddyfile")', + '"--config" | "-c"', + '"--adapter" | "-a"', + 'shim_session_nonce', + ], + }, + { + path: 'crates/cadder-daemon/src/config.rs', + markers: ['CONFIG_FILE_NAME', 'real_command', 'real_path', 'cannot both be configured'], + }, + { + path: 'crates/cadder-daemon/src/caddy/resolver.rs', + markers: [ + 'ExplicitDaemonOverride', + 'PortableConfiguration', + 'UserConfiguration', + 'SystemConfiguration', + 'resolve_caddy_on_path', + 'Project files, registration working directories, environment selectors, and shim flags', + ], + }, + { + path: 'crates/cadder-client/src/inspection.rs', + markers: [ + 'routes_for_port', + 'routes_for_domain', + 'canonicalize_domain', + 'local_upstream', + 'sort_routes', + ], + }, + { + path: 'crates/cadder-ipc/src/message_types.rs', + markers: [ + 'register-entrypoint-request', + 'unregister-entrypoint-request', + 'heartbeat-entrypoint-request', + 'query-state-request', + 'set-entrypoint-enabled-request', + 'set-domain-enabled-request', + 'query-logs-request', + 'shutdown-daemon-request', + 'PROTOCOL_ERROR_RESPONSE', + ], + }, + { + path: 'crates/cadder-ipc/src/operations.rs', + markers: ['OperationAccess::ReadOnly', 'OperationAccess::Mutation', 'OperationShape::Unary'], + }, + { + path: 'crates/cadder-ipc/src/wire.rs', + markers: [ + 'deny_unknown_fields', + 'a response must contain `result` or `error`', + 'a response cannot contain both `result` and `error`', + 'the response and protocol error request IDs must match', + ], + }, + { + path: 'crates/cadder-ipc/src/mutations.rs', + markers: [ + 'pub struct RegisterEntrypointPayload', + 'pub struct UnregisterEntrypointPayload', + 'pub struct HeartbeatEntrypointPayload', + 'pub struct SetEntrypointEnabledPayload', + 'pub struct SetDomainEnabledPayload', + 'pub struct ShutdownDaemonPayload', + 'pub shim_session_nonce: Option', + ], + }, + { + path: 'crates/cadder-ipc/src/commands.rs', + markers: ['pub struct QueryStatePayload', 'pub fn new_request_id'], + }, + { + path: 'crates/cadder-ipc/src/responses.rs', + markers: [ + 'pub struct RegisterEntrypointResponse', + 'pub struct BasicResponse', + 'pub struct QueryStateResponse', + 'pub registration_id: Option', + '#[serde(default)]', + ], + }, + { + path: 'crates/cadder-daemon/src/state/log_queries.rs', + markers: ['request.limit.unwrap_or(100).clamp(1, 200)', 'Caddy logs returned.'], + }, + { + path: 'crates/cadder-daemon/src/database.rs', + markers: ['limit.clamp(1, 200)', 'LIMIT 1000', 'LIMIT 5000'], + }, + { + path: 'crates/cadder-ipc/src/state.rs', + markers: [ + 'pub struct EntrypointRegistration', + 'pub struct EntrypointInstanceIdentity', + 'pub struct OwnerProcessIdentity', + 'pub struct RegisteredDomain', + 'pub struct RuntimeState', + 'pub struct ConfigState', + 'registration_id must match instance_id', + 'entrypoint and owner shim session nonce values must match', + ], + }, + { + path: 'crates/cadder-ipc/src/logs.rs', + markers: [ + 'pub struct LogStreamIdentity', + 'pub struct LogEntry', + 'pub enum LogStreamStatus', + 'pub struct QueryLogsPayload', + ], + }, + { + path: 'openspec/specs/operator-cli/spec.md', + markers: [ + 'Profile, machine-output, history, export, continuous tail, watch, IIS and autostart commands SHALL be rejected as unsupported.', + ], + }, + { + path: 'openspec/specs/operator-inspection/spec.md', + markers: ['INSPECT-001:', 'INSPECT-002:', 'INSPECT-003:', 'INSPECT-004:'], + }, + { + path: 'openspec/specs/operator-tui/spec.md', + markers: ['TUI-001:', 'TUI-002:', 'TUI-003:', 'TUI-004:'], + }, + { + path: 'openspec/specs/local-control-plane/spec.md', + markers: ['IPC-001:', 'IPC-002:', 'IPC-003:'], + }, + { + path: 'openspec/specs/observability/spec.md', + markers: ['OBS-001:', 'OBS-002:', 'OBS-003:', 'OBS-004:'], + }, + { + path: 'openspec/specs/product-topology/spec.md', + markers: ['TOP-001:', 'TOP-002:'], + }, + { + path: 'openspec/changes/reset-cadder-architecture/specs/project-registration/spec.md', + markers: ['REG-004:', 'active domain conflicts SHALL preserve the previous committed state'], + }, + { + path: 'openspec/changes/reset-cadder-architecture/specs/caddy-shim-integration/spec.md', + markers: ['SHIM-001:', 'SHIM-002:', 'SHIM-003:', 'standard streams and exit codes'], + }, +]; + +export type CliCase = { + argv: readonly string[]; + expected: 'success' | 'invalid-usage'; + exitCode: 0 | 2; + stream: 'stdout' | 'stderr'; + notes: string; +}; + +const success = ( + argv: readonly string[], + notes: string, + stream: CliCase['stream'] = 'stdout', +): CliCase => ({ argv, expected: 'success', exitCode: 0, stream, notes }); +const invalid = (argv: readonly string[], notes: string): CliCase => ({ + argv, + expected: 'invalid-usage', + exitCode: 2, + stream: 'stderr', + notes, +}); + +export const cliCases: readonly CliCase[] = [ + success(['cadder'], 'bare invocation prints help', 'stderr'), + success(['cadder', '--help'], 'explicit help', 'stdout'), + success(['cadder', '--version'], 'explicit version', 'stdout'), + success(['cadder', 'help'], 'help subcommand', 'stdout'), + success(['cadder', 'help', 'status'], 'nested command help', 'stdout'), + success(['cadder', 'status'], 'daemon-backed status'), + success(['cadder', 'daemon', 'start'], 'explicit daemon start'), + success(['cadder', 'daemon', 'stop'], 'explicit daemon stop'), + success(['cadder', 'daemon', 'restart'], 'ordered restart'), + success(['cadder', 'daemon'], 'nested group prints help', 'stderr'), + success(['cadder', 'projects', 'list'], 'project listing'), + success(['cadder', 'projects', 'enable', 'Caddyfile'], 'project activation'), + success(['cadder', 'projects', 'disable', 'Caddyfile'], 'project deactivation'), + success(['cadder', 'projects'], 'nested group prints help', 'stderr'), + success(['cadder', 'domains', 'list'], 'domain listing'), + success(['cadder', 'domains', 'inspect', 'app.localhost'], 'domain inspection'), + success( + ['cadder', 'domains', 'inspect', 'app.localhost', '--caddyfile', 'Caddyfile'], + 'domain inspection with project selector', + ), + success( + ['cadder', 'domains', 'enable', 'app.localhost', '--caddyfile', 'Caddyfile'], + 'domain activation with project selector', + ), + success(['cadder', 'domains', 'disable', 'app.localhost'], 'domain deactivation'), + success( + ['cadder', 'domains', 'disable', 'app.localhost', '--caddyfile', 'Caddyfile'], + 'domain deactivation with project selector', + ), + success(['cadder', 'domains'], 'nested group prints help', 'stderr'), + success(['cadder', 'port', 'inspect', '3000'], 'port inspection'), + success(['cadder', 'port', 'kill', '3000', '--pid', '42'], 'guarded process termination'), + success(['cadder', 'port'], 'nested group prints help', 'stderr'), + invalid(['cadder', 'port', 'kill', '3000'], 'kill requires observed PID'), + success(['cadder', 'caddyfile', 'inspect', 'Caddyfile'], 'Caddyfile inspection'), + success(['cadder', 'caddyfile'], 'nested group prints help', 'stderr'), + success(['cadder', 'diagnostics'], 'explicit diagnostics'), + success(['cadder', 'logs', 'runtime'], 'bounded runtime logs'), + success(['cadder', 'logs', 'runtime', '--limit', '200'], 'bounded runtime logs at upper bound'), + success(['cadder', 'logs', 'project', 'Caddyfile', '--limit', '200'], 'bounded project logs'), + success(['cadder', 'logs', 'domain', 'app.localhost', '--limit', '1'], 'bounded domain logs'), + success( + ['cadder', 'logs', 'domain', 'app.localhost', '--caddyfile', 'Caddyfile', '--limit', '1'], + 'bounded domain logs with project selector', + ), + invalid(['cadder', 'logs', 'runtime', '--limit', '0'], 'log limit lower bound'), + invalid(['cadder', 'logs', 'runtime', '--limit', '201'], 'log limit upper bound'), + success(['cadder', 'logs'], 'nested group prints help', 'stderr'), + success(['cadder', 'tui'], 'attach-first routes TUI'), + success(['cadder', 'tui', '--start-daemon'], 'explicit offline/start path'), + invalid(['cadder', '--runtime-dir', 'fixture', 'tui'], 'runtime selection is not CLI grammar'), + invalid(['cadder', 'web'], 'unknown command is rejected'), + invalid(['cadder', 'profile', 'dev'], 'removed profile input is clap invalid usage'), + invalid(['cadder', '--json', 'status'], 'removed machine output is clap invalid usage'), + invalid(['cadder', 'status', '--json'], 'removed machine output after subcommand'), + invalid(['cadder', 'tui', '--json'], 'removed machine output on TUI'), + invalid(['cadder', 'history'], 'removed history input is clap invalid usage'), + invalid(['cadder', 'export'], 'removed export input is clap invalid usage'), + invalid(['cadder', 'logs', 'runtime', '--tail'], 'removed continuous tail is clap invalid usage'), + invalid(['cadder', 'watch'], 'removed watch input is clap invalid usage'), + invalid(['cadder', 'autostart'], 'removed autostart input is clap invalid usage'), + invalid(['cadder', 'iis'], 'removed IIS input is clap invalid usage'), +]; + +export const exitCodes = { + success: 0, + invalidUsage: 2, + daemonUnavailable: 3, + daemonStartFailure: 4, + targetNotFound: 5, + conflictOrRejected: 6, + permissionOrElevation: 7, + unsupportedOperation: 8, + ipcFailure: 9, +} as const; + +type HumanOutputFixture = { + sourceRevision: string; + version: string; + platform: string; + capture: string; + sourceState: string; + portability: string; + cases: readonly { name: string; output: string }[]; +}; + +export const humanOutputFixture = JSON.parse( + readFileSync(new URL('./human-output.json', import.meta.url), 'utf8'), +) as HumanOutputFixture; + +export const humanOutput = { + status: ['cadderd running', 'Caddy running', 'Config idle'], + emptyProjects: ['No projects registered.'], + emptyDomains: ['No domains registered.'], + emptyLogs: ['No log entries.'], + logHeader: ['SEQ', 'TIME (UTC)', 'LEVEL', 'MESSAGE'], + diagnosticHeaders: ['Runtime diagnostics', 'Configuration diagnostics'], + populatedProjects: { + headers: ['STATE', 'PROJECT', 'CADDYFILE'], + }, + populatedDomains: { + headers: ['STATE', 'DOMAIN', 'UPSTREAM', 'PROJECT'], + }, + populatedPort: { + sections: ['Port {port}', 'Sockets', 'Cadder routes'], + ownerHeaders: ['PROTOCOL', 'ADDRESS', 'PID', 'PROCESS', 'EXECUTABLE'], + }, + populatedCaddyfile: { + sections: ['Registered yes', 'Domains', 'Local upstream sockets'], + headers: ['PROJECT STATE', 'DOMAIN STATE', 'DOMAIN', 'UPSTREAM'], + }, + populatedLogs: { + status: 'active', + channel: 'caddy', + domain: 'active.localhost', + }, + captureNames: [ + 'list_and_status_outputs_cover_empty_and_populated_snapshots', + 'port_output_covers_socket_and_registration_availability', + 'route_outputs_cover_registered_unregistered_and_socket_notes', + 'diagnostics_and_logs_cover_empty_and_detailed_reports', + ], +} as const; + +export type ShimPolicy = + | 'managed' + | 'read-only-inspection' + | 'explicit-passthrough' + | 'unsupported'; + +export const shimCommandCases: readonly { command: string; policy: ShimPolicy }[] = [ + { command: 'run', policy: 'managed' }, + ...['adapt', 'build-info', 'environ', 'help', 'list-modules', 'validate', 'version'].map( + (command) => ({ command, policy: 'read-only-inspection' as const }), + ), + ...['completion', 'file-server', 'fmt', 'manpage', 'reverse-proxy'].map((command) => ({ + command, + policy: 'explicit-passthrough' as const, + })), + ...[ + 'add-package', + 'reload', + 'remove-package', + 'start', + 'stop', + 'trust', + 'untrust', + 'upgrade', + ].map((command) => ({ command, policy: 'unsupported' as const })), + { command: 'not-a-caddy-command', policy: 'unsupported' }, +]; + +export const shimAliases = [ + { argv: [], normalized: 'help' }, + { argv: ['--help'], normalized: 'help' }, + { argv: ['-h'], normalized: 'help' }, + { argv: ['help'], normalized: 'help' }, + { argv: ['--version'], normalized: 'version' }, + { argv: ['-v'], normalized: 'version' }, + { argv: ['version'], normalized: 'version' }, +] as const; + +export const shimRunMetadata = { + defaultConfig: 'Caddyfile', + configOptions: ['--config', '-c'], + adapterOptions: ['--adapter', '-a'], + preservesRawArguments: true, + preservesJoinedCommandLine: true, + managedRun: { + streams: 'no child delegation; registration session owns no real-Caddy child', + exitCodes: { clean: 0, backendOrRecoveryFailure: 1 }, + missingDaemon: 'start managed daemon; never delegate to independent Caddy', + }, + delegation: { + classes: ['read-only-inspection', 'explicit-passthrough'], + streams: ['stdin', 'stdout', 'stderr'], + exitStatus: 'status.code() as u8, or 1 when no status exists', + }, +} as const; + +export const caddyResolutionPrecedence = [ + 'absolute daemon-start override', + 'portable cadder.toml beside the executable', + 'per-user trusted cadder.toml', + 'system trusted cadder.toml', + 'safe native caddy on PATH', +] as const; + +export const caddyResolutionAuthority = { + path: 'crates/cadder-daemon/src/caddy/tests.rs', + tests: [ + 'trusted_caddy_source_explicit_override_precedes_user_and_system_configuration', + 'trusted_caddy_source_user_configuration_precedes_system_configuration', + 'trusted_caddy_source_uses_system_default_after_empty_user_config', + 'trusted_caddy_source_invalid_higher_priority_config_fails_without_fallback', + 'trusted_caddy_source_uses_portable_configuration_and_ignores_project_and_environment_selectors', + 'trusted_caddy_source_portable_command_selects_the_named_path_executable', + 'trusted_caddy_source_path_skips_shim_identity_and_uses_next_candidate', + ], +} as const; + +export const configurationCases = [ + { + toml: '[caddy]\nreal_command = "caddy-real"', + result: 'command:caddy-real', + sourceMarker: 'real_command', + }, + { + toml: '[caddy]\nreal_path = "/opt/caddy"', + result: 'path:/opt/caddy', + sourceMarker: 'real_path', + }, + { + toml: '[caddy]\nreal_command = "caddy-real"\nreal_path = "/opt/caddy"', + result: 'reject:both selectors', + sourceMarker: 'cannot both be configured', + }, + { + toml: '[defaults]\nreal_caddy = "/opt/caddy"', + result: 'reject:removed defaults shape', + sourceMarker: 'deny_unknown_fields', + }, + { + toml: '[caddy]\nunknown = "caddy"', + result: 'reject:unknown field', + sourceMarker: 'deny_unknown_fields', + }, + { + toml: '[caddy\nreal_path = "/opt/caddy"', + result: 'reject:invalid TOML', + sourceMarker: 'from_reader', + }, +] as const; + +// Expected adaptation outcomes are migration examples, not executed real-Caddy parser goldens. +// Each citation proves only its named inspection/composition/mock aspect. +export const caddyfileExamples = [ + { + source: 'app.localhost {\n reverse_proxy 127.0.0.1:3000\n}', + domain: 'app.localhost', + upstream: '127.0.0.1:3000', + localPort: 3000, + expected: 'accepted', + evidence: { + aspect: 'local upstream port correlation, not Caddyfile adaptation', + path: 'crates/cadder-client/src/inspection.rs', + test: 'recognizes_local_upstream_aliases', + }, + }, + { + source: 'remote.example {\n reverse_proxy api.example.com:3000\n}', + domain: 'remote.example', + upstream: 'api.example.com:3000', + localPort: null, + expected: 'accepted', + evidence: { + aspect: 'remote upstream exclusion from local ports, not Caddyfile adaptation', + path: 'crates/cadder-client/src/inspection.rs', + test: 'excludes_remote_and_dynamic_upstreams', + }, + }, + { + source: ':3000 {\n respond "ok"\n}', + domain: '', + upstream: null, + localPort: null, + expected: 'accepted', + evidence: { + aspect: 'adapted hostless sibling route composition, not acceptance of this Caddyfile', + path: 'crates/cadder-daemon/src/caddy/tests.rs', + test: 'compose_config_guards_hostless_sibling_routes_with_registration_hosts', + }, + }, + { + source: 'app.localhost {\n', + domain: null, + upstream: null, + localPort: null, + expected: 'rejected', + evidence: { + aspect: 'malformed input denial by the retained test-only mock adapter', + path: 'crates/cadder-daemon/src/caddy/tests.rs', + test: 'mock_adapter_reports_invalid_caddyfile', + }, + }, +] as const; + +export const registrationFixture = { + registrationId: 'shim-fixture-01', + entrypointInstance: { + instanceId: 'shim-fixture-01', + startedAtUtc: '2026-01-01T00:00:00Z', + shimSessionNonce: 'session-fixture-01', + }, + sourceWorkingDirectory: { raw: '/workspace/project', canonical: '/workspace/project' }, + sourceConfigPath: { raw: 'Caddyfile', canonical: '/workspace/project/Caddyfile' }, + registeredDomains: [ + { + name: { raw: 'App.Localhost.', canonical: 'app.localhost' }, + activationState: 'active', + upstream: '127.0.0.1:3000', + logStream: { streamId: 'domain-app.localhost', domainKey: 'app.localhost', channel: 'caddy' }, + }, + ], + activationState: 'active', + ownerProcess: { + processId: 42, + processStartTimeUtc: '2026-01-01T00:00:00Z', + shimSessionNonce: 'session-fixture-01', + executablePath: null, + }, + logStream: { streamId: 'entrypoint-shim-fixture-01', domainKey: null, channel: 'caddy' }, + shimRun: { + adapter: 'caddyfile', + rawArguments: ['run', '--config', 'Caddyfile', '--adapter', 'caddyfile'], + commandLine: 'run --config Caddyfile --adapter caddyfile', + }, + createdAtUtc: '2026-01-01T00:00:00Z', + lastHeartbeatUtc: '2026-01-01T00:00:00Z', +} as const; + +export const stateModelFields = { + runtime: ['status', 'binaryPath', 'version', 'processId', 'adminEndpoint', 'diagnostics'], + config: [ + 'status', + 'lastAttemptedAtUtc', + 'lastSuccessfulReloadAtUtc', + 'effectiveConfigHash', + 'diagnostics', + ], + registration: [ + 'registrationId', + 'entrypointInstance', + 'sourceWorkingDirectory', + 'sourceConfigPath', + 'registeredDomains', + 'activationState', + 'ownerProcess', + 'logStream', + 'shimRun', + 'createdAtUtc', + 'lastHeartbeatUtc', + ], + entrypointInstance: ['instanceId', 'startedAtUtc', 'shimSessionNonce'], + ownerProcess: ['processId', 'processStartTimeUtc', 'shimSessionNonce', 'executablePath'], + domain: ['name', 'activationState', 'upstream', 'logStream'], + logEntry: [ + 'sequenceNumber', + 'timestampUtc', + 'severity', + 'stream', + 'attributionKind', + 'entryKind', + 'rawMessage', + 'domainKey', + 'sourceRegistrationId', + 'sourceInstanceId', + 'operation', + ], +} as const; + +type Field = { name: string; optional: boolean }; +type OperationFixture = { + operation: string; + payloadType: string; + payloadFields: readonly Field[]; + result: string; + resultType: string; + resultFields: readonly Field[]; + access: 'mutation' | 'read-only'; +}; + +const required = (name: string): Field => ({ name, optional: false }); +const optional = (name: string): Field => ({ name, optional: true }); + +export const operationCatalog: readonly OperationFixture[] = [ + { + operation: 'register-entrypoint-request', + payloadType: 'RegisterEntrypointPayload', + payloadFields: [required('registration')], + result: 'register-entrypoint-response', + resultType: 'RegisterEntrypointResponse', + resultFields: [ + required('requestId'), + required('accepted'), + required('message'), + optional('registrationId'), + ], + access: 'mutation', + }, + { + operation: 'unregister-entrypoint-request', + payloadType: 'UnregisterEntrypointPayload', + payloadFields: [required('registrationId'), required('shimSessionNonce')], + result: 'unregister-entrypoint-response', + resultType: 'BasicResponse', + resultFields: [required('requestId'), required('accepted'), required('message')], + access: 'mutation', + }, + { + operation: 'heartbeat-entrypoint-request', + payloadType: 'HeartbeatEntrypointPayload', + payloadFields: [required('registrationId'), required('shimSessionNonce')], + result: 'heartbeat-entrypoint-response', + resultType: 'BasicResponse', + resultFields: [required('requestId'), required('accepted'), required('message')], + access: 'mutation', + }, + { + operation: 'query-state-request', + payloadType: 'QueryStatePayload', + payloadFields: [], + result: 'query-state-response', + resultType: 'QueryStateResponse', + resultFields: [ + required('requestId'), + required('accepted'), + required('message'), + optional('snapshot'), + ], + access: 'read-only', + }, + { + operation: 'set-entrypoint-enabled-request', + payloadType: 'SetEntrypointEnabledPayload', + payloadFields: [required('registrationId'), optional('shimSessionNonce'), required('enabled')], + result: 'set-entrypoint-enabled-response', + resultType: 'BasicResponse', + resultFields: [required('requestId'), required('accepted'), required('message')], + access: 'mutation', + }, + { + operation: 'set-domain-enabled-request', + payloadType: 'SetDomainEnabledPayload', + payloadFields: [required('registrationId'), required('domainKey'), required('enabled')], + result: 'set-domain-enabled-response', + resultType: 'BasicResponse', + resultFields: [required('requestId'), required('accepted'), required('message')], + access: 'mutation', + }, + { + operation: 'query-logs-request', + payloadType: 'QueryLogsPayload', + payloadFields: [required('stream'), optional('limit')], + result: 'query-logs-response', + resultType: 'QueryLogsResponse', + resultFields: [ + required('requestId'), + required('accepted'), + required('message'), + required('stream'), + required('streamStatus'), + required('entries'), + ], + access: 'read-only', + }, + { + operation: 'shutdown-daemon-request', + payloadType: 'ShutdownDaemonPayload', + payloadFields: [], + result: 'shutdown-daemon-response', + resultType: 'BasicResponse', + resultFields: [required('requestId'), required('accepted'), required('message')], + access: 'mutation', + }, +]; + +export const responseContract = { + requestFields: ['protocolVersion', 'operation', 'requestId', 'payload'], + successFields: ['protocolVersion', 'operation', 'requestId', 'result'], + errorFields: ['protocolVersion', 'operation', 'requestId', 'error'], + protocolErrorResponse: { name: 'protocol-error-response', fields: ['requestId', 'error'] }, + errorRequestIdRule: 'response and protocol error request IDs must match', + exactlyOneOutcome: true, + unknownFieldsRejected: true, + streamStatuses: ['unknown', 'empty', 'active', 'stale', 'removed', 'readError'], + logLimit: { minimum: 1, maximum: 200, cliDefault: 50, rpcDefault: 100 }, +} as const; + +const rpcRequestId = '00000000-0000-4000-8000-000000000001'; +const rpcTimestamp = '2026-01-01T00:00:00.000Z'; +const rpcBasicResult = { + requestId: rpcRequestId, + accepted: true, + message: 'Fixture operation accepted.', +}; + +export const rpcSnapshotFixture = { + capturedAtUtc: rpcTimestamp, + registrations: [registrationFixture], + runtime: { + status: 'idle', + binaryPath: null, + version: null, + processId: null, + adminEndpoint: null, + diagnostics: [], + }, + config: { + status: 'idle', + lastAttemptedAtUtc: null, + lastSuccessfulReloadAtUtc: null, + effectiveConfigHash: null, + diagnostics: [], + }, + storage: null, +} as const; + +export const rpcOperationFixtures = [ + { + method: 'register-entrypoint-request', + params: { registration: registrationFixture }, + result: { ...rpcBasicResult, registrationId: registrationFixture.registrationId }, + }, + { + method: 'unregister-entrypoint-request', + params: { + registrationId: registrationFixture.registrationId, + shimSessionNonce: 'session-fixture-01', + }, + result: rpcBasicResult, + }, + { + method: 'heartbeat-entrypoint-request', + params: { + registrationId: registrationFixture.registrationId, + shimSessionNonce: 'session-fixture-01', + }, + result: rpcBasicResult, + }, + { + method: 'query-state-request', + params: {}, + result: { ...rpcBasicResult, snapshot: rpcSnapshotFixture }, + }, + { + method: 'set-entrypoint-enabled-request', + params: { + registrationId: registrationFixture.registrationId, + shimSessionNonce: null, + enabled: true, + }, + result: rpcBasicResult, + }, + { + method: 'set-domain-enabled-request', + params: { + registrationId: registrationFixture.registrationId, + domainKey: 'app.localhost', + enabled: false, + }, + result: rpcBasicResult, + }, + { + method: 'query-logs-request', + params: { + stream: { streamId: 'domain-app.localhost', domainKey: 'app.localhost', channel: 'caddy' }, + limit: 100, + }, + result: { + ...rpcBasicResult, + stream: { streamId: 'domain-app.localhost', domainKey: 'app.localhost', channel: 'caddy' }, + streamStatus: 'active', + entries: [], + }, + }, + { + method: 'shutdown-daemon-request', + params: {}, + result: rpcBasicResult, + }, +] as const; + +export const rpcExamples = { + queryLogsRequest: { + protocolVersion: 3, + operation: 'query-logs-request', + requestId: '00000000-0000-4000-8000-000000000002', + payload: { + stream: { streamId: 'domain-app.localhost', domainKey: 'app.localhost', channel: 'caddy' }, + limit: 100, + }, + }, + queryLogsSuccess: { + protocolVersion: 3, + operation: 'query-logs-response', + requestId: '00000000-0000-4000-8000-000000000002', + result: { + requestId: '00000000-0000-4000-8000-000000000002', + accepted: true, + message: 'Caddy logs returned.', + streamStatus: 'active', + entries: [], + }, + }, + protocolError: { + protocolVersion: 3, + operation: 'query-logs-request', + requestId: '00000000-0000-4000-8000-000000000003', + error: { + kind: 'unsupportedOperation', + code: 'unsupported_operation', + message: 'Unsupported operation', + guidance: null, + retryable: false, + requestId: '00000000-0000-4000-8000-000000000003', + }, + }, +} as const; diff --git a/test/compatibility/cli-output.json b/test/compatibility/cli-output.json new file mode 100644 index 0000000..471107d --- /dev/null +++ b/test/compatibility/cli-output.json @@ -0,0 +1,50 @@ +{ + "sourceRevision": "93942af84f555eab2bd68e0a4a9db487ff82d040", + "version": "1.0.5", + "platform": "win32", + "capture": "redirected non-TTY stdout and stderr; cargo build --locked -p cadder-client -p cadder-shim", + "cases": [ + { + "argv": [], + "exitCode": 0, + "stdout": "", + "stderr": "Inspect and manage Cadder routes\n\nUsage: cadder \n\nCommands:\n status Show cadderd, Caddy, and configuration state\n daemon Start, stop, or restart cadderd\n projects Inspect and manage registered projects\n domains Inspect and manage registered domains\n port Inspect a local port or terminate its expected owner\n caddyfile Inspect a Caddyfile registration and its local upstreams\n diagnostics Show explicit runtime and configuration diagnostics\n logs Read bounded, redacted daemon logs\n tui Open the full-screen operator\n help Print this message or the help of the given subcommand(s)\n\nOptions:\n -h, --help Print help\n -V, --version Print version\n" + }, + { + "argv": ["--help"], + "exitCode": 0, + "stdout": "Inspect and manage Cadder routes\n\nUsage: cadder \n\nCommands:\n status Show cadderd, Caddy, and configuration state\n daemon Start, stop, or restart cadderd\n projects Inspect and manage registered projects\n domains Inspect and manage registered domains\n port Inspect a local port or terminate its expected owner\n caddyfile Inspect a Caddyfile registration and its local upstreams\n diagnostics Show explicit runtime and configuration diagnostics\n logs Read bounded, redacted daemon logs\n tui Open the full-screen operator\n help Print this message or the help of the given subcommand(s)\n\nOptions:\n -h, --help Print help\n -V, --version Print version\n", + "stderr": "" + }, + { + "argv": ["--version"], + "exitCode": 0, + "stdout": "cadder 1.0.5\n", + "stderr": "" + }, + { + "argv": ["history"], + "exitCode": 2, + "stdout": "", + "stderr": "error: unrecognized subcommand 'history'\n\nUsage: cadder \n\nFor more information, try '--help'.\n" + }, + { + "argv": ["status", "--json"], + "exitCode": 2, + "stdout": "", + "stderr": "error: unexpected argument '--json' found\n\nUsage: cadder status\n\nFor more information, try '--help'.\n" + }, + { + "argv": ["daemon"], + "exitCode": 0, + "stdout": "", + "stderr": "Start, stop, or restart cadderd\n\nUsage: cadder daemon \n\nCommands:\n start Start cadderd or attach when it is already running\n stop Stop cadderd and its owned Caddy process\n restart Stop and start cadderd in order\n help Print this message or the help of the given subcommand(s)\n\nOptions:\n -h, --help Print help\n" + }, + { + "argv": ["tui", "--json"], + "exitCode": 2, + "stdout": "", + "stderr": "error: unexpected argument '--json' found\n\nUsage: cadder tui [OPTIONS]\n\nFor more information, try '--help'.\n" + } + ] +} diff --git a/test/compatibility/human-output.json b/test/compatibility/human-output.json new file mode 100644 index 0000000..10af78e --- /dev/null +++ b/test/compatibility/human-output.json @@ -0,0 +1,26 @@ +{ + "sourceRevision": "93942af84f555eab2bd68e0a4a9db487ff82d040", + "version": "1.0.5", + "platform": "win32", + "capture": "cargo test --locked -p cadder-client --bin cadder-client cli::output::tests:: -- --exact --nocapture --test-threads=1 --color never; renderer stdout between libtest markers, including its final LF", + "sourceState": "Released renderer with uncommitted fixed-time test inputs; no production changes", + "portability": "Windows renderer goldens; path separators are platform-specific. Native platform capture or explicit path normalization is required for cross-platform parity.", + "cases": [ + { + "name": "list_and_status_outputs_cover_empty_and_populated_snapshots", + "output": "cadderd running\nCaddy idle\nConfig idle\nNo projects registered.\nNo domains registered.\ncadderd running\nCaddy running\nConfig idle\n STATE PROJECT CADDYFILE \n faulted /workspace/project-a /workspace/project-a\\Caddyfile \n active /workspace/project-b /workspace/project-b\\Caddyfile \n STATE DOMAIN UPSTREAM PROJECT \n faulted faulted.localhost api.example.com:443 /workspace/project-a \n active active.localhost :3000 /workspace/project-b \n inactive inactive.localhost :4000 /workspace/project-b \n pending pending.localhost - /workspace/project-b \n" + }, + { + "name": "port_output_covers_socket_and_registration_availability", + "output": "Port 3000\n\nSockets\n PROTOCOL ADDRESS PID PROCESS EXECUTABLE \n tcp :3000 42 server /usr/bin/server \n udp :3000 unknown unknown - \n tcp 192.0.2.1:3000 84 server /usr/bin/server \n tcp [2001:db8::1]:3000 126 server /usr/bin/server \n\nCadder routes\n PROJECT STATE DOMAIN STATE DOMAIN UPSTREAM PROJECT CADDYFILE \n active active active.localhost :3000 /workspace/project-b /workspace/project-b\\Caddyfile \nPort 3000\n\nSockets\nUnavailable: socket inspection unavailable\n\nCadder routes\nRegistration data unavailable.\n\nCadder: daemon unavailable\nPort 3000\n\nSockets\nNo listening socket found.\n\nCadder routes\nNo matching routes.\n" + }, + { + "name": "route_outputs_cover_registered_unregistered_and_socket_notes", + "output": "Caddyfile /workspace/project-b\\Caddyfile\ncadderd running\nCaddy running\nConfig idle\nRegistered yes\n\n PROJECT STATE PROJECT CADDYFILE \n active /workspace/project-b /workspace/project-b\\Caddyfile \n\nDomains\n PROJECT STATE DOMAIN STATE DOMAIN UPSTREAM \n active active active.localhost :3000 \n active inactive inactive.localhost :4000 \n active registered pending.localhost - \n\nLocal upstream sockets\n PROTOCOL ADDRESS PID PROCESS EXECUTABLE \n tcp :3000 42 server /usr/bin/server \nCaddyfile missing/Caddyfile\ncadderd running\nCaddy running\nConfig idle\nRegistered no\nCaddyfile /workspace/project-b\\Caddyfile\ncadderd running\nCaddy running\nConfig idle\nRegistered yes\n\n PROJECT STATE PROJECT CADDYFILE \n active /workspace/project-b /workspace/project-b\\Caddyfile \n\nDomains\n PROJECT STATE DOMAIN STATE DOMAIN UPSTREAM \n active active active.localhost :3000 \n active inactive inactive.localhost :4000 \n active registered pending.localhost - \n\nLocal upstream sockets\nUnavailable: socket inspection unavailable\nDomain active.localhost\ncadderd running\nCaddy running\nConfig idle\n\n PROJECT STATE DOMAIN STATE DOMAIN UPSTREAM PROJECT CADDYFILE \n active active active.localhost :3000 /workspace/project-b /workspace/project-b\\Caddyfile \n\nLocal upstream sockets\n PROTOCOL ADDRESS PID PROCESS EXECUTABLE \n tcp :3000 42 server /usr/bin/server \nDomain active.localhost\ncadderd running\nCaddy running\nConfig idle\n\n PROJECT STATE DOMAIN STATE DOMAIN UPSTREAM PROJECT CADDYFILE \n active active active.localhost :3000 /workspace/project-b /workspace/project-b\\Caddyfile \n\nLocal upstream sockets\nUnavailable: socket inspection unavailable\n" + }, + { + "name": "diagnostics_and_logs_cover_empty_and_detailed_reports", + "output": "Caddy idle\nConfig idle\n\nRuntime diagnostics\nNone.\n\nConfiguration diagnostics\nNone.\nCaddy running\nConfig idle\nPID 42\nVersion v2.11.3\nBinary /usr/bin/caddy\nAdmin localhost:2019\n\nRuntime diagnostics\n CODE OPERATION MESSAGE \n runtime-warning reload runtime message \n\nConfiguration diagnostics\n CODE DOMAIN CADDYFILES MESSAGE \n config-warning active.localhost /workspace/project-b/Caddyfile config message \nStatus empty\nChannel caddy\nDomain active.localhost\n\nNo log entries.\nStatus active\nChannel caddy\nDomain active.localhost\n\n SEQ TIME (UTC) LEVEL MESSAGE \n 7 2026-01-01T00:00:00+00:00 warn redacted log message \n" + } + ] +} diff --git a/test/fixtures/installation-runtime-smoke.ts b/test/fixtures/installation-runtime-smoke.ts new file mode 100644 index 0000000..a39adec --- /dev/null +++ b/test/fixtures/installation-runtime-smoke.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { startLocalRuntime, type LocalRuntime } from '../../src/daemon/local-runtime.ts'; +import { rpc } from '../../src/client/connection.ts'; +import { resolvePaths } from '../../src/daemon/paths.ts'; +import { fakeStateResult } from './rpc-data.ts'; + +// Test-only distribution-root inputs; no product packaging or storage worker is implied. +export async function verifyInstallationRuntimes(): Promise { + // Keep the synthetic macOS home short and exclusively owned; never write to + // the real user's home. /tmp's OS alias does not prove runtime ancestor safety. + const root = await mkdtemp( + process.platform === 'darwin' ? '/tmp/c-' : join(tmpdir(), 'cadder-two-'), + ); + const keys = [ + 'CADDER_RUNTIME_DIR', + 'CADDER_RUNTIME_PROFILE', + 'LOCALAPPDATA', + 'XDG_RUNTIME_DIR', + 'HOME', + ] as const; + const environment = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + const runtimes: LocalRuntime[] = []; + try { + delete process.env.CADDER_RUNTIME_DIR; + process.env.CADDER_RUNTIME_PROFILE = 'default'; + process.env.LOCALAPPDATA = root; + process.env.XDG_RUNTIME_DIR = root; + if (process.platform === 'darwin') process.env.HOME = root; + const a = join(root, 'A'); + const b = join(root, 'B'); + await mkdir(a); + await mkdir(b); + const firstOptions = { installationRoot: a, profile: 'default' }; + const secondOptions = { installationRoot: b, profile: 'default' }; + const first = await startLocalRuntime(firstOptions, async () => fakeStateResult(101)); + runtimes.push(first); + const second = await startLocalRuntime(secondOptions, async () => fakeStateResult(202)); + runtimes.push(second); + assert.deepEqual(first.paths, resolvePaths(firstOptions)); + assert.deepEqual(second.paths, resolvePaths(secondOptions)); + for (const key of [ + 'directory', + 'instance', + 'endpoint', + 'secret', + 'lock', + 'metadata', + 'discovery', + 'history', + ] as const) + assert.notEqual(first.paths[key], second.paths[key]); + assert.notDeepEqual(await readFile(first.paths.secret), await readFile(second.paths.secret)); + for (const runtime of runtimes) { + for (const path of [runtime.paths.metadata, runtime.paths.discovery]) { + const diagnostic = JSON.parse(await readFile(path, 'utf8')) as { + runtimeDir: string; + endpoint: string; + }; + assert.equal(diagnostic.runtimeDir, runtime.paths.directory); + assert.equal(diagnostic.endpoint, runtime.paths.endpoint); + } + } + assert.equal((await rpc(first.paths, 'query-state-request')).snapshot?.runtime.processId, 101); + assert.equal((await rpc(second.paths, 'query-state-request')).snapshot?.runtime.processId, 202); + await assert.rejects( + startLocalRuntime(firstOptions, async () => fakeStateResult()), + /already locked/, + ); + await assert.rejects( + startLocalRuntime(secondOptions, async () => fakeStateResult()), + /already locked/, + ); + await first.stop(); + await assert.rejects( + rpc(first.paths, 'query-state-request'), + /Start it with cadder daemon start/, + ); + assert.equal((await rpc(second.paths, 'query-state-request')).snapshot?.runtime.processId, 202); + await assert.rejects( + startLocalRuntime(secondOptions, async () => fakeStateResult()), + /already locked/, + ); + const restarted = await startLocalRuntime(firstOptions, async () => fakeStateResult(303)); + runtimes.push(restarted); + assert.equal( + (await rpc(restarted.paths, 'query-state-request')).snapshot?.runtime.processId, + 303, + ); + assert.equal((await rpc(second.paths, 'query-state-request')).snapshot?.runtime.processId, 202); + } finally { + try { + const stopping = runtimes.map((runtime) => runtime.stop()); + // Wait for every finalizer before cleanup, then propagate any rejection. + await Promise.allSettled(stopping); + await Promise.all(stopping); + } finally { + for (const key of keys) { + if (environment[key] === undefined) delete process.env[key]; + else process.env[key] = environment[key]; + } + await rm(root, { recursive: true, force: true }); + } + } +} diff --git a/test/fixtures/rpc-data.ts b/test/fixtures/rpc-data.ts new file mode 100644 index 0000000..cfa7382 --- /dev/null +++ b/test/fixtures/rpc-data.ts @@ -0,0 +1,60 @@ +import type { EntrypointRegistration, StateSnapshot } from '../../src/protocol/dto.ts'; + +export const timestamp = '2026-01-01T00:00:00.000Z'; +export const stream = { streamId: 'runtime-control', domainKey: null, channel: 'control' }; +export function fakeRegistration(): EntrypointRegistration { + return { + registrationId: 'shim-fixture', + entrypointInstance: { + instanceId: 'shim-fixture', + startedAtUtc: timestamp, + shimSessionNonce: 'nonce', + }, + sourceWorkingDirectory: { raw: '/fixture', canonical: null }, + sourceConfigPath: { raw: '/fixture/Caddyfile', canonical: '/fixture/Caddyfile' }, + registeredDomains: [ + { + name: { raw: 'example.localhost', canonical: 'example.localhost' }, + activationState: 'active', + logStream: stream, + }, + ], + activationState: 'registered', + ownerProcess: { + processId: 123, + processStartTimeUtc: timestamp, + shimSessionNonce: 'nonce', + executablePath: null, + }, + logStream: stream, + shimRun: { adapter: null, rawArguments: ['run'], commandLine: 'caddy run' }, + createdAtUtc: timestamp, + lastHeartbeatUtc: timestamp, + }; +} +export function fakeSnapshot(processId = process.pid): StateSnapshot { + return { + capturedAtUtc: timestamp, + registrations: [fakeRegistration()], + runtime: { + status: 'running', + binaryPath: null, + version: null, + processId, + adminEndpoint: null, + diagnostics: [], + }, + config: { + status: 'idle', + lastAttemptedAtUtc: null, + lastSuccessfulReloadAtUtc: null, + effectiveConfigHash: null, + diagnostics: [], + }, + storage: { backend: 'fixture', path: null, schemaVersion: 1, diagnostics: [] }, + }; +} +export function fakeStateResult(processId = process.pid) { + return { accepted: true, message: 'Fixture state.', snapshot: fakeSnapshot(processId) }; +} +export const basicResult = { accepted: true, message: 'Fixture accepted.' }; diff --git a/test/fixtures/runtime-child.ts b/test/fixtures/runtime-child.ts index 3b2249d..d397dca 100644 --- a/test/fixtures/runtime-child.ts +++ b/test/fixtures/runtime-child.ts @@ -1,19 +1,20 @@ import { startLocalRuntime } from '../../src/daemon/local-runtime.ts'; +import { basicResult, fakeStateResult } from './rpc-data.ts'; const options: { runtimeDir?: string; runtimeOwner?: number } = {}; if (process.argv[2] !== undefined) options.runtimeDir = process.argv[2]; if (process.getuid && process.argv[3] !== undefined) options.runtimeOwner = Number(process.argv[3]); const runtime = await startLocalRuntime(options, async (request) => { - if (request.method === 'status') return { processId: process.pid, recovered: runtime.recovered }; - if (request.method === 'shutdown') { + if (request.method === 'query-state-request') return fakeStateResult(); + if (request.method === 'shutdown-daemon-request') { setTimeout(() => { void shutdown(); }, 30); - return { stopped: true }; + return basicResult; } throw new Error('Unknown fixture request'); }); -process.stdout.write('READY\n'); +process.stdout.write(`READY recovered=${runtime.recovered}\n`); async function shutdown() { await runtime.stop(); process.exitCode = 0; diff --git a/test/fixtures/runtime-denied-client.ts b/test/fixtures/runtime-denied-client.ts index 5e3ab9d..23cc8e9 100644 --- a/test/fixtures/runtime-denied-client.ts +++ b/test/fixtures/runtime-denied-client.ts @@ -6,7 +6,7 @@ import { errorCode } from '../../src/protocol/errors.ts'; const runtimeDir = process.argv[2]; if (!runtimeDir) throw new Error('Pass the owner runtime directory.'); try { - await rpc(resolvePaths({ runtimeDir }), 'status'); + await rpc(resolvePaths({ runtimeDir }), 'query-state-request'); throw new Error('An unauthorized account contacted the runtime.'); } catch (error) { assert.ok( diff --git a/test/fixtures/runtime-privilege-smoke.ts b/test/fixtures/runtime-privilege-smoke.ts index bb15080..4e3892a 100644 --- a/test/fixtures/runtime-privilege-smoke.ts +++ b/test/fixtures/runtime-privilege-smoke.ts @@ -53,8 +53,8 @@ try { }); daemon!.once('error', reject); }); - const status = (await rpc(paths, 'status')) as { processId: number }; - assert.ok(status.processId > 0); + const status = await rpc(paths, 'query-state-request'); + assert.ok((status.snapshot?.runtime.processId ?? 0) > 0); const discovery = JSON.parse(await readFile(paths.discovery, 'utf8')) as { elevated: boolean; owner: string; @@ -75,16 +75,16 @@ try { const [code] = await once(outsider, 'exit'); assert.equal(code, 0, deniedError); assert.equal(deniedOutput.trim(), 'DENIED'); - await rpc(paths, 'status'); + await rpc(paths, 'query-state-request'); const exited = once(daemon, 'exit'); - await rpc(paths, 'shutdown'); + await rpc(paths, 'shutdown-daemon-request'); await exited; daemon = undefined; process.stdout.write(`Native privilege gate passed on ${process.platform}/${process.arch}.\n`); } finally { if (daemon?.exitCode === null) { const exited = once(daemon, 'exit'); - await rpc(paths, 'shutdown').catch(() => {}); + await rpc(paths, 'shutdown-daemon-request').catch(() => {}); // The fixture's fixed 30-second lifetime bounds cleanup if authentication failed. await exited; } diff --git a/test/fixtures/runtime-smoke.ts b/test/fixtures/runtime-smoke.ts index 95f3017..8f7b23c 100644 --- a/test/fixtures/runtime-smoke.ts +++ b/test/fixtures/runtime-smoke.ts @@ -16,7 +16,7 @@ const environment = { ...process.env }; delete environment.NODE_OPTIONS; let child: ChildProcessWithoutNullStreams | undefined; -async function start(): Promise { +async function start(recovered = false): Promise { const processHandle = spawn( process.execPath, [entry!, root, ...(process.getuid ? [String(process.getuid())] : [])], @@ -31,9 +31,14 @@ async function start(): Promise { processHandle.kill(); reject(new Error(`Startup timeout: ${stderr}`)); }, 12000); - processHandle.stdout.once('data', () => { + let stdout = ''; + processHandle.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + if (!stdout.includes('\n')) return; clearTimeout(timer); - resolve(); + if (stdout.trim() !== `READY recovered=${recovered}`) + reject(new Error(`Unexpected fixture readiness: ${stdout}`)); + else resolve(); }); processHandle.once('exit', () => { clearTimeout(timer); @@ -46,18 +51,21 @@ async function start(): Promise { try { child = await start(); - assert.deepEqual(await rpc(paths, 'status'), { processId: child.pid, recovered: false }); - await assert.rejects(start, /already locked/); + assert.equal((await rpc(paths, 'query-state-request')).snapshot?.runtime.processId, child.pid); + await assert.rejects(() => start(), /already locked/); let exited = once(child, 'exit'); child.kill('SIGKILL'); await exited; - child = await start(); - assert.deepEqual(await rpc(paths, 'status'), { processId: child.pid, recovered: true }); + child = await start(true); + assert.equal((await rpc(paths, 'query-state-request')).snapshot?.runtime.processId, child.pid); exited = once(child, 'exit'); - await rpc(paths, 'shutdown'); + assert.equal((await rpc(paths, 'shutdown-daemon-request')).accepted, true); await exited; child = undefined; - await assert.rejects(() => rpc(paths, 'status'), /Start it with cadder daemon start/); + await assert.rejects( + () => rpc(paths, 'query-state-request'), + /Start it with cadder daemon start/, + ); process.stdout.write(`Native runtime gate passed on ${process.platform}/${process.arch}.\n`); } finally { if (child?.exitCode === null) { diff --git a/test/fixtures/runtime-windows-contact.ts b/test/fixtures/runtime-windows-contact.ts index 63633b1..1394dd6 100644 --- a/test/fixtures/runtime-windows-contact.ts +++ b/test/fixtures/runtime-windows-contact.ts @@ -24,11 +24,11 @@ if (action === 'prepare') { }; assert.equal(discovery.elevated, true, 'The fixture daemon must be elevated.'); assert.equal(discovery.owner, owner.id, 'The daemon must have the same owner SID.'); - const result = (await rpc(paths, 'status')) as { processId: number }; - assert.ok(result.processId > 0); + const result = await rpc(paths, 'query-state-request'); + assert.ok((result.snapshot?.runtime.processId ?? 0) > 0); process.stdout.write('SAME_OWNER_ELEVATED_CONTACT_PASSED\n'); } else if (action === 'shutdown') { - assert.deepEqual(await rpc(paths, 'shutdown'), { stopped: true }); + assert.equal((await rpc(paths, 'shutdown-daemon-request')).accepted, true); process.stdout.write('STOPPED\n'); } else { throw new Error('Expected prepare, elevated-status or shutdown.'); diff --git a/test/installation-identity.test.ts b/test/installation-identity.test.ts new file mode 100644 index 0000000..c58e8f3 --- /dev/null +++ b/test/installation-identity.test.ts @@ -0,0 +1,274 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { dirname, join, posix, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, expect, it, vi } from 'vitest'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { resolveInstallationRoot } from '../src/daemon/installation-root.ts'; + +vi.mock('node:os', async (original) => { + const os = await original(); + return { ...os, homedir: vi.fn(os.homedir) }; +}); + +const roots: string[] = []; +afterEach(async () => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + vi.mocked(homedir).mockReset(); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'cadder-identity-')); + roots.push(root); + vi.stubEnv('CADDER_RUNTIME_DIR', undefined); + vi.stubEnv('CADDER_RUNTIME_PROFILE', 'default'); + return root; +} + +it('separates all default runtime artifacts by canonical installation, not version', async () => { + const root = await fixture(); + const a = join(root, 'A'); + const b = join(root, 'B'); + await mkdir(a); + await mkdir(b); + const first = resolvePaths({ installationRoot: a }); + const second = resolvePaths({ installationRoot: b }); + expect(resolvePaths({ installationRoot: a })).toEqual(first); + for (const key of [ + 'directory', + 'instance', + 'endpoint', + 'secret', + 'lock', + 'metadata', + 'discovery', + 'history', + ] as const) + expect(first[key]).not.toBe(second[key]); + expect(first.directory).toMatch(/v2[/\\][a-f0-9]{16}$/); + expect(first.history).toBe(join(first.directory, 'runtime.sqlite3')); + expect(second.history).toBe(join(second.directory, 'runtime.sqlite3')); +}); + +it('keeps the actual platform base and all paths coherent with the full installation digest', async () => { + const root = await fixture(); + const digest = createHash('sha256') + .update(await realpath(root)) + .digest('hex') + .slice(0, 16); + let base: string; + if (process.platform === 'win32') + base = join( + process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), + 'Cadder', + 'Cadder', + 'run', + ); + else if (process.platform === 'darwin') + base = join(homedir(), 'Library', 'Application Support', 'dev.Cadder.Cadder', 'run'); + else if (process.env.XDG_RUNTIME_DIR) base = join(process.env.XDG_RUNTIME_DIR, 'cadder'); + else + base = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'cadder', 'run'); + const paths = resolvePaths({ installationRoot: root }); + expect(paths.directory).toBe(join(resolve(base), 'v2', digest)); + expect(paths.instance).toBe( + createHash('sha256').update(paths.directory).digest('hex').slice(0, 16), + ); + expect(paths.endpoint).toBe( + process.platform === 'win32' + ? `\\\\.\\pipe\\cadder-v2-${paths.instance}` + : join(paths.directory, 'cadder.sock'), + ); + for (const [key, filename] of [ + ['secret', 'ipc-secret'], + ['lock', 'lock.sqlite3'], + ['metadata', 'cadder.lock.json'], + ['discovery', 'cadder-ipc.json'], + ['history', 'runtime.sqlite3'], + ] as const) + expect(paths[key]).toBe(join(paths.directory, filename)); +}); + +it('fits a modeled normal macOS default without weakening the portable byte guard', async () => { + const root = await fixture(); + vi.mocked(homedir).mockReturnValue('/Users/normaluser'); + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + const digest = createHash('sha256') + .update(await realpath(root)) + .digest('hex') + .slice(0, 16); + const paths = resolvePaths({ installationRoot: root }); + const components = [ + 'Library', + 'Application Support', + 'dev.Cadder.Cadder', + 'run', + 'v2', + digest, + 'cadder.sock', + ]; + expect(paths.endpoint).toBe(join(resolve('/Users/normaluser'), ...components)); + expect(Buffer.byteLength(paths.endpoint)).toBeLessThanOrEqual(103); + // POSIX modeling is explicit: Windows native path resolution may add a drive. + expect(Buffer.byteLength(posix.join('/Users/normaluser', ...components))).toBe(99); + vi.mocked(homedir).mockReturnValue('/Users/' + 'x'.repeat(40)); + expect(() => resolvePaths({ installationRoot: root })).toThrow('103 UTF-8 bytes'); + vi.mocked(homedir).mockReturnValue('/Users/' + 'é'.repeat(20)); + expect(() => resolvePaths({ installationRoot: root })).toThrow('103 UTF-8 bytes'); +}); + +it('canonicalizes physical installation aliases including native junctions or symlinks', async () => { + const root = await fixture(); + const installation = join(root, 'package'); + await mkdir(join(installation, 'sub'), { recursive: true }); + const alias = join(root, 'alias'); + await symlink(installation, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const paths = resolvePaths({ installationRoot: installation }); + for (const spelling of [alias, join(installation, 'sub', '..'), `${installation}/`]) + expect(resolvePaths({ installationRoot: spelling })).toEqual(paths); + if (process.platform === 'win32') + expect(resolvePaths({ installationRoot: installation.toUpperCase() })).toEqual(paths); +}); + +it('anchors npm to the nearest physical cadder package, never shared Node, argv or cwd', async () => { + const root = await fixture(); + await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'cadder' })); + const packageRoot = join(root, 'nested'); + const module = join(packageRoot, 'dist', 'daemon', 'paths.js'); + await mkdir(dirname(module), { recursive: true }); + await writeFile(join(packageRoot, 'package.json'), JSON.stringify({ name: 'cadder' })); + await writeFile(join(packageRoot, 'dist', 'package.json'), JSON.stringify({ name: 'other' })); + await writeFile(module, ''); + const alias = join(root, 'alias'); + await symlink(packageRoot, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const input = { sea: false, executable: process.execPath }; + expect(resolveInstallationRoot({ ...input, moduleUrl: pathToFileURL(module).href })).toBe( + await realpath(packageRoot), + ); + expect( + resolveInstallationRoot({ + ...input, + moduleUrl: pathToFileURL(join(alias, 'dist', 'daemon', 'paths.js')).href, + }), + ).toBe(await realpath(packageRoot)); + expect(await realpath(packageRoot)).not.toBe(dirname(process.execPath)); + expect(resolveInstallationRoot()).toBe(await realpath(resolve('.'))); +}); + +it('makes all three SEA siblings agree and follows a real executable alias', async () => { + const root = await fixture(); + const installation = join(root, 'sea'); + await mkdir(installation); + const paths = resolvePaths({ installationRoot: installation }); + for (const name of ['cadder', 'cadderd', 'caddy']) { + const executable = join(installation, name); + await writeFile(executable, ''); + const installationRoot = resolveInstallationRoot({ sea: true, executable }); + expect(installationRoot).toBe(await realpath(installation)); + expect(resolvePaths({ installationRoot })).toEqual(paths); + } + const alias = join(root, 'alias'); + await symlink(installation, alias, process.platform === 'win32' ? 'junction' : 'dir'); + expect(resolveInstallationRoot({ sea: true, executable: join(alias, 'caddy') })).toBe( + await realpath(installation), + ); +}); + +it('fails closed on missing roots, non-directories, missing executables and npm anchors', async () => { + const root = await fixture(); + const module = join(root, 'module.js'); + await writeFile(module, ''); + for (const input of [ + { installationRoot: join(root, 'missing') }, + { installationRoot: module }, + { sea: true, executable: join(root, 'missing') }, + { sea: false, moduleUrl: pathToFileURL(module).href }, + { sea: false, moduleUrl: pathToFileURL(join(root, 'missing')).href }, + { sea: false, moduleUrl: 'https://invalid.example/module.js' }, + ]) + expect(() => resolveInstallationRoot(input)).toThrow('Cannot resolve'); + await writeFile(join(root, 'package.json'), '{'); + expect(() => + resolveInstallationRoot({ sea: false, moduleUrl: pathToFileURL(module).href }), + ).toThrow('Cannot resolve'); + expect(() => resolvePaths({ installationRoot: join(root, 'missing') })).toThrow('Cannot resolve'); +}); + +it('preserves option/env overrides and explicit profile behavior without requiring an anchor', async () => { + const root = await fixture(); + vi.stubEnv('CADDER_RUNTIME_DIR', join(root, 'env')); + vi.stubEnv('CADDER_RUNTIME_PROFILE', 'dev'); + const missing = join(root, 'missing'); + const env = resolvePaths({ installationRoot: missing }); + expect(env.directory).toBe(join(root, 'env', 'v2')); + expect(env.profile).toBe('default'); + const explicit = resolvePaths({ + runtimeDir: join(root, 'option'), + installationRoot: missing, + profile: 'dev', + }); + expect(explicit.directory).toBe(join(root, 'option', 'v2')); + expect(explicit.profile).toBe('dev'); +}); + +it('preserves existing empty override cwd resolution without installation anchoring', async () => { + const root = await fixture(); + const missing = join(root, 'missing'); + expect(resolvePaths({ runtimeDir: '', installationRoot: missing }).directory).toBe( + join(resolve(''), 'v2'), + ); + vi.stubEnv('CADDER_RUNTIME_DIR', ''); + expect(resolvePaths({ installationRoot: missing }).directory).toBe(join(resolve(''), 'v2')); +}); + +it('keeps dev namespaces beneath each installation and validates dev ids', async () => { + const root = await fixture(); + const a = join(root, 'A'); + const b = join(root, 'B'); + await mkdir(a); + await mkdir(b); + vi.stubEnv('CADDER_DEV_ID', 'test'); + const first = resolvePaths({ installationRoot: a, profile: 'dev' }); + const second = resolvePaths({ installationRoot: b, profile: 'dev' }); + expect(first.directory).toMatch(/v2[/\\][a-f0-9]{16}[/\\]profiles[/\\]dev[/\\]test$/); + expect(first.endpoint).not.toBe(second.endpoint); + vi.stubEnv('CADDER_DEV_ID', '..'); + expect(() => resolvePaths({ installationRoot: a, profile: 'dev' })).toThrow( + 'Invalid CADDER_DEV_ID', + ); +}); + +it.each(['linux', 'darwin'] as const)( + 'bounds %s sockets at 103 UTF-8 bytes, not characters', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform); + const prefix = resolve('/r'); + const suffixBytes = + Buffer.byteLength(join(prefix, 'v2', 'cadder.sock')) - Buffer.byteLength(prefix); + const runtimeDir = prefix + 'x'.repeat(103 - suffixBytes - Buffer.byteLength(prefix)); + expect(Buffer.byteLength(resolvePaths({ runtimeDir }).endpoint)).toBe(103); + expect(() => resolvePaths({ runtimeDir: runtimeDir + 'x' })).toThrow('103 UTF-8 bytes'); + const multibyte = runtimeDir.slice(0, -1) + 'é'; + expect(() => resolvePaths({ runtimeDir: multibyte })).toThrow('103 UTF-8 bytes'); + }, +); + +it('checks representative Unix default bases and fails closed on long configured bases', async () => { + const root = await fixture(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + vi.stubEnv('XDG_RUNTIME_DIR', '/run/user/1000'); + expect(Buffer.byteLength(resolvePaths({ installationRoot: root }).endpoint)).toBeLessThanOrEqual( + 103, + ); + vi.stubEnv('XDG_RUNTIME_DIR', ''); + vi.stubEnv('XDG_DATA_HOME', '/home/user/.local/share'); + expect(Buffer.byteLength(resolvePaths({ installationRoot: root }).endpoint)).toBeLessThanOrEqual( + 103, + ); + vi.stubEnv('XDG_DATA_HOME', '/home/' + 'x'.repeat(80)); + expect(() => resolvePaths({ installationRoot: root })).toThrow( + 'Select a shorter CADDER_RUNTIME_DIR', + ); +}); diff --git a/test/installation-runtime.test.ts b/test/installation-runtime.test.ts new file mode 100644 index 0000000..9c6f2e8 --- /dev/null +++ b/test/installation-runtime.test.ts @@ -0,0 +1,17 @@ +import { expectTypeOf, it } from 'vitest'; +import type { startLocalRuntime } from '../src/daemon/local-runtime.ts'; +import { verifyInstallationRuntimes } from './fixtures/installation-runtime-smoke.ts'; + +it( + 'holds two real SQLite lifetime locks and authenticated local listeners independently', + { + skip: process.getuid?.() === 0, + timeout: 30000, + }, + async () => { + expectTypeOf[0]['installationRoot']>().toEqualTypeOf< + string | undefined + >(); + await verifyInstallationRuntimes(); + }, +); diff --git a/test/ipc-publication-faults.test.ts b/test/ipc-publication-faults.test.ts new file mode 100644 index 0000000..ac42630 --- /dev/null +++ b/test/ipc-publication-faults.test.ts @@ -0,0 +1,187 @@ +import { EventEmitter } from 'node:events'; +import * as fs from 'node:fs/promises'; +import { createServer, type Server, type Socket } from 'node:net'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { serveIpc } from '../src/daemon/ipc-server.ts'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import * as security from '../src/platform/runtime-security.ts'; +import { JsonChannel } from '../src/protocol/channel.ts'; +import { authenticateServer } from '../src/protocol/authentication.ts'; + +vi.mock('node:fs/promises', () => ({ + chmod: vi.fn(), + lstat: vi.fn(), + unlink: vi.fn(), + writeFile: vi.fn(), +})); +vi.mock('node:net', () => ({ createServer: vi.fn() })); +vi.mock('../src/protocol/channel.ts', () => ({ JsonChannel: vi.fn() })); +vi.mock('../src/protocol/authentication.ts', () => ({ authenticateServer: vi.fn() })); +vi.mock('../src/platform/runtime-security.ts', () => ({ + createProtectedFile: vi.fn(), + protectCreated: vi.fn(), +})); +const paths = resolvePaths({ runtimeDir: '/publication-faults' }); +const owner = { id: 'uid:42', uid: 42, elevated: false }; +const failure = Object.assign(new Error('publication disk failure'), { code: 'ENOSPC' }); +let server: EventEmitter & { listen: ReturnType; close: ReturnType }; + +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + server = Object.assign(new EventEmitter(), { + listen: vi.fn(() => { + queueMicrotask(() => server.emit('listening')); + }), + close: vi.fn((callback: (error?: Error) => void) => callback()), + }); + vi.mocked(createServer).mockReturnValue(server as unknown as Server); + vi.mocked(fs.lstat).mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + vi.mocked(security.createProtectedFile).mockResolvedValue(true); + vi.mocked(JsonChannel).mockImplementation(function () { + return { close: vi.fn() } as never; + }); +}); +afterEach(() => vi.restoreAllMocks()); + +it.each(['sync-bind', 'async-bind', 'chmod', 'protection', 'discovery-create', 'discovery-write'])( + 'closes startup resources after %s', + async (phase) => { + if (phase === 'sync-bind') + server.listen.mockImplementationOnce(() => { + throw failure; + }); + if (phase === 'async-bind') + server.listen.mockImplementationOnce(() => { + queueMicrotask(() => server.emit('error', failure)); + }); + if (phase === 'chmod') vi.mocked(fs.chmod).mockRejectedValueOnce(failure); + if (phase === 'protection') vi.mocked(security.protectCreated).mockRejectedValueOnce(failure); + if (phase === 'discovery-create') + vi.mocked(security.createProtectedFile).mockRejectedValueOnce(failure); + if (phase === 'discovery-write') vi.mocked(fs.writeFile).mockRejectedValueOnce(failure); + const handler = vi.fn(); + await expect(serveIpc(paths, owner, Buffer.alloc(32), handler)).rejects.toBe(failure); + expect(server.close).toHaveBeenCalledOnce(); + if (phase === 'discovery-write') expect(fs.unlink).toHaveBeenCalledWith(paths.discovery); + else expect(fs.unlink).not.toHaveBeenCalledWith(paths.discovery); + expect(handler).not.toHaveBeenCalled(); + }, +); + +it('preserves unvalidated discovery and foreign endpoint paths', async () => { + vi.mocked(fs.lstat).mockResolvedValueOnce({ isSocket: () => false, uid: 42 } as never); + await expect(serveIpc(paths, owner, Buffer.alloc(32), vi.fn())).rejects.toMatchObject({ + code: 'unsafe-endpoint', + }); + expect(createServer).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); + vi.mocked(security.createProtectedFile).mockRejectedValueOnce(failure); + await expect(serveIpc(paths, owner, Buffer.alloc(32), vi.fn())).rejects.toBe(failure); + expect(fs.unlink).not.toHaveBeenCalledWith(paths.discovery); +}); + +it.each(['callback', 'throw'])( + 'unlinks validated discovery even if listener close fails: %s', + async (phase) => { + const listener = await serveIpc(paths, owner, Buffer.alloc(32), vi.fn()); + if (phase === 'callback') + server.close.mockImplementationOnce((callback: (error: Error) => void) => callback(failure)); + else + server.close.mockImplementationOnce(() => { + throw failure; + }); + const closing = listener.close(); + expect(listener.close()).toBe(closing); + await expect(closing).rejects.toBe(failure); + await expect(listener.close()).rejects.toBe(failure); + expect(fs.unlink).toHaveBeenCalledWith(paths.discovery); + }, +); + +it('retains publication failure and every cleanup failure', async () => { + vi.mocked(fs.writeFile).mockRejectedValueOnce(failure); + server.close.mockImplementationOnce((callback: (error: Error) => void) => + callback(new Error('close')), + ); + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('unlink')); + const error = await serveIpc(paths, owner, Buffer.alloc(32), vi.fn()).catch( + (error: unknown) => error, + ); + expect((error as AggregateError).errors).toHaveLength(2); + expect(error).toMatchObject({ cause: failure }); + expect(fs.unlink).toHaveBeenCalledWith(paths.discovery); +}); + +it('revokes queued requests and rejects new admission after startup failure', async () => { + let deliver: (request: unknown) => void = () => {}; + const session = { + receive: vi.fn( + () => + new Promise((resolve) => { + deliver = resolve; + }), + ), + close: vi.fn(), + }; + vi.mocked(authenticateServer).mockResolvedValue(session as never); + const socket = Object.assign(new EventEmitter(), { destroy: vi.fn() }); + vi.mocked(fs.writeFile).mockImplementationOnce(async () => { + const accept = vi.mocked(createServer).mock.calls[0]![0] as (socket: Socket) => void; + accept(socket as unknown as Socket); + await Promise.resolve(); + throw failure; + }); + const handler = vi.fn(); + await expect(serveIpc(paths, owner, Buffer.alloc(32), handler)).rejects.toBe(failure); + expect(session.receive).toHaveBeenCalledOnce(); + deliver({ + protocolVersion: 3, + requestId: '00000000-0000-4000-8000-000000000001', + method: 'query-state-request', + params: {}, + }); + await Promise.resolve(); + expect(handler).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + expect(socket.destroy).toHaveBeenCalledOnce(); + const lateSocket = Object.assign(new EventEmitter(), { destroy: vi.fn() }); + const accept = vi.mocked(createServer).mock.calls[0]![0] as (socket: Socket) => void; + accept(lateSocket as unknown as Socket); + expect(lateSocket.destroy).toHaveBeenCalledOnce(); + expect(authenticateServer).toHaveBeenCalledOnce(); +}); + +it('attempts every socket, listener and publication finalizer when a socket destroy throws', async () => { + vi.mocked(authenticateServer).mockReturnValue(new Promise(() => {})); + const listener = await serveIpc(paths, owner, Buffer.alloc(32), vi.fn()); + const accept = vi.mocked(createServer).mock.calls[0]![0] as (socket: Socket) => void; + const first = Object.assign(new EventEmitter(), { + destroy: vi.fn(() => { + throw failure; + }), + }); + const second = Object.assign(new EventEmitter(), { destroy: vi.fn() }); + accept(first as unknown as Socket); + accept(second as unknown as Socket); + await expect(listener.close()).rejects.toBe(failure); + expect(second.destroy).toHaveBeenCalledOnce(); + expect(server.close).toHaveBeenCalledOnce(); + expect(fs.unlink).toHaveBeenCalledWith(paths.discovery); +}); + +it('cleans validated safe discovery residue after a partial replacement fails', async () => { + vi.mocked(security.createProtectedFile).mockResolvedValueOnce(false); + vi.mocked(fs.writeFile).mockRejectedValueOnce(failure); + await expect(serveIpc(paths, owner, Buffer.alloc(32), vi.fn())).rejects.toBe(failure); + expect(fs.unlink).toHaveBeenCalledWith(paths.discovery); +}); + +it('reports discovery unlink failure consistently without repeating finalizers', async () => { + const listener = await serveIpc(paths, owner, Buffer.alloc(32), vi.fn()); + vi.mocked(fs.unlink).mockRejectedValueOnce(failure); + await expect(listener.close()).rejects.toBe(failure); + await expect(listener.close()).rejects.toBe(failure); + expect(server.close).toHaveBeenCalledOnce(); + expect(fs.unlink).toHaveBeenCalledOnce(); +}); diff --git a/test/ipc-server.test.ts b/test/ipc-server.test.ts index b27a1b4..d78183b 100644 --- a/test/ipc-server.test.ts +++ b/test/ipc-server.test.ts @@ -1,5 +1,6 @@ -import { randomBytes } from 'node:crypto'; -import { createConnection } from 'node:net'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { createConnection, createServer } from 'node:net'; +import { once } from 'node:events'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -9,13 +10,34 @@ import { resolvePaths } from '../src/daemon/paths.ts'; import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; import { serveIpc } from '../src/daemon/ipc-server.ts'; import { prepareRuntime, runtimeOwner } from '../src/platform/runtime-security.ts'; -import { CadderError } from '../src/protocol/errors.ts'; +import { RpcError, toWireError } from '../src/protocol/errors.ts'; import { JsonChannel } from '../src/protocol/channel.ts'; +import { authenticateClient, authenticateServer } from '../src/protocol/authentication.ts'; +import { + errorResponseSchema, + requestSchema, + responseSchema, + UNCORRELATED_REQUEST_ID, + type HandlerResult, + type RpcRequest, +} from '../src/protocol/rpc.ts'; +import { basicResult, fakeStateResult } from './fixtures/rpc-data.ts'; +import { MAX_FRAME_BYTES } from '../src/protocol/version.ts'; + +const id = randomUUID(); +const request = { protocolVersion: 3, requestId: id, method: 'query-state-request', params: {} }; +const typedError = { + kind: 'busy' as const, + code: 'busy', + message: 'A safe fixture error', + guidance: null, + retryable: true, + requestId: null, +}; -// This scenario performs several real PowerShell ACL checks on Windows runners. it( - 'serves only authenticated RPC and reports typed handler failures', - { timeout: 45000 }, + 'serves one authenticated correlated outcome, rejects malformed requests before handlers', + { timeout: 90000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'cadder-ipc-')); const owner = await runtimeOwner(); @@ -30,33 +52,103 @@ it( lock.secret, async (request) => { called++; - if (request.method === 'status') return { online: true }; - if (request.method === 'typed') - throw new CadderError('fixture-error', 'A safe fixture error'); + if (request.method === 'query-state-request') + return { ...fakeStateResult(), requestId: randomUUID() }; + if (request.method === 'shutdown-daemon-request') throw new RpcError(typedError); + if (request.method === 'heartbeat-entrypoint-request') + return { accepted: false, message: 'Wrong nonce.' }; + if (request.method === 'unregister-entrypoint-request') + return { arbitrary: true } as unknown as HandlerResult; throw new Error('Do not expose internal error text'); }, () => { denied++; }, ); + async function raw(input: unknown) { + const channel = new JsonChannel(createConnection(paths.endpoint)); + const session = await authenticateClient(channel, lock.secret, paths.instance); + try { + session.send(input); + session.send(request); // Pipelined input must not produce a second response or handler call. + const response = await session.receive(); + await expect(session.receive()).rejects.toThrow('closed'); + return response; + } finally { + session.close(); + channel.close(); + } + } try { - expect(await rpc(paths, 'status')).toEqual({ online: true }); - await expect(rpc(paths, 'typed')).rejects.toMatchObject({ - code: 'fixture-error', + const state = await rpc(paths, 'query-state-request'); + expect(state).toMatchObject(fakeStateResult()); + expect(state.requestId).toMatch(/^[a-f0-9-]{36}$/); + await expect(rpc(paths, 'shutdown-daemon-request')).rejects.toMatchObject({ + code: 'busy', message: 'A safe fixture error', + protocolError: { retryable: true }, }); - await expect(rpc(paths, 'generic')).rejects.toThrow('See diagnostics'); - const calledBefore = called; - const socket = createConnection(paths.endpoint); - const channel = new JsonChannel(socket); - channel.send({ method: 'shutdown' }); + expect( + await rpc(paths, 'heartbeat-entrypoint-request', { + registrationId: 'missing', + shimSessionNonce: 'wrong', + }), + ).toMatchObject({ accepted: false }); + for (const method of [ + 'unregister-entrypoint-request', + 'set-domain-enabled-request', + ] as const) { + const params = + method === 'unregister-entrypoint-request' + ? { registrationId: 'id', shimSessionNonce: 'nonce' } + : { registrationId: 'id', domainKey: 'domain', enabled: true }; + await expect(rpc(paths, method, params)).rejects.toMatchObject({ + code: 'internal', + message: 'Daemon request failed. See diagnostics.', + }); + } + const before = called; + for (const input of [ + { ...request, method: 'status' }, + { ...request, method: 'shutdown' }, + { ...request, params: { extra: true } }, + { ...request, extra: true }, + { ...request, protocolVersion: 2 }, + { ...request, method: 'heartbeat-entrypoint-request', params: { registrationId: 'id' } }, + { + ...request, + method: 'query-logs-request', + params: { stream: { streamId: 's', domainKey: null, channel: 'c', extra: 1 } }, + }, + { ...request, requestId: 'malformed' }, + null, + ]) { + const response = errorResponseSchema.parse(await raw(input)); + expect(response.requestId).toBe( + input && input.requestId === id ? id : UNCORRELATED_REQUEST_ID, + ); + expect(response.error.kind).toBe( + input && ['status', 'shutdown'].includes(input.method) + ? 'unsupportedOperation' + : 'payloadDecodeFailed', + ); + expect(called).toBe(before); + } + // Runtime callers cannot bypass request validation even with untyped inputs. + // @ts-expect-error Unknown fields are also rejected statically. + await expect(rpc(paths, 'query-state-request', { extra: true })).rejects.toMatchObject({ + code: 'invalid-request', + }); + expect(called).toBe(before); + const channel = new JsonChannel(createConnection(paths.endpoint)); + channel.send(request); await expect(channel.next()).rejects.toThrow('closed'); channel.close(); - expect(called).toBe(calledBefore); + expect(called).toBe(before); expect(denied).toBe(1); await writeFile(paths.secret, randomBytes(32)); - await expect(rpc(paths, 'status')).rejects.toThrow('authentication failed'); - expect(called).toBe(calledBefore); + await expect(rpc(paths, 'query-state-request')).rejects.toThrow('authentication failed'); + expect(called).toBe(before); } finally { await server.close(); await lock.release(); @@ -64,3 +156,205 @@ it( } }, ); + +it.each(['result', 'error', 'escaped-result', 'escaped-error'] as const)( + 'replaces an oversized valid %s with one bounded correlated frame error', + { timeout: 45000 }, + async (scenario) => { + const root = await mkdtemp(join(tmpdir(), 'cadder-oversized-')); + const owner = await runtimeOwner(); + const paths = resolvePaths({ runtimeDir: root }); + await prepareRuntime(paths.directory, owner); + const lock = await acquireRuntimeLock(paths, owner); + const escaped = scenario.startsWith('escaped-'); + const largeMessage = escaped + ? '"'.repeat(Math.floor(MAX_FRAME_BYTES / 3)) + : 'x'.repeat(MAX_FRAME_BYTES + 1); + const method = + scenario === 'escaped-result' ? 'shutdown-daemon-request' : 'query-state-request'; + const oversizedResult = + scenario === 'escaped-result' + ? { ...basicResult, message: largeMessage } + : { ...fakeStateResult(), message: largeMessage }; + const oversizedError = new RpcError({ ...typedError, message: largeMessage }); + const isError = scenario.endsWith('error'); + const originalResponse = { + protocolVersion: 3, + requestId: id, + ...(isError + ? { error: { ...oversizedError.protocolError, requestId: id } } + : { result: { ...oversizedResult, requestId: id } }), + }; + expect(responseSchema(method).safeParse(originalResponse).success).toBe(true); + const payload = JSON.stringify(originalResponse); + if (escaped) expect(Buffer.byteLength(payload)).toBeLessThan(MAX_FRAME_BYTES); + else expect(Buffer.byteLength(payload)).toBeGreaterThan(MAX_FRAME_BYTES); + expect( + Buffer.byteLength(JSON.stringify({ sequence: 0, payload, mac: '0'.repeat(64) }) + '\n'), + ).toBeGreaterThan(MAX_FRAME_BYTES); + let called = 0; + let denied = 0; + let requestId = ''; + const server = await serveIpc( + paths, + owner, + lock.secret, + async (request) => { + called++; + requestId = request.requestId; + if (isError) throw oversizedError; + return oversizedResult; + }, + () => { + denied++; + }, + ); + try { + const failure = await rpc(paths, method).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(RpcError); + expect(failure).toMatchObject({ + code: 'frame', + protocolError: { kind: 'frame', requestId, retryable: false }, + }); + expect(called).toBe(1); + const channel = new JsonChannel(createConnection(paths.endpoint)); + const session = await authenticateClient(channel, lock.secret, paths.instance); + try { + session.send({ ...request, method }); + const response = errorResponseSchema.parse(await session.receive()); + expect(response.requestId).toBe(id); + expect(response.error).toEqual({ + kind: 'frame', + code: 'frame', + message: 'Daemon response exceeds the IPC frame size limit.', + guidance: null, + retryable: false, + requestId: id, + }); + expect(Buffer.byteLength(JSON.stringify(response))).toBeLessThan(MAX_FRAME_BYTES); + expect(JSON.stringify(response)).not.toContain(largeMessage); + expect(JSON.stringify(response)).not.toContain(lock.secret.toString('hex')); + await expect(session.receive()).rejects.toThrow('closed'); + expect(called).toBe(2); + expect(denied).toBe(0); + } finally { + session.close(); + channel.close(); + } + } finally { + await server.close(); + await lock.release(); + await rm(root, { recursive: true, force: true }); + } + }, +); + +it( + 'client rejects authenticated malformed peers, non-exclusive outcomes and correlation failures', + { timeout: 90000 }, + async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-peer-')); + const owner = await runtimeOwner(); + const paths = resolvePaths({ runtimeDir: root }); + await prepareRuntime(paths.directory, owner); + const lock = await acquireRuntimeLock(paths, owner); + const mutations: Array<(request: RpcRequest) => unknown> = [ + (request) => ({ protocolVersion: 3, requestId: request.requestId }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + result: { ...fakeStateResult(), requestId: request.requestId }, + error: { ...typedError, requestId: request.requestId }, + }), + (request) => ({ + protocolVersion: 3, + requestId: randomUUID(), + result: { ...fakeStateResult(), requestId: request.requestId }, + }), + () => { + const requestId = randomUUID(); + return { protocolVersion: 3, requestId, result: { ...fakeStateResult(), requestId } }; + }, + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + result: { ...fakeStateResult(), requestId: randomUUID() }, + }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + error: { ...typedError, requestId: randomUUID() }, + }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + result: { ...basicResult, requestId: request.requestId }, + }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + result: { ...fakeStateResult(), requestId: request.requestId, recovered: true }, + }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + error: { code: 'busy', message: 'Incomplete error' }, + }), + (request) => ({ + protocolVersion: 2, + requestId: request.requestId, + result: { ...fakeStateResult(), requestId: request.requestId }, + }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + result: fakeStateResult(), + }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + error: { ...typedError, requestId: request.requestId, extra: true }, + }), + () => ({ + protocolVersion: 3, + requestId: UNCORRELATED_REQUEST_ID, + error: toWireError(undefined, UNCORRELATED_REQUEST_ID), + }), + () => null, + ]; + let index = 0; + const failures: unknown[] = []; + const server = createServer((socket) => { + const channel = new JsonChannel(socket); + void (async () => { + const session = await authenticateServer(channel, lock.secret, paths.instance); + try { + const incoming: RpcRequest = requestSchema.parse(await session.receive()); + session.send(mutations[index++]!(incoming)); + await new Promise((resolve) => socket.end(resolve)); + } finally { + session.close(); + channel.close(); + } + })().catch((error: unknown) => { + failures.push(error); + channel.close(); + }); + }); + server.listen(paths.endpoint); + await once(server, 'listening'); + try { + for (let attempt = 0; attempt < mutations.length; attempt++) { + await expect(rpc(paths, 'query-state-request')).rejects.toMatchObject({ + code: 'invalid-response', + }); + } + expect(index).toBe(mutations.length); + expect(failures).toEqual([]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await lock.release(); + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/test/local-runtime-faults.test.ts b/test/local-runtime-faults.test.ts new file mode 100644 index 0000000..80ad538 --- /dev/null +++ b/test/local-runtime-faults.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import { startLocalRuntime } from '../src/daemon/local-runtime.ts'; +import * as security from '../src/platform/runtime-security.ts'; +import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; +import { serveIpc } from '../src/daemon/ipc-server.ts'; + +vi.mock('../src/platform/runtime-security.ts', () => ({ + runtimeOwner: vi.fn(), + prepareRuntime: vi.fn(), +})); +vi.mock('../src/daemon/runtime-lock.ts', () => ({ acquireRuntimeLock: vi.fn() })); +vi.mock('../src/daemon/ipc-server.ts', () => ({ serveIpc: vi.fn() })); +const release = vi.fn(); +const close = vi.fn(); +const failure = new Error('startup failure'); +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(security.runtimeOwner).mockResolvedValue({ id: 'test', elevated: false }); + vi.mocked(acquireRuntimeLock).mockResolvedValue({ + recovered: false, + secret: Buffer.alloc(32), + release, + }); + vi.mocked(serveIpc).mockResolvedValue({ close }); +}); + +it.each(['owner', 'directory', 'lock'])( + 'does not bind or publish after %s failure', + async (phase) => { + if (phase === 'owner') vi.mocked(security.runtimeOwner).mockRejectedValueOnce(failure); + if (phase === 'directory') vi.mocked(security.prepareRuntime).mockRejectedValueOnce(failure); + if (phase === 'lock') vi.mocked(acquireRuntimeLock).mockRejectedValueOnce(failure); + await expect(startLocalRuntime({ runtimeDir: '/fault-runtime' }, vi.fn())).rejects.toBe( + failure, + ); + expect(serveIpc).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); + }, +); + +it('retains startup failure when releasing exclusion also fails', async () => { + vi.mocked(serveIpc).mockRejectedValueOnce(failure); + release.mockRejectedValueOnce(new Error('release failure')); + const error = await startLocalRuntime({ runtimeDir: '/fault-runtime' }, vi.fn()).catch( + (error: unknown) => error, + ); + expect(error).toMatchObject({ cause: failure }); + expect((error as AggregateError).errors).toHaveLength(2); + expect(release).toHaveBeenCalledOnce(); +}); + +it('settles concurrent stop consistently while attempting listener and lock finalizers', async () => { + const runtime = await startLocalRuntime({ runtimeDir: '/fault-runtime' }, vi.fn()); + close.mockRejectedValueOnce(failure); + release.mockRejectedValueOnce(new Error('release failure')); + const stopping = runtime.stop(); + expect(runtime.stop()).toBe(stopping); + const error = await stopping.catch((error: unknown) => error); + expect((error as AggregateError).errors).toHaveLength(2); + await expect(runtime.stop()).rejects.toBe(error); + expect(close).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledOnce(); +}); diff --git a/test/local-runtime.test.ts b/test/local-runtime.test.ts index 3614142..a705cd4 100644 --- a/test/local-runtime.test.ts +++ b/test/local-runtime.test.ts @@ -6,6 +6,7 @@ import { startLocalRuntime } from '../src/daemon/local-runtime.ts'; import { rpc } from '../src/client/connection.ts'; import { resolvePaths } from '../src/daemon/paths.ts'; import { prepareRuntime, runtimeOwner } from '../src/platform/runtime-security.ts'; +import { basicResult, fakeStateResult } from './fixtures/rpc-data.ts'; it('owns the full bind/lock lifetime and releases it idempotently', async () => { const root = await mkdtemp(join(tmpdir(), 'cadder-local-')); @@ -14,14 +15,16 @@ it('owns the full bind/lock lifetime and releases it idempotently', async () => ...(process.getuid ? { runtimeOwner: process.getuid() } : {}), }; try { - const runtime = await startLocalRuntime(options, async () => ({ online: true })); + const runtime = await startLocalRuntime(options, async () => fakeStateResult()); try { - expect(await rpc(runtime.paths, 'status')).toEqual({ online: true }); - await expect(startLocalRuntime(options, async () => null)).rejects.toThrow('already locked'); + expect(await rpc(runtime.paths, 'query-state-request')).toMatchObject(fakeStateResult()); + await expect(startLocalRuntime(options, async () => basicResult)).rejects.toThrow( + 'already locked', + ); } finally { await Promise.all([runtime.stop(), runtime.stop()]); } - const restarted = await startLocalRuntime(options, async () => null); + const restarted = await startLocalRuntime(options, async () => basicResult); expect(restarted.recovered).toBe(false); await restarted.stop(); } finally { @@ -40,12 +43,12 @@ it('closes the listener and releases the lock when protected discovery publicati try { await prepareRuntime(paths.directory, owner); await writeFile(paths.discovery, 'unsafe-existing-metadata', { mode: 0o644 }); - await expect(startLocalRuntime(options, async () => null)).rejects.toMatchObject({ + await expect(startLocalRuntime(options, async () => basicResult)).rejects.toMatchObject({ code: 'unsafe-runtime-permissions', }); expect(await readFile(paths.discovery, 'utf8')).toBe('unsafe-existing-metadata'); await unlink(paths.discovery); - const runtime = await startLocalRuntime(options, async () => null); + const runtime = await startLocalRuntime(options, async () => basicResult); await runtime.stop(); } finally { await rm(root, { recursive: true, force: true }); diff --git a/test/ports.test.ts b/test/ports.test.ts new file mode 100644 index 0000000..fdcbb4e --- /dev/null +++ b/test/ports.test.ts @@ -0,0 +1,221 @@ +import { readFile } from 'node:fs/promises'; +import { expect, expectTypeOf, it } from 'vitest'; +import type { ProtocolError } from '../src/protocol/errors.ts'; +import type { + CaddyApplyReceipt, + CaddyConfig, + CaddyPort, + CaddyValidation, + ClientServicePort, + DesiredState, + DomainActivationResult, + PlatformPort, + PortResult, + ProtectedPathKind, + ProjectActivationResult, + QueryLogsResult, + QueryStateResult, + RuntimeOwner, + ShutdownResult, + StoragePort, +} from '../src/contracts/ports.ts'; +import type { RpcParams } from '../src/protocol/rpc.ts'; + +const validConfig: CaddyConfig = { + effectiveConfigHash: 'config-hash', + adaptedConfig: { + format: 'json', + body: JSON.stringify({ + apps: { http: { servers: { app: { routes: [{ match: [{ host: ['app.localhost'] }] }] } } } }, + }), + }, +}; +const validation: CaddyValidation = { effectiveConfigHash: 'config-hash', diagnostics: [] }; +const error: ProtocolError = { + kind: 'configuration', + code: 'invalid_configuration', + message: 'The fixture rejected the candidate.', + guidance: null, + retryable: false, + requestId: null, +}; +const ok = (value: T): PortResult => ({ ok: true, value }); +const fail = (): PortResult => ({ ok: false, error }); + +it('keeps the four ports independently mockable with typed result/error contracts', async () => { + const caddy: CaddyPort = { + validate: async (config) => (config === validConfig ? ok(validation) : fail()), + apply: async (config) => + config === validConfig ? ok({ effectiveConfigHash: config.effectiveConfigHash }) : fail(), + }; + const platform: PlatformPort = { + currentOwner: async () => ok({ id: 'uid:1000', uid: 1000, elevated: false }), + assertProtected: async () => ok(undefined), + }; + const desired: DesiredState = { + projects: [ + { + projectKey: 'project-a', + sourceWorkingDirectory: '/workspace/project-a', + sourceConfigPath: '/workspace/project-a/Caddyfile', + enabled: true, + domains: [{ canonicalDomain: 'app.localhost', upstream: '127.0.0.1:3000', enabled: true }], + }, + ], + }; + const storage: StoragePort = { + loadDesiredState: async () => ok(desired), + persistDesiredState: async (state) => (state === desired ? ok(undefined) : fail()), + }; + const service: ClientServicePort = { + queryState: async () => + ok({ + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: 'ok', + snapshot: null, + }), + queryLogs: async (params) => + ok({ + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: 'ok', + stream: { ...params.stream, domainKey: params.stream.domainKey ?? null }, + streamStatus: 'empty', + entries: [], + }), + setProjectEnabled: async (params) => + ok({ + ...params, + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: 'ok', + }), + setDomainEnabled: async (params) => + ok({ + ...params, + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: 'ok', + }), + shutdown: async () => + ok({ requestId: '00000000-0000-4000-8000-000000000001', accepted: true, message: 'ok' }), + }; + + expect((await caddy.validate(validConfig)).ok).toBe(true); + expect((await platform.currentOwner()).ok).toBe(true); + expect((await storage.loadDesiredState()).ok).toBe(true); + expect((await service.shutdown()).ok).toBe(true); + const rejected = fail(); + expect(rejected.ok).toBe(false); + if (!rejected.ok) expect(rejected.error).toEqual(error); +}); + +it('pins every port signature, result and shared error type', () => { + expectTypeOf().parameters.toEqualTypeOf<[config: CaddyConfig]>(); + expectTypeOf().returns.toEqualTypeOf< + Promise> + >(); + expectTypeOf().parameters.toEqualTypeOf<[config: CaddyConfig]>(); + expectTypeOf().returns.toEqualTypeOf< + Promise> + >(); + + expectTypeOf().toEqualTypeOf< + (explicitUid?: number) => Promise> + >(); + expectTypeOf().returns.toEqualTypeOf< + Promise> + >(); + expectTypeOf().parameters.toEqualTypeOf< + [path: string, owner: RuntimeOwner, kind: ProtectedPathKind] + >(); + expectTypeOf().returns.toEqualTypeOf< + Promise> + >(); + + expectTypeOf().parameters.toEqualTypeOf<[]>(); + expectTypeOf().returns.toEqualTypeOf< + Promise> + >(); + expectTypeOf().parameters.toEqualTypeOf< + [state: DesiredState] + >(); + expectTypeOf().returns.toEqualTypeOf< + Promise> + >(); + + expectTypeOf().parameters.toEqualTypeOf<[]>(); + expectTypeOf().returns.toEqualTypeOf< + Promise> + >(); + expectTypeOf().parameters.toEqualTypeOf< + [params: RpcParams<'query-logs-request'>] + >(); + expectTypeOf().returns.toEqualTypeOf< + Promise> + >(); + expectTypeOf().parameters.toEqualTypeOf< + [params: RpcParams<'set-entrypoint-enabled-request'>] + >(); + expectTypeOf().returns.toEqualTypeOf< + Promise> + >(); + expectTypeOf().parameters.toEqualTypeOf< + [params: RpcParams<'set-domain-enabled-request'>] + >(); + expectTypeOf().returns.toEqualTypeOf< + Promise> + >(); + expectTypeOf().parameters.toEqualTypeOf<[]>(); + expectTypeOf().returns.toEqualTypeOf< + Promise> + >(); + + expectTypeOf>().toEqualTypeOf< + Readonly<{ ok: true; value: never }> | Readonly<{ ok: false; error: ProtocolError }> + >(); +}); + +it('pins exact contract keys and excludes live ownership fields', () => { + expectTypeOf().toEqualTypeOf<'effectiveConfigHash' | 'adaptedConfig'>(); + expectTypeOf().toEqualTypeOf<'format' | 'body'>(); + expectTypeOf().toEqualTypeOf<'id' | 'uid' | 'elevated'>(); + expectTypeOf().toEqualTypeOf<'projects'>(); + expectTypeOf().toEqualTypeOf< + 'projectKey' | 'sourceWorkingDirectory' | 'sourceConfigPath' | 'enabled' | 'domains' + >(); + expectTypeOf().toEqualTypeOf< + 'canonicalDomain' | 'upstream' | 'enabled' + >(); + + const project = { + projectKey: 'project-a', + sourceWorkingDirectory: '/workspace/project-a', + sourceConfigPath: '/workspace/project-a/Caddyfile', + enabled: false, + domains: [{ canonicalDomain: 'app.localhost', upstream: null, enabled: false }], + } satisfies DesiredState['projects'][number]; + + expect(Object.keys(project)).not.toEqual( + expect.arrayContaining([ + 'registrationId', + 'entrypointInstance', + 'shimSessionNonce', + 'ownerProcess', + 'processId', + 'activeRoute', + 'lease', + ]), + ); +}); + +it('keeps contracts as a protocol-only leaf module', async () => { + const source = await readFile(new URL('../src/contracts/ports.ts', import.meta.url), 'utf8'); + const imports = [...source.matchAll(/\bfrom\s+['"]([^'"]+)['"]/g)].map( + ([, specifier]) => specifier, + ); + expect(imports).toHaveLength(2); + expect(imports.every((specifier) => specifier?.startsWith('../protocol/'))).toBe(true); + expect(source.match(/export interface /g)).toHaveLength(4); +}); diff --git a/test/protected-file-faults.test.ts b/test/protected-file-faults.test.ts new file mode 100644 index 0000000..3296bd4 --- /dev/null +++ b/test/protected-file-faults.test.ts @@ -0,0 +1,150 @@ +import { constants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { createProtectedFile, prepareRuntime } from '../src/platform/runtime-security.ts'; +import { powershell } from '../src/platform/powershell.ts'; + +vi.mock('node:fs/promises', () => ({ + open: vi.fn(), + lstat: vi.fn(), + mkdir: vi.fn(), + chown: vi.fn(), + unlink: vi.fn(), +})); +vi.mock('../src/platform/powershell.ts', async (original) => ({ + ...(await original()), + powershell: vi.fn(), +})); +const owner = { id: 'uid:42', uid: 42, elevated: false }; +const failure = Object.assign(new Error('disk failure'), { code: 'ENOSPC' }); +const handle = { writeFile: vi.fn(), sync: vi.fn(), close: vi.fn() }; +const uidDescriptor = Object.getOwnPropertyDescriptor(process, 'getuid'); + +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + Object.defineProperty(process, 'getuid', { configurable: true, value: () => 0 }); + vi.mocked(fs.open).mockResolvedValue(handle as never); + vi.mocked(fs.lstat).mockImplementation(async (path) => { + const target = ['secret', 'metadata', 'foreign', 'new', '/runtime/v2'].includes(String(path)); + return { + isSymbolicLink: () => false, + isFile: () => target && path !== '/runtime/v2', + isDirectory: () => !target || path === '/runtime/v2', + uid: target ? 42 : 0, + mode: target ? 0o600 : 0o777, + } as never; + }); +}); +afterEach(() => { + vi.restoreAllMocks(); + if (uidDescriptor) Object.defineProperty(process, 'getuid', uidDescriptor); + else Reflect.deleteProperty(process, 'getuid'); +}); + +it.each(['writeFile', 'sync', 'close'] as const)( + 'removes a new partial file after %s fails', + async (phase) => { + handle[phase].mockRejectedValueOnce(failure); + await expect(createProtectedFile('secret', owner, 'private')).rejects.toBe(failure); + expect(handle.close).toHaveBeenCalledOnce(); + expect(fs.unlink).toHaveBeenCalledWith('secret'); + }, +); + +it('removes only its new file when protection or final validation fails', async () => { + vi.mocked(fs.chown).mockRejectedValueOnce(failure); + await expect(createProtectedFile('secret', owner)).rejects.toBe(failure); + expect(fs.unlink).toHaveBeenCalledWith('secret'); + vi.mocked(fs.unlink).mockClear(); + for (const phase of Object.values(handle)) phase.mockClear(); + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path, options) => { + if (path === 'metadata') throw failure; + return inspect(path, options); + }); + await expect(createProtectedFile('metadata', owner)).rejects.toBe(failure); + expect(fs.lstat).toHaveBeenCalledWith('metadata'); + expect(handle.writeFile).toHaveBeenCalledWith(''); + expect(handle.sync).toHaveBeenCalledOnce(); + expect(handle.close).toHaveBeenCalledOnce(); + expect(fs.unlink).toHaveBeenCalledWith('metadata'); +}); + +it('retains the primary failure while attempting close and unlink', async () => { + handle.writeFile.mockRejectedValueOnce(failure); + handle.close.mockRejectedValueOnce(new Error('close failed')); + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('unlink failed')); + const error = await createProtectedFile('secret', owner).catch((error: unknown) => error); + expect(error).toMatchObject({ cause: failure }); + expect((error as AggregateError).errors).toHaveLength(3); + expect(fs.unlink).toHaveBeenCalledWith('secret'); +}); + +it('does not remove or repair unsafe existing files or failed exclusive opens', async () => { + vi.mocked(fs.open).mockRejectedValueOnce(Object.assign(new Error('exists'), { code: 'EEXIST' })); + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path, options) => { + if (path === 'foreign') return { isSymbolicLink: () => true } as never; + return inspect(path, options); + }); + await expect(createProtectedFile('foreign', owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + vi.mocked(fs.open).mockRejectedValueOnce(failure); + await expect(createProtectedFile('new', owner)).rejects.toBe(failure); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(fs.chown).not.toHaveBeenCalled(); + expect(fs.open).toHaveBeenCalledWith( + 'new', + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, + 0o600, + ); +}); + +it('removes its new Windows file after ACL protection fails', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.mocked(powershell).mockRejectedValueOnce(failure); + await expect( + createProtectedFile('secret', { id: 'S-1-5-21-test', elevated: false }), + ).rejects.toBe(failure); + expect(handle.close).toHaveBeenCalledOnce(); + expect(fs.unlink).toHaveBeenCalledWith('secret'); +}); + +it('rejects an unsafe existing directory without repairing or deleting it', async () => { + await expect(prepareRuntime('/runtime/v2', owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.chown).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); +}); + +it.each(['mkdir-parent', 'mkdir-leaf', 'protection', 'validation'])( + 'fails directory preparation without repairing existing paths: %s', + async (phase) => { + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + let targetChecks = 0; + vi.mocked(fs.lstat).mockImplementation(async (path, options) => { + if (path !== '/runtime/v2') return inspect(path, options); + targetChecks += 1; + if (targetChecks === 1) throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + throw failure; + }); + if (phase === 'mkdir-parent') vi.mocked(fs.mkdir).mockRejectedValueOnce(failure); + if (phase === 'mkdir-leaf') + vi.mocked(fs.mkdir).mockResolvedValueOnce(undefined).mockRejectedValueOnce(failure); + if (phase === 'protection') vi.mocked(fs.chown).mockRejectedValueOnce(failure); + await expect(prepareRuntime('/runtime/v2', owner)).rejects.toBe(failure); + expect(fs.mkdir).toHaveBeenCalledWith('/runtime', { mode: 0o700, recursive: true }); + expect(fs.mkdir).toHaveBeenCalledTimes(phase === 'mkdir-parent' ? 1 : 2); + if (phase !== 'mkdir-parent') + expect(fs.mkdir).toHaveBeenCalledWith('/runtime/v2', { mode: 0o700 }); + if (phase === 'protection' || phase === 'validation') + expect(fs.chown).toHaveBeenCalledWith('/runtime/v2', 42, 42); + else expect(fs.chown).not.toHaveBeenCalled(); + expect(targetChecks).toBe(phase === 'validation' ? 2 : 1); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); diff --git a/test/rpc.test.ts b/test/rpc.test.ts new file mode 100644 index 0000000..a84096b --- /dev/null +++ b/test/rpc.test.ts @@ -0,0 +1,420 @@ +import { randomUUID } from 'node:crypto'; +import { expect, expectTypeOf, it } from 'vitest'; +import { rpc } from '../src/client/connection.ts'; +import type { RuntimePaths } from '../src/daemon/paths.ts'; +import { + catalog, + dispatchRpc, + errorResponseSchema, + requestSchema, + responseSchema, + UNCORRELATED_REQUEST_ID, + type RpcMethod, + type RpcParams, + type RpcResult, +} from '../src/protocol/rpc.ts'; +import { protocolErrorSchema, RpcError, toWireError } from '../src/protocol/errors.ts'; +import { + fakeRegistration, + fakeSnapshot, + fakeStateResult, + basicResult, + stream, + timestamp, +} from './fixtures/rpc-data.ts'; + +const id = randomUUID(); +const header = { protocolVersion: 3, requestId: id }; +const params: { [M in RpcMethod]: RpcParams } = { + 'register-entrypoint-request': { registration: fakeRegistration() }, + 'unregister-entrypoint-request': { registrationId: 'id', shimSessionNonce: 'nonce' }, + 'heartbeat-entrypoint-request': { registrationId: 'id', shimSessionNonce: 'nonce' }, + 'query-state-request': {}, + 'set-entrypoint-enabled-request': { registrationId: 'id', shimSessionNonce: null, enabled: true }, + 'set-domain-enabled-request': { registrationId: 'id', domainKey: 'domain', enabled: false }, + 'query-logs-request': { stream, limit: null }, + 'shutdown-daemon-request': {}, +}; +const entry = { + sequenceNumber: 1, + timestampUtc: timestamp, + severity: 'info' as const, + stream, + attributionKind: 'runtimeControl' as const, + entryKind: 'normal' as const, + rawMessage: 'Fixture log.', + domainKey: null, + sourceRegistrationId: null, + sourceInstanceId: null, + operation: null, +}; +const results: { [M in RpcMethod]: RpcResult } = { + 'register-entrypoint-request': { ...basicResult, requestId: id, registrationId: 'id' }, + 'unregister-entrypoint-request': { ...basicResult, requestId: id }, + 'heartbeat-entrypoint-request': { ...basicResult, requestId: id }, + 'query-state-request': { ...fakeStateResult(), requestId: id }, + 'set-entrypoint-enabled-request': { ...basicResult, requestId: id }, + 'set-domain-enabled-request': { ...basicResult, requestId: id }, + 'query-logs-request': { + ...basicResult, + requestId: id, + stream, + streamStatus: 'active', + entries: [entry], + }, + 'shutdown-daemon-request': { ...basicResult, requestId: id }, +}; +const methods = Object.keys(catalog) as RpcMethod[]; + +it('exposes exactly the eight released operation strings', () => { + expect(methods).toEqual([ + 'register-entrypoint-request', + 'unregister-entrypoint-request', + 'heartbeat-entrypoint-request', + 'query-state-request', + 'set-entrypoint-enabled-request', + 'set-domain-enabled-request', + 'query-logs-request', + 'shutdown-daemon-request', + ]); +}); + +it.each(methods)('%s round trips closed params, results and typed errors', async (method) => { + const request = { ...header, method, params: params[method] }; + expect(requestSchema.parse(JSON.parse(JSON.stringify(request)))).toEqual(request); + expect(requestSchema.safeParse({ ...request, extra: true }).success).toBe(false); + expect( + requestSchema.safeParse({ ...request, params: { ...params[method], extra: true } }).success, + ).toBe(false); + expect(requestSchema.safeParse({ ...request, params: null }).success).toBe(false); + expect(requestSchema.safeParse({ ...request, params: [] }).success).toBe(false); + const response = { ...header, result: results[method] }; + expect(responseSchema(method).parse(JSON.parse(JSON.stringify(response)))).toEqual(response); + expect(responseSchema(method).safeParse({ ...response, extra: true }).success).toBe(false); + expect( + responseSchema(method).safeParse({ ...response, result: { ...results[method], extra: true } }) + .success, + ).toBe(false); + expect( + responseSchema(method).safeParse({ + ...response, + result: { ...results[method], accepted: 'true' }, + }).success, + ).toBe(false); + expect( + responseSchema(method).safeParse({ + ...response, + result: { ...results[method], requestId: randomUUID() }, + }).success, + ).toBe(false); + const rejected = { + ...header, + error: { ...toWireError(undefined, id), kind: 'invalidInput', code: 'invalid_input' }, + }; + expect(responseSchema(method).parse(rejected)).toEqual(rejected); + expect(responseSchema(method).safeParse({ ...response, error: rejected.error }).success).toBe( + false, + ); + expect(responseSchema(method).safeParse(header).success).toBe(false); + expect( + responseSchema(method).safeParse({ ...rejected, error: { ...rejected.error, requestId: null } }) + .success, + ).toBe(false); + expect(await dispatchRpc(request, async () => results[method])).toEqual(response); +}); + +it('keeps identity and nonce ownership decisions in business results', async () => { + const registration = { ...fakeRegistration(), registrationId: '' }; + registration.entrypointInstance.shimSessionNonce = ''; + const request = { ...header, method: 'register-entrypoint-request', params: { registration } }; + expect(requestSchema.safeParse(request).success).toBe(true); + expect( + await dispatchRpc(request, async () => ({ + accepted: false, + message: 'Owner mismatch.', + registrationId: null, + })), + ).toEqual({ + ...header, + result: { requestId: id, accepted: false, message: 'Owner mismatch.', registrationId: null }, + }); +}); + +it('normalizes omitted serde Options to null without adding handler log defaults', () => { + const omitted = { + ...fakeRegistration(), + sourceWorkingDirectory: { raw: '/fixture' }, + sourceConfigPath: { raw: '/fixture/Caddyfile' }, + ownerProcess: { processId: 123, processStartTimeUtc: timestamp, shimSessionNonce: 'nonce' }, + logStream: { streamId: 'runtime-control', channel: 'control' }, + shimRun: undefined, + registeredDomains: [ + { + name: { raw: 'x', canonical: 'x' }, + activationState: 'active', + logStream: { streamId: 's', channel: 'c' }, + }, + ], + }; + const normalized = catalog['register-entrypoint-request'].params.parse( + JSON.parse(JSON.stringify({ registration: omitted })), + ).registration; + expect(normalized.sourceWorkingDirectory.canonical).toBe(null); + expect(normalized.sourceConfigPath.canonical).toBe(null); + expect(normalized.ownerProcess.executablePath).toBe(null); + expect(normalized.logStream.domainKey).toBe(null); + expect(normalized.shimRun).toBe(null); + expect(normalized.registeredDomains[0]!.logStream.domainKey).toBe(null); + expect(normalized.registeredDomains[0]).not.toHaveProperty('upstream'); + expect( + catalog['register-entrypoint-request'].params.parse({ + registration: { ...omitted, shimRun: { rawArguments: [], commandLine: 'run' } }, + }).registration.shimRun?.adapter, + ).toBe(null); + expect( + catalog['query-logs-request'].params.parse({ stream: { streamId: 's', channel: 'c' } }), + ).toEqual({ stream: { streamId: 's', channel: 'c', domainKey: null }, limit: null }); + for (const upstream of [undefined, null, '127.0.0.1:3000']) { + const registration = { + ...fakeRegistration(), + shimRun: null, + registeredDomains: [{ ...fakeRegistration().registeredDomains[0], upstream }], + }; + const normalized = catalog['register-entrypoint-request'].params.parse({ registration }); + const serialized = JSON.parse(JSON.stringify(normalized)); + if (upstream === undefined || upstream === null) + expect(serialized.registration.registeredDomains[0]).not.toHaveProperty('upstream'); + else expect(serialized.registration.registeredDomains[0].upstream).toBe(upstream); + expect( + catalog['query-state-request'].result.safeParse({ + ...basicResult, + requestId: id, + snapshot: { ...fakeSnapshot(), registrations: [registration] }, + }).success, + ).toBe(true); + } + const badUpstream = { + ...fakeRegistration(), + registeredDomains: [{ ...fakeRegistration().registeredDomains[0], upstream: 1 }], + }; + expect( + catalog['register-entrypoint-request'].params.safeParse({ registration: badUpstream }).success, + ).toBe(false); + for (const shimSessionNonce of [undefined, null, 'nonce']) { + expect( + catalog['set-entrypoint-enabled-request'].params.safeParse({ + registrationId: 'id', + enabled: false, + shimSessionNonce, + }).success, + ).toBe(true); + } + expect( + catalog['query-state-request'].result.parse({ ...basicResult, requestId: id, snapshot: null }) + .snapshot, + ).toBe(null); + expect( + catalog['register-entrypoint-request'].result.parse({ + ...basicResult, + requestId: id, + registrationId: null, + }).registrationId, + ).toBe(null); + for (const limit of [undefined, null, 0, 1, 100, 200, 500, Number.MAX_SAFE_INTEGER]) { + const input = { stream, limit }; + expect(catalog['query-logs-request'].params.parse(input)).toEqual({ + stream, + limit: limit ?? null, + }); + } + for (const limit of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, Infinity, '100']) { + expect(catalog['query-logs-request'].params.safeParse({ stream, limit }).success).toBe(false); + } +}); + +it('leaves RPC log default and clamping to the handler, not the schemas', async () => { + for (const limit of [undefined, null, 0, 1, 50, 100, 200, 500]) { + let observed: number | null | undefined; + const result = await dispatchRpc( + { ...header, method: 'query-logs-request', params: { stream, limit } }, + async (request) => { + if (request.method !== 'query-logs-request') throw new Error('Wrong catalog dispatch.'); + observed = request.params.limit; + const count = Math.max(1, Math.min(200, request.params.limit ?? 100)); + return { + ...results['query-logs-request'], + entries: Array.from({ length: count }, () => entry), + }; + }, + ); + expect(observed).toBe(limit ?? null); + const response = responseSchema('query-logs-request').parse(result); + expect( + 'result' in response && 'entries' in response.result ? response.result.entries.length : -1, + ).toBe(Math.max(1, Math.min(200, limit ?? 100))); + } +}); + +it('rejects nested unknown fields, missing fields, bad enum/time/number representations', () => { + const registration = fakeRegistration(); + const badRegistrations = [ + { ...registration, entrypointInstance: { ...registration.entrypointInstance, extra: true } }, + { ...registration, ownerProcess: { ...registration.ownerProcess, processId: 0x100000000 } }, + { ...registration, sourceConfigPath: { raw: 'x', canonical: 1 } }, + { ...registration, activationState: 'enabled' }, + { ...registration, createdAtUtc: 'yesterday' }, + { ...registration, logStream: { ...stream, extra: 1 } }, + { + ...registration, + registeredDomains: [ + { ...registration.registeredDomains[0], name: { raw: 'x', canonical: 'x', extra: true } }, + ], + }, + { ...registration, shimRun: { ...registration.shimRun, extra: true } }, + ]; + for (const candidate of badRegistrations) + expect( + catalog['register-entrypoint-request'].params.safeParse({ registration: candidate }).success, + ).toBe(false); + for (const method of ['unregister-entrypoint-request', 'heartbeat-entrypoint-request'] as const) + expect(catalog[method].params.safeParse({ registrationId: 'id' }).success).toBe(false); + expect( + catalog['set-domain-enabled-request'].params.safeParse({ registrationId: 'id', enabled: true }) + .success, + ).toBe(false); + expect( + catalog['set-entrypoint-enabled-request'].params.safeParse({ registrationId: 'id', enabled: 1 }) + .success, + ).toBe(false); + const snapshot = fakeSnapshot(); + for (const candidate of [ + { ...snapshot, recovered: true }, + { ...snapshot, runtime: { ...snapshot.runtime, status: 'online' } }, + { + ...snapshot, + runtime: { + ...snapshot.runtime, + diagnostics: [{ code: 'x', message: 'x', operation: null, extra: true }], + }, + }, + { + ...snapshot, + config: { + ...snapshot.config, + diagnostics: [ + { code: 'x', message: 'x', domainKey: null, sourceConfigPaths: [], extra: true }, + ], + }, + }, + { ...snapshot, storage: { ...snapshot.storage, schemaVersion: -1 } }, + ]) + expect( + catalog['query-state-request'].result.safeParse({ + ...basicResult, + requestId: id, + snapshot: candidate, + }).success, + ).toBe(false); + const logs = results['query-logs-request']; + expect( + catalog['query-logs-request'].result.safeParse({ + ...logs, + entries: Array.from({ length: 200 }, () => entry), + }).success, + ).toBe(true); + expect( + catalog['query-logs-request'].result.safeParse({ + ...logs, + entries: Array.from({ length: 201 }, () => entry), + }).success, + ).toBe(false); + for (const candidate of [ + { ...entry, sequenceNumber: Number.MAX_SAFE_INTEGER + 1 }, + { ...entry, severity: 'verbose' }, + { ...entry, attributionKind: 'server' }, + { ...entry, entryKind: 'gap' }, + { ...entry, stream: { ...stream, extra: 1 } }, + ]) + expect( + catalog['query-logs-request'].result.safeParse({ ...logs, entries: [candidate] }).success, + ).toBe(false); +}); + +it('round trips all typed error kinds and rejects malformed closed errors', () => { + for (const kind of protocolErrorSchema.shape.kind.options) { + const error = { ...toWireError(undefined, id), kind }; + expect(protocolErrorSchema.parse(JSON.parse(JSON.stringify(error)))).toEqual(error); + expect(new RpcError(error).protocolError).toEqual(error); + } + const error = { + ...toWireError(undefined, id), + kind: 'accessDenied', + deniedOperation: 'shutdown-daemon-request', + }; + expect(protocolErrorSchema.parse(error)).toEqual(error); + for (const deniedOperation of [undefined, null]) { + const normalized = protocolErrorSchema.parse({ ...error, deniedOperation }); + expect(JSON.parse(JSON.stringify(normalized))).not.toHaveProperty('deniedOperation'); + expect( + errorResponseSchema.safeParse({ ...header, error: { ...error, deniedOperation } }).success, + ).toBe(true); + } + expect(protocolErrorSchema.safeParse({ ...error, deniedOperation: 1 }).success).toBe(false); + for (const code of ['', '_code', 'a_', 'A', 'a-b', 'a'.repeat(65)]) + expect(protocolErrorSchema.safeParse({ ...error, code }).success).toBe(false); + for (const candidate of [ + { ...error, extra: true }, + { ...error, guidance: undefined }, + { ...error, requestId: undefined }, + { ...error, retryable: 'false' }, + { ...error, kind: 'generic' }, + ]) + expect(protocolErrorSchema.safeParse(candidate).success).toBe(false); +}); + +it('dispatch sanitizes invalid handler output and untrusted throws', async () => { + const request = { ...header, method: 'query-state-request', params: {} }; + const result = await dispatchRpc(request, async () => ({ + ...fakeStateResult(), + requestId: 'handler cannot choose id', + })); + expect(responseSchema('query-state-request').parse(result)).toEqual({ + ...header, + result: { ...fakeStateResult(), requestId: id }, + }); + const rejected = await dispatchRpc(request, async () => { + throw new Error('sensitive details'); + }); + expect(errorResponseSchema.parse(rejected).error).toEqual(toWireError(undefined, id)); + expect(JSON.stringify(rejected)).not.toContain('sensitive'); + let called = false; + expect( + errorResponseSchema.parse( + await dispatchRpc({}, async () => { + called = true; + return basicResult; + }), + ).requestId, + ).toBe(UNCORRELATED_REQUEST_ID); + expect(called).toBe(false); +}); + +// Checked by tsc, never invoked: payload requirements and method-indexed returns. +function staticContract(paths: RuntimePaths) { + expectTypeOf(rpc(paths, 'query-state-request')).toEqualTypeOf< + Promise> + >(); + expectTypeOf(rpc(paths, 'query-logs-request', { stream })).toEqualTypeOf< + Promise> + >(); + // @ts-expect-error No arbitrary method surface. + void rpc(paths, 'status'); + // @ts-expect-error Mutation params are required. + void rpc(paths, 'heartbeat-entrypoint-request'); + // @ts-expect-error Domain key is required. + void rpc(paths, 'set-domain-enabled-request', { registrationId: 'id', enabled: true }); + // @ts-expect-error State return cannot be mistaken for a log result. + const logs: Promise> = rpc(paths, 'query-state-request'); + void logs; +} +void staticContract; diff --git a/test/runtime-ancestors.test.ts b/test/runtime-ancestors.test.ts new file mode 100644 index 0000000..7e6ed49 --- /dev/null +++ b/test/runtime-ancestors.test.ts @@ -0,0 +1,274 @@ +import type { Stats } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { + assertProtected, + createProtectedFile, + prepareRuntime, +} from '../src/platform/runtime-security.ts'; +import { powershell } from '../src/platform/powershell.ts'; + +// POSIX paths and Unix/Darwin filesystem data are modeled on the Windows runner; +// these cases are not native Unix acceptance. +vi.mock('node:path', async (original) => { + const path = await original(); + return { ...path, ...path.posix }; +}); +vi.mock('node:fs/promises', async (original) => ({ + ...(await original()), + lstat: vi.fn(), + realpath: vi.fn(), + mkdir: vi.fn(), + open: vi.fn(), + unlink: vi.fn(), + chown: vi.fn(), +})); +vi.mock('../src/platform/powershell.ts', async (original) => ({ + ...(await original()), + powershell: vi.fn(), +})); +const owner = { id: 'uid:42', uid: 42, elevated: false }; +function info(uid: number, mode: number, directory = true, link = false): Stats { + return { + uid, + mode, + isDirectory: () => directory, + isFile: () => !directory, + isSymbolicLink: () => link, + } as Stats; +} +function filesystem(ancestor: string, uid: number, leaf: string) { + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path === ancestor) return info(uid, 0o777, false, true); + if (path === leaf) return info(42, 0o600, false); + // No new owner/mode policy for ordinary real ancestors, even broad modes. + return info(500, 0o777); + }); +} +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); +}); +afterEach(() => vi.restoreAllMocks()); + +it.each(['prepareRuntime', 'assertProtected'] as const)( + 'rejects modeled POSIX trailing-separator leaf links during %s', + async (operation) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path === '/runtime/link') return info(42, 0o777, false, true); + // POSIX lstat with a final slash follows the link to a directory. + if (path === '/runtime/link/' || path === '/runtime/link//') return info(42, 0o700); + return info(0, 0o755); + }); + for (const path of ['/runtime/link/', '/runtime/link//']) + await expect( + operation === 'prepareRuntime' + ? prepareRuntime(path, owner) + : assertProtected(path, owner, true), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(fs.lstat).toHaveBeenCalledWith('/runtime/link'); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); + expect(fs.chown).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); + +it.each(['/runtime/real/', '/runtime/real//', '/', '///'])( + 'retains modeled POSIX real directory/root behavior for %s', + async (path) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + vi.mocked(fs.lstat).mockResolvedValue(info(42, 0o700)); + await prepareRuntime(path, owner); + await assertProtected(path, owner, true); + const leaf = path.replace(/\/+$/, '') || '/'; + expect(fs.lstat).toHaveBeenLastCalledWith(leaf); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.realpath).not.toHaveBeenCalled(); + }, +); + +it('trims modeled POSIX separators without erasing unverified dot-dot components', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + filesystem('/home/link', 42, '/home/link/../real/'); + await expect(assertProtected('/home/link/../real/', owner, true)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(fs.lstat).toHaveBeenCalledWith('/home/link'); + expect(fs.lstat).not.toHaveBeenCalledWith('/home/real'); +}); + +it.each(['/tmp', '/var', '/etc'])( + 'accepts only the modeled standard Darwin alias %s', + async (ancestor) => { + const leaf = `${ancestor}/runtime/secret`; + filesystem(ancestor, 0, leaf); + vi.mocked(fs.realpath).mockResolvedValue(`/private${ancestor}`); + await assertProtected(leaf, owner); + expect(fs.realpath).toHaveBeenCalledWith(ancestor); + expect(fs.lstat).toHaveBeenCalledWith(`/private${ancestor}`); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); + expect(powershell).not.toHaveBeenCalled(); + }, +); + +it.each([ + ['user-owned tmp', 'darwin', '/tmp', 42, '/private/tmp'], + ['user-owned var', 'darwin', '/var', 42, '/private/var'], + ['user-owned etc', 'darwin', '/etc', 42, '/private/etc'], + ['wrong tmp target', 'darwin', '/tmp', 0, '/elsewhere/tmp'], + ['wrong var target', 'darwin', '/var', 0, '/private/var-extra'], + ['wrong etc target', 'darwin', '/etc', 0, '/private/tmp'], + ['nested tmp', 'darwin', '/home/tmp', 0, '/private/tmp'], + ['nested var', 'darwin', '/home/var', 0, '/private/var'], + ['nested etc', 'darwin', '/home/etc', 0, '/private/etc'], + ['different case', 'darwin', '/TMP', 0, '/private/tmp'], + ['other root-owned link', 'darwin', '/opt', 0, '/private/opt'], + ['Linux tmp', 'linux', '/tmp', 0, '/private/tmp'], + ['Linux var', 'linux', '/var', 0, '/private/var'], + ['Linux etc', 'linux', '/etc', 0, '/private/etc'], + ['Windows tmp model', 'win32', '/tmp', 0, '/private/tmp'], +] as const)( + 'rejects modeled %s before trust or mutation', + async (_description, platform, ancestor, uid, target) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform); + const leaf = `${ancestor}/missing/secret`; + filesystem(ancestor, uid, leaf); + vi.mocked(fs.realpath).mockResolvedValue(target); + for (const operation of [ + () => assertProtected(leaf, owner), + () => prepareRuntime(leaf, owner), + () => createProtectedFile(leaf, owner, 'must-not-be-created'), + ]) + await expect(operation()).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(fs.lstat).not.toHaveBeenCalledWith(leaf); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(fs.chown).not.toHaveBeenCalled(); + expect(powershell).not.toHaveBeenCalled(); + }, +); + +it.each(['missing-target', 'inaccessible-target', 'linked-target', 'file-target'])( + 'rejects an unverifiable modeled OS alias: %s', + async (phase) => { + filesystem('/tmp', 0, '/tmp/runtime/secret'); + vi.mocked(fs.realpath).mockResolvedValue('/private/tmp'); + if (phase === 'missing-target') + vi.mocked(fs.realpath).mockRejectedValue( + Object.assign(new Error('missing'), { code: 'ENOENT' }), + ); + else { + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path !== '/private/tmp') return inspect(path); + if (phase === 'inaccessible-target') + throw Object.assign(new Error('denied'), { code: 'EACCES' }); + return info(0, 0o777, false, phase === 'linked-target'); + }); + } + await expect(assertProtected('/tmp/runtime/secret', owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + }, +); + +it('does not introduce owner/mode restrictions on ordinary modeled real ancestors', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + filesystem('/unused-link', 42, '/ordinary/runtime/secret'); + await assertProtected('/ordinary/runtime/secret', owner); + expect(fs.realpath).not.toHaveBeenCalled(); +}); + +it('checks the linked prefix before dot-dot or deeper missing paths in modeled Unix', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + filesystem('/home/link', 42, '/home/link/../secret'); + await expect(assertProtected('/home/link/../secret', owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(fs.lstat).not.toHaveBeenCalledWith('/home/link/..'); + expect(fs.lstat).not.toHaveBeenCalledWith('/home/link/../secret'); +}); + +it('checks modeled missing/dot-dot paths instead of stopping before an existing link', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + const leaf = '/home/missing/../link/secret'; + filesystem('/home/link', 42, leaf); + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path === '/home/missing') throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + return inspect(path); + }); + for (const operation of [ + () => prepareRuntime(leaf, owner), + () => createProtectedFile(leaf, owner), + ]) + await expect(operation()).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(fs.lstat).toHaveBeenCalledWith('/home/link'); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); +}); + +it.each(['/tmp', '/var', '/etc'])( + 'retains modeled Darwin %s dot/dot-dot semantics without rewriting the caller path', + async (alias) => { + const leaf = `${alias}/nested/./../runtime/secret`; + filesystem(alias, 0, leaf); + vi.mocked(fs.realpath).mockResolvedValue(`/private${alias}`); + await assertProtected(leaf, owner); + expect(fs.lstat).toHaveBeenCalledWith(`/private${alias}/runtime`); + expect(fs.lstat).toHaveBeenLastCalledWith(leaf); + }, +); + +it('checks the fixed Darwin physical cursor after missing/dot-dot rather than normalizing to root', async () => { + const leaf = '/tmp/missing/../../hidden/secret'; + filesystem('/tmp', 0, leaf); + vi.mocked(fs.realpath).mockResolvedValue('/private/tmp'); + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path === '/private/tmp/missing') + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + if (path === '/private/hidden') return info(42, 0o777, false, true); + return inspect(path); + }); + for (const operation of [ + () => assertProtected(leaf, owner), + () => prepareRuntime(leaf, owner), + () => createProtectedFile(leaf, owner), + ]) + await expect(operation()).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(fs.lstat).not.toHaveBeenCalledWith('/hidden'); + expect(fs.lstat).not.toHaveBeenCalledWith(leaf); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); +}); + +it('does not extend Darwin recognition to a root-owned nested alias below a permitted alias', async () => { + const leaf = '/tmp/nested/tmp/secret'; + filesystem('/tmp', 0, leaf); + vi.mocked(fs.realpath).mockResolvedValue('/private/tmp'); + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path) => + path === '/private/tmp/nested/tmp' ? info(0, 0o777, false, true) : inspect(path), + ); + await expect(assertProtected(leaf, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(fs.realpath).toHaveBeenCalledOnce(); + expect(fs.realpath).toHaveBeenCalledWith('/tmp'); +}); + +it('retains unexpected filesystem failures without attempting creation', async () => { + filesystem('/unused-link', 42, '/ordinary/runtime/secret'); + const failure = Object.assign(new Error('I/O failure'), { code: 'EIO' }); + vi.mocked(fs.lstat).mockRejectedValueOnce(failure); + await expect(prepareRuntime('/ordinary/runtime', owner)).rejects.toBe(failure); + vi.mocked(fs.lstat).mockRejectedValueOnce(failure); + await expect(createProtectedFile('/ordinary/runtime/secret', owner)).rejects.toBe(failure); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); +}); diff --git a/test/runtime-boundaries.test.ts b/test/runtime-boundaries.test.ts new file mode 100644 index 0000000..829d6ca --- /dev/null +++ b/test/runtime-boundaries.test.ts @@ -0,0 +1,331 @@ +import { + chmod, + lstat, + mkdtemp, + mkdir, + readFile, + readlink, + readdir, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, sep } from 'node:path'; +import { createConnection } from 'node:net'; +import { once } from 'node:events'; +import { afterEach, expect, it, vi } from 'vitest'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { startLocalRuntime } from '../src/daemon/local-runtime.ts'; +import { + assertProtected, + createProtectedFile, + prepareRuntime, + runtimeOwner, +} from '../src/platform/runtime-security.ts'; +import { powershell, psLiteral } from '../src/platform/powershell.ts'; +import { basicResult } from './fixtures/rpc-data.ts'; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +const artifacts = ['directory', 'secret', 'lock', 'metadata', 'discovery'] as const; +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'cadder-boundary-')); + roots.push(root); + const options = { + runtimeDir: root, + ...(process.getuid ? { runtimeOwner: process.getuid() } : {}), + }; + const paths = resolvePaths(options); + const owner = await runtimeOwner(options.runtimeOwner); + return { root, options, paths, owner }; +} +async function snapshot(path: string) { + if (process.platform === 'win32') + return powershell(`(Get-Acl -LiteralPath ${psLiteral(path)}).Sddl`); + const info = await lstat(path); + return JSON.stringify({ uid: info.uid, gid: info.gid, mode: info.mode }); +} + +it.each(['prepareRuntime', 'assertProtected'] as const)( + 'denies a native trailing-separator leaf link during %s without modifying the target', + async (operation) => { + const { root, owner } = await fixture(); + const target = join(root, 'target'); + await prepareRuntime(target, owner); + const sentinel = join(target, 'sentinel'); + await createProtectedFile(sentinel, owner, 'leave-target-unchanged'); + const before = await snapshot(target); + const alias = join(root, 'alias'); + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const link = await readlink(alias); + const suffixes = process.platform === 'win32' ? ['\\', '/', '\\//'] : ['/', '//']; + for (const suffix of suffixes) { + const path = alias + suffix; + await expect( + operation === 'prepareRuntime' + ? prepareRuntime(path, owner) + : assertProtected(path, owner, true), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + } + expect(await readdir(target)).toEqual(['sentinel']); + expect(await readFile(sentinel, 'utf8')).toBe('leave-target-unchanged'); + expect(await snapshot(target)).toBe(before); + expect(await readlink(alias)).toBe(link); + }, +); + +it('retains ordinary native real-directory trailing-separator behavior', async () => { + const { root, owner } = await fixture(); + const directory = join(root, 'real'); + await prepareRuntime(directory + sep + sep, owner); + await assertProtected(directory + sep, owner, true); + await createProtectedFile(join(directory, 'sentinel'), owner, 'ordinary-real-path'); + expect(await readFile(join(directory, 'sentinel'), 'utf8')).toBe('ordinary-real-path'); +}); + +it('rejects a native ancestor junction before trusting a protected leaf or starting a listener', async () => { + const { root, owner } = await fixture(); + const target = join(root, 'target'); + const physical = resolvePaths({ runtimeDir: target }); + await prepareRuntime(physical.directory, owner); + for (const artifact of artifacts.filter((key) => key !== 'directory')) + await createProtectedFile( + physical[artifact], + owner, + artifact === 'secret' ? 'x'.repeat(32) : '', + ); + const sentinel = join(physical.directory, 'sentinel'); + await createProtectedFile(sentinel, owner, 'leave-target-unchanged'); + const contents = await Promise.all( + artifacts.filter((key) => key !== 'directory').map((key) => readFile(physical[key])), + ); + const acls = await Promise.all(artifacts.map((key) => snapshot(physical[key]))); + const alias = join(root, 'alias'); + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const paths = resolvePaths({ runtimeDir: alias }); + const link = await readlink(alias); + for (const artifact of artifacts) + await expect( + assertProtected(paths[artifact], owner, artifact === 'directory'), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + const handler = vi.fn(async () => basicResult); + let started: Awaited> | undefined; + try { + await expect( + startLocalRuntime( + { runtimeDir: alias, ...(process.getuid ? { runtimeOwner: process.getuid() } : {}) }, + handler, + ).then((runtime) => { + started = runtime; + return runtime; + }), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + } finally { + await started?.stop(); + } + const socket = createConnection(paths.endpoint); + try { + await expect(once(socket, 'connect')).rejects.toBeDefined(); + } finally { + socket.destroy(); + } + expect(handler).not.toHaveBeenCalled(); + expect(await readlink(alias)).toBe(link); + expect(await readFile(sentinel, 'utf8')).toBe('leave-target-unchanged'); + expect( + await Promise.all( + artifacts.filter((key) => key !== 'directory').map((key) => readFile(physical[key])), + ), + ).toEqual(contents); + expect(await Promise.all(artifacts.map((key) => snapshot(physical[key])))).toEqual(acls); +}); + +it.each(['prepareRuntime', 'createProtectedFile'] as const)( + 'rejects native linked ancestors before %s creates a missing path', + async (operation) => { + const { root, owner } = await fixture(); + const target = join(root, 'target'); + await prepareRuntime(target, owner); + const sentinel = join(target, 'sentinel'); + await createProtectedFile(sentinel, owner, 'leave-target-unchanged'); + const before = await snapshot(target); + const alias = join(root, 'alias'); + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const link = await readlink(alias); + const denied = + operation === 'prepareRuntime' + ? prepareRuntime(join(alias, 'missing', 'v2'), owner) + : createProtectedFile(join(alias, 'new-secret'), owner, 'must-not-be-created'); + await expect(denied).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + await expect( + startLocalRuntime( + { + runtimeDir: join(alias, 'missing'), + ...(process.getuid ? { runtimeOwner: process.getuid() } : {}), + }, + async () => basicResult, + ), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(await readdir(target)).toEqual(['sentinel']); + expect(await readFile(sentinel, 'utf8')).toBe('leave-target-unchanged'); + expect(await snapshot(target)).toBe(before); + expect(await readlink(alias)).toBe(link); + }, +); + +it.each(['prepareRuntime', 'createProtectedFile'] as const)( + 'checks native missing/dot-dot/link paths before %s mutates a target', + async (operation) => { + const { root, owner } = await fixture(); + const target = join(root, 'target'); + await prepareRuntime(target, owner); + const sentinel = join(target, 'sentinel'); + await createProtectedFile(sentinel, owner, 'leave-target-unchanged'); + const before = await snapshot(target); + const alias = join(root, 'alias'); + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const link = await readlink(alias); + const path = `${root}${sep}missing${sep}..${sep}alias${sep}escaped`; + const denied = + operation === 'prepareRuntime' + ? prepareRuntime(path, owner) + : createProtectedFile(path, owner, 'must-not-be-created'); + await expect(denied).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(await readdir(target)).toEqual(['sentinel']); + expect(await readFile(sentinel, 'utf8')).toBe('leave-target-unchanged'); + expect(await snapshot(target)).toBe(before); + expect(await readlink(alias)).toBe(link); + }, +); + +it('retains ordinary native real-directory dot and dot-dot semantics', async () => { + const { root, owner } = await fixture(); + await prepareRuntime(join(root, 'real', 'nested'), owner); + const directory = `${root}${sep}real${sep}nested${sep}.${sep}..${sep}v2`; + await prepareRuntime(directory, owner); + await assertProtected(directory, owner, true); + await createProtectedFile(`${directory}${sep}secret`, owner, 'ordinary-real-path'); + expect(await readFile(join(root, 'real', 'v2', 'secret'), 'utf8')).toBe('ordinary-real-path'); +}); + +it('does not erase a native linked ancestor with lexical dot-dot normalization', async () => { + const { root, owner } = await fixture(); + const target = join(root, 'target'); + await mkdir(join(target, 'nested'), { recursive: true }); + await prepareRuntime(join(target, 'v2'), owner); + const alias = join(root, 'alias'); + await symlink(join(target, 'nested'), alias, process.platform === 'win32' ? 'junction' : 'dir'); + await expect(assertProtected(`${alias}${sep}..${sep}v2`, owner, true)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); +}); + +it.each(artifacts)( + 'denies a native %s link/reparse substitution without touching the target', + async (artifact) => { + const { root, options, paths, owner } = await fixture(); + const target = join(root, 'target'); + const sentinel = join(target, 'sentinel'); + await mkdir(target, { mode: 0o700 }); + await writeFile(sentinel, 'leave-target-unchanged', { mode: 0o600 }); + if (artifact !== 'directory') await prepareRuntime(paths.directory, owner); + // Junction creation on Windows needs no symlink privilege; file artifacts are + // deliberately replaced by directory reparse points, not claimed file symlinks. + await symlink(target, paths[artifact], process.platform === 'win32' ? 'junction' : 'dir'); + const before = await snapshot(target); + const link = await readlink(paths[artifact]); + await expect( + assertProtected(paths[artifact], owner, artifact === 'directory'), + ).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + await expect(startLocalRuntime(options, async () => basicResult)).rejects.toMatchObject({ + code: artifact === 'metadata' ? 'EISDIR' : 'unsafe-runtime-permissions', + }); + if (artifact !== 'discovery') + await expect(lstat(paths.discovery)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readlink(paths[artifact])).toBe(link); + expect(await readFile(sentinel, 'utf8')).toBe('leave-target-unchanged'); + expect(await snapshot(target)).toBe(before); + }, +); + +it.each(artifacts)( + 'denies unsafe native %s permissions/ACLs without repairing existing content', + async (artifact) => { + const { options, paths, owner } = await fixture(); + await prepareRuntime(paths.directory, owner); + const path = paths[artifact]; + const sentinel = artifact === 'directory' ? join(path, 'sentinel') : path; + const content = artifact === 'secret' ? 'x'.repeat(32) : 'untouched-existing-content'; + if (artifact === 'directory') await createProtectedFile(sentinel, owner, content); + else await createProtectedFile(path, owner, content); + if (process.platform === 'win32') { + await powershell(` + $sections=[System.Security.AccessControl.AccessControlSections]::Owner -bor [System.Security.AccessControl.AccessControlSections]::Access; + $acl=[System.IO.${artifact === 'directory' ? 'Directory' : 'File'}]::GetAccessControl(${psLiteral(path)},$sections); + $sid=New-Object System.Security.Principal.SecurityIdentifier('S-1-1-0'); + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'Read','Allow'))); + [System.IO.${artifact === 'directory' ? 'Directory' : 'File'}]::SetAccessControl(${psLiteral(path)},$acl); + `); + } else await chmod(path, artifact === 'directory' ? 0o755 : 0o644); + const before = await snapshot(path); + await expect(startLocalRuntime(options, async () => basicResult)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await readFile(sentinel, 'utf8')).toBe(content); + expect(await snapshot(path)).toBe(before); + }, +); + +it.each(artifacts)( + 'denies a native %s owner mismatch without changing its owner or content', + async (artifact) => { + const { paths, owner } = await fixture(); + await prepareRuntime(paths.directory, owner); + const path = paths[artifact]; + const sentinel = artifact === 'directory' ? join(path, 'sentinel') : path; + await createProtectedFile(sentinel, owner, 'untouched-owner-mismatch'); + const before = await snapshot(path); + // Read the real ACL/stat against a deliberately different expected owner. + // This does not create an account or transfer ownership to another host user. + const other = + process.platform === 'win32' + ? { id: 'S-1-1-0', elevated: false } + : { id: `uid:${owner.uid! + 1}`, uid: owner.uid! + 1, elevated: false }; + await expect(assertProtected(path, other, artifact === 'directory')).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await snapshot(path)).toBe(before); + expect(await readFile(sentinel, 'utf8')).toBe('untouched-owner-mismatch'); + }, +); + +it.each(artifacts)( + 'denies inherited native Windows %s ACLs without rewriting them', + { + skip: process.platform !== 'win32', + }, + async (artifact) => { + const { options, paths, owner } = await fixture(); + await prepareRuntime(paths.directory, owner); + const path = paths[artifact]; + const sentinel = artifact === 'directory' ? join(path, 'sentinel') : path; + await createProtectedFile(sentinel, owner, 'untouched-inherited-acl'); + await powershell(` + $sections=[System.Security.AccessControl.AccessControlSections]::Owner -bor [System.Security.AccessControl.AccessControlSections]::Access; + $acl=[System.IO.${artifact === 'directory' ? 'Directory' : 'File'}]::GetAccessControl(${psLiteral(path)},$sections); + $acl.SetAccessRuleProtection($false,$true); + [System.IO.${artifact === 'directory' ? 'Directory' : 'File'}]::SetAccessControl(${psLiteral(path)},$acl); + `); + const before = await snapshot(path); + await expect(startLocalRuntime(options, async () => basicResult)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await snapshot(path)).toBe(before); + expect(await readFile(sentinel, 'utf8')).toBe('untouched-inherited-acl'); + }, +); diff --git a/test/runtime-descendants.test.ts b/test/runtime-descendants.test.ts new file mode 100644 index 0000000..8c62887 --- /dev/null +++ b/test/runtime-descendants.test.ts @@ -0,0 +1,295 @@ +import * as fs from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join, sep } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import * as shell from '../src/platform/powershell.ts'; +import * as security from '../src/platform/runtime-security.ts'; + +vi.mock('node:fs/promises', async (original) => { + const actual = await original(); + return { ...actual, lstat: vi.fn(actual.lstat) }; +}); + +const nativeFs = await vi.importActual('node:fs/promises'); +let fixture: string | undefined; +let root: string; +let owner: security.RuntimeOwner; + +beforeEach(async (context) => { + vi.mocked(fs.lstat).mockReset().mockImplementation(nativeFs.lstat); + owner = await security.runtimeOwner(process.getuid?.()); + if (process.platform === 'win32' && owner.elevated) context.skip(); + fixture = await fs.mkdtemp(join(tmpdir(), 'cadder-descendant-')); + root = join(fixture, 'protected'); + await security.prepareRuntime(root, owner); +}); +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + if (fixture) await fs.rm(fixture, { recursive: true, force: true }); + fixture = undefined; +}); + +async function snapshot(path: string): Promise { + const acl = + process.platform === 'win32' + ? await shell.powershell(`(Get-Acl -LiteralPath ${shell.psLiteral(path)}).Sddl`) + : JSON.stringify(await fs.lstat(path)); + return createHash('sha256').update(acl).digest('hex'); +} +async function descendants(): Promise<{ directory: string; file: string }> { + const directory = join(root, 'native', 'nested'); + await fs.mkdir(join(root, 'native'), { mode: 0o700 }); + await fs.mkdir(directory, { mode: 0o700 }); + const file = join(directory, 'leaf'); + await fs.writeFile(file, 'unchanged content', { mode: 0o600 }); + return { directory, file }; +} + +it('accepts native nested owner-only storage without modifying ACLs or content', async () => { + const { directory, file } = await descendants(); + const paths = [root, join(root, 'native'), directory, file]; + const before = await Promise.all(paths.map(snapshot)); + await security.assertRuntimeDescendant(root, directory, owner, true); + await security.assertRuntimeDescendant(root, file, owner); + expect(await Promise.all(paths.map(snapshot))).toEqual(before); + expect(await fs.readFile(file, 'utf8')).toBe('unchanged content'); + if (process.platform === 'win32') { + expect(owner.elevated).toBe(false); + await expect(security.assertProtected(file, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + } +}); + +it('accepts a real SQLite DELETE journal while preserving strict primary database checks', async () => { + const paths = resolvePaths({ runtimeDir: root }); + await security.prepareRuntime(paths.directory, owner); + const lock = await acquireRuntimeLock(paths, owner); + try { + const journal = `${paths.lock}-journal`; + await security.assertProtected(paths.lock, owner); + await security.assertRuntimeDescendant(paths.directory, journal, owner); + if (process.platform === 'win32') + await expect(security.assertProtected(journal, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + } finally { + await lock.release(); + } +}); + +it('rejects escapes, equal roots and lexical dot aliases before inspecting their target', async () => { + const { file } = await descendants(); + const inspect = vi.spyOn(fs, 'lstat').mockClear(); + for (const path of [ + root, + join(fixture!, 'outside'), + `${root}-sibling${sep}leaf`, + 'relative-leaf', + `${root}${sep}native${sep}..${sep}native${sep}nested${sep}leaf`, + `${root}${sep}.${sep}native${sep}nested${sep}leaf`, + ]) { + await expect(security.assertRuntimeDescendant(root, path, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + } + expect(inspect).not.toHaveBeenCalled(); + await security.assertRuntimeDescendant(root, file, owner); +}); + +it.skipIf(process.platform !== 'win32')( + 'does not relax an inherited root even when its descendants are owner-only', + async () => { + const { file } = await descendants(); + await expect(security.assertRuntimeDescendant(fixture!, file, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + }, +); + +it('refuses in-root junctions/links, including final separators and linked directory chains', async () => { + const { directory, file } = await descendants(); + const alias = join(root, 'alias'); + await fs.symlink(directory, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const before = await snapshot(directory); + for (const path of [alias, alias + sep, alias + sep + sep]) + await expect(security.assertRuntimeDescendant(root, path, owner, true)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + await expect( + security.assertRuntimeDescendant(root, join(alias, 'leaf'), owner), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(await snapshot(directory)).toBe(before); + expect(await fs.readFile(file, 'utf8')).toBe('unchanged content'); +}); + +it('refuses wrong kinds, missing root/chain/leaf and inspection faults without mutation', async () => { + const { directory, file } = await descendants(); + for (const [path, isDirectory] of [ + [directory, false], + [file, true], + [file + sep, false], + [join(file, 'child'), false], + ] as const) + await expect( + security.assertRuntimeDescendant(root, path, owner, isDirectory), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + for (const missing of [join(root, 'missing'), join(root, 'missing', 'leaf')]) + await expect(security.assertRuntimeDescendant(root, missing, owner)).rejects.toMatchObject({ + code: 'ENOENT', + path: join(root, 'missing'), + }); + const missingRoot = join(fixture!, 'missing-root'); + await expect( + security.assertRuntimeDescendant(missingRoot, join(missingRoot, 'leaf'), owner), + ).rejects.toMatchObject({ code: 'ENOENT', path: missingRoot }); + const original = nativeFs.lstat; + const failure = Object.assign(new Error('inspection failed'), { code: 'EACCES' }); + vi.spyOn(fs, 'lstat').mockImplementation((async (path: unknown) => { + if (path === file) throw failure; + return original(path as string); + }) as typeof fs.lstat); + await expect(security.assertRuntimeDescendant(root, file, owner)).rejects.toBe(failure); +}); + +it('refuses a root junction before accepting any descendant', async () => { + const { file } = await descendants(); + const alias = join(fixture!, 'root-alias'); + await fs.symlink(root, alias, process.platform === 'win32' ? 'junction' : 'dir'); + await expect( + security.assertRuntimeDescendant(alias + sep, join(alias, 'native', 'nested', 'leaf'), owner), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(await fs.readFile(file, 'utf8')).toBe('unchanged content'); +}); + +async function fixtureAcl(path: string, directory: boolean, extra: string): Promise { + await shell.powershell(` + $sid = New-Object Security.Principal.SecurityIdentifier(${shell.psLiteral(owner.id)}); + $foreign = New-Object Security.Principal.SecurityIdentifier('S-1-1-0'); + $acl = New-Object Security.AccessControl.${directory ? 'DirectorySecurity' : 'FileSecurity'}; + $acl.SetAccessRuleProtection($true,$false); + ${extra} + [IO.${directory ? 'Directory' : 'File'}]::SetAccessControl(${shell.psLiteral(path)},$acl); + `); +} + +it('refuses an unsafe root without repairing it', async () => { + const { file } = await descendants(); + if (process.platform === 'win32') + await fixtureAcl( + root, + true, + "$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow'))); $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($foreign,'Read','Allow')));", + ); + else await fs.chmod(root, 0o755); + const before = await snapshot(root); + await expect(security.assertRuntimeDescendant(root, file, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await snapshot(root)).toBe(before); +}); + +it.skipIf(process.platform !== 'win32')( + 'refuses foreign, denied, insufficient and InheritOnly-only full access on native Windows fixtures', + async () => { + const rules = [ + "$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow'))); $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($foreign,'Read','Allow')));", + "$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'Read','Allow')));", + "$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow'))); $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'Write','Deny')));", + "$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'Read','Allow'))); $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','InheritOnly','Allow')));", + ]; + for (let index = 0; index < rules.length; index++) { + const directory = join(root, `unsafe-${index}`); + await fs.mkdir(directory); + await fixtureAcl(directory, true, rules[index]!); + const before = await snapshot(directory); + await expect( + security.assertRuntimeDescendant(root, directory, owner, true), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + // Reject the unsafe chain before inspecting even an absent target beneath it. + await expect( + security.assertRuntimeDescendant(root, join(directory, 'absent-leaf'), owner), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(await snapshot(directory)).toBe(before); + } + }, +); + +it.skipIf(process.platform !== 'win32')( + 'models a different Windows owner despite owner-only FullControl without assigning a native owner', + async () => { + const { file } = await descendants(); + const before = await snapshot(file); + const original = shell.powershell; + vi.spyOn(shell, 'powershell').mockImplementation(async (source) => { + // Change only the in-memory descriptor returned for this leaf, never the actual ACL. + const get = `$acl = Get-Acl -LiteralPath ${shell.psLiteral(file)};`; + return original( + source.replace( + get, + `${get} $acl.SetOwner((New-Object Security.Principal.SecurityIdentifier('S-1-5-18')));`, + ), + ); + }); + await expect(security.assertRuntimeDescendant(root, file, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + vi.restoreAllMocks(); + expect(await snapshot(file)).toBe(before); + }, +); + +it('models Unix exact uid/mode checks for roots, directories and files without chown', async () => { + const { directory, file } = await descendants(); + const original = nativeFs.lstat; + const uid = 101; + const unixOwner = { id: `uid:${uid}`, uid, elevated: false }; + vi.stubGlobal('process', Object.create(process, { platform: { value: 'linux' } })); + let unsafe: { path: string; uid?: number; mode?: number } | undefined; + vi.spyOn(fs, 'lstat').mockImplementation((async (path: unknown) => { + const info = await original(path as string); + const normalMode = info.isDirectory() ? 0o700 : 0o600; + const requestedMode = unsafe && unsafe.path === path ? unsafe.mode : undefined; + return Object.create(info, { + uid: { value: unsafe && unsafe.path === path && unsafe.uid !== undefined ? unsafe.uid : uid }, + mode: { value: (info.mode & ~0o777) | (requestedMode ?? normalMode) }, + }); + }) as typeof fs.lstat); + await security.assertRuntimeDescendant(root, file, unixOwner); + for (const path of [root, directory, file]) { + unsafe = { path, uid: uid + 1 }; + await expect(security.assertRuntimeDescendant(root, file, unixOwner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + unsafe = { path, mode: path === file ? 0o640 : 0o750 }; + await expect(security.assertRuntimeDescendant(root, file, unixOwner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + } +}); + +it('refuses a pre-existing unsafe SQLite journal without consuming or deleting it', async () => { + const paths = resolvePaths({ runtimeDir: root }); + await security.prepareRuntime(paths.directory, owner); + await security.createProtectedFile(paths.lock, owner); + const journal = `${paths.lock}-journal`; + await fs.writeFile(journal, 'untrusted journal sentinel', { mode: 0o600 }); + if (process.platform === 'win32') + await fixtureAcl( + journal, + false, + "$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow'))); $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($foreign,'Read','Allow')));", + ); + else await fs.chmod(journal, 0o644); + const before = await snapshot(journal); + await expect(acquireRuntimeLock(paths, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await snapshot(journal)).toBe(before); + expect(await fs.readFile(journal, 'utf8')).toBe('untrusted journal sentinel'); + await expect(fs.lstat(paths.metadata)).rejects.toMatchObject({ code: 'ENOENT' }); +}); diff --git a/test/runtime-lock-faults.test.ts b/test/runtime-lock-faults.test.ts new file mode 100644 index 0000000..c519ef4 --- /dev/null +++ b/test/runtime-lock-faults.test.ts @@ -0,0 +1,285 @@ +import { randomBytes } from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import { DatabaseSync } from 'node:sqlite'; +import { beforeEach, expect, it, vi } from 'vitest'; +import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import * as security from '../src/platform/runtime-security.ts'; + +vi.mock('node:crypto', async (original) => ({ + ...(await original()), + randomBytes: vi.fn(), +})); +vi.mock('node:fs/promises', () => ({ readFile: vi.fn(), unlink: vi.fn(), writeFile: vi.fn() })); +vi.mock('node:sqlite', () => ({ DatabaseSync: vi.fn() })); +vi.mock('../src/platform/runtime-security.ts', () => ({ + assertProtected: vi.fn(), + assertRuntimeDescendant: vi.fn(), + createProtectedFile: vi.fn(), +})); +const paths = resolvePaths({ runtimeDir: '/fault-runtime' }); +const owner = { id: 'test-owner', elevated: false }; +const database = { exec: vi.fn(), close: vi.fn() }; +let secret: Buffer; +let generated: Buffer; +const ioFailure = Object.assign(new Error('disk I/O error'), { + code: 'ERR_SQLITE_ERROR', + errcode: 10, +}); + +beforeEach(() => { + vi.resetAllMocks(); + secret = Buffer.alloc(32, 7); + generated = Buffer.alloc(32, 9); + vi.mocked(randomBytes).mockReturnValue(generated as never); + vi.mocked(DatabaseSync).mockImplementation(function () { + return database as never; + }); + vi.mocked(security.createProtectedFile).mockResolvedValue(true); + vi.mocked(fs.readFile).mockImplementation(async (path) => { + if (path === paths.metadata) throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + return secret; + }); +}); + +it.each(['unsafe', 'inspection', 'missing-root', 'missing-intermediate'])( + 'refuses an existing journal before SQLite open: %s', + async (kind) => { + const failure = Object.assign(new Error(kind), { + code: + ( + { + 'missing-root': 'ENOENT', + 'missing-intermediate': 'ENOENT', + inspection: 'EACCES', + } as Record + )[kind] ?? 'unsafe-runtime-permissions', + path: + ( + { 'missing-root': paths.directory, 'missing-intermediate': '/missing-parent' } as Record< + string, + string + > + )[kind] ?? `${paths.lock}-journal`, + }); + vi.mocked(security.assertRuntimeDescendant).mockRejectedValueOnce(failure); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); + expect(DatabaseSync).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + }, +); + +it('does not treat unlocated ENOENT as journal leaf absence', async () => { + const failure = Object.assign(new Error('unlocated missing path'), { code: 'ENOENT' }); + vi.mocked(security.assertRuntimeDescendant).mockRejectedValueOnce(failure); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); + expect(DatabaseSync).not.toHaveBeenCalled(); +}); + +it('allows only known journal leaf absence and checks again while exclusion is held', async () => { + const journal = `${paths.lock}-journal`; + vi.mocked(security.assertRuntimeDescendant).mockRejectedValue( + Object.assign(new Error('absent leaf'), { code: 'ENOENT', path: journal }), + ); + const lock = await acquireRuntimeLock(paths, owner); + expect(security.assertRuntimeDescendant).toHaveBeenCalledTimes(2); + expect(security.assertRuntimeDescendant).toHaveBeenCalledWith(paths.directory, journal, owner); + expect(vi.mocked(security.assertRuntimeDescendant).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(DatabaseSync).mock.invocationCallOrder[0]!, + ); + expect(vi.mocked(security.assertRuntimeDescendant).mock.invocationCallOrder[1]).toBeGreaterThan( + database.exec.mock.invocationCallOrder[1]!, + ); + expect(vi.mocked(security.assertRuntimeDescendant).mock.invocationCallOrder[1]).toBeLessThan( + vi.mocked(fs.writeFile).mock.invocationCallOrder[0]!, + ); + await lock.release(); +}); + +it('refuses an unsafe generated journal and finalizes exclusion before publication', async () => { + const failure = new Error('unsafe generated journal'); + vi.mocked(security.assertRuntimeDescendant) + .mockResolvedValueOnce() + .mockRejectedValueOnce(failure); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); + expect(database.exec).toHaveBeenCalledWith('ROLLBACK;'); + expect(database.close).toHaveBeenCalledOnce(); + expect(security.createProtectedFile).toHaveBeenCalledTimes(1); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); +}); + +it('propagates SQLite open failure without pretending contention or deleting foreign artifacts', async () => { + vi.mocked(DatabaseSync).mockImplementationOnce(function () { + throw ioFailure; + }); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(ioFailure); + expect(fs.unlink).not.toHaveBeenCalled(); +}); + +it.each(['PRAGMA', 'BEGIN'])( + 'preserves storage errors during %s and closes the connection', + async (phase) => { + database.exec.mockImplementation((sql: string) => { + if (sql.includes(phase)) throw ioFailure; + }); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(ioFailure); + expect(database.close).toHaveBeenCalledOnce(); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); + +it.each([10, 13, 778])( + 'does not misclassify SQLite I/O or full-disk errors: %s', + async (errcode) => { + const failure = Object.assign(new Error('storage failure'), { + code: 'ERR_SQLITE_ERROR', + errcode, + }); + database.exec.mockImplementationOnce(() => { + throw failure; + }); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); + expect(database.close).toHaveBeenCalledOnce(); + }, +); + +it.each(['directory', 'lock-create', 'metadata-read', 'secret-create'])( + 'does not publish or delete unvalidated paths after %s fails', + async (phase) => { + const failure = new Error(phase); + if (phase === 'directory') vi.mocked(security.assertProtected).mockRejectedValueOnce(failure); + if (phase === 'lock-create') + vi.mocked(security.createProtectedFile).mockRejectedValueOnce(failure); + if (phase === 'metadata-read') vi.mocked(fs.readFile).mockRejectedValueOnce(failure); + if (phase === 'secret-create') + vi.mocked(security.createProtectedFile).mockImplementation(async (path) => { + if (path === paths.secret) throw failure; + return true; + }); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + if (phase === 'directory' || phase === 'lock-create') + expect(DatabaseSync).not.toHaveBeenCalled(); + else expect(database.close).toHaveBeenCalledOnce(); + if (phase === 'secret-create') expect(generated.every((byte) => byte === 0)).toBe(true); + }, +); + +it.each([5, 6, 261])( + 'classifies only SQLite busy/locked codes as contention: %s', + async (errcode) => { + database.exec.mockImplementationOnce(() => { + throw Object.assign(new Error('busy'), { code: 'ERR_SQLITE_ERROR', errcode }); + }); + await expect(acquireRuntimeLock(paths, owner)).rejects.toMatchObject({ + code: 'runtime-already-running', + }); + expect(database.close).toHaveBeenCalledOnce(); + }, +); + +it.each(['metadata-create', 'metadata-write', 'secret-read', 'invalid-secret'])( + 'finalizes owned resources after %s', + async (phase) => { + const failure = new Error(phase); + if (phase === 'metadata-create') + vi.mocked(security.createProtectedFile).mockImplementation(async (path) => { + if (path === paths.metadata) throw failure; + return true; + }); + if (phase === 'metadata-write') vi.mocked(fs.writeFile).mockRejectedValueOnce(failure); + if (phase === 'secret-read') + vi.mocked(fs.readFile) + .mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' })) + .mockRejectedValueOnce(failure); + if (phase === 'invalid-secret') secret = Buffer.alloc(5, 7); + await expect(acquireRuntimeLock(paths, owner)).rejects.toThrow(); + expect(database.close).toHaveBeenCalledOnce(); + expect(database.exec).toHaveBeenCalledWith('ROLLBACK;'); + expect(generated.every((byte) => byte === 0)).toBe(true); + if (phase !== 'secret-read') expect(secret.every((byte) => byte === 0)).toBe(true); + expect(fs.unlink).toHaveBeenCalledWith(paths.secret); + if (phase === 'metadata-write') expect(fs.unlink).toHaveBeenCalledWith(paths.metadata); + else expect(fs.unlink).not.toHaveBeenCalledWith(paths.metadata); + }, +); + +it('preserves pre-existing secret and unvalidated metadata on acquisition failure', async () => { + vi.mocked(security.createProtectedFile).mockImplementation(async (path) => { + if (path === paths.metadata) throw new Error('unsafe metadata'); + return false; + }); + await expect(acquireRuntimeLock(paths, owner)).rejects.toThrow('unsafe metadata'); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(secret.every((byte) => byte === 0)).toBe(true); + expect(generated.every((byte) => byte === 0)).toBe(true); +}); + +it.each(['unlink', 'rollback', 'close'])( + 'attempts every finalizer and shares release failure: %s', + async (phase) => { + const lock = await acquireRuntimeLock(paths, owner); + const failure = new Error(phase); + if (phase === 'unlink') vi.mocked(fs.unlink).mockRejectedValueOnce(failure); + if (phase === 'rollback') + database.exec.mockImplementation((sql: string) => { + if (sql === 'ROLLBACK;') throw failure; + }); + if (phase === 'close') + database.close.mockImplementationOnce(() => { + throw failure; + }); + const first = lock.release(); + expect(lock.release()).toBe(first); + await expect(first).rejects.toBe(failure); + await expect(lock.release()).rejects.toBe(failure); + expect(database.exec).toHaveBeenCalledWith('ROLLBACK;'); + expect(database.close).toHaveBeenCalledOnce(); + expect(secret.every((byte) => byte === 0)).toBe(true); + }, +); + +it('cleans validated stale diagnostic metadata but preserves an existing secret after write failure', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(Buffer.from('stale')).mockResolvedValueOnce(secret); + vi.mocked(security.createProtectedFile).mockResolvedValue(false); + vi.mocked(fs.writeFile).mockRejectedValueOnce(ioFailure); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(ioFailure); + expect(fs.unlink).toHaveBeenCalledWith(paths.metadata); + expect(fs.unlink).not.toHaveBeenCalledWith(paths.secret); + expect(secret.every((byte) => byte === 0)).toBe(true); +}); + +it('retains all release failures and zeroes the secret even when no finalizer succeeds', async () => { + const lock = await acquireRuntimeLock(paths, owner); + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('unlink')); + database.exec.mockImplementation((sql: string) => { + if (sql === 'ROLLBACK;') throw new Error('rollback'); + }); + database.close.mockImplementationOnce(() => { + throw new Error('close'); + }); + const error = await lock.release().catch((error: unknown) => error); + expect((error as AggregateError).errors).toHaveLength(3); + await expect(lock.release()).rejects.toBe(error); + expect(database.close).toHaveBeenCalledOnce(); + expect(secret.every((byte) => byte === 0)).toBe(true); +}); + +it('retains acquisition failure alongside rollback/close/unlink failures', async () => { + vi.mocked(fs.writeFile).mockRejectedValueOnce(ioFailure); + vi.mocked(fs.unlink).mockRejectedValue(new Error('unlink')); + database.exec.mockImplementation((sql: string) => { + if (sql === 'ROLLBACK;') throw new Error('rollback'); + }); + database.close.mockImplementationOnce(() => { + throw new Error('close'); + }); + const error = await acquireRuntimeLock(paths, owner).catch((error: unknown) => error); + expect(error).toMatchObject({ cause: ioFailure }); + expect((error as AggregateError).errors).toHaveLength(5); + expect(database.close).toHaveBeenCalledOnce(); + expect(secret.every((byte) => byte === 0)).toBe(true); +}); diff --git a/test/runtime-native-faults.test.ts b/test/runtime-native-faults.test.ts new file mode 100644 index 0000000..e92c714 --- /dev/null +++ b/test/runtime-native-faults.test.ts @@ -0,0 +1,181 @@ +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createConnection, createServer } from 'node:net'; +import { once } from 'node:events'; +import { DatabaseSync } from 'node:sqlite'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { startLocalRuntime } from '../src/daemon/local-runtime.ts'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; +import { prepareRuntime, runtimeOwner } from '../src/platform/runtime-security.ts'; +import { basicResult } from './fixtures/rpc-data.ts'; + +vi.mock('node:fs/promises', async (original) => { + const actual = await original(); + return { + ...actual, + readFile: vi.fn(actual.readFile), + writeFile: vi.fn(actual.writeFile), + unlink: vi.fn(actual.unlink), + }; +}); +vi.mock('node:net', async (original) => { + const actual = await original(); + return { ...actual, createServer: vi.fn(actual.createServer) }; +}); +vi.mock('node:sqlite', async (original) => { + const actual = await original(); + return { + ...actual, + DatabaseSync: vi.fn(function (...args: ConstructorParameters) { + return new actual.DatabaseSync(...args); + }), + }; +}); +const realFs = await vi.importActual('node:fs/promises'); +const realNet = await vi.importActual('node:net'); +const realSqlite = await vi.importActual('node:sqlite'); +let root: string; +let options: { runtimeDir: string; runtimeOwner?: number }; +let paths: ReturnType; +const failure = Object.assign(new Error('simulated storage exhaustion'), { code: 'ENOSPC' }); + +beforeEach(async () => { + vi.mocked(fs.readFile).mockImplementation(realFs.readFile); + vi.mocked(fs.writeFile).mockImplementation(realFs.writeFile); + vi.mocked(fs.unlink).mockImplementation(realFs.unlink); + vi.mocked(createServer).mockImplementation(realNet.createServer); + vi.mocked(DatabaseSync).mockImplementation(function (...args) { + return new realSqlite.DatabaseSync(...args); + }); + root = await fs.mkdtemp(join(tmpdir(), 'cadder-phase-fault-')); + options = { runtimeDir: root, ...(process.getuid ? { runtimeOwner: process.getuid() } : {}) }; + paths = resolvePaths(options); +}); +afterEach(async () => { + vi.restoreAllMocks(); + await realFs.rm(root, { recursive: true, force: true }); +}); + +async function assertStopped(): Promise { + const socket = createConnection(paths.endpoint); + const result = once(socket, 'connect').then( + () => true, + () => false, + ); + expect(await result).toBe(false); + socket.destroy(); +} + +it.each(['metadata', 'discovery'] as const)( + 'cleans real partial %s after ENOSPC and permits restart', + async (artifact) => { + let liveSecret: Buffer | undefined; + vi.mocked(fs.readFile).mockImplementation(async (path, options) => { + const result = await realFs.readFile(path, options); + if (path === paths.secret && Buffer.isBuffer(result)) liveSecret = result; + return result; + }); + vi.mocked(fs.writeFile).mockImplementation(async (path, data, options) => { + if (path !== paths[artifact]) return realFs.writeFile(path, data, options); + vi.mocked(fs.writeFile).mockImplementation(realFs.writeFile); + await realFs.writeFile(path, String(data).slice(0, 8), options); + throw failure; + }); + const handler = vi.fn(async () => basicResult); + await expect(startLocalRuntime(options, handler)).rejects.toBe(failure); + await assertStopped(); + expect(liveSecret?.every((byte) => byte === 0)).toBe(true); + await expect(fs.lstat(paths.metadata)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.lstat(paths.discovery)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(handler).not.toHaveBeenCalled(); + const runtime = await startLocalRuntime(options, handler); + await runtime.stop(); + }, +); + +it.each(['discovery-unlink', 'metadata-unlink', 'rollback', 'database-close', 'listener-close'])( + 'finalizes native resources and permits reacquisition despite %s error', + async (phase) => { + let secret: Buffer | undefined; + vi.mocked(fs.readFile).mockImplementation(async (path, options) => { + const result = await realFs.readFile(path, options); + if (path === paths.secret && Buffer.isBuffer(result)) secret = result; + return result; + }); + if (phase === 'rollback' || phase === 'database-close') { + vi.mocked(DatabaseSync).mockImplementationOnce(function (...args) { + const database = new realSqlite.DatabaseSync(...args); + const exec = database.exec.bind(database); + const close = database.close.bind(database); + vi.spyOn(database, 'exec').mockImplementation((sql) => { + if (phase === 'rollback' && sql === 'ROLLBACK;') throw failure; + return exec(sql); + }); + vi.spyOn(database, 'close').mockImplementation(() => { + close(); + if (phase === 'database-close') throw failure; + }); + return database; + }); + } + if (phase === 'listener-close') { + vi.mocked(createServer).mockImplementationOnce((...args) => { + const server = realNet.createServer(...args); + const close = server.close.bind(server); + vi.spyOn(server, 'close').mockImplementation((callback) => + close(() => callback?.(failure)), + ); + return server; + }); + } + const runtime = await startLocalRuntime(options, async () => basicResult); + if (phase.endsWith('unlink')) { + const target = phase === 'discovery-unlink' ? paths.discovery : paths.metadata; + vi.mocked(fs.unlink).mockImplementation(async (path) => { + if (path === target) { + vi.mocked(fs.unlink).mockImplementation(realFs.unlink); + throw failure; + } + return realFs.unlink(path); + }); + } + const stopping = runtime.stop(); + expect(runtime.stop()).toBe(stopping); + await expect(stopping).rejects.toBe(failure); + await expect(runtime.stop()).rejects.toBe(failure); + await assertStopped(); + expect(secret?.every((byte) => byte === 0)).toBe(true); + const owner = await runtimeOwner(options.runtimeOwner); + const lock = await acquireRuntimeLock(paths, owner); + secret = lock.secret; + await lock.release(); + expect(secret.every((byte) => byte === 0)).toBe(true); + const restarted = await startLocalRuntime(options, async () => basicResult); + await restarted.stop(); + }, +); + +it( + 'refuses an occupied listener without closing the unrelated server and releases its exclusion', + { skip: process.platform !== 'win32' }, + async () => { + const owner = await runtimeOwner(options.runtimeOwner); + await prepareRuntime(paths.directory, owner); + const foreign = realNet.createServer(); + foreign.listen(paths.endpoint); + await once(foreign, 'listening'); + try { + await expect(startLocalRuntime(options, async () => basicResult)).rejects.toMatchObject({ + code: 'EADDRINUSE', + }); + expect(foreign.listening).toBe(true); + await expect(fs.lstat(paths.discovery)).rejects.toMatchObject({ code: 'ENOENT' }); + const lock = await acquireRuntimeLock(paths, owner); + await lock.release(); + } finally { + await new Promise((resolve) => foreign.close(() => resolve())); + } + }, +); diff --git a/test/runtime-system.test.ts b/test/runtime-system.test.ts index ef59af9..18d46c3 100644 --- a/test/runtime-system.test.ts +++ b/test/runtime-system.test.ts @@ -9,7 +9,11 @@ import { expect, it } from 'vitest'; import { resolvePaths } from '../src/daemon/paths.ts'; import { rpc } from '../src/client/connection.ts'; -async function start(root: string, entry: string): Promise { +async function start( + root: string, + entry: string, + recovered = false, +): Promise { const environment = { ...process.env }; delete environment.NODE_OPTIONS; const child = spawn( @@ -26,9 +30,14 @@ async function start(root: string, entry: string): Promise { + let stdout = ''; + child.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + if (!stdout.includes('\n')) return; clearTimeout(timer); - resolve(); + if (stdout.trim() !== `READY recovered=${recovered}`) + reject(new Error(`Unexpected fixture readiness: ${stdout}`)); + else resolve(); }); child.once('exit', () => { clearTimeout(timer); @@ -57,18 +66,20 @@ it( target: 'node24.18', }); child = await start(root, entry); - expect(await rpc(paths, 'status')).toMatchObject({ processId: child.pid, recovered: false }); + expect((await rpc(paths, 'query-state-request')).snapshot?.runtime.processId).toBe(child.pid); await expect(start(root, entry)).rejects.toThrow('already locked'); const crashed = once(child, 'exit'); child.kill('SIGKILL'); await crashed; - child = await start(root, entry); - expect(await rpc(paths, 'status')).toMatchObject({ processId: child.pid, recovered: true }); + child = await start(root, entry, true); + expect((await rpc(paths, 'query-state-request')).snapshot?.runtime.processId).toBe(child.pid); const exited = once(child, 'exit'); - expect(await rpc(paths, 'shutdown')).toEqual({ stopped: true }); + expect((await rpc(paths, 'shutdown-daemon-request')).accepted).toBe(true); await exited; child = undefined; - await expect(rpc(paths, 'status')).rejects.toThrow('Start it with cadder daemon start'); + await expect(rpc(paths, 'query-state-request')).rejects.toThrow( + 'Start it with cadder daemon start', + ); } finally { if (child?.exitCode === null) { const exited = once(child, 'exit'); diff --git a/test/runtime-windows-ancestors.test.ts b/test/runtime-windows-ancestors.test.ts new file mode 100644 index 0000000..493ca39 --- /dev/null +++ b/test/runtime-windows-ancestors.test.ts @@ -0,0 +1,118 @@ +import type { Stats } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { parse } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { + assertProtected, + createProtectedFile, + prepareRuntime, +} from '../src/platform/runtime-security.ts'; +import { powershell } from '../src/platform/powershell.ts'; + +// Drive and UNC parsing uses Windows path primitives with a modeled filesystem; +// no UNC server or share is contacted by these cases. +vi.mock('node:path', async (original) => { + const path = await original(); + return { ...path, ...path.win32 }; +}); +vi.mock('node:fs/promises', async (original) => ({ + ...(await original()), + lstat: vi.fn(), + realpath: vi.fn(), + mkdir: vi.fn(), + open: vi.fn(), +})); +vi.mock('../src/platform/powershell.ts', async (original) => ({ + ...(await original()), + powershell: vi.fn(), +})); +const owner = { id: 'S-1-5-21-fixture', elevated: false }; +function info(directory = true, link = false): Stats { + return { + isSymbolicLink: () => link, + isDirectory: () => directory, + isFile: () => !directory, + } as Stats; +} +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.mocked(powershell).mockResolvedValue(''); +}); +afterEach(() => vi.restoreAllMocks()); + +it.each([ + 'C:\\', + 'C:\\\\\\', + 'C:/', + 'C:////', + '\\\\server\\share', + '\\\\server\\share\\', + '\\\\server\\share\\\\\\', +])('preserves modeled Windows drive/UNC root validation for %s', async (path) => { + vi.mocked(fs.lstat).mockResolvedValue(info()); + await prepareRuntime(path, owner); + await assertProtected(path, owner, true); + expect(fs.lstat).toHaveBeenLastCalledWith(parse(path).root); + expect(fs.mkdir).not.toHaveBeenCalled(); +}); + +it('trims only modeled Windows real leaf separators while keeping caller ACL spelling', async () => { + const path = 'C:/runtime/real\\//'; + vi.mocked(fs.lstat).mockResolvedValue(info()); + await prepareRuntime(path, owner); + await assertProtected(path, owner, true); + expect(fs.lstat).toHaveBeenLastCalledWith('C:/runtime/real'); + expect(powershell).toHaveBeenLastCalledWith(expect.stringContaining(path)); + expect(fs.mkdir).not.toHaveBeenCalled(); +}); + +it.each(['C:\\', '\\\\server\\share\\'])( + 'checks modeled Windows %s linked/dot-dot and missing/dot-dot ancestors', + async (root) => { + const linked = `${root}link`; + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path === `${root}missing`) throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + return info(true, path === linked); + }); + for (const path of [`${root}link\\..\\safe\\secret`, `${root}missing\\..\\link\\secret`]) { + for (const operation of [ + () => assertProtected(path, owner), + () => prepareRuntime(path, owner), + () => createProtectedFile(path, owner), + ]) + await expect(operation()).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + } + expect(fs.lstat).toHaveBeenCalledWith(root); + expect(fs.lstat).toHaveBeenCalledWith(linked); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); + expect(powershell).not.toHaveBeenCalled(); + }, +); + +it.each(['C:\\', '\\\\server\\share\\'])( + 'retains modeled Windows %s real dot/dot-dot semantics and leaf spelling', + async (root) => { + const leaf = `${root}real\\nested\\.\\..\\v2\\secret`; + vi.mocked(fs.lstat).mockImplementation(async (path) => info(path !== leaf)); + await assertProtected(leaf, owner); + expect(fs.lstat).toHaveBeenCalledWith(root); + expect(fs.lstat).toHaveBeenCalledWith(`${root}real\\v2`); + expect(fs.lstat).toHaveBeenLastCalledWith(leaf); + }, +); + +it('uses native Windows separator rules for mixed-separator input without erasing a link', async () => { + const leaf = 'C:/runtime/missing/../link/secret'; + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path === 'C:\\runtime\\missing') + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + return info(true, path === 'C:\\runtime\\link'); + }); + await expect(assertProtected(leaf, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(fs.lstat).toHaveBeenCalledWith('C:\\runtime\\link'); + expect(fs.lstat).not.toHaveBeenCalledWith(leaf); +}); diff --git a/test/runtime-windows-created-security.test.ts b/test/runtime-windows-created-security.test.ts new file mode 100644 index 0000000..377bf94 --- /dev/null +++ b/test/runtime-windows-created-security.test.ts @@ -0,0 +1,158 @@ +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as shell from '../src/platform/powershell.ts'; +import { + assertProtected, + createProtectedFile, + prepareRuntime, + runtimeOwner, + type RuntimeOwner, +} from '../src/platform/runtime-security.ts'; + +vi.mock('node:fs/promises', async (original) => { + const actual = await original(); + return { ...actual, open: vi.fn(actual.open) }; +}); + +type SecuritySnapshot = { + ownerMatches: boolean; + protected: boolean; + rules: { owner: boolean; fullControl: boolean; writeOwner: boolean; inherited: boolean }[]; +}; + +async function security(path: string, owner: RuntimeOwner): Promise { + return JSON.parse( + await shell.powershell(` + $acl = Get-Acl -LiteralPath ${shell.psLiteral(path)}; + $rules = @(); + foreach ($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) { + $rules += @{ + owner=($rule.IdentityReference.Value -eq ${shell.psLiteral(owner.id)}); + fullControl=(($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -eq [Security.AccessControl.FileSystemRights]::FullControl); + writeOwner=(($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::TakeOwnership) -ne 0); + inherited=$rule.IsInherited; + }; + }; + @{ ownerMatches=($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -eq ${shell.psLiteral(owner.id)}); protected=$acl.AreAccessRulesProtected; rules=@($rules) } | ConvertTo-Json -Depth 4 -Compress + `), + ) as SecuritySnapshot; +} + +// This is an ordinary-token native regression, not an elevated or modeled acceptance claim. +describe.skipIf(process.platform !== 'win32')( + 'ordinary-token Windows paths under Modify-only inheritance', + () => { + let owner: RuntimeOwner; + let parent: string | undefined; + let parentSecurity: SecuritySnapshot; + + beforeAll(async () => { + owner = await runtimeOwner(); + }); + beforeEach(async (context) => { + vi.clearAllMocks(); + if (owner.elevated) context.skip(); + parent = await fs.mkdtemp(join(tmpdir(), 'cadder-created-security-')); + // Mutate only this exclusively created empty fixture, never the system temp parent. + await shell.powershell(` + $sid = New-Object Security.Principal.SecurityIdentifier(${shell.psLiteral(owner.id)}); + $current = Get-Acl -LiteralPath ${shell.psLiteral(parent)}; + if ($current.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'Fixture owner mismatch' }; + $acl = New-Object Security.AccessControl.DirectorySecurity; + $acl.SetAccessRuleProtection($true,$false); + $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'Modify','ContainerInherit,ObjectInherit','None','Allow'))); + [IO.Directory]::SetAccessControl(${shell.psLiteral(parent)},$acl); + `); + parentSecurity = await security(parent, owner); + expect(owner.elevated).toBe(false); + expect(parentSecurity).toEqual({ + ownerMatches: true, + protected: true, + rules: [{ owner: true, fullControl: false, writeOwner: false, inherited: false }], + }); + }); + afterEach(async () => { + vi.restoreAllMocks(); + if (parent !== undefined) await fs.rm(parent, { recursive: true, force: true }); + parent = undefined; + }); + + it.each(['directory', 'file'] as const)( + 'protects a newly created %s without requiring owner reassignment', + { timeout: 30000 }, + async (kind) => { + const path = join(parent!, kind); + if (kind === 'directory') await prepareRuntime(path, owner); + else expect(await createProtectedFile(path, owner, 'fixture')).toBe(true); + await assertProtected(path, owner, kind === 'directory'); + expect(await security(path, owner)).toEqual({ + ownerMatches: true, + protected: true, + rules: [{ owner: true, fullControl: true, writeOwner: true, inherited: false }], + }); + expect(await security(parent!, owner)).toEqual(parentSecurity); + }, + ); + + it( + 'rejects unsafe existing directory and file without repairing them', + { timeout: 30000 }, + async () => { + const directory = join(parent!, 'existing-directory'); + const file = join(parent!, 'existing-file'); + await fs.mkdir(directory); + await fs.writeFile(file, 'retained fixture'); + const beforeDirectory = await security(directory, owner); + const beforeFile = await security(file, owner); + await expect(prepareRuntime(directory, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + await expect(createProtectedFile(file, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await security(directory, owner)).toEqual(beforeDirectory); + expect(await security(file, owner)).toEqual(beforeFile); + expect(await fs.readFile(file, 'utf8')).toBe('retained fixture'); + }, + ); + + it( + 'retains owner-reassignment refusal and cleans only its new file', + { timeout: 30000 }, + async () => { + const file = join(parent!, 'different-owner'); + const inspect = vi.spyOn(shell, 'powershell'); + // An ordinary token cannot assign its new file to the well-known SYSTEM account. + await expect( + createProtectedFile(file, { id: 'S-1-5-18', elevated: false }, 'must-not-be-written'), + ).rejects.toMatchObject({ code: 1 }); + expect(inspect).toHaveBeenCalledOnce(); + expect(inspect.mock.calls[0]![0]).toContain( + 'if ($actualOwner -ne $sid.Value) { $acl.SetOwner($sid) }', + ); + const handle = await vi.mocked(fs.open).mock.results[0]!.value; + expect(handle.fd).toBe(-1); + await expect(fs.lstat(file)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await security(parent!, owner)).toEqual(parentSecurity); + }, + ); + + it( + 'closes and unlinks its exclusive file when reading the new owner fails', + { timeout: 30000 }, + async () => { + const file = join(parent!, 'owner-read-failure'); + const failure = new Error('Simulated owner inspection failure'); + const inspect = vi.spyOn(shell, 'powershell').mockRejectedValueOnce(failure); + await expect(createProtectedFile(file, owner)).rejects.toBe(failure); + expect(inspect).toHaveBeenCalledOnce(); + expect(inspect.mock.calls[0]![0]).toContain('.GetOwner('); + const handle = await vi.mocked(fs.open).mock.results[0]!.value; + expect(handle.fd).toBe(-1); + await expect(fs.lstat(file)).rejects.toMatchObject({ code: 'ENOENT' }); + }, + ); + }, +); diff --git a/test/unix-security-policy.test.ts b/test/unix-security-policy.test.ts index 1cdcbe5..b4fb181 100644 --- a/test/unix-security-policy.test.ts +++ b/test/unix-security-policy.test.ts @@ -37,18 +37,25 @@ it('requires an explicit root owner and rejects invalid owner UIDs', async () => expect(await runtimeOwner()).toEqual({ id: 'uid:1234', uid: 1234, elevated: false }); }); +function leafInfo(value: Stats) { + vi.mocked(lstat).mockImplementation(async (path) => + path === 'fixture' ? value : info(0, 0o777, true), + ); +} + it('fails closed on broad permissions, another owner and symlinks', async () => { unix(1234); const owner = { id: 'uid:1234', uid: 1234, elevated: false }; - vi.mocked(lstat).mockResolvedValue(info(1234, 0o700, true)); + leafInfo(info(1234, 0o700, true)); await assertProtected('fixture', owner, true); - vi.mocked(lstat).mockResolvedValue(info(1234, 0o600)); + leafInfo(info(1234, 0o600)); await assertProtected('fixture', owner); for (const value of [info(1234, 0o644), info(1235, 0o600), info(1234, 0o600, false, true)]) { - vi.mocked(lstat).mockResolvedValue(value); + leafInfo(value); await expect(assertProtected('fixture', owner)).rejects.toMatchObject({ code: 'unsafe-runtime-permissions', }); + expect(lstat).toHaveBeenLastCalledWith('fixture'); } }); From 31b88e1f3417c98e3814f15419bca29422ddea4e Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Thu, 8 Oct 2026 14:43:04 +0200 Subject: [PATCH 20/29] feat: add secure Caddy adapters and owned configuration lifecycle --- .../.openspec.yaml | 2 + .../migrate-caddy-admin-security/design.md | 115 +++ .../migrate-caddy-admin-security/proposal.md | 59 ++ .../migrate-caddy-admin-security/tasks.md | 14 + .../verification.md | 278 ++++++ .../migrate-caddy-composition/.openspec.yaml | 2 + .../migrate-caddy-composition/design.md | 76 ++ .../migrate-caddy-composition/proposal.md | 58 ++ .../migrate-caddy-composition/tasks.md | 14 + .../migrate-caddy-composition/verification.md | 105 +++ .../migrate-caddy-lifecycle/.openspec.yaml | 2 + .../changes/migrate-caddy-lifecycle/design.md | 75 ++ .../migrate-caddy-lifecycle/proposal.md | 50 ++ .../changes/migrate-caddy-lifecycle/tasks.md | 9 + .../migrate-caddy-lifecycle/verification.md | 115 +++ .../migrate-caddy-preparation/.openspec.yaml | 2 + .../migrate-caddy-preparation/design.md | 156 ++++ .../migrate-caddy-preparation/proposal.md | 65 ++ .../migrate-caddy-preparation/tasks.md | 20 + .../migrate-caddy-preparation/verification.md | 142 ++++ .../migrate-caddy-transactions/.openspec.yaml | 2 + .../migrate-caddy-transactions/design.md | 112 +++ .../migrate-caddy-transactions/proposal.md | 67 ++ .../migrate-caddy-transactions/tasks.md | 17 + .../verification.md | 116 +++ src/caddy/adapter.ts | 62 ++ src/caddy/admin-client.ts | 266 ++++++ src/caddy/admin-policy.ts | 424 +++++++++ src/caddy/certificates.ts | 351 ++++++++ src/caddy/composition.ts | 151 ++++ src/caddy/configuration.ts | 152 ++++ src/caddy/domains.ts | 253 ++++++ src/caddy/executable.ts | 71 ++ src/caddy/image.ts | 221 +++++ src/caddy/preparation.ts | 128 +++ src/caddy/resolver.ts | 229 +++++ src/caddy/runtime.ts | 355 ++++++++ src/caddy/validation.ts | 65 ++ src/daemon/configuration-transactions.ts | 324 +++++++ src/platform/owned-command.ts | 438 ++++++++++ test/caddy-admin-client.test.ts | 685 +++++++++++++++ test/caddy-admin-policy.test.ts | 755 ++++++++++++++++ test/caddy-certificates.test.ts | 671 +++++++++++++++ test/caddy-composition.test.ts | 569 +++++++++++++ test/caddy-configuration.test.ts | 157 ++++ test/caddy-owned-process.test.ts | 485 +++++++++++ test/caddy-preparation.test.ts | 527 ++++++++++++ test/caddy-resolution.test.ts | 532 ++++++++++++ test/caddy-runtime.test.ts | 423 +++++++++ test/configuration-transactions.test.ts | 803 ++++++++++++++++++ test/fixtures/caddy-orphan.ts | 28 + test/fixtures/caddy-preload.ts | 40 + test/fixtures/caddy-preparation-preload.ts | 150 ++++ test/fixtures/caddy-runtime-backend.ts | 138 +++ 54 files changed, 11126 insertions(+) create mode 100644 openspec/changes/migrate-caddy-admin-security/.openspec.yaml create mode 100644 openspec/changes/migrate-caddy-admin-security/design.md create mode 100644 openspec/changes/migrate-caddy-admin-security/proposal.md create mode 100644 openspec/changes/migrate-caddy-admin-security/tasks.md create mode 100644 openspec/changes/migrate-caddy-admin-security/verification.md create mode 100644 openspec/changes/migrate-caddy-composition/.openspec.yaml create mode 100644 openspec/changes/migrate-caddy-composition/design.md create mode 100644 openspec/changes/migrate-caddy-composition/proposal.md create mode 100644 openspec/changes/migrate-caddy-composition/tasks.md create mode 100644 openspec/changes/migrate-caddy-composition/verification.md create mode 100644 openspec/changes/migrate-caddy-lifecycle/.openspec.yaml create mode 100644 openspec/changes/migrate-caddy-lifecycle/design.md create mode 100644 openspec/changes/migrate-caddy-lifecycle/proposal.md create mode 100644 openspec/changes/migrate-caddy-lifecycle/tasks.md create mode 100644 openspec/changes/migrate-caddy-lifecycle/verification.md create mode 100644 openspec/changes/migrate-caddy-preparation/.openspec.yaml create mode 100644 openspec/changes/migrate-caddy-preparation/design.md create mode 100644 openspec/changes/migrate-caddy-preparation/proposal.md create mode 100644 openspec/changes/migrate-caddy-preparation/tasks.md create mode 100644 openspec/changes/migrate-caddy-preparation/verification.md create mode 100644 openspec/changes/migrate-caddy-transactions/.openspec.yaml create mode 100644 openspec/changes/migrate-caddy-transactions/design.md create mode 100644 openspec/changes/migrate-caddy-transactions/proposal.md create mode 100644 openspec/changes/migrate-caddy-transactions/tasks.md create mode 100644 openspec/changes/migrate-caddy-transactions/verification.md create mode 100644 src/caddy/adapter.ts create mode 100644 src/caddy/admin-client.ts create mode 100644 src/caddy/admin-policy.ts create mode 100644 src/caddy/certificates.ts create mode 100644 src/caddy/composition.ts create mode 100644 src/caddy/configuration.ts create mode 100644 src/caddy/domains.ts create mode 100644 src/caddy/executable.ts create mode 100644 src/caddy/image.ts create mode 100644 src/caddy/preparation.ts create mode 100644 src/caddy/resolver.ts create mode 100644 src/caddy/runtime.ts create mode 100644 src/caddy/validation.ts create mode 100644 src/daemon/configuration-transactions.ts create mode 100644 src/platform/owned-command.ts create mode 100644 test/caddy-admin-client.test.ts create mode 100644 test/caddy-admin-policy.test.ts create mode 100644 test/caddy-certificates.test.ts create mode 100644 test/caddy-composition.test.ts create mode 100644 test/caddy-configuration.test.ts create mode 100644 test/caddy-owned-process.test.ts create mode 100644 test/caddy-preparation.test.ts create mode 100644 test/caddy-resolution.test.ts create mode 100644 test/caddy-runtime.test.ts create mode 100644 test/configuration-transactions.test.ts create mode 100644 test/fixtures/caddy-orphan.ts create mode 100644 test/fixtures/caddy-preload.ts create mode 100644 test/fixtures/caddy-preparation-preload.ts create mode 100644 test/fixtures/caddy-runtime-backend.ts diff --git a/openspec/changes/migrate-caddy-admin-security/.openspec.yaml b/openspec/changes/migrate-caddy-admin-security/.openspec.yaml new file mode 100644 index 0000000..79e0856 --- /dev/null +++ b/openspec/changes/migrate-caddy-admin-security/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-06 diff --git a/openspec/changes/migrate-caddy-admin-security/design.md b/openspec/changes/migrate-caddy-admin-security/design.md new file mode 100644 index 0000000..b589966 --- /dev/null +++ b/openspec/changes/migrate-caddy-admin-security/design.md @@ -0,0 +1,115 @@ +## Native Caddy boundary + +Pinned Caddy 2.11.4 admin identity accepts identifiers and issuer modules, not +static server certificate/key files. Its standard internal issuer can use an +imported root/intermediate CA. Remote administration requires client certificates; +access_control.public_keys contains base64 DER certificates, not raw SPKI. +Configure the authorized client leaf, not the issuing CA, to avoid authorizing +other clients issued by that CA. + +Admin disabled applies to the local plaintext endpoint in the pinned source; +remote administration has a separate startup path. Set disabled:true, explicit +loopback remote.listen, private identity identifiers and the internal issuer. +Never rely on remote's default :2021 or the default ACME identity issuers. These +source findings still require native startup and denial evidence. + +Identity uses Caddy DefaultStorage rather than arbitrary configured storage. +Explicit child environment must direct data/config/autosave paths into protected +runtime storage on each supported OS. Set install_trust:false for every CA that +can be provisioned, including local. No host trust-store or host Caddy operation +is permitted during development or acceptance. + +## Material ownership + +Use installed @peculiar/x509 2.1.0 with Node WebCrypto, not handwritten ASN.1 or +an OpenSSL/native dependency. Generate separate ECDSA P-256 root, intermediate +and client keys. Root/intermediate have CA/key-signing constraints; the client +is a non-CA digital-signature identity with clientAuth EKU. Caddy obtains and +renews its server leaf from the intermediate. Do not mint unused production +server credentials merely to satisfy a checklist. + +One small Caddy leaf module creates or loads a fixed complete PEM bundle in an +explicit directory for an explicit RuntimeOwner. The caller must already own +the runtime lifetime lock; this module adds no lock/lease/registry framework. +Reuse prepareRuntime, createProtectedFile and assertProtected. Never repair +permissions or replace existing credentials implicitly. + +Read existing files with bounded sizes; validate the complete bundle, validity +intervals, CA constraints, signing/client usages, issuer/signature chain and key +pair correspondence before returning usable material. Refuse partial, malformed, +expired, wrong-owner/ACL or linked material. Creation failure cleans only files +exclusively created by this call; it cannot remove pre-existing files. Required +credential bytes may be returned in memory to the internal TLS adapter, but +private keys and arbitrary parser errors must not enter diagnostics or logs. + +Restart loading reuses the same complete identity. Expired or incomplete material +is a typed failure, not permission to rotate a CA or overwrite client credentials. +Server leaf renewal is Caddy-owned; CA/client rotation is not an automatic new +product workflow in this slice. + +## Generated runtime files + +The user approved accepting safe inherited permissions rather than rewriting +native files. Keep assertProtected strict for Cadder-created runtime directories, +CA/client PEM files, IPC material and primary databases. On Windows these paths +still require the expected owner and an explicitly protected owner-only DACL. + +Caddy-generated storage files and SQLite journals may inherit owner-only access +from a verified protected runtime directory. Validate only the requested path +and its directory chain within that root: exact expected owner, owner-only access, +correct file/directory type and no links or junctions. On Windows every effective +access rule must grant only the runtime user, with usable owner access; the DACL +protection bit is not required on these descendants. Unix ownership and 0700/0600 +requirements remain unchanged. The root itself must pass strict validation. + +A different owner is unsafe even when the DACL lists only the runtime user: +Windows owners can change the DACL independently of its access entries. Existing +unsafe descendants are refused, not adopted, repaired or silently regenerated. +This is a read-only path check, not a tree monitor, ACL sealing pass or new storage +framework. Check a known SQLite journal before SQLite can consume it and after +creation while runtime exclusion remains held. Caddy startup integration uses +this boundary; full owned-process lifecycle remains roadmap 3.8. + +## Assembly and HTTPS increment + +Consume the existing guarded route plan rather than project admin/PKI fields. +Assemble a complete secure CaddyConfig before validation; no admin-policy merge +with arbitrary project settings. Preserve existing loopback route/host guards. +The HTTPS client uses explicit trusted CA, authorized client key/certificate and +normal Node server-name verification. No rejectUnauthorized:false, custom identity +bypass, HTTP retry, ambient trust substitution, redirects or arbitrary admin URLs. + +Independent active-state readback must compare the observed effective config, +not a submitted candidate or cached receipt. Define and test deterministic JSON +normalization at this backend boundary before exposing comparable hashes to +ConfigurationTransactions; unknown/unavailable observation is failure, never +idle/null. Idle lifecycle integration remains 3.8, not an invented success stub. +Apply transport failures without an explicit no-transition guarantee stay +ambiguous. Existing transaction fencing owns recovery ordering. + +## Verification and boundaries + +Deliver material generation/loading and fault tests before dependent admin +assembly/client integration. Then test TLS denial with disposable fixtures and +real pinned Caddy in isolated private staging, using existing owned command cleanup. +Native proof must preserve all host state and include generated server-key +ownership, not just Node-created PEM files. Do not contact unrelated localhost:2019 +or infer plaintext absence from an arbitrary host port probe. + +The initial native feasibility attempt stopped before script creation because +Arcantry required external-file confirmation unavailable in a headless child. +No Caddy process, certificate or listener was created. That denied external write +must not be retried through another mechanism. The user subsequently authorized +isolated private repository-local staging for temporary probes and permits Windows +Sandbox testing now. Shared source, validation and packaging must not depend on +that private staging. A new tool or permission denial still requires a stop and +supported approval, not an execution-mode workaround. Native evidence remains +open until actual results support it. + +Parent integrates independent MiMo reviews and runs proportional focused/full +Node checks. Existing implementation-schema missing-delta validation failures +remain explicit; no duplicate requirements or validator changes are added. +No production mocks, source-side generated JS, staging/commit/release or host +privilege/account/trust changes are authorized by this slice. Disposable Sandbox +execution is now user-authorized; partial-product results do not satisfy the final +completed-product privilege or G7 acceptance gate. diff --git a/openspec/changes/migrate-caddy-admin-security/proposal.md b/openspec/changes/migrate-caddy-admin-security/proposal.md new file mode 100644 index 0000000..2e18711 --- /dev/null +++ b/openspec/changes/migrate-caddy-admin-security/proposal.md @@ -0,0 +1,59 @@ +## Why + +Preserve the project Caddyfile -> managed shim -> daemon -> operator journey. +The next capability gives the existing transaction queue an authenticated Caddy +administration channel without restoring plaintext localhost administration. +Caddy remains the required runtime engine; Cadder remains TypeScript-only. + +## Requirement IDs + +- CADDY-006: Protected loopback mutual TLS, validated server identity, authorized + client material, no system trust installation and no project policy override. + +CADDY-003 supplies independent active-state verification and ambiguity fencing; +CADDY-002/004 supply owned process and local route boundaries. This slice does +not close their whole-product acceptance. + +## Scope + +Implement roadmap 3.6–3.7 incrementally: protected CA/client material first, +then secure configuration assembly and bounded authenticated administration. +Reuse existing runtime-security, CaddyConfig, preparation, composition and owned +command boundaries. No dependency additions or shared RPC/port changes. + +The approved engineering correction assigns root/intermediate CA and authorized +client generation to Node WebCrypto/@peculiar/x509. Caddy's standard internal +issuer obtains and renews the admin server certificate. Its default identity +storage must be redirected into the protected runtime boundary, not injected +with a separately generated leaf. + +## Non-goals + +No TLS proxy, custom issuer/plugin, CertMagic storage-layout injection, plaintext +fallback, OS trust installation, native runtime addon, CA installer or dependency +change. No daemon handlers, application SQLite worker, CLI/TUI, distribution, +full owned-server lifecycle (3.8), final completed-product privilege acceptance +or publication. The user permits disposable Sandbox testing of this increment; +it does not close G7. Test-only fake TLS servers are not production adapters. + +## Success criteria + +- Complete valid CA/client material is created or validated inside an explicitly + supplied owner-protected directory. Unsafe, partial, malformed, mismatched or + expired existing material is refused, not repaired or silently regenerated. +- Secure assembly owns admin/PKI policy and isolates Caddy identity/cache storage; + project input cannot widen trust, listeners or plaintext administration. +- Node HTTPS validates the CA chain and server identity and presents only the + authorized client certificate. Requests and responses are bounded; failures + never trigger HTTP fallback or cached-receipt observation. +- Native Caddy acceptance proves authorized access, missing/foreign-client + refusal, wrong server trust/identity refusal and protected storage. Source + inspection and fixture success alone do not close task 3.7. +- Focused/full Node checks cover changed boundaries above 85% own lines, with + independent MiMo review and explicit native/platform gaps. + +## Impact + +Additive Caddy leaf sources and dedicated tests; this focused planning/evidence +and roadmap progress only when supported by executed checks. Main requirements +are unchanged. Rust, earlier dirty work and final acceptance gates remain intact. diff --git a/openspec/changes/migrate-caddy-admin-security/tasks.md b/openspec/changes/migrate-caddy-admin-security/tasks.md new file mode 100644 index 0000000..8b39fbd --- /dev/null +++ b/openspec/changes/migrate-caddy-admin-security/tasks.md @@ -0,0 +1,14 @@ +## 1. Protected material (roadmap 3.6) + +- [x] 1.1 Generate/load a fixed protected root/intermediate CA and authorized client bundle with installed X.509/WebCrypto; validate complete material and return bounded typed failures without secret leakage. Parent material-increment evidence: 70 focused tests, actual Windows creation/reuse/ACL checks, 100% module coverage and the 550-test/97.27% full Node gate; see verification.md. +- [x] 1.2 Test valid generation/reuse, certificate/key constraints, expiry, malformed/partial/mismatched material, unsafe ownership/ACL/links and creation-failure cleanup. Run focused checks and independent review. MiMo Pro accepted the parent corrections; native Linux/macOS and real Caddy server-material evidence remain open in verification.md. + +## 2. Secure native administration (roadmap 3.7) + +- [x] 2.1 Assemble native loopback admin/internal-issuer/PKI policy without plaintext fallback or project override; isolate Caddy default identity/cache/config storage inside protected runtime paths. MiMo Pro review integrated; 92 policy tests passed. Native behavior remains task 2.3. +- [x] 2.2 Implement bounded Node HTTPS administration with authorized client and verified server identity; define independent active-config normalization/readback and uncertain apply failures for the existing queue. MiMo Flash review integrated; 61 client tests and the 703-test parent Node gate passed. No owned-process idle/lifecycle or native Caddy acceptance is claimed. +- [x] 2.3 Prove native pinned Caddy startup, authorized access/readback and no/foreign client, wrong-root and wrong-identity denial with owned teardown and owner-only generated server material. Keep Cadder-created roots/PEMs/databases strict; validate Caddy/SQLite descendants and their in-root directory chain without requiring an explicit Windows DACL protection bit or repairing permissions. Reject wrong owners, foreign access, links and root escapes; retain unsupported-platform and full-lifecycle gaps. Completed for ordinary-token Windows with pinned Caddy 2.11.4: full-policy validation, admin-only execution, native issuance, authorization/readback and owned cleanup. Unknown-SNI handshake denial and a separate stock offline peer-leaf name check are distinct evidence, not a mismatched-leaf network receipt. MiMo Pro accepted the inherited-storage boundary; see verification.md for remaining platform/lifecycle/privilege gates. + +## 3. Integration evidence + +- [x] 3.1 Integrate MiMo findings, simplification/fresh-eyes review and focused/full Node checks; validate planning and record only executed evidence. Keep roadmap/native/archive/final Sandbox gates open where proof is missing. MiMo Pro found no blockers in the final increment; parent nub run check passed with 727 tests, 2 existing Unix-native skips and 97.46% own-source line coverage. Planning validation was executed: the existing implementation-only missing-delta failure remains an explicit archive blocker, not a passed gate. Roadmap 3.6/3.7 and final completed-product acceptance remain open. diff --git a/openspec/changes/migrate-caddy-admin-security/verification.md b/openspec/changes/migrate-caddy-admin-security/verification.md new file mode 100644 index 0000000..3a23043 --- /dev/null +++ b/openspec/changes/migrate-caddy-admin-security/verification.md @@ -0,0 +1,278 @@ +# Verification + +## Approved issuance split + +The user approved correcting roadmap 3.6 and the reset design: Node +WebCrypto/X.509 creates protected root/intermediate CA and client material; +Caddy's standard internal issuer obtains and renews the admin server leaf. +CADDY-006 and final acceptance requirements are unchanged. + +Pinned Caddy 2.11.4 source inspection and independent MiMo Flash opinion support +this standard configuration path. Current official documentation was indexed +through grounded-docs without labelling it an exact-version snapshot. + +## Native feasibility status + +The initial delegated attempt stopped before execution. Its external probe +script write required Arcantry confirmation unavailable in the headless child. +No bypass was attempted. No Caddy process, certificates or listener were created; +only public source references remained in disposable temporary storage. + +Parent independently checked the native executable version (2.11.4), SHA-256 +and relevant module presence. This prerequisite inspection is not startup, +permission, TLS denial, reload or teardown proof. Roadmap 3.6–3.7 remain open. + +## Protected material increment + +Delivered src/caddy/certificates.ts and test/caddy-certificates.test.ts. +loadOrCreateAdminMaterial(directory, owner) creates or reuses six fixed protected +root/intermediate/client PEM files, validates complete profiles, signatures, +validity and key matches, and returns internal TLS credentials plus client leaf +DER for Caddy access control. The caller retains runtime-lock ownership. + +Parent checks after review fixes: + +- Focused suite: 70 tests passed, including actual Windows owned-temp creation, + reuse and owner-only ACL checks for all six files. Native Linux/macOS execution + and generated Caddy server-material protection remain unproved. +- nub run check: typecheck, ESLint, Prettier and coverage passed; 29 test files, + 550 passed and 2 existing Unix-native tests skipped. +- Own-source lines: 1,283/1,319 (97.27%). Certificates module: 128/128 lines, + 92/92 branches and 8/8 functions (100%). Coverage output stayed in runner temp. +- Active LSP probe had no TypeScript errors; six auxiliary style findings and + push-only clean-check uncertainty remain. This is not a universally clean + diagnostics claim. +- Strict main specs: 15 passed; reset roadmap and implementation schema valid; + git diff --check passed and nothing was staged. +- mise run openspec-check: 17 passed / 5 failed. Existing implementation-schema + missing-delta failures now include this focused slice; no duplicate requirements + or validation-tool changes were added to manufacture archive acceptance. + +MiMo Pro's initial material review found no P0/P1 and three P2 notes. Parent +required critical client EKU to match the generated fixed profile, added explicit +PEM-header and available-O_NOFOLLOW regression assertions, and clarified that +I/O/cleanup failure can leave partial files. The shared helper's AggregateError +has no cleanup-phase discriminator: the code does not guess a precise cleanup +classification or claim ownership of its residual path. MiMo Pro's targeted +follow-up accepted all three dispositions with no new defect in their blast +radius. Task 1.2 is complete within its tested increment. Roadmap 3.6/3.7 and +complete-slice acceptance remain open; these checks prove the CA/client increment, +not Caddy mTLS or server renewal. + +## Secure policy increment + +Delivered src/caddy/admin-policy.ts and test/caddy-admin-policy.test.ts. +prepareSecureAdminPolicy assembles the guarded route plan with explicit native +loopback mTLS administration, imported admin CA paths and internal web issuance. +It prepares protected runtime directories and confines child data/config/home/temp +environment paths. Wildcard subjects retain the existing composition contract. +normalizeCaddyConfig deterministically orders object keys while preserving arrays +and opaque JSON data, with complete-body, depth and traversal bounds. + +- Worker focused checks: 91 policy tests; 224 combined policy/material/composition + tests, typecheck, scoped lint and formatting passed. +- Parent nub run check after integration: typecheck, ESLint, Prettier and coverage + passed; 30 files, 641 passed / 2 existing Unix-native skips. +- Own-source lines: 1,421/1,457 (97.52%). Admin-policy module: 100% lines/functions + and 99.23% branches. Outputs remained in runner temporary storage. +- Actual owned-temp directory/material preparation and reuse passed on Windows; + Linux/macOS policy checks are modeled, not native acceptance. + +MiMo Pro accepted the policy with one test-gap note. Parent added a regression +for non-empty TLS subjects with fully pruned project routes, without requiring +subjects to equal the outer-guard union. The optional permission-error kind note +is deliberately unchanged: failure remains bounded and fail-closed, with no new +classification requirement accepted. Task 2.1 is complete as an assembly increment. +Real Caddy startup/readback/denial, generated descendant-file protection and server +renewal remain open. No Caddy process was launched by these tests; the parent +roadmap is not marked complete merely because assembly tests pass. + +## HTTPS client increment + +Delivered src/caddy/admin-client.ts and test/caddy-admin-client.test.ts. +CaddyAdminClient targets only the prepared loopback endpoint with explicit CA, +authorized client credentials and normal localhost certificate verification. +It refuses noncanonical candidates or changes to the pinned admin/PKI envelope, +bounds headers and complete UTF-8 response bodies, and aborts owned requests at an +absolute deadline. Completed HTTP 400 load rejection is distinct from uncertain +transport or other-status failures. GET /config/ independently normalizes observed +configuration; unavailable readback never becomes idle or a cached receipt. +Owned-process idle/lifecycle integration remains roadmap 3.8. + +- Worker: 60 focused client tests and 216 neighboring regressions passed; + typecheck, scoped lint and formatting passed. +- Parent nub run check: typecheck, ESLint, Prettier and coverage passed; 31 files, + 701 tests passed / 2 existing Unix-native skips. Own lines: 1,511/1,551 (97.42%). + Client coverage: 95.74% lines, 100% functions and 94.59% branches. +- Actual disposable Node HTTPS fixtures exercised CA/client/server-name/expiry + denial, complete independent readback, bounds, deadlines, disconnects and queue + rollback/fencing. They are not evidence of native Caddy authorization or renewal. +- git diff --check passed; nothing staged. Active LSP probes reported auxiliary + heuristic findings, not TypeScript compiler errors, and silent-on-clean coverage + remained inconclusive. JSON.parse findings in both leaf helpers have catch + boundaries at their callers; no blanket clean-diagnostics claim is made. +- The worker's direct external report write was refused by Arcantry. It stopped + that attempt and returned its final report for workflow-managed persistence; + no alternate write mechanism or permission override was used. + +MiMo Flash accepted the client with one explicitness note: idle/null refusal +previously depended on catching property access failure. Parent added an explicit +pre-network null guard and a dedicated no-request regression, preserving the +existing rejection outcome. No lifecycle stub or queue contract change was added. + +Final checks after both local review corrections: + +- Focused policy/client suites: 153 passed (92 policy, 61 client). +- nub run check: typecheck, ESLint, Prettier and coverage passed; 31 files, + 703 tests passed / 2 existing Unix-native skips; 1,511/1,551 own lines (97.42%). + Client branches: 94.73%; other reported module percentages remain unchanged. +- Fresh-eyes review checked the null guard's local blast radius and the composed + pruned-route fixture. Simplification review retained the single transport and + reused normalization boundary rather than introducing another validator/queue. +- The two auxiliary unchecked-JSON.parse findings were marked false positives: + private throwing validators are called inside their owning catch boundaries. + No inline suppression or broader diagnostics-cleanliness claim was added. +- git diff --check passed; nothing staged. +- Strict main specifications: 15 passed; reset-cadder-architecture valid; + OpenSpec doctor passed. mise run openspec-check still reported 17 passed / + 5 failed. Focused validation confirmed the unchanged missing-delta requirement + for this implementation-only change; no duplicate requirements were added. + +Tasks 2.1/2.2 are complete within their assembly/client scope. Native task 2.3, +complete-slice acceptance, roadmap 3.6/3.7, generated server-key protection and +final-product Sandbox remain open. No real Caddy process was launched by this +increment. The user subsequently authorized temporary probes in isolated private +repository-local staging and allows Sandbox testing now. The private directory is +excluded locally; the remote default branch does not track it, and existing test, +typecheck and formatting input globs do not consume it. No shared tool or product +may acquire a dependency on that staging. At that increment native execution and Sandbox results were still pending; +partial-product tests do not close final privilege/G7 acceptance. + +## Ordinary-owner correction and native administration evidence + +The user authorized isolated private probes and promotion of this sanitized +summary. No private paths, account identifiers, credential bytes or private +staging dependencies are included in shared sources. + +The Windows protection helper previously requested owner reassignment even when +an exclusively new path already had the expected owner. An ordinary-token +reproducer distinguished that write from DACL-only protection. protectCreated +now reads the actual owner and requests reassignment only when it differs; +assertProtected remains strict. Five native ordinary-token regressions cover +new directories/files under Modify-only inheritance, unsafe existing paths, +owner-read failure cleanup and refusal to assign a new file to another account. +MiMo Pro accepted the fix and its completed differing-owner coverage. + +Parent nub run check passed after that correction: 32 test files, 708 passed, +2 existing Unix-native skips, 1,511/1,551 own lines (97.42%), plus typecheck, +ESLint and Prettier. This predates the newly approved descendant-validation +increment and is not its final gate. + +Pinned stock Caddy 2.11.4 ran once on an ordinary Windows token using existing +product material/policy/client/image/owned-command boundaries. Unmodified full +factory configuration passed native validation without web listener binding. +The running admin-only candidate retained the exact admin/PKI policy, with +plaintext disabled, explicit loopback administration and no trust installation. +Authorized GET, complete POST/load and independent actual-config hash readback +passed. Missing clients, a distinct client issued by the same intermediate and +an incorrect server root were refused. Online unknown-SNI denial was EPROTO; +a separate stock offline name check against the normally verified peer leaf +returned ERR_TLS_CERT_ALTNAME_INVALID. This is not a mismatched-leaf network +receipt. Caddy issued its server leaf from the imported product intermediate. +Authorized stop returned 200; the owned child exited 0, streams settled and the +exclusively acquired private runtime was removed. MiMo Pro verified these +phase claims against the isolated evidence. + +The prior blanket strict audit failed honestly: 39 paths had the expected owner +and no foreign DACL grants or links, but 17 inherited owner-only DACLs lacked an +explicit protection bit. Fifteen belonged to native Caddy storage; two were a +probe-created intermediate directory and a SQLite journal, not Caddy files. +Product root and six PEM controls passed strict checks. An earlier elevated +Sandbox run also contained differently owned generated files; those remain +unsafe and are not reclassified by the ordinary-user result. + +The user approved the simpler acceptance boundary in design.md: strict Cadder +roots/credentials/databases; read-only owner/access/link validation for native +Caddy storage and SQLite journals beneath those protected roots, allowing safe +Windows inheritance without repairing ACLs. Implementation and a new native +proof were pending at that decision; their subsequent results follow below. +Earlier failed evidence remains retained rather than rewritten as a pass. + +## Inherited native storage increment + +Delivered assertRuntimeDescendant in src/platform/runtime-security.ts and known +DELETE-journal checks in src/daemon/runtime-lock.ts, with independent automated +coverage in test/runtime-descendants.test.ts and test/runtime-lock-faults.test.ts. +The validator inspects only the requested descendant and its in-root directory +chain beneath a strictly verified root. It refuses lexical escapes, links, +wrong kinds/owners and unsafe access without changing permissions. Cadder-created +roots, PEMs and primary databases retain strict validation. Unix UID and mode +requirements are unchanged. + +The journal is checked before SQLite opens the database and again while runtime +exclusion is held, before metadata publication. Only absence of that exact +journal leaf is tolerated; missing roots/ancestors and inspection failures remain +fatal. Unsafe existing journals are neither consumed nor removed. There is no +new scan, monitor, repair pass or storage framework. + +A fixture-only ACL mistake initially prevented cleanup of a task-created test +directory. After explicit approval, one DACL-only restoration on that exact +owned fixture permitted cleanup; the fixture inheritance was corrected. No +production, parent-directory or host-policy repair was performed. + +### Current native evidence + +A new ordinary-token Windows run used pinned stock Caddy 2.11.4 and the existing +product material, policy, HTTPS client and owned-command boundaries. The full +unmodified policy passed native validation; actual execution remained admin-only, +not a full web-runtime acceptance test. Authorized GET, complete POST/load and +independent normalized-config readback passed. Missing clients, a different +client from the same intermediate, a wrong server root and unknown SNI were +refused. Unknown-SNI EPROTO and the separate stock offline wrong-name check of +the normally verified peer leaf retain their distinct scope. + +Caddy issued the localhost server leaf from the product intermediate. A finite +probe audit found 39 paths with the expected owner, no foreign access grants and +no links: 18 strict product paths, 5 strict probe-scaffold paths and 16 safely +inherited native paths (15 Caddy paths and one SQLite journal). Product validation +failed on none. The generated server key passed descendant validation while +legacy assertProtected still refused its inherited DACL, confirming that strict +validation was not relaxed. No ACLs were repaired. + +Authorized stop returned 200; the owned child exited 0, its Job and streams +settled, and the exclusively acquired runtime was removed after ownership +revalidation. No trust installation, host elevation or Sandbox run was needed. +The earlier elevated generated-owner mismatch remains unsafe and unresolved. + +### Review and final checks + +MiMo Pro verified source, tests and the named native evidence independently and +returned OK with notes, with no blockers. Simplification/fresh-eyes review kept +one read-only validator and one known-journal wrapper. Two minor observations +remain explicit: the real-journal test pins the current SQLite creation timing; +the pre-existing strict check's treatment of InheritOnly FullControl was outside +this approved delta. Product-created strict ACLs use propagation-None rules. +Neither observation was converted into an unsolicited hardening task. + +Parent nub run check passed after the final source changes: + +- TypeScript typecheck, ESLint and Prettier passed. +- 33 test files passed; 727 tests passed and 2 existing Unix-native tests skipped. +- Own-source lines: 1,535/1,575 (97.46%); branches: 1,104/1,207 (91.46%). +- Runtime security and runtime lock: 100% line/function coverage. Coverage + artifacts remained in runner temporary storage. +- git diff --check passed; nothing was staged. Active diagnostics retained + auxiliary heuristic findings and push-only silent-check uncertainty; the + passing TypeScript compiler is not a universal LSP-cleanliness claim. +- OpenSpec doctor and all 15 main specifications passed; the maintained planning + check reported 17 passed / 5 failed. Focused strict validation again refused + this implementation-only change because it has no delta. That archive blocker + remains open; no duplicate requirements or validator changes were introduced. + +Tasks 2.3/3.1 are complete within this approved ordinary-Windows/admin-only +increment, including recording the blocked planning gate. This does not close +roadmap 3.6/3.7, complete migration or archive acceptance. Full web execution, +owned lifecycle 3.8, renewal/restart persistence, native Linux/macOS, elevated +owner handling, final completed-product privilege/Sandbox/G7 and distribution +acceptance remain open. Shared sources depend only on product code and ordinary +test fixtures, not on private probe scripts or results. diff --git a/openspec/changes/migrate-caddy-composition/.openspec.yaml b/openspec/changes/migrate-caddy-composition/.openspec.yaml new file mode 100644 index 0000000..79e0856 --- /dev/null +++ b/openspec/changes/migrate-caddy-composition/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-06 diff --git a/openspec/changes/migrate-caddy-composition/design.md b/openspec/changes/migrate-caddy-composition/design.md new file mode 100644 index 0000000..24ed525 --- /dev/null +++ b/openspec/changes/migrate-caddy-composition/design.md @@ -0,0 +1,76 @@ +## Evidence and dependency order + +The retained Rust behavior is in crates/cadder-daemon/src/caddy/configuration.rs: +active_domains, detect_conflicts, extract_http_routes, filter_route_hosts, +guard_registration_routes, compose_config and namespace_config_ids. Its tests +cover disabled domains, conflicts, loopback listeners and nested hostless siblings. +crates/cadder-ipc/src/state.rs defines enabled activation states and canonical +trim/trailing-dot/IDNA/ASCII-case behavior. Existing Node DTOs and preparation +carriers are the input contracts; this slice does not reopen those contracts. + +## Boundary and data flow + +Complete adapted JSON + registration activation/domain intent -> extracted HTTP +route trees -> canonical ownership check -> domain-filtered, outer-guarded trees +-> typed route plan containing owned HTTP/HTTPS servers and TLS subjects. + +Use a small pure API in new src/caddy modules. Parse only complete bounded +adapted carriers using existing preparation integrity checks. Preserve arbitrary +route/handler JSON; do not parse Caddyfile syntax or reinterpret handler settings. +Only route host matchers participate in domain extraction/filtering. Unrelated +handler properties named host are not registration ownership declarations. + +Registration activation and domain activation are both respected. Canonicalize +names at the business boundary rather than trusting caller case/spelling. +Node domainToASCII normally parses URL hosts, unlike Rust's literal IDNA input. +Use a fixed nonnumeric suffix during whole-domain mapping to prevent numeric IP +reinterpretation; keep the original trimmed domain with ASCII-only lowercasing +on failure or URL-delimiter input. Strip ASCII trailing dots before mapping. +Fixtures cover the observed differences, not exhaustive Node/Rust IDNA parity. +Deduplicate domains within one registration; competing registration IDs produce +bounded diagnostics with source paths and no successful plan. Reject ambiguous +duplicate registration input rather than making iteration order authoritative. +Keep route order within a project and a documented deterministic project order. + +## Route guards and failure behavior + +Preserve retained branches and hostless siblings behind an outer host matcher +containing only that registration's active canonical hosts. An emptied host +matcher must not become a catch-all. If pruning removes every subroute handler +from a retained terminal parent, preserve its matchers, empty handle array and +terminal position; deleting that parent would let a later route handle the request. +Nonterminal empty parents may be dropped. Proxy-shaped data inside an opaque +handler payload does not contribute upstream metadata. +A disabled-only project or a registration with no enabled hosts contributes no +routes. Never fabricate placeholder routes +when prepared input is missing or invalid. Return failure without mutating inputs +or a previously produced plan; committing/rejecting runtime changes belongs to +roadmap 3.4-3.5. + +Retain exact dual-stack loopback ports 80/443 and HTTP-copy ID namespacing from +the Rust behavior. Ignore project server listeners and root admin/TLS policy; +only extracted routes enter the plan. The plan is explicitly not CaddyConfig: +it supplies route servers and TLS subjects for later daemon-owned secure config +assembly. Do not emit localhost:2019, an admin fallback or a TLS-policy stub. + +## Verification + +Pure JSON fixtures exercise multiple projects, all activation states, IDN/case/ +trailing dots, duplicate ownership, malformed/hash-mismatched prepared input, +nested mixed enabled/disabled branches, hostless siblings, arbitrary handler +payloads, stable ordering, ID namespacing and input immutability. No installed +Caddy, network listener, process, filesystem permissions or Sandbox is needed. + +MiMo Flash independently checks retained behavior and boundary assumptions; +MiMo Pro reviews the actual implementation and test evidence. The parent resolves +concrete findings, performs simplification/fresh-eyes review, runs the full Node +gate and checks main specs/roadmap/schema plus the diff. Known global archive and +native/release blockers remain explicit, not claimed as passed. + +## Alternatives and migration + +Do not copy the Rust plaintext admin config or mock/placeholder branches into +the new production path. Do not build a generic JSON rule engine, new parser or +queue to deliver this slice. Additive pure modules leave the Rust baseline and +existing Node runtime/preparation unchanged and independently reversible. Later +queue and protected Admin API work consumes the plan before any real apply. diff --git a/openspec/changes/migrate-caddy-composition/proposal.md b/openspec/changes/migrate-caddy-composition/proposal.md new file mode 100644 index 0000000..ccd3e36 --- /dev/null +++ b/openspec/changes/migrate-caddy-composition/proposal.md @@ -0,0 +1,58 @@ +## Why + +Preserve the released project Caddyfile -> managed shim -> daemon -> operator +journey. The next capability combines already adapted project routes into a +local, host-guarded route plan and rejects competing active domain owners. +Caddy remains the mandatory separately installed server; this migrates Cadder's +integration, not Caddy itself. + +## Requirement IDs + +- `CADDY-002`: Compose active non-conflicting project routes (composition only). +- `CADDY-004`: Preserve exact active-host outer guards and IPv4/IPv6 loopback listeners. +- `REG-002`: Canonical domain ownership across active entrypoints. +- `REG-004`: Preserve existing adapted Caddyfile trees and activation behavior. + +## Scope + +Implement roadmap 3.3 as pure Node Caddy composition/domain functions and focused +tests. Consume the existing registration DTO and complete adapted JSON carriers. +Produce a typed route plan with the retained HTTP/HTTPS server shapes and TLS +subjects, or diagnostics without a usable plan. No process or active-state changes. + +The completed protocol and preparation slices remain unarchived because of the +known custom implementation-schema validation blocker. This slice consumes their +leaf contracts without modifying them. Dependency order is explicit; only one +writer uses the checkout, and parent-owned planning/evidence is separate from +worker-owned new composition source/tests. + +## Non-goals + +No placeholder production routes, Caddyfile parser, adapter subprocess changes, +mutation queue, apply/rollback, storage, registration sessions, Admin API, +certificate generation, server startup, CLI/TUI, dependencies, Rust changes, +packaging, Sandbox or host security changes. A route plan is not an executable +full Caddy configuration and must not be passed to the validation/apply port as +one. Protected administration remains roadmap 3.6-3.7. + +## Success criteria + +- Canonical case, trailing dots and IDN spelling identify the same domain owner. +- Only registered/activating/active registrations and domains contribute routes. +- Conflicts reject the complete plan; inactive entries cannot cause a conflict. +- Each complete retained registration route tree remains behind its own active + canonical hosts, including hostless siblings; disabled-only branches cannot + become hostless catch-alls. +- Project listeners/admin/TLS settings cannot widen the composed route boundary; + owned listeners are exactly 127.0.0.1/[::1] on ports 80 and 443. +- Input JSON/registrations remain unchanged; missing or invalid prepared input + does not silently become a placeholder route. +- Focused and full relevant Node checks pass with >=85% own-code line coverage; + independent MiMo review findings are reconciled. + +## Impact + +New Node Caddy composition/domain modules and dedicated tests; focused OpenSpec +plan/evidence and the existing roadmap 3.3 completion row only. No new product +requirements, RPC methods or public user commands. G1/G2/G3 and final native, +real-Caddy, distribution and Sandbox acceptance remain open. diff --git a/openspec/changes/migrate-caddy-composition/tasks.md b/openspec/changes/migrate-caddy-composition/tasks.md new file mode 100644 index 0000000..e517c58 --- /dev/null +++ b/openspec/changes/migrate-caddy-composition/tasks.md @@ -0,0 +1,14 @@ +## 1. Canonical domains and ownership + +- [x] 1.1 `[REG-002, REG-004]` Implement canonical domain extraction and enabled-state ownership checks using existing registration/carrier contracts (verification: case/IDN/trailing-dot, inactive entries, duplicates, deterministic diagnostics and integrity tests). + +## 2. Guarded multi-project composition + +- [x] 2.1 `[CADDY-002, CADDY-004, REG-004]` Compose retained trees into a pure typed route plan with exact loopback listeners and registration host guards (verification: multiple projects, disabled-only branches, hostless siblings, preserved handler JSON, HTTP IDs, ordering and immutable inputs). + +## 3. Evidence and closeout + +- [x] 3.1 `[CADDY-002, CADDY-004, REG-002, REG-004]` Reconcile MiMo review, focused/full Node checks and requirement-to-evidence coverage (verification: >=85% own-code lines, strict main/roadmap checks, schema/diff inspection and explicit remaining acceptance blockers). + +Checked tasks close roadmap 3.3 only. Queue/apply, protected administration, +server lifecycle, full requirement acceptance and G1/G2/G3 remain open. diff --git a/openspec/changes/migrate-caddy-composition/verification.md b/openspec/changes/migrate-caddy-composition/verification.md new file mode 100644 index 0000000..581c677 --- /dev/null +++ b/openspec/changes/migrate-caddy-composition/verification.md @@ -0,0 +1,105 @@ +## Summary + +Roadmap 3.3 and the focused implementation checks pass. Existing adapted project +JSON and registration/domain activation now produce a deterministic guarded route +plan, or bounded diagnostics with no usable plan. The new modules are +src/caddy/domains.ts and src/caddy/composition.ts; tests are in +test/caddy-composition.test.ts. The plan is not a complete runnable CaddyConfig. +No validation/apply wiring, Admin API, server or runtime-state mutation is added. + +Evidence applies to the uncommitted checkout based on HEAD +93942af84f555eab2bd68e0a4a9db487ff82d040. Earlier dirty work is preserved. +No staging, commit, publication, dependency, Rust production, Sandbox or host +privilege/account changes were performed by this slice. + +This is not archive-ready. The known OpenSpec 1.5 delta validator rejects the +three spec-free implementation changes. G1/G2/G3, real-Caddy/native/distribution +proof and final-product Sandbox remain open; checked tasks do not close whole +requirements or product acceptance. + +## Evidence + +| Requirement ID | Task | Evidence | Result | +| --- | --- | --- | --- | +| REG-002, REG-004 | 1.1 | `nub run test -- test/caddy-composition.test.ts`: canonical case/IDN/trailing dots, numeric literal and URL-delimiter differences, all registration/domain activation states, duplicate registration IDs, canonical self-deduplication, sorted competing-owner diagnostics and bounded paths. | Pass for composition boundary; exhaustive IDNA parity not claimed | +| REG-004 | 1.1 | Same suite: complete/hash/UTF-8/32 MiB carrier checks, malformed route shapes, sorted server extraction, first real route-position upstream and opaque payload rejection as metadata. | Pass; no Caddyfile parser or adapter execution added | +| CADDY-002, CADDY-004, REG-004 | 2.1 | Same suite: multi-project ordering, registration host guards, mixed/disabled-only matcher pruning, nested and separate hostless siblings, exact IPv4/IPv6 loopback listeners on 80/443, HTTP-only ID copies, immutable inputs and root admin/listener/TLS injection ignored. | Pass for pure route plan; server/apply acceptance pending | +| CADDY-002, CADDY-004, REG-004 | 2.1 | Terminal-parent regression retains chain position and matchers with an empty handler list after disabled subroutes prune; opaque proxy-shaped payload regression preserves JSON and yields no fake upstream. | Pass after parent repair; actual Caddy chain execution remains 3.9 | +| CADDY-002, CADDY-004, REG-002, REG-004 | 3.1 | Independent MiMo Flash opinion, MiMo Pro source review and targeted repair re-review; parent full Node/schema/main/roadmap checks, simplification/fresh-eyes pass and diff inspection below. | Slice checks pass; global/archive gates blocked | + +Final parent `nub run check` passes TypeScript, ESLint, Prettier and V8 coverage: +**415 tests pass, two existing Unix-native tests skip, 27 test files pass**. +Own TS/TSX lines are **1063/1098 (96.81%)**. Coverage-summary inspection includes +composition **62/62** and domains **105/106**, together **167/168 (99.40%)**; +composition's fully covered file is omitted from the compact text table, not the +coverage denominator. The uncovered domain line is the comparator's equal return. + +Final focused suite: `nub run test -- test/caddy-composition.test.ts`, **63 pass**. +An earlier focused run with `-t 'terminal chain|opaque non-route'` deliberately +reproduced the terminal regression: one failed, one passed. The repaired focused +and final full checks above pass; that initial failure is not passing evidence. + +## Review and repairs + +MiMo Flash identified plan-level risks around emptied host matchers, opaque host +properties, canonical equality and route-plan/full-config separation. The final +source and fixtures preserve those boundaries. A matcher emptied by filtering +is removed as an impossible OR alternative, never converted to a catch-all; +hostless siblings remain inside the registration's outer active-host guard. +Only actual route matcher and subroute positions contribute hosts/upstreams. + +MiMo Pro initially returned OK with notes, no P0/P1, and two P2 findings: + +- Terminal chain position: deleting a parent after all subroute handlers prune + could expose the following route. The parent reproduced this, then retained + terminal parents with their matchers, terminal flag and empty handle array. + Nonterminal empty parents and disabled-only host matcher routes still prune. + Regression fixtures cover active-host and hostless terminal parents. +- Opaque upstream metadata: narrower traversal than Rust's broad JSON walk is + intentional. A new fixture pins that a proxy-shaped plugin payload does not + contribute a domain/upstream and remains unchanged in the composed routes. + +The same MiMo Pro reviewer inspected those repairs and returned **OK**, both P2 +items resolved and no new defect in their blast radius. The follow-up's earlier +pending-full-gate caveat is superseded by the final passing parent gate above. +Parent simplification replaced a nested comparator ternary with explicit returns; +fresh-eyes inspection found no further substantive issue. + +The original Flash run failed with an upstream inference-generation error; +the original Pro run timed out without a verdict. Exact-run native resumes +produced the completed opinion, review and targeted follow-up. Failed receipts +remain failures, not successful reviews. A child direct report write was boundary +blocked; native runtime capture persisted the final response without a bypass or +alternate CLI. No orchestration failure is counted as product evidence. + +## Gate + +- [x] Every focused task and its stated slice checks is complete. +- [x] Every in-scope requirement aspect has evidence above. +- [x] Focused and full Node checks pass above 85% own-code lines. +- [x] `openspec schema validate implementation` passes. +- [x] `openspec validate --specs --strict --no-interactive`: 15 pass. +- [x] `openspec validate reset-cadder-architecture --strict --no-interactive` passes. +- [x] `git diff --check` passes; staged content is empty; no generated JS/DLL/EXE appears in the affected source/test tree. +- [ ] Global `mise run openspec-check` passes: currently 17 items pass and three spec-free implementation changes fail the missing-delta validator. +- [ ] Strict focused change validation passes: currently fails with `Change must have at least one delta. No deltas found.` +- [ ] Archive is authorized and validation-compatible. + +No fake deltas, checker replacement or validation-policy workaround was added. +Active LSP probes did not uniformly certify clean results; auxiliary rules retain +non-null assertion/filter-map warnings and runtime-type-check hints. Maintained +TypeScript and lint checks pass; this is not an LSP-clean claim. OpenSpec artifact +instructions also emitted the known custom-schema `specs` rule warning while +returning verification instructions; it was not treated as product failure or +silently repaired. The combined Rust/docs/npm/cargo-dist gate was not rerun for +this Node-only slice; the earlier preparation evidence remains separately dated. + +## Residual risk + +Pure JSON assertions do not prove execution by real Caddy, empty-handler terminal +semantics or every adapter output shape. Native Caddy, protected mTLS assembly, +queue/apply/rollback, lifecycle, distribution and final-product Sandbox remain +later roadmap work. Canonicalization fixtures cover observed Node/Rust IDNA +edges, not exhaustive Unicode/version parity. Pending/committed-state isolation +and preservation of the currently committed owner during a runtime mutation +belong to 3.4-3.5; this slice only rejects the complete conflicting route plan. diff --git a/openspec/changes/migrate-caddy-lifecycle/.openspec.yaml b/openspec/changes/migrate-caddy-lifecycle/.openspec.yaml new file mode 100644 index 0000000..46f2642 --- /dev/null +++ b/openspec/changes/migrate-caddy-lifecycle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-08 diff --git a/openspec/changes/migrate-caddy-lifecycle/design.md b/openspec/changes/migrate-caddy-lifecycle/design.md new file mode 100644 index 0000000..2abd6f0 --- /dev/null +++ b/openspec/changes/migrate-caddy-lifecycle/design.md @@ -0,0 +1,75 @@ +## Existing boundaries + +ConfigurationTransactions owns mutation ordering and publication. Its required +ConfigurationRuntime.apply accepts CaddyConfig or null; readActive returns an +independently observed hash, positively proved idle, or a failure. +CaddyAdminClient supplies bounded verified HTTPS /load and /config/ but not +process ownership. PinnedCaddyImage verifies the executable around native +creation. runOwnedCommand owns a Windows Job or Unix process group and waits for +owned cleanup. Reuse these boundaries. + +## Lifecycle increment + +One lifecycle object holds one owned execution and one prepared secure admin +identity/environment snapshot. It starts Caddy with a complete normalized secure +JSON candidate, never --resume, project environment or plaintext fallback. +Runtime exclusion is held by the caller throughout this object's lifetime. +Guard malformed/security-changing candidates before side effects. Freeze/copy +caller inputs. Initial and reload observation use the existing HTTPS readback. + +Only add the minimal long-lived mode to existing owned execution needed to avoid +a configuration-command deadline or unbounded lifetime output accumulation. +Keep adapt/validate command defaults and output limits unchanged. Preserve pin +checks and existing group/Job finalizers. No second native launcher, generic +process registry, monitoring framework or process enumeration is introduced. +Windows uses private child pipes so its verified-resume handshake cannot race +native output. Wrapper success follows Job and stream settlement; a bounded +four-byte footer carries the native exit code separately from helper failures. +A fixed-buffer stdout payload counter preserves immediate short-command limits; +long-lived execution drains without accumulating lifetime output. + +Before launching, start performs a bounded read-only conflict check and refuses +an already-responsive authenticated endpoint. Persisted TLS identity proves the +peer identity, not ownership of a newly resumed process. This check grants no +permission to adopt or stop the existing process. Active readback begins only +after verified native creation/resume and continues to watch owned settlement. +Failure cancels and settles that execution before another start or staging +cleanup. A process that exits unexpectedly is not automatically adopted or +restarted. A later explicit apply/reconciliation may start the pinned image again. +Unknown or incomplete teardown remains failure/ambiguity, never proof of idle. + +The existing transaction queue's explicit reconciliation may restore the last +committed target after an unavailable initial observation, not just a mismatch. +The runtime still refuses unsafe or unproved teardown; restoration must succeed +and independent final readback must match before the queue clears its fence. +This narrow integration correction neither publishes an abandoned candidate nor +persists new intent or introduces automatic recovery. + +Reload delegates to the existing HTTPS client. Transaction verification, +rollback and persistence remain in ConfigurationTransactions. Null performs a +real graceful /stop, waits for owned settlement, then forces through the existing +owned control boundary if needed. close stops admissions, cancels startup or +in-flight admin work, and drains teardown; it does not release the daemon lock +or close the caller-owned resolver/storage. No global Ctrl+C handlers belong to +the leaf adapter; callers invoke close from their shutdown/signal path. + +## Security and staging + +Use the secure policy's isolated environment for validation and execution. +Stage complete JSON only in protected runtime-owned storage and retain it until +owned execution settles. Strict product-created path checks remain unchanged. +Known generated Caddy storage paths use the accepted read-only descendant check; +no ACL rewrite, general tree scan or secret-bearing diagnostic is added. + +## Verification + +Use fake Caddy/process/admin seams in unit tests and disposable Node-native +children for lifecycle ownership evidence; tests require neither real Caddy nor +private paths. Prove idle/start/reload, unexpected exit, startup timeout/failure, +closed admission, cancellation, forced stop and unrelated process survival. +Integrate with the real transaction queue for idle rollback and reconciliation. +Native real-Caddy evidence is optional for this first adapter increment and must +be distinguished from fake fixtures and the previously completed admin-only +Windows proof. Whole roadmap 3.8/G3 acceptance remains open until its full native +scenarios pass. Known implementation-schema archive/validator issues are not +fixed by copying requirements or changing tooling in this slice. diff --git a/openspec/changes/migrate-caddy-lifecycle/proposal.md b/openspec/changes/migrate-caddy-lifecycle/proposal.md new file mode 100644 index 0000000..376f701 --- /dev/null +++ b/openspec/changes/migrate-caddy-lifecycle/proposal.md @@ -0,0 +1,50 @@ +## Why + +Preserve the project Caddyfile -> managed shim -> daemon -> operator journey. +The next capability runs and stops the daemon's own Caddy with independently +observed configuration, so the delivered transaction queue can leave and restore +idle. Existing resolution, validation, route composition and secure administration +are implemented; none currently owns a long-lived Caddy lifecycle. + +## Requirement IDs + +- `CADDY-001`: retain the pinned trusted executable. +- `CADDY-002`: one owned child serves composed routes. +- `CADDY-003`: independent readback and real idle transitions for the queue. +- `CADDY-006`: retain verified loopback mTLS for lifecycle administration. +- `RUN-003`: bounded graceful/forced teardown of owned work only. +- `RUN-004`: retain startup identity/configuration until restart. + +## Scope + +Implement a small Caddy lifecycle adapter over the existing pinned-image, +owned-command and HTTPS boundaries. Cover initial start, reload, independent +inspection, unexpected exit, idle rollback, bounded stop and admission close. +Extend existing owned execution only where long-lived execution needs it; +reuse its Windows Job/Unix group instead of another process framework. + +## Non-goals + +No registration handlers, SQLite application worker, shim, CLI/TUI, packaging, +auto-restart loop, process adoption/enumeration, trust installation, ACL repair, +new dependencies or runtime framework. No Rust removal or release action. +Native platform and full product acceptance remain separate gates. + +## Success criteria + +- Start and reload use the pinned image and immutable secure policy. +- Active hashes come from mTLS readback, never submitted bytes or receipts. +- Idle is reported only when absence/owned settlement is positively established; + unexpected exit or failed observation cannot masquerade as a committed state. +- Graceful stop has a deadline and then forces only the owned Job/group. +- Startup failure, cancellation and close settle acquired work before releasing + staging; unrelated processes survive fixture success and failure scenarios. +- Focused tests, Node checks and independent review provide executed evidence; + unexecuted native/product gates stay open. + +## Impact + +Caddy lifecycle/admin modules, the existing owned-command seam and direct tests. +The internal ConfigurationRuntime is implemented without changing public RPC or +CaddyPort. Conceptually this removes the missing idle/lifecycle seam; it does not +replace the existing resolver, queue or native process ownership implementation. diff --git a/openspec/changes/migrate-caddy-lifecycle/tasks.md b/openspec/changes/migrate-caddy-lifecycle/tasks.md new file mode 100644 index 0000000..ad33de9 --- /dev/null +++ b/openspec/changes/migrate-caddy-lifecycle/tasks.md @@ -0,0 +1,9 @@ +## 1. Owned lifecycle adapter + +- [x] 1.1 Reuse the existing pinned-image/owned-command boundary for bounded-start, long-lived Caddy without lifetime stream accumulation; preserve command-mode defaults and native cleanup semantics (CADDY-001/002, RUN-003/004). Native Windows Node fixtures cover startup notification, binary streams/footer, full native exit codes, exact/zero/overflow bounds and Job cleanup; see verification.md. +- [x] 1.2 Implement secure start/reload/readback, real idle stop, unexpected-exit diagnostics and close/admission settlement as ConfigurationRuntime, without duplicating the transaction queue (CADDY-003/006, RUN-003). The bounded conflict check refuses an existing authenticated endpoint; explicit queue reconciliation restores committed state only with independent final proof. Full product/native acceptance remains open. + +## 2. Verification + +- [x] 2.1 Test lifecycle success/failures, start cancellation, bounded forced teardown, immutable policy, uncertain observations and unrelated-process survival with fake adapters/native fixtures (CADDY-002/003/006, RUN-003/004). Include transaction idle rollback/reconciliation regressions. Parent post-review focused suites passed 96 tests; native ownership and HTTPS fixtures remain separate from actual Caddy lifecycle acceptance. +- [x] 2.2 Integrate independent review, simplification/fresh-eyes checks and the full Node gate; record exact executed evidence and retain native/product/archive gaps (CADDY-001/002/003/006, RUN-003/004). F1/F2 corrected and accepted by the targeted independent follow-up; parent final nub run check passed 780 tests with 2 skips and 96.14% own-source line coverage. Planning check remains blocked by implementation-only missing deltas; see verification.md. diff --git a/openspec/changes/migrate-caddy-lifecycle/verification.md b/openspec/changes/migrate-caddy-lifecycle/verification.md new file mode 100644 index 0000000..e88083b --- /dev/null +++ b/openspec/changes/migrate-caddy-lifecycle/verification.md @@ -0,0 +1,115 @@ +# Verification + +## Scope and source + +Verified the leaf owned-Caddy lifecycle increment on the migration branch at +base commit `93942af84f555eab2bd68e0a4a9db487ff82d040`, with the current uncommitted +source changes. This is not evidence for a tagged/source-clean release candidate, +whole roadmap 3.8, G3, final product or either distribution. Existing Rust and +previous dirty/untracked work were preserved; nothing was staged or committed. + +Delivered `src/caddy/runtime.ts`, extensions to the existing administration and +owned-command boundaries, and direct regression tests. A small correction to +`ConfigurationTransactions.reconcile` connects explicit recovery to this backend; +there is no new queue, launcher, process registry, automatic restart or RPC. + +## Requirement and task evidence + +| Requirement / task | Executed evidence | Boundary | +| --- | --- | --- | +| CADDY-001, RUN-004 / 1.1 | Existing resolver/image regression suite; immutable environment/candidate tests; verified native-start notification | PinnedCaddyImage is reused unchanged; no distribution recursion acceptance inferred | +| CADDY-002, RUN-003 / 1.1, 1.2, 2.1 | Native Windows Node argv/binary stream/exit/footer, immediate limits, cancellation/orphan cleanup and unrelated-child survival; lifecycle graceful/forced stop and close tests | Native process ownership and fake lifecycle tests are separate, not real-Caddy acceptance | +| CADDY-003 / 1.2, 2.1 | Independent observed hashes; ambiguity/rejection; actual idle rollback through ConfigurationTransactions; explicit crash recovery and failed-cleanup fencing | Application storage remains a fake atomic port; no handler or registration recovery claim | +| CADDY-006 / 1.2, 2.1 | Existing actual Node HTTPS TLS tests plus new bounded stop/cancellation; security-envelope refusal and startup conflict regressions | No plaintext fallback; prior native administration proof is not full lifecycle proof | +| All in-scope IDs / 2.2 | Independent review and accepted F1/F2 corrections; parent focused/full gates; fresh-eyes/simplification and planning checks | Failed planning and unexecuted native/product gates remain explicit | + +## Checks + +Worker final focused command: + +```sh +nub run test -- test/caddy-runtime.test.ts test/caddy-owned-process.test.ts test/caddy-admin-client.test.ts test/caddy-preparation.test.ts test/caddy-resolution.test.ts test/configuration-transactions.test.ts +``` + +It passed 271 tests with 2 existing Unix-native skips. This result predates the +parent's F1/F2 corrections and is not the final increment gate. + +Parent post-review correction checks: + +```sh +nub run test -- test/caddy-runtime.test.ts test/configuration-transactions.test.ts +nub run typecheck +nub run check +``` + +- Focused lifecycle/queue: 96 passed in two files; typecheck passed. +- Final Node gate: TypeScript, ESLint, Prettier and V8 coverage passed; 34 files, + 780 tests passed and 2 existing Unix-native tests skipped. +- Own lines: 1,721/1,790 (96.14%); statements 94.73%, branches 90.48%, functions + 96.78%. Lifecycle module: 92.48% lines and 86.72% branches. The Windows-host + owned-command module measured 80.57% lines; its unexecuted Unix/native paths + are not excluded or claimed covered. The workspace line gate is 85%. +- Active LSP probes reported no compiler errors but retained auxiliary style + heuristics and silent-on-clean uncertainty. The passing compiler is not a + universal clean-diagnostics claim. +- Coverage and validation logs remained outside checkout; no generated Node + output was added to source/test trees. Existing maintained JS tooling remains. +- `git diff --check` passed; the staged file list was empty. +- OpenSpec doctor, implementation schema and all 15 strict main specs passed; + reset roadmap validation passed. `mise run openspec-check` reported 17 passed + and 6 failed: implementation-only changes, including this slice, are rejected + for missing deltas by the known strict validator behavior. Archive readiness + remains blocked; no fake requirements or tooling changes were added. + +## Independent review and corrections + +The initial review found two concrete integration defects. F1: native resume +alone allowed an existing endpoint with the reused TLS identity to masquerade as +the new owned execution. Start now performs a bounded read-only conflict check +before validation/staging/launch and refuses that endpoint without adopting or +stopping it. Readiness still waits for verified native start. F2: the transaction +queue refused restoration whenever its initial observation was unavailable, +preventing explicit recovery after a clean owned exit. Reconciliation now attempts +last-known-good restoration in that case and still requires successful apply plus +independent final proof. Uncertain native cleanup remains fenced and cannot start +a replacement. + +The same reviewer inspected only these corrections and their blast radius, +accepted both findings as resolved, and identified no new material P0/P1/P2 issue. +Verdict: OK with notes. Native acceptance is separate: the preflight conflict +check is not an atomic endpoint-to-process identity proof. + +Fresh-eyes review checked unchanged immutable snapshots, cancellation/close +ordering, protected staging retention and strict uncertain-cleanup behavior. +Simplification retained the existing launcher/queue and fixed-size stream buffers, +without adding process enumeration, a generic lifecycle framework or monitoring. + +## Failures and limitations + +The first worker exceeded its 30-minute harness deadline. Parent inspected and +preserved the partial sources, then revived that exact retained child through +the same subagent protocol. The timeout is not successful verification. A report +write required unavailable user confirmation; the worker stopped the write and +returned its final report for normal runtime persistence. No alternate-path or +permission workaround was used. + +A pre-fix four-byte footer delayed immediate stdout overflow; a bounded payload +counter in the same native pump corrected it without changing normal stream +limits or native exit codes. One subsequent worker run transiently accepted the +32-MiB-plus-one adaptation stderr fixture. Its targeted rerun, complete focused +rerun and both parent full gates passed without suppression or fixture changes. +The transient cause remains unproved and is retained as a diagnostic limitation, +not declared fixed or attributed to the environment. + +Still open: + +- Real-Caddy lifecycle/HTTP/HTTPS integration and startup-conflict scenarios on + supported native platforms; runtime unit tests fake process/admin/protection + seams while using the real validator and transaction queue. +- Full daemon/handler/shim Ctrl+C, shutdown, registration and recovery journeys. +- Linux/macOS execution of the changed native group-settlement path. +- Completed-product Windows privilege/Sandbox, G1/G2/G3/G7 and npm/SEA parity. +- Existing implementation-only archive/validator blocker. + +Roadmap 3.8 and 3.9 remain unchecked. This increment supplies the backend needed +for those gates; no Rust removal, tagging or publication was authorized. diff --git a/openspec/changes/migrate-caddy-preparation/.openspec.yaml b/openspec/changes/migrate-caddy-preparation/.openspec.yaml new file mode 100644 index 0000000..79e0856 --- /dev/null +++ b/openspec/changes/migrate-caddy-preparation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-06 diff --git a/openspec/changes/migrate-caddy-preparation/design.md b/openspec/changes/migrate-caddy-preparation/design.md new file mode 100644 index 0000000..8747169 --- /dev/null +++ b/openspec/changes/migrate-caddy-preparation/design.md @@ -0,0 +1,156 @@ +## Context + +The operational baseline is released Rust 1.0.5. Current Node code already has +strict RPC DTOs, authenticated runtime, installation identity and four leaf ports. +No Node Caddy implementation existed at intake; this slice now delivers preparation only. Source authority is retained config.rs, +caddy/{resolver,configuration,tests}.rs, caddy_path_trust.rs, caddy_image.rs and +process_tree.rs; test/compatibility freezes their retained behavior. + +## Requirements + +REG-004 governs parsing and precedence. CADDY-001/SHIM-003 govern selection, +identity and pinning. CADDY-005 governs short-lived output limits and cleanup. +RUN-003 governs daemon shutdown/server lifecycle and remains outside slice closure. +The complete JSON carrier and validation result already exist in +src/contracts/ports.ts. No normative requirement is duplicated here. + +## Goals and non-goals + +Deliver configuration -> trusted executable -> bounded adapt -> JSON candidate -> +bounded validate. Keep the installed upstream Caddy separate and mandatory. +Do not compose routes, apply configuration, contact Admin API or start a server. + +## Ownership and trust boundaries + +Configuration parsing is pure; executable selection is daemon-owned. Preserve +explicit absolute daemon override, portable cadder.toml, per-user configuration, +system configuration, then safe PATH. Trusted files select a single command name +or absolute path; no arguments, relative command paths or project/shim/environment +selectors. Existing empty higher-priority files can fall through, malformed or +unsafe selected sources cannot. Use physical installation identity for npm and +SEA, not cwd, argv wrappers or the shared Node executable as a portable anchor. + +Platform defaults preserve the released OS configuration locations. Windows +known folders use the existing small PowerShell/.NET adapter rather than trusting +PROGRAMDATA/APPDATA overrides for system policy. Filesystem checks use native +regular-file/executable checks and dev/inode or equivalent identity; Unix aliases +are followed for identity, Windows final reparse/script candidates are denied. +Reject local/global npm script wrappers; native Windows Scoop descriptors may +resolve only to a checked native target. Compare all known distribution entries +and aliases before executing a candidate. + +Cache selected path and immutable image evidence for the resolver lifetime. +Reverify canonical path, identity and content digest before and immediately after +spawn; retain an open image anchor and release it on explicit close. Compatibility +probes preserve baseline versions >=2.11.4 and <3.0.0 and the 19 required module IDs. +Evidence failures never silently select another image. Shared probes belong to +the resolver lifetime: caller cancellation stops that wait, not other callers' +probes, and closing the resolver cancels its pending probe. This is check-and-detect behavior, not an +atomic pathname-exec guarantee against arbitrary same-owner concurrent swaps. + +## Data flow and public contracts + +1. Parse strict TOML with installed smol-toml and Zod; retain snake_case selectors. +2. Resolve and pin one trusted native image, then run bounded version/modules probes. +3. Adapt using an argument array with canonical config path when supplied, raw + path otherwise, and retained adapter metadata or caddyfile default. +4. Reject failed/partial/malformed output; carry complete adapted JSON and SHA-256. +5. Validate a JSON candidate through real Caddy's command, exposing the existing + CaddyPort validation signature without adding an unimplemented apply method. + +Errors stay typed and bounded. No child stderr or project text becomes an +unbounded authenticated error payload. No RPC catalog or daemon-ready state changes. + +## Decisions + +Use Node spawn with shell disabled and Node crypto/filesystem primitives. One +narrow owned-command helper is shared by compatibility probes and adaptation; +this is application process ownership, not a custom build/test framework. + +A native Windows reproducer confirmed that taskkill cannot clean a descendant +retaining stdout/stderr after its leader exits. The supervisor therefore approves +one narrow PowerShell/.NET Windows Job Object adapter, matching the retained Rust +per-command job semantics. This is necessary product OS integration, not optional +hardening: attach the selected child before it can execute, keep the job handle +non-inheritable, disallow descendant breakaway and kill the owned job on completion, +failure or wrapper exit. Preserve direct native argv and binary streams, existing +image checks, limits and bounded teardown. No native Node addon, distributed +launcher binary, general process broker, external tool installation, dependency +change or host privilege/account configuration is authorized. Failure to prove +the narrow adapter must stop this slice rather than trigger another layer. + +Implementation is serial: config/resolver plus their required bounded probe +helper, then adapter/validation using that verified handoff. One writer owns +each stage; fresh MiMo reviews cannot write source. Production has no test +bypass, fake backend, ambient fixture switch or arbitrary runner injection. +Synthetic fake-Caddy behavior remains in test/fixtures and owned temporary files. + +## Failure and recovery + +Adapt/validate defaults are 30 seconds and at most 32 MiB separately on stdout +and stderr; metadata probes retain the smaller 1 MiB bound. Reject output as soon +as a bound is exceeded rather than accepting its prefix. Always observe stream, +spawn and exit errors, settle pending reads, and close stdin explicitly. + +Cleanup targets only the owned child/process group/tree, with a bounded teardown +and primary-error preservation. Unix can use an owned detached process group; +Windows must use a narrow owned-tree mechanism and prove its actual guarantees. +An orphan/pipe-inheritance gap cannot be recorded as full CADDY-005 completion: +report a concrete blocker before adding broad process enumeration, native addons +or another orchestration layer. Unrelated processes must survive. + +After native success, the Windows helper waits up to four seconds for the Job's +active process count to reach zero. After leader exit, unsettled pipes cause a +bounded five-second stream error, not an operation timeout. Unix descendants +that deliberately escape the group cannot be reclaimed by this narrow mechanism; +that native acceptance limitation remains open, without process enumeration. + +Validation staging belongs under exclusively created system-temp storage and is +removed after all child finalizers, using three built-in removal retries with +100 ms linear backoff for transient filesystem locks. No generated JS, bundles or captures enter +checkout. Candidate rejection does not publish state or modify existing config. + +## Migration and rollback + +No product cutover, old-data access or persisted schema change. Retain Rust and +its packaging unchanged. Reverting this slice removes only the new Node modules +and tests, not runtime or compatibility work. Sandbox is mandatory final-product +acceptance under 2.8/7.4; no intermediate test is falsely marked passed. + +## Test strategy + +Use frozen TOML inputs and retained Rust precedence/adapter cases as authority. +Test explicit/portable/user/system/PATH sources, invalid-priority denial, command +injection, relative PATH entries, installation entries, aliases/hardlinks, +wrappers, pin mutations and missing/incompatible probes. + +Execute disposable fake Caddy processes for real argv/cwd, adaptation/validation, +invalid UTF-8/JSON, exits, both stream limits, spawn errors, timeout, abort, +parent/grandchild cleanup and unrelated-child survival. Fake executables must +pass the same production path policy; no test-only production trust switch. +Use current build dependencies only for outside-checkout fixture emission. +Native Windows proof and modeled Unix cases are labeled separately; later real +Caddy/npm/SEA acceptance is not supplied by these tests. + +Run focused Vitest tests after each stage, nub run check after integration, +strict main/roadmap OpenSpec checks and git diff --check. Inspect LSP results +without treating an inconclusive push-only probe as clean. Retain Rust tests as +baseline checks when test-only fixtures are affected. + +## Risks and trade-offs + +- Path-based spawning cannot claim atomic image identity -> pre/post verification, + owned cleanup and explicit race residual, with mutation regressions. +- Windows inherited descendants/pipes require concrete teardown proof -> do not + substitute a live-parent taskkill case for orphan cleanup evidence. +- npm/SEA artifacts are not built yet -> realistic layout fixtures, no distribution + acceptance claim until G6. +- Existing OpenSpec custom implementation validation mismatch -> schema/status + and main-spec validation plus honest residual; no fake deltas or validator layer. + +## Open questions + +No product behavior or sequencing question is open. If a required native cleanup +or identity guarantee cannot be delivered with the approved small Node/platform +boundary, stop with the reproducer and request a parent decision before expanding +implementation. Such a blocker leaves the affected task incomplete. diff --git a/openspec/changes/migrate-caddy-preparation/proposal.md b/openspec/changes/migrate-caddy-preparation/proposal.md new file mode 100644 index 0000000..f13e2e0 --- /dev/null +++ b/openspec/changes/migrate-caddy-preparation/proposal.md @@ -0,0 +1,65 @@ +## Why + +Preserve the released project Caddyfile -> managed shim -> daemon -> operator +journey. The next executable capability takes an existing project configuration, +selects trusted real Caddy, adapts it to JSON and validates a candidate without +starting a server or changing active routes. Caddy remains separately installed +and mandatory; this migrates Cadder's integration, not Caddy itself. + +The user approved deferring Windows Sandbox to completed-product acceptance. +Open G1/G2 evidence remains mandatory before G7, Rust removal or release. + +## Requirement IDs + +- `REG-004`: Preserve existing cadder.toml/Caddyfile formats and precedence. +- `CADDY-001`: Trusted real-Caddy selection, lifetime pinning and recursion denial. +- `CADDY-005`: Bounded adaptation output and owned adapter cleanup. +- `SHIM-003`: Reject npm/SEA wrappers and equivalent alias/file identities. + +RUN-003 daemon shutdown/server lifecycle is a later dependency (roadmap 3.8), +not an in-scope completion claim. Short-lived cleanup belongs to CADDY-005. + +## Scope + +Implement roadmap 3.1-3.2 in new Node Caddy modules and narrow platform helpers: +strict smol-toml parsing; explicit/portable/user/system/safe-PATH precedence; +executable identity/digest pinning and compatibility probes; direct bounded +adapt/validate subprocesses; typed results compatible with the validation part +of the existing Caddy port. Test real disposable fake executables, not installed +real Caddy or a production mock backend. + +The delivered close-node-rpc-catalog slice owns protocol/port contracts and has +complete implementation artifacts but remains unarchived due the known custom +schema validation limitation. This Caddy slice consumes those contracts without +modifying protocol behavior; its dependency and separate source ownership are +explicit. Only one writer uses the checkout at a time. + +## Non-goals + +No route composition, registration handler, mutation queue, apply/rollback, +Admin API contact, certificate generation, persistent state worker, real Caddy +server lifecycle, CLI/TUI, release packaging, Sandbox/UAC/account changes or +Rust production changes. Do not add an apply stub or a mock production backend. +Existing roadmap tasks 3.3-3.9 and all final acceptance gaps stay open. + +## Success criteria + +- Existing TOML selectors parse with unknown fields and conflicting selectors + rejected; an invalid higher-priority source never silently falls back. +- Only trusted source locations or safe absolute PATH entries select a native + executable; project/cwd/environment selectors cannot redirect it. +- npm wrappers, Cadder SEA entries, symlink/hardlink aliases and pinned-image + mutations are refused without recursive execution or silent reselection. +- The retained Caddy compatibility policy and 32 MiB-per-stream/30-second command + defaults apply; malformed JSON, nonzero exit, spawn failure, timeout, output + overflow and cancellation never yield usable partial configuration. +- Owned-child cleanup is bounded and never terminates unrelated processes. +- Focused tests and the relevant full Node gate pass with >=85% own-code line + coverage; unsupported native proofs remain identified, not inferred. + +## Impact + +New src/caddy modules, narrow platform integration and test fixtures; roadmap +sequencing and evidence documentation. Reuse current smol-toml/Zod and Node APIs. +No dependency change without a demonstrated need and parent decision. No version, +CI, publication-policy, host security or existing Rust release-path changes. diff --git a/openspec/changes/migrate-caddy-preparation/tasks.md b/openspec/changes/migrate-caddy-preparation/tasks.md new file mode 100644 index 0000000..d1e5010 --- /dev/null +++ b/openspec/changes/migrate-caddy-preparation/tasks.md @@ -0,0 +1,20 @@ +## 1. Configuration and trusted resolution + +- [x] 1.1 `[REG-004, CADDY-001]` Implement strict existing TOML selectors and released configuration locations/precedence (verification: focused config tests include unknown fields, conflicting selectors and malformed higher-priority denial). +- [x] 1.2 `[CADDY-001, SHIM-003]` Resolve trusted native Caddy, deny recursive npm/SEA/alias identities and pin immutable image/compatibility evidence (verification: source/layout, wrapper, hardlink, changed-image and version/module tests). +- [x] 1.3 `[CADDY-005]` Bound owned probe/process streams, deadlines and teardown without unrelated process termination (verification: real fake-child stream/timeout/abort/descendant tests; native gaps remain explicit). + +## 2. Adaptation and validation + +- [x] 2.1 `[REG-004, CADDY-005]` Adapt existing config paths and adapter metadata through the pinned executable into a complete typed JSON candidate (verification: real argv, canonical/raw paths, retained defaults, malformed output and nonzero-exit cases). +- [x] 2.2 `[CADDY-001, CADDY-005]` Validate a bounded JSON candidate with the existing validation-port signature and remove owned staging on every outcome (verification: success, rejection, timeout/overflow/spawn/pin failures and no leftover temp files). + +## 3. Evidence and closeout + +- [x] 3.1 `[REG-004, CADDY-001, CADDY-005, SHIM-003]` Integrate independent MiMo findings, full relevant validation and requirement-to-evidence reconciliation (verification: nub run check >=85% own lines; main/roadmap strict checks; diff inspection; record native/residual blockers without closing G1/G2/G3). + +Implementation and the stated slice checks are complete; evidence is in +verification.md. RUN-003 server shutdown remains roadmap 3.8, not a delivered +requirement here. Native Unix/real-Caddy/channel proofs and the OpenSpec 1.5 +custom-schema archive blocker remain explicit. Checked tasks do not close +CADDY-005 as a whole, G3, release acceptance or the archive gate. diff --git a/openspec/changes/migrate-caddy-preparation/verification.md b/openspec/changes/migrate-caddy-preparation/verification.md new file mode 100644 index 0000000..f042be2 --- /dev/null +++ b/openspec/changes/migrate-caddy-preparation/verification.md @@ -0,0 +1,142 @@ +## Summary + +Roadmap 3.1-3.2 implementation and the focused task checks pass. The delivered +capability is trusted configuration selection -> pinned real-Caddy image -> +adapted JSON/hash -> validation. It does not apply configuration, start a server +or expose a new operator command. Released Rust 1.0.5 remains operational. + +Base HEAD is `93942af84f555eab2bd68e0a4a9db487ff82d040`; this work and earlier +migration changes remain uncommitted. Existing dirty work was preserved. +The source additions are `src/caddy/{configuration,executable,image,resolver, +adapter,preparation,validation}.ts`, `src/platform/owned-command.ts`, four +`test/caddy-*.test.ts` files and three Caddy fixture files. + +This is not archive-ready: the OpenSpec 1.5 strict delta validator rejects both +spec-free implementation changes. G1/G2/G3, native Unix/real-Caddy/channel proof +and final-product Windows Sandbox remain open. Checked tasks record the stated +slice checks, not complete requirement or product acceptance. + +## Evidence + +| Requirement ID | Task | Evidence | Result | +| --- | --- | --- | --- | +| REG-004, CADDY-001 | 1.1 | `nub run test -- test/caddy-configuration.test.ts`; included in final full check. Strict TOML, unknown/conflicting fields, UTF-8, released OS locations and override/portable/user/system/PATH priority with malformed-priority denial. | Pass for slice; Unix locations modeled | +| CADDY-001, SHIM-003 | 1.2 | `nub run test -- test/caddy-resolution.test.ts`; included in final full check. Real native fixture executables, npm/SEA inventories, wrappers, hardlinks/reparse points, safe/quoted PATH, fully qualified Windows paths, sticky evidence failure and image mutation/replacement. | Pass; built distribution acceptance pending | +| CADDY-005 | 1.3 | `nub run test -- test/caddy-owned-process.test.ts`: 12 pass, one Unix-only skip. Native argv/binary streams, exact bounds, overflow, spawn errors, timeout/abort, exited-parent inherited pipes, Job membership and unrelated survival. | Native Windows pass; Unix proof pending | +| REG-004, CADDY-005 | 2.1 | `nub run test -- test/caddy-preparation.test.ts`; 33 pass at stage handoff and included in final full check. Canonical/raw paths, source cwd, adapter default/metadata, complete JSON/hash, malformed/invalid UTF-8/nonzero output, both exact stream bounds and overflow. | Pass with disposable native fake Caddy | +| CADDY-001, CADDY-005 | 2.2 | Same preparation suite: typed validation-only signature, candidate integrity, real exclusive temp input, abort/timeout/descendant/spawn/pin failures and staging removal. Native external-file-lock rejection returns failure, never validated success. | Pass; persistent external locks remain explicit | +| REG-004, CADDY-001, CADDY-005, SHIM-003 | 3.1 | Final `nub run check`; strict main-spec/roadmap/schema checks; independent MiMo opinions and parent repairs below; whitespace and unstaged review. | Slice checks pass; global gate/archive blocked below | + +Final parent Node gate: `nub run check` passes typecheck, ESLint, Prettier and V8 +coverage: 26 test files, **352 tests passed, two Unix-native tests skipped**. +Own TS/TSX lines are **896/930 (96.34%)**. Final LCOV inspection confirms the +Caddy/owned-command slice has **435/464 covered lines (93.75%)**, including +adapter **14/14**, preparation **43/43**, validation **20/20** and owned-command +**93/106**. Fully covered adapter/preparation files are omitted from the compact +text table, not the coverage denominator. Embedded C# statements are not measured +by V8; their guarantees have native behavioral tests, not a claimed C# percentage. + +Focused parent repair checks: + +- `nub run test -- test/caddy-resolution.test.ts -t 'caller cancellation|resolver close|quoted absolute'`: three pass. +- `nub run test -- test/caddy-owned-process.test.ts`: 12 pass, one skip. +- `nub run test -- test/caddy-preparation.test.ts -t 'staging cleanup|orphan|descendant'`: six pass. +- `nub run test -- test/caddy-resolution.test.ts -t 'qualification|root-relative|caller cancellation|resolver close|quoted absolute|Scoop'`: 12 pass. + +An initial new cancellation regression failed because the asynchronous Node +preload fixture still let Node interpret `version` as a script. The test-only +fixture now uses the existing asynchronous fixture's Module.runMain override. +Concurrent test promises are observed on failure. The corrected regression and +full gate above pass; the failed attempt is not counted as passing evidence. + +## Independent review and repairs + +MiMo Flash verified final-product Sandbox sequencing, retained Caddy/configuration +requirements and native cleanup constraints. The Windows taskkill orphan/pipe +reproducer confirmed its main concern. The approved narrow PowerShell/.NET Job +adapter creates the native child suspended and already assigned to its Job, +performs pre/post image verification before execution, preserves native argv and +binary streams, and kills only that Job. No addon, distributed launcher, generic +broker, dependency, tooling-policy or host privilege change was introduced. + +MiMo Pro reviewed actual source/tests independently: PASS WITH NOTES, including +one P1. That verdict was not treated as unconditional acceptance. Parent fixes: + +- P1 cancellation poisoning: shared probes now belong to a resolver-owned abort + lifetime. A cancelled caller stops its wait without cancelling another caller + or caching an abort as image evidence. Close cancels the owned pending probe. + New native regressions cover mid-probe cancellation, concurrent/later callers + and resolver shutdown; genuine failed version/modules remain sticky. +- P2 quoted PATH: Windows parsing preserves quoted absolute entries, including + semicolons, without accepting relative entries. A native regression passes. +- P2 post-exit pipes: leader exit starts bounded stream settlement; lingering + pipes fail with a stream diagnostic rather than the operation timeout. The + escaped-Unix fixture is added but skipped on this Windows host; reclaiming an + escaped group is not claimed. A subsequent primary failure clears the existing + settle timer before replacing it, preserving the primary error without a + leftover timer. +- P2 owned teardown/staging: the Windows helper checks the Job's active process + count reaches zero within four seconds before reporting success. Staging uses + three built-in removal retries with 100 ms linear backoff; external-lock + failure remains bounded and never produces validated success. +- P2 scope: RUN-003 is removed from slice completion tags. Daemon/server shutdown + remains roadmap 3.8. Validation's empty success diagnostics are not advertised + as warning-diagnostics support. +- Fresh-eyes parity repair: Windows root-relative paths are not fully qualified + absolute paths. Configuration, image and source-path checks now match that + distinction; native and modeled regressions pass. + +Parent fresh-eyes/simplification retained the single existing process helper and +made no new framework. Active LSP probes were not uniformly conclusive; auxiliary +lint rules also flagged intentional parser throws and stylistic constructs. +Malformed module/OS metadata parsing propagates through the owning async failure +path rather than yielding success. These are not an LSP-clean claim. Final +TypeScript compiler and repository lint checks pass. + +## Repository and archive gate + +- `openspec schema validate implementation`: pass. +- `openspec validate --specs --strict --no-interactive`: 15 pass. +- `openspec validate reset-cadder-architecture --strict --no-interactive`: pass. +- `git diff --check`: pass. `git diff --cached --name-only`: empty. +- `mise run check`: Rust formatting, workspace/all-target Clippy with `-D warnings` + and all **300 Rust tests pass**, then the existing OpenSpec 1.5 implementation + schema limitation blocks the combined gate: 17 items pass, two implementation + changes fail. Docs/npm/cargo-dist steps after that failure were not reached. +- `openspec validate migrate-caddy-preparation --type change --strict --no-interactive`: + fails with `Change must have at least one delta. No deltas found.` The accepted + custom schema intentionally implements existing IDs without delta specs. No + fake deltas, checker replacement or tool-policy workaround was added. + +- [x] Every focused implementation task and its stated slice checks is complete. +- [x] In-scope requirement aspects have the passing evidence above. +- [x] Focused and full Node checks pass above the 85% line threshold. +- [x] Strict main-spec, roadmap and implementation schema checks pass. +- [x] Documentation distinguishes implementation from native/product acceptance. +- [ ] Global `mise run check` / strict implementation validation passes. +- [ ] Archive is authorized and validation-compatible. + +## Residual risk + +Native Linux/macOS image/group execution, real installed Caddy, packed npm/SEA, +other Windows/filesystem matrices and final-product Sandbox remain unverified. +Normal Windows owner tests do not establish owner-to-elevated/other-account +acceptance. Main CADDY-005 and RUN-003 are not closed as whole requirements. + +Unix detached groups cannot reclaim descendants that deliberately leave the +group; the added stream-settle regression does not grant process enumeration or +prove escape cleanup. Image verification is check-and-detect, not atomic +pathname-exec immunity; Windows anchors detect mutation rather than reproducing +Rust share-mode prevention. PowerShell/Add-Type startup costs remain within each +command deadline. Persistent external locks can leave owned staging residue, +reported as a cleanup failure without terminating unrelated holders. + +Child direct artifact writes were denied with `Blocked write (Arcantry project +boundary): user confirmation required but no UI available.` Complete reports +were delivered through native runtime persistence; no boundary bypass or alternate +CLI execution was used. This is an orchestration limitation, not a passing test. + +Composition, apply/rollback/queue, mTLS/Admin API, server lifecycle, operator +surfaces, distribution and release remain the approved later roadmap. No commit, +staging, publication, Sandbox, UAC/account action or existing Rust product cutover +was performed in this slice. diff --git a/openspec/changes/migrate-caddy-transactions/.openspec.yaml b/openspec/changes/migrate-caddy-transactions/.openspec.yaml new file mode 100644 index 0000000..79e0856 --- /dev/null +++ b/openspec/changes/migrate-caddy-transactions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-06 diff --git a/openspec/changes/migrate-caddy-transactions/design.md b/openspec/changes/migrate-caddy-transactions/design.md new file mode 100644 index 0000000..99d2775 --- /dev/null +++ b/openspec/changes/migrate-caddy-transactions/design.md @@ -0,0 +1,112 @@ +## Evidence and ownership + +CADDY-003 and the roadmap design require one prepare/adapt/compose/validate/apply/ +verify/persist/publish queue. Retained Rust references are +crates/cadder-daemon/src/state/registrations.rs execute_registration_transaction +and apply_runtime_transition, plus runtime/process/receipts.rs rollback. They +separate candidate registration/coordinator state from final publication and +restore prior runtime configuration after persistence failure. Do not copy the +Rust mock backend or swallow failed rollback as acceptance. + +Existing Node CaddyPort has validation and an apply hash receipt, but neither +independent active observation nor definite-rejection/ambiguity classification. +StoragePort persists stable DesiredState only. Composition yields a route plan, +not a runnable secure CaddyConfig. Do not reinterpret that plan as an apply-ready +configuration or implement insecure administration to close this orchestration. + +## Internal contracts + +A transaction snapshot contains a complete CaddyConfig or null (idle), stable +DesiredState and current in-memory registration DTOs. The daemon supplies an +already independently observed, durably committed initial snapshot; this module +is not startup or lease restoration. Persist only DesiredState, never registration leases, +nonces or process ownership. Future handlers construct candidate state inside +preparation using the latest committed snapshot and existing adaptation/composition. + +Use the existing validation and persistence signatures without editing shared +ports. A small internal runtime interface supplies: + +- Apply a complete config or the idle/null target; return explicit applied, + definitely rejected or ambiguous outcome. Unexpected throws and outcomes without + an explicit no-transition guarantee are ambiguous, including typed failures + from the existing CaddyPort. Error kinds alone never prove rejection. + Null is a desired runtime target, not a mock success; the later owned lifecycle + backend must implement it. +- Independently read the observed active effective-config hash, or null only for + positively observed idle. Unavailable or undeterminable state is an observation + failure, never a successful null value. + Observation comes from protected runtime state, not cached receipts, candidate + bytes or desired intent. Backend normalization must make the observed hash + comparable with the target; real readback/mTLS implementation remains later. + +No product RPC, CaddyPort method or production backend is added. All dependencies +are required; missing runtime/storage adapters cannot become successful defaults. + +## Queue and publication + +Preparation callbacks run only after preceding queue work settles and receive +an isolated committed snapshot. Copy/validate/freeze candidate data before any +side effect; protect it from caller and adapter mutation. Validate complete +bounded/hash-consistent JSON using existing preparation checks and verify a +validator receipt matches that target. Skip config validation only for idle/null. + +Serialize apply -> authoritative observation -> atomic storage commit -> one +synchronous internal snapshot publication. There is no fallible external publish +callback after persistence; getters/results expose isolated snapshots. Read-only +queries see last committed data, plus a separate fence diagnostic when necessary, +not a pending state masquerading as authoritative. + +Keep the queue tail settled after errors, and reject queued mutations at execution +if an earlier operation fenced the runtime. An explicit close/drain may refuse new +admissions and await owned work; it does not release runtime exclusion or claim +product shutdown. Do not add a generic queue framework, event bus, background +worker, timer-based lease, receipt store or new configurable workflow machinery. +Existing phase adapters own deadlines and atomic settlement. Do not race a timer +and start later mutations while an older adapter can still perform side effects. + +## Failure state decisions + +- Prepare, shape/integrity or validation rejection: no apply, persistence or + publication; retain the prior committed snapshot and continue the queue. +- Definite apply rejection: backend guarantees no runtime transition; preserve + last-known-good without publishing or fencing solely for that rejection. +- Ambiguous apply/throw or failed/mismatched observation: keep committed state, + report a bounded typed diagnostic, fence mutations and require reconciliation. +- Persistence failure after verified apply: restore the prior complete config or + idle target and independently verify it. Keep the old committed snapshot and + return the storage failure. Failed/uncertain rollback remains fenced. +- Reconciliation is explicitly enqueued through the module's reconcile method by + the daemon caller, not automatically appended by a failure. FIFO mutations + before reconciliation are refused while fenced; a later queued mutation may + execute only after successful reconciliation clears the fence. This adds no RPC. + Observe current runtime; if it is not last-known-good, restore that target, then + observe again. Only independent proof of last-known-good clears the + fence. Never publish the abandoned candidate merely because it was observed. + Observation/restoration failure preserves the fence and last committed state. + +The storage adapter must satisfy STO-002: failed/rejected persistence is atomic +and leaves prior durable intent committed. This slice tests orchestration under +that contract, not the future SQLite worker's integrity/commit implementation. +Typed and thrown errors are bounded/redacted; arbitrary exception text must not +leak through results. A success receipt alone never proves apply or rollback. + +## Verification and migration + +Dedicated tests use fake adapters only in test code, with explicit deferred +promises rather than sleeps to prove single flight, fresh prepare inputs and +pending-read isolation. Cover every rejection/throw/mismatch, persistence failure, +first-apply idle rollback, rollback rejection/ambiguity/observation failure, +queued fencing/reconciliation, retry after recovery, mutation isolation and queue +drain. Include a prepare path consuming existing pure composition so callback +serialization is not merely a bare promise scheduler test. + +MiMo Flash critiques only this bounded ownership/failure plan; MiMo Pro reviews +completed source/tests independently. Parent integrates concrete findings, +performs simplification/fresh-eyes review and runs focused/full Node checks, +strict main/roadmap/schema and diff inspection. No real Caddy, SQLite worker, +registration handler, native privilege or public-product proof is inferred. + +Additive daemon source leaves the Rust baseline and previous Node leaf modules +unchanged. Actual secure backend/handler integration remains 3.6-3.9 and groups +4-5. Known OpenSpec implementation-schema archive failures remain explicit; +no fake delta requirements or custom validator repair is included. diff --git a/openspec/changes/migrate-caddy-transactions/proposal.md b/openspec/changes/migrate-caddy-transactions/proposal.md new file mode 100644 index 0000000..2577684 --- /dev/null +++ b/openspec/changes/migrate-caddy-transactions/proposal.md @@ -0,0 +1,67 @@ +## Why + +Preserve the existing project Caddyfile -> managed shim -> daemon -> operator +journey, including project/domain activation and rejected configuration changes. +The next capability serializes a complete configuration transaction through typed +adapters and exposes only a verified, durably committed snapshot. Failures retain +last-known-good or fence later mutations until runtime reconciliation succeeds. + +## Requirement IDs + +- `CADDY-003`: Serialized preparation, validation, apply, independent verification, + persistence and publication; rejection, rollback and ambiguous-outcome fencing. + +CADDY-002/004/006 and REG-002/004 supply composition and runtime dependencies; +STO-002 requires atomic persistence failure. This slice does not close their +whole-product acceptance or implement their missing backends. + +## Scope + +Implement roadmap 3.4-3.5 in a small daemon-owned transaction module with dedicated +concurrency/fault tests. Reuse CaddyConfig, DesiredState, registration DTOs, typed +ProtocolError/PortResult and existing validator/storage signatures. Introduce only +an internal runtime transaction boundary for explicit apply outcomes, authoritative +active observation and the idle/null target needed by first-apply rollback. + +Preparation is a domain callback executed inside the same queue, so adaptation, +composition and handler checks cannot overlap a preceding transaction. Publication +is one internal committed-snapshot assignment after verification and persistence, +not an external notification callback that can fail after durable commit. + +Completed protocol, preparation and composition changes remain unarchived because +of the known custom-schema validation blocker. This module consumes their leaf +contracts without modifying those sources. Only one source writer uses the +checkout; parent-owned planning/evidence is separate from worker-owned new files. + +## Non-goals + +No RPC/catalog/shared-port changes, product handlers, new Caddy parser, HTTP/Admin +API, certificates, secure config assembly, server process/lifecycle implementation, +SQLite application worker/schema, real installed Caddy, CLI/TUI, dependency, +packaging/tooling changes, Sandbox or host privilege/account actions. No production +mock or apply-success stub. Actual backends and product journeys remain later work. + +## Success criteria + +- Concurrent submissions serialize every phase and prepare against the newest + committed snapshot; one failed operation cannot poison the queue tail. +- Caller/adapters cannot mutate queued, pending or committed state by sharing + objects. Reads retain the old snapshot until verified persistence succeeds. +- Prepare/validation rejection and definite apply rejection preserve last-known-good. +- Successful apply is followed by independent active-state observation, never + receipt-only success. Storage failure restores and verifies last-known-good, + including the initially idle runtime, without publishing the rejected candidate. +- Unknown apply results, failed verification and failed rollback fence subsequent + mutations. Reconciliation uses the same queue, observes/restores/verifies the + last committed runtime target and alone may clear the fence. +- Focused tests cover phase order, controlled concurrent promises, all failures, + null/first-apply rollback, read isolation, poisoning, fencing and recovery; + full relevant Node checks pass above 85% own-code lines and MiMo findings are + reconciled without claiming actual backend/native/release acceptance. + +## Impact + +New daemon transaction source and dedicated tests; focused OpenSpec planning and +evidence, then existing roadmap 3.4-3.5 completion rows only after their checks. +No new requirements, public commands, RPC methods or publication authority. +G1/G2/G3 and final-product Sandbox remain open. diff --git a/openspec/changes/migrate-caddy-transactions/tasks.md b/openspec/changes/migrate-caddy-transactions/tasks.md new file mode 100644 index 0000000..4aacd4f --- /dev/null +++ b/openspec/changes/migrate-caddy-transactions/tasks.md @@ -0,0 +1,17 @@ +## 1. Serialized commits + +- [x] 1.1 `[CADDY-003]` Define and implement the narrow internal runtime observation/apply-outcome boundary and one transaction queue over existing carriers/validator/storage signatures (verification: explicit phase order, fresh committed prepare inputs and controlled concurrent promises). +- [x] 1.2 `[CADDY-003]` Isolate pending/caller/adapter data and publish only after independent verification and atomic persistence (verification: pending reads, input/result mutation, malformed/hash-mismatched candidate and receipt rejection; a failed operation does not poison the tail). + +## 2. Last-known-good recovery + +- [x] 2.1 `[CADDY-003]` Restore and verify the previous runtime target after persistence failure, including an initially idle runtime (verification: no rejected candidate publication and rollback apply/readback failure matrices). +- [x] 2.2 `[CADDY-003]` Fence ambiguous outcomes and failed recovery; serialize explicit active-state reconciliation before another mutation (verification: queued mutation refusal, observation/restoration errors, last-known-good restoration, clearing only after verification and post-recovery success). + +## 3. Evidence and closeout + +- [x] 3.1 `[CADDY-003]` Reconcile MiMo opinions/review, focused/full Node checks and requirement-to-evidence coverage (verification: >=85% own-code lines, strict main/roadmap/schema, diff review and explicit backend/native/archive limits). + +Checked tasks deliver roadmap 3.4-3.5 orchestration only. Caddy secure administration, +owned server lifecycle, SQLite application storage, product handlers and whole G3 +acceptance remain open. No production fake backend or ready-state stub is permitted. diff --git a/openspec/changes/migrate-caddy-transactions/verification.md b/openspec/changes/migrate-caddy-transactions/verification.md new file mode 100644 index 0000000..66565b6 --- /dev/null +++ b/openspec/changes/migrate-caddy-transactions/verification.md @@ -0,0 +1,116 @@ +## Summary + +Roadmap 3.4-3.5 orchestration and the focused implementation checks pass. +src/daemon/configuration-transactions.ts owns one queue from preparation through +internal committed-snapshot publication. Dedicated tests are in +test/configuration-transactions.test.ts. Required adapters perform actual +validation, runtime apply/readback and atomic DesiredState persistence; there are +no successful production defaults or mock backends. + +ConfigurationTransactions exposes submit, reconcile, readCommitted, readFence +and close. Initial state must already be independently observed and durably +committed. This is not startup readiness, a registration handler, a Caddy process, +a protected Admin API implementation or a SQLite application worker. + +Evidence applies to the uncommitted checkout based on HEAD +93942af84f555eab2bd68e0a4a9db487ff82d040. Existing dirty work is preserved. +The source/test additions and OpenSpec planning/evidence are the only surfaces +changed by this slice. No shared-port/RPC, dependency, Rust production, version, packaging, +host privilege/account, Sandbox, staging, commit or publication change occurred. + +This is not archive-ready. OpenSpec 1.5 still rejects the four spec-free +implementation changes with the known missing-delta mismatch. CADDY-003 as a +whole and G1/G2/G3 remain open pending backend/product/native acceptance. + +## Evidence + +| Requirement ID | Task | Evidence | Result | +| --- | --- | --- | --- | +| CADDY-003 | 1.1 | `nub run test -- test/configuration-transactions.test.ts`: explicit phase order, controlled concurrent promises, preparation against the latest committed snapshot, in-queue pure composition consumption, settled queue tail and close/drain ordering. | Pass for orchestration; no real Caddy execution | +| CADDY-003 | 1.2 | Same suite: pending reads stay old during every phase; initial/caller/adapter/return snapshots are private and frozen; strict candidate shape/hash/byte checks; validator hash mismatch; rejection/throws redact exception text and preserve the tail. | Pass; publication is one internal assignment after persistence | +| CADDY-003 | 2.1 | Same suite: rejected/thrown/invalid atomic persistence results restore complete last-known-good or idle/null, independently observe it and never publish the rejected candidate; rollback failure matrix sets a fence while retaining the storage failure. | Pass under STO-002 atomic-failure contract; SQLite backend not implemented | +| CADDY-003 | 2.2 | Same suite: explicit definite rejection versus ambiguous/throw/unknown/receipt-shaped apply outcomes, observation errors/mismatches, FIFO fenced refusal, explicit queued reconciliation, restoration failures and verified post-recovery changes. | Pass under truthful independent readback contract | +| CADDY-003 | 2.2 | Added first-apply regression: candidate is actually live in the fake adapter, unavailable observation remains fenced despite idle last-known-good, later truthful readback restores/verifies idle without publishing/persisting the abandoned candidate. | Pass; null means positively observed idle, never unknown | +| CADDY-003 | 3.1 | MiMo Flash plan critique, MiMo Pro source/test review, parent contract clarification/fresh-eyes pass and final focused/full Node/schema/main/roadmap/diff checks below. | Slice checks pass; global/archive/backend gates remain open | + +Final focused suite: **65 tests pass**. Worker increments passed 22 then 64 tests; +the parent added the idle/unknown-observation case and reran the full focused suite. + +Final parent `nub run check` passes TypeScript, ESLint, Prettier and V8 coverage: +**480 tests pass, two existing Unix-native tests skip, 28 test files pass**. +Own TS/TSX coverage is **1155/1191 lines (96.97%)**. Coverage-summary inspection +confirms configuration-transactions.ts has **92/93 lines (98.92%)** and +**50/50 branches (100%)**. The uncovered line is the queue-tail rejection +continuation, not a claimed 100% line/function result. Generated coverage remains +outside the checkout. + +## Independent review and disposition + +MiMo Flash raised three plan-level concerns: + +- Reconciliation admission: the implementation has an explicit queued reconcile + method, not automatic retries. FIFO work before reconciliation is refused while + fenced; a later operation runs only after verified clearing. The design now + states who calls/enqueues reconciliation and does not introduce another RPC. + Flash's proposed automatic recovery was not adopted as product authority. +- Unknown observation versus idle: MiMo Pro confirmed one P2 wording omission. + The source interface and design now require null only for positively observed + idle; undeterminable state must be an observation failure. The added regression + exercises that failure while an ambiguous first candidate is live and prevents + false fence clearing. No backend implementation or algorithm widening was added. +- Typed apply failures: only an explicit definitely-rejected outcome guarantees + no transition. Throws, malformed/receipt-shaped results and outcomes without + that guarantee are ambiguous; ProtocolError kinds are not certainty evidence. + Source schema/tests already enforce this, and design wording is now explicit. + +MiMo Pro independently read completed source/tests and returned **OK with notes**, +no P0/P1 and the single P2 contract-wording item above. The parent resolved that +item and tested it; this does not claim a separate later reviewer verdict. +The reviewer confirmed single flight, immutable candidates, independent readback, +DesiredState-only persistence, publication point, initial idle rollback, fencing +and bounded error settlement. Both reviews completed in the native workflow. + +Parent simplification/fresh-eyes inspection retained one domain-specific owner +rather than splitting a coherent transaction across a generic framework. No +additional substantive defect was found. All fake adapters live only in tests; +no test-only seam became a production success path. + +## Gate + +- [x] Every focused task and its stated orchestration checks is complete. +- [x] In-scope CADDY-003 aspects have evidence above. +- [x] Focused and full Node checks pass above 85% own-code lines. +- [x] `openspec schema validate implementation` passes. +- [x] `openspec validate --specs --strict --no-interactive`: 15 pass. +- [x] `openspec validate reset-cadder-architecture --strict --no-interactive` passes. +- [x] `git diff --check` passes; nothing is staged; no generated JS/DLL/EXE appears in src/test. +- [ ] `mise run openspec-check` passes: currently 17 items pass and four spec-free implementation changes fail strict delta validation. +- [ ] Archive is authorized and validation-compatible. + +OpenSpec verification instructions returned the known custom-schema unknown +`specs` rule warning along with the usable artifact instructions. No fake deltas, +checker replacement or validation-policy workaround was added. Active LSP probes +were not uniformly conclusive and auxiliary rules reported runtime-type and +large-class hints; maintained TypeScript/lint checks pass, not an LSP-clean claim. +The combined Rust/docs/npm/cargo-dist gate was not rerun for this Node-only slice; +previous Rust baseline evidence remains separate, not a new passing combined gate. + +## Residual risk + +Actual secure Caddy apply/readback and idle transitions must implement the internal +runtime boundary in 3.6-3.9. Hash readback must be independent and comparably +normalized; unavailable observation cannot be reported as idle. The future storage +worker must prove STO-002 atomic failure, including thrown/rejected persistence; +these test fakes do not prove SQLite commit or durability. + +The daemon caller must establish the independently observed/durable initial state +and explicitly enqueue reconciliation when recovering from a fence. Future +registration/heartbeat handlers must join or coordinate with the same ownership +boundary; this slice does not prove live lease merging or product RPC integration. +A fenced diagnostic describes recovery, but is not permission to automatically +retry an uncertain user operation. Phase adapters own bounded settlement: the +queue does not start later side effects while earlier work remains active. + +Native product, real Caddy, mTLS denial, distribution and final-product Windows +Sandbox acceptance remain open. The released Rust 1.0.5 operational baseline is +unchanged. No migration-complete or release-readiness claim is made. diff --git a/src/caddy/adapter.ts b/src/caddy/adapter.ts new file mode 100644 index 0000000..6747896 --- /dev/null +++ b/src/caddy/adapter.ts @@ -0,0 +1,62 @@ +import type { CaddyConfig, PortResult } from '../contracts/ports.ts'; +import type { EntrypointRegistration } from '../protocol/dto.ts'; +import { RealCaddyResolver } from './resolver.ts'; +import { absoluteCaddyPath } from './executable.ts'; +import { + CaddyPreparationError, + assertJsonBody, + configHash, + configurationCommandOptions, + decodeOutput, + preparationFailure, + type PreparationOptions, +} from './preparation.ts'; + +export type CaddyAdaptInput = Pick< + EntrypointRegistration, + 'sourceConfigPath' | 'sourceWorkingDirectory' | 'shimRun' +>; + +/** Adapt only; no registration mutation, composition or server lifecycle. */ +export class CaddyConfigAdapter { + constructor( + private readonly resolver: RealCaddyResolver, + private readonly options: PreparationOptions = {}, + ) {} + + async adapt(input: CaddyAdaptInput, signal?: AbortSignal): Promise> { + try { + const cwd = input.sourceWorkingDirectory.canonical ?? input.sourceWorkingDirectory.raw; + const path = input.sourceConfigPath.canonical ?? input.sourceConfigPath.raw; + const adapter = input.shimRun?.adapter ?? 'caddyfile'; + if ( + !absoluteCaddyPath(cwd) || + !path || + cwd.includes('\0') || + path.includes('\0') || + adapter.includes('\0') || + (input.sourceConfigPath.canonical !== null && !absoluteCaddyPath(path)) + ) + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Caddy adaptation requires an absolute source directory and a valid configuration path.', + ); + const image = await this.resolver.pin(signal); + const output = await image.run(['adapt', '--config', path, '--adapter', adapter], { + ...configurationCommandOptions(this.options, signal), + cwd, + }); + const body = decodeOutput(output, 'adapt'); + assertJsonBody(body); + return { + ok: true, + value: Object.freeze({ + effectiveConfigHash: configHash(body), + adaptedConfig: Object.freeze({ format: 'json', body }), + }), + }; + } catch (error) { + return { ok: false, error: preparationFailure(error, 'adapt') }; + } + } +} diff --git a/src/caddy/admin-client.ts b/src/caddy/admin-client.ts new file mode 100644 index 0000000..7fd54d7 --- /dev/null +++ b/src/caddy/admin-client.ts @@ -0,0 +1,266 @@ +import type { ClientRequest, IncomingMessage } from 'node:http'; +import { request } from 'node:https'; +import type { CaddyConfig, PortResult } from '../contracts/ports.ts'; +import type { ConfigurationApplyOutcome } from '../daemon/configuration-transactions.ts'; +import { normalizeCaddyConfig, type SecureAdminPolicy } from './admin-policy.ts'; +import { + assertCandidate, + caddyConfigurationTimeoutMs, + maxCaddyConfigurationBytes, +} from './preparation.ts'; + +export type AdminClientOptions = Readonly<{ + timeoutMs?: number; + maxResponseBytes?: number; + maxHeaderBytes?: number; +}>; +type PreparedPolicy = Pick; +type Reply = Readonly<{ status: number; body: string }>; + +function bounded(value: number | undefined, maximum: number): number { + if (value === undefined) return maximum; + if (!Number.isInteger(value) || value < 1 || value > maximum) + throw new Error('Invalid administration client bounds.'); + return value; +} + +/** Compare only the prepared security envelope; route assembly remains the authority. */ +function securityEnvelope(config: CaddyConfig): string { + const value = JSON.parse(config.adaptedConfig.body) as { + admin?: unknown; + apps?: { pki?: unknown }; + }; + if (!value.admin || !value.apps?.pki) + throw new Error('Missing administration security envelope.'); + return normalizeCaddyConfig(JSON.stringify({ admin: value.admin, pki: value.apps.pki })) + .adaptedConfig.body; +} + +/** + * HTTPS transport/readback only, not an owned-process ConfigurationRuntime. + * A completed /load acknowledgement is not active-state proof. The transaction + * queue must independently read back and compare before publishing a change. + */ +export class CaddyAdminClient { + readonly #client: SecureAdminPolicy['client']; + readonly #security: string; + readonly #timeoutMs: number; + readonly #maxResponseBytes: number; + readonly #maxHeaderBytes: number; + readonly #pending = new Set<() => void>(); + #closed = false; + + constructor(policy: PreparedPolicy, options: AdminClientOptions = {}) { + try { + const { host, port, servername, rejectUnauthorized, ca, cert, key } = policy.client; + if ( + host !== '127.0.0.1' || + !Number.isInteger(port) || + port < 1 || + port > 65535 || + servername !== 'localhost' || + rejectUnauthorized !== true || + [ca, cert, key].some((value) => typeof value !== 'string' || value.length === 0) + ) + throw new Error(); + this.#client = Object.freeze({ + host: '127.0.0.1', + port, + servername: 'localhost', + rejectUnauthorized: true, + ca, + cert, + key, + }); + assertCandidate(policy.config); + this.#security = securityEnvelope(normalizeCaddyConfig(policy.config.adaptedConfig.body)); + this.#timeoutMs = bounded(options.timeoutMs, caddyConfigurationTimeoutMs); + this.#maxResponseBytes = bounded(options.maxResponseBytes, maxCaddyConfigurationBytes); + this.#maxHeaderBytes = bounded(options.maxHeaderBytes, 16 * 1024); + } catch { + throw new Error('Invalid prepared administration policy or client bounds.'); + } + } + + /** Shared lifecycle guard, evaluated before validation or native creation. */ + accepts(config: CaddyConfig): boolean { + try { + assertCandidate(config); + const normalized = normalizeCaddyConfig(config.adaptedConfig.body); + return ( + normalized.adaptedConfig.body === config.adaptedConfig.body && + securityEnvelope(normalized) === this.#security + ); + } catch { + return false; + } + } + + async apply(config: CaddyConfig, signal?: AbortSignal): Promise { + let body: string; + try { + // Idle transitions belong to the owned-process lifecycle, not HTTPS administration. + if (this.#closed || config == null) return { status: 'definitely-rejected' }; + // Copy primitives before validation/network; never retain a caller-owned candidate. + const copy: CaddyConfig = { + adaptedConfig: { format: config.adaptedConfig.format, body: config.adaptedConfig.body }, + effectiveConfigHash: config.effectiveConfigHash, + }; + if (!this.accepts(copy)) return { status: 'definitely-rejected' }; + body = copy.adaptedConfig.body; + } catch { + return { status: 'definitely-rejected' }; + } + try { + const reply = await this.#request('POST', '/load', body, signal); + if (reply && reply.status >= 200 && reply.status < 300) return { status: 'applied' }; + // Caddy /load documents rollback on completed load/adapt failure (HTTP 400). + if (reply?.status === 400) return { status: 'definitely-rejected' }; + } catch { + // An unexpected transport exception cannot prove that no transition occurred. + } + return { status: 'ambiguous' }; + } + + /** A receipt is not process settlement; the owned runtime must still wait/force. */ + async stop(signal?: AbortSignal): Promise { + const reply = await this.#request('POST', '/stop', undefined, signal); + return { status: reply && reply.status >= 200 && reply.status < 300 ? 'applied' : 'ambiguous' }; + } + + async readActive(signal?: AbortSignal): Promise> { + try { + const reply = await this.#request('GET', '/config/', undefined, signal); + if (reply && reply.status >= 200 && reply.status < 300) + return { ok: true, value: normalizeCaddyConfig(reply.body).effectiveConfigHash }; + } catch { + // Unavailable, incomplete or invalid observation is never idle/null. + } + return { + ok: false, + error: { + kind: 'caddyRuntime', + code: 'caddy_admin_readback_failed', + message: 'Cannot observe the active Caddy configuration.', + guidance: null, + retryable: false, + requestId: null, + }, + }; + } + + /** Stops admissions and aborts only this client's in-flight requests. No retry. */ + close(): void { + this.#closed = true; + for (const abort of this.#pending) abort(); + } + + #request( + method: 'POST' | 'GET', + path: '/load' | '/config/' | '/stop', + body?: string, + signal?: AbortSignal, + ): Promise { + if (this.#closed || signal?.aborted) return Promise.resolve(null); + return new Promise((resolve) => { + let req: ClientRequest | undefined; + let response: IncomingMessage | undefined; + let settled = false; + const deadline = performance.now() + this.#timeoutMs; + const finish = (reply: Reply | null): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + this.#pending.delete(abort); + signal?.removeEventListener('abort', abort); + response?.destroy(); + req?.destroy(); + resolve(reply); + }; + const abort = (): void => finish(null); + const timer = setTimeout(abort, this.#timeoutMs); + this.#pending.add(abort); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) { + abort(); + return; + } + try { + req = request( + { + ...this.#client, + method, + path, + agent: false, + maxHeaderSize: this.#maxHeaderBytes, + headers: + body === undefined + ? {} + : { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body, 'utf8'), + }, + }, + (res) => { + response = res; + if (settled) { + res.destroy(); + return; + } + res.on('error', abort); + res.on('aborted', abort); + res.on('close', () => { + if (!res.complete) abort(); + }); + const declared = res.headers['content-length']; + if ( + (declared !== undefined && + (!/^\d+$/.test(declared) || Number(declared) > this.#maxResponseBytes)) || + (res.headers['content-encoding'] !== undefined && + res.headers['content-encoding'] !== 'identity') + ) { + abort(); + return; + } + const chunks: Buffer[] = []; + let bytes = 0; + res.on('data', (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > this.#maxResponseBytes || performance.now() >= deadline) { + abort(); + return; + } + chunks.push(chunk); + }); + res.on('end', () => { + if (settled) return; + if ( + !res.complete || + performance.now() >= deadline || + (declared !== undefined && Number(declared) !== bytes) + ) { + abort(); + return; + } + try { + const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode( + Buffer.concat(chunks, bytes), + ); + finish({ status: res.statusCode ?? 0, body: text }); + } catch { + abort(); + } + }); + }, + ); + req.on('error', abort); + req.on('close', () => { + if (!settled) abort(); + }); + req.end(body); + } catch { + abort(); + } + }); + } +} diff --git a/src/caddy/admin-policy.ts b/src/caddy/admin-policy.ts new file mode 100644 index 0000000..8d5b3bd --- /dev/null +++ b/src/caddy/admin-policy.ts @@ -0,0 +1,424 @@ +import { isAbsolute, join, parse, resolve } from 'node:path'; +import { types } from 'node:util'; +import type { CaddyConfig, PortResult, RuntimeOwner } from '../contracts/ports.ts'; +import { assertProtected, prepareRuntime } from '../platform/runtime-security.ts'; +import { loadOrCreateAdminMaterial } from './certificates.ts'; +import type { CaddyRoutePlan } from './composition.ts'; +import { canonicalizeDomain, compareText, type JsonObject, type JsonValue } from './domains.ts'; +import { + assertCandidate, + assertJsonBody, + CaddyPreparationError, + configHash, + maxCaddyConfigurationBytes, +} from './preparation.ts'; + +export type SecureAdminPolicyOptions = Readonly<{ + runtimeBase: string; + owner: RuntimeOwner; + materialDirectory: string; + adminPort: number; + plan: CaddyRoutePlan; + baseEnvironment?: Readonly; +}>; +export type SecureAdminPolicy = Readonly<{ + config: CaddyConfig; + paths: Readonly<{ + base: string; + data: string; + config: string; + scratch: string; + home: string; + defaultStorage: string; + autosaveDirectory: string; + }>; + environment: Readonly; + /** Use normal Node HTTPS identity verification, never a custom bypass or ambient CA. */ + client: Readonly<{ + host: '127.0.0.1'; + port: number; + servername: 'localhost'; + rejectUnauthorized: true; + ca: string; + cert: string; + key: string; + }>; +}>; + +function invalidCandidate(): never { + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Expected a complete, bounded JSON object with safe JSON values.', + ); +} + +/** No stringify(object), getters, toJSON, prototype assignment or proxy traversal. */ +function canonicalBody(input: unknown): string { + const parts: string[] = []; + const active = new WeakSet(); + let bytes = 0; + let visits = 0; + function emit(text: string): void { + bytes += Buffer.byteLength(text, 'utf8'); + if (bytes > maxCaddyConfigurationBytes) invalidCandidate(); + parts.push(text); + } + function visit(value: unknown, depth: number): void { + // Explicit bounds precede native recursion/serialization limits. + if (depth > 128 || ++visits > 1_000_000) invalidCandidate(); + if (value === null || typeof value === 'boolean') { + emit(String(value)); + } else if (typeof value === 'string') { + if (Buffer.byteLength(value, 'utf8') > maxCaddyConfigurationBytes) invalidCandidate(); + emit(JSON.stringify(value)); + } else if (typeof value === 'number' && Number.isFinite(value)) { + emit(JSON.stringify(value)); + } else if (typeof value === 'object') { + if (types.isProxy(value) || active.has(value)) invalidCandidate(); + const array = Array.isArray(value); + const prototype: unknown = Object.getPrototypeOf(value); + if ( + array ? prototype !== Array.prototype : prototype !== null && prototype !== Object.prototype + ) + invalidCandidate(); + const keys = Reflect.ownKeys(value); + if (keys.length > 1_000_000) invalidCandidate(); + const descriptors = Object.getOwnPropertyDescriptors(value); + if (keys.some((key) => typeof key !== 'string')) invalidCandidate(); + active.add(value); + if (array) { + const length = descriptors.length!.value as number; + if (length > 1_000_000 || keys.length !== length + 1) invalidCandidate(); + emit('['); + for (let index = 0; index < length; index++) { + const entry = descriptors[String(index)]; + if (!entry || !entry.enumerable || !('value' in entry)) invalidCandidate(); + if (index > 0) emit(','); + visit(entry.value, depth + 1); + } + emit(']'); + } else { + emit('{'); + const ordered = (keys as string[]).sort(compareText); + for (let index = 0; index < ordered.length; index++) { + const key = ordered[index]!; + const entry = descriptors[key]!; + if (!entry.enumerable || !('value' in entry)) invalidCandidate(); + if (index > 0) emit(','); + if (Buffer.byteLength(key, 'utf8') > maxCaddyConfigurationBytes) invalidCandidate(); + emit(JSON.stringify(key)); + emit(':'); + visit(entry.value, depth + 1); + } + emit('}'); + } + active.delete(value); + } else invalidCandidate(); + } + try { + visit(input, 0); + return parts.join(''); + } catch { + // Native failures and attacker-controlled exception messages never enter diagnostics. + return invalidCandidate(); + } +} + +function candidate(body: string): CaddyConfig { + const config: CaddyConfig = Object.freeze({ + adaptedConfig: Object.freeze({ format: 'json', body }), + effectiveConfigHash: configHash(body), + }); + assertCandidate(config); + return config; +} + +/** + * Normalize a complete submitted or independently fetched JSON object, not a receipt. + * Object keys sort lexically (including numeric keys); arrays and opaque data stay intact. + * No Caddy default-field elision: native effective-config differences remain differences. + * Depth >128 and >1M visited values are refused even below the 32 MiB byte bound. + */ +export function normalizeCaddyConfig(body: string): CaddyConfig { + try { + if (typeof body !== 'string') invalidCandidate(); + assertJsonBody(body); + const value: unknown = JSON.parse(body); + object(value); + return candidate(canonicalBody(value)); + } catch { + return invalidCandidate(); + } +} + +function object(value: unknown): JsonObject { + if (value === null || typeof value !== 'object' || Array.isArray(value)) invalidCandidate(); + return value as JsonObject; +} +function exactKeys(value: JsonObject, expected: readonly string[]): void { + const keys = Object.keys(value).sort(compareText); + if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) + invalidCandidate(); +} +function sameArray(value: JsonValue | undefined, expected: readonly string[]): boolean { + return ( + Array.isArray(value) && + value.length === expected.length && + value.every((v, i) => v === expected[i]) + ); +} + +/** Only the existing outer composeRoutePlan guards are policy; nested handlers are opaque. */ +function guardedPlan(input: CaddyRoutePlan): CaddyRoutePlan { + const plan = object(JSON.parse(canonicalBody(input)) as unknown); + exactKeys(plan, ['kind', 'servers', 'tlsSubjects']); + if (plan.kind !== 'caddy-route-plan' || !Array.isArray(plan.tlsSubjects)) invalidCandidate(); + const subjects = plan.tlsSubjects; + if ( + subjects.some((host) => typeof host !== 'string' || !host || canonicalizeDomain(host) !== host) + ) + invalidCandidate(); + const subjectSet = new Set(subjects as string[]); + const sorted = [...subjectSet].sort(compareText); + if (!sameArray(subjects, sorted)) invalidCandidate(); + const servers = object(plan.servers); + exactKeys(servers, ['cadder_http', 'cadder_https']); + const guards: string[][][] = []; + for (const [name, port] of [ + ['cadder_http', 80], + ['cadder_https', 443], + ] as const) { + const server = object(servers[name]); + exactKeys(server, ['listen', 'routes']); + if ( + !sameArray(server.listen, [`127.0.0.1:${port}`, `[::1]:${port}`]) || + !Array.isArray(server.routes) + ) + invalidCandidate(); + const seen = new Set(); + const hosts: string[][] = []; + for (const entry of server.routes) { + const route = object(entry); + exactKeys(route, ['handle', 'match', 'terminal']); + if ( + route.terminal !== true || + !Array.isArray(route.match) || + route.match.length !== 1 || + !Array.isArray(route.handle) || + route.handle.length !== 1 + ) + invalidCandidate(); + const matcher = object(route.match[0]); + const handler = object(route.handle[0]); + exactKeys(matcher, ['host']); + exactKeys(handler, ['handler', 'routes']); + if ( + !Array.isArray(matcher.host) || + matcher.host.length === 0 || + handler.handler !== 'subroute' || + !Array.isArray(handler.routes) || + handler.routes.some( + (child) => child === null || typeof child !== 'object' || Array.isArray(child), + ) + ) + invalidCandidate(); + for (const host of matcher.host) { + if (typeof host !== 'string' || !subjectSet.has(host) || seen.has(host)) invalidCandidate(); + seen.add(host); + } + hosts.push(matcher.host as string[]); + } + guards.push(hosts); + } + if (canonicalBody(guards[0]) !== canonicalBody(guards[1])) invalidCandidate(); + // SAFETY: the private JSON tree passed the exact route-plan/listener/guard checks above. + return plan as unknown as CaddyRoutePlan; +} + +const isolatedKeys = new Set([ + 'XDG_DATA_HOME', + 'XDG_CONFIG_HOME', + 'XDG_CACHE_HOME', + 'XDG_STATE_HOME', + 'XDG_RUNTIME_DIR', + 'HOME', + 'USERPROFILE', + 'HOMEDRIVE', + 'HOMEPATH', + 'APPDATA', + 'LOCALAPPDATA', + 'TEMP', + 'TMP', + 'TMPDIR', + 'CADDY_ADMIN', +]); +function childEnvironment( + paths: SecureAdminPolicy['paths'], + base: Readonly, +): Readonly { + const environment: NodeJS.ProcessEnv = Object.create(null) as NodeJS.ProcessEnv; + // Also remove differently cased keys on Unix: do not leave Windows aliases in the carrier. + for (const [key, value] of Object.entries(base)) { + if (!isolatedKeys.has(key.toUpperCase())) environment[key] = value; + } + Object.assign(environment, { + XDG_DATA_HOME: paths.data, + XDG_CONFIG_HOME: paths.config, + XDG_CACHE_HOME: paths.data, + XDG_STATE_HOME: paths.data, + XDG_RUNTIME_DIR: paths.scratch, + HOME: paths.home, + TEMP: paths.scratch, + TMP: paths.scratch, + TMPDIR: paths.scratch, + }); + if (process.platform === 'win32') { + const drive = parse(paths.home).root.replace(/[\\/]+$/, ''); + Object.assign(environment, { + USERPROFILE: paths.home, + HOMEDRIVE: drive, + HOMEPATH: paths.home.slice(drive.length), + APPDATA: paths.config, + LOCALAPPDATA: paths.data, + }); + } + return Object.freeze(environment); +} + +/** + * Caller holds the runtime lifetime lock. No server, port allocation or new lock is created. + * Pinned Caddy 2.11.4 uses XDG_* on ALL OSes for DefaultStorage/admin identity and autosave. + * Caddy owns admin leaf issuance/renewal; startup, mTLS denial and generated-file protection + * still need native acceptance. Directory checks here never repair existing unsafe paths. + */ +export async function prepareSecureAdminPolicy( + options: SecureAdminPolicyOptions, +): Promise> { + try { + if ( + !Number.isInteger(options.adminPort) || + options.adminPort < 1 || + options.adminPort > 65535 || + [80, 443].includes(options.adminPort) + ) + throw new CaddyPreparationError( + 'caddy_admin_policy_invalid', + 'Expected an explicit non-route administration port.', + ); + if (!isAbsolute(options.runtimeBase) || !isAbsolute(options.materialDirectory)) + throw new CaddyPreparationError( + 'caddy_admin_policy_invalid', + 'Administration runtime and material directories must be absolute.', + ); + const plan = guardedPlan(options.plan); + await prepareRuntime(options.runtimeBase, options.owner); + const base = resolve(options.runtimeBase); + const paths = Object.freeze({ + base, + data: join(base, 'data'), + config: join(base, 'config'), + scratch: join(base, 'scratch'), + home: join(base, 'home'), + defaultStorage: join(base, 'data', 'caddy'), + autosaveDirectory: join(base, 'config', 'caddy'), + }); + for (const directory of [ + paths.data, + paths.config, + paths.scratch, + paths.home, + paths.defaultStorage, + paths.autosaveDirectory, + ]) { + await prepareRuntime(directory, options.owner); + await assertProtected(directory, options.owner, true); + } + const material = await loadOrCreateAdminMaterial(options.materialDirectory, options.owner); + if (!material.ok) return material; + const { paths: pem, authorizedClientCertificateBase64, tls } = material.value; + const config = candidate( + canonicalBody({ + admin: { + disabled: true, + identity: { + identifiers: ['localhost'], + issuers: [{ module: 'internal', ca: 'cadder-admin' }], + }, + remote: { + listen: `127.0.0.1:${options.adminPort}`, + access_control: [{ public_keys: [authorizedClientCertificateBase64] }], + }, + }, + apps: { + pki: { + certificate_authorities: { + 'cadder-admin': { + install_trust: false, + root: { format: 'pem_file', certificate: pem.root.certificate }, + intermediate: { + format: 'pem_file', + certificate: pem.intermediate.certificate, + private_key: pem.intermediate.key, + }, + }, + local: { install_trust: false }, + }, + }, + http: { + servers: { + cadder_http: { ...plan.servers.cadder_http, automatic_https: { disable: true } }, + cadder_https: { + ...plan.servers.cadder_https, + automatic_https: { disable: true }, + tls_connection_policies: [{}], + }, + }, + }, + // The retained local/internal web issuer, distinct from the imported admin CA. + // An empty subject list must not become a catch-all issuance policy. + tls: { + automation: { + policies: + plan.tlsSubjects.length === 0 + ? [] + : [ + { + subjects: plan.tlsSubjects, + issuers: [{ module: 'internal', ca: 'local' }], + }, + ], + }, + certificates: { automate: plan.tlsSubjects }, + }, + }, + }), + ); + return { + ok: true, + value: Object.freeze({ + config, + paths, + environment: childEnvironment(paths, options.baseEnvironment ?? process.env), + client: Object.freeze({ + host: '127.0.0.1', + port: options.adminPort, + servername: 'localhost', + rejectUnauthorized: true, + ...tls, + }), + }), + }; + } catch (error) { + return { + ok: false, + error: { + kind: 'configuration', + code: error instanceof CaddyPreparationError ? error.code : 'caddy_admin_policy_failed', + message: 'Caddy administration policy or protected runtime was refused.', + guidance: null, + retryable: false, + requestId: null, + }, + }; + } +} diff --git a/src/caddy/certificates.ts b/src/caddy/certificates.ts new file mode 100644 index 0000000..78e9a56 --- /dev/null +++ b/src/caddy/certificates.ts @@ -0,0 +1,351 @@ +import 'reflect-metadata'; +import { webcrypto } from 'node:crypto'; +import { constants } from 'node:fs'; +import { open, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + AuthorityKeyIdentifierExtension, + BasicConstraintsExtension, + ExtendedKeyUsage, + ExtendedKeyUsageExtension, + KeyUsageFlags, + KeyUsagesExtension, + PemConverter, + SubjectKeyIdentifierExtension, + X509Certificate, + X509CertificateGenerator, +} from '@peculiar/x509'; +import type { PortResult, RuntimeOwner } from '../contracts/ports.ts'; +import { + assertProtected, + createProtectedFile, + prepareRuntime, +} from '../platform/runtime-security.ts'; +import { errorCode, type ProtocolError } from '../protocol/errors.ts'; + +// SAFETY: Node 24 WebCrypto implements the standard Crypto API expected by x509; +// its declaration uses Node-specific BufferSource types instead of DOM ones. +const crypto = webcrypto as unknown as Crypto; +const curve = { name: 'ECDSA', namedCurve: 'P-256' }; +const signingAlgorithm = { name: 'ECDSA', hash: 'SHA-256' }; +const day = 24 * 60 * 60 * 1000; +const roles = ['root', 'intermediate', 'client'] as const; +type Role = (typeof roles)[number]; +type PemIdentity = Readonly<{ certificate: string; key: string }>; +type Bundle = Record; +const maxFileBytes = 16 * 1024; +const issuerRoles = { root: 'root', intermediate: 'root', client: 'intermediate' } as const; +const pathLengths = { root: 1, intermediate: 0, client: undefined }; +const caUsage = KeyUsageFlags.keyCertSign | KeyUsageFlags.cRLSign; +const filenames = { + root: { certificate: 'root.crt.pem', key: 'root.key.pem' }, + intermediate: { certificate: 'intermediate.crt.pem', key: 'intermediate.key.pem' }, + client: { certificate: 'client.crt.pem', key: 'client.key.pem' }, +} as const; + +export type AdminMaterial = Readonly<{ + paths: Readonly>; + certificates: Readonly>; + /** Caddy access_control.public_keys expects leaf certificate DER, not SPKI. */ + authorizedClientCertificateBase64: string; + /** Internal TLS adapter credentials; never log or include this object in diagnostics. */ + tls: Readonly<{ ca: string; cert: string; key: string }>; +}>; + +const names: Record = { + root: 'CN=Cadder Admin Root', + intermediate: 'CN=Cadder Admin Intermediate', + client: 'CN=Cadder Admin Client', +}; + +class MaterialError extends Error { + constructor(readonly code: string) { + super('Caddy administration material was refused.'); + } +} + +function pemData(pem: string, tag: string): ArrayBuffer { + // Accept only one complete PEM block, not parser-tolerated trailing material. + const blocks = PemConverter.decodeWithHeaders(pem); + if ( + blocks.length !== 1 || + blocks[0]!.type !== tag || + blocks[0]!.headers.length !== 0 || + PemConverter.encode(blocks[0]!.rawData, tag).replace(/\s/g, '') !== pem.replace(/\s/g, '') + ) + throw new MaterialError('caddy_material_invalid'); + return blocks[0]!.rawData; +} + +async function generateBundle(): Promise { + // Fixed validity policy: 365-day years, root 10 years, intermediate 5, + // client 1. Backdate five minutes for initial skew. Caddy owns repeated + // short-lived server issuance; this module never rotates expired material. + const now = Date.now(); + const notBefore = new Date(now - 5 * 60 * 1000); + const years = { root: 10, intermediate: 5, client: 1 }; + const keys = {} as Record; + const bundle = {} as Bundle; + for (const role of roles) { + keys[role] = await crypto.subtle.generateKey(curve, true, ['sign', 'verify']); + const issuer = issuerRoles[role]; + const ca = role !== 'client'; + const certificate = await X509CertificateGenerator.create( + { + subject: names[role], + issuer: names[issuer], + publicKey: keys[role].publicKey, + signingKey: keys[issuer].privateKey, + signingAlgorithm, + notBefore, + notAfter: new Date(now + years[role] * 365 * day), + extensions: [ + new BasicConstraintsExtension(ca, pathLengths[role], true), + new KeyUsagesExtension(ca ? caUsage : KeyUsageFlags.digitalSignature, true), + ...(ca ? [] : [new ExtendedKeyUsageExtension([ExtendedKeyUsage.clientAuth], true)]), + await SubjectKeyIdentifierExtension.create(keys[role].publicKey, false, crypto), + await AuthorityKeyIdentifierExtension.create(keys[issuer].publicKey, false, crypto), + ], + }, + crypto, + ); + bundle[role] = { + certificate: certificate.toString('pem'), + key: PemConverter.encode( + await crypto.subtle.exportKey('pkcs8', keys[role].privateKey), + 'PRIVATE KEY', + ), + }; + } + return bundle; +} + +async function validateBundle(bundle: Bundle): Promise> { + try { + const certificates = {} as Record; + const publicKeys: string[] = []; + const now = Date.now(); + for (const role of roles) { + const certificate = new X509Certificate(pemData(bundle[role].certificate, 'CERTIFICATE')); + certificates[role] = certificate; + const ca = role !== 'client'; + const basic = certificate.getExtension(BasicConstraintsExtension); + const usage = certificate.getExtension(KeyUsagesExtension); + const eku = certificate.getExtension(ExtendedKeyUsageExtension); + const extensionTypes = certificate.extensions.map((extension) => extension.type); + if ( + certificate.subject !== names[role] || + certificate.signatureAlgorithm.name !== 'ECDSA' || + certificate.signatureAlgorithm.hash.name !== 'SHA-256' || + !basic?.critical || + basic.ca !== ca || + basic.pathLength !== pathLengths[role] || + !usage?.critical || + usage.usages !== (ca ? caUsage : KeyUsageFlags.digitalSignature) || + (ca + ? eku !== null + : !eku || + !eku.critical || + eku.usages.length !== 1 || + eku.usages[0] !== ExtendedKeyUsage.clientAuth) || + new Set(extensionTypes).size !== extensionTypes.length || + certificate.extensions.some( + (extension) => + extension.critical && + !( + extension instanceof BasicConstraintsExtension || + extension instanceof KeyUsagesExtension || + extension instanceof ExtendedKeyUsageExtension + ), + ) + ) + throw new MaterialError('caddy_material_invalid'); + if ( + !Number.isFinite(certificate.notBefore.getTime()) || + !Number.isFinite(certificate.notAfter.getTime()) || + certificate.notBefore.getTime() > now || + certificate.notAfter.getTime() <= now + ) + throw new MaterialError('caddy_material_validity'); + const publicKey = await certificate.publicKey.export(curve, ['verify'], crypto); + publicKeys.push(Buffer.from(certificate.publicKey.rawData).toString('base64')); + const privateKey = await crypto.subtle.importKey( + 'pkcs8', + pemData(bundle[role].key, 'PRIVATE KEY'), + curve, + false, + ['sign'], + ); + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const signature = await crypto.subtle.sign(signingAlgorithm, privateKey, challenge); + if (!(await crypto.subtle.verify(signingAlgorithm, publicKey, signature, challenge))) + throw new MaterialError('caddy_material_invalid'); + } + if (new Set(publicKeys).size !== roles.length) + throw new MaterialError('caddy_material_invalid'); + for (const role of roles) { + const certificate = certificates[role]; + const issuer = certificates[issuerRoles[role]]; + if ( + certificate.issuer !== issuer.subject || + certificate.notBefore < issuer.notBefore || + certificate.notAfter > issuer.notAfter || + !(await certificate.verify({ publicKey: issuer.publicKey, signatureOnly: true }, crypto)) + ) + throw new MaterialError('caddy_material_invalid'); + } + return certificates; + } catch (error) { + if (error instanceof MaterialError) throw error; + // Never return certificate/private-key parser or WebCrypto exception text. + throw new MaterialError('caddy_material_invalid'); + } +} + +async function readPem(path: string, owner: RuntimeOwner): Promise { + await assertProtected(path, owner); + const handle = await open( + path, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | constants.O_NONBLOCK, + ); + try { + const info = await handle.stat(); + if ( + !info.isFile() || + (process.platform !== 'win32' && (info.uid !== owner.uid || (info.mode & 0o777) !== 0o600)) + ) + throw new MaterialError('caddy_material_unsafe'); + if (info.size > maxFileBytes) throw new MaterialError('caddy_material_oversized'); + // One extra byte detects growth after stat. No unbounded readFile allocation. + const bytes = Buffer.alloc(maxFileBytes + 1); + let used = 0; + while (used < bytes.length) { + const { bytesRead } = await handle.read(bytes, used, bytes.length - used, null); + if (bytesRead === 0) break; + used += bytesRead; + } + if (used > maxFileBytes) throw new MaterialError('caddy_material_oversized'); + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, used)); + } catch { + throw new MaterialError('caddy_material_invalid'); + } + } finally { + await handle.close(); + } +} + +/** + * Caller must hold the runtime lifetime lock, and keep it while using material. + * Creates six fixed PEM files only when all six are absent. Existing identity + * is validated without writes, repair or rotation. Failure cleanup removes only + * exclusively created files; prepareRuntime's directory is left in place. + */ +export async function loadOrCreateAdminMaterial( + directory: string, + owner: RuntimeOwner, +): Promise> { + const created: string[] = []; + try { + await prepareRuntime(directory, owner); + const paths = {} as Record; + let present = 0; + for (const role of roles) { + paths[role] = { + certificate: join(directory, filenames[role].certificate), + key: join(directory, filenames[role].key), + }; + for (const path of Object.values(paths[role])) { + try { + await assertProtected(path, owner); + present += 1; + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error; + } + } + } + if (present !== 0 && present !== 6) throw new MaterialError('caddy_material_incomplete'); + let bundle: Bundle; + if (present === 0) { + try { + bundle = await generateBundle(); + } catch { + throw new MaterialError('caddy_material_generation_failed'); + } + } else { + bundle = {} as Bundle; + for (const role of roles) { + bundle[role] = { + certificate: await readPem(paths[role].certificate, owner), + key: await readPem(paths[role].key, owner), + }; + } + } + const certificates = await validateBundle(bundle); + if (present === 0) { + for (const role of roles) { + for (const field of ['certificate', 'key'] as const) { + const path = paths[role][field]; + if (!(await createProtectedFile(path, owner, bundle[role][field]))) + throw new MaterialError('caddy_material_conflict'); + created.push(path); + } + } + } + return { + ok: true, + value: { + paths, + certificates: { + root: bundle.root.certificate, + intermediate: bundle.intermediate.certificate, + client: bundle.client.certificate, + }, + authorizedClientCertificateBase64: certificates.client.toString('base64'), + tls: { + ca: bundle.root.certificate, + cert: `${bundle.client.certificate}\n${bundle.intermediate.certificate}`, + key: bundle.client.key, + }, + }, + }; + } catch (error) { + let cleanupFailed = false; + for (const path of created.reverse()) { + try { + await unlink(path); + } catch (cleanupError) { + if (errorCode(cleanupError) !== 'ENOENT') cleanupFailed = true; + } + } + let code = 'caddy_material_io'; + if (cleanupFailed) code = 'caddy_material_cleanup_failed'; + else if (error instanceof MaterialError) code = error.code; + else if (errorCode(error) === 'unsafe-runtime-permissions') code = 'caddy_material_unsafe'; + let kind: ProtocolError['kind'] = 'configuration'; + switch (code) { + case 'caddy_material_unsafe': + kind = 'accessDenied'; + break; + case 'caddy_material_conflict': + kind = 'conflict'; + break; + case 'caddy_material_io': + case 'caddy_material_cleanup_failed': + kind = 'storage'; + break; + } + return { + ok: false, + error: { + kind, + code, + message: 'Caddy administration material could not be created or validated.', + guidance: + 'Use a complete valid owner-protected bundle. I/O or cleanup failures may leave partial files; inspect the protected directory. Existing credentials are never repaired or rotated automatically.', + retryable: false, + requestId: null, + }, + }; + } +} diff --git a/src/caddy/composition.ts b/src/caddy/composition.ts new file mode 100644 index 0000000..860fc98 --- /dev/null +++ b/src/caddy/composition.ts @@ -0,0 +1,151 @@ +import type { CaddyConfig } from '../contracts/ports.ts'; +import { + canonicalizeDomain, + checkDomainOwnership, + compareText, + diagnostic, + maxCompositionDiagnostics, + readPreparedRoutes, + type CompositionDiagnostic, + type CompositionRegistration, + type CompositionResult, + type JsonObject, + type JsonValue, +} from './domains.ts'; + +export type PreparedCompositionInput = Readonly<{ + registration: CompositionRegistration; + config?: CaddyConfig; +}>; +export type RouteServer = Readonly<{ + listen: readonly string[]; + routes: readonly JsonObject[]; +}>; +/** Not a runnable CaddyConfig: secure admin/TLS assembly and apply belong to later work. */ +export type CaddyRoutePlan = Readonly<{ + kind: 'caddy-route-plan'; + servers: Readonly<{ cadder_http: RouteServer; cadder_https: RouteServer }>; + tlsSubjects: readonly string[]; +}>; + +/** Mutate only a private parsed tree, and only actual host matcher/subroute positions. */ +function filterRoute(route: JsonObject, hosts: ReadonlySet): JsonObject | null { + if (Array.isArray(route.match) && route.match.length > 0) { + const matchers: JsonObject[] = []; + for (const entry of route.match) { + const matcher = entry as JsonObject; + if (Array.isArray(matcher.host)) { + const retained = [ + ...new Set( + (matcher.host as string[]).map(canonicalizeDomain).filter((host) => hosts.has(host)), + ), + ]; + // An impossible matcher is removed as an OR alternative, never made hostless. + if (retained.length === 0) continue; + matcher.host = retained; + } + matchers.push(matcher); + } + if (matchers.length === 0) return null; + route.match = matchers; + } + if (Array.isArray(route.handle) && route.handle.length > 0) { + const handlers: JsonObject[] = []; + for (const entry of route.handle) { + const handler = entry as JsonObject; + if ( + handler.handler === 'subroute' && + Array.isArray(handler.routes) && + handler.routes.length > 0 + ) { + const children = filterRoutes(handler.routes as JsonObject[], hosts); + if (children.length === 0) continue; + handler.routes = children; + } + handlers.push(handler); + } + // An empty terminal route still stops the chain; keep its position and matchers. + if (handlers.length === 0 && route.terminal !== true) return null; + route.handle = handlers; + } + return route; +} + +function filterRoutes(routes: readonly JsonObject[], hosts: ReadonlySet): JsonObject[] { + const retained: JsonObject[] = []; + for (const route of routes) { + const filtered = filterRoute(route, hosts); + if (filtered !== null) retained.push(filtered); + } + return retained; +} + +function namespaceHttpIds(value: JsonValue): void { + if (Array.isArray(value)) { + for (const child of value) namespaceHttpIds(child); + } else if (value !== null && typeof value === 'object') { + if (typeof value['@id'] === 'string') value['@id'] = `http_${value['@id']}`; + for (const child of Object.values(value)) namespaceHttpIds(child); + } +} + +/** Stable registration-ID order, sorted server keys, original project route/handler order. */ +export function composeRoutePlan( + inputs: readonly PreparedCompositionInput[], +): CompositionResult { + const ownership = checkDomainOwnership(inputs.map((input) => input.registration)); + if (!ownership.ok) return ownership; + const diagnostics: CompositionDiagnostic[] = []; + const routes: JsonObject[] = []; + const httpRoutes: JsonObject[] = []; + const tlsSubjects = new Set(); + const ordered = [...inputs].sort((left, right) => + compareText(left.registration.registrationId, right.registration.registrationId), + ); + for (const input of ordered) { + const hosts = ownership.value.get(input.registration.registrationId)!; + if (hosts.length === 0) continue; + const parsed = readPreparedRoutes(input.config, [input.registration]); + if (!parsed.ok) { + diagnostics.push(...parsed.diagnostics); + continue; + } + try { + // The carrier is a string: JSON parsing already produced a private clone. + const retained = filterRoutes(parsed.value, new Set(hosts)); + for (const host of hosts) tlsSubjects.add(host); + if (retained.length === 0) continue; + const guard: JsonObject = { + match: [{ host: [...hosts] }], + handle: [{ handler: 'subroute', routes: retained }], + terminal: true, + }; + const httpGuard = structuredClone(guard); + namespaceHttpIds(httpGuard); + routes.push(guard); + httpRoutes.push(httpGuard); + } catch { + // Even complete bounded JSON may exceed the native clone/traversal capacity. + diagnostics.push( + diagnostic( + 'prepared-input-invalid', + 'Cannot safely clone and filter the complete adapted route tree.', + [input.registration], + ), + ); + } + } + if (diagnostics.length > 0) + return { ok: false, diagnostics: diagnostics.slice(0, maxCompositionDiagnostics) }; + return { + ok: true, + value: { + kind: 'caddy-route-plan', + servers: { + cadder_http: { listen: ['127.0.0.1:80', '[::1]:80'], routes: httpRoutes }, + cadder_https: { listen: ['127.0.0.1:443', '[::1]:443'], routes }, + }, + tlsSubjects: [...tlsSubjects].sort(compareText), + }, + }; +} diff --git a/src/caddy/configuration.ts b/src/caddy/configuration.ts new file mode 100644 index 0000000..feadb49 --- /dev/null +++ b/src/caddy/configuration.ts @@ -0,0 +1,152 @@ +import { constants } from 'node:fs'; +import { lstat, open, realpath } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { posix, win32 } from 'node:path'; +import { parse } from 'smol-toml'; +import { z } from 'zod'; +import { resolveInstallationRoot } from '../daemon/installation-root.ts'; +import { powershell } from '../platform/powershell.ts'; +import { absoluteCaddyPath } from './executable.ts'; +import { assertNotReparsePoint } from './executable.ts'; + +export type CaddySelection = Readonly<{ kind: 'command' | 'path'; value: string }>; +export type ConfigurationPaths = Readonly<{ portable: string; user: string; system: string }>; + +const selectors = z.strictObject({ + real_command: z.string().optional(), + real_path: z.string().optional(), +}); +const configuration = z.strictObject({ caddy: selectors.optional() }); + +/** Parse the released selectors only; source safety and executable checks are separate. */ +export function parseConfiguration(contents: string): CaddySelection | undefined { + const { caddy } = configuration.parse(parse(contents)); + if (!caddy) return undefined; + if (caddy.real_command !== undefined && caddy.real_path !== undefined) + throw new Error('caddy.real_command and caddy.real_path cannot both be configured.'); + if (caddy.real_command !== undefined) return { kind: 'command', value: caddy.real_command }; + if (caddy.real_path !== undefined) return { kind: 'path', value: caddy.real_path }; + return undefined; +} + +/** Only absence falls through. Broken links, unsafe files and invalid TOML fail closed. */ +export async function readConfiguration(path: string): Promise { + if (!absoluteCaddyPath(path)) throw new Error('Cadder configuration must use an absolute path.'); + try { + await lstat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + if (process.platform === 'win32') await assertNotReparsePoint(path); + const canonical = await realpath(path); + const file = await open(canonical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + if (!(await file.stat()).isFile()) + throw new Error('Cadder configuration must be a regular file.'); + return parseConfiguration( + new TextDecoder('utf-8', { fatal: true }).decode(await file.readFile()), + ); + } finally { + await file.close(); + } +} + +export interface ConfigurationLocationInput { + platform: NodeJS.Platform; + installationRoot: string; + home: string; + xdgConfigHome?: string | undefined; + knownFolders?: Readonly<{ roamingAppData: string; programData: string }> | undefined; +} + +/** Pure OS location calculation; modeled locations are not native platform evidence. */ +export function configurationLocations(input: ConfigurationLocationInput): ConfigurationPaths { + const path = input.platform === 'win32' ? win32 : posix; + if ( + !absoluteCaddyPath(input.installationRoot, input.platform) || + !absoluteCaddyPath(input.home, input.platform) + ) + throw new Error('Configuration roots must be absolute.'); + const portable = path.join(input.installationRoot, 'cadder.toml'); + if (input.platform === 'win32') { + const folders = input.knownFolders; + if ( + !folders || + !absoluteCaddyPath(folders.roamingAppData, 'win32') || + !absoluteCaddyPath(folders.programData, 'win32') + ) + throw new Error('Windows configuration requires OS known folders.'); + return { + portable, + user: win32.join(folders.roamingAppData, 'Cadder', 'Cadder', 'config', 'cadder.toml'), + system: win32.join(folders.programData, 'Cadder', 'cadder.toml'), + }; + } + if (input.platform === 'darwin') + return { + portable, + user: posix.join( + input.home, + 'Library', + 'Application Support', + 'dev.Cadder.Cadder', + 'cadder.toml', + ), + system: '/Library/Application Support/Cadder/cadder.toml', + }; + return { + portable, + user: posix.join( + input.xdgConfigHome && posix.isAbsolute(input.xdgConfigHome) + ? input.xdgConfigHome + : posix.join(input.home, '.config'), + 'cadder', + 'cadder.toml', + ), + system: '/etc/cadder/cadder.toml', + }; +} + +/** Physical npm package / SEA directory, never cwd, argv wrappers or shared Node. */ +export async function defaultConfigurationPaths(): Promise { + const knownFolders = + process.platform === 'win32' + ? (JSON.parse( + await powershell(` + @{ roamingAppData = [Environment]::GetFolderPath('ApplicationData'); + programData = [Environment]::GetFolderPath('CommonApplicationData') } | ConvertTo-Json -Compress + `), + ) as { roamingAppData: string; programData: string }) + : undefined; + return configurationLocations({ + platform: process.platform, + installationRoot: resolveInstallationRoot(), + home: homedir(), + xdgConfigHome: process.env.XDG_CONFIG_HOME, + knownFolders, + }); +} + +export type SelectionSource = 'override' | 'portable' | 'user' | 'system' | 'path'; +export type SelectedConfiguration = Readonly<{ + selection: CaddySelection; + source: SelectionSource; +}>; + +/** Daemon-owned inputs only. No project, shim argument or environment selector is accepted. */ +export async function selectConfiguration( + paths: ConfigurationPaths, + explicitOverride?: string, +): Promise { + if (explicitOverride !== undefined) { + if (!absoluteCaddyPath(explicitOverride)) + throw new Error('Real-Caddy override must be absolute.'); + return { selection: { kind: 'path', value: explicitOverride }, source: 'override' }; + } + for (const source of ['portable', 'user', 'system'] as const) { + const selection = await readConfiguration(paths[source]); + if (selection) return { selection, source }; + } + return { selection: { kind: 'command', value: 'caddy' }, source: 'path' }; +} diff --git a/src/caddy/domains.ts b/src/caddy/domains.ts new file mode 100644 index 0000000..33b694f --- /dev/null +++ b/src/caddy/domains.ts @@ -0,0 +1,253 @@ +import { domainToASCII } from 'node:url'; +import type { CaddyConfig } from '../contracts/ports.ts'; +import type { EntrypointRegistration } from '../protocol/dto.ts'; +import { assertCandidate } from './preparation.ts'; + +export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject; +export type JsonObject = { [key: string]: JsonValue }; +export type CompositionRegistration = Pick< + EntrypointRegistration, + 'registrationId' | 'activationState' | 'registeredDomains' | 'sourceConfigPath' +>; +export type CompositionDiagnostic = Readonly<{ + code: 'duplicate-registration' | 'domain-conflict' | 'prepared-input-invalid'; + message: string; + domainKey: string | null; + sourceConfigPaths: readonly string[]; +}>; +export type CompositionResult = + | Readonly<{ ok: true; value: T }> + | Readonly<{ ok: false; diagnostics: readonly CompositionDiagnostic[] }>; +export type ExtractedDomain = Readonly<{ canonicalDomain: string; upstream: string | null }>; +export const maxCompositionDiagnostics = 64; +const maxDiagnosticText = 4096; + +export function compareText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +/** Match the retained literal-domain IDNA boundary, not URL host parsing. */ +export function canonicalizeDomain(raw: string): string { + const domain = raw.trim().replace(/\.+$/, ''); + const fallback = domain.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); + // These characters would invoke URL decoding, separators or bracket-host parsing. + if ( + /[\s%/:\\@#?]/u.test(domain) || + domain.includes('[') || + domain.includes(']') || + [...domain].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) + ) + return fallback; + const suffix = '.cadder-idna'; + const mapped = domainToASCII(domain + suffix); + return mapped.endsWith(suffix) ? mapped.slice(0, -suffix.length) : fallback; +} + +export function isEnabled(state: CompositionRegistration['activationState']): boolean { + return state === 'registered' || state === 'activating' || state === 'active'; +} + +export function activeDomains(registration: CompositionRegistration): readonly string[] { + if (!isEnabled(registration.activationState)) return []; + return [ + ...new Set( + registration.registeredDomains + .filter((domain) => isEnabled(domain.activationState)) + .map((domain) => canonicalizeDomain(domain.name.canonical)), + ), + ].sort(compareText); +} + +/** Bound diagnostic payloads even when registration metadata is caller-controlled. */ +export function diagnostic( + code: CompositionDiagnostic['code'], + message: string, + registrations: readonly CompositionRegistration[], + domainKey: string | null = null, +): CompositionDiagnostic { + const paths = [...new Set(registrations.map((registration) => registration.sourceConfigPath.raw))] + .sort(compareText) + .slice(0, maxCompositionDiagnostics); + return { + code, + message: message.slice(0, maxDiagnosticText), + domainKey: domainKey?.slice(0, maxDiagnosticText) ?? null, + sourceConfigPaths: paths.map((path) => path.slice(0, maxDiagnosticText)), + }; +} + +/** Duplicate IDs are ambiguous even when inactive; domain ownership uses both states. */ +export function checkDomainOwnership( + registrations: readonly CompositionRegistration[], +): CompositionResult> { + const byId = new Map(); + for (const registration of registrations) { + const group = byId.get(registration.registrationId) ?? []; + group.push(registration); + byId.set(registration.registrationId, group); + } + const diagnostics: CompositionDiagnostic[] = []; + const active = new Map(); + const owners = new Map(); + for (const id of [...byId.keys()].sort(compareText)) { + const group = byId.get(id)!; + if (group.length > 1) { + diagnostics.push( + diagnostic('duplicate-registration', 'Registration ID occurs more than once.', group), + ); + continue; + } + const registration = group[0]!; + const hosts = activeDomains(registration); + active.set(id, hosts); + for (const host of hosts) { + const group = owners.get(host) ?? []; + group.push(registration); + owners.set(host, group); + } + } + for (const host of [...owners.keys()].sort(compareText)) { + const group = owners.get(host)!; + if (group.length > 1) + diagnostics.push( + diagnostic( + 'domain-conflict', + 'Domain is registered by multiple enabled entrypoints.', + group, + host, + ), + ); + } + return diagnostics.length > 0 + ? { ok: false, diagnostics: diagnostics.slice(0, maxCompositionDiagnostics) } + : { ok: true, value: active }; +} + +function isObject(value: JsonValue | undefined): value is JsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function object(value: JsonValue | undefined): JsonObject { + if (!isObject(value)) throw new Error('Expected object.'); + return value; +} + +function optionalObject(parent: JsonObject, key: string): JsonObject { + return parent[key] === undefined ? {} : object(parent[key]); +} + +function validateRoutes(value: JsonValue): JsonObject[] { + if (!Array.isArray(value)) throw new Error('Expected routes.'); + return value.map((entry) => { + const route = object(entry); + if (route.match !== undefined) { + if (!Array.isArray(route.match)) throw new Error('Expected matchers.'); + for (const entry of route.match) { + const matcher = object(entry); + if ( + matcher.host !== undefined && + (!Array.isArray(matcher.host) || matcher.host.some((host) => typeof host !== 'string')) + ) + throw new Error('Expected host strings.'); + } + } + if (route.handle !== undefined) { + if (!Array.isArray(route.handle)) throw new Error('Expected handlers.'); + for (const entry of route.handle) { + const handler = object(entry); + if (handler.handler === 'subroute' && handler.routes !== undefined) + validateRoutes(handler.routes); + } + } + return route; + }); +} + +/** Parse a complete integrity-checked carrier; never reinterpret opaque handler payloads. */ +export function readPreparedRoutes( + config: CaddyConfig | undefined, + registrations: readonly CompositionRegistration[] = [], +): CompositionResult { + try { + if (config === undefined) throw new Error('Missing prepared input.'); + assertCandidate(config); + const root = object(JSON.parse(config.adaptedConfig.body) as JsonValue); + const servers = optionalObject(optionalObject(optionalObject(root, 'apps'), 'http'), 'servers'); + const routes: JsonObject[] = []; + for (const name of Object.keys(servers).sort(compareText)) { + const server = object(servers[name]); + if (server.routes !== undefined) routes.push(...validateRoutes(server.routes)); + } + return { ok: true, value: routes }; + } catch { + return { + ok: false, + diagnostics: [ + diagnostic( + 'prepared-input-invalid', + 'Expected a complete, bounded, integrity-checked adapted HTTP route tree.', + registrations, + ), + ], + }; + } +} + +/** Visit only Caddy route positions; handler properties named host/routes remain opaque. */ +function nestedRoutes(route: JsonObject): JsonObject[] { + const routes: JsonObject[] = []; + for (const handler of (route.handle ?? []) as JsonObject[]) { + if (handler.handler === 'subroute') routes.push(...((handler.routes ?? []) as JsonObject[])); + } + return routes; +} + +function collectHosts(route: JsonObject, hosts: Set): void { + for (const matcher of (route.match ?? []) as JsonObject[]) { + for (const host of (matcher.host ?? []) as string[]) hosts.add(canonicalizeDomain(host)); + } + for (const child of nestedRoutes(route)) collectHosts(child, hosts); +} + +function firstUpstream(route: JsonObject): string | null { + for (const handler of (route.handle ?? []) as JsonObject[]) { + if (handler.handler === 'reverse_proxy' && Array.isArray(handler.upstreams)) { + for (const upstream of handler.upstreams) { + if (isObject(upstream) && typeof upstream.dial === 'string' && upstream.dial.trim() !== '') + return upstream.dial; + } + } + if (handler.handler === 'subroute') { + for (const child of (handler.routes ?? []) as JsonObject[]) { + const upstream = firstUpstream(child); + if (upstream !== null) return upstream; + } + } + } + return null; +} + +/** Retain the first upstream per top-level route and first occurrence of each domain. */ +export function extractRegisteredDomains( + config: CaddyConfig, +): CompositionResult { + const parsed = readPreparedRoutes(config); + if (!parsed.ok) return parsed; + const domains = new Map(); + for (const route of parsed.value) { + const hosts = new Set(); + collectHosts(route, hosts); + const upstream = firstUpstream(route); + for (const host of hosts) { + if (!domains.has(host)) domains.set(host, { canonicalDomain: host, upstream }); + } + } + return { + ok: true, + value: [...domains.values()].sort((left, right) => + compareText(left.canonicalDomain, right.canonicalDomain), + ), + }; +} diff --git a/src/caddy/executable.ts b/src/caddy/executable.ts new file mode 100644 index 0000000..92b4a95 --- /dev/null +++ b/src/caddy/executable.ts @@ -0,0 +1,71 @@ +import { constants, type BigIntStats } from 'node:fs'; +import { lstat, open, realpath, stat } from 'node:fs/promises'; +import { extname, posix, win32 } from 'node:path'; +import { powershell, psLiteral } from '../platform/powershell.ts'; + +export function fileIdentity(info: BigIntStats): string { + if (info.ino === 0n) throw new Error('Native executable file identity is unavailable.'); + return `${info.dev}:${info.ino}`; +} + +/** Match Rust absolute paths: Windows drive-rooted paths without a prefix are relative. */ +export function absoluteCaddyPath(value: string, platform = process.platform): boolean { + const path = platform === 'win32' ? win32 : posix; + return path.isAbsolute(value) && (platform !== 'win32' || path.parse(value).root.length > 1); +} + +/** Installed executables need not have runtime owner-only permissions or ACLs. */ +export async function nativeExecutable(path: string): Promise { + if (!absoluteCaddyPath(path) || path.includes('\0')) + throw new Error('Caddy executable must use an absolute path.'); + if (process.platform === 'win32') { + if (['.cmd', '.bat', '.ps1', '.js', '.cjs', '.mjs'].includes(extname(path).toLowerCase())) + throw new Error('Caddy executable must be native, not a command/script wrapper.'); + await assertNotReparsePoint(path); + } + const canonical = await realpath(path); + const file = await open(canonical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const info = await file.stat({ bigint: true }); + if (!info.isFile()) throw new Error('Caddy executable must be a regular file.'); + fileIdentity(info); + if (process.platform !== 'win32' && (info.mode & 0o111n) === 0n) + throw new Error('Caddy image is not executable.'); + const header = Buffer.alloc(4); + const { bytesRead } = await file.read(header, 0, 4, 0); + const native = + process.platform === 'win32' + ? header[0] === 0x4d && header[1] === 0x5a + : [ + '7f454c46', + 'feedface', + 'cefaedfe', + 'feedfacf', + 'cffaedfe', + 'cafebabe', + 'bebafeca', + 'cafebabf', + 'bfbafeca', + ].includes(header.toString('hex')); + if (bytesRead < 4 || !native) + throw new Error('Caddy executable must be a native image, not a script wrapper.'); + } finally { + await file.close(); + } + return canonical; +} + +export async function assertNotReparsePoint(path: string): Promise { + if ((await lstat(path)).isSymbolicLink()) + throw new Error('Caddy path must not be a Windows reparse point.'); + if (process.platform === 'win32') + await powershell(` + if (([IO.File]::GetAttributes(${psLiteral(path)}) -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Caddy path is a reparse point' } + `); +} + +/** Native dev/inode identity (bigint avoids Windows file-index precision loss). */ +export async function sameFile(left: string, right: string): Promise { + const [a, b] = await Promise.all([stat(left, { bigint: true }), stat(right, { bigint: true })]); + return fileIdentity(a) === fileIdentity(b); +} diff --git a/src/caddy/image.ts b/src/caddy/image.ts new file mode 100644 index 0000000..6ec30d7 --- /dev/null +++ b/src/caddy/image.ts @@ -0,0 +1,221 @@ +import { createHash } from 'node:crypto'; +import { constants, type BigIntStats } from 'node:fs'; +import { open, realpath, type FileHandle } from 'node:fs/promises'; +import { + runOwnedCommand, + type OwnedCommandOptions, + type OwnedCommandOutput, +} from '../platform/owned-command.ts'; +import { fileIdentity, nativeExecutable } from './executable.ts'; +import type { SelectionSource } from './configuration.ts'; + +export const minimumCaddyVersion = '2.11.4'; +export const compatibilityProbeRevision = 'cadder-v1-probe-1'; +export const requiredCaddyModules = Object.freeze([ + 'http', + 'http.encoders.gzip', + 'http.encoders.zstd', + 'http.handlers.encode', + 'http.handlers.file_server', + 'http.handlers.headers', + 'http.handlers.reverse_proxy', + 'http.handlers.rewrite', + 'http.handlers.static_response', + 'http.handlers.subroute', + 'http.matchers.header', + 'http.matchers.host', + 'http.matchers.method', + 'http.matchers.path', + 'http.matchers.query', + 'http.reverse_proxy.transport.http', + 'pki', + 'tls', + 'tls.issuance.internal', +]); +export type CompatibilityEvidence = Readonly<{ + version: string; + modules: readonly string[]; + probeRevision: string; +}>; + +export function parseVersion(output: Buffer): string { + const token = new TextDecoder('utf-8', { fatal: true }) + .decode(output) + .trim() + .split(/\s+/)[0] + ?.replace(/^v/, ''); + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/.exec( + token ?? '', + ); + if (!match) throw new Error('Cannot parse Caddy semantic version.'); + const major = BigInt(match[1]!); + const minor = BigInt(match[2]!); + const patch = BigInt(match[3]!); + // Baseline semver >=2.11.4, major<3: prerelease of the minimum is below it. + if ( + [major, minor, patch].some((part) => part > 0xffffffffffffffffn) || + major !== 2n || + minor < 11n || + (minor === 11n && (patch < 4n || (patch === 4n && match[4] !== undefined))) + ) + throw new Error(`Unsupported Caddy version; requires >=${minimumCaddyVersion}, major<3.`); + return token!; +} + +export function parseModules(output: Buffer): readonly string[] { + const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(output)); + if ( + !Array.isArray(value) || + value.some( + (item: unknown) => + !item || + typeof item !== 'object' || + typeof (item as { module_name?: unknown }).module_name !== 'string', + ) + ) + throw new Error('Invalid Caddy module inventory.'); + const modules = [ + ...new Set( + (value as { module_name: string }[]) + .map((item) => item.module_name) + .filter((name) => name.trim()), + ), + ].sort(); + const missing = requiredCaddyModules.filter((name) => !modules.includes(name)); + if (missing.length) throw new Error(`Caddy is missing required modules: ${missing.join(', ')}.`); + return Object.freeze(modules); +} + +function assertImage(info: BigIntStats): void { + if ( + !info.isFile() || + info.nlink === 0n || + (process.platform !== 'win32' && (info.mode & 0o111n) === 0n) + ) + throw new Error('Pinned Caddy image is not an executable regular file.'); +} +async function digest(file: FileHandle): Promise { + const before = await file.stat({ bigint: true }); + assertImage(before); + const hash = createHash('sha256'); + const buffer = Buffer.alloc(64 * 1024); + let position = 0; + while (BigInt(position) < before.size) { + const count = Number(before.size - BigInt(position)); + const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, count), position); + if (!bytesRead) throw new Error('Pinned Caddy image shrank while hashing.'); + hash.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + const after = await file.stat({ bigint: true }); + if ( + before.size !== after.size || + before.ctimeNs !== after.ctimeNs || + before.mtimeNs !== after.mtimeNs + ) + throw new Error('Pinned Caddy image changed while hashing.'); + return hash.digest('hex'); +} + +/** Open anchor + immutable native identity/content evidence; explicitly close after owned work. */ +export class PinnedCaddyImage { + private closed = false; + private compatibility: CompatibilityEvidence | undefined; + private active = 0; + private constructor( + readonly path: string, + readonly source: SelectionSource, + readonly identity: string, + readonly sha256: string, + private readonly anchor: FileHandle, + ) {} + + static async open(path: string, source: SelectionSource): Promise { + const canonical = await nativeExecutable(path); + const anchor = await open(canonical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const identity = fileIdentity(await anchor.stat({ bigint: true })); + const image = new PinnedCaddyImage(canonical, source, identity, await digest(anchor), anchor); + await image.verify(); + return image; + } catch (error) { + await anchor.close(); + throw error; + } + } + + get evidence(): CompatibilityEvidence { + if (!this.compatibility || this.closed) + throw new Error('Caddy compatibility evidence is unavailable.'); + return this.compatibility; + } + + async verify(): Promise { + if (this.closed) throw new Error('Pinned Caddy image is closed.'); + if ((await realpath(this.path)) !== this.path) + throw new Error('Pinned Caddy canonical path changed.'); + const current = await open(this.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const info = await current.stat({ bigint: true }); + assertImage(info); + if (fileIdentity(info) !== this.identity) + throw new Error('Pinned Caddy file identity changed.'); + if ((await digest(current)) !== this.sha256) + throw new Error('Pinned Caddy content digest changed.'); + } finally { + await current.close(); + } + } + + /** Stage 2 reuses this method; adapt/validate must specify 32 MiB per stream. */ + async run( + args: readonly string[], + options: Omit = {}, + ): Promise { + void this.evidence; + return await this.execute(args, options); + } + + private async execute( + args: readonly string[], + options: Omit, + ): Promise { + await this.verify(); + this.active++; + try { + return await runOwnedCommand(this.path, args, { + ...options, + beforeSpawn: () => this.verify(), + afterSpawn: () => this.verify(), + }); + } finally { + this.active--; + } + } + + async probe(options: Pick = {}): Promise { + if (this.compatibility) { + await this.verify(); + return; + } + const version = await this.execute(['version'], options); + if (version.exitCode !== 0) throw new Error('Caddy version probe failed.'); + const parsedVersion = parseVersion(version.stdout); + const modules = await this.execute(['list-modules', '--json'], options); + if (modules.exitCode !== 0) throw new Error('Caddy modules probe failed.'); + this.compatibility = Object.freeze({ + version: parsedVersion, + modules: parseModules(modules.stdout), + probeRevision: compatibilityProbeRevision, + }); + await this.verify(); + } + + async close(): Promise { + if (this.closed) return; + if (this.active) throw new Error('Cannot close pinned Caddy while an owned command is active.'); + this.closed = true; + await this.anchor.close(); + } +} diff --git a/src/caddy/preparation.ts b/src/caddy/preparation.ts new file mode 100644 index 0000000..dfa29d4 --- /dev/null +++ b/src/caddy/preparation.ts @@ -0,0 +1,128 @@ +import { createHash } from 'node:crypto'; +import type { CaddyConfig } from '../contracts/ports.ts'; +import type { ProtocolError } from '../protocol/errors.ts'; +import { + OwnedCommandError, + type OwnedCommandOptions, + type OwnedCommandOutput, +} from '../platform/owned-command.ts'; + +export const caddyConfigurationTimeoutMs = 30_000; +export const maxCaddyConfigurationBytes = 32 * 1024 * 1024; +export type PreparationOptions = Pick; + +export class CaddyPreparationError extends Error { + constructor( + readonly code: string, + message: string, + options?: ErrorOptions, + ) { + super(message.slice(0, 4096), options); + this.name = 'CaddyPreparationError'; + } +} + +export function configurationCommandOptions( + options: PreparationOptions, + signal?: AbortSignal, +): OwnedCommandOptions { + return { + ...options, + timeoutMs: Math.min( + options.timeoutMs ?? caddyConfigurationTimeoutMs, + caddyConfigurationTimeoutMs, + ), + maxStreamBytes: Math.min( + options.maxStreamBytes ?? maxCaddyConfigurationBytes, + maxCaddyConfigurationBytes, + ), + ...(signal === undefined ? {} : { signal }), + }; +} + +export function decodeOutput(output: OwnedCommandOutput, operation: 'adapt' | 'validate'): string { + const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + let stdout: string; + let stderr: string; + try { + stdout = decoder.decode(output.stdout); + stderr = decoder.decode(output.stderr); + } catch (error) { + throw new CaddyPreparationError( + 'caddy_output_invalid', + 'Caddy returned invalid UTF-8 output.', + { cause: error }, + ); + } + if (output.exitCode !== 0) + throw new CaddyPreparationError( + `caddy_${operation}_failed`, + `Caddy ${operation} failed: ${stderr.trim().slice(0, 4000)}`, + ); + return stdout; +} + +export function configHash(body: string): string { + return createHash('sha256').update(body, 'utf8').digest('hex'); +} + +export function assertJsonBody(body: string, maxBytes = maxCaddyConfigurationBytes): void { + if (Buffer.byteLength(body, 'utf8') > maxBytes) + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Caddy JSON candidate exceeds its byte bound.', + ); + const bytes = Buffer.from(body, 'utf8'); + if (new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes) !== body) + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Caddy JSON candidate has invalid encoding.', + ); + try { + JSON.parse(body); + } catch (error) { + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Caddy configuration candidate is not complete JSON.', + { cause: error }, + ); + } +} + +export function assertCandidate(config: CaddyConfig): void { + if ( + config?.adaptedConfig?.format !== 'json' || + typeof config.adaptedConfig.body !== 'string' || + typeof config.effectiveConfigHash !== 'string' + ) + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Expected a complete JSON Caddy candidate.', + ); + assertJsonBody(config.adaptedConfig.body); + if (config.effectiveConfigHash !== configHash(config.adaptedConfig.body)) + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Caddy candidate hash does not match its complete JSON body.', + ); +} + +/** Preserve typed bounded failures, never publish arbitrary resolver/project exception text. */ +export function preparationFailure(error: unknown, operation: 'adapt' | 'validate'): ProtocolError { + let code = `caddy_${operation}_failed`; + let message = `Cannot ${operation} the Caddy configuration.`; + let kind: ProtocolError['kind'] = 'caddyRuntime'; + if (error instanceof CaddyPreparationError) { + code = error.code; + message = error.message; + if (code === 'caddy_candidate_invalid') kind = 'configuration'; + } else if (error instanceof OwnedCommandError) { + code = `caddy_command_${error.code}`; + message = error.message; + if (error.code === 'timeout') kind = 'timeout'; + } else if (error instanceof AggregateError) { + code = 'caddy_cleanup_failed'; + message = 'Caddy command or staging cleanup failed.'; + } + return { kind, code, message, guidance: null, retryable: false, requestId: null }; +} diff --git a/src/caddy/resolver.ts b/src/caddy/resolver.ts new file mode 100644 index 0000000..c27e668 --- /dev/null +++ b/src/caddy/resolver.ts @@ -0,0 +1,229 @@ +import { lstat, readFile, realpath } from 'node:fs/promises'; +import { dirname, extname, join, parse, resolve, delimiter } from 'node:path'; +import { isSea } from 'node:sea'; +import { resolveInstallationRoot } from '../daemon/installation-root.ts'; +import { OwnedCommandError } from '../platform/owned-command.ts'; +import { + defaultConfigurationPaths, + selectConfiguration, + type ConfigurationPaths, + type SelectionSource, +} from './configuration.ts'; +import { absoluteCaddyPath, nativeExecutable, sameFile } from './executable.ts'; +import { PinnedCaddyImage } from './image.ts'; + +export type CaddyInstallation = Readonly<{ + root: string; + distribution: 'npm' | 'sea'; + executable?: string; +}>; +export interface CaddyResolverOptions { + explicitOverride?: string; + configurationPaths?: ConfigurationPaths; + installation?: CaddyInstallation; + /** Daemon startup environment snapshot, not project/shim selectors. */ + environment?: NodeJS.ProcessEnv; +} + +async function exists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} + +export async function installationEntries( + installation: CaddyInstallation, +): Promise { + if (!absoluteCaddyPath(installation.root)) + throw new Error('Cadder installation root must be absolute.'); + const root = await realpath(installation.root); + const entries: string[] = []; + const names = ['caddy', 'cadder', 'cadderd', 'cadder-caddy']; + if (installation.distribution === 'sea') { + if (!installation.executable || !absoluteCaddyPath(installation.executable)) + throw new Error('SEA installation requires its native executable.'); + entries.push(installation.executable); + for (const name of names) + entries.push(join(root, process.platform === 'win32' ? `${name}.exe` : name)); + } else { + let manifest: { name?: unknown; bin?: unknown }; + try { + manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as typeof manifest; + } catch (error) { + throw new Error('Cannot read the physical Cadder npm manifest.', { cause: error }); + } + if (manifest.name !== 'cadder') + throw new Error('Portable npm anchor is not the physical Cadder package.'); + const bin = manifest.bin; + if (typeof bin === 'string') entries.push(resolve(root, bin)); + else if (bin !== undefined) { + if (!bin || typeof bin !== 'object' || Array.isArray(bin)) + throw new Error('Invalid Cadder npm bin inventory.'); + for (const target of Object.values(bin)) { + if (typeof target !== 'string') throw new Error('Invalid Cadder npm bin target.'); + entries.push(resolve(root, target)); + } + } + // Local npm .bin and Windows/global npm wrappers sit outside the physical package. + let directory = root; + while (dirname(directory) !== directory) { + if (parse(directory).base === 'node_modules') { + const prefix = dirname(directory); + const wrappers = [join(directory, '.bin'), prefix, resolve(prefix, '..', 'bin')]; + for (const wrapper of wrappers) + for (const name of names) + for (const suffix of ['', '.cmd', '.ps1', '.exe']) + entries.push(join(wrapper, `${name}${suffix}`)); + break; + } + directory = dirname(directory); + } + } + return entries; +} + +async function rejectInstallationIdentity( + candidate: string, + entries: readonly string[], +): Promise { + for (const entry of entries) { + if ((await exists(entry)) && (await sameFile(candidate, entry))) + throw new Error('Selected Caddy aliases a Cadder distribution entry.'); + } +} + +function assertCommand(command: string): void { + if (!command || /[\s\\/\0:]/.test(command) || ['.', '..'].includes(command)) + throw new Error('Real-Caddy command must be a single program name without arguments.'); +} + +async function resolveNative( + path: string, + entries: readonly string[], + scoop: boolean, +): Promise { + let canonical = await nativeExecutable(path); + await rejectInstallationIdentity(canonical, entries); + if (scoop && process.platform === 'win32') { + const descriptor = canonical.slice(0, canonical.length - extname(canonical).length) + '.shim'; + if (await exists(descriptor)) { + const contents = await readFile(descriptor, 'utf8'); + const target = contents + .split(/\r?\n/) + .map((line) => /^\s*path\s*=\s*"([^"\0]+)"\s*$/.exec(line)?.[1]) + .find((value) => value !== undefined); + if (!target) throw new Error('Invalid native Scoop target descriptor.'); + canonical = await nativeExecutable( + absoluteCaddyPath(target) ? target : resolve(dirname(descriptor), target), + ); + await rejectInstallationIdentity(canonical, entries); + } + } + return canonical; +} + +export class RealCaddyResolver { + private readonly options: CaddyResolverOptions; + private readonly environment: NodeJS.ProcessEnv; + private pinned: Promise | undefined; + private readonly lifetime = new AbortController(); + private closed = false; + + constructor(options: CaddyResolverOptions = {}) { + this.options = { ...options }; + this.environment = { ...(options.environment ?? process.env) }; + } + + /** Evidence failures stay cached; caller cancellation stops only that caller's wait. */ + async pin(signal?: AbortSignal): Promise { + if (this.closed) throw new Error('Caddy resolver is closed.'); + if (signal?.aborted) throw new OwnedCommandError('abort', 'Owned command cancelled.'); + const pinned = (this.pinned ??= this.capture()); + if (!signal) return await pinned; + return await new Promise((resolve, reject) => { + const abort = () => reject(new OwnedCommandError('abort', 'Owned command cancelled.')); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + void pinned.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + }); + } + + private async capture(): Promise { + const sea = isSea(); + const installation = this.options.installation ?? { + root: resolveInstallationRoot(), + distribution: sea ? 'sea' : 'npm', + ...(sea ? { executable: process.execPath } : {}), + }; + const paths = this.options.configurationPaths ?? { + ...(await defaultConfigurationPaths()), + portable: join(await realpath(installation.root), 'cadder.toml'), + }; + const entries = await installationEntries(installation); + const selected = await selectConfiguration(paths, this.options.explicitOverride); + let path: string; + if (selected.selection.kind === 'path') + path = await resolveNative(selected.selection.value, entries, false); + else path = await this.onPath(selected.selection.value, entries); + const image = await PinnedCaddyImage.open(path, selected.source); + try { + await image.probe({ env: this.environment, signal: this.lifetime.signal }); + return image; + } catch (error) { + await image.close(); + throw error; + } + } + + private async onPath(command: string, entries: readonly string[]): Promise { + assertCommand(command); + const key = Object.keys(this.environment).find((name) => + process.platform === 'win32' ? name.toLowerCase() === 'path' : name === 'PATH', + ); + const value = key === undefined ? undefined : this.environment[key]; + if (value === undefined) throw new Error('PATH is not set.'); + for (const directory of pathDirectories(value)) { + if (!absoluteCaddyPath(directory)) continue; + const candidate = join( + directory, + process.platform === 'win32' && !extname(command) ? `${command}.exe` : command, + ); + if (!(await exists(candidate))) continue; + try { + return await resolveNative(candidate, entries, true); + } catch { + /* Unsafe PATH candidates cannot select an image; retain PATH order among safe files. */ + } + } + throw new Error('Trusted native Caddy command was not found on safe absolute PATH.'); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.lifetime.abort(); + if (this.pinned) { + const image = await this.pinned.catch(() => undefined); + await image?.close(); + } + } +} + +/** Windows split_paths semantics: quotes protect separators, not relative entries. */ +function pathDirectories(value: string): string[] { + if (process.platform !== 'win32') return value.split(delimiter); + const directories = ['']; + let quoted = false; + for (const character of value) { + if (character === '"') quoted = !quoted; + else if (character === ';' && !quoted) directories.push(''); + else directories[directories.length - 1] += character; + } + return directories; +} + +export type { SelectionSource }; diff --git a/src/caddy/runtime.ts b/src/caddy/runtime.ts new file mode 100644 index 0000000..82d395b --- /dev/null +++ b/src/caddy/runtime.ts @@ -0,0 +1,355 @@ +import { randomUUID } from 'node:crypto'; +import { unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { CaddyConfig, PortResult, RuntimeOwner } from '../contracts/ports.ts'; +import type { + ConfigurationApplyOutcome, + ConfigurationRuntime, +} from '../daemon/configuration-transactions.ts'; +import { OwnedCommandError } from '../platform/owned-command.ts'; +import { + assertProtected, + assertRuntimeDescendant, + createProtectedFile, +} from '../platform/runtime-security.ts'; +import { CaddyAdminClient } from './admin-client.ts'; +import type { SecureAdminPolicy } from './admin-policy.ts'; +import type { RealCaddyResolver } from './resolver.ts'; +import { CaddyConfigValidator } from './validation.ts'; + +export type CaddyRuntimeOptions = Readonly<{ + startupMs?: number; + stopMs?: number; + pollMs?: number; +}>; +type Execution = { + abort: AbortController; + settled: Promise; + completed: boolean; + started: boolean; + clean: boolean; + staged: string; +}; +const rejected: ConfigurationApplyOutcome = Object.freeze({ status: 'definitely-rejected' }); +const ambiguous: ConfigurationApplyOutcome = Object.freeze({ status: 'ambiguous' }); +const applied: ConfigurationApplyOutcome = Object.freeze({ status: 'applied' }); + +function bound(value: number | undefined, fallback: number): number { + const result = value ?? fallback; + if (!Number.isInteger(result) || result < 1 || result > 30_000) + throw new Error('Invalid Caddy lifecycle bounds.'); + return result; +} +function unavailable(): PortResult { + return { + ok: false, + error: { + kind: 'caddyRuntime', + code: 'caddy_runtime_unavailable', + message: 'Cannot prove the owned Caddy runtime state.', + guidance: null, + retryable: false, + requestId: null, + }, + }; +} + +/** One caller-owned resolver, one prepared identity, one owned execution. No automatic restart. */ +export class CaddyRuntime implements ConfigurationRuntime { + readonly #policy: SecureAdminPolicy; + readonly #owner: RuntimeOwner; + readonly #admin: CaddyAdminClient; + readonly #validator: CaddyConfigValidator; + readonly #startupMs: number; + readonly #stopMs: number; + readonly #pollMs: number; + #execution: Execution | undefined; + #uncertain = false; + #closed = false; + #tail: Promise = Promise.resolve(); + #operation: AbortController | undefined; + #closing: Promise | undefined; + + constructor( + policy: SecureAdminPolicy, + owner: RuntimeOwner, + private readonly resolver: RealCaddyResolver, + options: CaddyRuntimeOptions = {}, + ) { + // Copy all startup primitives; no project environment or mutable policy is retained. + this.#policy = Object.freeze({ + config: Object.freeze({ + adaptedConfig: Object.freeze({ ...policy.config.adaptedConfig }), + effectiveConfigHash: policy.config.effectiveConfigHash, + }), + paths: Object.freeze({ ...policy.paths }), + environment: Object.freeze({ ...policy.environment }), + client: Object.freeze({ ...policy.client }), + }); + this.#owner = Object.freeze({ ...owner }); + this.#startupMs = bound(options.startupMs, 30_000); + this.#stopMs = bound(options.stopMs, 5000); + this.#pollMs = bound(options.pollMs, 50); + this.#admin = new CaddyAdminClient(this.#policy); + this.#validator = new CaddyConfigValidator(resolver, { env: this.#policy.environment }); + } + + apply(config: CaddyConfig | null): Promise { + if (this.#closed) return Promise.resolve(rejected); + let candidate: CaddyConfig | null = null; + if (config !== null) { + try { + // Snapshot before entering the queue, and refuse noncanonical/security-changing input. + candidate = Object.freeze({ + adaptedConfig: Object.freeze({ ...config.adaptedConfig }), + effectiveConfigHash: config.effectiveConfigHash, + }); + if (!this.#admin.accepts(candidate)) return Promise.resolve(rejected); + } catch { + return Promise.resolve(rejected); + } + } + return this.#enqueue(async (signal) => { + if (candidate === null) return await this.#stop(signal); + if (this.#uncertain) return ambiguous; + if (this.#execution?.completed) { + if (!(await this.#release())) return ambiguous; + } + if (this.#execution) return await this.#admin.apply(candidate, signal); + return await this.#start(candidate, signal); + }, rejected); + } + + readActive(): Promise> { + return this.#enqueue(async (signal) => { + if (this.#uncertain || this.#execution?.completed) return unavailable(); + if (!this.#execution) return { ok: true, value: null }; + const observed = await this.#admin.readActive(signal); + // An exit during observation cannot be cached as successful active state. + return this.#execution.completed ? unavailable() : observed; + }, unavailable()); + } + + /** Refuse admission immediately, cancel pending admin/start work, then drain native work/staging. */ + close(): Promise { + if (this.#closing) return this.#closing; + this.#closed = true; + this.#operation?.abort(); + if (this.#execution && !this.#execution.started) this.#execution.abort.abort(); + this.#closing = (async () => { + await this.#tail; + try { + const result = await this.#stop(new AbortController().signal); + if (result.status !== 'applied') + throw new Error('Owned Caddy teardown could not be proved.'); + } finally { + this.#admin.close(); + } + })(); + return this.#closing; + } + + #enqueue(operation: (signal: AbortSignal) => Promise, refused: T): Promise { + if (this.#closed) return Promise.resolve(refused); + const result = this.#tail.then(async () => { + if (this.#closed) return refused; + const abort = new AbortController(); + this.#operation = abort; + try { + return await operation(abort.signal); + } finally { + this.#operation = undefined; + } + }); + this.#tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async #roots(signal: AbortSignal): Promise { + const paths = this.#policy.paths; + for (const name of ['data', 'config', 'scratch', 'home'] as const) { + if (paths[name] !== join(paths.base, name)) throw new Error('Invalid runtime roots.'); + } + if ( + paths.defaultStorage !== join(paths.data, 'caddy') || + paths.autosaveDirectory !== join(paths.config, 'caddy') + ) + throw new Error('Invalid runtime roots.'); + for (const path of [paths.base, paths.data, paths.config, paths.scratch, paths.home]) { + if (signal.aborted) return; + await assertProtected(path, this.#owner, true); + } + if (signal.aborted) return; + await assertRuntimeDescendant(paths.data, paths.defaultStorage, this.#owner, true); + if (signal.aborted) return; + await assertRuntimeDescendant(paths.config, paths.autosaveDirectory, this.#owner, true); + } + + async #start(candidate: CaddyConfig, outer: AbortSignal): Promise { + const abort = new AbortController(); + const cancel = (): void => abort.abort(); + outer.addEventListener('abort', cancel, { once: true }); + if (outer.aborted) cancel(); + const timer = setTimeout(cancel, this.#startupMs); + let staged: string | undefined; + try { + await this.#roots(abort.signal); + if (abort.signal.aborted) return rejected; + // Persisted TLS identity authenticates a peer, not ownership of this execution. + // Refuse an existing endpoint rather than adopting or stopping its process. + if ((await this.#admin.readActive(abort.signal)).ok || abort.signal.aborted) return rejected; + const validation = await this.#validator.validate(candidate, abort.signal); + if (!validation.ok || abort.signal.aborted) return rejected; + const image = await this.resolver.pin(abort.signal); + if (abort.signal.aborted) return rejected; + const path = join(this.#policy.paths.scratch, `caddy-${randomUUID()}.json`); + if (!(await createProtectedFile(path, this.#owner, candidate.adaptedConfig.body))) + return rejected; + staged = path; + await assertProtected(path, this.#owner); + if (abort.signal.aborted) return rejected; + const execution: Execution = { + abort: new AbortController(), + completed: false, + started: false, + clean: false, + staged: path, + settled: Promise.resolve(), + }; + this.#execution = execution; + staged = undefined; // The execution now owns staging until positive settlement. + let notifyStarted!: () => void; + const ready = new Promise((resolve) => { + notifyStarted = resolve; + }); + execution.settled = image + .run(['run', '--config', path], { + longLived: true, + signal: execution.abort.signal, + env: this.#policy.environment, + cwd: this.#policy.paths.scratch, + onStarted: () => { + execution.started = true; + notifyStarted(); + }, + }) + .then( + () => { + execution.clean = true; + }, + (error: unknown) => { + execution.clean = error instanceof OwnedCommandError && error.code === 'abort'; + }, + ) + .then(() => { + execution.completed = true; + }); + void execution.settled.then(cancel); // Early exit aborts a pending startup readback. + await this.#waitForStart(execution, ready, abort.signal); + while (execution.started && !abort.signal.aborted && !execution.completed) { + const observed = await this.#admin.readActive(abort.signal); + if (observed.ok) { + if ( + observed.value === candidate.effectiveConfigHash && + !execution.completed && + !abort.signal.aborted + ) + return applied; + break; // Authenticated mismatch is a failed startup, not a retry receipt. + } + await this.#pause(abort.signal); + } + execution.abort.abort(); + await execution.settled; + await this.#release(); + return ambiguous; + } catch { + if (this.#execution) { + this.#execution.abort.abort(); + await this.#execution.settled; + await this.#release(); + return ambiguous; + } + return rejected; + } finally { + clearTimeout(timer); + outer.removeEventListener('abort', cancel); + if (staged !== undefined) { + try { + await assertProtected(staged, this.#owner); + await unlink(staged); + } catch { + this.#uncertain = true; + } + } + } + } + + #waitForStart(execution: Execution, ready: Promise, signal: AbortSignal): Promise { + if (signal.aborted || execution.completed) return Promise.resolve(); + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish); + resolve(); + }; + signal.addEventListener('abort', finish, { once: true }); + void ready.then(finish); + void execution.settled.then(finish); + }); + } + + #pause(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const finish = (): void => { + clearTimeout(timer); + signal.removeEventListener('abort', finish); + resolve(); + }; + const timer = setTimeout(finish, this.#pollMs); + signal.addEventListener('abort', finish, { once: true }); + }); + } + + async #stop(signal: AbortSignal): Promise { + const execution = this.#execution; + if (!execution) return this.#uncertain ? ambiguous : applied; + if (execution.started && !execution.completed && !signal.aborted) { + const grace = new AbortController(); + const cancel = (): void => grace.abort(); + signal.addEventListener('abort', cancel, { once: true }); + const timer = setTimeout(cancel, this.#stopMs); + try { + await this.#admin.stop(grace.signal); + while (!execution.completed && !grace.signal.aborted) await this.#pause(grace.signal); + } finally { + clearTimeout(timer); + signal.removeEventListener('abort', cancel); + } + } + execution.abort.abort(); + await execution.settled; + return (await this.#release()) ? applied : ambiguous; + } + + async #release(): Promise { + const execution = this.#execution; + if (!execution?.completed || !execution.clean) { + this.#uncertain = true; + return false; + } + try { + await assertProtected(execution.staged, this.#owner); + await unlink(execution.staged); + this.#execution = undefined; + this.#uncertain = false; + return true; + } catch { + this.#uncertain = true; + return false; + } + } +} diff --git a/src/caddy/validation.ts b/src/caddy/validation.ts new file mode 100644 index 0000000..dbc1f05 --- /dev/null +++ b/src/caddy/validation.ts @@ -0,0 +1,65 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { CaddyConfig, CaddyPort, CaddyValidation, PortResult } from '../contracts/ports.ts'; +import { RealCaddyResolver } from './resolver.ts'; +import { + CaddyPreparationError, + assertCandidate, + configurationCommandOptions, + decodeOutput, + preparationFailure, + type PreparationOptions, +} from './preparation.ts'; + +/** Implements the existing validation signature only; deliberately has no apply method. */ +export class CaddyConfigValidator implements Pick { + constructor( + private readonly resolver: RealCaddyResolver, + private readonly options: PreparationOptions = {}, + ) {} + + async validate(config: CaddyConfig, signal?: AbortSignal): Promise> { + let directory: string | undefined; + let failure: unknown; + let validated: CaddyValidation | undefined; + try { + assertCandidate(config); + const body = config.adaptedConfig.body; + const effectiveConfigHash = config.effectiveConfigHash; + const image = await this.resolver.pin(signal); + directory = await mkdtemp(join(tmpdir(), 'cadder-caddy-validation-')); + const path = join(directory, 'candidate.json'); + await writeFile(path, body, { flag: 'wx', mode: 0o600 }); + const output = await image.run(['validate', '--config', path], { + ...configurationCommandOptions(this.options, signal), + cwd: directory, + }); + decodeOutput(output, 'validate'); + validated = Object.freeze({ effectiveConfigHash, diagnostics: Object.freeze([]) }); + } catch (error) { + failure = error; + } + // run() has completed all owned child/pipe finalizers before owned staging is removed. + if (directory !== undefined) { + try { + await rm(directory, { recursive: true, maxRetries: 3, retryDelay: 100 }); + } catch (error) { + failure = new CaddyPreparationError( + 'caddy_cleanup_failed', + 'Caddy validation staging cleanup failed.', + { + cause: + failure === undefined + ? error + : new AggregateError([failure, error], 'Validation and staging cleanup failed.', { + cause: failure, + }), + }, + ); + } + } + if (validated !== undefined && failure === undefined) return { ok: true, value: validated }; + return { ok: false, error: preparationFailure(failure, 'validate') }; + } +} diff --git a/src/daemon/configuration-transactions.ts b/src/daemon/configuration-transactions.ts new file mode 100644 index 0000000..6ccc0e7 --- /dev/null +++ b/src/daemon/configuration-transactions.ts @@ -0,0 +1,324 @@ +import { z } from 'zod'; +import { assertCandidate } from '../caddy/preparation.ts'; +import type { + CaddyConfig, + CaddyPort, + DesiredState, + PortResult, + StoragePort, +} from '../contracts/ports.ts'; +import { registrationSchema, type EntrypointRegistration } from '../protocol/dto.ts'; +import { RpcError, type ProtocolError } from '../protocol/errors.ts'; + +export type ConfigurationSnapshot = Readonly<{ + config: CaddyConfig | null; + desiredState: DesiredState; + registrations: readonly EntrypointRegistration[]; +}>; + +/** Rejection guarantees no transition; throws and unrecognized results are uncertain. */ +export type ConfigurationApplyOutcome = Readonly<{ + status: 'applied' | 'definitely-rejected' | 'ambiguous'; +}>; + +/** Required owned-runtime backend; null means a real transition to idle, not a stub. */ +export interface ConfigurationRuntime { + apply(config: CaddyConfig | null): Promise; + /** + * Independent protected-runtime observation, never a cached apply receipt. + * Null is positively observed idle; undeterminable state must return ok:false, not null. + */ + readActive(): Promise>; +} + +export type PrepareConfiguration = ( + committed: ConfigurationSnapshot, +) => PortResult | Promise>; + +const applyOutcomeSchema = z.strictObject({ + status: z.enum(['applied', 'definitely-rejected', 'ambiguous']), +}); + +const configurationSnapshotSchema = z.strictObject({ + config: z + .strictObject({ + effectiveConfigHash: z.string(), + adaptedConfig: z.strictObject({ format: z.literal('json'), body: z.string() }), + }) + .nullable(), + desiredState: z.strictObject({ + projects: z.array( + z.strictObject({ + projectKey: z.string(), + sourceWorkingDirectory: z.string(), + sourceConfigPath: z.string(), + enabled: z.boolean(), + domains: z.array( + z.strictObject({ + canonicalDomain: z.string(), + upstream: z.string().nullable(), + enabled: z.boolean(), + }), + ), + }), + ), + }), + registrations: z.array(registrationSchema), +}); + +function freeze(value: T): T { + if (value !== null && typeof value === 'object') { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + return value; +} + +function copySnapshot(value: ConfigurationSnapshot): ConfigurationSnapshot { + const copy = configurationSnapshotSchema.parse(structuredClone(value)); + if (copy.config !== null) assertCandidate(copy.config); + return freeze(copy); +} + +function diagnostic(kind: ProtocolError['kind'], code: string, message: string): ProtocolError { + return { kind, code, message, guidance: null, retryable: false, requestId: null }; +} + +function failure(kind: ProtocolError['kind'], code: string, message: string): PortResult { + return { ok: false, error: diagnostic(kind, code, message) }; +} + +/** One queue owns preparation through publication; initial state is already verified/durable. */ +export class ConfigurationTransactions { + #committed: ConfigurationSnapshot; + #fence: ProtocolError | null = null; + #closed = false; + #tail: Promise = Promise.resolve(); + readonly #validator: Pick; + readonly #storage: Pick; + readonly #runtime: ConfigurationRuntime; + + constructor( + initial: ConfigurationSnapshot, + validator: Pick, + storage: Pick, + runtime: ConfigurationRuntime, + ) { + try { + this.#committed = copySnapshot(initial); + } catch { + throw new RpcError( + diagnostic( + 'configuration', + 'config_candidate_invalid', + 'Invalid committed configuration snapshot.', + ), + ); + } + this.#validator = validator; + this.#storage = storage; + this.#runtime = runtime; + } + + readCommitted(): ConfigurationSnapshot { + return freeze(structuredClone(this.#committed)); + } + + readFence(): ProtocolError | null { + return this.#fence === null ? null : { ...this.#fence }; + } + + submit(prepare: PrepareConfiguration): Promise> { + return this.#enqueue(async () => { + if (this.#fence !== null) + return failure( + 'caddyRuntime', + 'config_mutations_fenced', + 'Runtime reconciliation is required before another change.', + ); + return this.#change(prepare); + }); + } + + /** Queued proof of last-known-good only; abandoned candidates are never committed. */ + reconcile(): Promise> { + return this.#enqueue(async () => { + // Prepare the isolated return value before any restoration side effects. + const success = { ok: true as const, value: this.readCommitted() }; + const observed = await this.#observe(); + if ( + (!observed.ok || observed.value !== this.#targetHash()) && + !(await this.#restoreCommitted()) + ) + return this.#fenced( + 'config_reconciliation_failed', + 'Cannot reconcile the last committed Caddy configuration.', + ); + this.#fence = null; + return success; + }); + } + + /** Stops admissions and waits for owned queue work, without claiming resource teardown. */ + async close(): Promise { + this.#closed = true; + await this.#tail; + } + + #enqueue( + operation: () => Promise>, + ): Promise> { + if (this.#closed) + return Promise.resolve( + failure( + 'shuttingDown', + 'config_transactions_closed', + 'Configuration transactions are closed.', + ), + ); + const result = this.#tail + .then(operation) + .catch(() => + this.#fenced('config_transaction_failed', 'Configuration transaction failed unexpectedly.'), + ); + this.#tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + #fenced(code: string, message: string): PortResult { + this.#fence = diagnostic('caddyRuntime', code, message); + return { ok: false, error: { ...this.#fence } }; + } + + async #change(prepare: PrepareConfiguration): Promise> { + let prepared: PortResult; + try { + prepared = await prepare(this.readCommitted()); + if (prepared.ok !== true) + return failure( + 'configuration', + 'config_prepare_failed', + 'Configuration preparation was rejected.', + ); + } catch { + return failure( + 'configuration', + 'config_prepare_failed', + 'Cannot prepare the configuration change.', + ); + } + let candidate: ConfigurationSnapshot; + let success: PortResult; + try { + candidate = copySnapshot(prepared.value); + // Build the isolated result before side effects: publication itself cannot fail. + success = { ok: true, value: freeze(structuredClone(candidate)) }; + } catch { + return failure( + 'configuration', + 'config_candidate_invalid', + 'Invalid configuration transaction candidate.', + ); + } + if (candidate.config !== null) { + try { + const validation = await this.#validator.validate( + freeze(structuredClone(candidate.config)), + ); + if (validation.ok !== true) + return failure( + 'configuration', + 'config_validation_failed', + 'Caddy configuration validation failed.', + ); + if (validation.value.effectiveConfigHash !== candidate.config.effectiveConfigHash) + return failure( + 'configuration', + 'config_validation_mismatch', + 'Caddy validation did not identify the complete candidate.', + ); + } catch { + return failure( + 'configuration', + 'config_validation_failed', + 'Caddy configuration validation failed.', + ); + } + } + const outcome = await this.#apply(candidate.config); + if (outcome === 'definitely-rejected') + return failure( + 'caddyRuntime', + 'config_apply_rejected', + 'Caddy definitely rejected the configuration change.', + ); + if (outcome !== 'applied') + return this.#fenced('config_apply_uncertain', 'Caddy application outcome is uncertain.'); + const observed = await this.#observe(); + if (!observed.ok || observed.value !== (candidate.config?.effectiveConfigHash ?? null)) + return this.#fenced( + 'config_verification_failed', + 'Cannot verify the active Caddy configuration.', + ); + let persisted = false; + try { + const result = await this.#storage.persistDesiredState( + freeze(structuredClone(candidate.desiredState)), + ); + persisted = result.ok === true; + } catch { + // STO-002 dependency: a thrown/rejected persist leaves previous durable intent intact. + } + if (!persisted) { + if (!(await this.#restoreCommitted())) + this.#fenced( + 'config_rollback_failed', + 'Cannot restore and verify the last committed Caddy configuration.', + ); + return failure( + 'storage', + 'config_persist_failed', + 'Cannot persist the configuration change.', + ); + } + this.#committed = candidate; + return success; + } + + #targetHash(): string | null { + return this.#committed.config?.effectiveConfigHash ?? null; + } + + async #apply(config: CaddyConfig | null): Promise { + try { + const outcome = await this.#runtime.apply(freeze(structuredClone(config))); + return applyOutcomeSchema.parse(outcome).status; + } catch { + return 'ambiguous'; + } + } + + async #observe(): Promise> { + try { + const observed = await this.#runtime.readActive(); + if (observed.ok === true && (observed.value === null || typeof observed.value === 'string')) + return { ok: true, value: observed.value }; + } catch { + // Arbitrary backend exception text cannot cross the ProtocolError boundary. + } + return failure( + 'caddyRuntime', + 'config_verification_failed', + 'Cannot observe the active Caddy configuration.', + ); + } + + async #restoreCommitted(): Promise { + if ((await this.#apply(this.#committed.config)) !== 'applied') return false; + const observed = await this.#observe(); + return observed.ok && observed.value === this.#targetHash(); + } +} diff --git a/src/platform/owned-command.ts b/src/platform/owned-command.ts new file mode 100644 index 0000000..e9e2339 --- /dev/null +++ b/src/platform/owned-command.ts @@ -0,0 +1,438 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { isAbsolute, join } from 'node:path'; + +export interface OwnedCommandOptions { + timeoutMs?: number; + maxStreamBytes?: number; + cwd?: string; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + /** Drain without retaining output or a lifetime deadline; requires an abort owner. */ + longLived?: boolean; + /** Verified native creation/resume, not application readiness. Callback failures stop owned work. */ + onStarted?: () => void; + /** Verify immediately before actual native creation (after Windows wrapper startup). */ + beforeSpawn?: () => Promise; + /** Reverify the pinned image after creation; Windows does this while it is suspended. */ + afterSpawn?: () => Promise; +} +export type OwnedCommandOutput = Readonly<{ stdout: Buffer; stderr: Buffer; exitCode: number }>; + +export class OwnedCommandError extends Error { + constructor( + readonly code: 'spawn' | 'timeout' | 'overflow' | 'abort' | 'stream' | 'cleanup', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'OwnedCommandError'; + } +} + +// One non-inheritable Job per command. Only JSON data crosses the PowerShell boundary. +// The native image is suspended until Node has reverified it, and never runs outside the Job. +const windowsScript = ` +$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; +Add-Type -TypeDefinition @' +using System; +using System.Text; +using System.Runtime.InteropServices; +public static class CadderOwnedCommand { + [StructLayout(LayoutKind.Sequential)] struct BasicLimits { + public long ProcessTime, JobTime; public uint Flags; public UIntPtr Min, Max; + public uint Active; public UIntPtr Affinity; public uint Priority, Scheduling; + } + [StructLayout(LayoutKind.Sequential)] struct Counters { public ulong A,B,C,D,E,F; } + [StructLayout(LayoutKind.Sequential)] struct Accounting { + public long User, Kernel, PeriodUser, PeriodKernel; public uint Faults, Total, Active, Terminated; + } + [StructLayout(LayoutKind.Sequential)] struct Limits { + public BasicLimits Basic; public Counters Io; public UIntPtr ProcessMemory, JobMemory, PeakProcess, PeakJob; + } + [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct Startup { + public uint Size; public string Reserved, Desktop, Title; public uint X,Y,XS,YS,XC,YC,Fill,Flags; + public ushort Show, ReservedSize; public IntPtr ReservedData, In, Out, Err; + } + [StructLayout(LayoutKind.Sequential)] struct StartupEx { public Startup Startup; public IntPtr Attributes; } + [StructLayout(LayoutKind.Sequential)] struct ProcessInfo { public IntPtr Process, Thread; public uint Pid, Tid; } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attributes, string name); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int kind, IntPtr data, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, UIntPtr attribute, IntPtr value, UIntPtr size, IntPtr previous, IntPtr returned); + [DllImport("kernel32.dll")] static extern void DeleteProcThreadAttributeList(IntPtr list); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int kind, out Accounting data, uint size, IntPtr returned); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool CreatePipe(out IntPtr read, out IntPtr write, IntPtr attributes, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateFile(string path, uint access, uint share, IntPtr attributes, uint creation, uint flags, IntPtr template); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool CreateProcess(string image, StringBuilder command, IntPtr pa, IntPtr ta, bool inherit, uint flags, IntPtr environment, string cwd, ref StartupEx startup, out ProcessInfo info); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr handle, uint timeout); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetExitCodeProcess(IntPtr process, out uint code); + static void Check(bool ok) { if (!ok) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); } + static void StopJob(IntPtr job, uint code) { + Check(TerminateJobObject(job, code)); + var deadline = System.Diagnostics.Stopwatch.StartNew(); + while (true) { + Accounting data; + Check(QueryInformationJobObject(job, 1, out data, (uint)Marshal.SizeOf(typeof(Accounting)), IntPtr.Zero)); + if (data.Active == 0) return; + if (deadline.ElapsedMilliseconds >= 4000) throw new TimeoutException("Owned Job did not terminate within four seconds."); + System.Threading.Thread.Sleep(10); + } + } + static string Quote(string value) { + StringBuilder b = new StringBuilder(); b.Append('\\"'); int slashes = 0; + foreach (char c in value) { + if (c == '\\\\') { slashes++; continue; } + if (c == '\\"') { b.Append('\\\\', slashes * 2 + 1); b.Append(c); } + else { b.Append('\\\\', slashes); b.Append(c); } + slashes = 0; + } + b.Append('\\\\', slashes * 2); b.Append('\\"'); return b.ToString(); + } + public static int Run(string image, string[] args, string cwd, long maxStreamBytes) { + IntPtr job = IntPtr.Zero, input = new IntPtr(-1), attributes = IntPtr.Zero, jobValue = IntPtr.Zero; + ProcessInfo p = new ProcessInfo(); bool attributeReady = false; + IntPtr outRead = IntPtr.Zero, outWrite = IntPtr.Zero, errRead = IntPtr.Zero, errWrite = IntPtr.Zero; + System.IO.FileStream outPipe = null, errPipe = null; + System.Threading.Tasks.Task outPump = null, errPump = null; + int overflow = 0; + try { + job = CreateJobObject(IntPtr.Zero, null); Check(job != IntPtr.Zero); + Limits limits = new Limits(); limits.Basic.Flags = 0x2000; + int size = Marshal.SizeOf(limits); IntPtr data = Marshal.AllocHGlobal(size); + try { Marshal.StructureToPtr(limits, data, false); Check(SetInformationJobObject(job, 9, data, (uint)size)); } + finally { Marshal.FreeHGlobal(data); } + input = CreateFile("NUL", 0x80000000, 3, IntPtr.Zero, 3, 0, IntPtr.Zero); + Check(input != new IntPtr(-1)); Check(SetHandleInformation(input, 1, 1)); + StartupEx s = new StartupEx(); s.Startup.Size = (uint)Marshal.SizeOf(s); s.Startup.Flags = 0x100; + Check(CreatePipe(out outRead, out outWrite, IntPtr.Zero, 0)); + Check(CreatePipe(out errRead, out errWrite, IntPtr.Zero, 0)); + Check(SetHandleInformation(outWrite, 1, 1)); Check(SetHandleInformation(errWrite, 1, 1)); + s.Startup.In = input; s.Startup.Out = outWrite; s.Startup.Err = errWrite; + IntPtr attributeSize = IntPtr.Zero; + InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attributeSize); + attributes = Marshal.AllocHGlobal(attributeSize); + Check(InitializeProcThreadAttributeList(attributes, 1, 0, ref attributeSize)); attributeReady = true; + jobValue = Marshal.AllocHGlobal(IntPtr.Size); Marshal.WriteIntPtr(jobValue, job); + Check(UpdateProcThreadAttribute(attributes, 0, new UIntPtr(0x2000d), jobValue, new UIntPtr((uint)IntPtr.Size), IntPtr.Zero, IntPtr.Zero)); + s.Attributes = attributes; + StringBuilder command = new StringBuilder(Quote(image)); + foreach (string arg in args) command.Append(" ").Append(Quote(arg)); + var output = Console.OpenStandardOutput(); var control = Console.OpenStandardInput(); + output.WriteByte(1); output.Flush(); + if (control.ReadByte() != 1) throw new Exception("Missing pre-creation image verification."); + Check(CreateProcess(image, command, IntPtr.Zero, IntPtr.Zero, true, 0x08080004, IntPtr.Zero, String.IsNullOrEmpty(cwd) ? null : cwd, ref s, out p)); + Check(CloseHandle(outWrite)); outWrite = IntPtr.Zero; + Check(CloseHandle(errWrite)); errWrite = IntPtr.Zero; + output.WriteByte(2); output.Flush(); + if (control.ReadByte() != 2) throw new Exception("Missing post-creation image verification."); + Check(ResumeThread(p.Thread) != 0xffffffff); + output.WriteByte(3); output.Flush(); + outPipe = new System.IO.FileStream(new Microsoft.Win32.SafeHandles.SafeFileHandle(outRead, true), System.IO.FileAccess.Read); outRead = IntPtr.Zero; + errPipe = new System.IO.FileStream(new Microsoft.Win32.SafeHandles.SafeFileHandle(errRead, true), System.IO.FileAccess.Read); errRead = IntPtr.Zero; + outPump = System.Threading.Tasks.Task.Factory.StartNew(() => { + byte[] buffer = new byte[81920]; long forwarded = 0; int count; + while ((count = outPipe.Read(buffer, 0, buffer.Length)) != 0) { + if (maxStreamBytes >= 0 && count > maxStreamBytes - forwarded) { + System.Threading.Interlocked.Exchange(ref overflow, 1); + throw new System.IO.IOException("Owned stdout payload exceeded its limit."); + } + output.Write(buffer, 0, count); + if (maxStreamBytes >= 0) forwarded += count; + } + }); + errPump = System.Threading.Tasks.Task.Factory.StartNew(() => errPipe.CopyTo(Console.OpenStandardError())); + var stop = System.Threading.Tasks.Task.Factory.StartNew(() => control.ReadByte()); + while (WaitForSingleObject(p.Process, 20) != 0) { + if (outPump.IsFaulted || errPump.IsFaulted) throw new Exception("Owned output forwarding failed."); + if (stop.IsCompleted) { StopJob(job, 1); break; } + } + uint code; Check(GetExitCodeProcess(p.Process, out code)); + StopJob(job, code); + System.Threading.Tasks.Task.WaitAll(outPump, errPump); + byte[] result = BitConverter.GetBytes(code); output.Write(result, 0, result.Length); output.Flush(); + return 0; + } catch { + if (job != IntPtr.Zero) StopJob(job, 1); + // Job settlement precedes pipe settlement: descendants may hold the write ends. + if (outPump != null) { try { outPump.Wait(); } catch (AggregateException) {} } + if (errPump != null) { try { errPump.Wait(); } catch (AggregateException) {} } + return overflow != 0 ? 254 : 252; + } finally { + if (p.Process != IntPtr.Zero) { + TerminateJobObject(job, 1); + WaitForSingleObject(p.Process, 5000); CloseHandle(p.Process); + } + if (outPipe != null) outPipe.Dispose(); if (errPipe != null) errPipe.Dispose(); + if (outRead != IntPtr.Zero) CloseHandle(outRead); if (outWrite != IntPtr.Zero) CloseHandle(outWrite); + if (errRead != IntPtr.Zero) CloseHandle(errRead); if (errWrite != IntPtr.Zero) CloseHandle(errWrite); + if (p.Thread != IntPtr.Zero) CloseHandle(p.Thread); + if (input != new IntPtr(-1)) CloseHandle(input); + if (job != IntPtr.Zero) CloseHandle(job); + if (attributeReady) DeleteProcThreadAttributeList(attributes); + if (attributes != IntPtr.Zero) Marshal.FreeHGlobal(attributes); + if (jobValue != IntPtr.Zero) Marshal.FreeHGlobal(jobValue); + } + } +} +'@; +try { + $data = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:CADDER_OWNED_COMMAND_DATA)) | ConvertFrom-Json; + [Environment]::SetEnvironmentVariable('CADDER_OWNED_COMMAND_DATA', $null); + exit [CadderOwnedCommand]::Run($data.image, [string[]]$data.args, $data.cwd, [long]$data.maxStreamBytes); +} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 253; } +`; + +/** Direct argv, separately bounded binary streams, and cleanup limited to this owned group/Job. */ +export async function runOwnedCommand( + image: string, + args: readonly string[], + options: OwnedCommandOptions = {}, +): Promise { + if (!isAbsolute(image) || image.includes('\0') || args.some((arg) => arg.includes('\0'))) + throw new OwnedCommandError( + 'spawn', + 'Owned command requires an absolute image and NUL-free argv.', + ); + if (options.longLived && !options.signal) + throw new RangeError('Long-lived execution requires an abort owner.'); + const timeoutMs = options.timeoutMs ?? 30_000; + const limit = options.maxStreamBytes ?? 1024 * 1024; + if ( + !Number.isSafeInteger(timeoutMs) || + timeoutMs <= 0 || + !Number.isSafeInteger(limit) || + limit < 0 + ) + throw new RangeError('Command deadline and stream limit must be bounded nonnegative integers.'); + if (options.signal?.aborted) throw new OwnedCommandError('abort', 'Owned command cancelled.'); + const windows = process.platform === 'win32'; + if (!windows) await options.beforeSpawn?.(); + if (options.signal?.aborted) throw new OwnedCommandError('abort', 'Owned command cancelled.'); + const env = { ...(options.env ?? process.env) }; + let executable = image; + let argv = [...args]; + if (windows) { + executable = join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); + argv = [ + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + Buffer.from(windowsScript, 'utf16le').toString('base64'), + ]; + env.CADDER_OWNED_COMMAND_DATA = Buffer.from( + JSON.stringify({ + image, + args, + cwd: options.cwd ?? null, + maxStreamBytes: options.longLived ? -1 : limit, + }), + ).toString('base64'); + for (const key of Object.keys(env)) if (key.toLowerCase() === 'psmodulepath') delete env[key]; + env.PSModulePath = join(executable, '..', 'Modules'); + } + return await new Promise((resolve, reject) => { + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(executable, argv, { + shell: false, + detached: !windows, + windowsHide: true, + env, + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + stdio: 'pipe', + }); + } catch (error) { + reject(new OwnedCommandError('spawn', 'Cannot create owned command.', { cause: error })); + return; + } + const chunks: Buffer[][] = [[], []]; + const counts = [0, 0]; + let stdoutTail = Buffer.alloc(0); + let failure: unknown; + let phase = windows ? 0 : 3; + let settled = false; + let cleanupTimer: ReturnType | undefined; + let pendingVerification = Promise.resolve(); + const terminate = () => { + try { + if (!windows && child.pid !== undefined) process.kill(-child.pid, 'SIGKILL'); + else if (phase >= 2) child.stdin.end(Buffer.from([3])); + else child.stdin.end(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH' && failure === undefined) + failure = error; + } + }; + const finish = (code: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(cleanupTimer); + options.signal?.removeEventListener('abort', abort); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + if (windows && code === 253) + failure = new OwnedCommandError('cleanup', 'Owned Job settlement could not be proved.'); + if (windows && code === 254 && failure === undefined) + failure = new OwnedCommandError( + 'overflow', + 'Owned command output exceeded its per-stream limit.', + ); + if (failure !== undefined) reject(failure); + else if (phase !== 3 || code === null || (windows && (code !== 0 || stdoutTail.length !== 4))) + reject( + new OwnedCommandError('spawn', 'Owned command did not complete native execution.', { + cause: new Error(Buffer.concat(chunks[1]!).subarray(0, 4096).toString('utf8')), + }), + ); + else + resolve({ + stdout: Buffer.concat(chunks[0]!), + stderr: Buffer.concat(chunks[1]!), + exitCode: windows ? stdoutTail.readUInt32LE(0) : code, + }); + }; + const fail = (error: unknown) => { + if (failure !== undefined || settled) return; + failure = error; + terminate(); + clearTimeout(cleanupTimer); + cleanupTimer = setTimeout(() => { + failure = new AggregateError( + [failure, new OwnedCommandError('cleanup', 'Owned command cleanup exceeded 5 seconds.')], + 'Owned command and cleanup failed.', + { cause: failure }, + ); + child.kill('SIGKILL'); + finish(null); + }, 5000); + }; + const verify = async (before = false) => { + try { + if (before) await options.beforeSpawn?.(); + else await options.afterSpawn?.(); + if (failure === undefined && !settled) { + if (windows && before) child.stdin.write(Buffer.from([1])); + else if (windows) child.stdin.write(Buffer.from([2])); + else { + child.stdin.end(); + if (child.exitCode === null && child.signalCode === null) options.onStarted?.(); + } + } + } catch (error) { + fail(error); + } + }; + const abort = () => fail(new OwnedCommandError('abort', 'Owned command cancelled.')); + const timer = options.longLived + ? undefined + : setTimeout( + () => fail(new OwnedCommandError('timeout', 'Owned command exceeded its deadline.')), + timeoutMs, + ); + options.signal?.addEventListener('abort', abort, { once: true }); + if (options.signal?.aborted) abort(); + child.once('error', (error) => + fail(new OwnedCommandError('spawn', 'Cannot start owned command.', { cause: error })), + ); + child.once('spawn', () => { + if (!windows) pendingVerification = verify(); + }); + child.once('exit', (code) => { + // Kill residual Unix group members even when the leader exited successfully. + if (!windows) terminate(); + if (settled || failure !== undefined) return; + clearTimeout(timer); + cleanupTimer = setTimeout(() => { + failure = new OwnedCommandError( + 'stream', + 'Owned command streams did not settle after exit.', + ); + finish(code); + }, 5000); + }); + child.once('close', (code) => { + void pendingVerification.then(async () => { + if (!windows && child.pid !== undefined) { + const deadline = performance.now() + 4000; + while (!settled) { + try { + process.kill(-child.pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') break; + failure = new OwnedCommandError('cleanup', 'Cannot observe owned group settlement.'); + break; + } + if (performance.now() >= deadline) { + failure = new OwnedCommandError( + 'cleanup', + 'Owned group settlement exceeded its deadline.', + ); + break; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + finish(code); + }); + }); + child.stdin.on('error', (error) => + fail(new OwnedCommandError('stream', 'Owned command stdin failed.', { cause: error })), + ); + for (const [index, stream] of [child.stdout, child.stderr].entries()) { + stream.on('error', (error) => + fail(new OwnedCommandError('stream', 'Owned command output failed.', { cause: error })), + ); + stream.on('data', (bytes: Buffer) => { + if (settled || failure !== undefined) return; + while (windows && index === 0 && phase < 3 && bytes.length > 0) { + if (bytes[0] !== phase + 1) { + fail(new OwnedCommandError('spawn', 'Invalid owned command creation handshake.')); + return; + } + phase++; + bytes = bytes.subarray(1); + if (phase < 3) pendingVerification = verify(phase === 1); + else { + try { + if (child.exitCode === null && child.signalCode === null) options.onStarted?.(); + } catch (error) { + fail(error); + return; + } + } + } + if (windows && index === 0 && phase === 3) { + // Only wrapper exit 0 authorizes decoding this fixed-size completion trailer. + const combined = Buffer.concat([stdoutTail, bytes]); + const payloadBytes = Math.max(0, combined.length - 4); + bytes = combined.subarray(0, payloadBytes); + stdoutTail = Buffer.from(combined.subarray(payloadBytes)); + } + if (options.longLived) return; + counts[index]! += bytes.length; + if (counts[index]! > limit) { + fail( + new OwnedCommandError( + 'overflow', + 'Owned command output exceeded its per-stream limit.', + ), + ); + return; + } + chunks[index]!.push(bytes); + }); + } + }); +} diff --git a/test/caddy-admin-client.test.ts b/test/caddy-admin-client.test.ts new file mode 100644 index 0000000..8d60753 --- /dev/null +++ b/test/caddy-admin-client.test.ts @@ -0,0 +1,685 @@ +import 'reflect-metadata'; +import { webcrypto } from 'node:crypto'; +import { createServer, request } from 'node:https'; +import { createServer as createTcpServer, type Server } from 'node:net'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Duplex } from 'node:stream'; +import { + BasicConstraintsExtension, + ExtendedKeyUsage, + ExtendedKeyUsageExtension, + KeyUsageFlags, + KeyUsagesExtension, + PemConverter, + SubjectAlternativeNameExtension, + X509CertificateGenerator, +} from '@peculiar/x509'; +import { afterEach, beforeAll, expect, it, vi } from 'vitest'; +import { CaddyAdminClient } from '../src/caddy/admin-client.ts'; +import { normalizeCaddyConfig, type SecureAdminPolicy } from '../src/caddy/admin-policy.ts'; +import type { CaddyConfig } from '../src/contracts/ports.ts'; +import { ConfigurationTransactions } from '../src/daemon/configuration-transactions.ts'; +import { configHash } from '../src/caddy/preparation.ts'; + +vi.mock('node:https', async (original) => { + const actual = await original(); + return { ...actual, request: vi.fn(actual.request) }; +}); + +const crypto = webcrypto as unknown as Crypto; +const algorithm = { name: 'ECDSA', namedCurve: 'P-256' }; +const signingAlgorithm = { name: 'ECDSA', hash: 'SHA-256' }; +type Identity = { cert: string; key: string; keys: CryptoKeyPair }; +type Handler = (req: IncomingMessage, res: ServerResponse) => void; +let root: Identity; +let foreignRoot: Identity; +let client: Identity; +let foreignClient: Identity; +let serverIdentity: Identity; +let wrongName: Identity; +let expired: Identity; +const servers: Server[] = []; +const sockets = new Set(); +const clients: CaddyAdminClient[] = []; +const timers = new Set>(); + +async function issue( + role: 'root' | 'client' | 'server', + issuer?: Identity, + name = 'localhost', + old = false, +): Promise { + const keys = await crypto.subtle.generateKey(algorithm, true, ['sign', 'verify']); + const certificate = await X509CertificateGenerator.create( + { + subject: `CN=${role === 'root' ? 'Fixture Root' : name}`, + issuer: 'CN=Fixture Root', + publicKey: keys.publicKey, + signingKey: issuer?.keys.privateKey ?? keys.privateKey, + signingAlgorithm, + notBefore: new Date(Date.now() - 60_000), + notAfter: new Date(Date.now() + (old ? -1_000 : 3_600_000)), + extensions: [ + new BasicConstraintsExtension(role === 'root', role === 'root' ? 1 : undefined, true), + new KeyUsagesExtension( + role === 'root' ? KeyUsageFlags.keyCertSign : KeyUsageFlags.digitalSignature, + true, + ), + ...(role === 'root' + ? [] + : [ + new ExtendedKeyUsageExtension( + [role === 'client' ? ExtendedKeyUsage.clientAuth : ExtendedKeyUsage.serverAuth], + true, + ), + ]), + ...(role === 'server' + ? [new SubjectAlternativeNameExtension([{ type: 'dns', value: name }])] + : []), + ], + }, + crypto, + ); + return { + cert: certificate.toString('pem'), + key: PemConverter.encode( + await crypto.subtle.exportKey('pkcs8', keys.privateKey), + 'PRIVATE KEY', + ), + keys, + }; +} + +beforeAll(async () => { + root = await issue('root'); + foreignRoot = await issue('root'); + client = await issue('client', root); + foreignClient = await issue('client', foreignRoot); + serverIdentity = await issue('server', root); + wrongName = await issue('server', root, 'other.localhost'); + expired = await issue('server', root, 'localhost', true); +}); +afterEach(async () => { + vi.mocked(request).mockClear(); + for (const client of clients.splice(0)) client.close(); + for (const timer of timers) clearInterval(timer); + timers.clear(); + for (const socket of sockets) socket.destroy(); + sockets.clear(); + await Promise.all( + servers + .splice(0) + .map( + (server) => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + ), + ); +}); + +function candidate(tag = 'initial'): CaddyConfig { + return normalizeCaddyConfig( + JSON.stringify({ + admin: { + disabled: true, + remote: { listen: 'fixture', access_control: [{ public_keys: ['authorized'] }] }, + }, + apps: { + pki: { certificate_authorities: { local: { install_trust: false } } }, + http: { opaque: [tag, { z: 1, a: 2 }] }, + }, + }), + ); +} +async function fixture( + handler: Handler, + identity = serverIdentity, +): Promise> { + const server = createServer( + { + key: identity.key, + cert: identity.cert, + ca: root.cert, + requestCert: true, + rejectUnauthorized: true, + }, + handler, + ); + servers.push(server); + server.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (typeof address !== 'object' || address === null) throw new Error('Fixture address missing'); + return { + config: candidate(), + client: { + host: '127.0.0.1', + port: address.port, + servername: 'localhost', + rejectUnauthorized: true, + ca: root.cert, + cert: client.cert, + key: client.key, + }, + }; +} +function makeClient( + policy: Pick, + options: ConstructorParameters[1] = {}, +): CaddyAdminClient { + const value = new CaddyAdminClient(policy, options); + clients.push(value); + return value; +} +async function unavailable(value: CaddyAdminClient): Promise { + const result = await value.readActive(); + expect(result).toMatchObject({ + ok: false, + error: { code: 'caddy_admin_readback_failed', retryable: false }, + }); + expect(JSON.stringify(result)).not.toMatch( + /PRIVATE KEY|CERTIFICATE|SECRET|localhost|127\.0\.0\.1/, + ); +} + +it('uses authorized HTTPS, POST /load complete canonical JSON and independent GET /config/', async () => { + let observedBody = ''; + let active = candidate('actually changed'); + const calls: string[] = []; + const policy = await fixture((req, res) => { + calls.push(`${req.method} ${req.url}`); + if (req.method === 'POST') { + expect(req.headers['content-type']).toBe('application/json'); + req.setEncoding('utf8'); + req.on('data', (chunk: string) => { + observedBody += chunk; + }); + req.on('end', () => res.end()); + } else res.end(` \n${active.adaptedConfig.body}\n`); + }); + const value = makeClient(policy); + expect(await value.apply(policy.config)).toEqual({ status: 'applied' }); + expect(observedBody).toBe(policy.config.adaptedConfig.body); + expect(await value.readActive()).toEqual({ ok: true, value: active.effectiveConfigHash }); + active = candidate('second independent change'); + expect(await value.readActive()).toEqual({ ok: true, value: active.effectiveConfigHash }); + expect(calls).toEqual(['POST /load', 'GET /config/', 'GET /config/']); +}); + +it('verified POST /stop is bounded, cancellation-aware and only a receipt', async () => { + const calls: string[] = []; + const policy = await fixture((req, res) => { + calls.push(`${req.method} ${req.url}`); + res.end(); + }); + const value = makeClient(policy); + expect(await value.stop()).toEqual({ status: 'applied' }); + expect(calls).toEqual(['POST /stop']); + expect(await value.stop(AbortSignal.abort())).toEqual({ status: 'ambiguous' }); + expect(calls).toHaveLength(1); + const stalled = makeClient(await fixture(() => undefined), { timeoutMs: 50 }); + expect(await stalled.stop()).toEqual({ status: 'ambiguous' }); + const abort = new AbortController(); + const pending = stalled.readActive(abort.signal); + abort.abort(); + expect((await pending).ok).toBe(false); +}); + +it('stop retains normal HTTPS authentication denial', async () => { + let requests = 0; + const policy = await fixture((_req, res) => { + requests++; + res.end(); + }, wrongName); + expect(await makeClient(policy).stop()).toEqual({ status: 'ambiguous' }); + expect(requests).toBe(0); +}); + +it.each(['root', 'name', 'client', 'expired'] as const)( + 'denies wrong %s via actual TLS without diagnostics leakage', + async (kind) => { + let requests = 0; + let identity = serverIdentity; + if (kind === 'name') identity = wrongName; + if (kind === 'expired') identity = expired; + const policy = await fixture((_req, res) => { + requests++; + res.end('{}'); + }, identity); + const tls = { ...policy.client }; + if (kind === 'root') tls.ca = foreignRoot.cert; + if (kind === 'client') { + tls.cert = foreignClient.cert; + tls.key = foreignClient.key; + } + const value = makeClient({ ...policy, client: tls }); + expect(await value.apply(policy.config)).toEqual({ status: 'ambiguous' }); + await unavailable(value); + expect(requests).toBe(0); + }, +); + +it('refuses noncanonical, invalid, mismatched and changed security candidates before network', async () => { + let requests = 0; + const policy = await fixture((_req, res) => { + requests++; + res.end(); + }); + const value = makeClient(policy); + const changed = JSON.parse(policy.config.adaptedConfig.body) as { + admin: { disabled: boolean }; + apps: { pki: unknown }; + }; + const bad: unknown[] = [ + null, + {}, + { ...policy.config, effectiveConfigHash: 'incorrect' }, + normalizeCaddyConfig('{}'), + ]; + const spaced = ` ${policy.config.adaptedConfig.body}`; + const normalized = normalizeCaddyConfig(spaced); + // Matching raw hash is still not a canonical candidate. + bad.push({ + ...normalized, + adaptedConfig: { format: 'json', body: spaced }, + effectiveConfigHash: configHash(spaced), + }); + changed.admin.disabled = false; + bad.push(normalizeCaddyConfig(JSON.stringify(changed))); + changed.admin.disabled = true; + changed.apps.pki = { certificate_authorities: { local: { install_trust: true } } }; + bad.push(normalizeCaddyConfig(JSON.stringify(changed))); + changed.apps.pki = { + certificate_authorities: { 'cadder-admin': { root: { certificate: 'foreign CA' } } }, + }; + bad.push(normalizeCaddyConfig(JSON.stringify(changed))); + const foreignAuthorization = JSON.parse(policy.config.adaptedConfig.body) as { + admin: { remote: { access_control: unknown } }; + }; + foreignAuthorization.admin.remote.access_control = [{ public_keys: ['foreign client'] }]; + bad.push(normalizeCaddyConfig(JSON.stringify(foreignAuthorization))); + for (const config of bad) + expect(await value.apply(config as CaddyConfig)).toEqual({ status: 'definitely-rejected' }); + expect(requests).toBe(0); +}); + +it('explicitly refuses idle without sending an HTTPS request', async () => { + const policy = await fixture((_req, res) => res.end()); + const value = makeClient(policy); + expect(await value.apply(null as never)).toEqual({ status: 'definitely-rejected' }); + expect(vi.mocked(request)).not.toHaveBeenCalled(); +}); + +it('isolates the explicit credentials and baseline from caller mutation', async () => { + const policy = await fixture((_req, res) => res.end(candidate().adaptedConfig.body)); + const mutable = { config: policy.config, client: { ...policy.client } }; + const value = makeClient(mutable); + mutable.client.ca = 'SECRET bad root'; + mutable.client.port = 2019; + mutable.config = normalizeCaddyConfig('{}'); + expect(await value.apply(policy.config)).toEqual({ status: 'applied' }); + expect(await value.readActive()).toEqual({ ok: true, value: policy.config.effectiveConfigHash }); +}); + +it.each([200, 204, 299, 400, 301, 307, 401, 403, 404, 500, 503])( + 'classifies completed HTTP %i without redirects/retries', + async (status) => { + let calls = 0; + const policy = await fixture((_req, res) => { + calls++; + res.writeHead(status, { Location: 'http://127.0.0.1:2019/', 'Content-Type': 'text/plain' }); + res.end('SECRET Caddy error /private/path'); + }); + const value = makeClient(policy); + let expected = 'ambiguous'; + if (status >= 200 && status < 300) expected = 'applied'; + if (status === 400) expected = 'definitely-rejected'; + expect(await value.apply(policy.config)).toEqual({ status: expected }); + expect(calls).toBe(1); + }, +); + +it.each([204, 301, 400, 403, 500])( + 'readback HTTP %i is unavailable, not idle or a receipt', + async (status) => { + const policy = await fixture((_req, res) => { + res.writeHead(status); + res.end('{}'); + }); + await unavailable(makeClient(policy)); + }, +); + +it.each(['truncated', 'array', 'null', 'text', 'utf8', 'empty', 'bom'])( + 'refuses invalid readback %s, never substitutes cached state', + async (kind) => { + const bodies: Record = { + truncated: '{', + array: '[]', + null: 'null', + text: 'SECRET exception', + utf8: Buffer.from([0xc3, 0x28]), + empty: '', + bom: '\ufeff{}', + }; + const policy = await fixture((_req, res) => res.end(bodies[kind])); + await unavailable(makeClient(policy)); + }, +); + +it.each([ + 'declared', + 'chunked', + 'headers', + 'encoding', + 'framing', + 'invalid-header', + 'invalid-utf8', +])('bounds and validates %s replies for both operations', async (kind) => { + const policy = await fixture((_req, res) => { + if (kind === 'declared') { + res.writeHead(200, { 'Content-Length': 1024 }); + res.flushHeaders(); + } else if (kind === 'chunked') { + res.write('{}'); + res.end('x'.repeat(40)); + } else if (kind === 'headers') { + res.setHeader('X-Big', 'x'.repeat(1024)); + res.end('{}'); + } else if (kind === 'encoding') { + res.setHeader('Content-Encoding', 'gzip'); + res.end('{}'); + } else if (kind === 'invalid-utf8') res.end(Buffer.from([0xff])); + else if (kind === 'invalid-header') { + res.socket!.end('HTTP/1.1 200 OK\r\nContent-Length: nope\r\n\r\n{}'); + } else { + res.writeHead(400, { 'Content-Length': 10 }); + res.write('{}'); + res.socket!.end(); + } + }); + const value = makeClient(policy, { maxResponseBytes: 32, maxHeaderBytes: 512, timeoutMs: 1000 }); + expect(await value.apply(policy.config)).toEqual({ status: 'ambiguous' }); + await unavailable(value); +}); + +it('uses an absolute body deadline despite slow continuous chunks and never retries a POST', async () => { + let calls = 0; + const policy = await fixture((_req, res) => { + calls++; + res.writeHead(200); + res.write('{'); + const timer = setInterval(() => res.write(' '), 10); + timers.add(timer); + res.on('close', () => { + clearInterval(timer); + timers.delete(timer); + }); + }); + const value = makeClient(policy, { timeoutMs: 120 }); + const start = performance.now(); + expect(await value.apply(policy.config)).toEqual({ status: 'ambiguous' }); + expect(performance.now() - start).toBeLessThan(1000); + expect(calls).toBe(1); +}); + +it('absolute deadline aborts a stalled TLS handshake and destroys the owned socket', async () => { + let clientHelloBytes = 0; + const server = createTcpServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + socket.on('data', (bytes: Buffer) => { + clientHelloBytes += bytes.length; + }); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing fixture port'); + const value = makeClient( + { + config: candidate(), + client: { + host: '127.0.0.1', + port: address.port, + servername: 'localhost', + rejectUnauthorized: true, + ca: root.cert, + cert: client.cert, + key: client.key, + }, + }, + { timeoutMs: 100 }, + ); + expect(await value.apply(candidate())).toEqual({ status: 'ambiguous' }); + expect(clientHelloBytes).toBeGreaterThan(0); + expect(vi.mocked(request)).toHaveBeenCalledTimes(1); + // Owned request destruction closes the peer; no fixture teardown needed to trigger it. + await vi.waitFor(() => expect(sockets.size).toBe(0)); +}); + +it('accepts the exact byte bound and UTF-8 characters split across chunks', async () => { + const body = '{"emoji":"😀"}'; + const bytes = Buffer.from(body); + const policy = await fixture((_req, res) => { + res.write(bytes.subarray(0, 11)); + setImmediate(() => res.end(bytes.subarray(11))); + }); + const value = makeClient(policy, { maxResponseBytes: bytes.length }); + expect(await value.readActive()).toEqual({ + ok: true, + value: normalizeCaddyConfig(body).effectiveConfigHash, + }); +}); + +it('close aborts in-flight work, settles once and blocks new admissions', async () => { + let entered!: () => void; + const entry = new Promise((resolve) => { + entered = resolve; + }); + let calls = 0; + const policy = await fixture((_req, res) => { + calls++; + res.writeHead(200); + res.write(' '); + entered(); + }); + const value = makeClient(policy); + const pending = value.apply(policy.config); + await entry; + value.close(); + value.close(); + expect(await pending).toEqual({ status: 'ambiguous' }); + expect(await value.apply(policy.config)).toEqual({ status: 'definitely-rejected' }); + await unavailable(value); + expect(calls).toBe(1); +}); + +it.each([ + 'port', + 'host', + 'servername', + 'verify', + 'ca', + 'cert', + 'key', + 'config', + 'missing-envelope', + 'timeout', + 'response-bound', + 'header-bound', +] as const)('rejects invalid constructor %s with bounded secret-free errors', async (kind) => { + const policy = await fixture((_req, res) => res.end('{}')); + const mutable = { config: policy.config, client: { ...policy.client } }; + const options: { timeoutMs?: number; maxResponseBytes?: number; maxHeaderBytes?: number } = {}; + if (kind === 'port') mutable.client.port = 1.5; + if (kind === 'host') Object.assign(mutable.client, { host: 'SECRET.example' }); + if (kind === 'servername') Object.assign(mutable.client, { servername: 'SECRET.example' }); + if (kind === 'verify') Object.assign(mutable.client, { rejectUnauthorized: false }); + if (kind === 'ca' || kind === 'cert' || kind === 'key') mutable.client[kind] = ''; + if (kind === 'config') mutable.config = { ...policy.config, effectiveConfigHash: 'SECRET' }; + if (kind === 'missing-envelope') mutable.config = normalizeCaddyConfig('{}'); + if (kind === 'timeout') options.timeoutMs = 0; + if (kind === 'response-bound') options.maxResponseBytes = 32 * 1024 * 1024 + 1; + if (kind === 'header-bound') options.maxHeaderBytes = Infinity; + expect(() => new CaddyAdminClient(mutable, options)).toThrow( + 'Invalid prepared administration policy or client bounds.', + ); + expect(vi.mocked(request)).not.toHaveBeenCalled(); +}); + +it('unexpected HTTPS throws are ambiguous, sanitized and never retried', async () => { + const policy = await fixture((_req, res) => res.end('{}')); + const value = makeClient(policy); + vi.mocked(request).mockImplementationOnce(() => { + throw new Error('SECRET /private/key.pem'); + }); + expect(await value.apply(policy.config)).toEqual({ status: 'ambiguous' }); + expect(vi.mocked(request)).toHaveBeenCalledTimes(1); + vi.mocked(request).mockImplementationOnce(() => { + throw new Error('SECRET /private/key.pem'); + }); + await unavailable(value); + expect(vi.mocked(request)).toHaveBeenCalledTimes(2); +}); + +it.each(['disconnect', 'no-headers'])('handles %s without proving no transition', async (kind) => { + const policy = await fixture((req) => { + if (kind === 'disconnect') req.socket.destroy(); + }); + const value = makeClient(policy, { timeoutMs: 100 }); + expect(await value.apply(policy.config)).toEqual({ status: 'ambiguous' }); + await unavailable(value); +}); + +it('normalizes actual readback with lexical numeric keys, arrays and opaque data intact', async () => { + const actual = + '{"z":[3,1,{"2":"b","10":"a"}],"admin":{"unknown":true},"a":{"unknown_module":{"payload":[null,false]}}}'; + const policy = await fixture((_req, res) => res.end(actual)); + const value = makeClient(policy); + expect(await value.readActive()).toEqual({ + ok: true, + value: normalizeCaddyConfig(actual).effectiveConfigHash, + }); + expect(normalizeCaddyConfig(actual).adaptedConfig.body).toContain('"10":"a","2":"b"'); +}); + +it('preserves late POST uncertainty even if a delayed response attempts to succeed', async () => { + let reply!: ServerResponse; + let entered!: () => void; + const entry = new Promise((resolve) => { + entered = resolve; + }); + let calls = 0; + const policy = await fixture((_req, res) => { + calls++; + reply = res; + entered(); + }); + const value = makeClient(policy, { timeoutMs: 100 }); + const operation = value.apply(policy.config); + await entry; + expect(await operation).toEqual({ status: 'ambiguous' }); + reply.end(); + expect(await operation).toEqual({ status: 'ambiguous' }); + expect(calls).toBe(1); +}); + +it('queue restores and independently verifies the committed configuration after persist failure', async () => { + let active = candidate(); + const applied: string[] = []; + const policy = await fixture((req, res) => { + if (req.method !== 'POST') { + res.end(active.adaptedConfig.body); + return; + } + let text = ''; + req.setEncoding('utf8'); + req.on('data', (chunk: string) => { + text += chunk; + }); + req.on('end', () => { + active = normalizeCaddyConfig(text); + applied.push(active.effectiveConfigHash); + res.end(); + }); + }); + const value = makeClient(policy); + const initial = { config: policy.config, desiredState: { projects: [] }, registrations: [] }; + const next = candidate('new'); + const queue = new ConfigurationTransactions( + initial, + { + validate: async (config) => ({ + ok: true, + value: { effectiveConfigHash: config.effectiveConfigHash, diagnostics: [] }, + }), + }, + { + persistDesiredState: async () => ({ + ok: false, + error: { + kind: 'storage', + code: 'failed', + message: 'failed', + guidance: null, + retryable: false, + requestId: null, + }, + }), + }, + { + apply: async (config) => (config === null ? { status: 'ambiguous' } : value.apply(config)), + readActive: () => value.readActive(), + }, + ); + expect( + await queue.submit(() => ({ ok: true, value: { ...initial, config: next } })), + ).toMatchObject({ ok: false, error: { code: 'config_persist_failed' } }); + expect(applied).toEqual([next.effectiveConfigHash, policy.config.effectiveConfigHash]); + expect(queue.readFence()).toBeNull(); + expect(queue.readCommitted()).toEqual(initial); + await queue.close(); +}); + +it('fences queue publication on independently changed readback', async () => { + const policy = await fixture((_req, res) => + res.end(candidate('unexpected native difference').adaptedConfig.body), + ); + const value = makeClient(policy); + let persisted = 0; + const initial = { config: policy.config, desiredState: { projects: [] }, registrations: [] }; + const queue = new ConfigurationTransactions( + initial, + { + validate: async (config) => ({ + ok: true, + value: { effectiveConfigHash: config.effectiveConfigHash, diagnostics: [] }, + }), + }, + { + persistDesiredState: async () => { + persisted++; + return { ok: true, value: undefined }; + }, + }, + { + apply: async (config) => (config === null ? { status: 'ambiguous' } : value.apply(config)), + readActive: () => value.readActive(), + }, + ); + expect( + await queue.submit(() => ({ ok: true, value: { ...initial, config: candidate('submitted') } })), + ).toMatchObject({ ok: false, error: { code: 'config_verification_failed' } }); + expect(queue.readFence()?.code).toBe('config_verification_failed'); + expect(persisted).toBe(0); + expect(queue.readCommitted()).toEqual(initial); + await queue.close(); +}); diff --git a/test/caddy-admin-policy.test.ts b/test/caddy-admin-policy.test.ts new file mode 100644 index 0000000..3ce5a52 --- /dev/null +++ b/test/caddy-admin-policy.test.ts @@ -0,0 +1,755 @@ +import { X509Certificate } from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + normalizeCaddyConfig, + prepareSecureAdminPolicy, + type SecureAdminPolicy, + type SecureAdminPolicyOptions, +} from '../src/caddy/admin-policy.ts'; +import { loadOrCreateAdminMaterial, type AdminMaterial } from '../src/caddy/certificates.ts'; +import { composeRoutePlan, type CaddyRoutePlan } from '../src/caddy/composition.ts'; +import type { CompositionRegistration, JsonObject } from '../src/caddy/domains.ts'; +import { + assertCandidate, + configHash, + maxCaddyConfigurationBytes, +} from '../src/caddy/preparation.ts'; +import * as security from '../src/platform/runtime-security.ts'; +import { powershell } from '../src/platform/powershell.ts'; +import { protocolErrorSchema } from '../src/protocol/errors.ts'; + +vi.mock('../src/platform/runtime-security.ts', async (original) => { + const actual = await original(); + return { + ...actual, + prepareRuntime: vi.fn(actual.prepareRuntime), + assertProtected: vi.fn(actual.assertProtected), + }; +}); +vi.mock('../src/caddy/certificates.ts', async (original) => { + const actual = await original(); + return { ...actual, loadOrCreateAdminMaterial: vi.fn(actual.loadOrCreateAdminMaterial) }; +}); +vi.mock('node:fs/promises', async (original) => { + const actual = await original(); + return { ...actual, lstat: vi.fn(actual.lstat) }; +}); +vi.mock('../src/platform/powershell.ts', async (original) => { + const actual = await original(); + return { ...actual, powershell: vi.fn(actual.powershell) }; +}); +const actualSecurity = await vi.importActual( + '../src/platform/runtime-security.ts', +); +const actualMaterial = await vi.importActual( + '../src/caddy/certificates.ts', +); +const actualFs = await vi.importActual('node:fs/promises'); +const actualPowerShell = await vi.importActual( + '../src/platform/powershell.ts', +); +const nativePlatform = process.platform; +const uidDescriptor = Object.getOwnPropertyDescriptor(process, 'getuid'); +const owner = { id: 'uid:42', uid: 42, elevated: false }; +const base = join(tmpdir(), 'cadder-policy-modeled'); +const materialDirectory = join(tmpdir(), 'cadder-policy-material-modeled'); +const mockMaterial: AdminMaterial = { + paths: { + root: { + certificate: join(materialDirectory, 'root.crt.pem'), + key: join(materialDirectory, 'root.key.pem'), + }, + intermediate: { + certificate: join(materialDirectory, 'intermediate.crt.pem'), + key: join(materialDirectory, 'intermediate.key.pem'), + }, + client: { + certificate: join(materialDirectory, 'client.crt.pem'), + key: join(materialDirectory, 'client.key.pem'), + }, + }, + certificates: { + root: 'ROOT PUBLIC PEM', + intermediate: 'INTERMEDIATE PUBLIC PEM', + client: 'CLIENT PUBLIC PEM', + }, + authorizedClientCertificateBase64: 'CLIENT_LEAF_DER', + tls: { ca: 'ROOT PUBLIC PEM', cert: 'CLIENT CHAIN PUBLIC PEM', key: 'SECRET CLIENT KEY' }, +}; +let cleanup: string | undefined; +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + Object.defineProperty(process, 'getuid', { configurable: true, value: () => 42 }); + vi.mocked(security.prepareRuntime).mockResolvedValue(); + vi.mocked(security.assertProtected).mockResolvedValue(); + vi.mocked(loadOrCreateAdminMaterial).mockResolvedValue({ ok: true, value: mockMaterial }); + vi.mocked(powershell).mockResolvedValue(''); + vi.mocked(fs.lstat).mockImplementation(async () => stat()); +}); +afterEach(async () => { + vi.restoreAllMocks(); + if (uidDescriptor) Object.defineProperty(process, 'getuid', uidDescriptor); + else Reflect.deleteProperty(process, 'getuid'); + if (cleanup) { + await actualFs.rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; + } +}); +function stat( + changes: Partial<{ uid: number; mode: number; link: boolean; directory: boolean }> = {}, +): Awaited> { + return { + uid: changes.uid ?? 42, + mode: changes.mode ?? 0o700, + isSymbolicLink: () => changes.link ?? false, + isDirectory: () => changes.directory ?? true, + isFile: () => false, + } as Awaited>; +} +function registration(id: string, hosts = ['app.localhost']): CompositionRegistration { + return { + registrationId: id, + activationState: 'active', + sourceConfigPath: { raw: `${id}/Caddyfile`, canonical: null }, + registeredDomains: hosts.map((host) => ({ + name: { raw: host, canonical: host }, + activationState: 'active', + logStream: { streamId: id, domainKey: host, channel: 'domain' }, + })), + }; +} +function routePlan(projectExtras: JsonObject = {}): CaddyRoutePlan { + const body = JSON.stringify({ + ...projectExtras, + apps: { + ...(projectExtras.apps as JsonObject), + http: { + servers: { + untrusted: { + listen: [':9999'], + routes: [ + { + '@id': 'opaque', + match: [{ host: ['app.localhost', 'other.localhost'] }], + handle: [ + { + handler: 'static_response', + body: 'original', + opaque: { + host: 'opaque', + admin: { disabled: false }, + routes: [2, 1], + __proto__: null, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }); + const result = composeRoutePlan([ + { registration: registration('b', ['other.localhost']), config: normalizeCaddyConfig(body) }, + { registration: registration('a'), config: normalizeCaddyConfig(body) }, + ]); + if (!result.ok) throw new Error('fixture composition failed'); + return result.value; +} +function options(overrides: Partial = {}): SecureAdminPolicyOptions { + return { + runtimeBase: base, + materialDirectory, + owner, + adminPort: 32451, + plan: overrides.plan ?? routePlan(), + baseEnvironment: { PATH: '/safe/bin' }, + ...overrides, + }; +} +async function policy( + overrides: Partial = {}, +): Promise { + const result = await prepareSecureAdminPolicy(options(overrides)); + if (!result.ok) throw new Error(`Policy failed: ${result.error.code}`); + return result.value; +} +async function refused(overrides: Partial, code?: string): Promise { + const result = await prepareSecureAdminPolicy(options(overrides)); + expect(result).toMatchObject({ + ok: false, + error: { retryable: false, ...(code ? { code } : {}) }, + }); + if (result.ok) throw new Error('unexpected success'); + expect(protocolErrorSchema.safeParse(result.error).success).toBe(true); + expect(result.error.message.length).toBeLessThan(256); + expect(JSON.stringify(result)).not.toMatch(/SECRET|PRIVATE KEY|attacker|parser/); +} + +it('assembles native mutual-TLS policy, imported CA paths and real internal web issuance', async () => { + const value = await policy(); + const config = JSON.parse(value.config.adaptedConfig.body); + expect(config.admin).toEqual({ + disabled: true, + identity: { identifiers: ['localhost'], issuers: [{ module: 'internal', ca: 'cadder-admin' }] }, + remote: { listen: '127.0.0.1:32451', access_control: [{ public_keys: ['CLIENT_LEAF_DER'] }] }, + }); + expect(config.apps.pki.certificate_authorities).toEqual({ + 'cadder-admin': { + install_trust: false, + root: { format: 'pem_file', certificate: mockMaterial.paths.root.certificate }, + intermediate: { + format: 'pem_file', + certificate: mockMaterial.paths.intermediate.certificate, + private_key: mockMaterial.paths.intermediate.key, + }, + }, + local: { install_trust: false }, + }); + expect(config.apps.tls).toEqual({ + automation: { + policies: [ + { + subjects: ['app.localhost', 'other.localhost'], + issuers: [{ module: 'internal', ca: 'local' }], + }, + ], + }, + certificates: { automate: ['app.localhost', 'other.localhost'] }, + }); + expect(config.apps.http.servers.cadder_https.tls_connection_policies).toEqual([{}]); + expect(config.apps.http.servers.cadder_http.tls_connection_policies).toBeUndefined(); + expect(config.apps.http.servers.cadder_https.automatic_https).toEqual({ disable: true }); + expect(value.config.adaptedConfig.body).not.toMatch( + /SECRET|PUBLIC PEM|root.key.pem|client.key.pem|acme/, + ); + expect(value.client).toEqual({ + host: '127.0.0.1', + port: 32451, + servername: 'localhost', + rejectUnauthorized: true, + ...mockMaterial.tls, + }); + expect(loadOrCreateAdminMaterial).toHaveBeenCalledWith(materialDirectory, owner); + assertCandidate(value.config); + expect(normalizeCaddyConfig(value.config.adaptedConfig.body)).toEqual(value.config); + expect(Object.isFrozen(value)).toBe(true); + expect(Object.isFrozen(value.client)).toBe(true); + expect(Object.isFrozen(value.config.adaptedConfig)).toBe(true); +}); + +it('preserves listeners, outer guards, original nested opaque handlers, ordering and input immutability', async () => { + const plan = routePlan(); + const before = structuredClone(plan); + const value = await policy({ plan }); + const servers = JSON.parse(value.config.adaptedConfig.body).apps.http.servers; + for (const name of ['cadder_http', 'cadder_https'] as const) { + expect(servers[name].listen).toEqual(before.servers[name].listen); + expect(servers[name].routes).toEqual(before.servers[name].routes); + expect( + servers[name].routes.map((r: { match: { host: string[] }[] }) => r.match[0]!.host), + ).toEqual([['app.localhost'], ['other.localhost']]); + } + expect(plan).toEqual(before); + expect(Object.isFrozen(plan)).toBe(false); +}); + +it('does not import project admin, remote, PKI, storage or server settings', async () => { + const plan = routePlan({ + admin: { + disabled: false, + remote: { listen: ':2021' }, + identity: { issuers: [{ module: 'acme' }] }, + }, + storage: { module: 'attacker' }, + apps: { + pki: { certificate_authorities: { evil: { install_trust: true } } }, + tls: { automation: { policies: [{ issuers: [{ module: 'acme' }] }] } }, + }, + }); + const config = JSON.parse((await policy({ plan })).config.adaptedConfig.body); + expect(Object.keys(config).sort()).toEqual(['admin', 'apps']); + expect(Object.keys(config.apps).sort()).toEqual(['http', 'pki', 'tls']); + expect(Object.keys(config.apps.pki.certificate_authorities).sort()).toEqual([ + 'cadder-admin', + 'local', + ]); + expect(config.apps.http.servers.untrusted).toBeUndefined(); + expect(config.admin.remote.listen).toBe('127.0.0.1:32451'); +}); + +it('preserves a composed wildcard guard and subject without introducing a new domain policy', async () => { + const config = normalizeCaddyConfig( + JSON.stringify({ + apps: { + http: { + servers: { + project: { + routes: [ + { + match: [{ host: ['*.localhost'] }], + handle: [{ handler: 'static_response', body: 'wildcard' }], + }, + ], + }, + }, + }, + }, + }), + ); + const composed = composeRoutePlan([ + { registration: registration('wildcard', ['*.localhost']), config }, + ]); + if (!composed.ok) throw new Error('wildcard composition failed'); + const body = JSON.parse((await policy({ plan: composed.value })).config.adaptedConfig.body); + expect(body.apps.tls.automation.policies[0].subjects).toEqual(['*.localhost']); + for (const name of ['cadder_http', 'cadder_https'] as const) { + expect(body.apps.http.servers[name].routes).toEqual(composed.value.servers[name].routes); + } +}); + +it('keeps an empty plan valid without catch-all certificate automation', async () => { + const result = composeRoutePlan([]); + if (!result.ok) throw new Error('empty composition failed'); + const config = JSON.parse((await policy({ plan: result.value })).config.adaptedConfig.body); + expect(config.apps.tls).toEqual({ automation: { policies: [] }, certificates: { automate: [] } }); + expect(config.apps.http.servers.cadder_https.routes).toEqual([]); +}); + +it('retains TLS subjects when all project routes prune without requiring a matching guard', async () => { + const config = normalizeCaddyConfig( + JSON.stringify({ + apps: { + http: { + servers: { + project: { + routes: [ + { + match: [{ host: ['disabled.localhost'] }], + handle: [{ handler: 'static_response', body: 'disabled-only' }], + }, + ], + }, + }, + }, + }, + }), + ); + const composed = composeRoutePlan([{ registration: registration('pruned'), config }]); + if (!composed.ok) throw new Error('pruned-route composition failed'); + expect(composed.value.tlsSubjects).toEqual(['app.localhost']); + const body = JSON.parse((await policy({ plan: composed.value })).config.adaptedConfig.body); + expect(body.apps.tls.automation.policies[0].subjects).toEqual(['app.localhost']); + expect(body.apps.tls.certificates.automate).toEqual(['app.localhost']); + for (const name of ['cadder_http', 'cadder_https'] as const) { + expect(composed.value.servers[name].routes).toEqual([]); + expect(body.apps.http.servers[name].routes).toEqual([]); + } +}); + +it.each(['linux', 'darwin', 'win32'] as const)( + 'isolates Caddy default storage, autosave, home and scratch on %s, including mixed-case env keys', + async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform); + const ambient = { ...process.env }; + const baseEnvironment = Object.freeze({ + PATH: 'preserved', + unrelated: 'yes', + XDG_DATA_HOME: '/outside', + xdg_data_home: '/outside2', + Xdg_Config_Home: '/outside', + HOME: '/outside', + home: '/outside', + UserProfile: '/outside', + HomeDrive: 'X:', + HomePath: '/outside', + AppData: '/outside', + LOCALAPPDATA: '/outside', + Temp: '/outside', + TMP: '/outside', + TmpDir: '/outside', + XDG_CACHE_HOME: '/outside', + XDG_STATE_HOME: '/outside', + XDG_RUNTIME_DIR: '/outside', + caddy_admin: ':2019', + }); + const snapshot = { ...baseEnvironment }; + const value = await policy({ baseEnvironment }); + const env = value.environment; + expect(env.PATH).toBe('preserved'); + expect(env.unrelated).toBe('yes'); + expect(env.XDG_DATA_HOME).toBe(value.paths.data); + expect(env.XDG_CONFIG_HOME).toBe(value.paths.config); + expect(join(env.XDG_DATA_HOME!, 'caddy')).toBe(value.paths.defaultStorage); + expect(join(env.XDG_CONFIG_HOME!, 'caddy')).toBe(value.paths.autosaveDirectory); + for (const key of ['TEMP', 'TMP', 'TMPDIR', 'XDG_RUNTIME_DIR']) + expect(env[key]).toBe(value.paths.scratch); + expect(env.HOME).toBe(value.paths.home); + expect(env.XDG_CACHE_HOME).toBe(value.paths.data); + expect(env.XDG_STATE_HOME).toBe(value.paths.data); + expect(Object.values(env)).not.toContain('/outside'); + expect(Object.keys(env).filter((key) => key.toUpperCase() === 'XDG_DATA_HOME')).toEqual([ + 'XDG_DATA_HOME', + ]); + expect(env.caddy_admin).toBeUndefined(); + expect(env.CADDY_ADMIN).toBeUndefined(); + if (platform === 'win32') { + expect(env.USERPROFILE).toBe(value.paths.home); + expect(env.HOMEDRIVE! + env.HOMEPATH!).toBe(value.paths.home); + expect(env.APPDATA).toBe(value.paths.config); + expect(env.LOCALAPPDATA).toBe(value.paths.data); + } else { + for (const key of ['USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA']) + expect(env[key]).toBeUndefined(); + } + expect(baseEnvironment).toEqual(snapshot); + expect({ ...process.env }).toEqual(ambient); + expect(Object.isFrozen(env)).toBe(true); + expect(security.prepareRuntime).toHaveBeenCalledTimes(7); + for (const directory of [ + value.paths.data, + value.paths.config, + value.paths.scratch, + value.paths.home, + value.paths.defaultStorage, + value.paths.autosaveDirectory, + ]) { + expect(security.prepareRuntime).toHaveBeenCalledWith(directory, owner); + expect(security.assertProtected).toHaveBeenCalledWith(directory, owner, true); + } + }, +); + +it('defaults to a copy of ambient env without changing it', async () => { + const before = { ...process.env }; + const input = options(); + const withoutEnvironment = { ...input }; + delete withoutEnvironment.baseEnvironment; + const result = await prepareSecureAdminPolicy(withoutEnvironment); + expect(result.ok).toBe(true); + expect({ ...process.env }).toEqual(before); +}); + +it.each([0, -1, 65536, 1.1, NaN, Infinity, 80, 443, '32451', undefined, null])( + 'refuses invalid/reserved admin port %s before runtime writes', + async (adminPort) => { + await refused({ adminPort: adminPort as number }, 'caddy_admin_policy_invalid'); + expect(security.prepareRuntime).not.toHaveBeenCalled(); + expect(loadOrCreateAdminMaterial).not.toHaveBeenCalled(); + }, +); +it.each([1, 2021, 65535])('uses only the exact explicit valid port %s', async (adminPort) => { + const value = await policy({ adminPort }); + expect(JSON.parse(value.config.adaptedConfig.body).admin.remote.listen).toBe( + `127.0.0.1:${adminPort}`, + ); +}); +it.each(['runtimeBase', 'materialDirectory'] as const)('requires an absolute %s', async (key) => { + await refused({ [key]: 'relative' }, 'caddy_admin_policy_invalid'); + expect(security.prepareRuntime).not.toHaveBeenCalled(); +}); + +const outer = 'servers.cadder_http.routes.0'; +it.each([ + ['kind', 'kind', 'config'], + ['project admin', 'admin', { remote: { listen: ':2021' } }], + ['PKI', 'apps', { pki: {} }], + ['third server', 'servers.evil', {}], + ['widened HTTP', 'servers.cadder_http.listen', [':80']], + ['widened HTTPS', 'servers.cadder_https.listen', ['localhost:443']], + ['extra listener policy', 'servers.cadder_https.listener_wrappers', []], + ['missing routes', 'servers.cadder_http.routes', undefined], + ['non-array routes', 'servers.cadder_http.routes', {}], + ['malformed server', 'servers.cadder_http', null], + ['non-array subjects', 'tlsSubjects', null], + ['unsorted subjects', 'tlsSubjects', ['other.localhost', 'app.localhost']], + ['duplicate subjects', 'tlsSubjects', ['app.localhost', 'app.localhost']], + ['empty subject', 'tlsSubjects', ['']], + ['non-string subject', 'tlsSubjects', [42]], + ['noncanonical subject', 'tlsSubjects', ['APP.localhost']], + ['missing guard', `${outer}.match`, []], + ['hostless guard', `${outer}.match.0.host`, []], + ['unguarded route', `${outer}.terminal`, false], + ['guard matcher widening', `${outer}.match.0.path`, ['*']], + ['extra outer handler', `${outer}.handle.1`, { handler: 'static_response' }], + ['not a subroute', `${outer}.handle.0.handler`, 'file_server'], + ['invalid child routes', `${outer}.handle.0.routes`, [42]], + ['guard outside subjects', `${outer}.match.0.host`, ['evil.localhost']], + ['duplicate guard host', `${outer}.match.0.host`, ['app.localhost', 'app.localhost']], + ['mismatched guard order', `${outer}.match.0.host`, ['other.localhost']], +] as const)('refuses malformed/widened plan: %s', async (_name, path, value) => { + const plan = structuredClone(routePlan()); + const keys = path.split('.'); + let target: unknown = plan; + for (const key of keys.slice(0, -1)) target = (target as Record)[key]; + if (value === undefined) Reflect.deleteProperty(target as object, keys.at(-1)!); + else + Object.defineProperty(target, keys.at(-1)!, { + enumerable: true, + configurable: true, + writable: true, + value, + }); + await refused({ plan }, 'caddy_candidate_invalid'); + expect(security.prepareRuntime).not.toHaveBeenCalled(); +}); + +it.each(['owner', 'mode', 'link', 'ancestor-link', 'not-directory', 'ACL'] as const)( + 'uses existing helpers to deny unsafe runtime %s without repairing it', + async (failure) => { + vi.mocked(security.prepareRuntime).mockImplementation(actualSecurity.prepareRuntime); + if (failure === 'ACL') { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.mocked(powershell).mockRejectedValue(new Error('SECRET attacker ACL details')); + } else { + vi.mocked(fs.lstat).mockImplementation(async (path) => { + const target = String(path) === base || failure === 'ancestor-link'; + return target + ? stat({ + uid: failure === 'owner' ? 99 : 42, + mode: failure === 'mode' ? 0o755 : 0o700, + link: failure === 'link' || failure === 'ancestor-link', + directory: failure !== 'not-directory', + }) + : stat(); + }); + } + await refused({}, 'caddy_admin_policy_failed'); + expect(loadOrCreateAdminMaterial).not.toHaveBeenCalled(); + }, +); +it('refuses unsafe existing child paths and material failures without replacement or diagnostic leakage', async () => { + vi.mocked(security.assertProtected).mockRejectedValue(new Error('SECRET path parser details')); + await refused({}, 'caddy_admin_policy_failed'); + expect(loadOrCreateAdminMaterial).not.toHaveBeenCalled(); + vi.mocked(security.assertProtected).mockResolvedValue(); + vi.mocked(loadOrCreateAdminMaterial).mockResolvedValue({ + ok: false, + error: { + kind: 'configuration', + code: 'caddy_material_invalid', + message: 'Caddy administration material was refused.', + guidance: null, + retryable: false, + requestId: null, + }, + }); + await refused({}, 'caddy_material_invalid'); +}); + +it('prepares and re-verifies an actual owned runtime, with authorized client LEAF DER', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(nativePlatform); + if (uidDescriptor) Object.defineProperty(process, 'getuid', uidDescriptor); + else Reflect.deleteProperty(process, 'getuid'); + vi.mocked(fs.lstat).mockImplementation(actualFs.lstat); + vi.mocked(powershell).mockImplementation(actualPowerShell.powershell); + vi.mocked(security.prepareRuntime).mockImplementation(actualSecurity.prepareRuntime); + vi.mocked(security.assertProtected).mockImplementation(actualSecurity.assertProtected); + vi.mocked(loadOrCreateAdminMaterial).mockImplementation(actualMaterial.loadOrCreateAdminMaterial); + const nativeOwner = await security.runtimeOwner( + nativePlatform === 'win32' ? undefined : process.getuid!(), + ); + cleanup = await actualFs.mkdtemp(join(tmpdir(), 'cadder-policy-owned-')); + const input = options({ + runtimeBase: join(cleanup, 'runtime'), + materialDirectory: join(cleanup, 'material'), + owner: nativeOwner, + }); + const first = await prepareSecureAdminPolicy(input); + if (!first.ok) throw new Error(`Native protected preparation failed: ${first.error.code}`); + for (const directory of Object.values(first.value.paths)) + await actualSecurity.assertProtected(directory, nativeOwner, true); + const second = await prepareSecureAdminPolicy(input); + expect(second).toEqual(first); + const config = JSON.parse(first.value.config.adaptedConfig.body); + const der = Buffer.from(config.admin.remote.access_control[0].public_keys[0], 'base64'); + const leaf = new X509Certificate(der); + const clientPem = await actualFs.readFile( + join(input.materialDirectory, 'client.crt.pem'), + 'utf8', + ); + expect(leaf.raw).toEqual(new X509Certificate(clientPem).raw); + expect(leaf.ca).toBe(false); + expect(leaf.raw).not.toEqual(new X509Certificate(first.value.client.ca).raw); + expect(first.value.config.adaptedConfig.body).not.toContain('PRIVATE KEY'); +}, 60_000); + +describe('independent complete-body normalization', () => { + it('orders all object keys deterministically, preserving arrays, primitives and opaque __proto__ data', () => { + const left = + '{"z":[3,1,{"b":false,"a":null}],"2":"two","10":"ten","__proto__":{"polluted":true},"toJSON":"opaque","a":{"z":"\\u00e9","a":-0}}'; + const right = + '{"a":{"a":0,"z":"é"},"toJSON":"opaque","__proto__":{"polluted":true},"10":"ten","2":"two","z":[3,1,{"a":null,"b":false}]}'; + const first = normalizeCaddyConfig(left); + expect(first).toEqual(normalizeCaddyConfig(right)); + const canonical = + '{"10":"ten","2":"two","__proto__":{"polluted":true},"a":{"a":0,"z":"é"},"toJSON":"opaque","z":[3,1,{"a":null,"b":false}]}'; + expect(first.adaptedConfig.body).toBe(canonical); + expect(first.effectiveConfigHash).toBe(configHash(canonical)); + expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); + expect(normalizeCaddyConfig('{"a":[1,2]}').effectiveConfigHash).not.toBe( + normalizeCaddyConfig('{"a":[2,1]}').effectiveConfigHash, + ); + expect(normalizeCaddyConfig('{"a":{}}')).not.toEqual( + normalizeCaddyConfig('{"a":{"default":false}}'), + ); + }); + it.each([ + '', + '{', + '{} {}', + '[]', + 'null', + '42', + '"string"', + '{"x":NaN}', + '{"x":Infinity}', + '{"x":"\ud800"}', + '{"x":1e400}', + ])('refuses incomplete, non-object or invalid JSON body %j with bounded diagnostics', (body) => { + expect(() => normalizeCaddyConfig(body)).toThrow('Expected a complete, bounded JSON object'); + }); + it('refuses object carriers without invoking toJSON or getters', async () => { + const toJSON = vi.fn(() => { + throw new Error('SECRET'); + }); + expect(() => normalizeCaddyConfig({ toJSON } as never)).toThrow(); + expect(toJSON).not.toHaveBeenCalled(); + const plan = routePlan(); + Object.defineProperty(plan, 'toJSON', { enumerable: true, value: toJSON }); + await refused({ plan }, 'caddy_candidate_invalid'); + expect(toJSON).not.toHaveBeenCalled(); + const getter = vi.fn(() => { + throw new Error('SECRET'); + }); + Object.defineProperty(plan, 'kind', { enumerable: true, get: getter }); + await refused({ plan }, 'caddy_candidate_invalid'); + expect(getter).not.toHaveBeenCalled(); + }); + it.each([ + 'cycle', + 'proxy', + 'native-failure', + 'prototype', + 'symbol', + 'nonenumerable', + 'undefined', + 'function', + 'bigint', + 'nan', + 'sparse', + 'array-property', + 'array-getter', + 'deep', + 'oversize-string', + 'oversize-key', + 'aggregate-bytes', + 'many-values', + ] as const)('refuses unsafe/oversized plan carrier: %s', async (fault) => { + const plan = routePlan(); + const route = plan.servers.cadder_http.routes[0]!; + let payload: unknown; + switch (fault) { + case 'cycle': + payload = plan; + break; + case 'proxy': + payload = new Proxy( + {}, + { + ownKeys: () => { + throw new Error('SECRET'); + }, + }, + ); + break; + case 'native-failure': + vi.spyOn(Object, 'getOwnPropertyDescriptors').mockImplementationOnce(() => { + throw new Error('SECRET'); + }); + payload = {}; + break; + case 'prototype': + payload = new Date(); + break; + case 'symbol': + payload = { [Symbol('secret')]: 1 }; + break; + case 'nonenumerable': + payload = Object.defineProperty({}, 'secret', { value: 1 }); + break; + case 'undefined': + payload = undefined; + break; + case 'function': + payload = () => 1; + break; + case 'bigint': + payload = 1n; + break; + case 'nan': + payload = NaN; + break; + case 'sparse': + payload = new Array(2); + break; + case 'array-property': + payload = Object.assign([1], { extra: 2 }); + break; + case 'array-getter': + payload = Object.defineProperty([1], '0', { + get: () => { + throw new Error('SECRET'); + }, + }); + break; + case 'deep': + payload = {}; + for (let i = 0; i < 150; i++) payload = { child: payload }; + break; + case 'oversize-string': + payload = 'x'.repeat(maxCaddyConfigurationBytes + 1); + break; + case 'oversize-key': + payload = { ['x'.repeat(maxCaddyConfigurationBytes + 1)]: 1 }; + break; + case 'aggregate-bytes': + payload = { + a: 'x'.repeat(maxCaddyConfigurationBytes / 2), + b: 'x'.repeat(maxCaddyConfigurationBytes / 2), + }; + break; + case 'many-values': + payload = new Array(1_000_001).fill(null); + break; + } + Object.defineProperty(route, 'payload', { enumerable: true, value: payload }); + await refused({ plan }, 'caddy_candidate_invalid'); + }); + it('bounds full bodies, canonical expansion, depth and total visited values', () => { + expect(() => + normalizeCaddyConfig('{"s":"' + 'x'.repeat(maxCaddyConfigurationBytes) + '"}'), + ).toThrow(); + const repeated = 'é'.repeat(Math.floor(maxCaddyConfigurationBytes / 2)); + expect(() => normalizeCaddyConfig(JSON.stringify({ s: repeated }))).toThrow(); + // Compact number spellings fit the input bound; canonical spelling exceeds it. + const expanding = + '{"a":[' + '1e9,'.repeat(900_000) + '0],"s":"' + 'x'.repeat(25_000_000) + '"}'; + expect(Buffer.byteLength(expanding)).toBeLessThan(maxCaddyConfigurationBytes); + expect(() => normalizeCaddyConfig(expanding)).toThrow(); + expect(() => + normalizeCaddyConfig( + '{"s":"' + '\u0001'.repeat(Math.floor(maxCaddyConfigurationBytes / 6)) + '"}', + ), + ).toThrow(); + expect(() => + normalizeCaddyConfig('{"s":' + '['.repeat(150) + '0' + ']'.repeat(150) + '}'), + ).toThrow(); + expect(() => normalizeCaddyConfig('{"s":[' + '0,'.repeat(1_000_000) + '0]}')).toThrow(); + }); +}); diff --git a/test/caddy-certificates.test.ts b/test/caddy-certificates.test.ts new file mode 100644 index 0000000..cda9d25 --- /dev/null +++ b/test/caddy-certificates.test.ts @@ -0,0 +1,671 @@ +import 'reflect-metadata'; +import { constants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { createPrivateKey, webcrypto, X509Certificate as NativeCertificate } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { + BasicConstraintsExtension, + ExtendedKeyUsage, + ExtendedKeyUsageExtension, + Extension, + KeyUsageFlags, + KeyUsagesExtension, + PemConverter, + X509Certificate, + X509CertificateGenerator, + type X509CertificateCreateParams, +} from '@peculiar/x509'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { loadOrCreateAdminMaterial, type AdminMaterial } from '../src/caddy/certificates.ts'; +import { assertProtected, runtimeOwner } from '../src/platform/runtime-security.ts'; +import { powershell } from '../src/platform/powershell.ts'; +import { protocolErrorSchema } from '../src/protocol/errors.ts'; + +vi.mock('node:fs/promises', async (original) => { + const actual = await original(); + return { + ...actual, + lstat: vi.fn(actual.lstat), + open: vi.fn(actual.open), + unlink: vi.fn(actual.unlink), + }; +}); +vi.mock('../src/platform/powershell.ts', async (original) => { + const actual = await original(); + return { ...actual, powershell: vi.fn(actual.powershell) }; +}); +const actualFs = await vi.importActual('node:fs/promises'); +const actualPowerShell = await vi.importActual( + '../src/platform/powershell.ts', +); +const owner = { id: 'uid:42', uid: 42, elevated: false }; +const directory = join(tmpdir(), 'cadder-mocked-admin'); +const roles = ['root', 'intermediate', 'client'] as const; +type Role = (typeof roles)[number]; +type CertificateChanges = Partial>; +type File = { bytes: Buffer; uid: number; mode: number; linked?: boolean }; +const files = new Map(); +const handles = new Map>(); +const uidDescriptor = Object.getOwnPropertyDescriptor(process, 'getuid'); +const day = 24 * 60 * 60 * 1000; +const nativeDirectories: string[] = []; +let writeFailure: 'writeFile' | 'sync' | 'close' | undefined; +let failurePath: string | undefined; + +function missing() { + return Object.assign(new Error('missing'), { code: 'ENOENT' }); +} +function diskFailure() { + return Object.assign(new Error('SECRET private parser payload'), { code: 'ENOSPC' }); +} +function fileHandle(path: string) { + let offset = 0; + const phases = { + writeFile: vi.fn(async (data: string) => { + files.get(path)!.bytes = Buffer.from(data); + if (writeFailure === 'writeFile' && failurePath === path) throw diskFailure(); + }), + sync: vi.fn(async () => { + if (writeFailure === 'sync' && failurePath === path) throw diskFailure(); + }), + close: vi.fn(async () => { + if (writeFailure === 'close' && failurePath === path) throw diskFailure(); + }), + stat: vi.fn(async () => ({ + isFile: (): boolean => true, + uid: files.get(path)!.uid, + mode: files.get(path)!.mode, + size: files.get(path)!.bytes.length, + })), + read: vi.fn(async (buffer: Buffer, target: number, length: number) => { + const source = files.get(path)!.bytes; + const bytesRead = Math.min(length, source.length - offset); + source.copy(buffer, target, offset, offset + bytesRead); + offset += bytesRead; + return { bytesRead, buffer }; + }), + }; + return phases; +} + +beforeEach(() => { + files.clear(); + handles.clear(); + writeFailure = undefined; + failurePath = undefined; + vi.clearAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + Object.defineProperty(process, 'getuid', { configurable: true, value: () => 42 }); + vi.mocked(powershell).mockResolvedValue(''); + vi.mocked(fs.lstat).mockImplementation(async (path) => { + const file = files.get(String(path)); + if (!file && String(path).endsWith('.pem')) throw missing(); + return { + isSymbolicLink: () => file?.linked ?? false, + isFile: () => !!file, + isDirectory: () => !file, + uid: file?.uid ?? 42, + mode: file?.mode ?? 0o700, + } as never; + }); + vi.mocked(fs.open).mockImplementation(async (path, flags) => { + const name = String(path); + if (Number(flags) & constants.O_EXCL) { + if (files.has(name)) throw Object.assign(new Error('exists'), { code: 'EEXIST' }); + files.set(name, { bytes: Buffer.alloc(0), uid: 42, mode: 0o600 }); + } else if (!files.has(name)) throw missing(); + const handle = fileHandle(name); + handles.set(name, handle); + return handle as never; + }); + vi.mocked(fs.unlink).mockImplementation(async (path) => { + if (!files.delete(String(path))) throw missing(); + }); +}); +afterEach(async () => { + vi.restoreAllMocks(); + if (uidDescriptor) Object.defineProperty(process, 'getuid', uidDescriptor); + else Reflect.deleteProperty(process, 'getuid'); + await Promise.all( + nativeDirectories.splice(0).map((path) => actualFs.rm(path, { recursive: true, force: true })), + ); +}); + +async function material(): Promise { + const result = await loadOrCreateAdminMaterial(directory, owner); + if (!result.ok) throw new Error(`Material setup failed: ${result.error.code}`); + return result.value; +} +function text(path: string): string { + return files.get(path)!.bytes.toString('utf8'); +} +function replace(path: string, value: string | Buffer) { + files.get(path)!.bytes = Buffer.from(value); +} +async function expectFailure(code: string) { + const result = await loadOrCreateAdminMaterial(directory, owner); + expect(result).toMatchObject({ ok: false, error: { code, retryable: false } }); + if (result.ok) throw new Error('unexpected material success'); + expect(protocolErrorSchema.safeParse(result.error).success).toBe(true); + expect(JSON.stringify(result)).not.toMatch(/SECRET|PRIVATE KEY|parser/); + return result.error; +} +async function reissue(value: AdminMaterial, role: Role, changes: CertificateChanges) { + const certificate = new X509Certificate(value.certificates[role]); + const issuer = role === 'client' ? 'intermediate' : 'root'; + const signingKey = await crypto.subtle.importKey( + 'pkcs8', + PemConverter.decodeFirst(text(value.paths[issuer].key)), + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'], + ); + const updated = await X509CertificateGenerator.create( + { + subject: certificate.subject, + issuer: certificate.issuer, + publicKey: certificate.publicKey, + signingKey, + signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + notBefore: certificate.notBefore, + notAfter: certificate.notAfter, + extensions: certificate.extensions, + ...changes, + }, + crypto, + ); + replace(value.paths[role].certificate, updated.toString('pem')); +} + +it('generates independent P-256 CAs/client, fixed PEM names, key matches and verified chain', async () => { + const value = await material(); + const publicKeys = roles.map((role) => + new NativeCertificate(value.certificates[role]).publicKey + .export({ format: 'der', type: 'spki' }) + .toString('base64'), + ); + expect(new Set(publicKeys).size).toBe(3); + for (const role of roles) { + const parsed = new X509Certificate(value.certificates[role]); + const native = new NativeCertificate(value.certificates[role]); + const issuer = new NativeCertificate( + value.certificates[role === 'client' ? 'intermediate' : 'root'], + ); + expect(native.verify(issuer.publicKey)).toBe(true); + expect(native.checkPrivateKey(createPrivateKey(text(value.paths[role].key)))).toBe(true); + expect(native.publicKey.asymmetricKeyDetails?.namedCurve).toBe('prime256v1'); + expect(parsed.getExtension(BasicConstraintsExtension)).toMatchObject({ + ca: role !== 'client', + critical: true, + pathLength: role === 'root' ? 1 : role === 'intermediate' ? 0 : undefined, + }); + expect(parsed.getExtension(KeyUsagesExtension)).toMatchObject({ + usages: + role === 'client' + ? KeyUsageFlags.digitalSignature + : KeyUsageFlags.keyCertSign | KeyUsageFlags.cRLSign, + critical: true, + }); + expect(parsed.getExtension(ExtendedKeyUsageExtension)?.usages).toEqual( + role === 'client' ? [ExtendedKeyUsage.clientAuth] : undefined, + ); + expect(parsed.notBefore.getTime()).toBeLessThanOrEqual(Date.now()); + expect(Math.round((parsed.notAfter.getTime() - Date.now()) / day)).toBe( + (role === 'root' ? 10 : role === 'intermediate' ? 5 : 1) * 365, + ); + expect(value.paths[role]).toEqual({ + certificate: join(directory, `${role}.crt.pem`), + key: join(directory, `${role}.key.pem`), + }); + } + expect(value.authorizedClientCertificateBase64).toBe( + new X509Certificate(value.certificates.client).toString('base64'), + ); + expect(value.tls).toEqual({ + ca: value.certificates.root, + cert: `${value.certificates.client}\n${value.certificates.intermediate}`, + key: text(value.paths.client.key), + }); + expect(files.size).toBe(6); +}); + +it('reuses existing complete identity without writes or regeneration', async () => { + const first = await material(); + const before = [...files].map(([path, file]) => [path, file.bytes.toString('base64')]); + const generate = vi.spyOn(X509CertificateGenerator, 'create'); + const reused = await material(); + expect(reused).toEqual(first); + expect(generate).not.toHaveBeenCalled(); + expect([...files].map(([path, file]) => [path, file.bytes.toString('base64')])).toEqual(before); + for (const handle of handles.values()) { + expect(handle.writeFile).not.toHaveBeenCalled(); + expect(handle.close).toHaveBeenCalledOnce(); + } +}); + +it('uses the available no-follow flag when opening existing PEM files', async () => { + await material(); + vi.mocked(fs.open).mockClear(); + await material(); + expect(fs.open).toHaveBeenCalledTimes(6); + for (const [, flags] of vi.mocked(fs.open).mock.calls) { + expect(flags).toBe(constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | constants.O_NONBLOCK); + expect(Number(flags) & constants.O_EXCL).toBe(0); + } +}); + +it('redacts generation exceptions without creating files', async () => { + vi.spyOn(X509CertificateGenerator, 'create').mockRejectedValueOnce(diskFailure()); + await expectFailure('caddy_material_generation_failed'); + expect(files.size).toBe(0); + expect(fs.open).not.toHaveBeenCalled(); +}); + +it('validates generated material before persisting, including independent keys', async () => { + const keys = await webcrypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ]); + vi.spyOn(webcrypto.subtle, 'generateKey').mockResolvedValue(keys); + await expectFailure('caddy_material_invalid'); + expect(files.size).toBe(0); + expect(fs.open).not.toHaveBeenCalled(); +}); + +it.each(roles)('fails closed on expired %s without rotation', async (role) => { + const value = await material(); + const expiry = new X509Certificate(value.certificates[role]).notAfter.getTime(); + vi.spyOn(Date, 'now').mockReturnValue(expiry); + await expectFailure('caddy_material_validity'); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(files.size).toBe(6); +}); +it.each(roles)('rejects not-yet-valid %s', async (role) => { + const value = await material(); + await reissue(value, role, { notBefore: new Date(Date.now() + day) }); + await expectFailure('caddy_material_validity'); +}); + +it.each( + roles.flatMap((role) => (['certificate', 'key'] as const).map((field) => ({ role, field }))), +)('refuses malformed $role $field and leaves it unchanged', async ({ role, field }) => { + const value = await material(); + replace(value.paths[role][field], 'PRIVATE KEY SECRET parser payload'); + const before = text(value.paths[role][field]); + await expectFailure('caddy_material_invalid'); + expect(text(value.paths[role][field])).toBe(before); + expect(fs.unlink).not.toHaveBeenCalled(); +}); +it.each(['certificate', 'key'] as const)( + 'rejects oversized %s before allocating/reading it', + async (field) => { + const value = await material(); + replace(value.paths.root[field], Buffer.alloc(16 * 1024 + 1)); + await expectFailure('caddy_material_oversized'); + expect(handles.get(value.paths.root[field])!.read).not.toHaveBeenCalled(); + }, +); +it('bounds a growing file even if stat reported a small size', async () => { + const value = await material(); + replace(value.paths.root.certificate, Buffer.alloc(32 * 1024)); + const open = vi.mocked(fs.open).getMockImplementation()!; + vi.mocked(fs.open).mockImplementation(async (path, flags, mode) => { + const handle = await open(path, flags, mode); + if (path === value.paths.root.certificate) + handles + .get(String(path))! + .stat.mockResolvedValue({ isFile: () => true, uid: 42, mode: 0o600, size: 1 }); + return handle; + }); + await expectFailure('caddy_material_oversized'); + expect(handles.get(value.paths.root.certificate)!.read.mock.calls[0]![2]).toBe(16 * 1024 + 1); +}); +it('rejects invalid UTF-8, extra PEM blocks and empty files', async () => { + const value = await material(); + for (const bad of [ + Buffer.from([0xff]), + '', + `${value.certificates.root}\n${value.certificates.root}`, + ]) { + replace(value.paths.root.certificate, bad); + await expectFailure('caddy_material_invalid'); + } +}); +it.each(['certificate', 'key'] as const)('rejects PEM headers on %s', async (field) => { + const value = await material(); + const path = value.paths.root[field]; + const pem = PemConverter.encode( + PemConverter.decodeFirst(text(path)), + field === 'certificate' ? 'CERTIFICATE' : 'PRIVATE KEY', + ).replace(/^(-----BEGIN [^-]+-----)/, '$1\nProc-Type: 4,ENCRYPTED\n'); + expect(PemConverter.decodeWithHeaders(pem)[0]!.headers).toEqual([ + { key: 'Proc-Type', value: '4,ENCRYPTED' }, + ]); + replace(path, pem); + await expectFailure('caddy_material_invalid'); + expect(text(path)).toBe(pem); + expect(fs.unlink).not.toHaveBeenCalled(); +}); + +it.each(['certificate', 'key'] as const)( + 'redacts valid-PEM malformed DER %s parser exceptions', + async (field) => { + const value = await material(); + replace( + value.paths.root[field], + PemConverter.encode( + Buffer.from('SECRET parser details'), + field === 'certificate' ? 'CERTIFICATE' : 'PRIVATE KEY', + ), + ); + await expectFailure('caddy_material_invalid'); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); +it('refuses a correctly signed/matched client with a wrong EC curve', async () => { + const value = await material(); + const keys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-384' }, true, [ + 'sign', + 'verify', + ]); + await reissue(value, 'client', { publicKey: keys.publicKey }); + replace( + value.paths.client.key, + PemConverter.encode(await crypto.subtle.exportKey('pkcs8', keys.privateKey), 'PRIVATE KEY'), + ); + await expectFailure('caddy_material_invalid'); +}); + +it.each(roles)('refuses mismatched %s key', async (role) => { + const value = await material(); + replace(value.paths[role].key, text(value.paths[role === 'root' ? 'client' : 'root'].key)); + await expectFailure('caddy_material_invalid'); +}); +it.each([1, 3, 5])( + 'refuses a partial %i-file bundle without touching existing files', + async (count) => { + await material(); + for (const path of [...files.keys()].slice(count)) files.delete(path); + const before = [...files.keys()]; + await expectFailure('caddy_material_incomplete'); + expect([...files.keys()]).toEqual(before); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); + +it.each([ + 'ca', + 'path', + 'usage', + 'eku', + 'duplicate', + 'critical', + 'subject', + 'issuer', + 'signature', + 'containment', +] as const)('rejects invalid chain/profile: %s', async (fault) => { + const value = await material(); + const role = + fault === 'ca' || fault === 'path' || fault === 'usage' || fault === 'eku' + ? 'intermediate' + : 'client'; + const certificate = new X509Certificate(value.certificates[role]); + let extensions = certificate.extensions; + if (fault === 'ca' || fault === 'path') + extensions = extensions + .filter((extension) => !(extension instanceof BasicConstraintsExtension)) + .concat( + new BasicConstraintsExtension(fault !== 'ca', fault === 'path' ? 1 : undefined, true), + ); + if (fault === 'usage') + extensions = extensions + .filter((extension) => !(extension instanceof KeyUsagesExtension)) + .concat(new KeyUsagesExtension(KeyUsageFlags.digitalSignature, true)); + if (fault === 'eku') + extensions = [ + ...extensions, + new ExtendedKeyUsageExtension([ExtendedKeyUsage.clientAuth], true), + ]; + if (fault === 'duplicate') + extensions = [...extensions, new BasicConstraintsExtension(false, undefined, true)]; + if (fault === 'critical') + extensions = [ + ...extensions, + new Extension('1.2.3.4', true, new BasicConstraintsExtension(false).value), + ]; + const changes: CertificateChanges = { extensions }; + if (fault === 'subject') changes.subject = 'CN=Another client'; + if (fault === 'issuer') changes.issuer = 'CN=Another issuer'; + if (fault === 'containment') changes.notAfter = new Date(Date.now() + 6 * 365 * day); + if (fault === 'signature') + changes.signingKey = await crypto.subtle.importKey( + 'pkcs8', + PemConverter.decodeFirst(text(value.paths.root.key)), + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'], + ); + await reissue(value, role, changes); + await expectFailure('caddy_material_invalid'); +}); +it.each(roles)( + 'verifies the %s certificate signature rather than trusting issuer names', + async (role) => { + const value = await material(); + const wrongSigner = role === 'client' ? 'root' : 'client'; + const signingKey = await crypto.subtle.importKey( + 'pkcs8', + PemConverter.decodeFirst(text(value.paths[wrongSigner].key)), + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'], + ); + await reissue(value, role, { signingKey }); + await expectFailure('caddy_material_invalid'); + }, +); + +it.each([ + 'missing-usage', + 'ca', + 'missing-eku', + 'server-eku', + 'noncritical-basic', + 'noncritical-usage', + 'noncritical-eku', + 'wrong-signature-algorithm', +] as const)('rejects invalid authorized client: %s', async (fault) => { + const value = await material(); + let extensions = new X509Certificate(value.certificates.client).extensions; + if (fault === 'missing-usage' || fault === 'noncritical-usage') + extensions = extensions.filter((extension) => !(extension instanceof KeyUsagesExtension)); + if (fault === 'noncritical-usage') + extensions.push(new KeyUsagesExtension(KeyUsageFlags.digitalSignature, false)); + if (fault === 'ca' || fault === 'noncritical-basic') + extensions = extensions.filter( + (extension) => !(extension instanceof BasicConstraintsExtension), + ); + if (fault === 'ca') extensions.push(new BasicConstraintsExtension(true, 0, true)); + if (fault === 'noncritical-basic') extensions.push(new BasicConstraintsExtension(false)); + if (fault === 'missing-eku' || fault === 'server-eku' || fault === 'noncritical-eku') + extensions = extensions.filter( + (extension) => !(extension instanceof ExtendedKeyUsageExtension), + ); + if (fault === 'server-eku') + extensions.push(new ExtendedKeyUsageExtension([ExtendedKeyUsage.serverAuth])); + if (fault === 'noncritical-eku') + extensions.push(new ExtendedKeyUsageExtension([ExtendedKeyUsage.clientAuth], false)); + await reissue(value, 'client', { + extensions, + ...(fault === 'wrong-signature-algorithm' + ? { signingAlgorithm: { name: 'ECDSA', hash: 'SHA-384' } } + : {}), + }); + await expectFailure('caddy_material_invalid'); +}); + +it.each(['link', 'owner', 'mode'] as const)( + 'denies unsafe existing %s without writes, repairs or cleanup', + async (fault) => { + const value = await material(); + const file = files.get(value.paths.root.certificate)!; + if (fault === 'link') file.linked = true; + if (fault === 'owner') file.uid = 43; + if (fault === 'mode') file.mode = 0o644; + vi.mocked(fs.open).mockClear(); + expect((await expectFailure('caddy_material_unsafe')).kind).toBe('accessDenied'); + expect(fs.open).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); +it('rejects linked ancestors before looking for or creating files', async () => { + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path, options) => { + if (String(path) === dirname(directory)) + return { isSymbolicLink: () => true, uid: 42 } as never; + return inspect(path, options); + }); + await expectFailure('caddy_material_unsafe'); + expect(fs.open).not.toHaveBeenCalled(); + expect(files.size).toBe(0); +}); +it('denies Windows ACL failures without repairing them', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.mocked(powershell).mockRejectedValueOnce(new Error('unsafe ACL SECRET')); + await expectFailure('caddy_material_unsafe'); + expect(fs.open).not.toHaveBeenCalled(); + expect(powershell).toHaveBeenCalledOnce(); +}); + +it.each(['writeFile', 'sync', 'close'] as const)( + 'cleans only exclusively created paths after %s failure', + async (phase) => { + const unrelated = join(directory, 'unrelated.pem'); + files.set(unrelated, { bytes: Buffer.from('unowned'), uid: 43, mode: 0o644 }); + writeFailure = phase; + failurePath = join(directory, 'intermediate.key.pem'); + await expectFailure('caddy_material_io'); + expect([...files.keys()]).toEqual([unrelated]); + expect(fs.unlink).not.toHaveBeenCalledWith(unrelated); + expect(handles.get(failurePath)!.close).toHaveBeenCalledOnce(); + expect(fs.unlink).toHaveBeenCalledTimes(4); + }, +); +it('never removes a pre-existing concurrent file after an exclusive-create conflict', async () => { + const conflict = join(directory, 'intermediate.key.pem'); + const open = vi.mocked(fs.open).getMockImplementation()!; + vi.mocked(fs.open).mockImplementation(async (path, flags, mode) => { + if (path === conflict) + files.set(conflict, { bytes: Buffer.from('pre-existing'), uid: 42, mode: 0o600 }); + return open(path, flags, mode); + }); + await expectFailure('caddy_material_conflict'); + expect([...files.keys()]).toEqual([conflict]); + expect(text(conflict)).toBe('pre-existing'); + expect(fs.unlink).not.toHaveBeenCalledWith(conflict); +}); +it('reports cleanup failure while attempting every owned path and preserving unowned data', async () => { + writeFailure = 'sync'; + failurePath = join(directory, 'intermediate.key.pem'); + const unlink = vi.mocked(fs.unlink).getMockImplementation()!; + vi.mocked(fs.unlink).mockImplementation(async (path) => { + if (path === join(directory, 'root.key.pem')) throw diskFailure(); + return unlink(path); + }); + await expectFailure('caddy_material_cleanup_failed'); + expect([...files.keys()]).toEqual([join(directory, 'root.key.pem')]); + expect(fs.unlink).toHaveBeenCalledWith(join(directory, 'root.crt.pem')); +}); +it('tolerates an already-removed owned file while cleaning remaining owned paths', async () => { + writeFailure = 'sync'; + failurePath = join(directory, 'intermediate.key.pem'); + const unlink = vi.mocked(fs.unlink).getMockImplementation()!; + vi.mocked(fs.unlink).mockImplementation(async (path) => { + if (path === join(directory, 'root.key.pem')) files.delete(String(path)); + return unlink(path); + }); + await expectFailure('caddy_material_io'); + expect(files.size).toBe(0); + expect(fs.unlink).toHaveBeenCalledTimes(4); +}); + +it('does not claim ownership after failed open or helper cleanup failure', async () => { + vi.mocked(fs.open).mockRejectedValueOnce(diskFailure()); + await expectFailure('caddy_material_io'); + expect(fs.unlink).not.toHaveBeenCalled(); + writeFailure = 'writeFile'; + failurePath = join(directory, 'root.crt.pem'); + vi.mocked(fs.unlink).mockRejectedValueOnce(diskFailure()); + // The helper's aggregate error does not expose which cleanup phase failed. + // Do not invent a precise classification or claim its residual file as ours. + const error = await expectFailure('caddy_material_io'); + expect(error.guidance).toContain('may leave partial files'); + expect(fs.unlink).toHaveBeenCalledTimes(1); + expect(files.size).toBe(1); +}); +it.each(['read', 'close', 'stat'] as const)( + 'returns redacted I/O failures on existing-file %s', + async (phase) => { + const value = await material(); + const open = vi.mocked(fs.open).getMockImplementation()!; + vi.mocked(fs.open).mockImplementation(async (path, flags, mode) => { + const handle = await open(path, flags, mode); + if (path === value.paths.root.certificate) + handles.get(String(path))![phase].mockRejectedValueOnce(diskFailure()); + return handle; + }); + await expectFailure('caddy_material_io'); + expect(handles.get(value.paths.root.certificate)!.close).toHaveBeenCalledOnce(); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); +it('validates the opened object rather than trusting pre-open path checks', async () => { + const value = await material(); + const open = vi.mocked(fs.open).getMockImplementation()!; + vi.mocked(fs.open).mockImplementation(async (path, flags, mode) => { + const handle = await open(path, flags, mode); + if (path === value.paths.root.certificate) + handles + .get(String(path))! + .stat.mockResolvedValue({ isFile: () => false, uid: 42, mode: 0o600, size: 1 }); + return handle; + }); + await expectFailure('caddy_material_unsafe'); + expect(handles.get(value.paths.root.certificate)!.read).not.toHaveBeenCalled(); +}); + +it( + 'creates, reuses and checks all six permissions in actual owned temporary runtime data', + { timeout: 60_000 }, + async () => { + vi.restoreAllMocks(); + if (uidDescriptor) Object.defineProperty(process, 'getuid', uidDescriptor); + else Reflect.deleteProperty(process, 'getuid'); + vi.mocked(fs.lstat).mockImplementation(actualFs.lstat); + vi.mocked(fs.open).mockImplementation(actualFs.open); + vi.mocked(fs.unlink).mockImplementation(actualFs.unlink); + vi.mocked(powershell).mockImplementation(actualPowerShell.powershell); + const root = await actualFs.mkdtemp(join(tmpdir(), 'cadder-admin-material-')); + nativeDirectories.push(root); + const nativeOwner = await runtimeOwner( + process.platform !== 'win32' && process.getuid?.() === 0 ? 0 : undefined, + ); + const target = join(root, 'material'); + const first = await loadOrCreateAdminMaterial(target, nativeOwner); + expect(first.ok).toBe(true); + if (!first.ok) throw new Error(`Native creation refused: ${first.error.code}`); + await assertProtected(target, nativeOwner, true); + for (const paths of Object.values(first.value.paths)) + for (const path of Object.values(paths)) await assertProtected(path, nativeOwner); + const reused = await loadOrCreateAdminMaterial(target, nativeOwner); + expect(reused).toEqual(first); + if (process.platform !== 'win32') { + await actualFs.chmod(first.value.paths.client.key, 0o644); + const unsafe = await loadOrCreateAdminMaterial(target, nativeOwner); + expect(unsafe).toMatchObject({ ok: false, error: { kind: 'accessDenied' } }); + expect((await actualFs.lstat(first.value.paths.client.key)).mode & 0o777).toBe(0o644); + } + }, +); diff --git a/test/caddy-composition.test.ts b/test/caddy-composition.test.ts new file mode 100644 index 0000000..387d3e1 --- /dev/null +++ b/test/caddy-composition.test.ts @@ -0,0 +1,569 @@ +import { expect, it } from 'vitest'; +import { composeRoutePlan, type CaddyRoutePlan } from '../src/caddy/composition.ts'; +import type { CaddyConfig } from '../src/contracts/ports.ts'; +import { + activeDomains, + canonicalizeDomain, + checkDomainOwnership, + extractRegisteredDomains, + maxCompositionDiagnostics, + readPreparedRoutes, + type CompositionRegistration, + type JsonObject, +} from '../src/caddy/domains.ts'; +import { configHash, maxCaddyConfigurationBytes } from '../src/caddy/preparation.ts'; + +function candidate(value: unknown): CaddyConfig { + return bodyCandidate(JSON.stringify(value)); +} +function bodyCandidate(body: string): CaddyConfig { + return { adaptedConfig: { format: 'json', body }, effectiveConfigHash: configHash(body) }; +} +function configWithRoutes(routes: unknown[]): CaddyConfig { + return candidate({ apps: { http: { servers: { project: { routes } } } } }); +} +function registration( + id: string, + hosts: string[] = ['app.localhost'], + state: CompositionRegistration['activationState'] = 'active', +): CompositionRegistration { + return { + registrationId: id, + activationState: state, + sourceConfigPath: { raw: `${id}/Caddyfile`, canonical: null }, + registeredDomains: hosts.map((host) => ({ + name: { raw: host, canonical: host }, + activationState: 'active', + logStream: { streamId: id, domainKey: host, channel: 'domain' }, + })), + }; +} +const states = ['unknown', 'registered', 'activating', 'active', 'inactive', 'faulted'] as const; + +it.each([ + [' APP.Localhost... ', 'app.localhost'], + ['BÜCHER.localhost', 'xn--bcher-kva.localhost'], + ['XN--BCHER-KVA.localhost.', 'xn--bcher-kva.localhost'], + ['faß.localhost', 'xn--fa-hia.localhost'], + ['APP。LOCALHOST', 'app.localhost'], + ['APP.LOCALHOST', 'app.localhost'], + ['*.LOCALHOST', '*.localhost'], + ['_SERVICE.LOCALHOST', '_service.localhost'], + ['foo/bar', 'foo/bar'], + ['FOO%2eBAR', 'foo%2ebar'], + ['0x7f.1', '0x7f.1'], + ['127.1', '127.1'], + ['0177.0.0.1', '0177.0.0.1'], + ['127.0.0.1', '127.0.0.1'], + ['FOO:80', 'foo:80'], + ['FOO\\BAR', 'foo\\bar'], + ['FOO@BAR', 'foo@bar'], + ['FOO#BAR', 'foo#bar'], + ['FOO?BAR', 'foo?bar'], + ['[ABCD::1]', '[abcd::1]'], + ['Ä.BAD HOST', 'Ä.bad host'], + ['Ä.\u0000BAD', 'Ä.\u0000bad'], + ['Ä.\u0080BAD', 'Ä.\u0080bad'], + ['Ä.xn--', 'Ä.xn--'], + ['', ''], +])('canonicalizes literal domains without URL host reinterpretation: %s', (raw, expected) => { + expect(canonicalizeDomain(raw)).toBe(expected); +}); + +it('respects all registration/domain states and deduplicates canonical spellings', () => { + for (const projectState of states) { + for (const domainState of states) { + const r = registration('a', ['APP.localhost.', 'app.localhost'], projectState); + r.registeredDomains.forEach((domain) => { + domain.activationState = domainState; + }); + const enabled = ['registered', 'activating', 'active']; + expect(activeDomains(r)).toEqual( + enabled.includes(projectState) && enabled.includes(domainState) ? ['app.localhost'] : [], + ); + } + } +}); + +it('rejects conflicting enabled owners deterministically, not duplicates within one owner', () => { + const a = registration('a', ['BÜCHER.localhost.', 'xn--bcher-kva.localhost']); + const b = registration('b', ['XN--BCHER-KVA.localhost']); + const expected = { + ok: false, + diagnostics: [ + { + code: 'domain-conflict', + message: 'Domain is registered by multiple enabled entrypoints.', + domainKey: 'xn--bcher-kva.localhost', + sourceConfigPaths: ['a/Caddyfile', 'b/Caddyfile'], + }, + ], + }; + expect(checkDomainOwnership([a, b])).toEqual(expected); + expect(checkDomainOwnership([b, a])).toEqual(expected); + expect(checkDomainOwnership([a])).toMatchObject({ ok: true }); + b.activationState = 'inactive'; + expect(checkDomainOwnership([a, b])).toMatchObject({ ok: true }); + b.activationState = 'active'; + b.registeredDomains[0]!.activationState = 'faulted'; + expect(checkDomainOwnership([a, b])).toMatchObject({ ok: true }); +}); + +it('rejects duplicate registration input even when inactive and bounds diagnostics', () => { + const a = registration('a', [], 'inactive'); + expect(checkDomainOwnership([a, a])).toMatchObject({ + ok: false, + diagnostics: [{ code: 'duplicate-registration', sourceConfigPaths: ['a/Caddyfile'] }], + }); + const rows = Array.from({ length: 100 }, (_, i) => registration(String(i), ['x'.repeat(5000)])); + rows.forEach((r) => { + r.sourceConfigPath.raw = r.registrationId + 'x'.repeat(5000); + }); + const result = checkDomainOwnership([...rows, ...rows]); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('Expected diagnostics.'); + expect(result.diagnostics).toHaveLength(maxCompositionDiagnostics); + expect(result.diagnostics.every((d) => d.sourceConfigPaths.every((p) => p.length <= 4096))).toBe( + true, + ); + const conflicts = checkDomainOwnership(rows); + if (conflicts.ok) throw new Error('Expected conflict.'); + expect(conflicts.diagnostics[0]!.domainKey).toHaveLength(4096); + expect(conflicts.diagnostics[0]!.sourceConfigPaths).toHaveLength(maxCompositionDiagnostics); +}); + +it('does not extract proxy metadata from opaque non-route handler payloads', () => { + const routes = [ + { + match: [{ host: ['app.localhost'] }], + handle: [ + { + handler: 'plugin', + payload: { + handler: 'reverse_proxy', + upstreams: [{ dial: 'opaque:8080' }], + host: ['opaque.localhost'], + }, + }, + ], + }, + ]; + const config = configWithRoutes(routes); + expect(extractRegisteredDomains(config)).toEqual({ + ok: true, + value: [{ canonicalDomain: 'app.localhost', upstream: null }], + }); + expect(projectRoutes(plan([{ registration: registration('a'), config }]))).toEqual(routes); +}); + +it('extracts sorted matcher domains/first upstream only from HTTP route positions', () => { + const input = candidate({ + admin: { host: ['admin.localhost'] }, + apps: { + tls: { host: ['tls.localhost'] }, + http: { + servers: { + z: { + routes: [ + { + match: [{ host: ['APP.localhost.'] }], + handle: [{ handler: 'reverse_proxy', upstreams: [{ dial: 'later:80' }] }], + }, + ], + }, + a: { + routes: [ + { + handle: [ + { + handler: 'subroute', + routes: [ + { + match: [{ host: ['BÜCHER.localhost', 'App.localhost'] }], + handle: [ + { + handler: 'reverse_proxy', + host: ['opaque.localhost'], + routes: [{ match: [{ host: ['opaque-route.localhost'] }] }], + upstreams: [ + null, + { dial: ' ' }, + { dial: 1 }, + { dial: '127.0.0.1:8080' }, + ], + }, + ], + }, + ], + }, + ], + }, + { match: [{ host: ['z.localhost'] }], handle: [] }, + ], + }, + }, + }, + }, + }); + expect(extractRegisteredDomains(input)).toEqual({ + ok: true, + value: [ + { canonicalDomain: 'app.localhost', upstream: '127.0.0.1:8080' }, + { canonicalDomain: 'xn--bcher-kva.localhost', upstream: '127.0.0.1:8080' }, + { canonicalDomain: 'z.localhost', upstream: null }, + ], + }); + expect(extractRegisteredDomains(candidate({}))).toEqual({ ok: true, value: [] }); + expect(extractRegisteredDomains(bodyCandidate('{'))).toMatchObject({ ok: false }); +}); + +it.each([ + undefined, + { adaptedConfig: { format: 'json', body: '{}' } }, + { adaptedConfig: { format: 'caddyfile', body: '{}' }, effectiveConfigHash: configHash('{}') }, + { adaptedConfig: { format: 'json', body: 1 }, effectiveConfigHash: 'x' }, + { ...candidate({}), effectiveConfigHash: 'wrong' }, + bodyCandidate('{"apps":'), + bodyCandidate('"\ud800"'), + bodyCandidate(' '.repeat(maxCaddyConfigurationBytes + 1)), + candidate(null), + candidate([]), + candidate({ apps: [] }), + candidate({ apps: { http: [] } }), + candidate({ apps: { http: { servers: [] } } }), + candidate({ apps: { http: { servers: { a: null } } } }), + candidate({ apps: { http: { servers: { a: { routes: {} } } } } }), + configWithRoutes([null]), + configWithRoutes([{ match: {} }]), + configWithRoutes([{ match: [null] }]), + configWithRoutes([{ match: [{ host: 'a' }] }]), + configWithRoutes([{ match: [{ host: [1] }] }]), + configWithRoutes([{ handle: {} }]), + configWithRoutes([{ handle: [null] }]), + configWithRoutes([{ handle: [{ handler: 'subroute', routes: {} }] }]), +])('rejects missing/malformed/hash-mismatched/overflow prepared carriers %#', (input) => { + expect(readPreparedRoutes(input as CaddyConfig | undefined, [registration('a')])).toEqual({ + ok: false, + diagnostics: [ + { + code: 'prepared-input-invalid', + domainKey: null, + message: 'Expected a complete, bounded, integrity-checked adapted HTTP route tree.', + sourceConfigPaths: ['a/Caddyfile'], + }, + ], + }); +}); + +function plan(inputs: Parameters[0]): CaddyRoutePlan { + const result = composeRoutePlan(inputs); + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + return result.value; +} +function projectRoutes(value: CaddyRoutePlan, projectIndex = 0): JsonObject[] { + const guard = value.servers.cadder_https.routes[projectIndex]!; + return (guard.handle as JsonObject[])[0]!.routes as JsonObject[]; +} +function response(body: string, hosts?: string[]): JsonObject { + return { + ...(hosts === undefined ? {} : { match: [{ host: hosts }] }), + handle: [{ handler: 'static_response', body }], + }; +} + +it('composes multiple projects deterministically behind active guards with exact owned listeners', () => { + const a = { + registration: registration('a', ['BÜCHER.localhost.', 'app.localhost']), + config: configWithRoutes([response('a1', ['APP.LOCALHOST.']), response('a2')]), + }; + const z = { + registration: registration('z', ['z.localhost']), + config: configWithRoutes([response('z', ['z.localhost'])]), + }; + const value = plan([z, a]); + expect(plan([a, z])).toEqual(value); + expect(value.kind).toBe('caddy-route-plan'); + expect(value.servers.cadder_http.listen).toEqual(['127.0.0.1:80', '[::1]:80']); + expect(value.servers.cadder_https.listen).toEqual(['127.0.0.1:443', '[::1]:443']); + expect(value.tlsSubjects).toEqual(['app.localhost', 'xn--bcher-kva.localhost', 'z.localhost']); + expect(value.servers.cadder_https.routes.map((r) => r.match)).toEqual([ + [{ host: ['app.localhost', 'xn--bcher-kva.localhost'] }], + [{ host: ['z.localhost'] }], + ]); + expect(projectRoutes(value)).toEqual([response('a1', ['app.localhost']), response('a2')]); + expect(projectRoutes(value, 1)).toEqual([response('z', ['z.localhost'])]); + expect(value.servers.cadder_https.routes.every((route) => route.terminal === true)).toBe(true); +}); + +it('composition honors every activation combination and ignores unselected missing carriers', () => { + for (const projectState of states) { + for (const domainState of states) { + const r = registration('a', ['app.localhost'], projectState); + r.registeredDomains[0]!.activationState = domainState; + const enabled = + ['registered', 'activating', 'active'].includes(projectState) && + ['registered', 'activating', 'active'].includes(domainState); + const value = plan([ + { + registration: r, + ...(enabled ? { config: configWithRoutes([response('active', ['app.localhost'])]) } : {}), + }, + ]); + expect(value.servers.cadder_https.routes).toHaveLength(enabled ? 1 : 0); + expect(value.tlsSubjects).toEqual(enabled ? ['app.localhost'] : []); + } + } + expect(plan([{ registration: registration('empty', []) }]).servers.cadder_https.routes).toEqual( + [], + ); + expect(plan([]).tlsSubjects).toEqual([]); +}); + +it('prunes disabled-only nested branches/OR alternatives, preserves nested and separate hostless siblings', () => { + const r = registration('a', ['app.localhost', 'disabled.localhost']); + r.registeredDomains[1]!.activationState = 'inactive'; + const opaque = { + handler: 'plugin', + host: ['disabled.localhost', 42], + routes: [{ match: [{ host: ['disabled.localhost'] }] }], + nested: { host: { arbitrary: true } }, + dial: 'opaque', + }; + const nested = { + handle: [ + { + handler: 'subroute', + routes: [ + response('mixed', ['APP.localhost.', 'disabled.localhost']), + response('disabled', ['disabled.localhost']), + { + handle: [ + { handler: 'subroute', routes: [response('deep disabled', ['disabled.localhost'])] }, + ], + }, + response('nested hostless'), + { + match: [ + { host: ['disabled.localhost'], path: ['/disabled/*'] }, + { host: ['app.localhost'], path: ['/active/*'] }, + { path: ['/shared/*'] }, + ], + handle: [opaque], + }, + ], + }, + ], + }; + const value = plan([ + { + registration: r, + config: configWithRoutes([ + nested, + response('top disabled', ['disabled.localhost']), + response('separate hostless'), + { match: [{ host: [] }], handle: [opaque] }, + ]), + }, + ]); + expect(projectRoutes(value)).toEqual([ + { + handle: [ + { + handler: 'subroute', + routes: [ + response('mixed', ['app.localhost']), + response('nested hostless'), + { + match: [{ host: ['app.localhost'], path: ['/active/*'] }, { path: ['/shared/*'] }], + handle: [opaque], + }, + ], + }, + ], + }, + response('separate hostless'), + ]); + expect(value.servers.cadder_https.routes[0]!.match).toEqual([{ host: ['app.localhost'] }]); + expect(value.tlsSubjects).toEqual(['app.localhost']); +}); + +it('retains terminal chain position after disabled subroutes are pruned', () => { + for (const match of [undefined, [{ host: ['app.localhost'] }]]) { + const terminalRoute = { + ...(match === undefined ? {} : { match }), + handle: [{ handler: 'subroute', routes: [response('disabled', ['disabled.localhost'])] }], + terminal: true, + }; + const later = response('later', ['app.localhost']); + const config = configWithRoutes([terminalRoute, later]); + const before = config.adaptedConfig.body; + const value = plan([{ registration: registration('a'), config }]); + expect(projectRoutes(value)).toEqual([{ ...terminalRoute, handle: [] }, later]); + expect(value.servers.cadder_https.routes[0]!.match).toEqual([{ host: ['app.localhost'] }]); + expect(config.adaptedConfig.body).toBe(before); + } +}); + +it('retains complete hostless handler payloads, multiple handlers and empty/optional subroutes', () => { + const routes = [ + { match: [], handle: [{ handler: 'subroute', routes: [] }, { handler: 'subroute' }] }, + { + handle: [ + { handler: 'subroute', routes: [response('disabled', ['disabled.localhost'])] }, + { handler: 'headers', response: { set: { Host: ['unchanged'] } } }, + ], + }, + { arbitrary: null, host: 'opaque top-level', payload: [true, 123, 'keep'], handle: [] }, + ]; + const value = plan([{ registration: registration('a'), config: configWithRoutes(routes) }]); + expect(projectRoutes(value)).toEqual([ + routes[0], + { handle: [{ handler: 'headers', response: { set: { Host: ['unchanged'] } } }] }, + routes[2], + ]); +}); + +it('ignores root admin/TLS/server settings, preserves JSON and namespaces only HTTP ID copies', () => { + const routes = [ + { + '@id': 'route', + match: [{ host: ['app.localhost'] }], + handle: [ + { + '@id': 'proxy', + handler: 'reverse_proxy', + upstreams: [{ dial: '127.0.0.1:8080' }], + host: ['opaque.localhost'], + tls: { arbitrary: true }, + opaque: [ + { '@id': 'payload', host: ['not-a-domain'], number: 42, flag: false, null: null }, + ], + '@other': 'unchanged', + }, + ], + terminal: false, + }, + ]; + const config = candidate({ + admin: { listen: '0.0.0.0:2019' }, + apps: { + tls: { + automation: { policies: [{ subjects: ['evil.example'], issuers: [{ module: 'acme' }] }] }, + }, + http: { + servers: { + project: { listen: [':8080'], tls_connection_policies: [{ injected: true }], routes }, + }, + }, + }, + }); + const input = { registration: registration('a'), config }; + const before = structuredClone(input); + const value = plan([input]); + const snapshot = structuredClone(value); + expect(input).toEqual(before); + expect(Object.keys(value).sort()).toEqual(['kind', 'servers', 'tlsSubjects']); + expect(Object.keys(value.servers.cadder_https).sort()).toEqual(['listen', 'routes']); + expect(projectRoutes(value)).toEqual(routes); + const httpGuard = value.servers.cadder_http.routes[0]!; + const httpRoute = ((httpGuard.handle as JsonObject[])[0]!.routes as JsonObject[])[0]!; + expect(httpRoute['@id']).toBe('http_route'); + const proxy = (httpRoute.handle as JsonObject[])[0]!; + expect(proxy['@id']).toBe('http_proxy'); + expect((proxy.opaque as JsonObject[])[0]!['@id']).toBe('http_payload'); + httpRoute['@id'] = 'changed copy'; + expect(projectRoutes(value)[0]!['@id']).toBe('route'); + expect(plan([input])).toEqual(snapshot); + expect(input).toEqual(before); + expect(composeRoutePlan([input, { registration: registration('b'), config }]).ok).toBe(false); + expect(snapshot).toEqual(plan([input])); +}); + +it('preserves route order across deterministically sorted source servers', () => { + const config = candidate({ + apps: { + http: { + servers: { + z: { routes: [response('z1'), response('z2')] }, + a: { routes: [response('a1'), response('a2')] }, + missing: {}, + }, + }, + }, + }); + expect(projectRoutes(plan([{ registration: registration('a'), config }]))).toEqual([ + response('a1'), + response('a2'), + response('z1'), + response('z2'), + ]); +}); + +it('returns no plan for conflict/duplicate input and never fabricates routes', () => { + const a = { registration: registration('a'), config: configWithRoutes([response('a')]) }; + const b = { registration: registration('b'), config: configWithRoutes([response('b')]) }; + const conflict = composeRoutePlan([a, b]); + expect(conflict).toMatchObject({ ok: false, diagnostics: [{ code: 'domain-conflict' }] }); + expect(conflict).not.toHaveProperty('value'); + expect(composeRoutePlan([a, a])).toMatchObject({ + ok: false, + diagnostics: [{ code: 'duplicate-registration' }], + }); + expect( + plan([{ registration: registration('a'), config: candidate({}) }]).servers.cadder_https.routes, + ).toEqual([]); +}); + +it('fails the whole plan for each invalid selected carrier and sorts/bounds failures', () => { + const invalid = [ + undefined, + bodyCandidate('{'), + candidate([]), + { ...candidate({}), effectiveConfigHash: 'incorrect' }, + bodyCandidate(' '.repeat(maxCaddyConfigurationBytes + 1)), + configWithRoutes([{ handle: [{ handler: 'subroute', routes: [null] }] }]), + ]; + for (const config of invalid) { + const bad = { registration: registration('a'), ...(config === undefined ? {} : { config }) }; + const good = { + registration: registration('z', ['z.localhost']), + config: configWithRoutes([response('good')]), + }; + const before = structuredClone([good, bad]); + const result = composeRoutePlan([good, bad]); + expect(result).toMatchObject({ ok: false, diagnostics: [{ code: 'prepared-input-invalid' }] }); + expect(result).not.toHaveProperty('value'); + expect([good, bad]).toEqual(before); + } + const inputs = Array.from({ length: 100 }, (_, i) => ({ + registration: registration(String(i), [`${i}.localhost`]), + })); + const result = composeRoutePlan(inputs); + expect(result).toEqual(composeRoutePlan([...inputs].reverse())); + if (result.ok) throw new Error('Expected failure.'); + expect(result.diagnostics).toHaveLength(maxCompositionDiagnostics); +}); + +it('bounds clone/traversal capacity failures without returning a partial plan or mutating inputs', () => { + const deep = '{"next":'.repeat(50_000) + 'null' + '}'.repeat(50_000); + const config = bodyCandidate( + configWithRoutes([ + { handle: [{ handler: 'opaque', payload: 'DEEP' }] }, + ]).adaptedConfig.body.replace('"DEEP"', deep), + ); + const input = { registration: registration('a'), config }; + const before = { ...config, adaptedConfig: { ...config.adaptedConfig } }; + expect(readPreparedRoutes(config).ok).toBe(true); + expect(composeRoutePlan([input])).toEqual({ + ok: false, + diagnostics: [ + { + code: 'prepared-input-invalid', + domainKey: null, + sourceConfigPaths: ['a/Caddyfile'], + message: 'Cannot safely clone and filter the complete adapted route tree.', + }, + ], + }); + expect(config).toEqual(before); +}); diff --git a/test/caddy-configuration.test.ts b/test/caddy-configuration.test.ts new file mode 100644 index 0000000..442225a --- /dev/null +++ b/test/caddy-configuration.test.ts @@ -0,0 +1,157 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + configurationLocations, + defaultConfigurationPaths, + parseConfiguration, + readConfiguration, + selectConfiguration, + type ConfigurationPaths, +} from '../src/caddy/configuration.ts'; +import { configurationCases, caddyResolutionPrecedence } from './compatibility/catalog.ts'; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); +async function paths(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'cadder-caddy-config-')); + directories.push(directory); + return Object.fromEntries( + ['portable', 'user', 'system'].map((source) => [source, join(directory, `${source}.toml`)]), + ) as unknown as ConfigurationPaths; +} + +describe('released configuration contract', () => { + it.each(configurationCases)('$result', ({ toml, result }) => { + if (result.startsWith('reject:')) expect(() => parseConfiguration(toml)).toThrow(); + else { + const selected = parseConfiguration(toml)!; + expect(`${selected.kind}:${selected.value}`).toBe(result); + } + }); + it.each(['', '[caddy]', '# empty'])('defaults/empty selection: %j', (toml) => { + expect(parseConfiguration(toml)).toBeUndefined(); + }); + it.each(['caddy = 1', '[caddy]\nreal_path = 42', 'unknown = true', '[caddy]\nreal_command = []'])( + 'strict types and unknown top-level fields: %j', + (toml) => expect(() => parseConfiguration(toml)).toThrow(), + ); + it('does not reinterpret an empty configured string as an absent selector', () => { + expect(parseConfiguration('[caddy]\nreal_command = ""')).toEqual({ + kind: 'command', + value: '', + }); + }); + it('preserves all five source priorities', async () => { + expect(caddyResolutionPrecedence).toHaveLength(5); + const input = await paths(); + for (const source of ['portable', 'user', 'system'] as const) + await writeFile(input[source], `[caddy]\nreal_command = "${source}"`); + expect((await selectConfiguration(input, process.execPath)).source).toBe('override'); + for (const source of ['portable', 'user', 'system'] as const) { + expect(await selectConfiguration(input)).toEqual({ + selection: { kind: 'command', value: source }, + source, + }); + await writeFile(input[source], '[caddy]'); + } + expect((await selectConfiguration(input)).source).toBe('path'); + }); + it.each(['[caddy', '[caddy]\nunknown = true', '[caddy]\nreal_path = "x"\nreal_command = "x"'])( + 'invalid higher priority never falls back: %j', + async (toml) => { + const input = await paths(); + await writeFile(input.user, toml); + await writeFile(input.system, '[caddy]\nreal_command = "caddy"'); + await expect(selectConfiguration(input)).rejects.toThrow(); + }, + ); + it('rejects relative override without inspecting lower configuration', async () => { + await expect(selectConfiguration(await paths(), 'caddy')).rejects.toThrow('absolute'); + }); + it('invalid UTF-8 configuration fails rather than replacing bytes', async () => { + const input = await paths(); + await writeFile(input.portable, Buffer.from([0xff])); + await expect(selectConfiguration(input)).rejects.toThrow(); + }); + it('missing files fall through but directories and relative sources fail', async () => { + const input = await paths(); + expect((await selectConfiguration(input)).source).toBe('path'); + await mkdir(input.portable); + await expect(selectConfiguration(input)).rejects.toThrow('regular file'); + await expect(readConfiguration('cadder.toml')).rejects.toThrow('absolute'); + }); +}); + +describe('OS-modeled locations (not native filesystem proof)', () => { + it('Linux preserves absolute XDG and default locations, never relative XDG', () => { + const input = { + platform: 'linux' as const, + installationRoot: '/opt/cadder', + home: '/home/user', + }; + expect(configurationLocations(input)).toEqual({ + portable: '/opt/cadder/cadder.toml', + user: '/home/user/.config/cadder/cadder.toml', + system: '/etc/cadder/cadder.toml', + }); + expect(configurationLocations({ ...input, xdgConfigHome: '/config' }).user).toBe( + '/config/cadder/cadder.toml', + ); + expect(configurationLocations({ ...input, xdgConfigHome: 'project' }).user).toBe( + '/home/user/.config/cadder/cadder.toml', + ); + }); + it('macOS preserves the released ProjectDirs spelling', () => { + expect( + configurationLocations({ + platform: 'darwin', + installationRoot: '/opt/cadder', + home: '/Users/test', + }).user, + ).toBe('/Users/test/Library/Application Support/dev.Cadder.Cadder/cadder.toml'); + }); + it('Windows requires known folders rather than ambient APPDATA/PROGRAMDATA', () => { + const input = { + platform: 'win32' as const, + installationRoot: 'C:\\apps\\cadder', + home: 'C:\\Users\\test', + }; + expect(() => configurationLocations(input)).toThrow('known folders'); + expect( + configurationLocations({ + ...input, + knownFolders: { roamingAppData: 'C:\\roaming', programData: 'C:\\policy' }, + }), + ).toEqual({ + portable: 'C:\\apps\\cadder\\cadder.toml', + user: 'C:\\roaming\\Cadder\\Cadder\\config\\cadder.toml', + system: 'C:\\policy\\Cadder\\cadder.toml', + }); + }); + it('relative installation/home anchors fail', () => { + expect(() => + configurationLocations({ platform: 'linux', installationRoot: '.', home: '/home/test' }), + ).toThrow('absolute'); + }); +}); + +it.skipIf(process.platform !== 'win32')( + 'native Windows defaults ignore spoofed PROGRAMDATA', + async () => { + const before = await defaultConfigurationPaths(); + const previous = process.env.PROGRAMDATA; + try { + process.env.PROGRAMDATA = 'C:\\untrusted-policy'; + expect((await defaultConfigurationPaths()).system).toBe(before.system); + } finally { + if (previous === undefined) delete process.env.PROGRAMDATA; + else process.env.PROGRAMDATA = previous; + } + }, +); diff --git a/test/caddy-owned-process.test.ts b/test/caddy-owned-process.test.ts new file mode 100644 index 0000000..0450f75 --- /dev/null +++ b/test/caddy-owned-process.test.ts @@ -0,0 +1,485 @@ +import { execFile, spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, normalize } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { promisify } from 'node:util'; +import { build } from 'esbuild'; +import { expect, it, vi } from 'vitest'; +import { runOwnedCommand } from '../src/platform/owned-command.ts'; + +vi.mock('node:child_process', async (original) => { + const actual = await original(); + return { ...actual, spawn: vi.fn(actual.spawn) }; +}); +const execute = promisify(execFile); + +it('owned command preserves native argv and binary streams', async () => { + const args = ['space value', 'quote"value', 'trailing\\', '', 'line\nvalue']; + const output = await runOwnedCommand(process.execPath, [ + '-e', + 'process.stdout.write(JSON.stringify(process.argv.slice(1))); process.stderr.write(Buffer.from([0,255,128]));', + '--', + ...args, + ]); + expect(output.exitCode).toBe(0); + expect(JSON.parse(output.stdout.toString())).toEqual(args); + expect(output.stderr).toEqual(Buffer.from([0, 255, 128])); +}); + +it('verified startup notification precedes immediate binary output without leaking handshake bytes', async () => { + const events: string[] = []; + const output = await runOwnedCommand( + process.execPath, + [ + '-e', + 'process.stdout.write(Buffer.from([1,2,3,0,255]));process.stderr.write(Buffer.from([3,2,1,128]));setTimeout(()=>{},100);', + ], + { + beforeSpawn: async () => { + await delay(20); + events.push('before'); + }, + afterSpawn: async () => { + await delay(20); + events.push('after'); + }, + onStarted: () => { + events.push('started'); + }, + }, + ); + expect(events).toEqual(['before', 'after', 'started']); + expect(output.stdout).toEqual(Buffer.from([1, 2, 3, 0, 255])); + expect(output.stderr).toEqual(Buffer.from([3, 2, 1, 128])); +}); + +it('startup notification failure or cancellation settles the same owned boundary', async () => { + await expect( + runOwnedCommand(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + onStarted: () => { + throw new Error('fixture startup callback failed'); + }, + }), + ).rejects.toThrow('fixture startup callback failed'); + const abort = new AbortController(); + await expect( + runOwnedCommand(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + signal: abort.signal, + onStarted: () => abort.abort(), + }), + ).rejects.toMatchObject({ code: 'abort' }); +}); + +it.each([0, 1, 252, 253, 254])( + 'preserves native exit code %s without reserving application codes', + async (code) => { + const output = await runOwnedCommand(process.execPath, [ + '-e', + `process.stdout.write(Buffer.from([1,2,3,252,253,0]));process.exit(${code})`, + ]); + expect(output.exitCode).toBe(code); + expect(output.stdout).toEqual(Buffer.from([1, 2, 3, 252, 253, 0])); + }, +); + +it.skipIf(process.platform !== 'win32')( + 'preserves native unsigned Windows exit codes', + async () => { + const output = await runOwnedCommand(process.execPath, ['-e', 'process.exit(2147483648)']); + expect(output.exitCode).toBe(2147483648); + }, +); + +it.skipIf(process.platform !== 'win32')( + 'completion framing supports fragmented/coalesced binary payload at exact and zero limits', + async () => { + for (const payload of [Buffer.alloc(0), Buffer.from([1, 2, 3, 253, 252, 0, 1, 2])]) { + const trailer = Buffer.alloc(4); + trailer.writeUInt32LE(253); + const bytes = Buffer.concat([Buffer.from([3]), payload, trailer]); + // Separate handshake messages; split the completion code across events and coalesce payload/control bytes. + fakeWrapper( + [ + bytes.subarray(0, 2), + bytes.subarray(2, bytes.length - 2), + bytes.subarray(bytes.length - 2), + ], + 0, + ); + const output = await runOwnedCommand(process.execPath, [], { + maxStreamBytes: payload.length, + }); + expect(output.stdout).toEqual(payload); + expect(output.exitCode).toBe(253); + } + }, +); + +it.skipIf(process.platform !== 'win32').each(['missing', 'partial', 'fault'] as const)( + 'never accepts %s wrapper finalization', + async (kind) => { + let bytes = Buffer.from([3, 1, 2, 3]); + if (kind === 'missing') bytes = Buffer.from([3]); + else if (kind === 'fault') bytes = Buffer.from([3, 0, 0, 0, 0]); + fakeWrapper([bytes], kind === 'fault' ? 252 : 0); + await expect(runOwnedCommand(process.execPath, [])).rejects.toMatchObject({ code: 'spawn' }); + }, +); + +function fakeWrapper(chunks: readonly Buffer[], code: number): void { + vi.mocked(spawn).mockImplementationOnce(() => { + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null as number | null, + signalCode: null, + kill: () => true, + }); + child.stdin.on('data', (bytes: Buffer) => { + if (bytes[0] === 1) child.stdout.write(Buffer.from([2])); + if (bytes[0] === 2) { + for (const chunk of chunks) child.stdout.write(chunk); + child.stdout.end(); + child.stderr.end(); + queueMicrotask(() => { + child.exitCode = code; + child.emit('exit', code); + child.emit('close', code); + }); + } + }); + queueMicrotask(() => { + child.emit('spawn'); + child.stdout.write(Buffer.from([1])); + }); + return child as unknown as ReturnType; + }); +} + +it('owned command retains stream boundary, cwd, stdin EOF and nonzero native exit', async () => { + const output = await runOwnedCommand( + process.execPath, + [ + '-e', + 'process.stdin.on("end",()=>{process.stdout.write(Buffer.alloc(16,1));process.stderr.write(Buffer.alloc(16,2));process.exitCode=7});process.stdin.resume()', + ], + { maxStreamBytes: 16, cwd: tmpdir() }, + ); + expect(output.exitCode).toBe(7); + expect(output.stdout).toEqual(Buffer.alloc(16, 1)); + expect(output.stderr).toEqual(Buffer.alloc(16, 2)); + const cwd = await runOwnedCommand( + process.execPath, + ['-e', 'process.stdout.write(process.cwd())'], + { cwd: tmpdir() }, + ); + expect(normalize(cwd.stdout.toString()).toLowerCase()).toBe(normalize(tmpdir()).toLowerCase()); +}); + +it.each([0, 16])( + 'native stdout respects exact %s-byte payload bounds including its binary suffix', + async (limit) => { + const output = await runOwnedCommand( + process.execPath, + [ + '-e', + `process.stdout.write(Buffer.alloc(${limit},253));process.stderr.write(Buffer.alloc(${limit},254));`, + ], + { maxStreamBytes: limit }, + ); + expect(output.stdout).toEqual(Buffer.alloc(limit, 253)); + expect(output.stderr).toEqual(Buffer.alloc(limit, 254)); + expect(output.exitCode).toBe(0); + }, +); + +it.each([0, 16])( + 'hanging stdout max+1 rejects at %s-byte bounds without waiting for command deadline', + async (limit) => { + await expect( + runOwnedCommand( + process.execPath, + ['-e', `process.stdout.write(Buffer.alloc(${limit}+1));setInterval(()=>{},1000)`], + { maxStreamBytes: limit, timeoutMs: 10_000 }, + ), + ).rejects.toMatchObject({ code: 'overflow' }); + }, +); + +it.skipIf(process.platform !== 'win32').each([254, 253])( + 'wrapper status %s distinguishes proved overflow from cleanup failure', + async (status) => { + fakeWrapper([Buffer.from([3, 0, 0, 0, 0])], status); + await expect(runOwnedCommand(process.execPath, [])).rejects.toMatchObject({ + code: status === 254 ? 'overflow' : 'cleanup', + }); + }, +); + +it('default metadata output bound rejects more than 1 MiB', async () => { + await expect( + runOwnedCommand(process.execPath, [ + '-e', + 'process.stdout.write(Buffer.alloc(1024*1024+1));setInterval(()=>{},1000)', + ]), + ).rejects.toMatchObject({ code: 'overflow' }); +}); + +it('long-lived execution drains beyond command limits until owned cancellation settles', async () => { + const abort = new AbortController(); + const command = runOwnedCommand( + process.execPath, + [ + '-e', + 'setInterval(()=>{process.stdout.write(Buffer.alloc(65536));process.stderr.write(Buffer.alloc(65536))},10)', + ], + { longLived: true, signal: abort.signal, timeoutMs: 1, maxStreamBytes: 1 }, + ); + setTimeout(() => abort.abort(), 2500); + await expect(command).rejects.toMatchObject({ code: 'abort' }); + await expect(runOwnedCommand(process.execPath, [], { longLived: true })).rejects.toThrow( + RangeError, + ); +}); + +it('pre-aborted and invalid command options create no child', async () => { + await expect(runOwnedCommand('relative-caddy', [])).rejects.toMatchObject({ code: 'spawn' }); + await expect(runOwnedCommand(process.execPath, ['bad\0argument'])).rejects.toMatchObject({ + code: 'spawn', + }); + await expect( + runOwnedCommand(process.execPath, [], { signal: AbortSignal.abort() }), + ).rejects.toMatchObject({ code: 'abort' }); + await expect(runOwnedCommand(process.execPath, [], { timeoutMs: 0 })).rejects.toThrow(RangeError); + await expect(runOwnedCommand(process.execPath, [], { maxStreamBytes: -1 })).rejects.toThrow( + RangeError, + ); +}); + +it('post-creation verification cannot be lost when a fast child exits', async () => { + let started = false; + await expect( + runOwnedCommand(process.execPath, ['-e', 'process.exit(0)'], { + afterSpawn: async () => { + await delay(200); + throw new Error('delayed pin mismatch'); + }, + onStarted: () => { + started = true; + }, + }), + ).rejects.toThrow('delayed pin mismatch'); + expect(started).toBe(false); +}); + +it('owned command fails native creation without usable partial output', async () => { + await expect( + runOwnedCommand(join(tmpdir(), 'missing-caddy-native-image'), []), + ).rejects.toMatchObject({ code: 'spawn' }); +}); + +it.each(['stdout', 'stderr'])('owned command rejects %s overflow immediately', async (stream) => { + await expect( + runOwnedCommand( + process.execPath, + ['-e', `process.${stream}.write(Buffer.alloc(2048)); setInterval(()=>{},1000)`], + { maxStreamBytes: 1024 }, + ), + ).rejects.toMatchObject({ code: 'overflow' }); +}); + +it.each(['timeout', 'abort'] as const)( + 'owned %s settles descendants, not an unrelated process', + async (mode) => { + const directory = await mkdtemp(join(tmpdir(), 'cadder-owned-command-')); + const marker = join(directory, 'descendant.pid'); + const unrelated = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + stdio: 'ignore', + }); + let descendant: number | undefined; + const abort = new AbortController(); + const timer = mode === 'abort' ? setTimeout(() => abort.abort(), 4000) : undefined; + try { + const source = `const {spawn}=require('node:child_process'); const {writeFileSync}=require('node:fs'); const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:process.platform==='win32',stdio:['ignore','inherit','inherit']}); writeFileSync(process.argv[1],String(child.pid)); setInterval(()=>{},1000);`; + await expect( + runOwnedCommand(process.execPath, ['-e', source, marker], { + timeoutMs: 4000, + ...(mode === 'abort' ? { longLived: true, signal: abort.signal } : {}), + }), + ).rejects.toMatchObject({ code: mode }); + descendant = Number(await readFile(marker, 'utf8')); + expect(alive(descendant)).toBe(false); + expect(alive(unrelated.pid!)).toBe(true); + } finally { + clearTimeout(timer); + if (descendant && alive(descendant)) process.kill(descendant, 'SIGKILL'); + unrelated.kill('SIGKILL'); + await new Promise((resolve) => { + if (unrelated.exitCode !== null) resolve(); + else unrelated.once('exit', () => resolve()); + }); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it('owned command cancellation and failed post-creation pin check settle resources', async () => { + const abort = new AbortController(); + const command = runOwnedCommand(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + signal: abort.signal, + }); + setTimeout(() => abort.abort(), 2000); + await expect(command).rejects.toMatchObject({ code: 'abort' }); + await expect( + runOwnedCommand(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + afterSpawn: async () => { + throw new Error('post-spawn pin mismatch'); + }, + }), + ).rejects.toThrow('pin mismatch'); +}); + +it('owned Job/group closes inherited pipes after the leader exits and cleans the orphan', async () => { + const directory = await mkdtemp(join(tmpdir(), 'cadder-owned-orphan-')); + const fixture = join(directory, 'orphan.cjs'); + const marker = join(directory, 'grandchild.pid'); + let grandchild: number | undefined; + try { + await build({ + entryPoints: ['test/fixtures/caddy-orphan.ts'], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: fixture, + }); + const output = await runOwnedCommand(process.execPath, [fixture, 'parent', marker], { + timeoutMs: 5000, + }); + grandchild = Number(await readFile(marker, 'utf8')); + expect(output.exitCode).toBe(0); + expect(output.stdout.toString()).toBe('inherited stdout\n'); + expect(output.stderr.toString()).toBe('inherited stderr\n'); + expect(alive(grandchild)).toBe(false); + } finally { + if (grandchild && alive(grandchild)) process.kill(grandchild, 'SIGKILL'); + await rm(directory, { recursive: true, force: true }); + } +}); +it.skipIf(process.platform === 'win32')( + 'escaped Unix pipe holders produce a bounded stream-settle error, not an operation timeout', + async () => { + const directory = await mkdtemp(join(tmpdir(), 'cadder-owned-escape-')); + const marker = join(directory, 'escaped.pid'); + let escaped: number | undefined; + try { + const source = `const {spawn}=require('node:child_process'); const {writeFileSync}=require('node:fs'); const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:['ignore','inherit','inherit']});writeFileSync(process.argv[1],String(child.pid));child.unref();process.exit(0);`; + await expect( + runOwnedCommand(process.execPath, ['-e', source, marker], { timeoutMs: 10000 }), + ).rejects.toMatchObject({ code: 'stream' }); + escaped = Number(await readFile(marker, 'utf8')); + expect(alive(escaped)).toBe(true); + } finally { + // A fixture-known escaped PID is not a production process-enumeration permission. + if (escaped === undefined) { + try { + escaped = Number(await readFile(marker, 'utf8')); + } catch { + /* No native child was created. */ + } + } + if (escaped && alive(escaped)) process.kill(escaped, 'SIGKILL'); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** A capability reproducer, not acceptance of taskkill as an owned-tree implementation. */ +it.skipIf(process.platform !== 'win32')( + 'native Windows taskkill cannot clean an exited parent with inherited pipes', + async () => { + const directory = await mkdtemp(join(tmpdir(), 'cadder-caddy-orphan-')); + const fixture = join(directory, 'orphan.cjs'); + const marker = join(directory, 'grandchild.pid'); + let grandchild: number | undefined; + let child: ReturnType | undefined; + try { + await build({ + entryPoints: ['test/fixtures/caddy-orphan.ts'], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: fixture, + }); + child = spawn(process.execPath, [fixture, 'parent', marker], { + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdoutClosed = false; + let stderrClosed = false; + child.stdout!.on('close', () => { + stdoutClosed = true; + }); + child.stderr!.on('close', () => { + stderrClosed = true; + }); + child.stdout!.resume(); + let diagnostic = ''; + child.stderr!.on('data', (bytes: Buffer) => { + diagnostic += bytes.toString(); + }); + const leader = child.pid!; + await new Promise((resolve, reject) => { + child!.once('error', reject); + child!.once('exit', (code) => + code === 0 ? resolve() : reject(new Error(`Leader exited ${code}`)), + ); + }); + for (let attempt = 0; attempt < 100; attempt++) { + try { + grandchild = Number(await readFile(marker, 'utf8')); + break; + } catch { + await delay(20); + } + } + expect(grandchild, diagnostic).toBeDefined(); + expect(alive(grandchild!)).toBe(true); + const outcome = await execute( + join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), + ['/PID', String(leader), '/T', '/F'], + { timeout: 5000, maxBuffer: 1024 * 1024 }, + ).then( + () => 'unexpected success', + (error: unknown) => String(error), + ); + await delay(100); + expect(outcome).not.toBe('unexpected success'); + expect(alive(grandchild!)).toBe(true); + expect(stdoutClosed).toBe(false); + expect(stderrClosed).toBe(false); + console.info( + `Owned-tree blocker: leader=${leader} exited; taskkill /T /F failed; orphan=${grandchild} alive and both inherited pipes open. ${outcome}`, + ); + } finally { + // Test owns this exact fixture PID; product code may not substitute this for tree tracking. + if (grandchild !== undefined && alive(grandchild)) process.kill(grandchild, 'SIGKILL'); + child?.stdout?.destroy(); + child?.stderr?.destroy(); + if (child?.exitCode === null) child.kill('SIGKILL'); + await rm(directory, { recursive: true, force: true }); + } + }, +); diff --git a/test/caddy-preparation.test.ts b/test/caddy-preparation.test.ts new file mode 100644 index 0000000..a1ce76f --- /dev/null +++ b/test/caddy-preparation.test.ts @@ -0,0 +1,527 @@ +import { + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; +import { dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { join, normalize } from 'node:path'; +import { build } from 'esbuild'; +import { afterEach, expect, it, vi } from 'vitest'; +import { CaddyConfigAdapter, type CaddyAdaptInput } from '../src/caddy/adapter.ts'; +import { CaddyConfigValidator } from '../src/caddy/validation.ts'; +import { + configHash, + maxCaddyConfigurationBytes, + caddyConfigurationTimeoutMs, + configurationCommandOptions, + preparationFailure, +} from '../src/caddy/preparation.ts'; +import { RealCaddyResolver } from '../src/caddy/resolver.ts'; +import { requiredCaddyModules } from '../src/caddy/image.ts'; +import type { CaddyConfig, CaddyPort } from '../src/contracts/ports.ts'; +import { caddyfileExamples } from './compatibility/catalog.ts'; + +const cleanups: (() => Promise)[] = []; +afterEach(async () => { + for (const close of cleanups.splice(0).reverse()) await close(); +}); +const candidate = (body = '{}'): CaddyConfig => ({ + effectiveConfigHash: configHash(body), + adaptedConfig: { format: 'json', body }, +}); +async function setup() { + const directory = await mkdtemp(join(tmpdir(), 'cadder-preparation-test-')); + cleanups.push(() => rm(directory, { recursive: true, force: true })); + const root = join(directory, 'node_modules', 'cadder'); + await mkdir(root, { recursive: true }); + await writeFile( + join(root, 'package.json'), + JSON.stringify({ name: 'cadder', bin: { caddy: 'dist/caddy.js' } }), + ); + const imagePath = join(directory, process.platform === 'win32' ? 'real-caddy.exe' : 'real-caddy'); + await copyFile(process.execPath, imagePath); + const preload = join(directory, 'preload.cjs'); + await build({ + entryPoints: ['test/fixtures/caddy-preparation-preload.ts'], + bundle: true, + format: 'cjs', + platform: 'node', + outfile: preload, + }); + const env = { ...process.env, NODE_OPTIONS: `--require=${JSON.stringify(preload)}` }; + await writeFile(join(directory, 'Caddyfile'), caddyfileExamples[0]!.source); + const log = join(directory, 'commands.jsonl'); + const settings = { modules: requiredCaddyModules, log }; + async function behavior(value: object) { + await writeFile(`${imagePath}.json`, JSON.stringify({ ...settings, ...value })); + } + await behavior({}); + const resolver = new RealCaddyResolver({ + explicitOverride: imagePath, + environment: env, + installation: { root, distribution: 'npm' }, + configurationPaths: { + portable: join(root, 'cadder.toml'), + user: join(directory, 'user.toml'), + system: join(directory, 'system.toml'), + }, + }); + cleanups.push(() => resolver.close()); + const input: CaddyAdaptInput = { + sourceWorkingDirectory: { raw: directory, canonical: null }, + sourceConfigPath: { raw: 'Caddyfile', canonical: null }, + shimRun: null, + }; + async function records() { + return (await readFile(log, 'utf8')) + .trim() + .split('\n') + .map( + (line) => + JSON.parse(line) as { + command: string; + args: string[]; + cwd: string; + image: string; + path: string; + body?: string; + sourceBody?: string; + pid: number; + }, + ); + } + return { + directory, + imagePath, + env, + input, + resolver, + behavior, + records, + adapter: new CaddyConfigAdapter(resolver, { env }), + validator: new CaddyConfigValidator(resolver, { env }), + }; +} + +it('adapts with raw relative path/source cwd and retained caddyfile default into complete JSON/hash', async () => { + const s = await setup(); + const result = await s.adapter.adapt(s.input); + expect(result).toEqual({ ok: true, value: candidate('{"apps":{"http":{"servers":{}}}}\n') }); + const [record] = await s.records(); + expect(record!.args).toEqual(['adapt', '--config', 'Caddyfile', '--adapter', 'caddyfile']); + expect(normalize(record!.cwd)).toBe(normalize(s.directory)); + expect(normalize(record!.image)).toBe(normalize(s.imagePath)); + expect(record!.sourceBody).toBe(caddyfileExamples[0]!.source); +}); + +it('prefers canonical config/cwd and preserves adapter metadata as direct argv', async () => { + const s = await setup(); + const cwd = join(s.directory, 'source project'); + await mkdir(cwd); + const path = join(cwd, 'config space;literal.json'); + await writeFile(path, '{}'); + const input = { + ...s.input, + sourceWorkingDirectory: { raw: 'unusable-relative', canonical: cwd }, + sourceConfigPath: { raw: 'wrong', canonical: path }, + shimRun: { + adapter: 'custom adapter', + rawArguments: ['--real-caddy', 'wrong'], + commandLine: 'ignored', + }, + }; + expect((await s.adapter.adapt(input)).ok).toBe(true); + const [record] = await s.records(); + expect(record!.args).toEqual(['adapt', '--config', path, '--adapter', 'custom adapter']); + expect(normalize(record!.cwd)).toBe(normalize(cwd)); +}); + +it.each(['malformed', 'invalid-utf8', 'invalid-stderr', 'exit'])( + 'adapt refuses %s output without a partial candidate', + async (mode) => { + const s = await setup(); + await s.behavior({ adapt: { mode, body: 'x'.repeat(20000) } }); + const result = await s.adapter.adapt(s.input); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.message.length).toBeLessThanOrEqual(4096); + expect(result).not.toHaveProperty('value'); + }, +); + +it('rejects unsafe source metadata before spawning', async () => { + const s = await setup(); + for (const input of [ + { ...s.input, sourceWorkingDirectory: { raw: '.', canonical: null } }, + { ...s.input, sourceConfigPath: { raw: '', canonical: null } }, + { ...s.input, sourceConfigPath: { raw: 'good', canonical: 'relative' } }, + { ...s.input, sourceConfigPath: { raw: 'bad\0path', canonical: null } }, + ]) + expect((await s.adapter.adapt(input)).ok).toBe(false); + await expect(readFile(join(s.directory, 'commands.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT', + }); +}); + +it('validates the complete JSON carrier through the existing validate-only port and removes staging', async () => { + const s = await setup(); + const validator: Pick = s.validator; + const config = candidate('{"apps":{}}\n'); + expect(await validator.validate(config)).toEqual({ + ok: true, + value: { effectiveConfigHash: config.effectiveConfigHash, diagnostics: [] }, + }); + expect(validator).not.toHaveProperty('apply'); + const [record] = await s.records(); + expect(record!.args).toEqual(['validate', '--config', record!.path]); + expect(record!.body).toBe(config.adaptedConfig.body); + expect(record!.path).not.toContain(process.cwd()); + await expect(readFile(record!.path)).rejects.toMatchObject({ code: 'ENOENT' }); +}); + +it('validation snapshots the original complete body/hash across asynchronous pinning', async () => { + const s = await setup(); + const config = { + effectiveConfigHash: configHash('{}'), + adaptedConfig: { format: 'json' as const, body: '{}' }, + }; + const command = s.validator.validate(config); + config.adaptedConfig.body = '{"changed":true}'; + config.effectiveConfigHash = configHash(config.adaptedConfig.body); + expect(await command).toEqual({ + ok: true, + value: { effectiveConfigHash: configHash('{}'), diagnostics: [] }, + }); + const [record] = await s.records(); + expect(record!.body).toBe('{}'); +}); + +it.each(['invalid-utf8', 'invalid-stderr', 'exit'])( + 'validation refuses %s and removes owned staging', + async (mode) => { + const s = await setup(); + await s.behavior({ validate: { mode, body: 'rejected '.repeat(5000) } }); + const result = await s.validator.validate(candidate()); + expect(result.ok).toBe(false); + expect(result).not.toHaveProperty('value'); + if (!result.ok) expect(result.error.message.length).toBeLessThanOrEqual(4096); + const [record] = await s.records(); + await expect(readFile(record!.path)).rejects.toMatchObject({ code: 'ENOENT' }); + }, +); + +it.each([ + ['adapt', 'stdout', false], + ['adapt', 'stderr', false], + ['validate', 'stdout', false], + ['validate', 'stderr', false], + ['adapt', 'stdout', true], + ['adapt', 'stderr', true], + ['validate', 'stdout', true], + ['validate', 'stderr', true], +] as const)( + '%s %s enforces exact 32-MiB stream bound (overflow=%s)', + async (operation, stream, overflow) => { + const s = await setup(); + await s.behavior({ + [operation]: { mode: stream, bytes: maxCaddyConfigurationBytes + Number(overflow) }, + }); + const result = + operation === 'adapt' + ? await s.adapter.adapt(s.input) + : await s.validator.validate(candidate()); + expect(result.ok).toBe(!overflow); + if (overflow) + expect(result).toMatchObject({ ok: false, error: { code: 'caddy_command_overflow' } }); + if (operation === 'validate') { + const [record] = await s.records(); + await expect(readFile(record!.path)).rejects.toMatchObject({ code: 'ENOENT' }); + } + }, + 30000, +); + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} +async function until(action: () => Promise): Promise { + const deadline = Date.now() + 8000; + for (;;) { + try { + return await action(); + } catch (error) { + if (Date.now() >= deadline) throw error; + await delay(20); + } + } +} + +it.each(['adapt', 'validate'] as const)( + '%s timeout cleans inherited-pipe descendants and unrelated child survives', + async (operation) => { + const s = await setup(); + await s.resolver.pin(); + const marker = join(s.directory, 'descendant.pid'); + await s.behavior({ [operation]: { mode: 'tree', marker } }); + const unrelated = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + stdio: 'ignore', + }); + let descendant: number | undefined; + try { + const result = + operation === 'adapt' + ? await new CaddyConfigAdapter(s.resolver, { env: s.env, timeoutMs: 5000 }).adapt(s.input) + : await new CaddyConfigValidator(s.resolver, { env: s.env, timeoutMs: 5000 }).validate( + candidate(), + ); + descendant = Number(await readFile(marker, 'utf8')); + expect(result).toMatchObject({ ok: false, error: { code: 'caddy_command_timeout' } }); + expect(alive(descendant)).toBe(false); + expect(alive(unrelated.pid!)).toBe(true); + if (operation === 'validate') { + const [record] = await s.records(); + await expect(readFile(record!.path)).rejects.toMatchObject({ code: 'ENOENT' }); + } + } finally { + if (descendant && alive(descendant)) process.kill(descendant, 'SIGKILL'); + unrelated.kill('SIGKILL'); + await new Promise((resolve) => { + if (unrelated.exitCode !== null) resolve(); + else unrelated.once('exit', () => resolve()); + }); + } + }, + 30000, +); + +it.each(['adapt', 'validate'] as const)( + '%s abort kills ready descendants and closes inherited pipes', + async (operation) => { + const s = await setup(); + await s.resolver.pin(); + const marker = join(s.directory, 'descendant.pid'); + await s.behavior({ [operation]: { mode: 'tree', marker } }); + const controller = new AbortController(); + const command = + operation === 'adapt' + ? s.adapter.adapt(s.input, controller.signal) + : s.validator.validate(candidate(), controller.signal); + const descendant = await until(async () => Number(await readFile(marker, 'utf8'))); + expect(alive(descendant)).toBe(true); + controller.abort(); + expect(await command).toMatchObject({ ok: false, error: { code: 'caddy_command_abort' } }); + expect(alive(descendant)).toBe(false); + if (operation === 'validate') { + const [record] = await s.records(); + await expect(readFile(record!.path)).rejects.toMatchObject({ code: 'ENOENT' }); + } + }, + 30000, +); + +it('exited validation parent leaves no orphan, inherited pipes or staging', async () => { + const s = await setup(); + const marker = join(s.directory, 'orphan.pid'); + await s.behavior({ validate: { mode: 'orphan', marker } }); + expect((await s.validator.validate(candidate())).ok).toBe(true); + expect(alive(Number(await readFile(marker, 'utf8')))).toBe(false); + const [record] = await s.records(); + await expect(readFile(record!.path)).rejects.toMatchObject({ code: 'ENOENT' }); +}); + +it('cancellation before initial pin creates no native command or staging', async () => { + const s = await setup(); + const signal = AbortSignal.abort(); + expect(await s.adapter.adapt(s.input, signal)).toMatchObject({ + ok: false, + error: { code: 'caddy_command_abort' }, + }); + expect(await s.validator.validate(candidate(), signal)).toMatchObject({ + ok: false, + error: { code: 'caddy_command_abort' }, + }); + await expect(readFile(join(s.directory, 'commands.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT', + }); +}); + +it.each(['adapt', 'validate'] as const)( + '%s native spawn failure refuses partial results and removes staging', + async (operation) => { + const s = await setup(); + const image = await s.resolver.pin(); + const original = image.verify.bind(image); + let count = 0; + const before = (await readdir(tmpdir())).filter((name) => + name.startsWith('cadder-caddy-validation-'), + ); + const spy = vi.spyOn(image, 'verify').mockImplementation(async () => { + count++; + await original(); + if (count === 2) await rename(s.imagePath, join(s.directory, 'moved-before-create')); + }); + try { + const result = + operation === 'adapt' + ? await s.adapter.adapt(s.input) + : await s.validator.validate(candidate()); + expect(result).toMatchObject({ ok: false, error: { code: 'caddy_command_spawn' } }); + expect( + (await readdir(tmpdir())).filter((name) => name.startsWith('cadder-caddy-validation-')), + ).toEqual(before); + await expect(readFile(join(s.directory, 'commands.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + } finally { + spy.mockRestore(); + } + }, +); + +it('preparation limits cannot be raised above the retained command policy', () => { + expect( + configurationCommandOptions({ timeoutMs: 60000, maxStreamBytes: 64 * 1024 * 1024 }), + ).toMatchObject({ timeoutMs: 30000, maxStreamBytes: 32 * 1024 * 1024 }); + expect( + preparationFailure(new AggregateError([], 'unbounded private internals'), 'validate'), + ).toMatchObject({ + code: 'caddy_cleanup_failed', + message: 'Caddy command or staging cleanup failed.', + }); +}); + +it('pin mutation prevents adaptation/validation without reselection or leftover staging', async () => { + const s = await setup(); + await s.resolver.pin(); + await writeFile(s.imagePath, Buffer.from('mutated image')); + expect((await s.adapter.adapt(s.input)).ok).toBe(false); + const before = (await readdir(tmpdir())).filter((name) => + name.startsWith('cadder-caddy-validation-'), + ); + expect((await s.validator.validate(candidate())).ok).toBe(false); + expect( + (await readdir(tmpdir())).filter((name) => name.startsWith('cadder-caddy-validation-')), + ).toEqual(before); + await expect(readFile(join(s.directory, 'commands.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT', + }); +}); + +it.skipIf(process.platform !== 'win32')( + 'actual suspended native creation mismatch rejects validation and cleans staging before execution', + async () => { + const s = await setup(); + const image = await s.resolver.pin(); + const original = image.verify.bind(image); + let count = 0; + let replaced = false; + const replacement = join(s.directory, 'replacement.exe'); + await copyFile(process.execPath, replacement); + const before = (await readdir(tmpdir())).filter((name) => + name.startsWith('cadder-caddy-validation-'), + ); + const spy = vi.spyOn(image, 'verify').mockImplementation(async () => { + count++; + if (count === 3) { + await rename(s.imagePath, join(s.directory, 'old.exe')); + await rename(replacement, s.imagePath); + replaced = true; + } + await original(); + }); + try { + expect((await s.validator.validate(candidate())).ok).toBe(false); + expect(count).toBe(3); + expect(replaced).toBe(true); + await expect(readFile(join(s.directory, 'commands.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect( + (await readdir(tmpdir())).filter((name) => name.startsWith('cadder-caddy-validation-')), + ).toEqual(before); + } finally { + spy.mockRestore(); + } + }, +); + +it.skipIf(process.platform !== 'win32')( + 'native staging cleanup failure cannot return validated success', + async () => { + const s = await setup(); + await s.resolver.pin(); + const release = join(s.directory, 'release'); + await s.behavior({ validate: { mode: 'wait', release } }); + const command = s.validator.validate(candidate()); + const [record] = await until(() => s.records()); + cleanups.push(() => rm(dirname(record!.path), { recursive: true, force: true })); + const script = `$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue';$stream=[IO.File]::Open($env:CADDER_TEST_LOCK_FILE,'Open','Read','None');[Console]::Out.WriteLine('locked');[Console]::Out.Flush();[Console]::In.ReadLine() | Out-Null;$stream.Dispose()`; + const locker = spawn( + join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ), + [ + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + Buffer.from(script, 'utf16le').toString('base64'), + ], + { env: { ...process.env, CADDER_TEST_LOCK_FILE: record!.path }, stdio: 'pipe' }, + ); + try { + await new Promise((resolve, reject) => { + locker.once('error', reject); + locker.stdout.once('data', () => resolve()); + }); + await writeFile(release, 'go'); + expect(await command).toMatchObject({ ok: false, error: { code: 'caddy_cleanup_failed' } }); + } finally { + locker.stdin.end(); + await new Promise((resolve) => { + if (locker.exitCode !== null) resolve(); + else locker.once('exit', () => resolve()); + }); + } + expect(await readFile(record!.path, 'utf8')).toBe('{}'); + }, + 30000, +); + +it('invalid JSON/hash/encoding/oversize carriers never reach Caddy', async () => { + const s = await setup(); + for (const config of [ + candidate('{'), + { ...candidate(), effectiveConfigHash: 'wrong' }, + candidate('"\ud800"'), + candidate(' '.repeat(maxCaddyConfigurationBytes + 1)), + { + ...candidate(), + adaptedConfig: { format: 'caddyfile', body: '{}' }, + } as unknown as CaddyConfig, + ]) + expect(await s.validator.validate(config)).toMatchObject({ + ok: false, + error: { code: 'caddy_candidate_invalid' }, + }); + await expect(readFile(join(s.directory, 'commands.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect(caddyConfigurationTimeoutMs).toBe(30000); + expect(maxCaddyConfigurationBytes).toBe(32 * 1024 * 1024); +}); diff --git a/test/caddy-resolution.test.ts b/test/caddy-resolution.test.ts new file mode 100644 index 0000000..6ad520b --- /dev/null +++ b/test/caddy-resolution.test.ts @@ -0,0 +1,532 @@ +import { + copyFile, + link, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; +import { build } from 'esbuild'; +import { afterEach, expect, it } from 'vitest'; +import { RealCaddyResolver, installationEntries } from '../src/caddy/resolver.ts'; +import { + absoluteCaddyPath, + fileIdentity, + nativeExecutable, + sameFile, +} from '../src/caddy/executable.ts'; +import { + PinnedCaddyImage, + parseModules, + parseVersion, + requiredCaddyModules, + compatibilityProbeRevision, +} from '../src/caddy/image.ts'; +import { configurationLocations } from '../src/caddy/configuration.ts'; +import { runOwnedCommand } from '../src/platform/owned-command.ts'; +import { resolveInstallationRoot } from '../src/daemon/installation-root.ts'; + +const cleanup: (() => Promise)[] = []; +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close(); +}); +const nativeName = (name: string) => (process.platform === 'win32' ? `${name}.exe` : name); +async function setup() { + const directory = await mkdtemp(join(tmpdir(), 'cadder-caddy-resolution-')); + cleanup.push(() => rm(directory, { recursive: true, force: true })); + const root = join(directory, 'project', 'node_modules', 'cadder'); + await mkdir(join(root, 'dist'), { recursive: true }); + await writeFile( + join(root, 'package.json'), + JSON.stringify({ + name: 'cadder', + bin: { caddy: 'dist/caddy.js', cadder: 'dist/cadder.js', cadderd: 'dist/cadderd.js' }, + }), + ); + const preload = join(directory, 'fixture.cjs'); + await build({ + entryPoints: ['test/fixtures/caddy-preload.ts'], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: preload, + }); + const env = { + ...process.env, + NODE_OPTIONS: `--require=${JSON.stringify(preload)}`, + PATH: directory, + }; + const paths = configurationLocations({ + platform: process.platform, + installationRoot: root, + home: directory, + ...(process.platform === 'win32' + ? { knownFolders: { roamingAppData: directory, programData: directory } } + : {}), + }); + // Source files intentionally distinct even when OS modeled roots share this temp directory. + const configurationPaths = { + ...paths, + user: join(directory, 'user.toml'), + system: join(directory, 'system.toml'), + }; + async function fake(name = 'caddy', behavior: object = {}) { + const path = join(directory, nativeName(name)); + await copyFile(process.execPath, path); + await writeFile(`${path}.json`, JSON.stringify({ modules: requiredCaddyModules, ...behavior })); + return path; + } + function resolver(extra: ConstructorParameters[0] = {}) { + const value = new RealCaddyResolver({ + environment: env, + installation: { root, distribution: 'npm' }, + configurationPaths, + ...extra, + }); + cleanup.push(() => value.close()); + return value; + } + return { directory, root, env, configurationPaths, fake, resolver }; +} + +it.each([ + ['C:\\tools\\caddy.exe', true], + ['\\\\server\\share\\caddy.exe', true], + ['//server/share/caddy.exe', true], + ['\\caddy.exe', false], + ['/caddy.exe', false], + ['C:caddy.exe', false], + ['caddy.exe', false], +] as const)('modeled Windows qualification %j is %s', (path, expected) => { + expect(absoluteCaddyPath(path, 'win32')).toBe(expected); +}); + +it.skipIf(process.platform !== 'win32')( + 'native Windows refuses root-relative executable overrides', + async () => { + const s = await setup(); + await expect(s.resolver({ explicitOverride: '\\caddy.exe' }).pin()).rejects.toThrow('absolute'); + await expect(nativeExecutable('\\caddy.exe')).rejects.toThrow('absolute'); + }, +); + +it('native physical identity detects hardlinks, distinguishes copies and never conflates shared Node', async () => { + const s = await setup(); + const first = await s.fake(); + const alias = join(s.directory, nativeName('alias')); + await link(first, alias); + const second = await s.fake('second'); + expect(fileIdentity(await stat(first, { bigint: true }))).not.toMatch(/:0$/); + expect(await sameFile(first, alias)).toBe(true); + expect(await sameFile(first, second)).toBe(false); + expect(await sameFile(first, process.execPath)).toBe(false); +}); + +it('pins compatibility/image evidence and closes explicitly', async () => { + const s = await setup(); + const path = await s.fake(); + const resolver = s.resolver(); + const image = await resolver.pin(); + expect(image.path).toBe(await realpath(path)); + expect(image.source).toBe('path'); + expect(image.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(image.evidence).toEqual({ + version: '2.11.4', + modules: [...requiredCaddyModules].sort(), + probeRevision: compatibilityProbeRevision, + }); + expect(await resolver.pin()).toBe(image); + await resolver.close(); + await expect(image.verify()).rejects.toThrow('closed'); + await expect(resolver.pin()).rejects.toThrow('closed'); +}); + +it.each(['override', 'portable', 'user', 'system'] as const)( + 'resolves precedence source %s', + async (source) => { + const s = await setup(); + const selected = await s.fake('selected'); + await s.fake(); + const lower = '[caddy]\nreal_path = "relative-must-not-win"'; + for (const kind of ['portable', 'user', 'system'] as const) + await writeFile(s.configurationPaths[kind], lower); + if (source !== 'override') { + for (const higher of ['portable', 'user', 'system'] as const) { + if (higher === source) break; + await writeFile(s.configurationPaths[higher], '[caddy]'); + } + await writeFile( + s.configurationPaths[source], + `[caddy]\nreal_path = ${JSON.stringify(selected)}`, + ); + } + const image = await s + .resolver(source === 'override' ? { explicitOverride: selected } : {}) + .pin(); + expect(image.source).toBe(source); + expect(image.path).toBe(await realpath(selected)); + }, +); + +it('portable single command preserves PATH order and ignores relative entries/project/legacy selectors', async () => { + const s = await setup(); + const chosen = await s.fake('caddy-real'); + await s.fake(); + await writeFile(s.configurationPaths.portable, '[caddy]\nreal_command = "caddy-real"'); + await writeFile(join(s.directory, 'cadder.toml'), '[caddy]\nreal_path = "untrusted-project"'); + s.env.PATH = ['.', 'relative', s.directory, s.root].join(delimiter); + const resolver = s.resolver({ + environment: { + ...s.env, + CADDER_CADDY_REAL_COMMAND: 'wrong', + CADDER_CADDY__REAL_PATH: 'wrong', + CADDER_CADDY_SHIM_PATH: chosen, + }, + }); + expect((await resolver.pin()).path).toBe(await realpath(chosen)); +}); + +it.skipIf(process.platform !== 'win32')( + 'native Windows PATH preserves quoted absolute directories containing separators', + async () => { + const s = await setup(); + const original = await s.fake(); + const directory = join(s.directory, 'quoted; directory'); + await mkdir(directory); + const path = join(directory, nativeName('caddy')); + await rename(original, path); + await rename(`${original}.json`, `${path}.json`); + const image = await s + .resolver({ environment: { ...s.env, PATH: `.;"${directory}";relative` } }) + .pin(); + expect(image.path).toBe(await realpath(path)); + }, +); + +async function waitForProbe(marker: string): Promise { + for (let attempt = 0; attempt < 300; attempt++) { + try { + if ((await readFile(marker, 'utf8')).includes('"version"')) return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + await delay(20); + } + throw new Error('Owned version probe did not become ready.'); +} + +it('caller cancellation during the first probe cannot poison concurrent or later pin callers', async () => { + const s = await setup(); + const marker = join(s.directory, 'probe-commands'); + const releaseVersion = join(s.directory, 'release-version'); + await s.fake('caddy', { marker, releaseVersion }); + const resolver = s.resolver(); + const controller = new AbortController(); + const cancelled = resolver.pin(controller.signal).catch((error: unknown) => error); + const otherController = new AbortController(); + const concurrent = resolver.pin(otherController.signal).catch((error: unknown) => error); + await waitForProbe(marker); + controller.abort(); + expect(await cancelled).toMatchObject({ code: 'abort' }); + await writeFile(releaseVersion, 'go'); + const image = await resolver.pin(); + expect(await concurrent).toBe(image); + expect(image.evidence.version).toBe('2.11.4'); + expect((await readFile(marker, 'utf8')).trim().split('\n')).toHaveLength(2); +}); + +it('resolver close cancels its owned pending probe and rejects future pin callers', async () => { + const s = await setup(); + const marker = join(s.directory, 'probe-commands'); + await s.fake('caddy', { marker, releaseVersion: join(s.directory, 'never-released') }); + const resolver = s.resolver(); + const pending = resolver.pin().catch((error: unknown) => error); + await waitForProbe(marker); + await resolver.close(); + expect(await pending).toMatchObject({ code: 'abort' }); + await expect(resolver.pin()).rejects.toThrow('closed'); +}); + +it.each(['caddy --version', '../caddy', '', 'caddy; evil', 'C:caddy', 'caddy\\relative'])( + 'denies arbitrary configured commands %j', + async (command) => { + const s = await setup(); + await s.fake(); + await writeFile( + s.configurationPaths.user, + `[caddy]\nreal_command = ${JSON.stringify(command)}`, + ); + await expect(s.resolver().pin()).rejects.toThrow('single program'); + }, +); + +it('invalid selected native image fails instead of trying a lower source', async () => { + const s = await setup(); + await s.fake(); + await writeFile(s.configurationPaths.portable, '[caddy]\nreal_path = "relative"'); + await expect(s.resolver().pin()).rejects.toThrow('absolute'); +}); + +it.each(['.cmd', '.ps1', '.js', ''])( + 'rejects npm script wrapper %j before executing any help probe', + async (suffix) => { + const s = await setup(); + const path = join(s.directory, `wrapper${suffix}`); + await writeFile(path, '#!/usr/bin/env node\nthrow new Error("recursive shim")', { + mode: 0o755, + }); + await expect(s.resolver({ explicitOverride: path }).pin()).rejects.toThrow(/native|wrapper/); + }, +); + +it('rejects hardlinked npm bin identities and skips aliased entries on PATH', async () => { + const s = await setup(); + const real = await s.fake('upstream'); + const shim = join(s.root, 'dist', 'caddy.js'); + await copyFile(process.execPath, shim); + const candidate = join(s.directory, nativeName('caddy')); + await link(shim, candidate); + await expect(s.resolver({ explicitOverride: candidate }).pin()).rejects.toThrow( + 'Cadder distribution', + ); + const upstreamDir = join(s.directory, 'upstream'); + await mkdir(upstreamDir); + const upstream = join(upstreamDir, nativeName('caddy')); + await rename(real, upstream); + await rename(`${real}.json`, `${upstream}.json`); + expect( + ( + await s + .resolver({ environment: { ...s.env, PATH: [s.directory, upstreamDir].join(delimiter) } }) + .pin() + ).path, + ).toBe(await realpath(upstream)); +}); + +it('SEA root denies known executable and sibling hardlink aliases', async () => { + const s = await setup(); + const sea = join(s.root, nativeName('cadderd')); + await copyFile(process.execPath, sea); + const candidate = join(s.directory, nativeName('upstream')); + await link(sea, candidate); + await expect( + s + .resolver({ + explicitOverride: candidate, + installation: { root: s.root, distribution: 'sea', executable: sea }, + }) + .pin(), + ).rejects.toThrow('Cadder distribution'); + expect( + await installationEntries({ root: s.root, distribution: 'sea', executable: sea }), + ).toContain(join(s.root, nativeName('caddy'))); +}); + +it('realistic physical npm local/global and SEA anchors do not use cwd/shared Node', async () => { + const s = await setup(); + const entry = join(s.root, 'dist', 'cadderd.js'); + await writeFile(entry, '// entry'); + const moduleUrl = pathToFileURL(entry).href; + expect(resolveInstallationRoot({ moduleUrl })).toBe(await realpath(s.root)); + expect(resolveInstallationRoot({ sea: true, executable: entry })).toBe( + await realpath(join(s.root, 'dist')), + ); + const entries = await installationEntries({ root: s.root, distribution: 'npm' }); + expect(entries).toContain(join(s.root, 'dist', 'caddy.js')); + expect(entries).toContain(join(s.directory, 'project', 'node_modules', '.bin', 'caddy.cmd')); + expect(entries).not.toContain(process.execPath); +}); + +it.skipIf(process.platform !== 'win32')( + 'native Scoop descriptor selects only its checked native target and denies Cadder alias targets', + async () => { + const s = await setup(); + const wrapper = await s.fake(); + const target = await s.fake('scoop-target'); + await writeFile(wrapper.replace(/\.exe$/, '.shim'), `path = "${target}"\nargs = "--ignored"`); + expect((await s.resolver().pin()).path).toBe(await realpath(target)); + const sea = join(s.root, nativeName('cadderd')); + await copyFile(process.execPath, sea); + await writeFile(wrapper.replace(/\.exe$/, '.shim'), `path = "${sea}"`); + await expect( + s.resolver({ installation: { root: s.root, distribution: 'sea', executable: sea } }).pin(), + ).rejects.toThrow('safe absolute PATH'); + }, +); + +it.skipIf(process.platform === 'win32')( + 'native Unix symlinks follow identity but scripts/non-executable files fail', + async () => { + const s = await setup(); + const first = await s.fake(); + const alias = join(s.directory, 'alias'); + await symlink(first, alias); + expect(await sameFile(first, alias)).toBe(true); + expect(await nativeExecutable(alias)).toBe(await realpath(first)); + const noExec = join(s.directory, 'not-executable'); + await writeFile(noExec, Buffer.from('7f454c46', 'hex'), { mode: 0o600 }); + await expect(nativeExecutable(noExec)).rejects.toThrow('not executable'); + }, +); + +it.skipIf(process.platform !== 'win32')( + 'native Windows final symlink/reparse executable is denied', + async (context) => { + const s = await setup(); + const path = await s.fake(); + const alias = join(s.directory, nativeName('symlink-alias')); + try { + await symlink(path, alias, 'file'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EPERM') { + context.skip(); + return; + } + throw error; + } + expect(await sameFile(path, alias)).toBe(true); + await expect(nativeExecutable(alias)).rejects.toThrow('reparse'); + }, +); + +it('realistic global npm prefix inventories are physical, not shared Node', async () => { + const s = await setup(); + const globalRoot = join(s.directory, 'prefix', 'lib', 'node_modules', 'cadder'); + await mkdir(globalRoot, { recursive: true }); + await writeFile( + join(globalRoot, 'package.json'), + JSON.stringify({ name: 'cadder', bin: 'dist/caddy.js' }), + ); + const entries = await installationEntries({ root: globalRoot, distribution: 'npm' }); + expect(entries).toContain(join(s.directory, 'prefix', 'bin', 'caddy')); + expect(entries).toContain(join(globalRoot, 'dist', 'caddy.js')); +}); + +it('missing PATH and only relative PATH never select cwd', async () => { + const s = await setup(); + await s.fake(); + const environment = { ...s.env }; + for (const key of Object.keys(environment)) + if (key.toLowerCase() === 'path') delete (environment as NodeJS.ProcessEnv)[key]; + await expect(s.resolver({ environment }).pin()).rejects.toThrow('PATH is not set'); + await expect( + s.resolver({ environment: { ...environment, PATH: '.;relative' } }).pin(), + ).rejects.toThrow('safe absolute PATH'); +}); + +it('incompatible real native probes and missing modules fail without usable evidence', async () => { + const s = await setup(); + const path = await s.fake('caddy', { version: 'v3.0.0' }); + await expect(s.resolver().pin()).rejects.toThrow('Unsupported Caddy'); + await writeFile(`${path}.json`, JSON.stringify({ modules: [] })); + await expect(s.resolver().pin()).rejects.toThrow('missing required modules'); + expect(requiredCaddyModules).toHaveLength(19); +}); + +it.each(['version', 'list-modules'])( + 'probe %s failure is sticky and never tries another image', + async (fail) => { + const s = await setup(); + const marker = join(s.directory, 'commands'); + const path = await s.fake('caddy', { fail, marker }); + const resolver = s.resolver(); + await expect(resolver.pin()).rejects.toThrow('probe failed'); + await writeFile(`${path}.json`, JSON.stringify({ modules: requiredCaddyModules, marker })); + await expect(resolver.pin()).rejects.toThrow('probe failed'); + const commands = (await readFile(marker, 'utf8')).trim().split('\n'); + expect(commands).toHaveLength(fail === 'version' ? 1 : 2); + }, +); + +it.each(['v2.11.3', 'v3.0.0', 'v2.11.4-rc.1', 'garbage', ''])( + 'denies incompatible version %j', + (version) => { + expect(() => parseVersion(Buffer.from(version))).toThrow(); + }, +); +it.each(['v2.11.4 h1:sum', '2.12.0-rc.1', 'v2.11.4+custom'])( + 'accepts retained compatible version %j', + (version) => { + expect(parseVersion(Buffer.from(version))).toBe(version.split(' ')[0]!.replace(/^v/, '')); + }, +); +it.each(['[]', '{}', 'not JSON', '[{"module_name":7}]'])( + 'denies missing/malformed module inventory %j', + (body) => { + expect(() => parseModules(Buffer.from(body))).toThrow(); + }, +); + +it('pin replacement and hardlink content mutation are detected, with no reselection', async () => { + const s = await setup(); + const path = await s.fake(); + const resolver = s.resolver(); + const image = await resolver.pin(); + const alias = join(s.directory, nativeName('alias')); + await link(path, alias); + await writeFile(alias, Buffer.from('mutated')); + await expect(image.verify()).rejects.toThrow('digest changed'); + await expect(image.run(['version'], { env: s.env })).rejects.toThrow('digest changed'); + expect(await resolver.pin()).toBe(image); + const replacement = await s.fake('replacement'); + await rename(path, join(s.directory, nativeName('old'))); + await rename(replacement, path); + await expect(image.verify()).rejects.toThrow('identity changed'); +}); + +it.skipIf(process.platform !== 'win32')( + 'mutation during wrapper startup is rejected before native execution', + async () => { + const s = await setup(); + const path = await s.fake(); + const image = await PinnedCaddyImage.open(path, 'override'); + cleanup.push(() => image.close()); + const marker = join(s.directory, 'must-not-execute'); + const command = runOwnedCommand(path, ['marker', marker], { + env: s.env, + beforeSpawn: () => image.verify(), + afterSpawn: () => image.verify(), + }); + const mutation = new Promise((resolve, reject) => + setTimeout(() => { + void writeFile(path, Buffer.from('mutation during compilation')).then( + () => resolve(), + reject, + ); + }, 100), + ); + await mutation; + await expect(command).rejects.toThrow('digest changed'); + await expect(readFile(marker)).rejects.toMatchObject({ code: 'ENOENT' }); + }, +); + +it.skipIf(process.platform !== 'win32')( + 'post-native-create pin mismatch kills the suspended owned Job before any execution', + async () => { + const s = await setup(); + const path = await s.fake(); + const image = await PinnedCaddyImage.open(path, 'override'); + cleanup.push(() => image.close()); + const marker = join(s.directory, 'must-not-execute'); + await expect( + runOwnedCommand(path, ['marker', marker], { + env: s.env, + beforeSpawn: () => image.verify(), + afterSpawn: async () => { + throw new Error('post-native-create mismatch'); + }, + }), + ).rejects.toThrow('post-native-create mismatch'); + await expect(readFile(marker)).rejects.toMatchObject({ code: 'ENOENT' }); + }, +); diff --git a/test/caddy-runtime.test.ts b/test/caddy-runtime.test.ts new file mode 100644 index 0000000..2d5e981 --- /dev/null +++ b/test/caddy-runtime.test.ts @@ -0,0 +1,423 @@ +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { normalizeCaddyConfig, type SecureAdminPolicy } from '../src/caddy/admin-policy.ts'; +import { CaddyRuntime } from '../src/caddy/runtime.ts'; +import { configHash } from '../src/caddy/preparation.ts'; +import { + ConfigurationTransactions, + type ConfigurationSnapshot, +} from '../src/daemon/configuration-transactions.ts'; +import { assertProtected } from '../src/platform/runtime-security.ts'; +import { backend, fixtureResolver } from './fixtures/caddy-runtime-backend.ts'; + +// Only this test's policy/path and HTTPS seams are fake. The real validator and queue are exercised. +vi.mock('../src/platform/runtime-security.ts', () => ({ + assertProtected: vi.fn(async () => undefined), + assertRuntimeDescendant: vi.fn(async () => undefined), + createProtectedFile: async (path: string, _owner: unknown, body: string) => { + await writeFile(path, body, { flag: 'wx', mode: 0o600 }); + return true; + }, +})); +vi.mock('../src/caddy/admin-client.ts', async (original) => { + const actual = await original(); + return { + CaddyAdminClient: class extends actual.CaddyAdminClient { + override apply = backend.apply.bind(backend); + override readActive = backend.readActive.bind(backend); + override stop = backend.stop.bind(backend); + }, + }; +}); +let directory: string; +const runtimes: CaddyRuntime[] = []; +function candidate(tag = 'first') { + return normalizeCaddyConfig( + JSON.stringify({ + admin: { disabled: true, remote: { listen: 'fixture' } }, + apps: { + pki: { certificate_authorities: { local: { install_trust: false } } }, + http: { tag }, + }, + }), + ); +} +function policy(): SecureAdminPolicy { + return { + config: candidate(), + paths: { + base: directory, + data: join(directory, 'data'), + config: join(directory, 'config'), + home: join(directory, 'home'), + scratch: join(directory, 'scratch'), + defaultStorage: join(directory, 'data', 'caddy'), + autosaveDirectory: join(directory, 'config', 'caddy'), + }, + environment: { FIXTURE_STARTUP: 'immutable' }, + client: { + host: '127.0.0.1', + port: 12345, + servername: 'localhost', + rejectUnauthorized: true, + ca: 'fixture-ca', + cert: 'fixture-cert', + key: 'fixture-key', + }, + }; +} +function runtime(input = policy()): CaddyRuntime { + const value = new CaddyRuntime( + input, + { id: 'fixture-owner', elevated: false }, + fixtureResolver(), + { startupMs: 150, stopMs: 30, pollMs: 2 }, + ); + runtimes.push(value); + return value; +} +beforeEach(async () => { + backend.reset(); + vi.mocked(assertProtected).mockReset().mockResolvedValue(undefined); + directory = await mkdtemp(join(tmpdir(), 'cadder-runtime-test-')); + const { mkdir } = await import('node:fs/promises'); + await mkdir(join(directory, 'scratch')); +}); +afterEach(async () => { + for (const value of runtimes.splice(0)) await value.close().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); +}); + +it('idle -> secure initial run -> independently observed active -> reload -> actual idle', async () => { + const value = runtime(); + expect(await value.readActive()).toEqual({ ok: true, value: null }); + expect(await value.apply(candidate())).toEqual({ status: 'applied' }); + expect(backend.runCalls[0]!.args.slice(0, 2)).toEqual(['run', '--config']); + expect(backend.runCalls[0]!.args).not.toContain('--resume'); + expect(backend.runCalls[0]!.options.longLived).toBe(true); + expect(await value.readActive()).toEqual({ ok: true, value: candidate().effectiveConfigHash }); + expect(await value.apply(candidate('reload'))).toEqual({ status: 'applied' }); + expect(await value.readActive()).toEqual({ + ok: true, + value: candidate('reload').effectiveConfigHash, + }); + expect(backend.runCalls).toHaveLength(1); + expect(await value.apply(null)).toEqual({ status: 'applied' }); + expect(backend.stops).toBe(1); + expect(await value.readActive()).toEqual({ ok: true, value: null }); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); +}); + +it('performs only a conflict check before verified native startup', async () => { + let release!: () => void; + backend.launchGate = new Promise((resolve) => { + release = resolve; + }); + const value = runtime(); + const pending = value.apply(candidate()); + await vi.waitFor(() => expect(backend.runCalls).toHaveLength(1), { interval: 2 }); + expect(backend.reads).toBe(1); + expect(backend.stops).toBe(0); + release(); + expect(await pending).toEqual({ status: 'applied' }); + expect(backend.reads).toBeGreaterThan(0); +}); + +it.each(['ready', 'silent', 'exit', 'launch-failed'] as const)( + 'refuses an existing matching endpoint without launching or stopping it (%s)', + async (mode) => { + backend.active = candidate().effectiveConfigHash; + backend.startup = mode; + const value = runtime(); + expect(await value.apply(candidate())).toEqual({ status: 'definitely-rejected' }); + await value.close(); + expect(backend.runCalls).toHaveLength(0); + expect(backend.reads).toBe(1); + expect(backend.stops).toBe(0); + expect(backend.active).toBe(candidate().effectiveConfigHash); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); + }, +); + +it('close cancels delayed native startup without administering an unstarted process', async () => { + backend.launchGate = new Promise(() => undefined); + const value = runtime(); + const pending = value.apply(candidate()); + await vi.waitFor(() => expect(backend.runCalls).toHaveLength(1), { interval: 2 }); + await value.close(); + expect(await pending).toEqual({ status: 'ambiguous' }); + expect(backend.reads).toBe(1); + expect(backend.stops).toBe(0); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); +}); + +it('rejection and ambiguous reload never substitute receipts for independent state', async () => { + const value = runtime(); + await value.apply(candidate()); + backend.loadStatus = 'definitely-rejected'; + expect(await value.apply(candidate('rejected'))).toEqual({ status: 'definitely-rejected' }); + expect(await value.readActive()).toEqual({ ok: true, value: candidate().effectiveConfigHash }); + backend.loadStatus = 'ambiguous'; + backend.uncertainLoadChanges = true; + expect(await value.apply(candidate('uncertain'))).toEqual({ status: 'ambiguous' }); + expect(await value.readActive()).toEqual({ + ok: true, + value: candidate('uncertain').effectiveConfigHash, + }); + backend.active = null; + expect((await value.readActive()).ok).toBe(false); +}); + +it('snapshots candidates and startup environment before asynchronous effects', async () => { + const original = policy(); + const value = runtime(original); + Object.assign(original.environment, { FIXTURE_STARTUP: 'mutated' }); + const first = structuredClone(candidate()); + const hash = first.effectiveConfigHash; + const pending = value.apply(first); + Object.assign(first.adaptedConfig, { body: '{}' }); + Object.assign(first, { effectiveConfigHash: 'mutated' }); + expect(await pending).toEqual({ status: 'applied' }); + expect(backend.runCalls[0]!.options.env!.FIXTURE_STARTUP).toBe('immutable'); + expect(await value.readActive()).toEqual({ ok: true, value: hash }); +}); + +it('refuses malformed, noncanonical and security-changing candidates before any effects', async () => { + const value = runtime(); + expect(await value.apply({ ...candidate(), effectiveConfigHash: 'wrong' })).toEqual({ + status: 'definitely-rejected', + }); + const changed = normalizeCaddyConfig('{"admin":{"disabled":false},"apps":{"pki":{}}}'); + expect(await value.apply(changed)).toEqual({ status: 'definitely-rejected' }); + expect(await value.apply(normalizeCaddyConfig('{}'))).toEqual({ status: 'definitely-rejected' }); + const spaced = ` ${candidate().adaptedConfig.body}`; + expect( + await value.apply({ + adaptedConfig: { format: 'json', body: spaced }, + effectiveConfigHash: configHash(spaced), + }), + ).toEqual({ status: 'definitely-rejected' }); + expect(backend.runCalls).toEqual([]); + expect(backend.reads).toBe(0); + expect(vi.mocked(assertProtected)).not.toHaveBeenCalled(); +}); + +it.each(['silent', 'mismatch', 'exit'] as const)( + 'settles startup %s and supports explicit retry only', + async (mode) => { + backend.startup = mode; + const value = runtime(); + expect(await value.apply(candidate())).toEqual({ status: 'ambiguous' }); + expect(await value.readActive()).toEqual({ ok: true, value: null }); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); + expect(backend.runCalls).toHaveLength(1); + backend.startup = 'ready'; + expect(await value.apply(candidate('retry'))).toEqual({ status: 'applied' }); + expect(backend.runCalls).toHaveLength(2); + }, +); + +it('unexpected exit is unavailable, never cached active/idle, until explicit reconciliation', async () => { + const value = runtime(); + await value.apply(candidate()); + backend.settle?.(); + await Promise.resolve(); + await Promise.resolve(); + expect((await value.readActive()).ok).toBe(false); + expect(backend.runCalls).toHaveLength(1); + expect(await value.apply(candidate('explicit'))).toEqual({ status: 'applied' }); + expect(backend.runCalls).toHaveLength(2); +}); + +it.each(['ignored', 'hanging'] as const)( + 'forces only owned work after bounded %s stop', + async (mode) => { + const value = runtime(); + await value.apply(candidate()); + backend.stopMode = mode; + expect(await value.apply(null)).toEqual({ status: 'applied' }); + expect(await value.readActive()).toEqual({ ok: true, value: null }); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); + }, +); + +it('close cancels startup readback, refuses queued admissions and drains before return', async () => { + backend.startup = 'silent'; + backend.blockRead = true; + const value = runtime(); + const starting = value.apply(candidate()); + await vi.waitFor(() => expect(backend.runCalls).toHaveLength(1)); + const queued = value.apply(candidate('never')); + const closing = value.close(); + expect(await value.apply(candidate('closed'))).toEqual({ status: 'definitely-rejected' }); + expect(await queued).toEqual({ status: 'definitely-rejected' }); + expect(await starting).toEqual({ status: 'ambiguous' }); + await closing; + expect(value.close()).toBe(closing); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); + expect((await value.readActive()).ok).toBe(false); +}); + +it.each(['graceful', 'ignored', 'hanging'] as const)( + 'healthy close attempts bounded %s stop before owned force', + async (mode) => { + const value = runtime(); + await value.apply(candidate()); + backend.stopMode = mode; + await value.close(); + expect(backend.stops).toBe(1); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); + expect(await value.apply(candidate('closed'))).toEqual({ status: 'definitely-rejected' }); + }, +); + +it('close aborts a reload in flight without waiting for its normal admin deadline', async () => { + const value = runtime(); + await value.apply(candidate()); + backend.blockRead = true; + const loading = value.apply(candidate('reload')); + await vi.waitFor(() => expect(backend.loads).toBe(1)); + await value.close(); + expect(await loading).toEqual({ status: 'ambiguous' }); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); +}); + +it('cleanup faults stay ambiguous, retain staging and prevent false idle/restart', async () => { + const value = runtime(); + await value.apply(candidate()); + backend.stopMode = 'ignored'; + backend.cleanupFault = true; + expect(await value.apply(null)).toEqual({ status: 'ambiguous' }); + expect((await value.readActive()).ok).toBe(false); + expect(await value.apply(candidate('unsafe-restart'))).toEqual({ status: 'ambiguous' }); + expect(await readdir(join(directory, 'scratch'))).toHaveLength(1); + await expect(value.close()).rejects.toThrow('teardown could not be proved'); +}); + +it('refuses unsafe roots and validator rejection without a native start', async () => { + vi.mocked(assertProtected).mockRejectedValue(new Error('private ACL parser diagnostic')); + const value = runtime(); + expect(await value.apply(candidate())).toEqual({ status: 'definitely-rejected' }); + expect(backend.runCalls).toEqual([]); + vi.mocked(assertProtected).mockResolvedValue(undefined); + backend.validationFails = true; + expect(await value.apply(candidate())).toEqual({ status: 'definitely-rejected' }); + expect(backend.runCalls).toEqual([]); +}); + +it('close cancels between strict root checks without acquiring native or admin work', async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + vi.mocked(assertProtected).mockImplementationOnce(() => blocked); + const value = runtime(); + const pending = value.apply(candidate()); + await vi.waitFor(() => expect(assertProtected).toHaveBeenCalledTimes(1), { interval: 2 }); + const closing = value.close(); + release(); + expect(await pending).toEqual({ status: 'definitely-rejected' }); + await closing; + expect(assertProtected).toHaveBeenCalledTimes(1); + expect(backend.runCalls).toEqual([]); + expect(backend.reads).toBe(0); +}); + +it('staging deletion failure remains unavailable and close refuses false success', async () => { + const value = runtime(); + await value.apply(candidate()); + vi.mocked(assertProtected).mockRejectedValue(new Error('staging replaced')); + expect(await value.apply(null)).toEqual({ status: 'ambiguous' }); + expect((await value.readActive()).ok).toBe(false); + await expect(value.close()).rejects.toThrow('teardown could not be proved'); +}); + +it('real transaction queue rolls persistence-failed first start back to actual idle and reconciles', async () => { + const value = runtime(); + const idle: ConfigurationSnapshot = { + config: null, + desiredState: { projects: [] }, + registrations: [], + }; + const queue = new ConfigurationTransactions( + idle, + { + validate: async (config) => ({ + ok: true, + value: { effectiveConfigHash: config.effectiveConfigHash, diagnostics: [] }, + }), + }, + { + persistDesiredState: async () => ({ + ok: false, + error: { + kind: 'storage', + code: 'fixture_persist_failed', + message: 'Fixture persistence failure.', + guidance: null, + retryable: false, + requestId: null, + }, + }), + }, + value, + ); + expect( + (await queue.submit(() => ({ ok: true, value: { ...idle, config: candidate() } }))).ok, + ).toBe(false); + expect(queue.readCommitted()).toEqual(idle); + expect(await value.readActive()).toEqual({ ok: true, value: null }); + expect((await queue.reconcile()).ok).toBe(true); + await queue.close(); +}); + +it.each([false, true])( + 'real queue recovers an unexpected exit only after proved cleanup (cleanupFault=%s)', + async (cleanupFault) => { + const value = runtime(); + const idle: ConfigurationSnapshot = { + config: null, + desiredState: { projects: [] }, + registrations: [], + }; + const persist = vi.fn(async () => ({ ok: true as const, value: undefined })); + const queue = new ConfigurationTransactions( + idle, + { + validate: async (config) => ({ + ok: true, + value: { effectiveConfigHash: config.effectiveConfigHash, diagnostics: [] }, + }), + }, + { persistDesiredState: persist }, + value, + ); + const committed = { ...idle, config: candidate() }; + expect((await queue.submit(() => ({ ok: true, value: committed }))).ok).toBe(true); + if (cleanupFault) { + backend.stopMode = 'ignored'; + backend.cleanupFault = true; + expect(await value.apply(null)).toEqual({ status: 'ambiguous' }); + } else { + backend.settle?.(); + } + expect((await value.readActive()).ok).toBe(false); + expect((await queue.reconcile()).ok).toBe(!cleanupFault); + expect(queue.readCommitted()).toEqual(committed); + expect(persist).toHaveBeenCalledTimes(1); + expect(backend.runCalls).toHaveLength(cleanupFault ? 1 : 2); + if (cleanupFault) { + expect(queue.readFence()?.code).toBe('config_reconciliation_failed'); + expect((await value.readActive()).ok).toBe(false); + } else { + expect(queue.readFence()).toBeNull(); + expect(await value.readActive()).toEqual({ + ok: true, + value: committed.config.effectiveConfigHash, + }); + } + await queue.close(); + }, +); diff --git a/test/configuration-transactions.test.ts b/test/configuration-transactions.test.ts new file mode 100644 index 0000000..6c14477 --- /dev/null +++ b/test/configuration-transactions.test.ts @@ -0,0 +1,803 @@ +import { expect, it, vi } from 'vitest'; +import { composeRoutePlan } from '../src/caddy/composition.ts'; +import { configHash, maxCaddyConfigurationBytes } from '../src/caddy/preparation.ts'; +import type { CaddyConfig, DesiredState, PortResult } from '../src/contracts/ports.ts'; +import { + ConfigurationTransactions, + type ConfigurationApplyOutcome, + type ConfigurationSnapshot, + type PrepareConfiguration, +} from '../src/daemon/configuration-transactions.ts'; +import { protocolErrorSchema, type ProtocolError } from '../src/protocol/errors.ts'; +import { fakeRegistration } from './fixtures/rpc-data.ts'; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function ok(value: T): PortResult { + return { ok: true, value }; +} +const secret = 'private project exception text'; +const adapterError: ProtocolError = { + kind: 'invalidInput', + code: 'adapter_failed', + message: secret, + guidance: secret, + retryable: false, + requestId: null, +}; +function rejected(): PortResult { + return { ok: false, error: adapterError }; +} +function config(body: string): CaddyConfig { + return { adaptedConfig: { format: 'json', body }, effectiveConfigHash: configHash(body) }; +} +function snapshot(label = 'old', idle = false): ConfigurationSnapshot { + return { + config: idle ? null : config(JSON.stringify({ fixture: label })), + desiredState: { + projects: [ + { + projectKey: label, + sourceWorkingDirectory: '/fixture', + sourceConfigPath: '/fixture/Caddyfile', + enabled: true, + domains: [{ canonicalDomain: 'example.localhost', upstream: null, enabled: true }], + }, + ], + }, + registrations: [{ ...fakeRegistration(), registrationId: label }], + }; +} +function fixture(initial = snapshot()) { + const phases: string[] = []; + let active = initial.config?.effectiveConfigHash ?? null; + let durable = structuredClone(initial.desiredState); + const validate = vi.fn(async (target: CaddyConfig) => { + phases.push('validate'); + return ok({ effectiveConfigHash: target.effectiveConfigHash, diagnostics: [] }); + }); + const apply = vi.fn(async (target: CaddyConfig | null): Promise => { + phases.push(`apply:${target?.effectiveConfigHash ?? 'idle'}`); + active = target?.effectiveConfigHash ?? null; + return { status: 'applied' }; + }); + const readActive = vi.fn(async (): Promise> => { + phases.push('verify'); + return ok(active); + }); + const persistDesiredState = vi.fn(async (state: DesiredState): Promise> => { + phases.push('persist'); + durable = structuredClone(state); + return ok(undefined); + }); + const transactions = new ConfigurationTransactions( + initial, + { validate }, + { persistDesiredState }, + { apply, readActive }, + ); + const prepare = + (target = snapshot('new')): PrepareConfiguration => + () => { + phases.push('prepare'); + return ok(target); + }; + return { + transactions, + phases, + validate, + apply, + readActive, + persistDesiredState, + prepare, + durable: () => durable, + active: () => active, + }; +} +function errorCode(result: PortResult) { + expect(result.ok).toBe(false); + if (result.ok) throw new Error('Expected failure.'); + expect(protocolErrorSchema.safeParse(result.error).success).toBe(true); + expect(JSON.stringify(result.error)).not.toContain(secret); + expect(result.error.message.length).toBeLessThan(200); + return result.error.code; +} + +it('orders preparation, validation, apply, independent verification, persistence and publication', async () => { + const f = fixture(); + const target = snapshot('new'); + const result = await f.transactions.submit(f.prepare(target)); + expect(result).toEqual(ok(target)); + expect(f.phases).toEqual([ + 'prepare', + 'validate', + `apply:${target.config!.effectiveConfigHash}`, + 'verify', + 'persist', + ]); + expect(f.transactions.readCommitted()).toEqual(target); + expect(f.durable()).toEqual(target.desiredState); + expect(Object.keys(f.persistDesiredState.mock.calls[0]![0])).toEqual(['projects']); + expect(JSON.stringify(f.durable())).not.toMatch(/nonce|registrationId|processId|lastHeartbeat/); +}); + +it('serializes competing preparations, consumes route plans inside the queue and reads the newest commit', async () => { + const f = fixture(); + const entered = deferred(); + const release = deferred(); + f.persistDesiredState.mockImplementationOnce(async () => { + f.phases.push('persist-pending'); + entered.resolve(); + await release.promise; + return ok(undefined); + }); + const first = f.transactions.submit(f.prepare(snapshot('first'))); + const secondPrepare = vi.fn((committed: ConfigurationSnapshot) => { + f.phases.push('compose'); + expect(committed).toEqual(snapshot('first')); + const plan = composeRoutePlan([ + { + registration: committed.registrations[0]!, + config: config( + JSON.stringify({ + apps: { + http: { + servers: { + project: { + routes: [ + { + match: [{ host: ['example.localhost'] }], + handle: [{ handler: 'static_response', body: 'fixture' }], + }, + ], + }, + }, + }, + }, + }), + ), + }, + ]); + expect(plan.ok).toBe(true); + if (!plan.ok) throw new Error('Expected route plan.'); + expect(plan.value.kind).toBe('caddy-route-plan'); + expect(plan.value.tlsSubjects).toEqual(['example.localhost']); + // This test candidate is independent: a route plan is not a runnable configuration. + return ok(snapshot('second')); + }); + const second = f.transactions.submit(secondPrepare); + await entered.promise; + expect(secondPrepare).not.toHaveBeenCalled(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.validate).toHaveBeenCalledTimes(1); + release.resolve(); + expect((await first).ok).toBe(true); + expect((await second).ok).toBe(true); + expect(f.transactions.readCommitted()).toEqual(snapshot('second')); + expect(f.phases.indexOf('compose')).toBeGreaterThan(f.phases.indexOf('persist-pending')); +}); + +it.each(['prepare', 'validate', 'apply', 'verify', 'persist'] as const)( + 'keeps pending %s state invisible and the next preparation blocked', + async (phase) => { + const f = fixture(); + const entered = deferred(); + const release = deferred(); + const wait = async () => { + entered.resolve(); + await release.promise; + }; + let prepare = f.prepare(); + if (phase === 'prepare') + prepare = async () => { + await wait(); + return ok(snapshot('new')); + }; + if (phase === 'validate') + f.validate.mockImplementationOnce(async (target) => { + await wait(); + return ok({ effectiveConfigHash: target.effectiveConfigHash, diagnostics: [] }); + }); + if (phase === 'apply') { + const apply = f.apply.getMockImplementation()!; + f.apply.mockImplementationOnce(async (target) => { + await wait(); + return apply(target); + }); + } + if (phase === 'verify') + f.readActive.mockImplementationOnce(async () => { + await wait(); + return ok(snapshot('new').config!.effectiveConfigHash); + }); + if (phase === 'persist') + f.persistDesiredState.mockImplementationOnce(async () => { + await wait(); + return ok(undefined); + }); + const first = f.transactions.submit(prepare); + const next = vi.fn(f.prepare(snapshot('later'))); + const second = f.transactions.submit(next); + await entered.promise; + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(next).not.toHaveBeenCalled(); + release.resolve(); + expect((await first).ok).toBe(true); + expect((await second).ok).toBe(true); + }, +); + +it.each([ + { + ...snapshot('bad'), + config: { adaptedConfig: { format: 'json', body: '{' }, effectiveConfigHash: configHash('{') }, + }, + { ...snapshot('bad'), config: { ...config('{}'), effectiveConfigHash: 'wrong' } }, + { + ...snapshot('bad'), + config: { ...config('{}'), adaptedConfig: { format: 'yaml', body: '{}' } }, + }, + { ...snapshot('bad'), desiredState: { projects: [], nonce: 'forbidden' } }, + { ...snapshot('bad'), registrations: [{}] }, + { + ...snapshot('bad'), + config: { + effectiveConfigHash: 'wrong', + adaptedConfig: { format: 'json', body: ' '.repeat(maxCaddyConfigurationBytes + 1) }, + }, + }, +])('rejects malformed/unstable/hash-invalid candidates before adapters', async (invalid) => { + const f = fixture(); + expect(errorCode(await f.transactions.submit(() => ok(invalid as ConfigurationSnapshot)))).toBe( + 'config_candidate_invalid', + ); + expect(f.validate).not.toHaveBeenCalled(); + expect(f.apply).not.toHaveBeenCalled(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.transactions.readFence()).toBeNull(); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); +}); + +it('validates the supplied initial shape without inventing startup verification or storage work', () => { + expect(() => fixture({ ...snapshot(), config: config('{') })).toThrow( + 'Invalid committed configuration snapshot.', + ); + const f = fixture(snapshot('idle', true)); + expect(f.phases).toEqual([]); + expect(f.transactions.readFence()).toBeNull(); +}); + +it.each(['rejected', 'throw'] as const)( + 'retains old state after preparation %s and continues the tail', + async (failure) => { + const f = fixture(); + expect( + errorCode( + await f.transactions.submit(() => { + if (failure === 'throw') throw new Error(secret); + return rejected(); + }), + ), + ).toBe('config_prepare_failed'); + expect(f.apply).not.toHaveBeenCalled(); + expect(f.transactions.readFence()).toBeNull(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); + }, +); + +it.each(['rejected', 'throw', 'hash'] as const)( + 'retains old state after validator %s without fencing', + async (failure) => { + const f = fixture(); + f.validate.mockImplementationOnce(async () => { + if (failure === 'throw') throw new Error(secret); + if (failure === 'hash') return ok({ effectiveConfigHash: 'wrong', diagnostics: [] }); + return rejected(); + }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe( + failure === 'hash' ? 'config_validation_mismatch' : 'config_validation_failed', + ); + expect(f.apply).not.toHaveBeenCalled(); + expect(f.transactions.readFence()).toBeNull(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); + }, +); + +it('skips validation only for a real idle target, and still applies, observes and persists', async () => { + const f = fixture(); + const idle = snapshot('idle', true); + expect(await f.transactions.submit(f.prepare(idle))).toEqual(ok(idle)); + expect(f.validate).not.toHaveBeenCalled(); + expect(f.apply).toHaveBeenCalledWith(null); + expect(f.phases).toEqual(['prepare', 'apply:idle', 'verify', 'persist']); +}); + +it('isolates caller, adapter, initial, read and result data with private deeply frozen copies', async () => { + const initial = snapshot(); + const target = snapshot('new'); + const original = structuredClone(target); + const f = fixture(initial); + const entered = deferred(); + const release = deferred(); + initial.registrations[0]!.registrationId = 'caller changed initial'; + const attempt = (value: object, key: string) => { + expect(Object.isFrozen(value)).toBe(true); + expect(() => { + Reflect.set(value, key, 'tampered'); + }).not.toThrow(); + expect(Reflect.set(value, key, 'tampered')).toBe(false); + }; + f.validate.mockImplementationOnce(async (config) => { + attempt(config.adaptedConfig, 'body'); + entered.resolve(); + await release.promise; + return ok({ effectiveConfigHash: config.effectiveConfigHash, diagnostics: [] }); + }); + f.apply.mockImplementationOnce(async (config) => { + attempt(config!.adaptedConfig, 'body'); + return { status: 'applied' }; + }); + f.readActive.mockResolvedValueOnce(ok(target.config!.effectiveConfigHash)); + f.persistDesiredState.mockImplementationOnce(async (state) => { + attempt(state.projects[0]!.domains[0]!, 'canonicalDomain'); + return ok(undefined); + }); + const pending = f.transactions.submit((committed) => { + attempt(committed.registrations[0]!.entrypointInstance, 'shimSessionNonce'); + return ok(target); + }); + await entered.promise; + target.registrations[0]!.registrationId = 'caller changed pending'; + Reflect.set(target.config!.adaptedConfig, 'body', '{}'); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + release.resolve(); + const result = await pending; + expect(result).toEqual(ok(original)); + if (!result.ok) throw new Error('Expected commit.'); + attempt(result.value.registrations[0]!.ownerProcess, 'processId'); + attempt(f.transactions.readCommitted().registrations[0]!, 'registrationId'); + expect(f.transactions.readCommitted()).toEqual(original); +}); + +it('closes admissions and drains outstanding work without starting later side effects early', async () => { + const f = fixture(); + const entered = deferred(); + const release = deferred(); + const pending = f.transactions.submit(async () => { + entered.resolve(); + await release.promise; + return ok(snapshot('new')); + }); + const alreadyQueued = f.transactions.submit(f.prepare(snapshot('last'))); + await entered.promise; + let drained = false; + const drain = f.transactions.close().then(() => { + drained = true; + }); + const refused = vi.fn(f.prepare()); + expect(errorCode(await f.transactions.submit(refused))).toBe('config_transactions_closed'); + expect(drained).toBe(false); + expect(refused).not.toHaveBeenCalled(); + expect(f.apply).not.toHaveBeenCalled(); + release.resolve(); + await pending; + await alreadyQueued; + await drain; + expect(drained).toBe(true); + expect(f.transactions.readCommitted()).toEqual(snapshot('last')); + await f.transactions.close(); + expect(errorCode(await f.transactions.reconcile())).toBe('config_transactions_closed'); +}); + +it('continues the queue after explicit definite rejection without observation, persistence or fencing', async () => { + const f = fixture(); + f.apply.mockResolvedValueOnce({ status: 'definitely-rejected' }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_apply_rejected'); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.transactions.readFence()).toBeNull(); + expect(f.readActive).not.toHaveBeenCalled(); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + expect((await f.transactions.submit(f.prepare(snapshot('later')))).ok).toBe(true); +}); + +it.each(['ambiguous', 'throw', 'invalid', 'receipt', 'extra'] as const)( + 'fences %s apply results without inferring rejection from an error kind', + async (failure) => { + const f = fixture(); + f.apply.mockImplementationOnce(async () => { + if (failure === 'throw') throw { ...adapterError, kind: 'invalidInput' }; + if (failure === 'invalid') return { status: 'other' } as unknown as ConfigurationApplyOutcome; + if (failure === 'receipt') + return { + effectiveConfigHash: snapshot('new').config!.effectiveConfigHash, + } as unknown as ConfigurationApplyOutcome; + if (failure === 'extra') + return { status: 'applied', error: adapterError } as ConfigurationApplyOutcome; + return { status: 'ambiguous' }; + }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_apply_uncertain'); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.transactions.readFence()?.code).toBe('config_apply_uncertain'); + expect(f.readActive).not.toHaveBeenCalled(); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + const later = vi.fn(f.prepare()); + expect(errorCode(await f.transactions.submit(later))).toBe('config_mutations_fenced'); + expect(later).not.toHaveBeenCalled(); + }, +); + +it.each(['hash', 'null', 'rejected', 'throw', 'invalid'] as const)( + 'fences authoritative verification %s failures even after an applied outcome', + async (failure) => { + const f = fixture(); + f.readActive.mockImplementationOnce(async () => { + if (failure === 'throw') throw new Error(secret); + if (failure === 'rejected') return rejected(); + if (failure === 'invalid') return ok(123) as unknown as PortResult; + return ok(failure === 'null' ? null : 'wrong'); + }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_verification_failed'); + expect(f.transactions.readFence()?.code).toBe('config_verification_failed'); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + }, +); + +it.each([false, true])( + 'requires independent verification for idle=%s, never receipt-only success', + async (idle) => { + const f = fixture(snapshot('old', !idle)); + const target = snapshot('new', idle); + f.readActive.mockResolvedValueOnce(ok(idle ? snapshot().config!.effectiveConfigHash : null)); + expect(errorCode(await f.transactions.submit(f.prepare(target)))).toBe( + 'config_verification_failed', + ); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + }, +); + +it.each([false, true])( + 'restores and verifies atomic persistence failures, including first apply idle=%s', + async (idle) => { + for (const failure of ['rejected', 'throw', 'invalid'] as const) { + const initial = snapshot('old', idle); + const f = fixture(initial); + f.persistDesiredState.mockImplementationOnce(async () => { + f.phases.push('persist-failed'); + if (failure === 'throw') throw new Error(secret); + if (failure === 'invalid') return null as unknown as PortResult; + return rejected(); + }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_persist_failed'); + expect(f.apply.mock.calls.map(([config]) => config)).toEqual([ + snapshot('new').config, + initial.config, + ]); + expect(f.readActive).toHaveBeenCalledTimes(2); + expect(f.phases).toEqual([ + 'prepare', + 'validate', + `apply:${snapshot('new').config!.effectiveConfigHash}`, + 'verify', + 'persist-failed', + `apply:${initial.config?.effectiveConfigHash ?? 'idle'}`, + 'verify', + ]); + expect(f.active()).toBe(initial.config?.effectiveConfigHash ?? null); + expect(f.durable()).toEqual(initial.desiredState); + expect(f.transactions.readCommitted()).toEqual(initial); + expect(f.transactions.readFence()).toBeNull(); + expect((await f.transactions.submit(f.prepare(snapshot('later')))).ok).toBe(true); + } + }, +); + +const recoveryFailures = [ + 'definitely-rejected', + 'ambiguous', + 'throw', + 'invalid', + 'hash', + 'read-rejected', + 'read-throw', + 'read-invalid', +] as const; +function failRestore(f: ReturnType, failure: (typeof recoveryFailures)[number]) { + if (['definitely-rejected', 'ambiguous', 'throw', 'invalid'].includes(failure)) { + f.apply.mockImplementationOnce(async () => { + if (failure === 'throw') throw new Error(secret); + if (failure === 'invalid') return {} as ConfigurationApplyOutcome; + return { status: failure as 'definitely-rejected' | 'ambiguous' }; + }); + } else { + f.readActive.mockImplementationOnce(async () => { + if (failure === 'read-throw') throw new Error(secret); + if (failure === 'read-rejected') return rejected(); + if (failure === 'read-invalid') return undefined as unknown as PortResult; + return ok('wrong'); + }); + } +} + +it.each(recoveryFailures)( + 'fences rollback %s, reports storage failure and never publishes the abandoned candidate', + async (failure) => { + const f = fixture(); + const initialApply = f.apply.getMockImplementation()!; + const initialRead = f.readActive.getMockImplementation()!; + f.apply.mockImplementationOnce(initialApply); + f.readActive.mockImplementationOnce(initialRead); + failRestore(f, failure); + f.persistDesiredState.mockResolvedValueOnce(rejected()); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_persist_failed'); + expect(f.transactions.readFence()?.code).toBe('config_rollback_failed'); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.durable()).toEqual(snapshot().desiredState); + const refused = vi.fn(f.prepare(snapshot('later'))); + expect(errorCode(await f.transactions.submit(refused))).toBe('config_mutations_fenced'); + expect(refused).not.toHaveBeenCalled(); + }, +); + +it('holds the queue through unfinished rollback; no timer or later preparation can overlap it', async () => { + const f = fixture(snapshot('idle', true)); + const entered = deferred(); + const release = deferred(); + const initialApply = f.apply.getMockImplementation()!; + f.apply.mockImplementationOnce(initialApply); + f.apply.mockImplementationOnce(async (target) => { + expect(target).toBeNull(); + entered.resolve(); + await release.promise; + return initialApply(target); + }); + f.persistDesiredState.mockResolvedValueOnce(rejected()); + const failed = f.transactions.submit(f.prepare()); + const nextPrepare = vi.fn(f.prepare(snapshot('later'))); + const next = f.transactions.submit(nextPrepare); + await entered.promise; + expect(nextPrepare).not.toHaveBeenCalled(); + expect(f.transactions.readCommitted()).toEqual(snapshot('idle', true)); + release.resolve(); + expect(errorCode(await failed)).toBe('config_persist_failed'); + expect((await next).ok).toBe(true); +}); + +it('queues fence refusal, reconciliation and recovery, restoring old state rather than committing an observed abandoned candidate', async () => { + const f = fixture(); + const entered = deferred(); + const release = deferred(); + const actualApply = f.apply.getMockImplementation()!; + f.apply.mockImplementationOnce(async (target) => { + await actualApply(target); + entered.resolve(); + await release.promise; + return { status: 'ambiguous' }; + }); + const uncertain = f.transactions.submit(f.prepare()); + const refusedPrepare = vi.fn(f.prepare(snapshot('refused'))); + const refused = f.transactions.submit(refusedPrepare); + const reconciliation = f.transactions.reconcile(); + const recoveredPrepare = vi.fn((committed: ConfigurationSnapshot) => { + expect(committed).toEqual(snapshot()); + return ok(snapshot('recovered')); + }); + const recovered = f.transactions.submit(recoveredPrepare); + await entered.promise; + expect(f.readActive).not.toHaveBeenCalled(); + expect(refusedPrepare).not.toHaveBeenCalled(); + expect(recoveredPrepare).not.toHaveBeenCalled(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + release.resolve(); + expect(errorCode(await uncertain)).toBe('config_apply_uncertain'); + expect(errorCode(await refused)).toBe('config_mutations_fenced'); + expect(refusedPrepare).not.toHaveBeenCalled(); + expect(await reconciliation).toEqual(ok(snapshot())); + expect((await recovered).ok).toBe(true); + expect(f.apply.mock.calls.map(([target]) => target)).toEqual([ + snapshot('new').config, + snapshot().config, + snapshot('recovered').config, + ]); + expect(f.persistDesiredState).toHaveBeenCalledTimes(1); + expect(f.transactions.readCommitted()).toEqual(snapshot('recovered')); + expect(f.transactions.readFence()).toBeNull(); +}); + +it.each([false, true])( + 'clears a fence on independent proof of unchanged committed state idle=%s without apply or persist', + async (idle) => { + const initial = snapshot('old', idle); + const f = fixture(initial); + f.apply.mockResolvedValueOnce({ status: 'ambiguous' }); + await f.transactions.submit(f.prepare()); + const fence = f.transactions.readFence()!; + fence.message = secret; + expect(f.transactions.readFence()?.message).not.toBe(secret); + expect(await f.transactions.reconcile()).toEqual(ok(initial)); + expect(f.apply).toHaveBeenCalledTimes(1); + expect(f.readActive).toHaveBeenCalledTimes(1); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + expect(f.transactions.readFence()).toBeNull(); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); + }, +); + +it('never treats unavailable readback as idle proof after an ambiguous first apply', async () => { + const initial = snapshot('idle', true); + const f = fixture(initial); + const actualApply = f.apply.getMockImplementation()!; + f.apply.mockImplementationOnce(async (target) => { + await actualApply(target); + return { status: 'ambiguous' }; + }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_apply_uncertain'); + expect(f.active()).toBe(snapshot('new').config!.effectiveConfigHash); + // The backend contract reports unknown/unavailable state as failure, never ok(null). + f.readActive.mockResolvedValueOnce(rejected()).mockResolvedValueOnce(rejected()); + expect(errorCode(await f.transactions.reconcile())).toBe('config_reconciliation_failed'); + expect(f.transactions.readFence()).not.toBeNull(); + expect(f.transactions.readCommitted()).toEqual(initial); + expect(f.apply).toHaveBeenCalledTimes(2); + const refused = vi.fn(f.prepare(snapshot('refused'))); + expect(errorCode(await f.transactions.submit(refused))).toBe('config_mutations_fenced'); + expect(refused).not.toHaveBeenCalled(); + expect(await f.transactions.reconcile()).toEqual(ok(initial)); + expect(f.apply.mock.calls.map(([target]) => target)).toEqual([snapshot('new').config, null]); + expect(f.readActive).toHaveBeenCalledTimes(3); + expect(f.active()).toBeNull(); + expect(f.transactions.readFence()).toBeNull(); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + expect(f.transactions.readCommitted()).toEqual(initial); +}); + +it.each(['rejected', 'throw', 'invalid'] as const)( + 'keeps reconciliation observation %s fenced when restoration cannot be independently verified', + async (failure) => { + const f = fixture(); + f.apply.mockResolvedValueOnce({ status: 'ambiguous' }); + await f.transactions.submit(f.prepare()); + const unavailableRead = async (): Promise> => { + if (failure === 'throw') throw new Error(secret); + if (failure === 'invalid') return {} as PortResult; + return rejected(); + }; + f.readActive.mockImplementationOnce(unavailableRead).mockImplementationOnce(unavailableRead); + expect(errorCode(await f.transactions.reconcile())).toBe('config_reconciliation_failed'); + expect(f.apply).toHaveBeenCalledTimes(2); + expect(f.transactions.readFence()).not.toBeNull(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect((await f.transactions.reconcile()).ok).toBe(true); + }, +); + +it.each(['rejected', 'throw', 'invalid'] as const)( + 'restores committed state after initial observation %s only with independent final proof', + async (failure) => { + const f = fixture(); + f.apply.mockResolvedValueOnce({ status: 'ambiguous' }); + await f.transactions.submit(f.prepare()); + f.readActive.mockImplementationOnce(async () => { + if (failure === 'throw') throw new Error(secret); + if (failure === 'invalid') return {} as PortResult; + return rejected(); + }); + expect(await f.transactions.reconcile()).toEqual(ok(snapshot())); + expect(f.apply.mock.calls.map(([target]) => target)).toEqual([ + snapshot('new').config, + snapshot().config, + ]); + expect(f.readActive).toHaveBeenCalledTimes(2); + expect(f.transactions.readFence()).toBeNull(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + }, +); + +it.each(recoveryFailures)( + 'keeps reconciliation restoration %s fenced and permits only a subsequent verified recovery', + async (failure) => { + const f = fixture(snapshot('idle', true)); + const actualApply = f.apply.getMockImplementation()!; + f.apply.mockImplementationOnce(async (target) => { + await actualApply(target); + return { status: 'ambiguous' }; + }); + await f.transactions.submit(f.prepare()); + const actualRead = f.readActive.getMockImplementation()!; + f.readActive.mockImplementationOnce(actualRead); + failRestore(f, failure); + expect(errorCode(await f.transactions.reconcile())).toBe('config_reconciliation_failed'); + expect(f.transactions.readFence()).not.toBeNull(); + expect(f.transactions.readCommitted()).toEqual(snapshot('idle', true)); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + expect((await f.transactions.reconcile()).ok).toBe(true); + expect(f.transactions.readFence()).toBeNull(); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); + }, +); + +it('never clears the fence until reconciliation readback settles and proves the committed target', async () => { + const f = fixture(); + const entered = deferred(); + const release = deferred(); + const actualApply = f.apply.getMockImplementation()!; + f.apply.mockImplementationOnce(async (target) => { + await actualApply(target); + return { status: 'ambiguous' }; + }); + await f.transactions.submit(f.prepare()); + const actualRead = f.readActive.getMockImplementation()!; + f.readActive.mockImplementationOnce(actualRead); + f.readActive.mockImplementationOnce(async () => { + entered.resolve(); + await release.promise; + return actualRead(); + }); + const reconcile = f.transactions.reconcile(); + const nextPrepare = vi.fn(f.prepare(snapshot('later'))); + const next = f.transactions.submit(nextPrepare); + await entered.promise; + expect(f.transactions.readFence()).not.toBeNull(); + expect(nextPrepare).not.toHaveBeenCalled(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + release.resolve(); + expect(await reconcile).toEqual(ok(snapshot())); + expect((await next).ok).toBe(true); +}); + +it('redacts invalid callback/validator carriers without poisoning the tail or unnecessarily fencing', async () => { + const f = fixture(); + expect( + errorCode( + await f.transactions.submit(() => null as unknown as PortResult), + ), + ).toBe('config_prepare_failed'); + f.validate.mockResolvedValueOnce({ ok: true } as never); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_validation_failed'); + expect(f.transactions.readFence()).toBeNull(); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); +}); + +it('treats attempted callback/validator mutation as bounded pre-apply failure, not runtime uncertainty', async () => { + const f = fixture(); + expect( + errorCode( + await f.transactions.submit((committed) => { + committed.registrations[0]!.registrationId = secret; + return ok(snapshot('new')); + }), + ), + ).toBe('config_prepare_failed'); + f.validate.mockImplementationOnce(async (target) => { + Object.defineProperty(target.adaptedConfig, 'body', { value: '{}' }); + return ok({ effectiveConfigHash: target.effectiveConfigHash, diagnostics: [] }); + }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_validation_failed'); + expect(f.transactions.readFence()).toBeNull(); + expect(f.apply).not.toHaveBeenCalled(); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); +}); + +it('settles even unexpected queue errors; close drains failures and leaves admissions closed', async () => { + const f = fixture(); + vi.spyOn(f.transactions, 'readCommitted').mockImplementationOnce(() => { + throw new Error(secret); + }); + expect(errorCode(await f.transactions.reconcile())).toBe('config_transaction_failed'); + expect(f.transactions.readFence()).not.toBeNull(); + expect((await f.transactions.reconcile()).ok).toBe(true); + const failed = f.transactions.submit(() => { + throw new Error(secret); + }); + await f.transactions.close(); + expect(errorCode(await failed)).toBe('config_prepare_failed'); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_transactions_closed'); +}); diff --git a/test/fixtures/caddy-orphan.ts b/test/fixtures/caddy-orphan.ts new file mode 100644 index 0000000..57a985f --- /dev/null +++ b/test/fixtures/caddy-orphan.ts @@ -0,0 +1,28 @@ +import { spawn } from 'node:child_process'; +import { existsSync, writeFileSync } from 'node:fs'; + +const [mode, marker] = process.argv.slice(2); +if (!marker) throw new Error('Missing owned test marker.'); +if (mode === 'grandchild') { + writeFileSync(marker, String(process.pid)); + process.stdout.write('inherited stdout\n'); + process.stderr.write('inherited stderr\n'); + setInterval(() => {}, 1000); +} else { + const child = spawn(process.execPath, [process.argv[1]!, 'grandchild', marker], { + shell: false, + // Avoid Node/libuv's own non-detached child Job Object; a native adapter may leave an orphan. + detached: process.platform === 'win32', + stdio: ['ignore', 'inherit', 'inherit'], + }); + child.once('error', (error) => { + throw error; + }); + child.unref(); + const ready = setInterval(() => { + if (existsSync(marker)) { + clearInterval(ready); + process.exit(0); + } + }, 10); +} diff --git a/test/fixtures/caddy-preload.ts b/test/fixtures/caddy-preload.ts new file mode 100644 index 0000000..053ef9e --- /dev/null +++ b/test/fixtures/caddy-preload.ts @@ -0,0 +1,40 @@ +import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename } from 'node:path'; +import Module from 'node:module'; + +// The async owned fixture must not let Node execute the Caddy command as a script. +Module.runMain = () => {}; + +const behavior = JSON.parse(readFileSync(`${process.execPath}.json`, 'utf8')) as { + version?: string; + modules: string[]; + fail?: string; + output?: string; + marker?: string; + releaseVersion?: string; +}; +const args = process.argv.slice(1); +// Node resolves argv[1] as a script path before preloads; the disposable native fake intercepts it. +args[0] = basename(args[0]!); +if (behavior.marker) appendFileSync(behavior.marker, JSON.stringify(args) + '\n'); +if (behavior.fail === args[0]) process.exit(7); +function finish(): void { + if (args[0] === 'version') process.stdout.write(behavior.version ?? 'v2.11.4 h1:fixture\n'); + else if (args[0] === 'list-modules') + process.stdout.write( + JSON.stringify( + behavior.modules.map((module_name) => ({ module_name, module_type: 'standard' })), + ), + ); + else if (args[0] === 'marker') writeFileSync(args[1]!, 'executed'); + else process.stdout.write(behavior.output ?? '{}'); + process.exit(0); +} +if (args[0] === 'version' && behavior.releaseVersion) { + const timer = setInterval(() => { + if (existsSync(behavior.releaseVersion!)) { + clearInterval(timer); + finish(); + } + }, 20); +} else finish(); diff --git a/test/fixtures/caddy-preparation-preload.ts b/test/fixtures/caddy-preparation-preload.ts new file mode 100644 index 0000000..e14a0bd --- /dev/null +++ b/test/fixtures/caddy-preparation-preload.ts @@ -0,0 +1,150 @@ +import { spawn } from 'node:child_process'; +import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import Module from 'node:module'; +import { basename } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { once } from 'node:events'; + +// A copied native Node image is the disposable executable. Its owned preload handles Caddy argv +// instead of letting Node interpret 'adapt'/'validate' as JavaScript source paths. +Module.runMain = () => {}; +const args = process.argv.slice(1); +args[0] = basename(args[0]!); +type Behavior = { + mode?: + | 'success' + | 'malformed' + | 'invalid-utf8' + | 'invalid-stderr' + | 'exit' + | 'stdout' + | 'stderr' + | 'block' + | 'orphan' + | 'tree' + | 'wait'; + body?: string; + bytes?: number; + marker?: string; + release?: string; +}; +const settings = JSON.parse(readFileSync(`${process.execPath}.json`, 'utf8')) as { + modules: string[]; + log: string; + adapt?: Behavior; + validate?: Behavior; +}; + +async function write(stream: NodeJS.WriteStream, bytes: Buffer | string): Promise { + if (!stream.write(bytes)) await once(stream, 'drain'); +} +async function main(): Promise { + const command = args[0]; + if (command === 'version') { + await write(process.stdout, 'v2.11.4 h1:fixture\n'); + return; + } + if (command === 'list-modules') { + await write( + process.stdout, + JSON.stringify(settings.modules.map((module_name) => ({ module_name }))), + ); + return; + } + if (command === 'descendant') { + writeFileSync(args[1]!, String(process.pid)); + await write(process.stdout, 'descendant stdout\n'); + await write(process.stderr, 'descendant stderr\n'); + setInterval(() => {}, 1000); + return; + } + if (command !== 'adapt' && command !== 'validate') + throw new Error('Unexpected fake-Caddy command.'); + const path = args[args.indexOf('--config') + 1]!; + const body = command === 'validate' ? readFileSync(path, 'utf8') : undefined; + const sourceBody = command === 'adapt' ? readFileSync(path, 'utf8') : undefined; + appendFileSync( + settings.log, + JSON.stringify({ + command, + args, + cwd: process.cwd(), + pid: process.pid, + image: process.execPath, + path, + body, + sourceBody, + }) + '\n', + ); + const behavior = settings[command] ?? {}; + if (behavior.mode === 'tree' || behavior.mode === 'orphan') { + const child = spawn(process.execPath, ['descendant', behavior.marker!], { + shell: false, + detached: process.platform === 'win32', + stdio: ['ignore', 'inherit', 'inherit'], + }); + child.once('error', (error) => { + throw error; + }); + child.unref(); + while (!existsSync(behavior.marker!)) await delay(10); + if (behavior.mode === 'tree') { + setInterval(() => {}, 1000); + return; + } + } + if (behavior.mode === 'block') { + setInterval(() => {}, 1000); + return; + } + if (behavior.mode === 'wait') while (!existsSync(behavior.release!)) await delay(10); + if (behavior.mode === 'invalid-utf8') { + await write(process.stdout, Buffer.from([0xff])); + return; + } + if (behavior.mode === 'invalid-stderr') { + await write(process.stderr, Buffer.from([0xff])); + return; + } + if (behavior.mode === 'exit') { + await write(process.stderr, behavior.body ?? 'fixture validation rejected'); + process.exitCode = 7; + return; + } + if (behavior.mode === 'stdout' || behavior.mode === 'stderr') { + const size = behavior.bytes!; + if (behavior.mode === 'stdout' && command === 'adapt') { + const prefix = '{"padding":"'; + const suffix = '"}'; + await write( + process.stdout, + prefix + 'x'.repeat(size - prefix.length - suffix.length) + suffix, + ); + } else + await write( + behavior.mode === 'stdout' ? process.stdout : process.stderr, + Buffer.alloc(size, 0x20), + ); + if (behavior.mode === 'stderr' && command === 'adapt') await write(process.stdout, '{}'); + return; + } + if (command === 'adapt') + await write( + process.stdout, + behavior.mode === 'malformed' + ? '{"partial":' + : (behavior.body ?? '{"apps":{"http":{"servers":{}}}}\n'), + ); +} +void main().then( + () => { + // Tree/block modes deliberately retain the event loop until the owned helper kills them. + const behavior = args[0] === 'adapt' ? settings.adapt : settings.validate; + if (args[0] !== 'descendant' && behavior?.mode !== 'tree' && behavior?.mode !== 'block') + process.exit(process.exitCode ?? 0); + }, + (error: unknown) => { + console.error(error); + process.exit(1); + }, +); diff --git a/test/fixtures/caddy-runtime-backend.ts b/test/fixtures/caddy-runtime-backend.ts new file mode 100644 index 0000000..7d80ad9 --- /dev/null +++ b/test/fixtures/caddy-runtime-backend.ts @@ -0,0 +1,138 @@ +import { readFile } from 'node:fs/promises'; +import type { CaddyConfig, PortResult } from '../../src/contracts/ports.ts'; +import type { ConfigurationApplyOutcome } from '../../src/daemon/configuration-transactions.ts'; +import { + OwnedCommandError, + type OwnedCommandOptions, + type OwnedCommandOutput, +} from '../../src/platform/owned-command.ts'; +import type { PinnedCaddyImage } from '../../src/caddy/image.ts'; +import type { RealCaddyResolver } from '../../src/caddy/resolver.ts'; +import { normalizeCaddyConfig } from '../../src/caddy/admin-policy.ts'; + +const empty = (): OwnedCommandOutput => ({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + exitCode: 0, +}); +export const backend = { + active: null as string | null, + runCalls: [] as { args: readonly string[]; options: OwnedCommandOptions; body: string }[], + reads: 0, + stops: 0, + loads: 0, + loadStatus: 'applied' as ConfigurationApplyOutcome['status'], + uncertainLoadChanges: false, + startup: 'ready' as 'ready' | 'silent' | 'mismatch' | 'exit' | 'launch-failed', + launchGate: undefined as Promise | undefined, + stopMode: 'graceful' as 'graceful' | 'ignored' | 'hanging', + cleanupFault: false, + validationFails: false, + blockRead: false, + settle: undefined as (() => void) | undefined, + reset(): void { + this.active = null; + this.runCalls = []; + this.reads = 0; + this.stops = 0; + this.loads = 0; + this.loadStatus = 'applied'; + this.uncertainLoadChanges = false; + this.startup = 'ready'; + this.launchGate = undefined; + this.stopMode = 'graceful'; + this.cleanupFault = false; + this.validationFails = false; + this.blockRead = false; + this.settle = undefined; + }, + async readActive(signal?: AbortSignal): Promise> { + this.reads++; + if (this.blockRead && this.runCalls.length > 0 && !signal?.aborted) await cancelled(signal); + if (!signal?.aborted && this.active !== null) return { ok: true, value: this.active }; + return { + ok: false, + error: { + kind: 'caddyRuntime', + code: 'fixture_unavailable', + message: 'Fixture unavailable.', + guidance: null, + retryable: false, + requestId: null, + }, + }; + }, + async apply(config: CaddyConfig, signal?: AbortSignal): Promise { + this.loads++; + if (this.blockRead && !signal?.aborted) await cancelled(signal); + if (signal?.aborted) return { status: 'ambiguous' }; + if (this.loadStatus === 'applied' || this.uncertainLoadChanges) + this.active = config.effectiveConfigHash; + return { status: this.loadStatus }; + }, + async stop(signal?: AbortSignal): Promise { + this.stops++; + if (this.stopMode === 'hanging' && !signal?.aborted) await cancelled(signal); + if (this.stopMode === 'graceful') this.settle?.(); + return { status: this.stopMode === 'graceful' ? 'applied' : 'ambiguous' }; + }, +}; +function cancelled(signal?: AbortSignal): Promise { + return new Promise((resolve) => + signal?.addEventListener('abort', () => resolve(), { once: true }), + ); +} + +export function fixtureResolver(): RealCaddyResolver { + const image = { + run(args: readonly string[], options: OwnedCommandOptions = {}): Promise { + if (args[0] === 'validate') + return Promise.resolve({ ...empty(), exitCode: backend.validationFails ? 1 : 0 }); + return new Promise((resolve, reject) => { + let done = false; + const finish = (error?: unknown): void => { + if (done) return; + done = true; + options.signal?.removeEventListener('abort', abort); + backend.active = null; + if (error) reject(error); + else resolve(empty()); + }; + const abort = (): void => + finish( + new OwnedCommandError( + backend.cleanupFault ? 'cleanup' : 'abort', + 'Fixture settlement.', + ), + ); + backend.settle = () => finish(); + options.signal?.addEventListener('abort', abort, { once: true }); + if (options.signal?.aborted) { + abort(); + return; + } + void readFile(args[2]!, 'utf8') + .then(async (body) => { + if (done) return; + backend.runCalls.push({ args: [...args], options, body }); + await backend.launchGate; + if (done) return; + if (backend.startup === 'launch-failed') { + finish(new OwnedCommandError('spawn', 'Fixture launch failed.')); + return; + } + if (backend.startup === 'exit') { + finish(); + return; + } + options.onStarted?.(); + if (backend.startup === 'ready') + backend.active = normalizeCaddyConfig(body).effectiveConfigHash; + else if (backend.startup === 'mismatch') backend.active = 'independent-mismatch'; + }, finish) + .catch(finish); + }); + }, + } as PinnedCaddyImage; + return { pin: async () => image } as unknown as RealCaddyResolver; +} From 81c120b1b67d640b2c344abc040943c4cd2b3435 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Thu, 8 Oct 2026 20:02:39 +0200 Subject: [PATCH 21/29] fix: preserve user ownership of native Windows runtime files Normalize the Node primary token default owner only after safe journal preflight and before SQLite admission. Keep strict existing-path checks and verify native readback without changing privileges or ACLs. Cover fail-closed ordering with regressions and record the real Windows Sandbox SQLite/Caddy lifecycle proof. Final Node check passes 812 tests with 96.16 percent own-source line coverage; completed-product and distribution gates remain open. --- .../changes/migrate-caddy-lifecycle/design.md | 16 +++ .../changes/migrate-caddy-lifecycle/tasks.md | 8 +- .../migrate-caddy-lifecycle/verification.md | 81 +++++++++-- src/daemon/runtime-lock.ts | 2 + src/platform/runtime-creation-owner.ts | 106 ++++++++++++++ test/runtime-creation-owner.test.ts | 129 ++++++++++++++++++ test/runtime-lock-faults.test.ts | 52 ++++++- 7 files changed, 383 insertions(+), 11 deletions(-) create mode 100644 src/platform/runtime-creation-owner.ts create mode 100644 test/runtime-creation-owner.test.ts diff --git a/openspec/changes/migrate-caddy-lifecycle/design.md b/openspec/changes/migrate-caddy-lifecycle/design.md index 2abd6f0..00c914c 100644 --- a/openspec/changes/migrate-caddy-lifecycle/design.md +++ b/openspec/changes/migrate-caddy-lifecycle/design.md @@ -61,6 +61,22 @@ owned execution settles. Strict product-created path checks remain unchanged. Known generated Caddy storage paths use the accepted read-only descendant check; no ACL rewrite, general tree scan or secret-bearing diagnostic is added. +Windows native creation must also preserve the actual user's ownership. An +elevated primary token may otherwise create SQLite journals and Caddy keys with +the Administrators group as owner, even beneath a user-only inherited DACL. +After validating the runtime directory, lock file and existing known journal, +`acquireRuntimeLock` awaits `normalizeRuntimeCreationOwner` before opening SQLite. +The bounded existing PowerShell bridge targets the Node process primary token, +verifies its TokenUser against the runtime owner, and queries TokenOwner. Only a +mismatch requests TOKEN_ADJUST_DEFAULT and sets TokenOwner to that existing user +SID. A separately reopened query-only token must prove the same user and owner +before admission continues. Unix remains a no-op; `runtimeOwner` stays +observational. This changes default ownership for future native-created objects, +not token privileges, groups, elevation, identity or existing file ACLs. Unsafe +existing journals still fail before normalization. No native addon or additional +launcher is introduced. Runtime exclusion remains the caller's prerequisite for +Caddy lifecycle execution. + ## Verification Use fake Caddy/process/admin seams in unit tests and disposable Node-native diff --git a/openspec/changes/migrate-caddy-lifecycle/tasks.md b/openspec/changes/migrate-caddy-lifecycle/tasks.md index ad33de9..28ea76f 100644 --- a/openspec/changes/migrate-caddy-lifecycle/tasks.md +++ b/openspec/changes/migrate-caddy-lifecycle/tasks.md @@ -6,4 +6,10 @@ ## 2. Verification - [x] 2.1 Test lifecycle success/failures, start cancellation, bounded forced teardown, immutable policy, uncertain observations and unrelated-process survival with fake adapters/native fixtures (CADDY-002/003/006, RUN-003/004). Include transaction idle rollback/reconciliation regressions. Parent post-review focused suites passed 96 tests; native ownership and HTTPS fixtures remain separate from actual Caddy lifecycle acceptance. -- [x] 2.2 Integrate independent review, simplification/fresh-eyes checks and the full Node gate; record exact executed evidence and retain native/product/archive gaps (CADDY-001/002/003/006, RUN-003/004). F1/F2 corrected and accepted by the targeted independent follow-up; parent final nub run check passed 780 tests with 2 skips and 96.14% own-source line coverage. Planning check remains blocked by implementation-only missing deltas; see verification.md. +- [x] 2.2 Integrate independent review, simplification/fresh-eyes checks and the full Node gate; record exact executed evidence and retain native/product/archive gaps (CADDY-001/002/003/006, RUN-003/004). F1/F2 corrected and accepted by the targeted independent follow-up; the initial parent gate passed 780 tests with 2 skips and 96.14% own-source line coverage. Planning check remains blocked by implementation-only missing deltas; see verification.md. + +## 3. Windows native creation-owner correction + +- [x] 3.1 Preserve the actual user as default owner of future native-created files before SQLite admission; verify the Node primary token, request adjustment only on mismatch, require independent readback, and refuse unsafe existing journals first (RUN-005, CADDY-006). Keep Unix and runtimeOwner observation unchanged; no privilege or existing ACL changes. +- [x] 3.2 Exercise the production correction with real SQLite and Caddy 2.11.4 in an isolated Windows Sandbox: generated journal/key protection, HTTP/HTTPS multi-project routing, reload, malformed rejection with last-known-good retention, idle stop and material-reused restart (RUN-005, CADDY-002/003/006). Guest identity was elevated; this is leaf integration, not completed-product privilege or distribution acceptance. +- [x] 3.3 Add mocked fail-closed/admission-order regressions, independent static review and the final Node gate: 812 passed, 2 existing Unix-native skips, 96.16% own-source lines (RUN-005, CADDY-006). Native C# success paths are exercised separately by the Sandbox scenario, not by TypeScript coverage. diff --git a/openspec/changes/migrate-caddy-lifecycle/verification.md b/openspec/changes/migrate-caddy-lifecycle/verification.md index e88083b..a3e5f7a 100644 --- a/openspec/changes/migrate-caddy-lifecycle/verification.md +++ b/openspec/changes/migrate-caddy-lifecycle/verification.md @@ -2,11 +2,12 @@ ## Scope and source -Verified the leaf owned-Caddy lifecycle increment on the migration branch at -base commit `93942af84f555eab2bd68e0a4a9db487ff82d040`, with the current uncommitted -source changes. This is not evidence for a tagged/source-clean release candidate, -whole roadmap 3.8, G3, final product or either distribution. Existing Rust and -previous dirty/untracked work were preserved; nothing was staged or committed. +The initial leaf owned-Caddy lifecycle increment was verified on the migration +branch at base commit `93942af84f555eab2bd68e0a4a9db487ff82d040` with then-uncommitted +source changes. It was subsequently committed as `31b88e1` and pushed. The Windows +creation-owner follow-up below was verified against that commit plus the exact +hashed production delta. Neither result accepts a tagged release candidate, +whole roadmap 3.8, G3, final product or either distribution. Existing Rust remains. Delivered `src/caddy/runtime.ts`, extensions to the existing administration and owned-command boundaries, and direct regression tests. A small correction to @@ -101,11 +102,75 @@ rerun and both parent full gates passed without suppression or fixture changes. The transient cause remains unproved and is retained as a diagnostic limitation, not declared fixed or attributed to the environment. +## Windows native creation-owner follow-up + +A real Windows Sandbox run first failed before Caddy launch: SQLite's native +DELETE journal had the Administrators group as owner beneath a sole-user DACL. +The strict read-only descendant check correctly rejected it. A minimized native +reproduction confirmed the difference between explicitly protected paths and the +journal. No existing owner or ACL was repaired or weakened. + +A guest-only default-owner prototype then proved the mechanism. After correcting +an administration-key path error in that temporary scenario, real SQLite and the +full Caddy lifecycle probe passed. Two intervening Sandbox frontend exits without +guest reports were not accepted as successful execution. A guest logon marker +made subsequent startup and scenario execution observable; the earlier missing +reports have no proved cause. + +The production correction now targets the Node primary token through the +existing bounded PowerShell bridge. It requests query access first, conditional +TOKEN_ADJUST_DEFAULT only for a mismatched TokenOwner, and separate query-only +readback. The lock's existing-journal preflight still precedes normalization; +SQLite admission follows verified normalization. Unix and runtimeOwner remain +unchanged. No groups, privileges, elevation, identity or existing ACLs change. + +The parent ran the production source delta in a fresh isolated Windows Sandbox +using Node 24.18.0 and separately installed Caddy 2.11.4. Input and source hashes +were verified; no prototype mutation helper was included. The guest's actual +token was elevated. Independent read-only token observation proved the default +owner changed from Administrators to the same TokenUser, with unchanged identity +and elevation. A repeat production helper call retained that owner. + +Executed assertions passed: + +- Real runtime-lock acquisition and user-owned native SQLite journal. +- Real pinned Caddy adaptation, guarded composition and secure initial startup, + followed by independent matching configuration readback. +- Read-only protection checks for native-generated web CA and administration keys. +- HTTP and verified HTTPS for two projects after reload; unknown and disabled + hosts did not receive a project response. +- Real malformed candidate rejection and independently observed last-known-good + retention. +- Idle stop and free listeners; restart with reused administration + material and web CA, followed by independent readback and HTTPS routing. +- Owned execution settlement, lock release, runtime removal and final free + listeners. The guest report and bootstrap both passed; scenario exit was 0. + +Runtime data and credentials stayed in guest temporary storage. Only bounded +reports were exported to task-owned system temporary storage. Input was mapped +read-only; report output was the only writable mapping. Networking, clipboard, +GPU, camera, microphone and printer sharing were disabled. No real Caddy ran on +the host, no unrelated process was stopped, and no system trust was installed. + +The four-file production/test correction received independent static review with +no material finding. Mocked helper and lock-failure tests passed 63/63, covering +malformed/mismatched proof, helper failure, Unix no-op, preflight ordering and the +pending-normalization admission barrier. Script-string and mocked tests do not +execute native C# branches; the real Sandbox run supplies separate native proof. + +The final `nub run check` passed TypeScript, ESLint, Prettier and V8 coverage: +35 files, 812 tests passed, 2 existing Unix-native skips. Own-source line coverage +was 1,728/1,797 (96.16%); statements 94.75%, branches 90.60%, functions 96.79%. +Coverage measures TypeScript, not C# embedded in the Windows bridge. Active LSP +probes found no diagnostics but could not confirm clean files on a silent-on-clean +server; the passing compiler is the type-check evidence. Reports stayed outside +the checkout. + Still open: -- Real-Caddy lifecycle/HTTP/HTTPS integration and startup-conflict scenarios on - supported native platforms; runtime unit tests fake process/admin/protection - seams while using the real validator and transaction queue. +- Real-Caddy lifecycle/HTTP/HTTPS integration on Linux/macOS and native startup + conflict scenarios. The delivered Windows leaf scenario does not exercise + ordinary-client/elevated-daemon IPC or different-account acceptance. - Full daemon/handler/shim Ctrl+C, shutdown, registration and recovery journeys. - Linux/macOS execution of the changed native group-settlement path. - Completed-product Windows privilege/Sandbox, G1/G2/G3/G7 and npm/SEA parity. diff --git a/src/daemon/runtime-lock.ts b/src/daemon/runtime-lock.ts index ce88265..402ccdb 100644 --- a/src/daemon/runtime-lock.ts +++ b/src/daemon/runtime-lock.ts @@ -3,6 +3,7 @@ import { readFile, unlink, writeFile } from 'node:fs/promises'; import { DatabaseSync } from 'node:sqlite'; import type { RuntimePaths } from './paths.ts'; import type { RuntimeOwner } from '../platform/runtime-security.ts'; +import { normalizeRuntimeCreationOwner } from '../platform/runtime-creation-owner.ts'; import { assertProtected, assertRuntimeDescendant, @@ -37,6 +38,7 @@ export async function acquireRuntimeLock( } } await assertJournal(); + await normalizeRuntimeCreationOwner(owner); const database = new DatabaseSync(paths.lock, { timeout: 0 }); let transaction = false; let metadataOwned = false; diff --git a/src/platform/runtime-creation-owner.ts b/src/platform/runtime-creation-owner.ts new file mode 100644 index 0000000..6719b79 --- /dev/null +++ b/src/platform/runtime-creation-owner.ts @@ -0,0 +1,106 @@ +import type { RuntimeOwner } from './runtime-security.ts'; +import { powershell, psLiteral } from './powershell.ts'; +import { CadderError } from '../protocol/errors.ts'; + +/** Set only the Windows primary token's default creation owner, never existing file ACLs. */ +export async function normalizeRuntimeCreationOwner(owner: RuntimeOwner): Promise { + if (process.platform !== 'win32') return; + try { + const proof: unknown = JSON.parse( + await powershell(` +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +public static class CadderRuntimeCreationOwner { + [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint access, bool inherit, int pid); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("advapi32.dll", SetLastError=true)] static extern bool OpenProcessToken(IntPtr process, uint access, out IntPtr token); + [DllImport("advapi32.dll", SetLastError=true)] static extern bool GetTokenInformation(IntPtr token, int kind, IntPtr data, int length, out int required); + [DllImport("advapi32.dll", SetLastError=true)] static extern bool SetTokenInformation(IntPtr token, int kind, IntPtr data, int length); + public sealed class Result { public int ProcessId; public string User; public string Before; public string After; public bool Changed; } + static IntPtr Information(IntPtr token, int kind) { + int length; + bool queried = GetTokenInformation(token, kind, IntPtr.Zero, 0, out length); + int error = Marshal.GetLastWin32Error(); + if (queried || error != 122 || length < IntPtr.Size || length > 65536) throw new Win32Exception(error); + IntPtr data = Marshal.AllocHGlobal(length); + try { + if (!GetTokenInformation(token, kind, data, length, out length)) throw new Win32Exception(Marshal.GetLastWin32Error()); + return data; + } catch { Marshal.FreeHGlobal(data); throw; } + } + static string Sid(IntPtr token, int kind) { + IntPtr data = Information(token, kind); + try { return new SecurityIdentifier(Marshal.ReadIntPtr(data)).Value; } + finally { Marshal.FreeHGlobal(data); } + } + static void CloseToken(ref IntPtr token) { + if (!CloseHandle(token)) throw new Win32Exception(Marshal.GetLastWin32Error()); + token = IntPtr.Zero; + } + public static Result Normalize(int pid, string expected) { + IntPtr process = IntPtr.Zero, token = IntPtr.Zero, user = IntPtr.Zero, owner = IntPtr.Zero; + try { + // PROCESS_QUERY_LIMITED_INFORMATION; target the Node process, not this helper. + process = OpenProcess(0x1000, false, pid); + if (process == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (!OpenProcessToken(process, 0x0008, out token)) throw new Win32Exception(Marshal.GetLastWin32Error()); + string actual = Sid(token, 1); + if (actual != expected) throw new InvalidOperationException("Target token user mismatch."); + string before = Sid(token, 4); + bool changed = before != actual; + if (changed) { + CloseToken(ref token); + // TOKEN_QUERY | TOKEN_ADJUST_DEFAULT, only when TokenOwner differs. + if (!OpenProcessToken(process, 0x0008 | 0x0080, out token)) throw new Win32Exception(Marshal.GetLastWin32Error()); + user = Information(token, 1); + if (new SecurityIdentifier(Marshal.ReadIntPtr(user)).Value != expected) throw new InvalidOperationException("Adjustment token user mismatch."); + owner = Marshal.AllocHGlobal(IntPtr.Size); + Marshal.WriteIntPtr(owner, Marshal.ReadIntPtr(user)); + if (!SetTokenInformation(token, 4, owner, IntPtr.Size)) throw new Win32Exception(Marshal.GetLastWin32Error()); + } + // Independently reopen the process primary token for readback, with query only. + CloseToken(ref token); + if (!OpenProcessToken(process, 0x0008, out token)) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (Sid(token, 1) != expected) throw new InvalidOperationException("Readback token user mismatch."); + string after = Sid(token, 4); + if (after != actual) throw new InvalidOperationException("Default owner readback mismatch."); + return new Result { ProcessId=pid, User=actual, Before=before, After=after, Changed=changed }; + } finally { + if (owner != IntPtr.Zero) Marshal.FreeHGlobal(owner); + if (user != IntPtr.Zero) Marshal.FreeHGlobal(user); + if (token != IntPtr.Zero) CloseHandle(token); + if (process != IntPtr.Zero) CloseHandle(process); + } + } +} +'@; +[CadderRuntimeCreationOwner]::Normalize(${process.pid}, ${psLiteral(owner.id)}) | ConvertTo-Json -Compress +`), + ); + if ( + !proof || + typeof proof !== 'object' || + !('ProcessId' in proof) || + proof.ProcessId !== process.pid || + !('User' in proof) || + proof.User !== owner.id || + !('Before' in proof) || + typeof proof.Before !== 'string' || + !/^S-1-\d+(?:-\d+)+$/.test(proof.Before) || + !('After' in proof) || + proof.After !== owner.id || + !('Changed' in proof) || + typeof proof.Changed !== 'boolean' || + proof.Changed !== (proof.Before !== proof.User) + ) + throw new Error('Invalid native creation owner proof.'); + } catch { + throw new CadderError( + 'unsafe-runtime-permissions', + 'Cannot verify the current process default creation owner.', + ); + } +} diff --git a/test/runtime-creation-owner.test.ts b/test/runtime-creation-owner.test.ts new file mode 100644 index 0000000..52d2fd1 --- /dev/null +++ b/test/runtime-creation-owner.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { normalizeRuntimeCreationOwner } from '../src/platform/runtime-creation-owner.ts'; +import { powershell } from '../src/platform/powershell.ts'; + +vi.mock('../src/platform/powershell.ts', async (original) => ({ + ...(await original()), + powershell: vi.fn(), +})); +const owner = { id: 'S-1-5-21-123-456-789-1001', elevated: true }; +const proof = { + ProcessId: process.pid, + User: owner.id, + Before: owner.id, + After: owner.id, + Changed: false, +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.mocked(powershell).mockResolvedValue(JSON.stringify(proof)); +}); +afterEach(() => vi.restoreAllMocks()); + +it.each(['linux', 'darwin'] as const)('is a no-op on %s', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform); + await normalizeRuntimeCreationOwner({ id: 'uid:1001', uid: 1001, elevated: false }); + expect(powershell).not.toHaveBeenCalled(); +}); + +it('targets the actual Node primary token with limited process access and user proof', async () => { + await normalizeRuntimeCreationOwner(owner); + expect(powershell).toHaveBeenCalledOnce(); + const script = vi.mocked(powershell).mock.calls[0]![0]; + expect(script).toContain(`::Normalize(${process.pid}, '${owner.id}')`); + expect(script).toContain('OpenProcess(0x1000, false, pid)'); + expect(script).toContain('OpenProcessToken(process, 0x0008, out token)'); + expect(script).toContain('string actual = Sid(token, 1)'); + expect(script).toContain('if (actual != expected) throw'); + expect(script).not.toMatch(/WindowsIdentity|GetCurrentProcess|AdjustTokenPrivileges|Impersonate/); +}); + +it('keeps adjustment access and TokenOwner mutation inside the mismatch branch', async () => { + await normalizeRuntimeCreationOwner(owner); + const script = vi.mocked(powershell).mock.calls[0]![0]; + const branch = script.slice(script.indexOf('if (changed) {'), script.indexOf('// Independently')); + expect(branch).toContain('OpenProcessToken(process, 0x0008 | 0x0080, out token)'); + expect(branch).toContain('user = Information(token, 1)'); + expect(branch).toContain('Marshal.WriteIntPtr(owner, Marshal.ReadIntPtr(user))'); + expect(branch).toContain('SetTokenInformation(token, 4, owner, IntPtr.Size)'); + expect(script.match(/0x0080/g)).toHaveLength(1); + expect(script.match(/if \(!SetTokenInformation/g)).toHaveLength(1); + expect(script).toContain('bool changed = before != actual'); + const readback = script.slice( + script.indexOf('// Independently'), + script.indexOf('return new Result'), + ); + expect(readback).toContain('CloseToken(ref token)'); + expect(readback).toContain('OpenProcessToken(process, 0x0008, out token)'); + expect(readback).toContain('if (Sid(token, 1) != expected) throw'); + expect(readback).toContain('string after = Sid(token, 4)'); + expect(readback).toContain('if (after != actual) throw'); + expect(script).toContain('finally { Marshal.FreeHGlobal(data); }'); + for (const resource of ['owner', 'user']) + expect(script).toContain(`if (${resource} != IntPtr.Zero) Marshal.FreeHGlobal(${resource})`); + for (const resource of ['token', 'process']) + expect(script).toContain(`if (${resource} != IntPtr.Zero) CloseHandle(${resource})`); +}); + +it('accepts a mismatched default owner only after successful user-owner readback', async () => { + vi.mocked(powershell).mockResolvedValue( + JSON.stringify({ ...proof, Before: 'S-1-5-32-544', Changed: true }), + ); + await expect(normalizeRuntimeCreationOwner(owner)).resolves.toBeUndefined(); +}); + +it('passes the owner as an escaped PowerShell literal', async () => { + const quoted = { ...owner, id: "S-1-5-21-1001'; throw 'injected" }; + vi.mocked(powershell).mockResolvedValue( + JSON.stringify({ ...proof, User: quoted.id, After: quoted.id }), + ); + await expect(normalizeRuntimeCreationOwner(quoted)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(vi.mocked(powershell).mock.calls[0]![0]).toContain("'S-1-5-21-1001''; throw ''injected'"); +}); + +it.each([ + null, + [], + {}, + { ...proof, ProcessId: process.pid + 1 }, + { ...proof, ProcessId: String(process.pid) }, + { ...proof, User: 'S-1-5-18' }, + { ...proof, After: 'S-1-5-32-544' }, + { ...proof, Before: '' }, + { ...proof, Before: 'not-a-sid' }, + { ...proof, Before: null }, + { ...proof, Changed: true }, + { ...proof, Changed: 'false' }, + { ...proof, Before: 'S-1-5-32-544', Changed: false }, +])('rejects invalid native proof %#', async (invalid) => { + vi.mocked(powershell).mockResolvedValue(JSON.stringify(invalid)); + await expect(normalizeRuntimeCreationOwner(owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); +}); + +it.each(['', 'not JSON', '{', 'null'])('fails closed on malformed output %j', async (output) => { + vi.mocked(powershell).mockResolvedValue(output); + await expect(normalizeRuntimeCreationOwner(owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); +}); + +it.each([ + 'OpenProcess', + 'TokenUser', + 'TokenOwner', + 'adjust access', + 'SetTokenInformation', + 'readback', + 'timeout', +])('fails closed on native/helper failure: %s', async (phase) => { + vi.mocked(powershell).mockRejectedValue(new Error(phase)); + await expect(normalizeRuntimeCreationOwner(owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); +}); diff --git a/test/runtime-lock-faults.test.ts b/test/runtime-lock-faults.test.ts index c519ef4..8ce4df6 100644 --- a/test/runtime-lock-faults.test.ts +++ b/test/runtime-lock-faults.test.ts @@ -5,6 +5,11 @@ import { beforeEach, expect, it, vi } from 'vitest'; import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; import { resolvePaths } from '../src/daemon/paths.ts'; import * as security from '../src/platform/runtime-security.ts'; +import { normalizeRuntimeCreationOwner } from '../src/platform/runtime-creation-owner.ts'; + +vi.mock('../src/platform/runtime-creation-owner.ts', () => ({ + normalizeRuntimeCreationOwner: vi.fn(), +})); vi.mock('node:crypto', async (original) => ({ ...(await original()), @@ -64,6 +69,7 @@ it.each(['unsafe', 'inspection', 'missing-root', 'missing-intermediate'])( }); vi.mocked(security.assertRuntimeDescendant).mockRejectedValueOnce(failure); await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); + expect(normalizeRuntimeCreationOwner).not.toHaveBeenCalled(); expect(DatabaseSync).not.toHaveBeenCalled(); expect(fs.unlink).not.toHaveBeenCalled(); expect(fs.writeFile).not.toHaveBeenCalled(); @@ -85,7 +91,11 @@ it('allows only known journal leaf absence and checks again while exclusion is h const lock = await acquireRuntimeLock(paths, owner); expect(security.assertRuntimeDescendant).toHaveBeenCalledTimes(2); expect(security.assertRuntimeDescendant).toHaveBeenCalledWith(paths.directory, journal, owner); + expect(normalizeRuntimeCreationOwner).toHaveBeenCalledExactlyOnceWith(owner); expect(vi.mocked(security.assertRuntimeDescendant).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(normalizeRuntimeCreationOwner).mock.invocationCallOrder[0]!, + ); + expect(vi.mocked(normalizeRuntimeCreationOwner).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(DatabaseSync).mock.invocationCallOrder[0]!, ); expect(vi.mocked(security.assertRuntimeDescendant).mock.invocationCallOrder[1]).toBeGreaterThan( @@ -97,6 +107,43 @@ it('allows only known journal leaf absence and checks again while exclusion is h await lock.release(); }); +it('awaits creation owner normalization before opening SQLite', async () => { + let complete!: () => void; + vi.mocked(normalizeRuntimeCreationOwner).mockReturnValueOnce( + new Promise((resolve) => { + complete = resolve; + }), + ); + const acquiring = acquireRuntimeLock(paths, owner); + await vi.waitFor(() => expect(normalizeRuntimeCreationOwner).toHaveBeenCalledOnce()); + expect(DatabaseSync).not.toHaveBeenCalled(); + expect(fs.readFile).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + complete(); + const lock = await acquiring; + expect(DatabaseSync).toHaveBeenCalledOnce(); + await lock.release(); +}); + +it('leaves SQLite, metadata and secret untouched when creation owner normalization fails', async () => { + const failure = Object.assign(new Error('native owner failure'), { + code: 'unsafe-runtime-permissions', + }); + vi.mocked(normalizeRuntimeCreationOwner).mockRejectedValueOnce(failure); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); + expect(normalizeRuntimeCreationOwner).toHaveBeenCalledExactlyOnceWith(owner); + expect(security.assertProtected).toHaveBeenCalledWith(paths.directory, owner, true); + expect(security.createProtectedFile).toHaveBeenCalledExactlyOnceWith(paths.lock, owner); + expect(security.assertRuntimeDescendant).toHaveBeenCalledOnce(); + expect(DatabaseSync).not.toHaveBeenCalled(); + expect(database.exec).not.toHaveBeenCalled(); + expect(database.close).not.toHaveBeenCalled(); + expect(fs.readFile).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(randomBytes).not.toHaveBeenCalled(); +}); + it('refuses an unsafe generated journal and finalizes exclusion before publication', async () => { const failure = new Error('unsafe generated journal'); vi.mocked(security.assertRuntimeDescendant) @@ -161,9 +208,10 @@ it.each(['directory', 'lock-create', 'metadata-read', 'secret-create'])( await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); expect(fs.unlink).not.toHaveBeenCalled(); expect(fs.writeFile).not.toHaveBeenCalled(); - if (phase === 'directory' || phase === 'lock-create') + if (phase === 'directory' || phase === 'lock-create') { + expect(normalizeRuntimeCreationOwner).not.toHaveBeenCalled(); expect(DatabaseSync).not.toHaveBeenCalled(); - else expect(database.close).toHaveBeenCalledOnce(); + } else expect(database.close).toHaveBeenCalledOnce(); if (phase === 'secret-create') expect(generated.every((byte) => byte === 0)).toBe(true); }, ); From 5b4daee1b09c383f207e2738f1d21d710abc48ce Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Tue, 20 Oct 2026 13:27:05 +0200 Subject: [PATCH 22/29] feat: implement Node product and candidate distributions Connect durable state and project sessions to the daemon, CLI, TUI and shim. Add compiled npm and standalone SEA candidates with shared native acceptance, retaining the Rust baseline until platform and privilege gates pass. --- .github/workflows/node-runtime.yml | 35 +- docs/site/README.md | 14 +- docs/site/astro.config.mjs | 1 + docs/site/nub.lock | 4002 ++++++++++ docs/site/package-lock.json | 6773 ----------------- docs/site/package.json | 10 +- .../content/docs/reference/node-candidate.mdx | 52 + mise.lock | 4 + mise.toml | 14 +- .../.openspec.yaml | 2 + .../migrate-node-project-runtime/design.md | 75 + .../migrate-node-project-runtime/proposal.md | 46 + .../migrate-node-project-runtime/tasks.md | 25 + .../verification.md | 26 + .../reset-cadder-architecture/tasks.md | 22 +- .../reset-cadder-architecture/verification.md | 91 + package.json | 4 + scripts/licenses/yoga-layout-3.2.1-LICENSE | 21 + scripts/pack-node.ts | 116 + scripts/pack-sea.ts | 176 + scripts/prepare-caddy-gate.ts | 47 + scripts/prepare-product-sandbox.ts | 62 + scripts/source-identity.ts | 27 + scripts/verify-node.ts | 139 + scripts/verify-product-journey.ts | 214 + scripts/verify-sea.ts | 102 + src/caddy/admin-policy.ts | 59 +- src/caddy/domains.ts | 20 +- src/caddy/runtime.ts | 9 + src/cli/output.ts | 154 + src/cli/run.ts | 224 + src/client/connection.ts | 10 +- src/client/launch.ts | 43 + src/client/selectors.ts | 61 + src/client/service.ts | 115 + src/daemon/application.ts | 441 ++ src/daemon/caddy-logs.ts | 126 + src/daemon/configuration-transactions.ts | 22 +- src/daemon/local-runtime.ts | 7 + src/daemon/start.ts | 200 + src/entrypoints/cadder.ts | 3 + src/entrypoints/cadderd.ts | 53 + src/entrypoints/caddy.ts | 11 + src/platform/inspection.ts | 110 + src/platform/owned-command.ts | 12 +- src/platform/powershell.ts | 5 +- src/protocol/domain.ts | 17 + src/shim/run.ts | 210 + src/storage/database.ts | 120 + src/storage/redaction.ts | 28 + src/storage/schema.ts | 29 + src/storage/storage.ts | 154 + src/storage/worker.ts | 35 + src/tui/app.tsx | 202 + test/application-storage.test.ts | 185 + test/caddy-admin-policy.test.ts | 18 + test/caddy-log-ingestion.test.ts | 88 + test/caddy-owned-process.test.ts | 19 +- test/client-service.test.ts | 120 + test/compatibility/catalog.test.ts | 556 +- test/daemon-application.test.ts | 279 + test/local-runtime-faults.test.ts | 39 + test/native-inspection.test.ts | 26 + test/operator-cli.test.ts | 140 + test/ports.test.ts | 216 +- test/product-daemon.test.ts | 37 + test/routes-tui.test.tsx | 65 + test/shim.test.ts | 84 + 68 files changed, 8857 insertions(+), 7595 deletions(-) create mode 100644 docs/site/nub.lock delete mode 100644 docs/site/package-lock.json create mode 100644 docs/site/src/content/docs/reference/node-candidate.mdx create mode 100644 openspec/changes/migrate-node-project-runtime/.openspec.yaml create mode 100644 openspec/changes/migrate-node-project-runtime/design.md create mode 100644 openspec/changes/migrate-node-project-runtime/proposal.md create mode 100644 openspec/changes/migrate-node-project-runtime/tasks.md create mode 100644 openspec/changes/migrate-node-project-runtime/verification.md create mode 100644 scripts/licenses/yoga-layout-3.2.1-LICENSE create mode 100644 scripts/pack-node.ts create mode 100644 scripts/pack-sea.ts create mode 100644 scripts/prepare-caddy-gate.ts create mode 100644 scripts/prepare-product-sandbox.ts create mode 100644 scripts/source-identity.ts create mode 100644 scripts/verify-node.ts create mode 100644 scripts/verify-product-journey.ts create mode 100644 scripts/verify-sea.ts create mode 100644 src/cli/output.ts create mode 100644 src/cli/run.ts create mode 100644 src/client/launch.ts create mode 100644 src/client/selectors.ts create mode 100644 src/client/service.ts create mode 100644 src/daemon/application.ts create mode 100644 src/daemon/caddy-logs.ts create mode 100644 src/daemon/start.ts create mode 100644 src/entrypoints/cadder.ts create mode 100644 src/entrypoints/cadderd.ts create mode 100644 src/entrypoints/caddy.ts create mode 100644 src/platform/inspection.ts create mode 100644 src/protocol/domain.ts create mode 100644 src/shim/run.ts create mode 100644 src/storage/database.ts create mode 100644 src/storage/redaction.ts create mode 100644 src/storage/schema.ts create mode 100644 src/storage/storage.ts create mode 100644 src/storage/worker.ts create mode 100644 src/tui/app.tsx create mode 100644 test/application-storage.test.ts create mode 100644 test/caddy-log-ingestion.test.ts create mode 100644 test/client-service.test.ts create mode 100644 test/daemon-application.test.ts create mode 100644 test/native-inspection.test.ts create mode 100644 test/operator-cli.test.ts create mode 100644 test/product-daemon.test.ts create mode 100644 test/routes-tui.test.tsx create mode 100644 test/shim.test.ts diff --git a/.github/workflows/node-runtime.yml b/.github/workflows/node-runtime.yml index f609a38..a5e9ca7 100644 --- a/.github/workflows/node-runtime.yml +++ b/.github/workflows/node-runtime.yml @@ -17,11 +17,11 @@ jobs: runtime: name: Node runtime (${{ matrix.os }}) runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 30 strategy: fail-fast: false matrix: - os: [windows-latest, ubuntu-latest, macos-latest] + os: [windows-latest, ubuntu-latest, macos-15, macos-15-intel] steps: - uses: actions/checkout@v6.0.3 - uses: actions/setup-node@v7.0.0 @@ -53,6 +53,37 @@ jobs: - name: Prepare interactive Windows gate without launching Sandbox if: runner.os == 'Windows' run: node scripts/prepare-windows-runtime-sandbox.ts + - name: Prepare the separate, checksum-pinned upstream Caddy fixture + id: upstream + shell: bash + run: | + path="$(node scripts/prepare-caddy-gate.ts)" + echo "path=$path" >> "$GITHUB_OUTPUT" + - name: Allow the separate Linux fixture to bind web ports + if: runner.os == 'Linux' + run: sudo setcap cap_net_bind_service=+ep "${{ steps.upstream.outputs.path }}" + - name: Verify packed npm and the shared real-Caddy catalog on the Node minimum + run: nub run verify:node "${{ steps.upstream.outputs.path }}" + - name: Select the independently pinned SEA builder + uses: actions/setup-node@v7.0.0 + with: + node-version: 26.12.0 + - name: Build native SEA artifacts without publishing + id: sea + shell: bash + run: | + node scripts/pack-sea.ts > "$RUNNER_TEMP/cadder-sea.json" + node --input-type=module -e 'import fs from "node:fs"; import path from "node:path"; const report = JSON.parse(fs.readFileSync(path.join(process.env.RUNNER_TEMP, "cadder-sea.json"), "utf8")); fs.appendFileSync(process.env.GITHUB_OUTPUT, "archive=" + report.archive + "\nstage=" + report.stage + "\n");' + - name: Verify extracted SEA without Node/npm on product PATH + run: node scripts/verify-sea.ts "${{ steps.sea.outputs.archive }}" + - name: Verify SEA through the same real-Caddy catalog + run: node scripts/verify-product-journey.ts "${{ steps.sea.outputs.stage }}" "${{ steps.upstream.outputs.path }}" + - name: Retain exact native candidate artifacts + uses: actions/upload-artifact@v7.0.1 + with: + name: cadder-node-candidate-${{ matrix.os }} + path: ${{ steps.sea.outputs.archive }} + if-no-files-found: error - name: Verify no tracked build output run: git diff --exit-code - name: Upload coverage diff --git a/docs/site/README.md b/docs/site/README.md index 89777bf..d02dfec 100644 --- a/docs/site/README.md +++ b/docs/site/README.md @@ -14,14 +14,14 @@ mise run docs-build When working directly in this package, run commands from `docs/site`. ```sh -npm ci -npm run dev -npm run check -npm run build -npm run preview +nub ci +nub run dev +nub run check +nub run build +nub run preview ``` -`npm run build` writes generated output to `docs/site/dist`. Do not commit generated output, `.astro`, cache directories, or preview artifacts. +`nub run build` writes generated output to `docs/site/dist`. Do not commit generated output, `.astro`, cache directories, or preview artifacts. ## Content source @@ -35,6 +35,6 @@ The repository CI validates documentation from source on `master` through `mise mise run docs-check ``` -Release or publishing workflows may additionally run `mise run docs-build` and upload `docs/site/dist`, but they should not write generated site output back to the repository. +The documentation workflow runs `mise run docs-build` and uploads `docs/site/dist`. Production documentation is published below `https://maxie.dev/cadder/`. diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index b651010..4628453 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -65,6 +65,7 @@ export default defineConfig({ label: 'Reference', items: [ { label: 'Releases', slug: 'reference/releases' }, + { label: 'Try the 2.0 candidate', slug: 'reference/node-candidate' }, { label: 'Runtime configuration', slug: 'reference/runtime-configuration' }, { label: 'Real Caddy resolution', slug: 'reference/real-caddy-resolution' }, ], diff --git a/docs/site/nub.lock b/docs/site/nub.lock new file mode 100644 index 0000000..8391617 --- /dev/null +++ b/docs/site/nub.lock @@ -0,0 +1,4002 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@astrojs/check': + specifier: 0.9.10 + version: 0.9.10(typescript@6.0.3) + '@astrojs/starlight': + specifier: 0.42.1 + version: 0.42.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2)(typescript@6.0.3) + '@fontsource-variable/fira-code': + specifier: 5.3.0 + version: 5.3.0 + '@fontsource-variable/space-grotesk': + specifier: 5.3.0 + version: 5.3.0 + '@fortawesome/free-solid-svg-icons': + specifier: 7.3.1 + version: 7.3.1 + astro: + specifier: 7.3.2 + version: 7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2) + astro-expressive-code: + specifier: 0.44.2 + version: 0.44.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2) + sharp: + specifier: 0.35.4 + version: 0.35.4(@types/node@25.9.2) + simple-icons: + specifier: 16.31.0 + version: 16.31.0 + typescript: + specifier: 6.0.3 + version: 6.0.3 + +packages: + + '@astrojs/check@0.9.10': + resolution: {integrity: sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + + '@astrojs/compiler-binding-android-arm64@0.4.1': + resolution: {integrity: sha512-Wtx2DZORuNTLASLLZ8hlrjFaJe1LUloatcCn9EPq7knBhRpjMGXeJANIJMeBZG4igvz7x+3nyAJM2iwCSW9xog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@astrojs/compiler-binding-darwin-arm64@0.4.1': + resolution: {integrity: sha512-7iwXcU+hB60Y1SvOfnWHNioT72gDsDQKaDipXh2Onwp/TvQMozhbvhy13wJR8Af940a0h6PbddCeJFpj791UDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@astrojs/compiler-binding-darwin-x64@0.4.1': + resolution: {integrity: sha512-pifjICos49RXhSVKSDu5gDB0/8j0O7DmxSp1DaWW2PyLG5ekwSkcrfOZto5JqhdI7QYMZB3XOiXpsdOXpS4npA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@astrojs/compiler-binding-linux-arm64-gnu@0.4.1': + resolution: {integrity: sha512-hsZMlNWc6LqWHh9LlSwTq1/XCY7pxA5rkkKIdZRV8mgbOz9OwLvjEoe5Jry13eIZjMUQO8XnQyzk8o/Pjw2YFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-arm64-musl@0.4.1': + resolution: {integrity: sha512-a3EW27f+Z9uf57vYmpLgUriA1y9wDhKz57PT8D8XlFZt2HZEWhUxKuxI1jvKVI9p8okYee11WXEm/FHY1G9yoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-linux-x64-gnu@0.4.1': + resolution: {integrity: sha512-XruPTKB/SS4DFzeAjQ1ZyM0ieHDjapHkOSnnzRApZ4FXY2YAI7UY3mETjfJiIS/acyZyrnPH5jObv1gATTdUJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-x64-musl@0.4.1': + resolution: {integrity: sha512-E8j7lGanqNpudtkUbsFaOLJhmwFe4Ecqux7K9hkj4wULIPNZRM2bYZNcMSj8r1oWvaUiwZl8LaaNwv3+14jqhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-wasm32-wasi@0.4.1': + resolution: {integrity: sha512-vNLxIv41ZE+nmPFtw24cRW5ZXG+ZPcAGtgigtjCP8lvcxKI7u2GrXoMsCgEH5fMw8WCEJpwLvsVBg15rJKAkyA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@astrojs/compiler-binding-win32-arm64-msvc@0.4.1': + resolution: {integrity: sha512-/xTDgRsFDoTh0/+KFXJ74DY4Jqn8CoSHwa8UP5RAblHfnRHkenwfSl4zYDET61O9vXKLk09UzHjjq09yP20/AQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@astrojs/compiler-binding-win32-x64-msvc@0.4.1': + resolution: {integrity: sha512-0x5J6iHZO0Fjo/CVzoIbXKzWnGESftDbEUQ/oXQ61HhUAFoR7+T1jfToZSNAS+gYyBXHSTpMWYC7lzarbhClcg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@astrojs/compiler-binding@0.4.1': + resolution: {integrity: sha512-ZYVDW1P58OXyxqZbOMp+/2U9KA7y2esIx9r4pWdpZ/l6fCZhaw4ul+D7EgpRVxYLkyTWqzANbTTUFGPFP8zDHA==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/compiler-rs@0.4.1': + resolution: {integrity: sha512-//NmAuRhy7eU9iP0ceI5Pymy55M+nUZ//kS6XeOyhBWdDib2KbJOyFaVJ52k7sDkJwTKVox7oPu3SpYiVnGm5A==} + engines: {node: '>=22.12.0'} + + '@astrojs/compiler@2.13.1': {} + + '@astrojs/internal-helpers@0.11.0': + resolution: {integrity: sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA==} + + '@astrojs/language-server@2.16.16': + resolution: {integrity: sha512-KuS17AOOqH/M5mHXPmKvtp8L+NNteARC7Xjf395+9R9dtJOBndqdStwU4V+OKzYZpgZ+hliV13knzx/oMtFl7Q==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + + '@astrojs/markdown-satteri@0.4.1': + resolution: {integrity: sha512-EniHbFNa6SQHDih7JnpPos3NWXO6hdt4raBcOosfGmlfc3gP9CI/sESA3VOf1PgJZ7SFfewlZW9Livn0JugEZg==} + + '@astrojs/mdx@8.0.1': + resolution: {integrity: sha512-VKodp/f3+XE6L0950K+ZOAolY47vdt0R78Ao+L2RdJ1hfAdYffL7IEjc/MsoyuS7CPVvCsd5/I3RP0ZyAIewdQ==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + '@astrojs/markdown-satteri': ^0.4.0 + astro: ^7.2.6 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + '@astrojs/prism@4.0.2': + resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} + engines: {node: '>=22.12.0'} + + '@astrojs/sitemap@3.7.3': {} + + '@astrojs/starlight@0.42.1': + resolution: {integrity: sha512-ABS/A0IfbJeMbuhCKVkrAcO3EWxhpeLveFuL28+7+BQje7ZHa9OXZbUoiLXdYUhH7XFaiAQjAg44RiF2uuZPJw==} + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + astro: ^7.2.10 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + '@astrojs/telemetry@3.3.3': + resolution: {integrity: sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/yaml2ts@0.2.4': {} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bruits/satteri-darwin-arm64@0.10.5': + resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} + cpu: [arm64] + os: [darwin] + + '@bruits/satteri-darwin-x64@0.10.5': + resolution: {integrity: sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==} + cpu: [x64] + os: [darwin] + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + resolution: {integrity: sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-arm64-musl@0.10.5': + resolution: {integrity: sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@bruits/satteri-linux-x64-gnu@0.10.5': + resolution: {integrity: sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-x64-musl@0.10.5': + resolution: {integrity: sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@bruits/satteri-wasm32-wasi@0.10.5': + resolution: {integrity: sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + resolution: {integrity: sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==} + cpu: [arm64] + os: [win32] + + '@bruits/satteri-win32-x64-msvc@0.10.5': + resolution: {integrity: sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==} + cpu: [x64] + os: [win32] + + '@capsizecss/unpack@4.0.1': + resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} + engines: {node: '>=18'} + + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + + '@ctrl/tinycolor@4.2.1': + resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==} + engines: {node: '>=14'} + + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@expressive-code/core@0.44.2': + resolution: {integrity: sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==} + + '@expressive-code/plugin-frames@0.44.2': + resolution: {integrity: sha512-gVLm6TQMNIRWIaM7o+mCtxkGNjLZY5/2faSTLoXeo9wv/Pkj/0QAtZyXPVPrRsPARqiipATNFmfvQFLJudxPHQ==} + + '@expressive-code/plugin-shiki@0.44.2': + resolution: {integrity: sha512-ebWIvCDzXvjyD8rH6T6DfytYJw1GoArLR4nusIAE4VxXrCQf3d+owytiv010KU/dUg+SRLZskDn0N+HaFXeuSg==} + + '@expressive-code/plugin-text-markers@0.44.2': + resolution: {integrity: sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==} + + '@fontsource-variable/fira-code@5.3.0': {} + + '@fontsource-variable/space-grotesk@5.3.0': {} + + '@fortawesome/fontawesome-common-types@7.3.1': + engines: {node: '>=6'} + + '@fortawesome/free-solid-svg-icons@7.3.1': + engines: {node: '>=6'} + + '@img/colour@1.1.0': + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oxc-project/types@0.149.0': + resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + + '@pagefind/darwin-arm64@1.5.2': + resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.5.2': + resolution: {integrity: sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==} + cpu: [x64] + os: [darwin] + + '@pagefind/default-ui@1.5.2': {} + + '@pagefind/freebsd-x64@1.5.2': + resolution: {integrity: sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==} + cpu: [x64] + os: [freebsd] + + '@pagefind/linux-arm64@1.5.2': + resolution: {integrity: sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.5.2': + resolution: {integrity: sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-arm64@1.5.2': + resolution: {integrity: sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==} + cpu: [arm64] + os: [win32] + + '@pagefind/windows-x64@1.5.2': + cpu: [x64] + os: [win32] + + '@rolldown/binding-android-arm-eabi@1.2.8': + resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.8': + resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.8': + resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.8': + resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.8': + resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.8': + resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.8': + resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.8': + resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.8': + resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.8': + resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@shikijs/core@4.4.3': + resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.4.3': + resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.4.3': + resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} + engines: {node: '>=20'} + + '@shikijs/langs@4.4.3': + resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.4.3': + resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} + engines: {node: '>=20'} + + '@shikijs/themes@4.4.3': + resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} + engines: {node: '>=20'} + + '@shikijs/types@4.4.3': + resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + + '@types/debug@4.1.13': {} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/js-yaml@4.0.9': {} + + '@types/mdast@4.0.4': {} + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@24.13.1': {} + + '@types/node@25.9.2': {} + + '@types/sax@1.2.7': {} + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@volar/kit@2.4.28': + peerDependencies: + typescript: '*' + + '@volar/language-core@2.4.28': {} + + '@volar/language-server@2.4.28': {} + + '@volar/language-service@2.4.28': {} + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': {} + + '@vscode/emmet-helper@2.11.0': + resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + am-i-vibing@0.4.0: + resolution: {integrity: sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==} + hasBin: true + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: {} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + astro-expressive-code@0.44.2: + resolution: {integrity: sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg==} + peerDependencies: + astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0 + + astro@7.3.2: + resolution: {integrity: sha512-ysTcdpGP61XZpHoMRYC/CK19DQ94qkBvzXMtXEo0gEqPNkmOU/Tv++CtWmzaI4nF2Ck8RXFdiWvdlTWa2oOr9w==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: {} + + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + + bcp-47@2.1.0: {} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@4.0.3: + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + + css-selector-parser@3.3.0: + resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + debug@4.4.3: + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: {} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + engines: {node: '>=8'} + + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + + devlop@1.1.0: {} + + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + + direction@2.0.1: + resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} + hasBin: true + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expressive-code@0.44.2: + resolution: {integrity: sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==} + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fdir@6.5.0: + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + find-proc@0.1.0: + resolution: {integrity: sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg==} + engines: {node: ^20.19.0 || >=22.12.0} + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-tsconfig@5.0.0-beta.4: + resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + hast-util-embedded@3.0.0: + resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + + hast-util-format@1.1.0: + resolution: {integrity: sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==} + + hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + + hast-util-select@6.0.4: + resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html-whitespace-sensitive-tag-names@3.0.1: + resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + i18next@26.3.1: + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: {} + + is-decimal@2.0.1: {} + + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: + engines: {node: '>=12'} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + + jsonc-parser@3.3.1: {} + + kleur@4.1.5: + engines: {node: '>=6'} + + klona@2.0.6: + engines: {node: '>= 8'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + longest-streak@3.1.0: {} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + magic-string@1.4.1: + resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + mdast-util-directive@3.1.0: {} + + mdast-util-from-markdown@2.0.3: {} + + mdast-util-phrasing@4.1.0: {} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: {} + + mdast-util-to-string@4.0.0: {} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + micromark-core-commonmark@2.0.3: {} + + micromark-extension-directive@4.0.0: {} + + micromark-factory-destination@2.0.1: {} + + micromark-factory-label@2.0.1: {} + + micromark-factory-space@2.0.1: {} + + micromark-factory-title@2.0.1: {} + + micromark-factory-whitespace@2.0.1: {} + + micromark-util-character@2.1.1: {} + + micromark-util-chunked@2.0.1: {} + + micromark-util-classify-character@2.0.1: {} + + micromark-util-combine-extensions@2.0.1: {} + + micromark-util-decode-numeric-character-reference@2.0.2: {} + + micromark-util-decode-string@2.0.1: {} + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: {} + + micromark-util-resolve-all@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: {} + + micromark-util-subtokenize@2.1.0: {} + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: {} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neotraverse@1.0.1: + resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.5: + resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + p-limit@7.3.2: + resolution: {integrity: sha512-Ll0w3fU24vYpXoZmjjZIee6bJQDgG0oAyo1PdmFYI8UDwJJddaHAypxIH9avUu+t+lSsAwKVsb1jDCMIIChliw==} + engines: {node: '>=20'} + + p-queue@9.3.3: + resolution: {integrity: sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==} + engines: {node: '>=20'} + + p-timeout@7.0.2: + resolution: {integrity: sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==} + engines: {node: '>=20'} + + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + + pagefind@1.5.2: + hasBin: true + + parse-entities@4.0.2: {} + + path-browserify@1.0.1: {} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + process-ancestry@0.1.0: + resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} + engines: {node: '>=18.0.0'} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + readdirp@4.1.2: + engines: {node: '>= 14.18.0'} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-expressive-code@0.44.2: + resolution: {integrity: sha512-jvjQpsfWKBquDrEl5YQA3CJybRRIuDOYsui1BJTs5oKDUK8pPkJSSxY8FjxNlIFIdtgrTjC1zuahuY4Cc1j9SQ==} + + remark-directive@4.0.0: {} + + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: {} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + rolldown@1.2.8: + resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + satteri@0.10.5: + resolution: {integrity: sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shiki@4.4.3: + resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} + engines: {node: '>=20'} + + simple-icons@16.31.0: + engines: {node: '>=0.12.18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sitemap@9.0.1: + engines: {node: '>=20.19.5', npm: '>=10.8.2'} + hasBin: true + + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stream-replace-string@2.0.0: {} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + + stringify-entities@4.0.4: {} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} + engines: {node: '>=16'} + hasBin: true + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinyclip@0.1.15: + resolution: {integrity: sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: {} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} + + typescript@6.0.3: + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.7.0: + resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@7.18.2: {} + + undici-types@7.24.6: {} + + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + + unified@11.0.5: {} + + unifont@0.7.5: + resolution: {integrity: sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: {} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: {} + + unist-util-visit-parents@6.0.2: {} + + unist-util-visit@5.1.0: {} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + url-extras@0.1.0: + resolution: {integrity: sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==} + engines: {node: '>=20'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-message@4.0.3: {} + + vfile@6.0.3: {} + + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + volar-service-css@0.0.71: + resolution: {integrity: sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.71: + resolution: {integrity: sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.71: + resolution: {integrity: sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.71: + resolution: {integrity: sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.71: + resolution: {integrity: sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-typescript@0.0.71: + resolution: {integrity: sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-yaml@0.0.71: + resolution: {integrity: sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.10: + resolution: {integrity: sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==} + + vscode-html-languageservice@5.6.2: + resolution: {integrity: sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + + vscode-jsonrpc@8.2.0: + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: {} + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver-types@3.18.3: + resolution: {integrity: sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==} + + vscode-languageserver@9.0.1: + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@3.1.0: {} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml-language-server@1.23.0: + resolution: {integrity: sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==} + hasBin: true + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yaml@2.9.0: + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.1.0: + resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + + zwitch@2.0.4: {} + +snapshots: + + '@astrojs/check@0.9.10(typescript@6.0.3)': + dependencies: + '@astrojs/language-server': 2.16.16(typescript@6.0.3) + chokidar: 4.0.3 + kleur: 4.1.5 + typescript: 6.0.3 + yargs: 18.1.0 + transitivePeerDependencies: + - prettier + - prettier-plugin-astro + + '@astrojs/compiler-binding-android-arm64@0.4.1': + optional: true + + '@astrojs/compiler-binding-darwin-arm64@0.4.1': + optional: true + + '@astrojs/compiler-binding-darwin-x64@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-gnu@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-musl@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-gnu@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-musl@0.4.1': + optional: true + + '@astrojs/compiler-binding-wasm32-wasi@0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + dependencies: + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + optional: true + + '@astrojs/compiler-binding-win32-arm64-msvc@0.4.1': + optional: true + + '@astrojs/compiler-binding-win32-x64-msvc@0.4.1': + optional: true + + '@astrojs/compiler-binding@0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + optionalDependencies: + '@astrojs/compiler-binding-android-arm64': 0.4.1 + '@astrojs/compiler-binding-darwin-arm64': 0.4.1 + '@astrojs/compiler-binding-darwin-x64': 0.4.1 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.4.1 + '@astrojs/compiler-binding-linux-arm64-musl': 0.4.1 + '@astrojs/compiler-binding-linux-x64-gnu': 0.4.1 + '@astrojs/compiler-binding-linux-x64-musl': 0.4.1 + '@astrojs/compiler-binding-wasm32-wasi': 0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/compiler-binding-win32-arm64-msvc': 0.4.1 + '@astrojs/compiler-binding-win32-x64-msvc': 0.4.1 + + '@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + dependencies: + '@astrojs/compiler-binding': 0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + + '@astrojs/compiler@2.13.1': {} + + '@astrojs/internal-helpers@0.11.0': + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + js-yaml: 4.3.2 + picomatch: 4.0.7 + retext-smartypants: 6.2.0 + shiki: 4.4.3 + smol-toml: 1.8.0 + unified: 11.0.5 + + '@astrojs/language-server@2.16.16(typescript@6.0.3)': + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/yaml2ts': 0.2.4 + '@jridgewell/sourcemap-codec': 1.6.0 + '@volar/kit': 2.4.28(typescript@6.0.3) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28 + '@volar/language-service': 2.4.28 + muggle-string: 0.4.1 + tinyglobby: 0.2.17 + volar-service-css: 0.0.71(@volar/language-service@2.4.28) + volar-service-emmet: 0.0.71(@volar/language-service@2.4.28) + volar-service-html: 0.0.71(@volar/language-service@2.4.28) + volar-service-prettier: 0.0.71(@volar/language-service@2.4.28) + volar-service-typescript: 0.0.71(@volar/language-service@2.4.28) + volar-service-typescript-twoslash-queries: 0.0.71(@volar/language-service@2.4.28) + volar-service-yaml: 0.0.71(@volar/language-service@2.4.28) + vscode-html-languageservice: 5.6.2 + vscode-uri: 3.1.0 + transitivePeerDependencies: + - prettier + + '@astrojs/markdown-satteri@0.4.1': + dependencies: + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/prism': 4.0.2 + github-slugger: 2.0.0 + satteri: 0.10.5 + + '@astrojs/mdx@8.0.1(@astrojs/markdown-satteri@0.4.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2)': + dependencies: + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/markdown-satteri': 0.4.1 + astro: 7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2) + es-module-lexer: 2.3.2 + + '@astrojs/prism@4.0.2': + dependencies: + prismjs: 1.30.0 + + '@astrojs/sitemap@3.7.3': + dependencies: + sitemap: 9.0.1 + stream-replace-string: 2.0.0 + zod: 4.6.5 + + '@astrojs/starlight@0.42.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2)(typescript@6.0.3)': + dependencies: + '@astrojs/markdown-satteri': 0.4.1 + '@astrojs/mdx': 8.0.1(@astrojs/markdown-satteri@0.4.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2) + '@astrojs/sitemap': 3.7.3 + '@pagefind/default-ui': 1.5.2 + '@types/hast': 3.0.5 + '@types/js-yaml': 4.0.9 + '@types/mdast': 4.0.4 + astro: 7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2) + astro-expressive-code: 0.44.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2) + bcp-47: 2.1.0 + hast-util-format: 1.1.0 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-string: 3.0.1 + hastscript: 9.0.1 + i18next: 26.3.1(typescript@6.0.3) + js-yaml: 4.3.2 + klona: 2.0.6 + magic-string: 1.4.1 + mdast-util-directive: 3.1.0 + mdast-util-to-markdown: 2.1.2 + mdast-util-to-string: 4.0.0 + pagefind: 1.5.2 + remark-directive: 4.0.0 + satteri: 0.10.5 + ultrahtml: 1.7.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - '@astrojs/markdown-remark' + - supports-color + + '@astrojs/telemetry@3.3.3': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + package-manager-detector: 1.8.0 + + '@astrojs/yaml2ts@0.2.4': + dependencies: + yaml: 2.9.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bruits/satteri-darwin-arm64@0.10.5': + optional: true + + '@bruits/satteri-darwin-x64@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-musl@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-musl@0.10.5': + optional: true + + '@bruits/satteri-wasm32-wasi@0.10.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + optional: true + + '@bruits/satteri-win32-x64-msvc@0.10.5': + optional: true + + '@capsizecss/unpack@4.0.1': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@ctrl/tinycolor@4.2.1': {} + + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@expressive-code/core@0.44.2': + dependencies: + '@ctrl/tinycolor': 4.2.1 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-text: 4.0.2 + hastscript: 9.0.1 + postcss: 8.5.28 + postcss-nested: 6.2.0(postcss@8.5.28) + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + + '@expressive-code/plugin-frames@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + + '@expressive-code/plugin-shiki@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + shiki: 4.4.3 + + '@expressive-code/plugin-text-markers@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + + '@fontsource-variable/fira-code@5.3.0': {} + + '@fontsource-variable/space-grotesk@5.3.0': {} + + '@fortawesome/fontawesome-common-types@7.3.1': {} + + '@fortawesome/free-solid-svg-icons@7.3.1': + dependencies: + '@fortawesome/fontawesome-common-types': 7.3.1 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@oslojs/encoding@1.1.0': {} + + '@oxc-project/types@0.149.0': {} + + '@pagefind/darwin-arm64@1.5.2': + optional: true + + '@pagefind/darwin-x64@1.5.2': + optional: true + + '@pagefind/default-ui@1.5.2': {} + + '@pagefind/freebsd-x64@1.5.2': + optional: true + + '@pagefind/linux-arm64@1.5.2': + optional: true + + '@pagefind/linux-x64@1.5.2': + optional: true + + '@pagefind/windows-arm64@1.5.2': + optional: true + + '@pagefind/windows-x64@1.5.2': + optional: true + + '@rolldown/binding-android-arm-eabi@1.2.8': + optional: true + + '@rolldown/binding-android-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-x64@1.2.8': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.8': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.8': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.8': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.8': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.8': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@shikijs/core@4.4.3': + dependencies: + '@shikijs/primitive': 4.4.3 + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + + '@shikijs/primitive@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/themes@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + + '@shikijs/types@4.4.3': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/js-yaml@4.0.9': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/node@24.13.1': + dependencies: + undici-types: 7.18.2 + + '@types/node@25.9.2': + dependencies: + undici-types: 7.24.6 + + '@types/sax@1.2.7': + dependencies: + '@types/node': 25.9.2 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.4.0': {} + + '@volar/kit@2.4.28(typescript@6.0.3)': + dependencies: + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + typesafe-path: 0.2.2 + typescript: 6.0.3 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/language-server@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-service@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vscode/emmet-helper@2.11.0': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.3 + vscode-uri: 3.1.0 + + '@vscode/l10n@0.0.18': {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + am-i-vibing@0.4.0: + dependencies: + process-ancestry: 0.1.0 + + ansi-regex@6.3.0: {} + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + astro-expressive-code@0.44.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2): + dependencies: + astro: 7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2) + rehype-expressive-code: 0.44.2 + url-extras: 0.1.0 + + astro@7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2): + dependencies: + '@astrojs/compiler-rs': 0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/markdown-satteri': 0.4.1 + '@astrojs/telemetry': 3.3.3 + '@capsizecss/unpack': 4.0.1 + '@clack/prompts': 1.8.1 + '@oslojs/encoding': 1.1.0 + am-i-vibing: 0.4.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 2.0.1 + devalue: 5.9.4 + diff: 9.0.0 + dset: 3.1.4 + es-module-lexer: 2.3.2 + esbuild: 0.28.2 + find-proc: 0.1.0 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.4 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + js-yaml: 4.3.2 + jsonc-parser: 3.3.1 + magic-string: 1.4.1 + magicast: 0.5.5 + mrmime: 2.0.1 + neotraverse: 1.0.1 + obug: 2.2.1 + p-limit: 7.3.2 + p-queue: 9.3.3 + package-manager-detector: 1.8.0 + piccolore: 0.1.3 + picomatch: 4.0.7 + semver: 7.8.5 + shiki: 4.4.3 + smol-toml: 1.8.0 + svgo: 4.1.0 + tinyclip: 0.1.15 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + ultrahtml: 1.7.0 + unifont: 0.7.5 + unstorage: 1.17.5 + vite: 8.3.0(@types/node@25.9.2)(esbuild@0.28.2) + vitefu: 1.1.3(vite@8.3.0(@types/node@25.9.2)(esbuild@0.28.2)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.6.5 + optionalDependencies: + sharp: 0.35.4(@types/node@25.9.2) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - uploadthing + - yaml + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + bcp-47-match@2.0.3: {} + + bcp-47@2.1.0: + dependencies: + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + + boolbase@1.0.0: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + ci-info@4.4.0: {} + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + clsx@2.1.1: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + common-ancestor-path@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie@2.0.1: {} + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-selector-parser@3.3.0: {} + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@7.0.0: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + defu@6.1.7: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devalue@5.9.4: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@9.0.0: {} + + direction@2.0.1: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dset@3.1.4: {} + + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + + emoji-regex@10.6.0: {} + + entities@4.5.0: {} + + es-module-lexer@2.3.2: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + eventemitter3@5.0.4: {} + + expressive-code@0.44.2: + dependencies: + '@expressive-code/core': 0.44.2 + '@expressive-code/plugin-frames': 0.44.2 + '@expressive-code/plugin-shiki': 0.44.2 + '@expressive-code/plugin-text-markers': 0.44.2 + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.8: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.7): + dependencies: + picomatch: 4.0.7 + + find-proc@0.1.0: {} + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-tsconfig@5.0.0-beta.4: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.5 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + hast-util-embedded@3.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-is-element: 3.0.0 + + hast-util-format@1.1.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-minify-whitespace: 1.0.1 + hast-util-phrasing: 3.0.1 + hast-util-whitespace: 3.0.0 + html-whitespace-sensitive-tag-names: 3.0.1 + unist-util-visit-parents: 6.0.2 + + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-body-ok-link@3.0.1: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-minify-whitespace@1.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-phrasing@3.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + + hast-util-select@6.0.4: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + bcp-47-match: 2.0.3 + comma-separated-tokens: 2.0.3 + css-selector-parser: 3.3.0 + devlop: 1.1.0 + direction: 2.0.1 + hast-util-has-property: 3.0.0 + hast-util-to-string: 3.0.1 + hast-util-whitespace: 3.0.0 + nth-check: 2.1.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.5 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + html-whitespace-sensitive-tag-names@3.0.1: {} + + http-cache-semantics@4.2.0: {} + + i18next@26.3.1(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + iron-webcrypto@1.2.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-docker@4.0.0: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + jsonc-parser@2.3.1: {} + + jsonc-parser@3.3.1: {} + + kleur@4.1.5: {} + + klona@2.0.6: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + longest-streak@3.1.0: {} + + lru-cache@11.5.2: {} + + magic-string@1.4.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + mdast-util-directive@3.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + parse-entities: 4.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.19: {} + + neotraverse@1.0.1: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.5: {} + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@2.2.1: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.12: {} + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + p-limit@7.3.2: + dependencies: + yocto-queue: 1.2.2 + + p-queue@9.3.3: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.2 + + p-timeout@7.0.2: {} + + package-manager-detector@1.8.0: {} + + pagefind@1.5.2: + optionalDependencies: + '@pagefind/darwin-arm64': 1.5.2 + '@pagefind/darwin-x64': 1.5.2 + '@pagefind/freebsd-x64': 1.5.2 + '@pagefind/linux-arm64': 1.5.2 + '@pagefind/linux-x64': 1.5.2 + '@pagefind/windows-arm64': 1.5.2 + '@pagefind/windows-x64': 1.5.2 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + path-browserify@1.0.1: {} + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + postcss-nested@6.2.0(postcss@8.5.28): + dependencies: + postcss: 8.5.28 + postcss-selector-parser: 6.1.4 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.6: {} + + prismjs@1.30.0: {} + + process-ancestry@0.1.0: {} + + property-information@7.2.0: {} + + radix3@1.1.2: {} + + readdirp@4.1.2: {} + + readdirp@5.1.1: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-expressive-code@0.44.2: + dependencies: + expressive-code: 0.44.2 + + remark-directive@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.1.0 + micromark-extension-directive: 4.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + request-light@0.5.8: {} + + request-light@0.7.0: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + rolldown@1.2.8: + dependencies: + '@oxc-project/types': 0.149.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.8 + '@rolldown/binding-android-arm64': 1.2.8 + '@rolldown/binding-darwin-arm64': 1.2.8 + '@rolldown/binding-darwin-x64': 1.2.8 + '@rolldown/binding-freebsd-x64': 1.2.8 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 + '@rolldown/binding-linux-arm64-gnu': 1.2.8 + '@rolldown/binding-linux-arm64-musl': 1.2.8 + '@rolldown/binding-linux-ppc64-gnu': 1.2.8 + '@rolldown/binding-linux-s390x-gnu': 1.2.8 + '@rolldown/binding-linux-x64-gnu': 1.2.8 + '@rolldown/binding-linux-x64-musl': 1.2.8 + '@rolldown/binding-openharmony-arm64': 1.2.8 + '@rolldown/binding-win32-arm64-msvc': 1.2.8 + '@rolldown/binding-win32-x64-msvc': 1.2.8 + + satteri@0.10.5: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + optionalDependencies: + '@bruits/satteri-darwin-arm64': 0.10.5 + '@bruits/satteri-darwin-x64': 0.10.5 + '@bruits/satteri-linux-arm64-gnu': 0.10.5 + '@bruits/satteri-linux-arm64-musl': 0.10.5 + '@bruits/satteri-linux-x64-gnu': 0.10.5 + '@bruits/satteri-linux-x64-musl': 0.10.5 + '@bruits/satteri-wasm32-wasi': 0.10.5 + '@bruits/satteri-win32-arm64-msvc': 0.10.5 + '@bruits/satteri-win32-x64-msvc': 0.10.5 + + sax@1.6.1: {} + + semver@7.8.5: {} + + sharp@0.35.4(@types/node@25.9.2): + dependencies: + '@img/colour': 1.1.0 + '@types/node': 25.9.2 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + + shiki@4.4.3: + dependencies: + '@shikijs/core': 4.4.3 + '@shikijs/engine-javascript': 4.4.3 + '@shikijs/engine-oniguruma': 4.4.3 + '@shikijs/langs': 4.4.3 + '@shikijs/themes': 4.4.3 + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + simple-icons@16.31.0: {} + + sisteransi@1.0.5: {} + + sitemap@9.0.1: + dependencies: + '@types/node': 24.13.1 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.6.1 + + smol-toml@1.8.0: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + stream-replace-string@2.0.0: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + svgo@4.1.0: + dependencies: + commander: 11.1.0 + css-select: 6.0.0 + css-tree: 3.2.1 + css-what: 7.0.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.1 + + tiny-inflate@1.0.3: {} + + tinyclip@0.1.15: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: + optional: true + + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + dependencies: + semver: 7.8.5 + + typescript@6.0.3: {} + + ufo@1.6.4: {} + + ultrahtml@1.7.0: {} + + uncrypto@0.1.3: {} + + undici-types@7.18.2: {} + + undici-types@7.24.6: {} + + undici@8.10.2: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.5: + dependencies: + css-tree: 3.2.1 + ohash: 2.0.12 + undici: 8.10.2 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.2 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + url-extras@0.1.0: {} + + util-deprecate@1.0.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.3.0(@types/node@25.9.2)(esbuild@0.28.2): + dependencies: + '@types/node': 25.9.2 + esbuild: 0.28.2 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.8 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitefu@1.1.3(vite@8.3.0(@types/node@25.9.2)(esbuild@0.28.2)): + dependencies: + vite: 8.3.0(@types/node@25.9.2)(esbuild@0.28.2) + + volar-service-css@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@volar/language-service': 2.4.28 + vscode-css-languageservice: 6.3.10 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + volar-service-emmet@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@volar/language-service': 2.4.28 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.1.0 + + volar-service-html@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@volar/language-service': 2.4.28 + vscode-html-languageservice: 5.6.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + volar-service-prettier@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@volar/language-service': 2.4.28 + vscode-uri: 3.1.0 + + volar-service-typescript-twoslash-queries@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@volar/language-service': 2.4.28 + vscode-uri: 3.1.0 + + volar-service-typescript@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@volar/language-service': 2.4.28 + path-browserify: 1.0.1 + semver: 7.8.5 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.12 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + + volar-service-yaml@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@volar/language-service': 2.4.28 + vscode-uri: 3.1.0 + yaml-language-server: 1.23.0 + + vscode-css-languageservice@6.3.10: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-html-languageservice@5.6.2: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.3 + vscode-uri: 3.1.0 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.3 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver-types@3.18.3: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@3.1.0: {} + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + xxhash-wasm@1.1.0: {} + + y18n@5.0.8: {} + + yaml-language-server@1.23.0: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + prettier: 3.9.6 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.3 + vscode-uri: 3.1.0 + yaml: 2.8.3 + + yaml@2.8.3: {} + + yaml@2.9.0: {} + + yargs-parser@22.0.0: {} + + yargs@18.1.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.2.2 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yocto-queue@1.2.2: {} + + zod@4.6.5: {} + + zwitch@2.0.4: {} diff --git a/docs/site/package-lock.json b/docs/site/package-lock.json deleted file mode 100644 index 5b5dd49..0000000 --- a/docs/site/package-lock.json +++ /dev/null @@ -1,6773 +0,0 @@ -{ - "name": "cadder-docs", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "cadder-docs", - "devDependencies": { - "@astrojs/check": "0.9.10", - "@astrojs/starlight": "0.42.1", - "@fontsource-variable/fira-code": "5.3.0", - "@fontsource-variable/space-grotesk": "5.3.0", - "@fortawesome/free-solid-svg-icons": "7.3.1", - "astro": "7.3.2", - "sharp": "0.35.4", - "simple-icons": "16.31.0", - "typescript": "6.0.3" - } - }, - "node_modules/@astrojs/check": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.10.tgz", - "integrity": "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/language-server": "^2.16.7", - "chokidar": "^4.0.3", - "kleur": "^4.1.5", - "yargs": "^18.0.0" - }, - "bin": { - "astro-check": "bin/astro-check.js" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^6.0.0" - } - }, - "node_modules/@astrojs/compiler-binding": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.4.1.tgz", - "integrity": "sha512-ZYVDW1P58OXyxqZbOMp+/2U9KA7y2esIx9r4pWdpZ/l6fCZhaw4ul+D7EgpRVxYLkyTWqzANbTTUFGPFP8zDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@astrojs/compiler-binding-android-arm64": "0.4.1", - "@astrojs/compiler-binding-darwin-arm64": "0.4.1", - "@astrojs/compiler-binding-darwin-x64": "0.4.1", - "@astrojs/compiler-binding-linux-arm64-gnu": "0.4.1", - "@astrojs/compiler-binding-linux-arm64-musl": "0.4.1", - "@astrojs/compiler-binding-linux-x64-gnu": "0.4.1", - "@astrojs/compiler-binding-linux-x64-musl": "0.4.1", - "@astrojs/compiler-binding-wasm32-wasi": "0.4.1", - "@astrojs/compiler-binding-win32-arm64-msvc": "0.4.1", - "@astrojs/compiler-binding-win32-x64-msvc": "0.4.1" - } - }, - "node_modules/@astrojs/compiler-binding-android-arm64": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-android-arm64/-/compiler-binding-android-arm64-0.4.1.tgz", - "integrity": "sha512-Wtx2DZORuNTLASLLZ8hlrjFaJe1LUloatcCn9EPq7knBhRpjMGXeJANIJMeBZG4igvz7x+3nyAJM2iwCSW9xog==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-darwin-arm64": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.4.1.tgz", - "integrity": "sha512-7iwXcU+hB60Y1SvOfnWHNioT72gDsDQKaDipXh2Onwp/TvQMozhbvhy13wJR8Af940a0h6PbddCeJFpj791UDA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-darwin-x64": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.4.1.tgz", - "integrity": "sha512-pifjICos49RXhSVKSDu5gDB0/8j0O7DmxSp1DaWW2PyLG5ekwSkcrfOZto5JqhdI7QYMZB3XOiXpsdOXpS4npA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.4.1.tgz", - "integrity": "sha512-hsZMlNWc6LqWHh9LlSwTq1/XCY7pxA5rkkKIdZRV8mgbOz9OwLvjEoe5Jry13eIZjMUQO8XnQyzk8o/Pjw2YFA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.4.1.tgz", - "integrity": "sha512-a3EW27f+Z9uf57vYmpLgUriA1y9wDhKz57PT8D8XlFZt2HZEWhUxKuxI1jvKVI9p8okYee11WXEm/FHY1G9yoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.4.1.tgz", - "integrity": "sha512-XruPTKB/SS4DFzeAjQ1ZyM0ieHDjapHkOSnnzRApZ4FXY2YAI7UY3mETjfJiIS/acyZyrnPH5jObv1gATTdUJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-linux-x64-musl": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.4.1.tgz", - "integrity": "sha512-E8j7lGanqNpudtkUbsFaOLJhmwFe4Ecqux7K9hkj4wULIPNZRM2bYZNcMSj8r1oWvaUiwZl8LaaNwv3+14jqhw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-wasm32-wasi": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.4.1.tgz", - "integrity": "sha512-vNLxIv41ZE+nmPFtw24cRW5ZXG+ZPcAGtgigtjCP8lvcxKI7u2GrXoMsCgEH5fMw8WCEJpwLvsVBg15rJKAkyA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.2.4" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.4.1.tgz", - "integrity": "sha512-/xTDgRsFDoTh0/+KFXJ74DY4Jqn8CoSHwa8UP5RAblHfnRHkenwfSl4zYDET61O9vXKLk09UzHjjq09yP20/AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.4.1.tgz", - "integrity": "sha512-0x5J6iHZO0Fjo/CVzoIbXKzWnGESftDbEUQ/oXQ61HhUAFoR7+T1jfToZSNAS+gYyBXHSTpMWYC7lzarbhClcg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-rs": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.4.1.tgz", - "integrity": "sha512-//NmAuRhy7eU9iP0ceI5Pymy55M+nUZ//kS6XeOyhBWdDib2KbJOyFaVJ52k7sDkJwTKVox7oPu3SpYiVnGm5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/compiler-binding": "0.4.1" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@astrojs/internal-helpers": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.11.0.tgz", - "integrity": "sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.4", - "@types/mdast": "^4.0.4", - "js-yaml": "^4.3.0", - "picomatch": "^4.0.4", - "retext-smartypants": "^6.2.0", - "shiki": "^4.0.2", - "smol-toml": "^1.6.0", - "unified": "^11.0.5" - } - }, - "node_modules/@astrojs/language-server": { - "version": "2.16.16", - "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.16.tgz", - "integrity": "sha512-KuS17AOOqH/M5mHXPmKvtp8L+NNteARC7Xjf395+9R9dtJOBndqdStwU4V+OKzYZpgZ+hliV13knzx/oMtFl7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/compiler": "^2.13.1", - "@astrojs/yaml2ts": "^0.2.4", - "@jridgewell/sourcemap-codec": "^1.5.5", - "@volar/kit": "~2.4.28", - "@volar/language-core": "~2.4.28", - "@volar/language-server": "~2.4.28", - "@volar/language-service": "~2.4.28", - "muggle-string": "^0.4.1", - "tinyglobby": "^0.2.16", - "volar-service-css": "0.0.71", - "volar-service-emmet": "0.0.71", - "volar-service-html": "0.0.71", - "volar-service-prettier": "0.0.71", - "volar-service-typescript": "0.0.71", - "volar-service-typescript-twoslash-queries": "0.0.71", - "volar-service-yaml": "0.0.71", - "vscode-html-languageservice": "^5.6.2", - "vscode-uri": "^3.1.0" - }, - "bin": { - "astro-ls": "bin/nodeServer.js" - }, - "peerDependencies": { - "prettier": "^3.0.0", - "prettier-plugin-astro": ">=0.11.0" - }, - "peerDependenciesMeta": { - "prettier": { - "optional": true - }, - "prettier-plugin-astro": { - "optional": true - } - } - }, - "node_modules/@astrojs/language-server/node_modules/@astrojs/compiler": { - "version": "2.13.1", - "dev": true, - "license": "MIT" - }, - "node_modules/@astrojs/markdown-satteri": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.1.tgz", - "integrity": "sha512-EniHbFNa6SQHDih7JnpPos3NWXO6hdt4raBcOosfGmlfc3gP9CI/sESA3VOf1PgJZ7SFfewlZW9Livn0JugEZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/internal-helpers": "0.11.0", - "@astrojs/prism": "4.0.2", - "github-slugger": "^2.0.0", - "satteri": "^0.10.3" - } - }, - "node_modules/@astrojs/mdx": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-8.0.1.tgz", - "integrity": "sha512-VKodp/f3+XE6L0950K+ZOAolY47vdt0R78Ao+L2RdJ1hfAdYffL7IEjc/MsoyuS7CPVvCsd5/I3RP0ZyAIewdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/internal-helpers": "0.11.0", - "@astrojs/markdown-satteri": "0.4.1", - "es-module-lexer": "^2.0.0" - }, - "engines": { - "node": ">=22.12.0" - }, - "peerDependencies": { - "@astrojs/markdown-remark": "^7.3.0", - "@astrojs/markdown-satteri": "^0.4.0", - "astro": "^7.2.6" - }, - "peerDependenciesMeta": { - "@astrojs/markdown-remark": { - "optional": true - } - } - }, - "node_modules/@astrojs/prism": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", - "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "prismjs": "^1.30.0" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@astrojs/sitemap": { - "version": "3.7.3", - "dev": true, - "license": "MIT", - "dependencies": { - "sitemap": "^9.0.0", - "stream-replace-string": "^2.0.0", - "zod": "^4.3.6" - } - }, - "node_modules/@astrojs/starlight": { - "version": "0.42.1", - "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.42.1.tgz", - "integrity": "sha512-ABS/A0IfbJeMbuhCKVkrAcO3EWxhpeLveFuL28+7+BQje7ZHa9OXZbUoiLXdYUhH7XFaiAQjAg44RiF2uuZPJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/markdown-satteri": "^0.4.0", - "@astrojs/mdx": "^8.0.0", - "@astrojs/sitemap": "^3.7.3", - "@pagefind/default-ui": "^1.3.0", - "@types/hast": "^3.0.5", - "@types/js-yaml": "^4.0.9", - "@types/mdast": "^4.0.4", - "astro-expressive-code": "^0.44.0", - "bcp-47": "^2.1.0", - "hast-util-format": "^1.1.0", - "hast-util-select": "^6.0.4", - "hast-util-to-html": "^9.0.5", - "hast-util-to-string": "^3.0.1", - "hastscript": "^9.0.1", - "i18next": "^26.0.7", - "js-yaml": "^4.1.1", - "klona": "^2.0.6", - "magic-string": "^1.2.3", - "mdast-util-directive": "^3.1.0", - "mdast-util-to-markdown": "^2.1.2", - "mdast-util-to-string": "^4.0.0", - "pagefind": "^1.5.2", - "remark-directive": "^4.0.0", - "satteri": "^0.10.3", - "ultrahtml": "^1.6.0", - "unified": "^11.0.5", - "unist-util-visit": "^5.1.0", - "vfile": "^6.0.3" - }, - "peerDependencies": { - "@astrojs/markdown-remark": "^7.3.0", - "astro": "^7.2.10" - }, - "peerDependenciesMeta": { - "@astrojs/markdown-remark": { - "optional": true - } - } - }, - "node_modules/@astrojs/telemetry": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", - "integrity": "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ci-info": "^4.4.0", - "dset": "^3.1.4", - "is-docker": "^4.0.0", - "package-manager-detector": "^1.6.0" - }, - "engines": { - "node": "18.20.8 || ^20.3.0 || >=22.0.0" - } - }, - "node_modules/@astrojs/yaml2ts": { - "version": "0.2.4", - "dev": true, - "license": "MIT", - "dependencies": { - "yaml": "^2.8.3" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bruits/satteri-darwin-arm64": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", - "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@bruits/satteri-darwin-x64": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", - "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@bruits/satteri-linux-arm64-gnu": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", - "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@bruits/satteri-linux-arm64-musl": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", - "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@bruits/satteri-linux-x64-gnu": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", - "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@bruits/satteri-linux-x64-musl": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", - "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@bruits/satteri-wasm32-wasi": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", - "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.2.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@bruits/satteri-win32-arm64-msvc": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", - "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@bruits/satteri-win32-x64-msvc": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", - "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@capsizecss/unpack": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.1.tgz", - "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fontkitten": "^1.0.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@clack/core": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", - "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-wrap-ansi": "^0.2.0", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 20.12.0" - } - }, - "node_modules/@clack/prompts": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", - "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@clack/core": "1.5.1", - "fast-string-width": "^3.0.2", - "fast-wrap-ansi": "^0.2.0", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 20.12.0" - } - }, - "node_modules/@ctrl/tinycolor": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.1.tgz", - "integrity": "sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/@emmetio/abbreviation": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz", - "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@emmetio/scanner": "^1.0.4" - } - }, - "node_modules/@emmetio/css-abbreviation": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz", - "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@emmetio/scanner": "^1.0.4" - } - }, - "node_modules/@emmetio/css-parser": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz", - "integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@emmetio/stream-reader": "^2.2.0", - "@emmetio/stream-reader-utils": "^0.1.0" - } - }, - "node_modules/@emmetio/html-matcher": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz", - "integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@emmetio/scanner": "^1.0.0" - } - }, - "node_modules/@emmetio/scanner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz", - "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@emmetio/stream-reader": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz", - "integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@emmetio/stream-reader-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz", - "integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@emnapi/core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", - "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.3", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", - "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@expressive-code/core": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.2.tgz", - "integrity": "sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ctrl/tinycolor": "^4.0.4", - "hast-util-select": "^6.0.2", - "hast-util-to-html": "^9.0.1", - "hast-util-to-text": "^4.0.1", - "hastscript": "^9.0.0", - "postcss": "^8.4.38", - "postcss-nested": "^6.0.1", - "unist-util-visit": "^5.0.0", - "unist-util-visit-parents": "^6.0.1" - } - }, - "node_modules/@expressive-code/plugin-frames": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.44.2.tgz", - "integrity": "sha512-gVLm6TQMNIRWIaM7o+mCtxkGNjLZY5/2faSTLoXeo9wv/Pkj/0QAtZyXPVPrRsPARqiipATNFmfvQFLJudxPHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.2" - } - }, - "node_modules/@expressive-code/plugin-shiki": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.44.2.tgz", - "integrity": "sha512-ebWIvCDzXvjyD8rH6T6DfytYJw1GoArLR4nusIAE4VxXrCQf3d+owytiv010KU/dUg+SRLZskDn0N+HaFXeuSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.2", - "shiki": "^4.0.2" - } - }, - "node_modules/@expressive-code/plugin-text-markers": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.44.2.tgz", - "integrity": "sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.2" - } - }, - "node_modules/@fontsource-variable/fira-code": { - "version": "5.3.0", - "dev": true, - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource-variable/space-grotesk": { - "version": "5.3.0", - "dev": true, - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fortawesome/fontawesome-common-types": { - "version": "7.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "7.3.1", - "dev": true, - "license": "(CC-BY-4.0 AND MIT)", - "dependencies": { - "@fortawesome/fontawesome-common-types": "7.3.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", - "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" - } - }, - "node_modules/@oslojs/encoding": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", - "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@oxc-project/types": { - "version": "0.149.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", - "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/oxc-project" - } - }, - "node_modules/@pagefind/darwin-arm64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz", - "integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@pagefind/darwin-x64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz", - "integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@pagefind/default-ui": { - "version": "1.5.2", - "dev": true, - "license": "MIT" - }, - "node_modules/@pagefind/freebsd-x64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz", - "integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@pagefind/linux-arm64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz", - "integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@pagefind/linux-x64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz", - "integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@pagefind/windows-arm64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz", - "integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@pagefind/windows-x64": { - "version": "1.5.2", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", - "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", - "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", - "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", - "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", - "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", - "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", - "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", - "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", - "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", - "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", - "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", - "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", - "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", - "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", - "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@shikijs/core": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", - "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/primitive": "4.4.3", - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5", - "hast-util-to-html": "^9.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", - "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.6" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", - "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/langs": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", - "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/primitive": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", - "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/themes": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", - "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/types": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", - "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", - "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/debug": { - "version": "4.1.13", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/hast": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", - "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/nlcst": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", - "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/node": { - "version": "25.9.2", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, - "node_modules/@types/sax": { - "version": "1.2.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "dev": true, - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", - "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@volar/kit": { - "version": "2.4.28", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-service": "2.4.28", - "@volar/typescript": "2.4.28", - "typesafe-path": "^0.2.2", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "typescript": "*" - } - }, - "node_modules/@volar/language-core": { - "version": "2.4.28", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/source-map": "2.4.28" - } - }, - "node_modules/@volar/language-server": { - "version": "2.4.28", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "@volar/language-service": "2.4.28", - "@volar/typescript": "2.4.28", - "path-browserify": "^1.0.1", - "request-light": "^0.7.0", - "vscode-languageserver": "^9.0.1", - "vscode-languageserver-protocol": "^3.17.5", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@volar/language-service": { - "version": "2.4.28", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "vscode-languageserver-protocol": "^3.17.5", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@volar/source-map": { - "version": "2.4.28", - "dev": true, - "license": "MIT" - }, - "node_modules/@volar/typescript": { - "version": "2.4.28", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "path-browserify": "^1.0.1", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@vscode/emmet-helper": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz", - "integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "emmet": "^2.4.3", - "jsonc-parser": "^2.3.0", - "vscode-languageserver-textdocument": "^1.0.1", - "vscode-languageserver-types": "^3.15.1", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@vscode/emmet-helper/node_modules/jsonc-parser": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", - "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vscode/l10n": { - "version": "0.0.18", - "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", - "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-draft-04": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", - "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^8.5.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-i18n": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ajv-i18n/-/ajv-i18n-4.2.0.tgz", - "integrity": "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^8.0.0-beta.0" - } - }, - "node_modules/am-i-vibing": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", - "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "process-ancestry": "^0.1.0" - }, - "bin": { - "am-i-vibing": "dist/cli.mjs" - } - }, - "node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/astro": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/astro/-/astro-7.3.2.tgz", - "integrity": "sha512-ysTcdpGP61XZpHoMRYC/CK19DQ94qkBvzXMtXEo0gEqPNkmOU/Tv++CtWmzaI4nF2Ck8RXFdiWvdlTWa2oOr9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/compiler-rs": "^0.4.0", - "@astrojs/internal-helpers": "0.11.0", - "@astrojs/markdown-satteri": "0.4.1", - "@astrojs/telemetry": "3.3.3", - "@capsizecss/unpack": "^4.0.0", - "@clack/prompts": "^1.1.0", - "@oslojs/encoding": "^1.1.0", - "am-i-vibing": "^0.4.0", - "aria-query": "^5.3.2", - "axobject-query": "^4.1.0", - "ci-info": "^4.4.0", - "clsx": "^2.1.1", - "common-ancestor-path": "^2.0.0", - "cookie": "^2.0.1", - "devalue": "^5.8.1", - "diff": "^9.0.0", - "dset": "^3.1.4", - "es-module-lexer": "^2.0.0", - "esbuild": "^0.28.0", - "find-proc": "0.1.0", - "flattie": "^1.1.1", - "fontace": "~0.4.1", - "get-tsconfig": "5.0.0-beta.4", - "github-slugger": "^2.0.0", - "html-escaper": "3.0.3", - "http-cache-semantics": "^4.2.0", - "js-yaml": "^4.3.0", - "jsonc-parser": "^3.3.1", - "magic-string": "^1.0.0", - "magicast": "^0.5.2", - "mrmime": "^2.0.1", - "neotraverse": "^1.0.1", - "obug": "^2.1.1", - "p-limit": "^7.3.0", - "p-queue": "^9.1.0", - "package-manager-detector": "^1.6.0", - "piccolore": "^0.1.3", - "picomatch": "^4.0.4", - "semver": "^7.7.4", - "shiki": "^4.0.2", - "smol-toml": "^1.6.0", - "svgo": "^4.0.1", - "tinyclip": "^0.1.12", - "tinyexec": "^1.0.4", - "tinyglobby": "^0.2.15", - "ultrahtml": "^1.6.0", - "unifont": "~0.7.5", - "unstorage": "^1.17.5", - "vite": "^8.0.13", - "vitefu": "^1.1.2", - "xxhash-wasm": "^1.1.0", - "yargs-parser": "^22.0.0", - "zod": "^4.5.4" - }, - "bin": { - "astro": "bin/astro.mjs" - }, - "engines": { - "node": ">=22.12.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/astrodotbuild" - }, - "optionalDependencies": { - "sharp": "^0.35.4" - }, - "peerDependencies": { - "@astrojs/markdown-remark": "^7.3.0" - }, - "peerDependenciesMeta": { - "@astrojs/markdown-remark": { - "optional": true - } - } - }, - "node_modules/astro-expressive-code": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.44.2.tgz", - "integrity": "sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "rehype-expressive-code": "^0.44.2", - "url-extras": "^0.1.0" - }, - "peerDependencies": { - "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/bcp-47": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/bcp-47-match": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", - "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/ccount": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/common-ancestor-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", - "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">= 18" - } - }, - "node_modules/cookie": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", - "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cookie-es": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", - "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", - "dev": true, - "license": "MIT" - }, - "node_modules/crossws": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", - "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", - "dev": true, - "license": "MIT", - "dependencies": { - "uncrypto": "^0.1.3" - } - }, - "node_modules/css-select": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", - "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^7.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "nth-check": "^2.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-selector-parser": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", - "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ], - "license": "MIT" - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css-what": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", - "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csso": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", - "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "~2.2.0" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/csso/node_modules/css-tree": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", - "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.0.28", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.28", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/debug": { - "version": "4.4.3", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devalue": { - "version": "5.9.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", - "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", - "dev": true, - "license": "MIT" - }, - "node_modules/devlop": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/diff": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", - "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/direction": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz", - "integrity": "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==", - "dev": true, - "license": "MIT", - "bin": { - "direction": "cli.js" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dset": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", - "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/emmet": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz", - "integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==", - "dev": true, - "license": "MIT", - "workspaces": [ - "./packages/scanner", - "./packages/abbreviation", - "./packages/css-abbreviation", - "./" - ], - "dependencies": { - "@emmetio/abbreviation": "^2.3.3", - "@emmetio/css-abbreviation": "^2.1.8" - } - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/expressive-code": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.2.tgz", - "integrity": "sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.2", - "@expressive-code/plugin-frames": "^0.44.2", - "@expressive-code/plugin-shiki": "^0.44.2", - "@expressive-code/plugin-text-markers": "^0.44.2" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "dev": true, - "license": "MIT" - }, - "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-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" - } - }, - "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", - "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/find-proc": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/find-proc/-/find-proc-0.1.0.tgz", - "integrity": "sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/flattie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", - "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/fontace": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", - "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fontkitten": "^1.0.2" - } - }, - "node_modules/fontkitten": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", - "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tiny-inflate": "^1.0.3" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-tsconfig": { - "version": "5.0.0-beta.4", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", - "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "engines": { - "node": ">=20.20.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/github-slugger": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", - "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", - "dev": true, - "license": "ISC" - }, - "node_modules/h3": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", - "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cookie-es": "^1.2.3", - "crossws": "^0.3.5", - "defu": "^6.1.6", - "destr": "^2.0.5", - "iron-webcrypto": "^1.2.1", - "node-mock-http": "^1.0.4", - "radix3": "^1.1.2", - "ufo": "^1.6.3", - "uncrypto": "^0.1.3" - } - }, - "node_modules/hast-util-embedded": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", - "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-is-element": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-format": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", - "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-embedded": "^3.0.0", - "hast-util-minify-whitespace": "^1.0.0", - "hast-util-phrasing": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "html-whitespace-sensitive-tag-names": "^3.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-has-property": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", - "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-is-body-ok-link": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", - "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-is-element": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", - "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-minify-whitespace": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", - "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-embedded": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-phrasing": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", - "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-embedded": "^3.0.0", - "hast-util-has-property": "^3.0.0", - "hast-util-is-body-ok-link": "^3.0.0", - "hast-util-is-element": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-select": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz", - "integrity": "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "bcp-47-match": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "css-selector-parser": "^3.0.0", - "devlop": "^1.0.0", - "direction": "^2.0.0", - "hast-util-has-property": "^3.0.0", - "hast-util-to-string": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "nth-check": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-string": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", - "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-text": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", - "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "unist-util-find-after": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/html-escaper": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", - "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/html-whitespace-sensitive-tag-names": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", - "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/i18next": { - "version": "26.3.1", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://www.locize.com/i18next" - }, - { - "type": "individual", - "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" - }, - { - "type": "individual", - "url": "https://www.locize.com" - } - ], - "license": "MIT", - "peerDependencies": { - "typescript": "^5 || ^6" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/iron-webcrypto": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", - "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/brc-dd" - } - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-decimal": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-docker": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", - "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "dev": true, - "license": "MIT" - }, - "node_modules/kleur": { - "version": "4.1.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/klona": { - "version": "2.0.6", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", - "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.6.0" - } - }, - "node_modules/magicast": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", - "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "source-map-js": "^1.2.1" - } - }, - "node_modules/mdast-util-directive": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/micromark": { - "version": "4.0.2", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-directive": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "parse-entities": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/muggle-string": { - "version": "0.4.1", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/neotraverse": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", - "integrity": "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/nlcst-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", - "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-mock-http": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz", - "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/obug": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/ofetch": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", - "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "destr": "^2.0.5", - "node-fetch-native": "^1.6.7", - "ufo": "^1.6.1" - } - }, - "node_modules/ohash": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", - "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", - "dev": true, - "license": "MIT" - }, - "node_modules/oniguruma-parser": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", - "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", - "dev": true, - "license": "MIT" - }, - "node_modules/oniguruma-to-es": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", - "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "oniguruma-parser": "^0.12.2", - "regex": "^6.1.0", - "regex-recursion": "^6.0.2" - } - }, - "node_modules/p-limit": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.2.tgz", - "integrity": "sha512-Ll0w3fU24vYpXoZmjjZIee6bJQDgG0oAyo1PdmFYI8UDwJJddaHAypxIH9avUu+t+lSsAwKVsb1jDCMIIChliw==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.2.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-queue": { - "version": "9.3.3", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", - "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.4", - "p-timeout": "^7.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-timeout": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz", - "integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-manager-detector": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", - "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/pagefind": { - "version": "1.5.2", - "dev": true, - "license": "MIT", - "bin": { - "pagefind": "lib/runner/bin.cjs" - }, - "optionalDependencies": { - "@pagefind/darwin-arm64": "1.5.2", - "@pagefind/darwin-x64": "1.5.2", - "@pagefind/freebsd-x64": "1.5.2", - "@pagefind/linux-arm64": "1.5.2", - "@pagefind/linux-x64": "1.5.2", - "@pagefind/windows-arm64": "1.5.2", - "@pagefind/windows-x64": "1.5.2" - } - }, - "node_modules/parse-entities": { - "version": "4.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "dev": true, - "license": "MIT" - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/piccolore": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", - "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", - "dev": true, - "license": "ISC" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "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/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.18", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/process-ancestry": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz", - "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/radix3": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", - "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/readdirp": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "dev": true, - "license": "MIT" - }, - "node_modules/rehype-expressive-code": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.44.2.tgz", - "integrity": "sha512-jvjQpsfWKBquDrEl5YQA3CJybRRIuDOYsui1BJTs5oKDUK8pPkJSSxY8FjxNlIFIdtgrTjC1zuahuY4Cc1j9SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expressive-code": "^0.44.2" - } - }, - "node_modules/remark-directive": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-directive": "^3.0.0", - "micromark-extension-directive": "^4.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/request-light": { - "version": "0.7.0", - "dev": true, - "license": "MIT" - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/retext-smartypants": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", - "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0", - "nlcst-to-string": "^4.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rolldown": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", - "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.149.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.8", - "@rolldown/binding-android-arm64": "1.2.8", - "@rolldown/binding-darwin-arm64": "1.2.8", - "@rolldown/binding-darwin-x64": "1.2.8", - "@rolldown/binding-freebsd-x64": "1.2.8", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", - "@rolldown/binding-linux-arm64-gnu": "1.2.8", - "@rolldown/binding-linux-arm64-musl": "1.2.8", - "@rolldown/binding-linux-ppc64-gnu": "1.2.8", - "@rolldown/binding-linux-s390x-gnu": "1.2.8", - "@rolldown/binding-linux-x64-gnu": "1.2.8", - "@rolldown/binding-linux-x64-musl": "1.2.8", - "@rolldown/binding-openharmony-arm64": "1.2.8", - "@rolldown/binding-win32-arm64-msvc": "1.2.8", - "@rolldown/binding-win32-x64-msvc": "1.2.8" - } - }, - "node_modules/satteri": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", - "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.5", - "@types/hast": "^3.0.5", - "@types/mdast": "^4.0.4", - "@types/unist": "^3.0.3" - }, - "optionalDependencies": { - "@bruits/satteri-darwin-arm64": "0.10.5", - "@bruits/satteri-darwin-x64": "0.10.5", - "@bruits/satteri-linux-arm64-gnu": "0.10.5", - "@bruits/satteri-linux-arm64-musl": "0.10.5", - "@bruits/satteri-linux-x64-gnu": "0.10.5", - "@bruits/satteri-linux-x64-musl": "0.10.5", - "@bruits/satteri-wasm32-wasi": "0.10.5", - "@bruits/satteri-win32-arm64-msvc": "0.10.5", - "@bruits/satteri-win32-x64-msvc": "0.10.5" - } - }, - "node_modules/sax": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", - "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, - "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/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/shiki": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", - "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "4.4.3", - "@shikijs/engine-javascript": "4.4.3", - "@shikijs/engine-oniguruma": "4.4.3", - "@shikijs/langs": "4.4.3", - "@shikijs/themes": "4.4.3", - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/simple-icons": { - "version": "16.31.0", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/simple-icons" - }, - { - "type": "github", - "url": "https://github.com/sponsors/simple-icons" - } - ], - "license": "CC0-1.0", - "engines": { - "node": ">=0.12.18" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/sitemap": { - "version": "9.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "^24.9.2", - "@types/sax": "^1.2.1", - "arg": "^5.0.0", - "sax": "^1.4.1" - }, - "bin": { - "sitemap": "dist/esm/cli.js" - }, - "engines": { - "node": ">=20.19.5", - "npm": ">=10.8.2" - } - }, - "node_modules/sitemap/node_modules/@types/node": { - "version": "24.13.1", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/sitemap/node_modules/@types/node/node_modules/undici-types": { - "version": "7.18.2", - "dev": true, - "license": "MIT" - }, - "node_modules/smol-toml": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", - "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 18" - }, - "funding": { - "url": "https://github.com/sponsors/cyyynthia" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/stream-replace-string": { - "version": "2.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/svgo": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", - "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^11.1.0", - "css-select": "^6.0.0", - "css-tree": "^3.0.1", - "css-what": "^7.0.0", - "csso": "^5.0.5", - "picocolors": "^1.1.1", - "sax": "1.6.1" - }, - "bin": { - "svgo": "bin/svgo.js" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/svgo" - } - }, - "node_modules/tiny-inflate": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", - "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyclip": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", - "integrity": "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^16.14.0 || >= 17.3.0" - } - }, - "node_modules/tinyexec": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", - "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "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/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/typesafe-path": { - "version": "0.2.2", - "dev": true, - "license": "MIT" - }, - "node_modules/typescript": { - "version": "6.0.3", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-auto-import-cache": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz", - "integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.8" - } - }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ultrahtml": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", - "integrity": "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==", - "dev": true, - "license": "MIT" - }, - "node_modules/uncrypto": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", - "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici": { - "version": "8.10.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", - "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/undici-types": { - "version": "7.24.6", - "dev": true, - "license": "MIT" - }, - "node_modules/unified": { - "version": "11.0.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unifont": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.5.tgz", - "integrity": "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.1.0", - "ohash": "^2.0.11", - "undici": "^8.0.0" - } - }, - "node_modules/unist-util-find-after": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", - "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unstorage": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", - "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "^3.1.3", - "chokidar": "^5.0.0", - "destr": "^2.0.5", - "h3": "^1.15.10", - "lru-cache": "^11.2.7", - "node-fetch-native": "^1.6.7", - "ofetch": "^1.5.1", - "ufo": "^1.6.3" - }, - "peerDependencies": { - "@azure/app-configuration": "^1.8.0", - "@azure/cosmos": "^4.2.0", - "@azure/data-tables": "^13.3.0", - "@azure/identity": "^4.6.0", - "@azure/keyvault-secrets": "^4.9.0", - "@azure/storage-blob": "^12.26.0", - "@capacitor/preferences": "^6 || ^7 || ^8", - "@deno/kv": ">=0.9.0", - "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", - "@planetscale/database": "^1.19.0", - "@upstash/redis": "^1.34.3", - "@vercel/blob": ">=0.27.1", - "@vercel/functions": "^2.2.12 || ^3.0.0", - "@vercel/kv": "^1 || ^2 || ^3", - "aws4fetch": "^1.0.20", - "db0": ">=0.2.1", - "idb-keyval": "^6.2.1", - "ioredis": "^5.4.2", - "uploadthing": "^7.4.4" - }, - "peerDependenciesMeta": { - "@azure/app-configuration": { - "optional": true - }, - "@azure/cosmos": { - "optional": true - }, - "@azure/data-tables": { - "optional": true - }, - "@azure/identity": { - "optional": true - }, - "@azure/keyvault-secrets": { - "optional": true - }, - "@azure/storage-blob": { - "optional": true - }, - "@capacitor/preferences": { - "optional": true - }, - "@deno/kv": { - "optional": true - }, - "@netlify/blobs": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/blob": { - "optional": true - }, - "@vercel/functions": { - "optional": true - }, - "@vercel/kv": { - "optional": true - }, - "aws4fetch": { - "optional": true - }, - "db0": { - "optional": true - }, - "idb-keyval": { - "optional": true - }, - "ioredis": { - "optional": true - }, - "uploadthing": { - "optional": true - } - } - }, - "node_modules/unstorage/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/unstorage/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/url-extras": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/url-extras/-/url-extras-0.1.0.tgz", - "integrity": "sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vfile": { - "version": "6.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", - "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.6", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitefu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", - "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/volar-service-css": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.71.tgz", - "integrity": "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-css-languageservice": "^6.3.0", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - } - } - }, - "node_modules/volar-service-emmet": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.71.tgz", - "integrity": "sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@emmetio/css-parser": "^0.4.1", - "@emmetio/html-matcher": "^1.3.0", - "@vscode/emmet-helper": "^2.9.3", - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - } - } - }, - "node_modules/volar-service-html": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.71.tgz", - "integrity": "sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-html-languageservice": "^5.3.0", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - } - } - }, - "node_modules/volar-service-prettier": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.71.tgz", - "integrity": "sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0", - "prettier": "^2.2 || ^3.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - }, - "prettier": { - "optional": true - } - } - }, - "node_modules/volar-service-typescript": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.71.tgz", - "integrity": "sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-browserify": "^1.0.1", - "semver": "^7.6.2", - "typescript-auto-import-cache": "^0.3.5", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-nls": "^5.2.0", - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - } - } - }, - "node_modules/volar-service-typescript-twoslash-queries": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.71.tgz", - "integrity": "sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - } - } - }, - "node_modules/volar-service-yaml": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.71.tgz", - "integrity": "sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-uri": "^3.0.8", - "yaml-language-server": "~1.23.0" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - } - } - }, - "node_modules/vscode-css-languageservice": { - "version": "6.3.10", - "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", - "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vscode/l10n": "^0.0.18", - "vscode-languageserver-textdocument": "^1.0.12", - "vscode-languageserver-types": "3.17.5", - "vscode-uri": "^3.1.0" - } - }, - "node_modules/vscode-css-languageservice/node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "dev": true, - "license": "MIT" - }, - "node_modules/vscode-html-languageservice": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz", - "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vscode/l10n": "^0.0.18", - "vscode-languageserver-textdocument": "^1.0.12", - "vscode-languageserver-types": "^3.17.5", - "vscode-uri": "^3.1.0" - } - }, - "node_modules/vscode-json-languageservice": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz", - "integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "jsonc-parser": "^3.0.0", - "vscode-languageserver-textdocument": "^1.0.1", - "vscode-languageserver-types": "^3.16.0", - "vscode-nls": "^5.0.0", - "vscode-uri": "^3.0.2" - }, - "engines": { - "npm": ">=7.0.0" - } - }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vscode-languageserver": { - "version": "9.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-languageserver-protocol": "3.17.5" - }, - "bin": { - "installServerIntoExtension": "bin/installServerIntoExtension" - } - }, - "node_modules/vscode-languageserver-protocol": { - "version": "3.17.5", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-jsonrpc": "8.2.0", - "vscode-languageserver-types": "3.17.5" - } - }, - "node_modules/vscode-languageserver-protocol/node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "dev": true, - "license": "MIT" - }, - "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.12", - "dev": true, - "license": "MIT" - }, - "node_modules/vscode-languageserver-types": { - "version": "3.18.3", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz", - "integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vscode-nls": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", - "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", - "dev": true, - "license": "MIT" - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xxhash-wasm": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", - "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", - "dev": true, - "license": "MIT" - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yaml": { - "version": "2.9.0", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yaml-language-server": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.23.0.tgz", - "integrity": "sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vscode/l10n": "^0.0.18", - "ajv": "^8.17.1", - "ajv-draft-04": "^1.0.0", - "ajv-i18n": "^4.2.0", - "prettier": "^3.8.1", - "request-light": "^0.5.7", - "vscode-json-languageservice": "4.1.8", - "vscode-languageserver": "^9.0.0", - "vscode-languageserver-textdocument": "^1.0.1", - "vscode-languageserver-types": "^3.16.0", - "vscode-uri": "^3.0.2", - "yaml": "2.8.3" - }, - "bin": { - "yaml-language-server": "bin/yaml-language-server" - } - }, - "node_modules/yaml-language-server/node_modules/request-light": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz", - "integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==", - "dev": true, - "license": "MIT" - }, - "node_modules/yaml-language-server/node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yargs": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", - "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^8.2.1", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/docs/site/package.json b/docs/site/package.json index 8c358a0..932f8f7 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -16,9 +16,17 @@ "@fontsource-variable/space-grotesk": "5.3.0", "@fortawesome/free-solid-svg-icons": "7.3.1", "astro": "7.3.2", + "astro-expressive-code": "0.44.2", "sharp": "0.35.4", "simple-icons": "16.31.0", "typescript": "6.0.3" }, - "packageManager": "npm@11.16.0" + "packageManager": "nub@0.7.5", + "devEngines": { + "packageManager": { + "name": "nub", + "version": "^0.7.5", + "onFail": "ignore" + } + } } diff --git a/docs/site/src/content/docs/reference/node-candidate.mdx b/docs/site/src/content/docs/reference/node-candidate.mdx new file mode 100644 index 0000000..e238d72 --- /dev/null +++ b/docs/site/src/content/docs/reference/node-candidate.mdx @@ -0,0 +1,52 @@ +--- +title: Try the 2.0 candidate +description: Install a supplied Cadder 2.0 release candidate through npm or a standalone archive. +--- + +Cadder 2.0 is an unpublished migration candidate. The current public release remains 1.0.5. Use this page only with a supplied 2.0.0-rc.1 package or archive; availability in npm and GitHub Releases has not been announced. + +Both distributions provide the same three commands: `cadder`, `cadderd`, and the `caddy` shim. Upstream Caddy is a separate prerequisite in both cases. + +## Install the npm package + +Use Node 24 LTS, version 24.18.0 or later within the 24.x series. Install the supplied tarball with npm: + +```sh +npm install --global ./cadder-2.0.0-rc.1.tgz +cadder --version +``` + +The package contains compiled JavaScript. Installation does not download Caddy or a platform binary, compile source, or require a TypeScript loader. + +## Install the standalone archive + +Choose the supplied archive for Windows x64, Linux x64, Intel macOS, or Apple Silicon macOS. Extract it into a user-owned directory, verify its files against `SHA256SUMS`, and add that directory to PATH. Keep all three executables and accompanying files together. + +Standalone executables include their Node runtime and do not require Node or npm on PATH. `BUILD.json` identifies the candidate source and platform; `NODE-LICENSE` and `THIRD-PARTY-NOTICES` contain the bundled software notices. + +## Run a project + +[Configure real Caddy](../../user-guide/cadder-toml/), then run the existing Caddyfile from its project directory: + +```sh +caddy run +``` + +In another terminal: + +```sh +cadder status +cadder projects list +cadder domains list +cadder tui +``` + +In the TUI, Up and Down select a project or domain; Enter or Space toggles it. Press `s` and confirm with `y` to stop the daemon, `r` to restart it, or `q` to quit the TUI. When offline, Enter starts the daemon. Bare `cadder` displays help. + +## Switch from 1.0.5 + +1. Use the old installation's `cadder daemon stop` before changing PATH. +2. Install all three commands from the same 2.0 candidate. Avoid mixing npm and standalone commands in one installation directory. +3. Run `caddy run` again in each project. Live registrations are not restored across the transition. + +The candidate uses separate runtime storage and does not import or delete the Rust database. Keep the previous release available for rollback. Before switching back, stop the candidate with `cadder daemon stop`, restore the previous commands on PATH, and register the projects again. diff --git a/mise.lock b/mise.lock index 207c0f8..50a7e49 100644 --- a/mise.lock +++ b/mise.lock @@ -126,6 +126,10 @@ url = "https://nodejs.org/dist/v24.18.0/node-v24.18.0-win-x64.zip" version = "1.5.0" backend = "npm:@fission-ai/openspec" +[[tools."npm:@nubjs/nub"]] +version = "0.7.5" +backend = "npm:@nubjs/nub" + [[tools.rust]] version = "1.95.0" backend = "core:rust" diff --git a/mise.toml b/mise.toml index a6fb04c..a2e19df 100644 --- a/mise.toml +++ b/mise.toml @@ -6,6 +6,7 @@ node = "24.18.0" "npm:@fission-ai/openspec" = "1.5.0" "cargo:cargo-llvm-cov" = "0.8.7" "aqua:axodotdev/cargo-dist" = "0.33.0" +"npm:@nubjs/nub" = "0.7.5" [tasks.fmt] description = "Check Rust formatting" @@ -19,6 +20,10 @@ run = "cargo clippy --workspace --all-targets -- -D warnings" description = "Run workspace tests" run = "cargo test --workspace" +[tasks.node-check] +description = "Validate the locked Node migration workspace and coverage" +run = ["nub ci", "nub run check"] + [tasks.openspec-check] description = "Validate every OpenSpec artifact strictly" run = [ @@ -30,16 +35,16 @@ run = [ description = "Install and validate the documentation site" dir = "docs/site" run = [ - "npm ci", - "npm run check", + "nub ci", + "nub run check", ] [tasks.docs-build] description = "Build the documentation site" dir = "docs/site" run = [ - "npm ci", - "npm run build", + "nub ci", + "nub run build", ] [tasks.npm-check] @@ -60,6 +65,7 @@ run = [ "cargo fmt --all -- --check", "cargo clippy --workspace --all-targets -- -D warnings", "cargo test --workspace", + { task = "node-check" }, "openspec doctor", "openspec validate --all --strict --no-interactive", { task = "docs-check" }, diff --git a/openspec/changes/migrate-node-project-runtime/.openspec.yaml b/openspec/changes/migrate-node-project-runtime/.openspec.yaml new file mode 100644 index 0000000..60f3b45 --- /dev/null +++ b/openspec/changes/migrate-node-project-runtime/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-20 diff --git a/openspec/changes/migrate-node-project-runtime/design.md b/openspec/changes/migrate-node-project-runtime/design.md new file mode 100644 index 0000000..ffd61f7 --- /dev/null +++ b/openspec/changes/migrate-node-project-runtime/design.md @@ -0,0 +1,75 @@ +## Context and requirements + +The accepted reset roadmap supplies protocol 3 and ConfigurationTransactions. +CaddyRuntime already implements verified apply, idle and owned teardown. This +slice supplies the missing product orchestration against those contracts. + +## Ownership and data flow + +The daemon holds the existing installation lock before opening one SQLite worker. +Worker startup validates owner protection, schema and integrity. A bounded job +mailbox serializes intent/log work; graceful close drains jobs and closes SQLite +before exclusion is released. Existing Rust paths are never inspected. + +Registration uses canonical source paths for stable project identity. The daemon +adapts the source itself and derives domains rather than trusting supplied route +metadata. The live session nonce owns heartbeat/unregister; durable intent holds +only source paths and activation. All registration and activation changes prepare +inside ConfigurationTransactions. Reads expose committed snapshots. Expiration +removes live routes without forgetting user activation choices. + +Existing secure policy assembly handles each route plan using one runtime identity +and admin endpoint. Idle readiness opens protected application storage first; +the first active registration initializes Caddy/PKI lazily. Subsequent mutations +reuse that protected identity. CaddyRuntime and ConfigurationTransactions remain the only +apply/lifecycle queue. Shutdown stops admission, drains transactions, closes owned +Caddy/storage resources, then releases local IPC/exclusion. + +## Decisions and alternatives + +Use node:sqlite and worker_threads, not a new persistence library or job framework. +Store validated stable intent as one JSON row and bounded structured diagnostic +rows. Redact before insertion and sanitize terminal output at presentation. +Keep module/import/error conventions and installed Zod/Vitest tools. + +## Failure and recovery + +Reject unsupported/partial/corrupt schemas without repair. SQLite writes rollback +on failure; pending worker requests fail if the worker exits. The existing queue +restores last-known-good after persistence failure and fences uncertain recovery. +Do not restore session nonces or process ownership from storage. Worker teardown +failure must not release exclusion while owned work remains unsettled. + +Explicit launch resolves the daemon from the physical npm package or SEA sibling, +never PATH. Only its post-launch readiness loop waits through incomplete Windows +ACL publication; every RPC still refuses unsafe paths before reading the secret +or authenticating. Pre-existing unsafe paths fail immediately. Failed readiness +terminates only the newly spawned child. Mutation RPCs allow bounded cold Caddy +preparation (120 seconds); reads remain bounded at 15 seconds. + +## Operator and packaging + +Commander and routes-only Ink use one client service. The managed shim owns its +session nonce, heartbeat and unregister; delegated upstream commands retain raw +streams. Socket inspection and explicit expected-PID termination stay in the +operator, outside daemon-owned process teardown. + +Use tsc for the single compiled npm tarball and esbuild with Node v26.12.0 for +three standalone SEA entries. Embed the same SQLite worker as a SEA asset. +Stage only in system temp, include source identity and checksums, and retain +Node/bundled dependency notices. Reuse installed packages and platform tools. +Documentation moves to exact Nub locks; the existing Astro output directory and +publication workflow stay intact while the Rust release remains the baseline. + +## Verification + +Prefer database and handler behavior tests covering persistence, retention, +redaction, stale ownership, conflicts, toggles and restart over declaration or +source-string assertions. Keep security/fault regressions. Run focused checks +after increments, then the complete Node gate. Native product/distribution checks +remain separate evidence; local fixtures cannot accept unexecuted platforms. + +## Open questions + +None for these accepted contracts. Cross-platform acceptance and distribution +cutover remain governed by the parent roadmap. diff --git a/openspec/changes/migrate-node-project-runtime/proposal.md b/openspec/changes/migrate-node-project-runtime/proposal.md new file mode 100644 index 0000000..3c5cd32 --- /dev/null +++ b/openspec/changes/migrate-node-project-runtime/proposal.md @@ -0,0 +1,46 @@ +## Why + +The Node backend can prepare and apply Caddy configurations, but no product +handler registers projects or persists their intent. Preserve the released +Caddyfile -> managed shim -> daemon journey by joining the existing boundaries. + +## Requirement IDs + +- CADDY-003, CADDY-006: verified transactions and protected administration. +- REG-001 through REG-004: live session ownership and durable intent. +- STO-001 through STO-004: bounded SQLite state/log worker, no old-data import. +- OBS-001 through OBS-004: redacted bounded diagnostic streams. +- IPC-003, RUN-001 through RUN-003: existing RPC and owned lifetime. +- CLI-001, TUI-001 through TUI-004, TOP-002: shared client and current operator journey. +- SHIM-001 through SHIM-003, INSPECT-001 through INSPECT-004: command policy, managed sessions and local inspection. +- DIST-001/002/004/005, QT-001/003/006: compiled npm and native SEA candidates. +- DOC-001 through DOC-004: locked Nub documentation and candidate installation guidance. + +## Scope + +Application storage, registration handlers and daemon integration, followed by +the existing packaged CLI/TUI/shim journey, operator inspection and the two +non-publishing pack processes. Reuse the resolver, composition, +security, IPC and transaction queue. Remove self-testing contract mocks rather +than replacing them with another mock layer. + +## Non-goals + +No new RPC, state migration, feature expansion, publication or premature Rust +removal. Native acceptance remains open until it is executed. + +## Success criteria + +Registration, heartbeat, toggles, expiry and unregister share one transaction +boundary; stale sessions cannot replace a live owner. Restart restores intent +without routes. Rejected changes retain committed state. Diagnostic storage is +redacted, bounded and owner-protected. Focused integration and full checks pass. +Actual local/global npm wrappers and extracted Windows SEA entries work outside +the checkout. Record native evidence by platform and source rather than treating +prepared CI as execution. Full matrix, interactive privilege acceptance and the +Rust cutover remain parent-roadmap gates. + +## Impact + +Node daemon, storage and client/shim entrypoints; English implementation notes. +No additional production dependency or change to accepted contracts. diff --git a/openspec/changes/migrate-node-project-runtime/tasks.md b/openspec/changes/migrate-node-project-runtime/tasks.md new file mode 100644 index 0000000..0a252f1 --- /dev/null +++ b/openspec/changes/migrate-node-project-runtime/tasks.md @@ -0,0 +1,25 @@ +## 1. Durable application state + +- [x] 1.1 Implement bounded worker-owned SQLite state/log storage, exact schema/integrity validation and safe close (STO-001..004; verify real database restart, refusal and worker integration). +- [x] 1.2 Redact before insertion, enforce deterministic retention and ordered canonical-stream queries; keep terminal controls inert (OBS-001..004; verify actual retained/query rows and output). + +## 2. Product integration + +- [x] 2.1 Implement current registration/heartbeat/expiry and activation through the existing transaction queue, retaining durable intent without live lease restoration (REG-001..004, CADDY-003; verify handler journeys, conflicts, stale sessions and restart). +- [x] 2.2 Join protected storage, secure Caddy and IPC in the product daemon with owned shutdown (CADDY-006, IPC-003, RUN-001..003; verify compiled product start/contact/shutdown and failure cleanup). + +## 3. Current operator and shim journey + +- [x] 3.1 Implement shared lifecycle/state client, released Commander grammar and routes Ink controls (IPC-003, CLI-001, TUI-001..004; execute the frozen argv catalog and keyboard interaction tests, plus Windows native terminal smoke). +- [x] 3.2 Implement managed nonce/heartbeat/reconnect/cleanup and trusted delegation (REG-001/003, SHIM-001..003; execute policy/session tests and actual multi-project shim registration in Sandbox). +- [x] 3.3 Implement platform port inspection and expected-PID revalidation (INSPECT-001..004; verify CLI boundaries and actual Windows socket ownership/refusal). Linux/macOS branches require their parent native gates. + +## 4. Non-publishing distributions and documentation + +- [x] 4.1 Pack compiled npm with a consumer-only manifest and three bins (DIST-004, QT-003/006; verify actual local lifecycle and local/global wrappers without a TS loader). +- [x] 4.2 Pin the independently verified Node 26 builder and build SEA entries with the embedded worker, source identity, licenses and checksums (DIST-001/002/005; verify extracted Windows archive without Node/npm on product PATH). The remaining native variants stay in parent G6. +- [x] 4.3 Move docs dependencies/tasks to exact Nub locks and document the unpublished candidate's equal channels and transition (DOC-001..004; run Astro check and build). + +## 5. Verification and simplification + +- [x] 5.1 Replace declaration/self-mock tests with behavior evidence, review changed boundaries, run the final Node check and record exact results and remaining native gates (STO-001, REG-001, IPC-003, QT-003). Keep failed overall validation and unexecuted acceptance open; do not archive or remove Rust. diff --git a/openspec/changes/migrate-node-project-runtime/verification.md b/openspec/changes/migrate-node-project-runtime/verification.md new file mode 100644 index 0000000..f31e0b4 --- /dev/null +++ b/openspec/changes/migrate-node-project-runtime/verification.md @@ -0,0 +1,26 @@ +# Product implementation evidence + +This slice delivers the released Caddyfile -> shim -> daemon -> operator journey +against accepted main requirements. Parent G1-G7 and Rust removal remain open. +The complete dated commands, candidate identities and failed/skipped checks are +recorded in `../reset-cadder-architecture/verification.md` under "Node product +implementation checkpoint (2026-10-20)". This slice is not archive-ready while +the schema-blind OpenSpec gate and final native acceptance remain unresolved. + +| Tasks | Requirements | Executed evidence | +| --- | --- | --- | +| 1.1 | STO-001..004 | Actual SQLite schema creation/refusal, integrity, contention rollback, restart and protected worker tests; packed npm/SEA worker runs | +| 1.2 | OBS-001..004 | SQL row/retention/order assertions, before-write credential redaction, native line ingestion/attribution, overflow and terminal sanitization | +| 2.1 | REG-001..004, CADDY-003 | Existing real transaction queue with handler conflicts, session ownership, toggles, failed persistence, expiry and intent-only restart | +| 2.2 | CADDY-006, IPC-003, RUN-001..003 | Protected product daemon integration, initializer/readiness/teardown ordering and retained exclusion on failed ownership cleanup; native product evidence is qualified in the parent checkpoint | +| 3.1 | CLI-001, IPC-003, TUI-001..004, TOP-002 | Released argv catalog through actual Commander; typed shared client and Ink keyboard behavior; real Windows bare-help/TUI start/stop/quit | +| 3.2 | REG-001/003, SHIM-001..003 | Actual policy/registration/session tests; compiled multi-project managed shim in Windows Sandbox; complete native delegation fidelity remains parent G4 | +| 3.3 | INSPECT-001..004 | CLI boundaries and real host TCP ownership with changed-PID refusal; Linux/macOS branches remain unexecuted locally | +| 4.1 | DIST-004, QT-003/006 | Actual tsc/npm pack allowlist, local lifecycle and three local/global npm wrappers on Node 24.18.0 | +| 4.2 | DIST-001/002/005 | Pinned Node 26.12.0 build, worker asset, licenses and archive inventory/checksums; extracted Windows and supplementary WSL Linux native lifecycle without product Node PATH | +| 4.3 | DOC-001..004 | Exact Nub docs lock/tasks, explicit unpublished candidate instructions, Astro check and 14-page build | +| 5.1 | QT-003, STO-001, REG-001, IPC-003 | Fresh-eyes repair/review, removed redundant mock/declaration tests, 830 passing cases/two skips, 88.74% own-line coverage; failed aggregate and native checks remain recorded | + +No acceptance is inferred from artifact existence or CI preparation. The parent +roadmap retains native TUI/error/cancellation, full channel parity, final account +boundaries, clean-revision acceptance, Rust cutover and release preparation. diff --git a/openspec/changes/reset-cadder-architecture/tasks.md b/openspec/changes/reset-cadder-architecture/tasks.md index 48c41a7..6989161 100644 --- a/openspec/changes/reset-cadder-architecture/tasks.md +++ b/openspec/changes/reset-cadder-architecture/tasks.md @@ -59,27 +59,27 @@ the corrected migration scope. Retain existing task IDs for evidence continuity. ## 5. State, diagnostic logs, shared client and routes CLI/TUI (G5, requires G4) -- [ ] 5.1 Implement the single node:sqlite application-state/diagnostic-log worker with schema creation, integrity validation, bounded jobs and orderly close; test corrupt/newer schemas, rollback and worker errors (STO-001/002/003). -- [ ] 5.2 Persist stable desired state and bounded diagnostic logs without restoring live sessions or introducing history queries; prove the old Rust runtime/database is neither imported nor deleted (STO-004, REG-003, OBS-001/002). -- [ ] 5.3 Port pre-write redaction and deterministic log retention, including supported text/JSON/URL credentials and private keys; test boundaries and terminal-safe presentation (OBS-001/002/004). -- [ ] 5.4 Preserve bounded canonical runtime/project/domain stream queries and correct domain attribution; test 1-200 limits, newest-row selection and ascending order, without source/severity/all-log filters or TUI log views (OBS-003). -- [ ] 5.5 Implement shared client-service methods/models over the eight-operation RPC catalog for current lifecycle, projects, domains, diagnostics and bounded CLI logs; test offline/malformed states without importing daemon storage or adding RPC methods (IPC-003, TOP-002). -- [ ] 5.6 Implement Commander released-command parity and human-readable output/exit contracts against the group 1 catalog; test arguments, snapshots and rejection of removed commands/options from fake client responses (CLI-001). -- [ ] 5.7 Preserve attach-only state/inspection commands and explicit daemon start/restart, managed shim startup, tui --start-daemon and offline-TUI start; test bounded launch behavior and bare help with/without TTY never spawning a daemon (CLI-001, RUN-002). +- [x] 5.1 Implement the single node:sqlite application-state/diagnostic-log worker with schema creation, integrity validation, bounded jobs and orderly close; test corrupt/newer schemas, rollback and worker errors (STO-001/002/003). +- [x] 5.2 Persist stable desired state and bounded diagnostic logs without restoring live sessions or introducing history queries; prove the old Rust runtime/database is neither imported nor deleted (STO-004, REG-003, OBS-001/002). +- [x] 5.3 Port pre-write redaction and deterministic log retention, including supported text/JSON/URL credentials and private keys; test boundaries and terminal-safe presentation (OBS-001/002/004). +- [x] 5.4 Preserve bounded canonical runtime/project/domain stream queries and correct domain attribution; test 1-200 limits, newest-row selection and ascending order, without source/severity/all-log filters or TUI log views (OBS-003). +- [x] 5.5 Implement shared client-service methods/models over the eight-operation RPC catalog for current lifecycle, projects, domains, diagnostics and bounded CLI logs; test offline/malformed states without importing daemon storage or adding RPC methods (IPC-003, TOP-002). +- [x] 5.6 Implement Commander released-command parity and human-readable output/exit contracts against the group 1 catalog; test arguments, snapshots and rejection of removed commands/options from fake client responses (CLI-001). +- [x] 5.7 Preserve attach-only state/inspection commands and explicit daemon start/restart, managed shim startup, tui --start-daemon and offline-TUI start; test bounded launch behavior and bare help with/without TTY never spawning a daemon (CLI-001, RUN-002). - [ ] 5.8 Implement the current routes-first Ink/React TUI through the same client service with real state, tree rows and no separate activity/logs/history/platform views; remove production mocks/random statuses and test Space/Enter toggles, offline Enter-to-start, pending actions and small terminals (TUI-001/002/003/004). - [ ] 5.9 Preserve bare cadder help on TTY/non-TTY and explicit cadder tui with optional --start-daemon; prove terminal restoration on normal exit, error and Ctrl+C with rendering tests plus a real terminal smoke (CLI-001, TUI-004). - [ ] 5.10 Pass an actual Node daemon/shim/operator journey with multiple projects, domain changes, bounded CLI logs and shutdown; record G5 parity against the catalog, not only fixture-runtime success. ## 6. Equal npm/SEA distributions and documentation (G6, requires G5) -- [ ] 6.1 Emit npm JS using tsc import-extension rewriting exclusively into external staging; generate the consumer manifest and three bin entries with matching version/license/repository/assets (DIST-004, QT-003). +- [x] 6.1 Emit npm JS using tsc import-extension rewriting exclusively into external staging; generate the consumer manifest and three bin entries with matching version/license/repository/assets (DIST-004, QT-003). - [ ] 6.2 Pack one npm tarball with the publication packer and verify its file allowlist, no optional platform packages/postinstall/download/build/loader/real Caddy; test local/global installation and PATH recursion outside checkout (QT-006, DIST-004). -- [ ] 6.3 Select, verify and pin the Node 26 SEA builder patch independently of the Node 24.18.0 npm minimum; record source/runtime compatibility and builder inputs (QT-001, DIST-001). -- [ ] 6.4 Bundle the same sources with esbuild and build three SEA entries plus assets outside checkout; prove state/log SQLite workers and owned child launch without a system Node executable (DIST-002). +- [x] 6.3 Select, verify and pin the Node 26 SEA builder patch independently of the Node 24.18.0 npm minimum; record source/runtime compatibility and builder inputs (QT-001, DIST-001). +- [x] 6.4 Bundle the same sources with esbuild and build three SEA entries plus assets outside checkout; prove state/log SQLite workers and owned child launch without a system Node executable (DIST-002). - [ ] 6.5 Build Windows x64, Linux x64, macOS x64 and arm64 archives with checksums/provenance and exact accepted asset sets; reject version/source drift (DIST-001/002/005). - [ ] 6.6 Run native SEA smoke for every variant with Node/npm absent from PATH, covering all entries, actual CLI/TUI, SQLite workers, Caddy adapter children and platform helper launch (DIST-002, QT-006). - [ ] 6.7 Run the same current-scope functional catalog through packed npm and extracted SEA on native runners, including local/global npm wrappers and daemon start/restart; record cross-channel parity (DIST-005). -- [ ] 6.8 Move Astro/Starlight dependencies and tasks to Nub with exact locks; replace Bun/legacy instructions and document both channels equally, including separate Caddy prerequisite and safe transition (DOC-001/002/004). +- [x] 6.8 Move Astro/Starlight dependencies and tasks to Nub with exact locks; replace Bun/legacy instructions and document both channels equally, including separate Caddy prerequisite and safe transition (DOC-001/002/004). - [ ] 6.9 Build/check docs from the supported workflow and verify each public command/example against real product acceptance, without fixture/private/hidden diagnostic seams (DOC-003/004). - [ ] 6.10 Make CI validate the same non-publishing pack process used for publication, with native archive verification and least-privilege/provenance safeguards; record G6 only after both channels pass (QT-004/006/007). diff --git a/openspec/changes/reset-cadder-architecture/verification.md b/openspec/changes/reset-cadder-architecture/verification.md index 4764cad..5ca0d58 100644 --- a/openspec/changes/reset-cadder-architecture/verification.md +++ b/openspec/changes/reset-cadder-architecture/verification.md @@ -744,3 +744,94 @@ changes failing the known missing-delta validator. No fake delta or checker workaround was introduced; focused changes remain unarchived. mTLS, owned server lifecycle, native/distribution and final-product Sandbox proof remain pending. No stage, commit, publication, real Caddy or host privilege/account action occurred. + +## Node product implementation checkpoint (2026-10-20) + +The `migrate-node-project-runtime` slice joins the existing queue, protected local +runtime and Caddy backend to application SQLite storage, the eight RPC handlers, +shared client, Commander CLI, routes Ink TUI and managed/delegated shim. It also +adds non-publishing npm/SEA packers and native candidate CI. These are implemented +capabilities, not G1-G7 acceptance or permission to remove Rust. + +### Executed local checks + +- `nub run check`: 44 files, 830 passing cases and two existing Unix-only skips; + 2436/2745 own-source lines, 88.74%. Types, lints and formatting pass. Coverage + still includes entrypoints, workers and non-host platform branches; their child + execution is separate evidence, not silently excluded from V8's denominator. +- `mise run node-check`: locked Nub reinstall plus the same full check passes. + A prior concurrent run with Windows Sandbox had two 15-second native-test + timeouts. The serial rerun passes without increasing test limits. +- `mise run check`: Rust formatting, Clippy and workspace tests pass, followed by + the known OpenSpec 1.5 missing-delta failure. The run preceded the addition of + its Node-check delegation. Strict main specs pass 15/15; the reset change passes; + the full delta validator reports 17 passing items and seven implementation-only + changes without deltas. Downstream legacy npm/cargo-dist steps were not reached. + No fake deltas or validation-policy bypass were added. +- In `docs/site`, `nub run check` reports zero errors/warnings/hints and + `nub run build` builds 14 pages. Existing empty-i18n/missing-404 build warnings + remain. Nub owns the exact docs lock; Astro's established ignored output and + Pages workflow stay in place during the retained Rust baseline. +- `nub run verify:node`: compiled npm local-install daemon/SQLite/CLI lifecycle, + three local and global bin wrappers, removed-option rejection and clean stop + pass on Node 24.18.0. The consumer has no TS loader, install scripts or optional + native packages. +- `node scripts/verify-sea.ts `: extracted Windows x64 SEA passes all + three versions, exact file/checksum inventory, authenticated IPC, SQLite worker, + logs, restart and stop with only Windows system tools on product PATH. +- Real Windows PTY: bare help succeeds without creating runtime state; routes TUI + opens offline, explicitly starts the daemon, confirms stop, returns offline, + quits successfully and restores the terminal. This does not prove every error, + Ctrl+C, viewport or Unix TTY case. +- Supplementary Fedora 44/WSL2 x64 proof builds native Linux SEA entries from the + same platform-neutral bundles with the checksum-verified Linux Node 26.12.0 + builder. The native no-Node-PATH lifecycle/SQLite/restart probe passes under + Node 24.18.0 as the external test driver. This is not native CI, the complete + Linux pack process, real-Caddy routing or Unix privilege acceptance. + +### Candidate identity and actual Caddy evidence + +The local npm and Windows SEA candidates share base revision +`81c120b1b67d640b2c344abc040943c4cd2b3435`, dirty source/build-input digest +`f298de6361cd2f750eca5562cfff92b0d7e116a64981a7920ee2c7c04334de2d` +and version `2.0.0-rc.1`. They are local candidates, not published releases or a +clean candidate revision. Their SHA-256 values are: + +- npm tarball: `591fbebe5a403f3d818992b335644b253dacc02df5d9989ec940313d9d623a8f`. +- Windows archive: `e2983e3bbcec0bf98f1507ae46606985c3488fa2a127167d157277849b72ee62`. + +A prior Windows Sandbox product candidate with source digest +`c88a3550b1a250e9a7cc0763b246f06851b60288d6d084a25d6d82d67fce80e8` +passed actual daemon/shim/operator registration, simultaneous projects, HTTP and +CA-verified HTTPS, project/domain toggles, conflicts/malformed-update rejection +with last-known-good, heartbeat/crashed-session expiry, unauthenticated admin +denial and owned shutdown, using separate stock Caddy 2.11.4 and no Node on the +product PATH. A repeat against the f298 candidate while the host full suite ran +failed after shim crash with port 80 unavailable. That repeat is a failure, +not a final-candidate pass; serial/native CI diagnosis remains open. + +The startup consumer probe also reproduced a Windows ACL-publication race. +Post-launch readiness now waits for protection to finish without reading an +unsafe secret or authenticating; pre-existing unsafe paths still fail directly. +A failed readiness attempt terminates only its own newly launched child. The +regression and a fresh local/global npm consumer run pass. Log overflow now +recognizes case-insensitive private-key markers; failed worker posting settles +requests and terminates that worker. + +### Test scope and remaining gates + +Removed over 700 lines of declaration/source-string/self-mock checks from the +port and compatibility suites. Retained the frozen behavior data and security, +process-ownership, schema-refusal and rollback regressions. New tests exercise +real SQLite, handlers, CLI grammar, Ink input and host socket ownership. The +total case count grows because the missing product is now implemented; a lower +test count is not an acceptance criterion. + +G4/G5 native fidelity, full shared npm/SEA catalog, macOS variants, final Unix +owner/elevated behavior, interactive Windows same-SID/UAC/cancellation/different- +account checks and one clean-revision G7 ledger remain open. Candidate CI now +runs the shared real-Caddy journey for both distributions with a separately +downloaded checksum-pinned upstream fixture. Configuring that workflow is not +execution evidence. Rust deletion, release-tool cutover and publication remain +gated. The user explicitly authorized commit and push for native CI; this does +not authorize a PR, tag, registry publication or GitHub release. diff --git a/package.json b/package.json index 833c9b9..4bbb712 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,10 @@ "node": ">=24.18.0 <25" }, "scripts": { + "dev": "tsx src/entrypoints/cadder.ts", + "pack:node": "node scripts/pack-node.ts", + "pack:sea": "node scripts/pack-sea.ts", + "verify:node": "node scripts/verify-node.ts", "typecheck": "tsc --noEmit", "test": "vitest run", "coverage": "vitest run --coverage", diff --git a/scripts/licenses/yoga-layout-3.2.1-LICENSE b/scripts/licenses/yoga-layout-3.2.1-LICENSE new file mode 100644 index 0000000..b96dcb0 --- /dev/null +++ b/scripts/licenses/yoga-layout-3.2.1-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Facebook, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/scripts/pack-node.ts b/scripts/pack-node.ts new file mode 100644 index 0000000..233765e --- /dev/null +++ b/scripts/pack-node.ts @@ -0,0 +1,116 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { access, chmod, copyFile, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { sourceIdentity } from './source-identity.ts'; +import { VERSION } from '../src/protocol/version.ts'; + +const execute = promisify(execFile); +export const repositoryRoot = fileURLToPath(new URL('../', import.meta.url)); +export async function npmCli(): Promise { + for (const candidate of [ + join(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), + join(dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js'), + ]) { + try { + await access(candidate); + return candidate; + } catch { + /* Try the other standard Node layout. */ + } + } + throw new Error('The Node installation must include npm to pack consumer artifacts.'); +} + +/** Compile and pack the actual consumer package; all outputs stay outside checkout. */ +export async function packNode(): Promise<{ stage: string; archive: string }> { + const temporary = await mkdtemp(join(tmpdir(), 'cadder-node-pack-')); + const stage = join(temporary, 'package'); + await mkdir(stage); + const manifest = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8')) as { + name: string; + version: string; + description: string; + engines: Record; + dependencies: Record; + }; + const config = join(temporary, 'tsconfig.json'); + if (manifest.version !== VERSION) + throw new Error('Package and protocol release versions must match.'); + await writeFile( + config, + JSON.stringify({ + extends: join(repositoryRoot, 'tsconfig.json'), + include: ['src/**/*.ts', 'src/**/*.tsx'].map((path) => + join(repositoryRoot, path).replaceAll('\\', '/'), + ), + compilerOptions: { + noEmit: false, + rootDir: join(repositoryRoot, 'src'), + outDir: join(stage, 'lib'), + typeRoots: [join(repositoryRoot, 'node_modules/@types')], + declaration: false, + sourceMap: false, + }, + }), + ); + await execute( + process.execPath, + [join(repositoryRoot, 'node_modules/typescript/bin/tsc'), '--project', config], + { cwd: repositoryRoot }, + ); + await writeFile( + join(stage, 'package.json'), + JSON.stringify( + { + name: manifest.name, + version: manifest.version, + description: manifest.description, + engines: manifest.engines, + dependencies: manifest.dependencies, + type: 'module', + license: 'Apache-2.0', + repository: { type: 'git', url: 'git+https://github.com/MrMaxie/cadder.git' }, + cadderBuild: await sourceIdentity(repositoryRoot), + bin: { + cadder: 'lib/entrypoints/cadder.js', + cadderd: 'lib/entrypoints/cadderd.js', + caddy: 'lib/entrypoints/caddy.js', + }, + files: ['lib', 'LICENSE', 'README.md'], + }, + null, + 2, + ) + '\n', + ); + await copyFile(join(repositoryRoot, 'LICENSE'), join(stage, 'LICENSE')); + await writeFile( + join(stage, 'README.md'), + '# Cadder 2.0 release candidate\n\nCoordinate project Caddyfiles through one local daemon. Upstream Caddy is a separate prerequisite. This unpublished migration candidate requires Node 24 LTS >=24.18.0.\n\nRun `cadder --help` for commands, `cadder tui` for the routes workspace, or `caddy run` from a project directory. Stop the previous daemon before switching versions. Existing Rust runtime data is preserved; projects register again from their Caddyfiles.\n', + ); + for (const entry of ['cadder', 'cadderd', 'caddy']) + await chmod(join(stage, 'lib/entrypoints', `${entry}.js`), 0o755); + const packed = await execute( + process.execPath, + [await npmCli(), 'pack', stage, '--pack-destination', temporary, '--json', '--ignore-scripts'], + { cwd: temporary, maxBuffer: 1024 * 1024 }, + ); + type PackResult = { filename: string; files: { path: string }[] }; + const report = JSON.parse(packed.stdout) as PackResult[] | Record; + const result = Array.isArray(report) ? report[0] : report[manifest.name]; + if ( + !result || + result.files.some( + ({ path }) => + !['package.json', 'README.md', 'LICENSE'].includes(path) && + (!path.startsWith('lib/') || !path.endsWith('.js')), + ) + ) + throw new Error('Unexpected npm package contents.'); + return { stage, archive: join(temporary, result.filename) }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) + process.stdout.write(JSON.stringify(await packNode()) + '\n'); diff --git a/scripts/pack-sea.ts b/scripts/pack-sea.ts new file mode 100644 index 0000000..957fbd1 --- /dev/null +++ b/scripts/pack-sea.ts @@ -0,0 +1,176 @@ +import { build } from 'esbuild'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { promisify } from 'node:util'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { sourceIdentity } from './source-identity.ts'; +import { VERSION } from '../src/protocol/version.ts'; + +const execute = promisify(execFile); +// Verified against https://nodejs.org/en/download/archive/v26.12.0 . +export const seaBuilderVersion = 'v26.12.0'; +const builder = process.argv[2] ? resolve(process.argv[2]) : process.execPath; +if ((await execute(builder, ['--version'])).stdout.trim() !== seaBuilderVersion) + throw new Error( + `SEA requires the verified ${seaBuilderVersion} builder. Pass its absolute executable path.`, + ); +const stage = await mkdtemp(join(tmpdir(), 'cadder-sea-')); +const payload = join(stage, 'cadder'); +await mkdir(payload); +const root = fileURLToPath(new URL('../', import.meta.url)); +const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { + version: string; +}; +if (manifest.version !== VERSION) + throw new Error('Package and protocol release versions must match.'); +const banner = + "import { createRequire as cadderCreateRequire } from 'node:module'; const require = cadderCreateRequire(process.execPath);"; +// Ink guards this optional developer tool behind an installed-peer check. +const common = { + bundle: true, + platform: 'node' as const, + format: 'esm' as const, + target: 'node26', + absWorkingDir: root, + metafile: true as const, + external: ['./devtools.js'], + banner: { js: banner }, + define: { 'process.env.NODE_ENV': '"production"' }, +}; +const worker = join(stage, 'storage-worker.mjs'); +const workerBuild = await build({ + ...common, + entryPoints: [join(root, 'src/storage/worker.ts')], + outfile: worker, +}); +const inputs = new Set(Object.keys(workerBuild.metafile.inputs)); +const artifacts: string[] = []; +for (const entry of ['cadder', 'cadderd', 'caddy']) { + const bundle = join(stage, `${entry}.mjs`); + const bundled = await build({ + ...common, + entryPoints: [join(root, `src/entrypoints/${entry}.ts`)], + outfile: bundle, + }); + for (const input of Object.keys(bundled.metafile.inputs)) inputs.add(input); + const output = join(payload, process.platform === 'win32' ? `${entry}.exe` : entry); + const config = join(stage, `${entry}-sea.json`); + await writeFile( + config, + JSON.stringify({ + main: bundle, + mainFormat: 'module', + output, + disableExperimentalSEAWarning: true, + useCodeCache: false, + useSnapshot: false, + execArgvExtension: 'none', + assets: { 'cadder-storage-worker': worker }, + }), + ); + await execute(builder, ['--build-sea', config], { timeout: 120000, maxBuffer: 1024 * 1024 }); + if (process.platform === 'darwin') await execute('/usr/bin/codesign', ['--sign', '-', output]); + artifacts.push(output); +} +await copyFile(join(root, 'LICENSE'), join(payload, 'LICENSE')); +let nodeLicense: string | undefined; +for (const candidate of ['LICENSE', '../LICENSE', '../share/doc/node/LICENSE']) { + try { + nodeLicense = await readFile(join(dirname(builder), candidate), 'utf8'); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } +} +if (!nodeLicense) throw new Error('The pinned Node builder must include its distribution LICENSE.'); +await writeFile(join(payload, 'NODE-LICENSE'), nodeLicense); +const packages = new Map(); +for (const input of inputs) { + if (!input.replaceAll('\\', '/').includes('node_modules/')) continue; + let directory = dirname(resolve(root, input)); + while (directory !== dirname(directory)) { + try { + const metadata = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as { + name?: string; + version?: string; + }; + if (metadata.name && metadata.version) { + packages.set(`${metadata.name}@${metadata.version}`, directory); + break; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + directory = dirname(directory); + } +} +const notices: string[] = []; +for (const [name, directory] of [...packages].sort(([left], [right]) => + left.localeCompare(right), +)) { + const licenses = (await readdir(directory, { withFileTypes: true })).filter( + (entry) => entry.isFile() && /^(licen[cs]e|copying|notice)([.-]|$)/i.test(entry.name), + ); + if (name === 'yoga-layout@3.2.1' && !licenses.length) { + // The published package omits its upstream MIT text; retain the exact release license. + notices.push( + `${name} - https://github.com/facebook/yoga/blob/v3.2.1/LICENSE\n\n${await readFile(join(root, 'scripts/licenses/yoga-layout-3.2.1-LICENSE'), 'utf8')}`, + ); + continue; + } + if (!licenses.length) throw new Error(`Missing bundled dependency license: ${name}`); + for (const license of licenses) + notices.push( + `${name} - ${license.name}\n\n${await readFile(join(directory, license.name), 'utf8')}`, + ); +} +await writeFile(join(payload, 'THIRD-PARTY-NOTICES'), notices.join('\n\n') + '\n'); +await writeFile( + join(payload, 'BUILD.json'), + JSON.stringify( + { + version: manifest.version, + builder: seaBuilderVersion, + target: `${process.platform}-${process.arch}`, + source: await sourceIdentity(root), + }, + null, + 2, + ) + '\n', +); +const checksums: string[] = []; +for (const path of [ + ...artifacts, + ...['LICENSE', 'NODE-LICENSE', 'THIRD-PARTY-NOTICES', 'BUILD.json'].map((name) => + join(payload, name), + ), +]) + checksums.push( + `${createHash('sha256') + .update(await readFile(path)) + .digest('hex')} ${path.slice(payload.length + 1)}`, + ); +await writeFile(join(payload, 'SHA256SUMS'), checksums.join('\n') + '\n'); +const archive = join( + stage, + `cadder-${manifest.version}-${process.platform}-${process.arch}${process.platform === 'win32' ? '.zip' : '.tar.gz'}`, +); +await execute( + process.platform === 'win32' + ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32/tar.exe') + : 'tar', + [process.platform === 'win32' ? '-acf' : '-czf', archive, '-C', stage, 'cadder'], +); +process.stdout.write( + JSON.stringify({ + stage: payload, + archive, + version: manifest.version, + builder: seaBuilderVersion, + target: `${process.platform}-${process.arch}`, + artifacts, + }) + '\n', +); diff --git a/scripts/prepare-caddy-gate.ts b/scripts/prepare-caddy-gate.ts new file mode 100644 index 0000000..b1e371c --- /dev/null +++ b/scripts/prepare-caddy-gate.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +// Pinned from caddyserver/caddy v2.11.4's published SHA-512 checksums. +const targets: Record = { + 'win32-x64': [ + 'windows_amd64.zip', + 'cd5ccfd86a4b40732cf715890d0dca5bf3f63adefec5a7914de85adf240c60ce7e5d2791631b88ef9758e46b23bb1730e020b9c5d696889740b284ffd4788e35', + ], + 'linux-x64': [ + 'linux_amd64.tar.gz', + '8220d1f013b6f27510247b2360c9e0ca9f018feebd82515f07635318b34ff9777ccc8fd0b6e6f2486ce3a33fe389fbb7db12d05baa474f4587509fb4f5ebf1c9', + ], + 'darwin-x64': [ + 'mac_amd64.tar.gz', + 'e04eb10f9ce7e2e079bc9bff1bd5d3a3164888d1edbb1a49e5d15be4eab691b57e89ed36bb29c65ba43f1ba8d9279e0967b1003991c13fe4cb78384c3caf25de', + ], + 'darwin-arm64': [ + 'mac_arm64.tar.gz', + '3190ae0df98b59ab4b6021556fa35adc3c526a4f3e138776b0eaec8a037cc26121cbbb1ad53453f565551b47d37d5ba4755e2c2c3652256737fe2ce9e53c8ec0', + ], +}; +const target = targets[`${process.platform}-${process.arch}`]; +assert.ok(target, 'Unsupported native acceptance target.'); +const root = await mkdtemp(join(tmpdir(), 'cadder-upstream-gate-')); +const filename = `caddy_2.11.4_${target[0]}`; +const response = await fetch( + `https://github.com/caddyserver/caddy/releases/download/v2.11.4/${filename}`, + { signal: AbortSignal.timeout(120000) }, +); +assert.ok(response.ok, `Upstream acceptance download failed: ${response.status}`); +const bytes = Buffer.from(await response.arrayBuffer()); +assert.equal(createHash('sha512').update(bytes).digest('hex'), target[1]); +const archive = join(root, filename); +await writeFile(archive, bytes); +await promisify(execFile)( + process.platform === 'win32' + ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32/tar.exe') + : 'tar', + ['-xf', archive, '-C', root], +); +process.stdout.write(join(root, process.platform === 'win32' ? 'caddy.exe' : 'caddy') + '\n'); diff --git a/scripts/prepare-product-sandbox.ts b/scripts/prepare-product-sandbox.ts new file mode 100644 index 0000000..bb512a5 --- /dev/null +++ b/scripts/prepare-product-sandbox.ts @@ -0,0 +1,62 @@ +import { build } from 'esbuild'; +import { copyFile, mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +if (process.platform !== 'win32') throw new Error('Prepare the product Sandbox on Windows.'); +const [artifact, upstream] = process.argv.slice(2); +if (!artifact || !upstream) + throw new Error('Pass the SEA artifact directory and separate native Caddy.'); +const root = await mkdtemp(join(tmpdir(), 'cadder-product-sandbox-')); +const input = join(root, 'input'); +const reports = join(root, 'reports'); +await mkdir(input); +await mkdir(reports); +await copyFile(process.execPath, join(input, 'node.exe')); +await copyFile(resolve(upstream), join(input, 'caddy.exe')); +await build({ + entryPoints: [fileURLToPath(new URL('./verify-product-journey.ts', import.meta.url))], + outfile: join(input, 'journey.mjs'), + bundle: true, + platform: 'node', + target: 'node24.18', + format: 'esm', +}); +await writeFile( + join(input, 'bootstrap.ps1'), + ` +$ErrorActionPreference = 'Stop' +Set-Content -LiteralPath 'C:\\CadderReports\\started.txt' -Value 'Product Sandbox started' +try { + $probe = Start-Process -FilePath 'C:\\CadderTools\\node.exe' -ArgumentList @('C:\\CadderTools\\journey.mjs','C:\\CadderProduct','C:\\CadderTools\\caddy.exe') -WindowStyle Hidden -Wait -PassThru -RedirectStandardOutput 'C:\\CadderReports\\result.json' -RedirectStandardError 'C:\\CadderReports\\stderr.txt' + Set-Content -LiteralPath 'C:\\CadderReports\\exit.txt' -Value $probe.ExitCode +} catch { + Set-Content -LiteralPath 'C:\\CadderReports\\error.txt' -Value $_.Exception.Message + Set-Content -LiteralPath 'C:\\CadderReports\\exit.txt' -Value 1 +} +`, +); +const escape = (value: string): string => + value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +const config = join(root, 'product.wsb'); +await writeFile( + config, + ` + DisableDisableDisable + DisableDisableDisable + + ${escape(resolve(artifact))}C:\\CadderProducttrue + ${escape(input)}C:\\CadderToolstrue + ${escape(reports)}C:\\CadderReportsfalse + + powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\\CadderTools\\bootstrap.ps1 +`, +); +process.stdout.write( + JSON.stringify({ + config, + reports, + scope: 'Actual SEA product routing; final UAC/different-account acceptance remains separate.', + }) + '\n', +); diff --git a/scripts/source-identity.ts b/scripts/source-identity.ts new file mode 100644 index 0000000..de5886c --- /dev/null +++ b/scripts/source-identity.ts @@ -0,0 +1,27 @@ +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { readFile, readdir } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +export async function sourceIdentity(root: string) { + const files: string[] = []; + for (const directory of ['src', 'scripts']) + for (const entry of await readdir(join(root, directory), { + recursive: true, + withFileTypes: true, + })) + if (entry.isFile() && (/\.tsx?$/.test(entry.name) || entry.name.endsWith('-LICENSE'))) + files.push(join(entry.parentPath, entry.name)); + files.push( + ...['package.json', 'nub.lock', 'tsconfig.json', 'LICENSE'].map((name) => join(root, name)), + ); + const hash = createHash('sha256'); + for (const file of files.sort()) + hash.update(relative(root, file).replaceAll('\\', '/') + '\0').update(await readFile(file)); + const execute = promisify(execFile); + const revision = (await execute('git', ['rev-parse', 'HEAD'], { cwd: root })).stdout.trim(); + const dirty = + (await execute('git', ['status', '--porcelain'], { cwd: root })).stdout.trim() !== ''; + return { revision, digest: hash.digest('hex'), dirty }; +} diff --git a/scripts/verify-node.ts b/scripts/verify-node.ts new file mode 100644 index 0000000..87bb8f1 --- /dev/null +++ b/scripts/verify-node.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { packNode, npmCli } from './pack-node.ts'; +import { fileURLToPath } from 'node:url'; + +const execute = promisify(execFile); +const packed = await packNode(); +const consumer = await mkdtemp(join(tmpdir(), 'cadder-node-consumer-')); +const environment = { ...process.env, CADDER_RUNTIME_DIR: join(consumer, 'runtime') }; +let command: string | undefined; +try { + await execute( + process.execPath, + [ + await npmCli(), + 'install', + '--prefix', + consumer, + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + packed.archive, + ], + { timeout: 120000, maxBuffer: 1024 * 1024 }, + ); + command = join(consumer, 'node_modules/cadder/lib/entrypoints/cadder.js'); + if (process.argv[2]) + await execute( + process.execPath, + [ + fileURLToPath(new URL('./verify-product-journey.ts', import.meta.url)), + join(consumer, 'node_modules/cadder'), + process.argv[2], + process.execPath, + ], + { timeout: 900000, maxBuffer: 1024 * 1024 }, + ).then((result) => process.stdout.write(result.stdout)); + const run = (args: string[]) => + execute(process.execPath, [command!, ...args], { + cwd: consumer, + env: environment, + timeout: 45000, + maxBuffer: 1024 * 1024, + }); + assert.match((await run(['--version'])).stdout, /^2\.0\.0-rc\.1\s*$/); + assert.match((await run([])).stderr, /Usage: cadder/); + await assert.rejects( + run(['status']), + (error: unknown) => (error as { code?: unknown }).code === 3, + ); + const prefix = join(consumer, 'global'); + await execute( + process.execPath, + [ + await npmCli(), + 'install', + '--global', + '--prefix', + prefix, + '--ignore-scripts', + '--no-audit', + '--no-fund', + packed.archive, + ], + { timeout: 120000, maxBuffer: 1024 * 1024 }, + ); + const wrapperDirectory = process.platform === 'win32' ? prefix : join(prefix, 'bin'); + for (const name of ['cadder', 'cadderd', 'caddy']) { + const arguments_ = [name === 'caddy' ? '--cadder-shim-info' : '--version']; + for (const directory of [join(consumer, 'node_modules/.bin'), wrapperDirectory]) { + const wrapper = join(directory, process.platform === 'win32' ? `${name}.cmd` : name); + const result = + process.platform === 'win32' + ? await execute( + join(process.env.SystemRoot ?? 'C:\\Windows', 'System32/cmd.exe'), + ['/d', '/s', '/c', `""${wrapper}" ${arguments_[0]}"`], + { env: environment, windowsVerbatimArguments: true }, + ) + : await execute(wrapper, arguments_, { env: environment }); + assert.match(result.stdout, /2\.0\.0-rc\.1/); + } + } + const globalPackage = join( + prefix, + process.platform === 'win32' ? 'node_modules/cadder' : 'lib/node_modules/cadder', + ); + const installed = JSON.parse(await readFile(join(globalPackage, 'package.json'), 'utf8')) as { + scripts?: unknown; + optionalDependencies?: unknown; + }; + assert.equal(installed.scripts, undefined); + assert.equal(installed.optionalDependencies, undefined); + assert.match((await run(['daemon', 'start'])).stdout, /running/); + assert.match((await run(['status'])).stdout, /cadderd {2}running/); + assert.match((await run(['projects', 'list'])).stdout, /No projects registered/); + assert.match((await run(['logs', 'runtime', '--limit', '1'])).stdout, /Daemon started/); + await assert.rejects( + run(['logs', 'runtime', '--tail']), + (error: unknown) => (error as { code?: unknown }).code === 2, + ); + assert.match((await run(['daemon', 'stop'])).stdout, /stopped/); + await assert.rejects( + run(['status']), + (error: unknown) => (error as { code?: unknown }).code === 3, + ); + process.stdout.write( + JSON.stringify({ + archive: packed.archive, + result: 'PASS', + scope: + 'compiled npm local lifecycle, local/global three-bin wrappers, actual SQLite worker and CLI, no TS loader', + }) + '\n', + ); +} catch (error) { + const failure = error as { message?: string; stdout?: string; stderr?: string }; + process.stderr.write( + [failure.message, failure.stdout, failure.stderr].filter(Boolean).join('\n') + '\n', + ); + throw error; +} finally { + if (command) + await execute(process.execPath, [command, 'daemon', 'stop'], { + env: environment, + timeout: 20000, + }).catch(() => {}); + await rm(consumer, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }).catch( + (error: unknown) => { + process.stderr.write( + `Consumer cleanup failed: ${error instanceof Error ? error.message : 'unknown failure'}\n`, + ); + process.exitCode = 1; + }, + ); +} diff --git a/scripts/verify-product-journey.ts b/scripts/verify-product-journey.ts new file mode 100644 index 0000000..55113ee --- /dev/null +++ b/scripts/verify-product-journey.ts @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { get } from 'node:http'; +import { get as getTls } from 'node:https'; +import { setTimeout as delay } from 'node:timers/promises'; + +// Run on an isolated native machine with free loopback ports 80 and 443. +const [directory, upstream, node] = process.argv.slice(2); +assert.ok( + directory && upstream, + 'Pass the product directory, separate real Caddy, and Node for npm only.', +); +const root = await mkdtemp(join(tmpdir(), 'cadder-product-journey-')); +const stage = resolve(directory); +const binary = (name: string): string => + join(stage, process.platform === 'win32' ? `${name}.exe` : name); +const command = (name: string, args: string[]) => + node + ? { file: resolve(node), args: [join(stage, `lib/entrypoints/${name}.js`), ...args] } + : { file: binary(name), args }; +await mkdir(join(root, 'empty-path')); +const environment = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'path'), +); +environment.PATH = node + ? dirname(resolve(node)) + : process.platform === 'win32' + ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') + : join(root, 'empty-path'); +if (node && process.platform === 'win32') + environment.PATH += ';' + join(process.env.SystemRoot ?? 'C:\\Windows', 'System32'); +environment.CADDER_RUNTIME_DIR = join(root, 'runtime'); +const execute = promisify(execFile); +const run = (args: string[]) => { + const entry = command('cadder', args); + return execute(entry.file, entry.args, { + env: environment, + cwd: root, + timeout: 45000, + maxBuffer: 1024 * 1024, + }); +}; +const children: ReturnType[] = []; +let daemonErrors = ''; +function child(name: string, args: string[], cwd: string) { + const entry = command(name, args); + const process_ = spawn(entry.file, entry.args, { + env: environment, + cwd, + windowsHide: true, + stdio: ['ignore', 'ignore', 'pipe'], + }); + process_.stderr!.on('data', (chunk: Buffer) => { + daemonErrors = (daemonErrors + `${name} (${cwd}): ` + chunk.toString()).slice(-8192); + }); + children.push(process_); + return process_; +} +async function until(check: () => Promise, timeout = 120000): Promise { + const deadline = Date.now() + timeout; + let last: unknown; + while (Date.now() < deadline) { + try { + await check(); + return; + } catch (error) { + last = error; + await delay(200); + } + } + throw new Error(`Product scenario deadline exceeded: ${String(last)}\n${daemonErrors}`); +} +async function request(host: string, tls = false): Promise<{ status: number; body: string }> { + const ca = tls + ? await readFile( + join(environment.CADDER_RUNTIME_DIR!, 'v2/caddy/data/caddy/pki/authorities/local/root.crt'), + ) + : undefined; + return new Promise((resolveResponse, reject) => { + const request_ = (tls ? getTls : get)( + { + hostname: '127.0.0.1', + port: tls ? 443 : 80, + path: '/', + headers: { host }, + ...(tls ? { servername: host, ca } : {}), + }, + (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + body = (body + chunk).slice(0, 4096); + }); + response.on('end', () => resolveResponse({ status: response.statusCode ?? 0, body })); + }, + ); + request_.on('error', reject); + request_.setTimeout(5000, () => request_.destroy(new Error('HTTP deadline exceeded.'))); + }); +} +async function project(name: string, body: string) { + const path = join(root, name); + await mkdir(path); + const config = join(path, 'Caddyfile'); + await writeFile(config, body); + return { path, config, shim: child('caddy', ['run', '--config', config], path) }; +} +try { + const daemon = child('cadderd', ['--real-caddy', resolve(upstream)], root); + await until(async () => { + assert.equal(daemon.exitCode, null); + assert.match((await run(['status'])).stdout, /cadderd {2}running/); + }); + const alpha = await project('alpha', 'alpha.localhost {\n respond "alpha"\n}\n'); + await until(async () => assert.equal((await request('alpha.localhost')).body, 'alpha')); + assert.equal((await request('alpha.localhost', true)).body, 'alpha'); + const beta = await project('beta', 'beta.localhost {\n respond "beta"\n}\n'); + await until(async () => assert.equal((await request('beta.localhost')).body, 'beta')); + assert.equal((await request('alpha.localhost')).body, 'alpha'); + assert.equal((await request('beta.localhost', true)).body, 'beta'); + assert.notEqual((await request('unknown.localhost')).body, 'alpha'); + await run(['domains', 'disable', 'alpha.localhost']); + assert.notEqual((await request('alpha.localhost')).body, 'alpha'); + await run(['domains', 'enable', 'alpha.localhost']); + assert.equal((await request('alpha.localhost')).body, 'alpha'); + await run(['projects', 'disable', beta.config]); + assert.notEqual((await request('beta.localhost')).body, 'beta'); + await run(['projects', 'enable', beta.config]); + assert.equal((await request('beta.localhost')).body, 'beta'); + const conflict = await project('conflict', 'alpha.localhost {\n respond "conflict"\n}\n'); + await until(async () => assert.equal(conflict.shim.exitCode, 1)); + assert.equal((await request('alpha.localhost')).body, 'alpha'); + await writeFile(alpha.config, 'not a valid { Caddyfile'); + await assert.rejects(run(['projects', 'enable', alpha.config])); + assert.equal((await request('alpha.localhost')).body, 'alpha'); + await writeFile(alpha.config, 'alpha.localhost {\n respond "alpha"\n}\n'); + await delay(5500); + assert.match((await run(['domains', 'list'])).stdout, /alpha.localhost/); + alpha.shim.kill(); + await until(async () => assert.notEqual((await request('alpha.localhost')).body, 'alpha'), 25000); + assert.equal((await request('beta.localhost')).body, 'beta'); + const admin = /Admin\s+https:\/\/127\.0\.0\.1:(\d+)/.exec((await run(['diagnostics'])).stdout); + assert.ok(admin); + await assert.rejects( + new Promise((resolveResponse, reject) => { + const attempt = getTls( + { + hostname: '127.0.0.1', + port: Number(admin[1]), + path: '/config/', + rejectUnauthorized: false, + }, + (response) => { + response.resume(); + if ((response.statusCode ?? 0) >= 400) reject(new Error('Unauthorized client refused.')); + else resolveResponse(response.statusCode); + }, + ); + attempt.on('error', reject); + attempt.setTimeout(5000, () => + attempt.destroy(new Error('Administration deadline exceeded.')), + ); + }), + ); + await run(['daemon', 'stop']); + await assert.rejects(request('beta.localhost')); + beta.shim.kill(); + process.stdout.write( + JSON.stringify({ + result: 'PASS', + platform: process.platform, + distribution: node ? 'npm' : 'sea', + checks: [ + 'actual daemon/shim/operator', + 'multi-project HTTP/verified HTTPS', + 'domain/project toggles', + 'conflict and malformed rejection with last-known-good', + 'heartbeat and crashed-session expiry', + 'unauthorized admin denial', + 'owned shutdown', + ], + nodeOnProductPath: Boolean(node), + }) + '\n', + ); +} catch (error) { + for (const args of [['status'], ['projects', 'list'], ['logs', 'runtime', '--limit', '20']]) { + try { + process.stderr.write((await run(args)).stdout); + } catch { + /* The original failure remains authoritative. */ + } + } + process.stderr.write( + JSON.stringify( + children.map((entry) => ({ + pid: entry.pid, + exitCode: entry.exitCode, + signal: entry.signalCode, + })), + ) + + '\n' + + daemonErrors + + '\n', + ); + throw error; +} finally { + await run(['daemon', 'stop']).catch(() => {}); + for (const process_ of children) if (process_.exitCode === null) process_.kill(); + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); +} diff --git a/scripts/verify-sea.ts b/scripts/verify-sea.ts new file mode 100644 index 0000000..24b3273 --- /dev/null +++ b/scripts/verify-sea.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { createHash } from 'node:crypto'; +import { lstat, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const execute = promisify(execFile); +let stage = resolve(process.argv[2] ?? ''); +assert.ok(process.argv[2], 'Pass the extracted SEA artifact directory.'); +const runtime = await mkdtemp(join(tmpdir(), 'cadder-sea-consumer-')); +if (/\.(?:zip|tar\.gz)$/.test(stage)) { + const unpacked = join(runtime, 'unpacked'); + await mkdir(unpacked); + await execute( + process.platform === 'win32' + ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32/tar.exe') + : 'tar', + ['-xf', stage, '-C', unpacked], + ); + stage = join(unpacked, 'cadder'); +} +await mkdir(join(runtime, 'empty-path')); +const environment = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'path'), +); +environment.PATH = + process.platform === 'win32' + ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') + : join(runtime, 'empty-path'); +environment.CADDER_RUNTIME_DIR = join(runtime, 'runtime'); +const binary = (name: string): string => + join(stage, process.platform === 'win32' ? `${name}.exe` : name); +const run = (args: string[]) => + execute(binary('cadder'), args, { + env: environment, + cwd: runtime, + timeout: 45000, + maxBuffer: 1024 * 1024, + }); +try { + const identity = JSON.parse(await readFile(join(stage, 'BUILD.json'), 'utf8')) as { + version: string; + target: string; + }; + assert.equal(identity.target, `${process.platform}-${process.arch}`); + const allowed = new Set( + ['cadder', 'cadderd', 'caddy'] + .map((name) => (process.platform === 'win32' ? `${name}.exe` : name)) + .concat(['LICENSE', 'NODE-LICENSE', 'THIRD-PARTY-NOTICES', 'BUILD.json']), + ); + assert.deepEqual((await readdir(stage)).sort(), [...allowed, 'SHA256SUMS'].sort()); + const checksums = (await readFile(join(stage, 'SHA256SUMS'), 'utf8')).trim().split('\n'); + assert.equal(checksums.length, allowed.size); + for (const line of checksums) { + const hash = line.slice(0, 64); + const name = line.slice(66); + assert.match(hash, /^[a-f0-9]{64}$/); + assert.ok(allowed.delete(name)); + const path = join(stage, name); + const info = await lstat(path); + assert.ok(info.isFile() && !info.isSymbolicLink()); + assert.equal( + createHash('sha256') + .update(await readFile(path)) + .digest('hex'), + hash, + ); + } + assert.equal((await run(['--version'])).stdout.trim(), identity.version); + assert.equal( + (await execute(binary('cadderd'), ['--version'], { env: environment })).stdout.trim(), + identity.version, + ); + assert.equal( + JSON.parse( + (await execute(binary('caddy'), ['--cadder-shim-info'], { env: environment })).stdout, + ).version, + identity.version, + ); + await assert.rejects( + run(['status']), + (error: unknown) => (error as { code?: unknown }).code === 3, + ); + assert.match((await run(['daemon', 'start'])).stdout, /running/); + assert.match((await run(['status'])).stdout, /cadderd {2}running/); + assert.match((await run(['logs', 'runtime', '--limit', '1'])).stdout, /Daemon started/); + assert.match((await run(['daemon', 'restart'])).stdout, /running/); + assert.match((await run(['daemon', 'stop'])).stdout, /stopped/); + process.stdout.write( + JSON.stringify({ + stage, + result: 'PASS', + scope: + 'native SEA entries, authenticated daemon, SQLite worker and restart without Node/npm on PATH', + }) + '\n', + ); +} finally { + await run(['daemon', 'stop']); + await rm(runtime, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); +} diff --git a/src/caddy/admin-policy.ts b/src/caddy/admin-policy.ts index 8d5b3bd..cb44a0e 100644 --- a/src/caddy/admin-policy.ts +++ b/src/caddy/admin-policy.ts @@ -133,6 +133,38 @@ function candidate(body: string): CaddyConfig { return config; } +function routeApplications(plan: CaddyRoutePlan) { + return { + http: { + servers: { + cadder_http: { ...plan.servers.cadder_http, automatic_https: { disable: true } }, + cadder_https: { + ...plan.servers.cadder_https, + automatic_https: { disable: true }, + tls_connection_policies: [{}], + }, + }, + }, + tls: { + automation: { + policies: plan.tlsSubjects.length + ? [{ subjects: plan.tlsSubjects, issuers: [{ module: 'internal', ca: 'local' }] }] + : [], + }, + certificates: { automate: plan.tlsSubjects }, + }, + }; +} + +/** Reuse one verified identity; only the guarded HTTP/TLS route plan changes. */ +export function configForRoutePlan(policy: SecureAdminPolicy, input: CaddyRoutePlan): CaddyConfig { + const plan = guardedPlan(input); + const root = object(JSON.parse(policy.config.adaptedConfig.body) as unknown); + return candidate( + canonicalBody({ ...root, apps: { ...object(root.apps), ...routeApplications(plan) } }), + ); +} + /** * Normalize a complete submitted or independently fetched JSON object, not a receipt. * Object keys sort lexically (including numeric keys); arrays and opaque data stay intact. @@ -364,32 +396,7 @@ export async function prepareSecureAdminPolicy( local: { install_trust: false }, }, }, - http: { - servers: { - cadder_http: { ...plan.servers.cadder_http, automatic_https: { disable: true } }, - cadder_https: { - ...plan.servers.cadder_https, - automatic_https: { disable: true }, - tls_connection_policies: [{}], - }, - }, - }, - // The retained local/internal web issuer, distinct from the imported admin CA. - // An empty subject list must not become a catch-all issuance policy. - tls: { - automation: { - policies: - plan.tlsSubjects.length === 0 - ? [] - : [ - { - subjects: plan.tlsSubjects, - issuers: [{ module: 'internal', ca: 'local' }], - }, - ], - }, - certificates: { automate: plan.tlsSubjects }, - }, + ...routeApplications(plan), }, }), ); diff --git a/src/caddy/domains.ts b/src/caddy/domains.ts index 33b694f..ce0daa0 100644 --- a/src/caddy/domains.ts +++ b/src/caddy/domains.ts @@ -1,4 +1,5 @@ -import { domainToASCII } from 'node:url'; +import { canonicalizeDomain } from '../protocol/domain.ts'; +export { canonicalizeDomain } from '../protocol/domain.ts'; import type { CaddyConfig } from '../contracts/ports.ts'; import type { EntrypointRegistration } from '../protocol/dto.ts'; import { assertCandidate } from './preparation.ts'; @@ -28,23 +29,6 @@ export function compareText(left: string, right: string): number { return 0; } -/** Match the retained literal-domain IDNA boundary, not URL host parsing. */ -export function canonicalizeDomain(raw: string): string { - const domain = raw.trim().replace(/\.+$/, ''); - const fallback = domain.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); - // These characters would invoke URL decoding, separators or bracket-host parsing. - if ( - /[\s%/:\\@#?]/u.test(domain) || - domain.includes('[') || - domain.includes(']') || - [...domain].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) - ) - return fallback; - const suffix = '.cadder-idna'; - const mapped = domainToASCII(domain + suffix); - return mapped.endsWith(suffix) ? mapped.slice(0, -suffix.length) : fallback; -} - export function isEnabled(state: CompositionRegistration['activationState']): boolean { return state === 'registered' || state === 'activating' || state === 'active'; } diff --git a/src/caddy/runtime.ts b/src/caddy/runtime.ts index 82d395b..1d30694 100644 --- a/src/caddy/runtime.ts +++ b/src/caddy/runtime.ts @@ -21,6 +21,7 @@ export type CaddyRuntimeOptions = Readonly<{ startupMs?: number; stopMs?: number; pollMs?: number; + onOutput?: (stream: 'stdout' | 'stderr', bytes: Buffer) => void; }>; type Execution = { abort: AbortController; @@ -63,6 +64,7 @@ export class CaddyRuntime implements ConfigurationRuntime { readonly #startupMs: number; readonly #stopMs: number; readonly #pollMs: number; + readonly #onOutput: CaddyRuntimeOptions['onOutput']; #execution: Execution | undefined; #uncertain = false; #closed = false; @@ -90,6 +92,7 @@ export class CaddyRuntime implements ConfigurationRuntime { this.#startupMs = bound(options.startupMs, 30_000); this.#stopMs = bound(options.stopMs, 5000); this.#pollMs = bound(options.pollMs, 50); + this.#onOutput = options.onOutput; this.#admin = new CaddyAdminClient(this.#policy); this.#validator = new CaddyConfigValidator(resolver, { env: this.#policy.environment }); } @@ -130,6 +133,11 @@ export class CaddyRuntime implements ConfigurationRuntime { }, unavailable()); } + status(): 'running' | 'idle' | 'unhealthy' { + if (this.#uncertain || this.#execution?.completed) return 'unhealthy'; + return this.#execution?.started ? 'running' : 'idle'; + } + /** Refuse admission immediately, cancel pending admin/start work, then drain native work/staging. */ close(): Promise { if (this.#closing) return this.#closing; @@ -231,6 +239,7 @@ export class CaddyRuntime implements ConfigurationRuntime { signal: execution.abort.signal, env: this.#policy.environment, cwd: this.#policy.paths.scratch, + ...(this.#onOutput ? { onOutput: this.#onOutput } : {}), onStarted: () => { execution.started = true; notifyStarted(); diff --git a/src/cli/output.ts b/src/cli/output.ts new file mode 100644 index 0000000..4dcadfd --- /dev/null +++ b/src/cli/output.ts @@ -0,0 +1,154 @@ +import type { EntrypointRegistration, StateSnapshot } from '../protocol/dto.ts'; +import type { RpcResult } from '../protocol/rpc.ts'; +import { terminalSafe } from '../storage/redaction.ts'; +import { localUpstreamPort } from '../client/selectors.ts'; +import type { PortOwner } from '../platform/inspection.ts'; + +export function table(headers: string[], rows: (string | number | null | undefined)[][]): string { + const values = [headers, ...rows].map((row) => + row.map((value) => terminalSafe(String(value ?? '-'))), + ); + const widths = headers.map((_, column) => + Math.max(...values.map((row) => row[column]?.length ?? 0)), + ); + return values + .map((row) => ' ' + row.map((value, index) => value.padEnd(widths[index]!)).join(' ') + ' ') + .join('\n'); +} +export function status(snapshot: StateSnapshot): string { + return `cadderd running\nCaddy ${snapshot.runtime.status.toLowerCase()}\nConfig ${snapshot.config.status.toLowerCase()}`; +} +export function projects(snapshot: StateSnapshot): string { + if (!snapshot.registrations.length) return 'No projects registered.'; + return table( + ['STATE', 'PROJECT', 'CADDYFILE'], + [...snapshot.registrations] + .sort((a, b) => a.sourceWorkingDirectory.raw.localeCompare(b.sourceWorkingDirectory.raw)) + .map((project) => [ + project.activationState, + project.sourceWorkingDirectory.raw, + project.sourceConfigPath.raw, + ]), + ); +} +export function effectiveState( + project: EntrypointRegistration, + domain: EntrypointRegistration['registeredDomains'][number], +): string { + if (project.activationState === 'faulted' || domain.activationState === 'faulted') + return 'faulted'; + if (project.activationState === 'inactive' || domain.activationState === 'inactive') + return 'inactive'; + return project.activationState === 'active' && domain.activationState === 'active' + ? 'active' + : 'pending'; +} +export function domains(snapshot: StateSnapshot): string { + const rows = routes(snapshot); + if (!rows.length) return 'No domains registered.'; + return table( + ['STATE', 'DOMAIN', 'UPSTREAM', 'PROJECT'], + rows.map(({ project, domain }) => [ + effectiveState(project, domain), + domain.name.raw, + displayUpstream(domain.upstream), + project.sourceWorkingDirectory.raw, + ]), + ); +} +export function routes(snapshot: StateSnapshot) { + return [...snapshot.registrations] + .sort((a, b) => a.sourceWorkingDirectory.raw.localeCompare(b.sourceWorkingDirectory.raw)) + .flatMap((project) => + [...project.registeredDomains] + .sort((a, b) => a.name.canonical.localeCompare(b.name.canonical)) + .map((domain) => ({ project, domain })), + ); +} +export function displayUpstream(upstream: string | undefined): string { + const port = localUpstreamPort(upstream); + return port === undefined ? (upstream ?? '-') : `:${port}`; +} +export function routeTable(rows: ReturnType): string { + return rows.length + ? table( + ['PROJECT STATE', 'DOMAIN STATE', 'DOMAIN', 'UPSTREAM', 'PROJECT', 'CADDYFILE'], + rows.map(({ project, domain }) => [ + project.activationState, + domain.activationState, + domain.name.raw, + displayUpstream(domain.upstream), + project.sourceWorkingDirectory.raw, + project.sourceConfigPath.raw, + ]), + ) + : 'No matching routes.'; +} +export function sockets(owners: PortOwner[]): string { + if (!owners.length) return 'No listening socket found.'; + return table( + ['PROTOCOL', 'ADDRESS', 'PID', 'PROCESS', 'EXECUTABLE'], + owners.map((owner) => [ + owner.protocol, + `${owner.address}:${owner.port}`, + owner.pid ?? 'unknown', + owner.processName ?? 'unknown', + owner.executable, + ]), + ); +} +export function diagnostics(snapshot: StateSnapshot): string { + const runtime = snapshot.runtime; + return [ + `Caddy ${runtime.status.toLowerCase()}`, + `Config ${snapshot.config.status.toLowerCase()}`, + ...[ + [runtime.processId, 'PID'], + [runtime.version, 'Version'], + [runtime.binaryPath, 'Binary'], + [runtime.adminEndpoint, 'Admin'], + ] + .filter(([value]) => value !== null) + .map(([value, label]) => `${String(label).padEnd(9)}${terminalSafe(String(value))}`), + '', + 'Runtime diagnostics', + runtime.diagnostics.length + ? table( + ['CODE', 'OPERATION', 'MESSAGE'], + runtime.diagnostics.map((entry) => [entry.code, entry.operation, entry.message]), + ) + : 'None.', + '', + 'Configuration diagnostics', + snapshot.config.diagnostics.length + ? table( + ['CODE', 'DOMAIN', 'CADDYFILES', 'MESSAGE'], + snapshot.config.diagnostics.map((entry) => [ + entry.code, + entry.domainKey, + entry.sourceConfigPaths.join(', ') || '-', + entry.message, + ]), + ) + : 'None.', + ].join('\n'); +} +export function logs(response: RpcResult<'query-logs-request'>): string { + return [ + `Status ${response.streamStatus.toLowerCase()}`, + `Channel ${terminalSafe(response.stream.channel)}`, + ...(response.stream.domainKey ? [`Domain ${terminalSafe(response.stream.domainKey)}`] : []), + '', + response.entries.length + ? table( + ['SEQ', 'TIME (UTC)', 'LEVEL', 'MESSAGE'], + response.entries.map((entry) => [ + entry.sequenceNumber, + entry.timestampUtc.replace(/\.000Z$/, '+00:00').replace(/Z$/, '+00:00'), + entry.severity, + entry.rawMessage, + ]), + ) + : 'No log entries.', + ].join('\n'); +} diff --git a/src/cli/run.ts b/src/cli/run.ts new file mode 100644 index 0000000..db93d9f --- /dev/null +++ b/src/cli/run.ts @@ -0,0 +1,224 @@ +import { Command, CommanderError, InvalidArgumentError } from 'commander'; +import { ClientService } from '../client/service.ts'; +import { domainForName, localUpstreamPort, projectForPath } from '../client/selectors.ts'; +import { inspectPort, terminatePortOwner } from '../platform/inspection.ts'; +import { CadderError, RpcError, errorCode } from '../protocol/errors.ts'; +import { VERSION } from '../protocol/version.ts'; +import { runtimeLogStream } from '../storage/schema.ts'; +import type { LogInput } from '../storage/schema.ts'; +import { terminalSafe } from '../storage/redaction.ts'; +import * as output from './output.ts'; + +function integer(raw: string, maximum: number, minimum = 0): number { + if ( + !/^\d+$/.test(raw) || + !Number.isSafeInteger(Number(raw)) || + Number(raw) > maximum || + Number(raw) < minimum + ) + throw new InvalidArgumentError(`Expected an integer from ${minimum} to ${maximum}.`); + return Number(raw); +} +export function exitCode(error: unknown): number { + if (error instanceof CommanderError) return error.exitCode === 0 ? 0 : 2; + const code = errorCode(error); + if (code === 'daemon-unavailable' || code === 'daemon_unavailable') return 3; + if (code === 'daemon-start-failed') return 4; + if (code === 'target-not-found') return 5; + if (code === 'target-conflict') return 6; + if (code === 'unsupported-operation') return 8; + if (error instanceof RpcError) { + if (error.protocolError.kind === 'accessDenied') return 7; + if (['configuration', 'conflict', 'staleInstance'].includes(error.protocolError.kind)) return 6; + if (error.protocolError.kind === 'invalidInput') return 2; + } + if (code === 'EACCES' || code === 'EPERM' || code === 'unsafe-runtime-permissions') return 7; + if (code === 'invalid-input') return 2; + return 9; +} + +export async function runCli( + argv: readonly string[], + client = new ClientService(), + io = { + out: (text: string) => { + process.stdout.write(text); + }, + error: (text: string) => { + process.stderr.write(text); + }, + }, +): Promise { + const print = (text: string): void => io.out(text + '\n'); + const program = new Command('cadder') + .version(VERSION) + .description('Inspect and manage Cadder routes') + .exitOverride() + .configureOutput({ writeOut: io.out, writeErr: io.error }); + program.addHelpCommand('help [command]', 'Display help for a command.'); + function group(name: string, description: string): Command { + const command = program.command(name).description(description); + command.action(() => command.outputHelp({ error: true })); + return command; + } + program.action(() => program.outputHelp({ error: true })); + program + .command('status') + .description('Show cadderd, Caddy, and configuration state.') + .action(async () => print(output.status(await client.snapshot()))); + const daemon = group('daemon', 'Start, stop, or restart cadderd.'); + for (const action of ['start', 'stop', 'restart'] as const) + daemon.command(action).action(async () => { + await client[action](); + print(action === 'stop' ? 'cadderd stopped.' : 'cadderd running.'); + }); + const projects = group('projects', 'Inspect and manage registered projects.'); + projects.command('list').action(async () => print(output.projects(await client.snapshot()))); + const domains = group('domains', 'Inspect and manage registered domains.'); + domains.command('list').action(async () => print(output.domains(await client.snapshot()))); + for (const action of ['enable', 'disable'] as const) { + projects.command(`${action} `).action(async (path: string) => { + const project = await projectForPath(await client.snapshot(), path); + const result = await client.setProjectEnabled({ + registrationId: project.registrationId, + enabled: action === 'enable', + }); + if (!result.ok) throw new RpcError(result.error); + print(result.value.message); + }); + domains + .command(`${action} `) + .option('--caddyfile ') + .action(async (name: string, options: { caddyfile?: string }) => { + const target = await domainForName(await client.snapshot(), name, options.caddyfile); + const result = await client.setDomainEnabled({ + registrationId: target.project.registrationId, + domainKey: target.domain.name.canonical, + enabled: action === 'enable', + }); + if (!result.ok) throw new RpcError(result.error); + print(result.value.message); + }); + } + const port = group('port', 'Inspect a local port or terminate its expected owner.'); + port.command('inspect ').action(async (raw: string) => { + const value = integer(raw, 65535); + const owners = await inspectPort(value); + print(`Port ${value}\n\nSockets\n${output.sockets(owners)}\n\nCadder routes`); + try { + print( + output.routeTable( + output + .routes(await client.snapshot()) + .filter(({ domain }) => localUpstreamPort(domain.upstream) === value), + ), + ); + } catch (error) { + if (errorCode(error) !== 'daemon-unavailable') throw error; + print('Registration data unavailable.\n\nCadder: daemon unavailable'); + } + }); + port + .command('kill ') + .requiredOption('--pid ', 'Previously observed process ID') + .action(async (raw: string, options: { pid: string }) => { + const value = integer(raw, 65535); + const pid = integer(options.pid, 0xffffffff, 1); + await terminatePortOwner(value, pid); + print(`Termination signal sent to PID ${pid} for port ${value}.`); + }); + const caddyfile = group('caddyfile', 'Inspect a Caddyfile registration and its local upstreams.'); + caddyfile.command('inspect ').action(async (path: string) => { + const snapshot = await client.snapshot(); + print(`Caddyfile ${terminalSafe(path)}\n${output.status(snapshot)}`); + try { + const project = await projectForPath(snapshot, path); + print( + 'Registered yes\n\n' + + output.projects({ ...snapshot, registrations: [project] }) + + '\n\nDomains\n' + + output.domains({ ...snapshot, registrations: [project] }), + ); + for (const value of new Set( + project.registeredDomains + .map((domain) => localUpstreamPort(domain.upstream)) + .filter((value) => value !== undefined), + )) + print('\nLocal upstream sockets\n' + output.sockets(await inspectPort(value))); + } catch (error) { + if (errorCode(error) !== 'target-not-found') throw error; + print('Registered no'); + } + }); + domains + .command('inspect ') + .option('--caddyfile ') + .action(async (name: string, options: { caddyfile?: string }) => { + const snapshot = await client.snapshot(); + const selected = await domainForName(snapshot, name, options.caddyfile); + print( + `Domain ${terminalSafe(name)}\n${output.status(snapshot)}\n\n${output.routeTable([selected])}`, + ); + const value = localUpstreamPort(selected.domain.upstream); + if (value !== undefined) + print('\nLocal upstream sockets\n' + output.sockets(await inspectPort(value))); + }); + program + .command('diagnostics') + .description('Show explicit runtime and configuration diagnostics.') + .action(async () => print(output.diagnostics(await client.snapshot()))); + const logs = group('logs', 'Read bounded, redacted daemon logs.'); + for (const kind of ['runtime', 'project', 'domain'] as const) { + const command = logs + .command( + kind === 'runtime' ? 'runtime' : `${kind} <${kind === 'project' ? 'caddyfile' : 'domain'}>`, + ) + .option( + '--limit ', + 'Number of newest entries (1-200)', + (raw) => integer(raw, 200, 1), + 50, + ); + if (kind === 'domain') command.option('--caddyfile '); + command.action(async (...arguments_: unknown[]) => { + const options = arguments_[kind === 'runtime' ? 0 : 1] as { + limit: number; + caddyfile?: string; + }; + let stream: LogInput['stream'] = runtimeLogStream; + if (kind !== 'runtime') { + const snapshot = await client.snapshot(); + const selector = arguments_[0] as string; + stream = + kind === 'project' + ? (await projectForPath(snapshot, selector)).logStream + : (await domainForName(snapshot, selector, options.caddyfile)).domain.logStream; + } + const result = await client.queryLogs({ stream, limit: options.limit }); + if (!result.ok) throw new RpcError(result.error); + print(output.logs(result.value)); + }); + } + program + .command('tui') + .description('Open the full-screen operator.') + .option('--start-daemon') + .action(async (options: { startDaemon?: boolean }) => { + if (options.startDaemon) await client.start(); + if (!process.stdin.isTTY || !process.stdout.isTTY) + throw new CadderError( + 'unsupported-operation', + 'cadder tui requires an interactive terminal.', + ); + const { openTui } = await import('../tui/app.tsx'); + await openTui(client); + }); + try { + await program.parseAsync([...argv], { from: 'user' }); + return 0; + } catch (error) { + if (!(error instanceof CommanderError)) + io.error(terminalSafe(error instanceof Error ? error.message : 'Operation failed.') + '\n'); + return exitCode(error); + } +} diff --git a/src/client/connection.ts b/src/client/connection.ts index a04cb02..f6e4223 100644 --- a/src/client/connection.ts +++ b/src/client/connection.ts @@ -49,7 +49,15 @@ export async function rpc( const session = await authenticateClient(channel, secret, paths.instance); try { session.send(request.data); - const parsed = responseSchema(method).safeParse(await session.receive()); + const mutation = [ + 'register-entrypoint-request', + 'unregister-entrypoint-request', + 'set-entrypoint-enabled-request', + 'set-domain-enabled-request', + ].includes(method); + const parsed = responseSchema(method).safeParse( + await session.receive(mutation ? 120000 : 15000), + ); if ( !parsed.success || parsed.data.requestId !== requestId || diff --git a/src/client/launch.ts b/src/client/launch.ts new file mode 100644 index 0000000..fe6a14e --- /dev/null +++ b/src/client/launch.ts @@ -0,0 +1,43 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isSea } from 'node:sea'; + +/** Resolve only this installation's entry; never consult PATH for cadderd. */ +export async function daemonCommand(): Promise<{ file: string; args: string[] }> { + if (isSea()) { + const file = join( + dirname(process.execPath), + process.platform === 'win32' ? 'cadderd.exe' : 'cadderd', + ); + await access(file); + return { file, args: [] }; + } + const source = import.meta.url.endsWith('.ts'); + const path = fileURLToPath( + new URL(source ? '../entrypoints/cadderd.ts' : '../entrypoints/cadderd.js', import.meta.url), + ); + await access(path); + return { + file: process.execPath, + args: [...(source ? ['--import', import.meta.resolve('tsx')] : []), path], + }; +} + +export async function launchDaemon(args: readonly string[] = []): Promise { + const command = await daemonCommand(); + return new Promise((resolve, reject) => { + const child = spawn(command.file, [...command.args, ...args], { + detached: true, + windowsHide: true, + stdio: 'ignore', + shell: false, + }); + child.once('error', reject); + child.once('spawn', () => { + child.unref(); + resolve(child); + }); + }); +} diff --git a/src/client/selectors.ts b/src/client/selectors.ts new file mode 100644 index 0000000..a85ec48 --- /dev/null +++ b/src/client/selectors.ts @@ -0,0 +1,61 @@ +import { realpath } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { isIP } from 'node:net'; +import { canonicalizeDomain } from '../protocol/domain.ts'; +import type { StateSnapshot, EntrypointRegistration } from '../protocol/dto.ts'; +import { CadderError } from '../protocol/errors.ts'; + +export async function projectForPath( + snapshot: StateSnapshot, + path: string, +): Promise { + const absolute = await realpath(resolve(path)).catch(() => resolve(path)); + const normalized = process.platform === 'win32' ? absolute.toLowerCase() : absolute; + const project = snapshot.registrations.find((registration) => { + const source = registration.sourceConfigPath.canonical ?? registration.sourceConfigPath.raw; + return (process.platform === 'win32' ? source.toLowerCase() : source) === normalized; + }); + if (!project) throw new CadderError('target-not-found', 'Caddyfile is not registered.'); + return project; +} + +export async function domainForName(snapshot: StateSnapshot, name: string, caddyfile?: string) { + const canonical = canonicalizeDomain(name); + const projects = caddyfile ? [await projectForPath(snapshot, caddyfile)] : snapshot.registrations; + const matches = projects.flatMap((project) => + project.registeredDomains + .filter((domain) => domain.name.canonical === canonical) + .map((domain) => ({ project, domain })), + ); + if (matches.length > 1) + throw new CadderError('target-conflict', 'Domain is ambiguous. Select its --caddyfile.'); + if (!matches[0]) throw new CadderError('target-not-found', 'Domain is not registered.'); + return matches[0]; +} + +export function localUpstreamPort(upstream: string | undefined): number | undefined { + if (!upstream) return; + const authority = upstream + .trim() + .replace(/^https?:\/\//, '') + .split('/')[0]!; + const separator = authority.lastIndexOf(':'); + const portText = separator < 0 ? authority : authority.slice(separator + 1); + if (!/^\d+$/.test(portText)) return; + const port = Number(portText); + if (!Number.isInteger(port) || port > 65535) return; + if (separator < 0) return port; + const host = authority.slice(0, separator).replace(/^\[|\]$/g, ''); + if (!host || host.toLowerCase() === 'localhost') return port; + if (isIP(host) === 4 && (host.startsWith('127.') || host === '0.0.0.0')) return port; + try { + if ( + isIP(host) === 6 && + !host.includes('%') && + ['[::1]', '[::]'].includes(new URL(`http://[${host}]`).hostname) + ) + return port; + } catch { + /* Unix sockets and remote/non-network upstreams have no local port owner. */ + } +} diff --git a/src/client/service.ts b/src/client/service.ts new file mode 100644 index 0000000..cbca2d0 --- /dev/null +++ b/src/client/service.ts @@ -0,0 +1,115 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import type { ClientServicePort, PortResult } from '../contracts/ports.ts'; +import type { RpcParams } from '../protocol/rpc.ts'; +import type { StateSnapshot } from '../protocol/dto.ts'; +import { CadderError, RpcError, errorCode } from '../protocol/errors.ts'; +import { resolvePaths, type RuntimePaths } from '../daemon/paths.ts'; +import { rpc } from './connection.ts'; +import { launchDaemon } from './launch.ts'; + +export class ClientService implements ClientServicePort { + constructor(readonly paths: RuntimePaths = resolvePaths()) {} + queryState() { + return this.#result(() => rpc(this.paths, 'query-state-request')); + } + queryLogs(params: RpcParams<'query-logs-request'>) { + return this.#result(() => rpc(this.paths, 'query-logs-request', params)); + } + setProjectEnabled(params: RpcParams<'set-entrypoint-enabled-request'>) { + return this.#result(() => rpc(this.paths, 'set-entrypoint-enabled-request', params)); + } + setDomainEnabled(params: RpcParams<'set-domain-enabled-request'>) { + return this.#result(() => rpc(this.paths, 'set-domain-enabled-request', params)); + } + shutdown() { + return this.#result(() => rpc(this.paths, 'shutdown-daemon-request')); + } + + async snapshot(): Promise { + const response = await rpc(this.paths, 'query-state-request'); + if (!response.accepted || !response.snapshot) + throw new CadderError('daemon-unavailable', 'Daemon state is unavailable.'); + return response.snapshot; + } + async start(args: readonly string[] = []): Promise { + try { + await this.snapshot(); + return; + } catch (error) { + if (errorCode(error) !== 'daemon-unavailable') throw error; + } + const child = await launchDaemon(args); + let ready = false; + let permissionsFailure: unknown; + try { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + try { + await this.snapshot(); + ready = true; + return; + } catch (error) { + // New Windows paths exist briefly before their owner-only ACL is published. + if (errorCode(error) === 'unsafe-runtime-permissions') permissionsFailure = error; + else if (errorCode(error) !== 'daemon-unavailable') throw error; + } + await delay(100); + } + if (permissionsFailure) throw permissionsFailure; + throw new CadderError( + 'daemon-start-failed', + 'cadderd did not become ready. Run cadderd in the foreground for diagnostics.', + ); + } finally { + if (!ready && child.exitCode === null) child.kill(); + } + } + async stop(): Promise { + try { + await rpc(this.paths, 'shutdown-daemon-request'); + } catch (error) { + if (errorCode(error) === 'daemon-unavailable') return; + throw error; + } + const deadline = Date.now() + 15000; + let unavailable = 0; + while (Date.now() < deadline) { + try { + await this.snapshot(); + unavailable = 0; + } catch (error) { + if (errorCode(error) === 'daemon-unavailable') { + if (++unavailable === 3) return; + } else if (errorCode(error) === 'daemon_shutting_down') unavailable = 0; + else throw error; + } + await delay(100); + } + throw new CadderError( + 'daemon-stop-failed', + 'cadderd did not finish shutdown within its deadline.', + ); + } + async restart(): Promise { + await this.stop(); + await this.start(); + } + async #result(operation: () => Promise): Promise> { + try { + return { ok: true, value: await operation() }; + } catch (error) { + if (error instanceof RpcError) return { ok: false, error: error.protocolError }; + return { + ok: false, + error: { + kind: 'internal', + code: errorCode(error)?.replaceAll('-', '_') ?? 'client_failed', + message: error instanceof CadderError ? error.message : 'Cannot contact cadderd.', + guidance: null, + retryable: false, + requestId: null, + }, + }; + } + } +} diff --git a/src/daemon/application.ts b/src/daemon/application.ts new file mode 100644 index 0000000..e73960f --- /dev/null +++ b/src/daemon/application.ts @@ -0,0 +1,441 @@ +import { createHash } from 'node:crypto'; +import { realpath } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; +import type { CaddyConfig, DesiredState, PortResult } from '../contracts/ports.ts'; +import { canonicalizeDomain, extractRegisteredDomains, activeDomains } from '../caddy/domains.ts'; +import { + composeRoutePlan, + type CaddyRoutePlan, + type PreparedCompositionInput, +} from '../caddy/composition.ts'; +import type { CaddyAdaptInput } from '../caddy/adapter.ts'; +import type { EntrypointRegistration, StateSnapshot } from '../protocol/dto.ts'; +import { registrationSchema } from '../protocol/dto.ts'; +import { RpcError, type ProtocolError } from '../protocol/errors.ts'; +import type { RpcHandler, RpcRequest, HandlerResult } from '../protocol/rpc.ts'; +import { redact } from '../storage/redaction.ts'; +import { + ConfigurationTransactions, + type ConfigurationSnapshot, +} from './configuration-transactions.ts'; +import type { LogInput, StoredLog } from '../storage/schema.ts'; +import { runtimeLogStream } from '../storage/schema.ts'; + +type ApplicationOptions = { + transactions: ConfigurationTransactions; + adapt(input: CaddyAdaptInput): Promise>; + secure(plan: CaddyRoutePlan): Promise>; + queryLogs(stream: LogInput['stream'], limit: number): Promise; + log(entry: LogInput): Promise; + shutdown(): void; + runtime(): StateSnapshot['runtime']; + storage: StateSnapshot['storage']; +}; +function reject( + kind: 'invalidInput' | 'conflict' | 'staleInstance' | 'configuration', + message: string, +): never { + throw new RpcError({ + kind, + code: kind === 'staleInstance' ? 'stale_session' : 'operation_rejected', + message, + guidance: null, + retryable: false, + requestId: null, + }); +} +function projectKey(registration: EntrypointRegistration): string { + const path = registration.sourceConfigPath.canonical!; + return createHash('sha256') + .update(process.platform === 'win32' ? path.toLowerCase() : path) + .digest('hex') + .slice(0, 32); +} +function unwrap(result: PortResult): T { + if (!result.ok) throw new RpcError(result.error); + return result.value; +} + +/** Live leases stay in memory; only the existing queue publishes configuration changes. */ +export class DaemonApplication { + readonly #heartbeats = new Map(); + #closing = false; + #lastError: ProtocolError | null = null; + #attemptedAt: string | null = null; + #appliedAt: string | null = null; + constructor(private readonly options: ApplicationOptions) {} + + readonly handle: RpcHandler = async (request) => { + const mutation = [ + 'register-entrypoint-request', + 'unregister-entrypoint-request', + 'set-entrypoint-enabled-request', + 'set-domain-enabled-request', + ].includes(request.method); + if (mutation) this.#attemptedAt = new Date().toISOString(); + try { + const result = await this.#execute(request); + if (mutation) { + this.#lastError = null; + this.#appliedAt = new Date().toISOString(); + } + return result; + } catch (error) { + if (mutation && error instanceof RpcError) { + this.#lastError = { ...error.protocolError, message: redact(error.message) }; + await this.#log(this.#lastError.message); + } + throw error; + } + }; + + async #execute(request: RpcRequest): Promise { + if (this.#closing) + throw new RpcError({ + kind: 'shuttingDown', + code: 'daemon_shutting_down', + message: 'The daemon is shutting down.', + guidance: null, + retryable: true, + requestId: null, + }); + const basic = { accepted: true, message: 'Operation completed.' }; + switch (request.method) { + case 'query-state-request': + return { ...basic, snapshot: this.snapshot() }; + case 'query-logs-request': { + const stream = request.params.stream; + const limit = request.params.limit ?? 100; + if (limit < 1 || limit > 200) reject('invalidInput', 'Log limit must be from 1 to 200.'); + const canonical = this.#stream(stream); + const entries = await this.options.queryLogs(canonical, limit); + return { + ...basic, + stream: canonical, + entries, + streamStatus: entries.length ? 'active' : 'empty', + }; + } + case 'shutdown-daemon-request': + this.#closing = true; + // Let IPC flush the response before stopping admission and the owned runtime. + setTimeout(() => this.options.shutdown(), 30); + return basic; + case 'heartbeat-entrypoint-request': { + const registration = this.#owned( + request.params.registrationId, + request.params.shimSessionNonce, + ); + this.#heartbeats.set(registration.registrationId, Date.now()); + return basic; + } + case 'register-entrypoint-request': { + let id = ''; + const result = await this.options.transactions.submit(async (committed) => { + const supplied = registrationSchema.parse(request.params.registration); + const nonce = supplied.entrypointInstance.shimSessionNonce; + if ( + !nonce || + nonce.length > 256 || + supplied.ownerProcess.shimSessionNonce !== nonce || + supplied.registrationId !== supplied.entrypointInstance.instanceId + ) + reject('invalidInput', 'Registration requires one matching session nonce.'); + if ( + !isAbsolute(supplied.sourceWorkingDirectory.raw) || + !isAbsolute(supplied.sourceConfigPath.raw) + ) + reject('invalidInput', 'Registration source paths must be absolute.'); + const registration = { + ...supplied, + sourceWorkingDirectory: { + ...supplied.sourceWorkingDirectory, + canonical: await realpath(supplied.sourceWorkingDirectory.raw), + }, + sourceConfigPath: { + ...supplied.sourceConfigPath, + canonical: await realpath(supplied.sourceConfigPath.raw), + }, + }; + const key = projectKey(registration); + id = supplied.registrationId; + const previous = committed.registrations.find((entry) => projectKey(entry) === key); + if ( + (previous && + (previous.entrypointInstance.shimSessionNonce !== nonce || + previous.registrationId !== id)) || + committed.registrations.some( + (entry) => entry.registrationId === id && projectKey(entry) !== key, + ) + ) + reject('conflict', 'This project already belongs to a live shim session.'); + const adapted = unwrap(await this.options.adapt(registration)); + const domains = extractRegisteredDomains(adapted); + if (!domains.ok) + reject('configuration', 'Cannot read project domains from its adapted configuration.'); + const existing = committed.desiredState.projects.find( + (entry) => entry.projectKey === key, + ); + const desired = { + projectKey: key, + sourceWorkingDirectory: registration.sourceWorkingDirectory.canonical, + sourceConfigPath: registration.sourceConfigPath.canonical, + enabled: existing?.enabled ?? true, + domains: domains.value.map((domain) => ({ + ...domain, + enabled: + existing?.domains.find((entry) => entry.canonicalDomain === domain.canonicalDomain) + ?.enabled ?? true, + })), + }; + const now = new Date().toISOString(); + registration.registrationId = id; + registration.createdAtUtc = previous?.createdAtUtc ?? now; + registration.lastHeartbeatUtc = now; + registration.logStream = { + streamId: `entrypoint-${id}`, + domainKey: null, + channel: 'caddy', + }; + registration.registeredDomains = desired.domains.map((domain) => ({ + name: { raw: domain.canonicalDomain, canonical: domain.canonicalDomain }, + activationState: domain.enabled ? 'active' : 'inactive', + upstream: domain.upstream ?? undefined, + logStream: { + streamId: `domain-${domain.canonicalDomain}`, + domainKey: domain.canonicalDomain, + channel: 'caddy', + }, + })); + registration.activationState = desired.enabled ? 'active' : 'inactive'; + const desiredState = { + projects: [ + ...committed.desiredState.projects.filter((entry) => entry.projectKey !== key), + desired, + ], + }; + return this.#prepare( + [ + ...committed.registrations.filter((entry) => entry.registrationId !== id), + registration, + ], + desiredState, + new Map([[id, adapted]]), + ); + }); + unwrap(result); + this.#heartbeats.set(id, Date.now()); + await this.#log('Project registered.'); + return { ...basic, registrationId: id }; + } + case 'unregister-entrypoint-request': { + unwrap( + await this.options.transactions.submit(async (committed) => { + this.#owned(request.params.registrationId, request.params.shimSessionNonce, committed); + return this.#prepare( + committed.registrations.filter( + (entry) => entry.registrationId !== request.params.registrationId, + ), + committed.desiredState, + ); + }), + ); + this.#heartbeats.delete(request.params.registrationId); + await this.#log('Project unregistered.'); + return basic; + } + case 'set-entrypoint-enabled-request': + case 'set-domain-enabled-request': { + const params = request.params; + unwrap( + await this.options.transactions.submit(async (committed) => { + const registration = committed.registrations.find( + (entry) => entry.registrationId === params.registrationId, + ); + if (!registration) reject('invalidInput', 'Project was not found.'); + if ( + request.method === 'set-entrypoint-enabled-request' && + request.params.shimSessionNonce !== null + ) + this.#owned(params.registrationId, request.params.shimSessionNonce, committed); + const registrations = structuredClone( + committed.registrations, + ) as EntrypointRegistration[]; + const desiredState = structuredClone(committed.desiredState) as { + projects: DesiredState['projects'][number][]; + }; + const desired = desiredState.projects.find( + (entry) => entry.projectKey === projectKey(registration), + )!; + const target = registrations.find( + (entry) => entry.registrationId === params.registrationId, + )!; + if (request.method === 'set-entrypoint-enabled-request') { + desiredState.projects = desiredState.projects.map((entry) => + entry === desired ? { ...entry, enabled: request.params.enabled } : entry, + ); + target.activationState = request.params.enabled ? 'active' : 'inactive'; + } else { + const domain = canonicalizeDomain(request.params.domainKey); + if (!target.registeredDomains.some((entry) => entry.name.canonical === domain)) + reject('invalidInput', 'Domain was not found.'); + desiredState.projects = desiredState.projects.map((entry) => + entry === desired + ? { + ...entry, + domains: entry.domains.map((item) => + item.canonicalDomain === domain + ? { ...item, enabled: request.params.enabled } + : item, + ), + } + : entry, + ); + for (const entry of target.registeredDomains) + if (entry.name.canonical === domain) + entry.activationState = request.params.enabled ? 'active' : 'inactive'; + } + return this.#prepare(registrations, desiredState); + }), + ); + return basic; + } + } + } + + snapshot(): StateSnapshot { + const committed = this.options.transactions.readCommitted(); + const fence = this.options.transactions.readFence() ?? this.#lastError; + return { + capturedAtUtc: new Date().toISOString(), + registrations: committed.registrations.map((entry) => ({ + ...structuredClone(entry), + lastHeartbeatUtc: new Date( + this.#heartbeats.get(entry.registrationId) ?? Date.parse(entry.lastHeartbeatUtc), + ).toISOString(), + })), + runtime: this.options.runtime(), + config: { + status: fence ? 'failed' : committed.config ? 'applied' : 'idle', + lastAttemptedAtUtc: this.#attemptedAt, + lastSuccessfulReloadAtUtc: this.#appliedAt, + effectiveConfigHash: committed.config?.effectiveConfigHash ?? null, + diagnostics: fence + ? [{ code: fence.code, message: fence.message, domainKey: null, sourceConfigPaths: [] }] + : [], + }, + storage: structuredClone(this.options.storage), + }; + } + + async expire(cutoff = Date.now() - 15000): Promise { + const stale = this.options.transactions + .readCommitted() + .registrations.filter( + (entry) => + (this.#heartbeats.get(entry.registrationId) ?? Date.parse(entry.lastHeartbeatUtc)) < + cutoff, + ); + if (!stale.length || this.#closing) return; + unwrap( + await this.options.transactions.submit(async (committed) => + this.#prepare( + committed.registrations.filter( + (entry) => + (this.#heartbeats.get(entry.registrationId) ?? Date.parse(entry.lastHeartbeatUtc)) >= + cutoff, + ), + committed.desiredState, + ), + ), + ); + for (const entry of stale) + if ( + !this.options.transactions + .readCommitted() + .registrations.some((current) => current.registrationId === entry.registrationId) + ) + this.#heartbeats.delete(entry.registrationId); + } + close(): void { + this.#closing = true; + } + + #owned( + id: string, + nonce: string, + committed = this.options.transactions.readCommitted(), + ): EntrypointRegistration { + const registration = committed.registrations.find((entry) => entry.registrationId === id); + if (!registration || registration.entrypointInstance.shimSessionNonce !== nonce) + reject('staleInstance', 'This shim no longer owns the project session.'); + return registration; + } + async #prepare( + registrations: readonly EntrypointRegistration[], + desiredState: DesiredState, + prepared = new Map(), + ): Promise> { + const inputs: PreparedCompositionInput[] = []; + for (const registration of registrations) { + const config = activeDomains(registration).length + ? (prepared.get(registration.registrationId) ?? + unwrap(await this.options.adapt(registration))) + : undefined; + inputs.push({ registration, ...(config ? { config } : {}) }); + } + const plan = composeRoutePlan(inputs); + if (!plan.ok) reject('conflict', 'Project domains conflict or cannot be composed.'); + const config = plan.value.tlsSubjects.length + ? unwrap(await this.options.secure(plan.value)) + : null; + return { ok: true, value: { registrations, desiredState, config } }; + } + #stream(input: LogInput['stream']): LogInput['stream'] { + if ( + input.streamId === runtimeLogStream.streamId && + input.domainKey === null && + input.channel === runtimeLogStream.channel + ) + return runtimeLogStream; + for (const registration of this.options.transactions.readCommitted().registrations) { + for (const stream of [ + registration.logStream, + ...registration.registeredDomains.map((domain) => domain.logStream), + ]) { + if ( + stream.streamId === input.streamId && + stream.domainKey === input.domainKey && + stream.channel === input.channel + ) + return stream; + } + } + reject('invalidInput', 'Select one current project, domain or runtime log stream.'); + } + async #log(rawMessage: string): Promise { + try { + await this.options.log({ + timestampUtc: new Date().toISOString(), + severity: 'info', + stream: runtimeLogStream, + attributionKind: 'runtimeControl', + entryKind: 'lifecycle', + rawMessage, + domainKey: null, + sourceRegistrationId: null, + sourceInstanceId: null, + operation: null, + }); + } catch { + if (this.options.storage) + this.options.storage.diagnostics = [ + { + code: 'log_persistence_failed', + message: 'A diagnostic could not be retained; committed routes are unchanged.', + operation: null, + }, + ]; + } + } +} diff --git a/src/daemon/caddy-logs.ts b/src/daemon/caddy-logs.ts new file mode 100644 index 0000000..376a1d0 --- /dev/null +++ b/src/daemon/caddy-logs.ts @@ -0,0 +1,126 @@ +import { StringDecoder } from 'node:string_decoder'; +import type { EntrypointRegistration } from '../protocol/dto.ts'; +import { canonicalizeDomain } from '../protocol/domain.ts'; +import { runtimeLogStream, type LogInput } from '../storage/schema.ts'; + +/** Bounded line decoding; private-key continuation lines never become separate events. */ +export class CaddyLogs { + readonly #streams = { + stdout: { + decoder: new StringDecoder('utf8'), + line: '', + marker: '', + begin: false, + oversized: false, + privateKey: false, + }, + stderr: { + decoder: new StringDecoder('utf8'), + line: '', + marker: '', + begin: false, + oversized: false, + privateKey: false, + }, + }; + #pending = new Set>(); + constructor( + private readonly registrations: () => readonly EntrypointRegistration[], + private readonly append: (entries: LogInput[]) => Promise, + private readonly failed: () => void, + ) {} + accept = (channel: 'stdout' | 'stderr', bytes: Buffer): void => { + const stream = this.#streams[channel]; + for (const character of stream.decoder.write(bytes)) { + stream.marker = (stream.marker + character).slice(-11); + if (stream.marker.toUpperCase() === '-----BEGIN ') stream.begin = true; + if (stream.oversized && stream.begin) stream.privateKey = true; + if (character === '\n') { + if (!stream.oversized) this.#line(stream.line, stream); + stream.line = ''; + stream.marker = ''; + stream.begin = false; + stream.oversized = false; + } else if (!stream.oversized) { + if (stream.line.length < 8192) stream.line += character; + else { + stream.line = ''; + stream.oversized = true; + if (stream.begin) stream.privateKey = true; + this.failed(); + } + } + } + }; + async close(): Promise { + for (const stream of Object.values(this.#streams)) { + stream.line += stream.decoder.end(); + if (stream.line && !stream.oversized) this.#line(stream.line, stream); + stream.line = ''; + } + await Promise.all(this.#pending); + } + #line(line: string, stream: { privateKey: boolean }): void { + if (stream.privateKey) { + if (/-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----/i.test(line)) stream.privateKey = false; + return; + } + if ( + /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/i.test(line) && + !/-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----/i.test(line) + ) + stream.privateKey = true; + let message = line; + let host: string | undefined; + let level: LogInput['severity'] = 'info'; + try { + const parsed = JSON.parse(line) as { + msg?: unknown; + level?: unknown; + request?: { host?: unknown }; + identifier?: unknown; + }; + if (typeof parsed.msg === 'string') message = parsed.msg; + const candidate = parsed.request?.host ?? parsed.identifier; + if (typeof candidate === 'string') host = canonicalizeDomain(candidate.replace(/:\d+$/, '')); + if (['trace', 'debug', 'info', 'warn', 'error', 'fatal'].includes(String(parsed.level))) + level = parsed.level as LogInput['severity']; + } catch { + /* Plain upstream diagnostic lines use the same redaction boundary. */ + } + const event: LogInput = { + timestampUtc: new Date().toISOString(), + severity: level, + stream: runtimeLogStream, + attributionKind: 'runtime', + entryKind: 'normal', + rawMessage: message, + domainKey: null, + sourceRegistrationId: null, + sourceInstanceId: null, + operation: null, + }; + const entries = [event]; + for (const project of this.registrations()) { + const domain = project.registeredDomains.find((entry) => entry.name.canonical === host); + if (domain) + for (const logStream of [project.logStream, domain.logStream]) + entries.push({ + ...event, + stream: logStream, + attributionKind: logStream.domainKey ? 'domain' : 'entrypoint', + domainKey: logStream.domainKey, + sourceRegistrationId: project.registrationId, + sourceInstanceId: project.entrypointInstance.instanceId, + }); + } + if (this.#pending.size >= 64) { + this.failed(); + return; + } + const pending = this.append(entries) + .catch(this.failed) + .finally(() => this.#pending.delete(pending)); + this.#pending.add(pending); + } +} diff --git a/src/daemon/configuration-transactions.ts b/src/daemon/configuration-transactions.ts index 6ccc0e7..9fd995b 100644 --- a/src/daemon/configuration-transactions.ts +++ b/src/daemon/configuration-transactions.ts @@ -9,6 +9,7 @@ import type { } from '../contracts/ports.ts'; import { registrationSchema, type EntrypointRegistration } from '../protocol/dto.ts'; import { RpcError, type ProtocolError } from '../protocol/errors.ts'; +import { desiredStateSchema } from '../storage/schema.ts'; export type ConfigurationSnapshot = Readonly<{ config: CaddyConfig | null; @@ -46,23 +47,7 @@ const configurationSnapshotSchema = z.strictObject({ adaptedConfig: z.strictObject({ format: z.literal('json'), body: z.string() }), }) .nullable(), - desiredState: z.strictObject({ - projects: z.array( - z.strictObject({ - projectKey: z.string(), - sourceWorkingDirectory: z.string(), - sourceConfigPath: z.string(), - enabled: z.boolean(), - domains: z.array( - z.strictObject({ - canonicalDomain: z.string(), - upstream: z.string().nullable(), - enabled: z.boolean(), - }), - ), - }), - ), - }), + desiredState: desiredStateSchema, registrations: z.array(registrationSchema), }); @@ -203,7 +188,8 @@ export class ConfigurationTransactions { 'config_prepare_failed', 'Configuration preparation was rejected.', ); - } catch { + } catch (error) { + if (error instanceof RpcError) return { ok: false, error: error.protocolError }; return failure( 'configuration', 'config_prepare_failed', diff --git a/src/daemon/local-runtime.ts b/src/daemon/local-runtime.ts index a14233d..55aee88 100644 --- a/src/daemon/local-runtime.ts +++ b/src/daemon/local-runtime.ts @@ -2,6 +2,7 @@ import { resolvePaths, type RuntimePaths } from './paths.ts'; import { acquireRuntimeLock } from './runtime-lock.ts'; import { serveIpc } from './ipc-server.ts'; import { prepareRuntime, runtimeOwner } from '../platform/runtime-security.ts'; +import type { RuntimeOwner } from '../contracts/ports.ts'; import type { RpcHandler } from '../protocol/rpc.ts'; import { CadderError } from '../protocol/errors.ts'; @@ -17,6 +18,7 @@ export async function startLocalRuntime( profile?: string; runtimeOwner?: number; installationRoot?: string; + initialize?: (paths: RuntimePaths, owner: RuntimeOwner) => Promise<{ close(): Promise }>; }, handler: RpcHandler, onDenied?: () => void, @@ -31,7 +33,9 @@ export async function startLocalRuntime( const paths = resolvePaths(options); await prepareRuntime(paths.directory, owner); const lock = await acquireRuntimeLock(paths, owner); + let owned: { close(): Promise } | undefined; try { + owned = await options.initialize?.(paths, owner); const listener = await serveIpc(paths, owner, lock.secret, handler, onDenied); let stopping: Promise | undefined; return { @@ -39,6 +43,8 @@ export async function startLocalRuntime( recovered: lock.recovered, stop() { stopping ??= (async () => { + // Keep the endpoint/exclusion until owned teardown is proved complete. + await owned?.close(); const failures: unknown[] = []; for (const finalize of [() => listener.close(), () => lock.release()]) { try { @@ -57,6 +63,7 @@ export async function startLocalRuntime( } catch (error) { const failures = [error]; try { + await owned?.close(); await lock.release(); } catch (cleanupError) { failures.push(cleanupError); diff --git a/src/daemon/start.ts b/src/daemon/start.ts new file mode 100644 index 0000000..ca3dc71 --- /dev/null +++ b/src/daemon/start.ts @@ -0,0 +1,200 @@ +import { createServer } from 'node:net'; +import { join } from 'node:path'; +import { CaddyConfigAdapter } from '../caddy/adapter.ts'; +import { + configForRoutePlan, + prepareSecureAdminPolicy, + type SecureAdminPolicy, +} from '../caddy/admin-policy.ts'; +import { RealCaddyResolver } from '../caddy/resolver.ts'; +import { CaddyRuntime } from '../caddy/runtime.ts'; +import { CaddyConfigValidator } from '../caddy/validation.ts'; +import { ApplicationStorage } from '../storage/storage.ts'; +import { runtimeLogStream } from '../storage/schema.ts'; +import { RpcError } from '../protocol/errors.ts'; +import { ConfigurationTransactions } from './configuration-transactions.ts'; +import { DaemonApplication } from './application.ts'; +import { CaddyLogs } from './caddy-logs.ts'; +import type { StateSnapshot } from '../protocol/dto.ts'; +import { startLocalRuntime, type LocalRuntime } from './local-runtime.ts'; + +/** Reserve a candidate address; Caddy startup still refuses conflicts instead of adoption. */ +async function adminPort(): Promise { + const server = createServer(); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close((error) => { + if (error || !address || typeof address === 'string') + reject(error ?? new Error('Cannot allocate administration address.')); + else resolve(address.port); + }); + }); + }); +} + +export async function startDaemon( + options: { + runtimeDir?: string; + runtimeOwner?: number; + installationRoot?: string; + realCaddy?: string; + } = {}, +): Promise { + let application: DaemonApplication; + let expiry: NodeJS.Timeout | undefined; + const runtime: LocalRuntime = await startLocalRuntime( + { + ...options, + initialize: async (paths, owner) => { + const storage = await ApplicationStorage.open(paths.directory, owner); + const resolver = new RealCaddyResolver({ + ...(options.realCaddy ? { explicitOverride: options.realCaddy } : {}), + }); + let caddy: CaddyRuntime | undefined; + let logs: CaddyLogs | undefined; + let transactions: ConfigurationTransactions | undefined; + let policy: SecureAdminPolicy | undefined; + let validator: CaddyConfigValidator | undefined; + try { + const desired = await storage.loadDesiredState(); + if (!desired.ok) throw new RpcError(desired.error); + const storageStatus: NonNullable = { + backend: 'sqlite', + path: paths.history, + schemaVersion: 1, + diagnostics: [], + }; + logs = new CaddyLogs( + () => application?.snapshot().registrations ?? [], + storage.append.bind(storage), + () => { + storageStatus.diagnostics = [ + { + code: 'log_ingestion_failed', + message: 'Some Caddy diagnostics could not be retained.', + operation: null, + }, + ]; + }, + ); + const adapter = new CaddyConfigAdapter(resolver); + transactions = new ConfigurationTransactions( + { config: null, registrations: [], desiredState: desired.value }, + { validate: (config) => validator!.validate(config) }, + storage, + { + apply: (config) => + caddy + ? caddy.apply(config) + : Promise.resolve({ + status: config === null ? 'applied' : 'definitely-rejected', + }), + readActive: () => + caddy ? caddy.readActive() : Promise.resolve({ ok: true, value: null }), + }, + ); + let binaryPath: string | null = null; + let version: string | null = null; + application = new DaemonApplication({ + transactions, + adapt: async (input) => { + const adapted = await adapter.adapt(input); + if (adapted.ok) { + const image = await resolver.pin(); + binaryPath = image.path; + version = image.evidence.version; + } + return adapted; + }, + secure: async (plan) => { + if (!policy) { + const secured = await prepareSecureAdminPolicy({ + runtimeBase: join(paths.directory, 'caddy'), + materialDirectory: join(paths.directory, 'admin'), + owner, + adminPort: await adminPort(), + plan, + }); + if (!secured.ok) return secured; + policy = secured.value; + caddy = new CaddyRuntime(policy, owner, resolver, { onOutput: logs!.accept }); + validator = new CaddyConfigValidator(resolver, { env: policy.environment }); + } + return { ok: true, value: configForRoutePlan(policy, plan) }; + }, + queryLogs: storage.queryLogs.bind(storage), + log: async (entry) => storage.append([entry]), + shutdown: () => { + void runtime.stop().catch(() => { + process.exitCode = 1; + }); + }, + runtime: () => ({ + status: caddy?.status() ?? 'idle', + binaryPath, + version, + processId: null, + adminEndpoint: policy ? `https://127.0.0.1:${policy.client.port}` : null, + diagnostics: [], + }), + storage: storageStatus, + }); + await storage.append([ + { + timestampUtc: new Date().toISOString(), + severity: 'info', + stream: runtimeLogStream, + attributionKind: 'runtimeControl', + entryKind: 'lifecycle', + rawMessage: 'Daemon started.', + domainKey: null, + sourceRegistrationId: null, + sourceInstanceId: null, + operation: null, + }, + ]); + let expiring = false; + expiry = setInterval(() => { + if (expiring) return; + expiring = true; + void application + .expire() + .catch(async () => { + // An expired route must not keep serving after a failed revocation. + await runtime.stop().catch(() => { + process.exitCode = 1; + }); + }) + .finally(() => { + expiring = false; + }); + }, 1000); + expiry.unref(); + return { + close: async () => { + clearInterval(expiry); + application.close(); + await transactions!.close(); + await caddy?.close(); + await logs!.close(); + await resolver.close(); + await storage.close(); + }, + }; + } catch (error) { + clearInterval(expiry); + await transactions?.close(); + await caddy?.close(); + await logs?.close(); + await resolver.close(); + await storage.close(); + throw error; + } + }, + }, + (request) => application.handle(request), + ); + return runtime; +} diff --git a/src/entrypoints/cadder.ts b/src/entrypoints/cadder.ts new file mode 100644 index 0000000..564af05 --- /dev/null +++ b/src/entrypoints/cadder.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +import { runCli } from '../cli/run.ts'; +process.exitCode = await runCli(process.argv.slice(2)); diff --git a/src/entrypoints/cadderd.ts b/src/entrypoints/cadderd.ts new file mode 100644 index 0000000..f624739 --- /dev/null +++ b/src/entrypoints/cadderd.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import { Command, InvalidArgumentError } from 'commander'; +import { startDaemon } from '../daemon/start.ts'; +import { ClientService } from '../client/service.ts'; +import { resolvePaths } from '../daemon/paths.ts'; +import { VERSION } from '../protocol/version.ts'; +import { terminalSafe } from '../storage/redaction.ts'; + +const program = new Command('cadderd') + .version(VERSION) + .description('Own local IPC, project registrations, Caddy and diagnostic storage.') + .option('--real-caddy ', 'Absolute trusted upstream Caddy executable') + .option('--runtime-dir ', 'Explicit isolated runtime directory') + .option('--runtime-owner ', 'Explicit Unix runtime owner UID', (value) => { + if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) + throw new InvalidArgumentError('Expected a Unix UID.'); + return Number(value); + }) + .option('--background', 'Start or attach to the packaged daemon in the background'); +await program.parseAsync(); +const options = program.opts<{ + realCaddy?: string; + runtimeDir?: string; + runtimeOwner?: number; + background?: boolean; +}>(); +try { + if (options.background) { + const args = process.argv.slice(2).filter((argument) => argument !== '--background'); + await new ClientService(resolvePaths(options)).start(args); + } else { + const runtime = await startDaemon(options); + const stop = (): void => { + void runtime + .stop() + .then(() => { + process.off('SIGINT', stop); + process.off('SIGTERM', stop); + }) + .catch(() => { + process.stderr.write('Owned daemon shutdown failed.\n'); + process.exitCode = 1; + }); + }; + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + } +} catch (error) { + process.stderr.write( + terminalSafe(error instanceof Error ? error.message : 'Daemon startup failed.') + '\n', + ); + process.exitCode = 1; +} diff --git a/src/entrypoints/caddy.ts b/src/entrypoints/caddy.ts new file mode 100644 index 0000000..71568c1 --- /dev/null +++ b/src/entrypoints/caddy.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import { runShim } from '../shim/run.ts'; +import { terminalSafe } from '../storage/redaction.ts'; +try { + process.exitCode = await runShim(process.argv.slice(2)); +} catch (error) { + process.stderr.write( + terminalSafe(error instanceof Error ? error.message : 'Caddy shim failed.') + '\n', + ); + process.exitCode = 1; +} diff --git a/src/platform/inspection.ts b/src/platform/inspection.ts new file mode 100644 index 0000000..972f0f0 --- /dev/null +++ b/src/platform/inspection.ts @@ -0,0 +1,110 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { readlink } from 'node:fs/promises'; +import { powershell } from './powershell.ts'; +import { CadderError } from '../protocol/errors.ts'; + +const execute = promisify(execFile); +export type PortOwner = { + protocol: 'tcp' | 'udp'; + address: string; + port: number; + pid: number | null; + processName: string | null; + executable: string | null; +}; + +export async function inspectPort(port: number): Promise { + if (!Number.isInteger(port) || port < 0 || port > 65535) + throw new CadderError('invalid-input', 'Port must be from 0 to 65535.'); + if (process.platform === 'win32') { + return JSON.parse( + await powershell(` + $result = @(); + foreach ($kind in @('tcp', 'udp')) { + $sockets = if ($kind -eq 'tcp') { @(Get-NetTCPConnection -State Listen | Where-Object LocalPort -eq ${port}) } else { @(Get-NetUDPEndpoint | Where-Object LocalPort -eq ${port}) }; + foreach ($socket in $sockets) { + $ownerProcess = Get-Process -Id $socket.OwningProcess -ErrorAction SilentlyContinue; + $result += @{ protocol=$kind; address=[string]$socket.LocalAddress; port=[int]$socket.LocalPort; pid=[int]$socket.OwningProcess; processName=$ownerProcess.ProcessName; executable=$ownerProcess.Path }; + } + }; + ConvertTo-Json -InputObject @($result) -Compress + `), + ) as PortOwner[]; + } + if (process.platform === 'linux') { + const { stdout } = await execute('ss', ['-H', '-lntup', `sport = :${port}`], { + timeout: 5000, + maxBuffer: 1024 * 1024, + }); + const owners: PortOwner[] = []; + for (const line of stdout.trim().split('\n').filter(Boolean)) { + const fields = line.trim().split(/\s+/); + const protocol = fields[0]; + if (protocol !== 'tcp' && protocol !== 'udp') continue; + const address = fields[4]!.replace(/:\d+$/, ''); + const processes = [...line.matchAll(/\("([^"]*)",pid=(\d+)/g)]; + if (!processes.length) + owners.push({ protocol, address, port, pid: null, processName: null, executable: null }); + for (const match of processes) { + const pid = Number(match[2]); + owners.push({ + protocol, + address, + port, + pid, + processName: match[1] ?? null, + executable: await readlink(`/proc/${pid}/exe`).catch(() => null), + }); + } + } + return owners; + } + if (process.platform === 'darwin') { + let output: string; + try { + output = ( + await execute( + '/usr/sbin/lsof', + ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', `-iUDP:${port}`, '-FpcPn'], + { timeout: 5000, maxBuffer: 1024 * 1024 }, + ) + ).stdout; + } catch (error) { + if ((error as { code?: unknown }).code === 1) return []; + throw error; + } + const owners: PortOwner[] = []; + let pid: number | null = null; + let name: string | null = null; + let protocol: 'tcp' | 'udp' = 'tcp'; + for (const line of output.split('\n')) { + if (line.startsWith('p')) pid = Number(line.slice(1)); + if (line.startsWith('c')) name = line.slice(1); + if (line.startsWith('P')) protocol = line.slice(1) === 'UDP' ? 'udp' : 'tcp'; + if (line.startsWith('n')) + owners.push({ + protocol, + address: line.slice(1).replace(/:\d+$/, ''), + port, + pid, + processName: name, + executable: null, + }); + } + return owners; + } + throw new CadderError( + 'unsupported-operation', + 'Socket inspection is unavailable on this platform.', + ); +} + +export async function terminatePortOwner(port: number, pid: number): Promise { + if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) + throw new CadderError('invalid-input', 'Expected a different positive process ID.'); + const owners = await inspectPort(port); + if (!owners.some((owner) => owner.pid === pid)) + throw new CadderError('target-conflict', 'The expected process no longer owns this port.'); + process.kill(pid, 'SIGTERM'); +} diff --git a/src/platform/owned-command.ts b/src/platform/owned-command.ts index e9e2339..4bb66b5 100644 --- a/src/platform/owned-command.ts +++ b/src/platform/owned-command.ts @@ -11,6 +11,8 @@ export interface OwnedCommandOptions { longLived?: boolean; /** Verified native creation/resume, not application readiness. Callback failures stop owned work. */ onStarted?: () => void; + /** Observes application bytes only, after native framing has been removed. */ + onOutput?: (stream: 'stdout' | 'stderr', bytes: Buffer) => void; /** Verify immediately before actual native creation (after Windows wrapper startup). */ beforeSpawn?: () => Promise; /** Reverify the pinned image after creation; Windows does this while it is suspended. */ @@ -237,7 +239,9 @@ export async function runOwnedCommand( maxStreamBytes: options.longLived ? -1 : limit, }), ).toString('base64'); - for (const key of Object.keys(env)) if (key.toLowerCase() === 'psmodulepath') delete env[key]; + for (const key of Object.keys(env)) + if (['psmodulepath', 'lib', 'libpath', 'include'].includes(key.toLowerCase())) + delete env[key]; env.PSModulePath = join(executable, '..', 'Modules'); } return await new Promise((resolve, reject) => { @@ -420,6 +424,12 @@ export async function runOwnedCommand( bytes = combined.subarray(0, payloadBytes); stdoutTail = Buffer.from(combined.subarray(payloadBytes)); } + try { + if (bytes.length) options.onOutput?.(index === 0 ? 'stdout' : 'stderr', bytes); + } catch (error) { + fail(error); + return; + } if (options.longLived) return; counts[index]! += bytes.length; if (counts[index]! > limit) { diff --git a/src/platform/powershell.ts b/src/platform/powershell.ts index cdda8d7..675a3b6 100644 --- a/src/platform/powershell.ts +++ b/src/platform/powershell.ts @@ -19,9 +19,10 @@ export async function powershell(script: string): Promise { 'v1.0', 'powershell.exe', ); - // Windows worker environments can contain differently cased duplicate keys. + // Add-Type must not inherit unrelated native compiler search paths. + const excluded = new Set(['psmodulepath', 'lib', 'libpath', 'include']); const environment = Object.fromEntries( - Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'psmodulepath'), + Object.entries(process.env).filter(([key]) => !excluded.has(key.toLowerCase())), ); environment.PSModulePath = join(dirname(executable), 'Modules'); const result = await execute( diff --git a/src/protocol/domain.ts b/src/protocol/domain.ts new file mode 100644 index 0000000..e3952af --- /dev/null +++ b/src/protocol/domain.ts @@ -0,0 +1,17 @@ +import { domainToASCII } from 'node:url'; + +/** Match literal-domain IDNA without invoking URL decoding or host parsing. */ +export function canonicalizeDomain(raw: string): string { + const domain = raw.trim().replace(/\.+$/, ''); + const fallback = domain.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); + if ( + /[\s%/:\\@#?]/u.test(domain) || + domain.includes('[') || + domain.includes(']') || + [...domain].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) + ) + return fallback; + const suffix = '.cadder-idna'; + const mapped = domainToASCII(domain + suffix); + return mapped.endsWith(suffix) ? mapped.slice(0, -suffix.length) : fallback; +} diff --git a/src/shim/run.ts b/src/shim/run.ts new file mode 100644 index 0000000..e99393e --- /dev/null +++ b/src/shim/run.ts @@ -0,0 +1,210 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { ClientService } from '../client/service.ts'; +import { rpc } from '../client/connection.ts'; +import { RealCaddyResolver } from '../caddy/resolver.ts'; +import { VERSION } from '../protocol/version.ts'; +import { CadderError, errorCode } from '../protocol/errors.ts'; +import type { EntrypointRegistration } from '../protocol/dto.ts'; + +const inspection = new Set([ + 'adapt', + 'build-info', + 'environ', + 'help', + 'list-modules', + 'validate', + 'version', +]); +const passthrough = new Set(['completion', 'file-server', 'fmt', 'manpage', 'reverse-proxy']); +export function shimPolicy( + argv: readonly string[], +): 'managed' | 'inspection' | 'passthrough' | 'unsupported' { + const raw = argv[0] ?? 'help'; + const command = ['--help', '-h'].includes(raw) + ? 'help' + : ['--version', '-v'].includes(raw) + ? 'version' + : raw; + return command === 'run' + ? 'managed' + : inspection.has(command) + ? 'inspection' + : passthrough.has(command) + ? 'passthrough' + : 'unsupported'; +} + +export function buildRegistration( + argv: readonly string[], + cwd = process.cwd(), +): EntrypointRegistration { + let config = resolve(cwd, 'Caddyfile'); + let adapter: string | null = null; + for (let index = 1; index < argv.length; index++) { + if (['--config', '-c', '--adapter', '-a'].includes(argv[index]!)) { + const flag = argv[index]!; + const value = argv[++index]; + if (!value) throw new CadderError('invalid-input', `${flag} requires a value.`); + if (flag === '--config' || flag === '-c') config = resolve(cwd, value); + else adapter = value; + } + } + const nonce = randomUUID(); + const instanceId = randomUUID(); + const now = new Date().toISOString(); + return { + registrationId: instanceId, + entrypointInstance: { instanceId, startedAtUtc: now, shimSessionNonce: nonce }, + sourceWorkingDirectory: { raw: resolve(cwd), canonical: null }, + sourceConfigPath: { raw: config, canonical: null }, + registeredDomains: [], + activationState: 'active', + ownerProcess: { + processId: process.pid, + processStartTimeUtc: new Date(Date.now() - process.uptime() * 1000).toISOString(), + shimSessionNonce: nonce, + executablePath: process.execPath, + }, + logStream: { streamId: `entrypoint-${instanceId}`, domainKey: null, channel: 'caddy' }, + shimRun: { adapter, rawArguments: [...argv], commandLine: ['caddy', ...argv].join(' ') }, + createdAtUtc: now, + lastHeartbeatUtc: now, + }; +} + +export async function managedRun( + argv: readonly string[], + client = new ClientService(), + signal?: AbortSignal, +): Promise { + const abort = new AbortController(); + const stop = (): void => abort.abort(); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + signal?.addEventListener('abort', stop, { once: true }); + if (signal?.aborted) stop(); + const registration = buildRegistration(argv); + const nonce = registration.entrypointInstance.shimSessionNonce; + let id: string | null = null; + let cleanupFailure: unknown; + try { + if (abort.signal.aborted) return; + await client.start(); + if (abort.signal.aborted) return; + const register = async (): Promise => { + const reply = await rpc(client.paths, 'register-entrypoint-request', { registration }); + if (!reply.accepted || !reply.registrationId) + throw new CadderError('registration-rejected', reply.message); + return reply.registrationId; + }; + id = await register(); + let disconnectedAt: number | undefined; + while (!abort.signal.aborted) { + try { + await delay(5000, undefined, { signal: abort.signal }); + } catch { + break; + } + try { + const reply = await rpc(client.paths, 'heartbeat-entrypoint-request', { + registrationId: id, + shimSessionNonce: nonce, + }); + if (!reply.accepted) throw new CadderError('stale_session', reply.message); + disconnectedAt = undefined; + } catch (error) { + if (errorCode(error) === 'stale_session') { + id = await register(); + disconnectedAt = undefined; + } else if ( + ['daemon-unavailable', 'daemon_shutting_down'].includes(errorCode(error) ?? '') + ) { + disconnectedAt ??= Date.now(); + if (Date.now() - disconnectedAt >= 15000) + throw new CadderError( + 'daemon-unavailable', + 'Lost cadderd. Restart the daemon and run caddy run again.', + ); + } else throw error; + } + } + } finally { + process.off('SIGINT', stop); + process.off('SIGTERM', stop); + signal?.removeEventListener('abort', stop); + if (id) { + try { + await rpc(client.paths, 'unregister-entrypoint-request', { + registrationId: id, + shimSessionNonce: nonce, + }); + } catch (error) { + if ( + !['daemon-unavailable', 'daemon_shutting_down', 'stale_session'].includes( + errorCode(error) ?? '', + ) + ) + cleanupFailure = error; + } + } + } + if (cleanupFailure) throw cleanupFailure; +} + +export async function runShim(argv: readonly string[]): Promise { + if (argv.includes('--cadder-shim-info')) { + process.stdout.write( + JSON.stringify({ role: 'caddy-shim', version: VERSION, executable: process.execPath }) + '\n', + ); + return 0; + } + const policy = shimPolicy(argv); + if (policy === 'unsupported') + throw new CadderError( + 'unsupported-operation', + `Cadder shim does not support caddy ${argv[0] ?? ''}.`, + ); + if (policy === 'managed') { + await managedRun(argv); + return 0; + } + const resolver = new RealCaddyResolver(); + try { + const image = await resolver.pin(); + await image.verify(); + if (policy === 'inspection') + process.stderr.write('Output is real-Caddy inspection, not Cadder runtime state.\n'); + return await new Promise((resolveExit, reject) => { + const child = spawn(image.path, [...argv], { + stdio: 'inherit', + shell: false, + windowsHide: true, + }); + const interrupt = (): void => { + child.kill('SIGINT'); + }; + const terminate = (): void => { + child.kill('SIGTERM'); + }; + process.once('SIGINT', interrupt); + process.once('SIGTERM', terminate); + const cleanup = (): void => { + process.off('SIGINT', interrupt); + process.off('SIGTERM', terminate); + }; + child.once('error', (error) => { + cleanup(); + reject(error); + }); + child.once('exit', (code, signal) => { + cleanup(); + resolveExit(code ?? (signal === 'SIGINT' ? 130 : 1)); + }); + }); + } finally { + await resolver.close(); + } +} diff --git a/src/storage/database.ts b/src/storage/database.ts new file mode 100644 index 0000000..11912b8 --- /dev/null +++ b/src/storage/database.ts @@ -0,0 +1,120 @@ +import { DatabaseSync } from 'node:sqlite'; +import type { DesiredState } from '../contracts/ports.ts'; +import { logStreamSchema } from '../protocol/dto.ts'; +import { CadderError } from '../protocol/errors.ts'; +import { desiredStateSchema, logInputSchema, type LogInput, type StoredLog } from './schema.ts'; +import { redact } from './redaction.ts'; + +const tables = [ + 'CREATE TABLE desired (id INTEGER PRIMARY KEY CHECK (id = 1), body TEXT NOT NULL) STRICT', + 'CREATE TABLE logs (sequence INTEGER PRIMARY KEY AUTOINCREMENT, stream TEXT NOT NULL, body TEXT NOT NULL) STRICT', + 'CREATE INDEX logs_stream ON logs (stream, sequence)', +]; +function storageError(): CadderError { + return new CadderError('storage-failed', 'Application storage is unavailable or incompatible.'); +} +function streamKey(stream: unknown): string { + const value = logStreamSchema.parse(stream); + return JSON.stringify([value.streamId, value.domainKey, value.channel]); +} + +/** Only the storage worker opens this connection in production. */ +export class ApplicationDatabase { + readonly #database: DatabaseSync; + constructor(path: string) { + this.#database = new DatabaseSync(path, { timeout: 1000 }); + try { + const db = this.#database; + const integrity = db.prepare('PRAGMA quick_check').all(); + if (integrity.length !== 1 || integrity[0]?.quick_check !== 'ok') throw storageError(); + const version = db.prepare('PRAGMA user_version').get()?.user_version; + const objects = db + .prepare("SELECT sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY name") + .all(); + if (version === 0 && objects.length === 0) { + this.#transaction(() => { + for (const sql of tables) db.exec(sql); + db.prepare('INSERT INTO desired VALUES (1, ?)').run(JSON.stringify({ projects: [] })); + db.exec('PRAGMA user_version = 1'); + }); + } else { + if ( + version !== 1 || + objects.length !== tables.length || + objects.some((object) => !tables.includes(String(object.sql))) + ) + throw storageError(); + } + this.loadDesiredState(); + } catch (error) { + this.#database.close(); + throw error instanceof CadderError ? error : storageError(); + } + } + + loadDesiredState(): DesiredState { + const rows = this.#database + .prepare('SELECT body FROM desired WHERE length(CAST(body AS BLOB)) <= 1048576') + .all(); + if (rows.length !== 1 || typeof rows[0]?.body !== 'string') throw storageError(); + return desiredStateSchema.parse(JSON.parse(rows[0].body)); + } + + persistDesiredState(state: DesiredState): void { + const body = JSON.stringify(desiredStateSchema.parse(state)); + if (Buffer.byteLength(body) > 1024 * 1024) throw storageError(); + this.#transaction(() => + this.#database.prepare('UPDATE desired SET body = ? WHERE id = 1').run(body), + ); + } + + append(entries: readonly LogInput[]): void { + if (entries.length > 200) throw storageError(); + const values = entries.map((entry) => { + const parsed = logInputSchema.parse(entry); + return { ...parsed, rawMessage: redact(parsed.rawMessage).slice(0, 4096) }; + }); + this.#transaction(() => { + const insert = this.#database.prepare('INSERT INTO logs (stream, body) VALUES (?, ?)'); + for (const entry of values) insert.run(streamKey(entry.stream), JSON.stringify(entry)); + for (const key of new Set(values.map((entry) => streamKey(entry.stream)))) { + this.#database + .prepare( + 'DELETE FROM logs WHERE stream = ? AND sequence NOT IN (SELECT sequence FROM logs WHERE stream = ? ORDER BY sequence DESC LIMIT 1000)', + ) + .run(key, key); + } + this.#database.exec( + 'DELETE FROM logs WHERE sequence NOT IN (SELECT sequence FROM logs ORDER BY sequence DESC LIMIT 5000)', + ); + }); + } + + queryLogs(stream: unknown, limit: number): StoredLog[] { + if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw storageError(); + const rows = this.#database + .prepare( + 'SELECT sequence, body FROM (SELECT sequence, body FROM logs WHERE stream = ? ORDER BY sequence DESC LIMIT ?) ORDER BY sequence', + ) + .all(streamKey(stream), limit); + return rows.map((row) => ({ + ...logInputSchema.parse(JSON.parse(String(row.body))), + sequenceNumber: Number(row.sequence), + })); + } + + close(): void { + this.#database.close(); + } + + #transaction(operation: () => unknown): void { + this.#database.exec('BEGIN IMMEDIATE'); + try { + operation(); + this.#database.exec('COMMIT'); + } catch (error) { + this.#database.exec('ROLLBACK'); + throw error; + } + } +} diff --git a/src/storage/redaction.ts b/src/storage/redaction.ts new file mode 100644 index 0000000..f5cfb40 --- /dev/null +++ b/src/storage/redaction.ts @@ -0,0 +1,28 @@ +const marker = '[redacted]'; + +/** Run before a message crosses either persistence or presentation boundaries. */ +export function redact(message: string): string { + return message + .replace( + /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----|$)/gi, + marker, + ) + .replace( + /\b(authorization|proxy-authorization|cookie|set-cookie)\s*:\s*[^\r\n]*/gi, + '$1: ' + marker, + ) + .replace(/\bbearer\s+[^\s,;"']+/gi, 'Bearer ' + marker) + .replace( + /(["']?(?:access[_-]?token|refresh[_-]?token|api[_-]?token|token|password|passwd|secret|api[_-]?key)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s,;&}\]]+\]?)/gi, + '$1' + marker, + ); +} + +export function terminalSafe(message: string): string { + // Strip complete ANSI sequences before removing the remaining C0/C1 controls. + /* eslint-disable no-control-regex -- Terminal output must recognize and remove control bytes. */ + return message + .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\|$)|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[\s\S]?/g, '') + .replace(/[\x00-\x1f\x7f-\x9f]/g, ''); +} +/* eslint-enable no-control-regex */ diff --git a/src/storage/schema.ts b/src/storage/schema.ts new file mode 100644 index 0000000..6fe7c33 --- /dev/null +++ b/src/storage/schema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { logEntrySchema } from '../protocol/dto.ts'; + +export const desiredStateSchema = z.strictObject({ + projects: z.array( + z.strictObject({ + projectKey: z.string(), + sourceWorkingDirectory: z.string(), + sourceConfigPath: z.string(), + enabled: z.boolean(), + domains: z.array( + z.strictObject({ + canonicalDomain: z.string(), + upstream: z.string().nullable(), + enabled: z.boolean(), + }), + ), + }), + ), +}); +export type StoredLog = z.infer; +export const logInputSchema = logEntrySchema.omit({ sequenceNumber: true }); +export type LogInput = z.input; + +export const runtimeLogStream = Object.freeze({ + streamId: 'runtime-control', + domainKey: null, + channel: 'control', +}); diff --git a/src/storage/storage.ts b/src/storage/storage.ts new file mode 100644 index 0000000..1e13c1b --- /dev/null +++ b/src/storage/storage.ts @@ -0,0 +1,154 @@ +import { Worker } from 'node:worker_threads'; +import { getAsset, isSea } from 'node:sea'; +import { join } from 'node:path'; +import type { DesiredState, PortResult, RuntimeOwner, StoragePort } from '../contracts/ports.ts'; +import { + assertProtected, + assertRuntimeDescendant, + createProtectedFile, +} from '../platform/runtime-security.ts'; +import { errorCode, RpcError } from '../protocol/errors.ts'; +import type { LogInput, StoredLog } from './schema.ts'; +import { desiredStateSchema } from './schema.ts'; + +function failure(): PortResult { + return { + ok: false, + error: { + kind: 'storage', + code: 'storage_unavailable', + message: 'Application storage is unavailable.', + guidance: null, + retryable: false, + requestId: null, + }, + }; +} +type Reply = { id: number; ok: boolean; value?: unknown }; + +export class ApplicationStorage implements StoragePort { + #nextId = 1; + #pending = new Map< + number, + { resolve(value: unknown): void; reject(error: unknown): void; timer: NodeJS.Timeout } + >(); + #closed = false; + #closing: Promise | undefined; + readonly #exit: Promise; + private constructor(private readonly worker: Worker) { + this.#exit = new Promise((resolve) => worker.once('exit', resolve)); + worker.on('message', (reply: Reply) => { + const pending = this.#pending.get(reply.id); + if (!pending) return; + this.#pending.delete(reply.id); + clearTimeout(pending.timer); + if (reply.ok) pending.resolve(reply.value); + else pending.reject(this.#error()); + }); + worker.on('error', () => this.#fail()); + worker.on('exit', () => this.#fail()); + } + + static async open(directory: string, owner: RuntimeOwner): Promise { + await assertProtected(directory, owner, true); + const path = join(directory, 'runtime.sqlite3'); + await createProtectedFile(path, owner); + for (const suffix of ['-journal', '-wal', '-shm']) { + try { + await assertRuntimeDescendant(directory, path + suffix, owner); + if (suffix !== '-journal') throw new Error('Unsupported application journal mode.'); + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error; + } + } + const url = isSea() + ? new URL( + 'data:text/javascript,' + encodeURIComponent(getAsset('cadder-storage-worker', 'utf8')), + ) + : new URL(import.meta.url.endsWith('.ts') ? './worker.ts' : './worker.js', import.meta.url); + const worker = new Worker(url, { workerData: { path } }); + const storage = new ApplicationStorage(worker); + try { + await storage.#wait(0); + await assertProtected(path, owner); + return storage; + } catch (error) { + await worker.terminate(); + throw error; + } + } + + async loadDesiredState(): Promise> { + try { + return { ok: true, value: desiredStateSchema.parse(await this.#job('load')) }; + } catch { + return failure(); + } + } + async persistDesiredState(state: DesiredState): Promise> { + try { + await this.#job('persist', desiredStateSchema.parse(state)); + return { ok: true, value: undefined }; + } catch { + return failure(); + } + } + async append(entries: readonly LogInput[]): Promise { + await this.#job('append', entries); + } + async queryLogs(stream: LogInput['stream'], limit: number): Promise { + return (await this.#job('logs', stream, limit)) as StoredLog[]; + } + close(): Promise { + this.#closing ??= (async () => { + if (this.#closed) { + await this.#exit; + return; + } + const closing = this.#job('close'); + this.#closed = true; + try { + await closing; + await this.#exit; + } catch (error) { + await this.worker.terminate(); + throw error; + } + })(); + return this.#closing; + } + #job(method: string, value?: unknown, limit?: number): Promise { + if (this.#closed || this.#pending.size >= 64) return Promise.reject(this.#error()); + const id = this.#nextId++; + const result = this.#wait(id); + try { + this.worker.postMessage({ id, method, value, limit }); + } catch { + this.#fail(); + void this.worker.terminate(); + } + return result; + } + #wait(id: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#fail(); + void this.worker.terminate(); + }, 5000); + this.#pending.set(id, { resolve, reject, timer }); + }); + } + #error(): RpcError { + const result = failure(); + if (result.ok) throw new Error('Unreachable.'); + return new RpcError(result.error); + } + #fail(): void { + this.#closed = true; + for (const pending of this.#pending.values()) { + clearTimeout(pending.timer); + pending.reject(this.#error()); + } + this.#pending.clear(); + } +} diff --git a/src/storage/worker.ts b/src/storage/worker.ts new file mode 100644 index 0000000..8fd0374 --- /dev/null +++ b/src/storage/worker.ts @@ -0,0 +1,35 @@ +import { parentPort, workerData } from 'node:worker_threads'; +import { ApplicationDatabase } from './database.ts'; +import type { DesiredState } from '../contracts/ports.ts'; +import type { LogInput } from './schema.ts'; + +const database = new ApplicationDatabase((workerData as { path: string }).path); +parentPort!.postMessage({ id: 0, ok: true }); +parentPort!.on('message', (job: { id: number; method: string; value: unknown; limit?: number }) => { + try { + let value: unknown; + switch (job.method) { + case 'load': + value = database.loadDesiredState(); + break; + case 'persist': + database.persistDesiredState(job.value as DesiredState); + break; + case 'append': + database.append(job.value as LogInput[]); + break; + case 'logs': + value = database.queryLogs(job.value, job.limit!); + break; + case 'close': + database.close(); + break; + default: + throw new Error('Unknown storage operation.'); + } + parentPort!.postMessage({ id: job.id, ok: true, value }); + if (job.method === 'close') parentPort!.close(); + } catch { + parentPort!.postMessage({ id: job.id, ok: false }); + } +}); diff --git a/src/tui/app.tsx b/src/tui/app.tsx new file mode 100644 index 0000000..c6215de --- /dev/null +++ b/src/tui/app.tsx @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Text, render, useApp, useInput, useWindowSize } from 'ink'; +import { basename } from 'node:path'; +import { ClientService } from '../client/service.ts'; +import type { StateSnapshot } from '../protocol/dto.ts'; +import { RpcError, errorCode } from '../protocol/errors.ts'; +import { terminalSafe } from '../storage/redaction.ts'; +import { VERSION } from '../protocol/version.ts'; + +export function RoutesApp({ client }: { client: ClientService }) { + const { exit } = useApp(); + const { rows: height } = useWindowSize(); + const [snapshot, setSnapshot] = useState(null); + const [offline, setOffline] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const busy = useRef(false); + const alive = useRef(true); + const [confirm, setConfirm] = useState(false); + const [selected, setSelected] = useState(0); + const refresh = useCallback(async () => { + try { + const state = await client.snapshot(); + if (alive.current) { + setSnapshot(state); + setOffline(false); + } + } catch (failure) { + if (alive.current) { + setOffline(errorCode(failure) === 'daemon-unavailable'); + setSnapshot(null); + if (errorCode(failure) !== 'daemon-unavailable') + setError( + 'Cannot read daemon state. Open cadder diagnostics after restoring the connection.', + ); + } + } + }, [client]); + useEffect(() => { + alive.current = true; + let refreshing = false; + const poll = (): void => { + if (!refreshing && !busy.current) { + refreshing = true; + void refresh().finally(() => { + refreshing = false; + }); + } + }; + poll(); + const timer = setInterval(poll, 1000); + return () => { + alive.current = false; + clearInterval(timer); + }; + }, [refresh]); + const rows = (snapshot?.registrations ?? []).flatMap((project) => [ + { + key: project.registrationId, + project, + domain: null, + label: basename(project.sourceWorkingDirectory.raw) || project.sourceWorkingDirectory.raw, + enabled: project.activationState === 'active', + }, + ...project.registeredDomains.map((domain, index) => ({ + key: `${project.registrationId}:${domain.name.canonical}`, + project, + domain, + label: `${index === project.registeredDomains.length - 1 ? '\u2514\u2500' : '\u251c\u2500'} ${domain.name.raw}${domain.upstream ? ' ' + domain.upstream : ''}`, + enabled: domain.activationState === 'active', + })), + ]); + const current = Math.min(selected, Math.max(0, rows.length - 1)); + const visible = Math.max(1, height - 7); + const offset = Math.max(0, current - visible + 1); + async function act(operation: () => Promise): Promise { + if (busy.current) return; + busy.current = true; + setPending(true); + setError(null); + try { + await operation(); + await refresh(); + } catch (failure) { + if (alive.current) + setError( + terminalSafe(failure instanceof Error ? failure.message : 'Action failed. Try again.'), + ); + } finally { + busy.current = false; + if (alive.current) setPending(false); + } + } + useInput((input, key) => { + if (input === 'q' || key.escape) { + if (confirm) setConfirm(false); + else exit(); + return; + } + if (busy.current) return; + if (confirm) { + if (input === 'y') { + setConfirm(false); + void act(() => client.stop()); + } else if (input === 'n') setConfirm(false); + return; + } + if (key.upArrow) setSelected(Math.max(0, current - 1)); + if (key.downArrow) setSelected(Math.max(0, Math.min(rows.length - 1, current + 1))); + if (input === 'r') { + void act(() => client.restart()); + return; + } + if (input === 's' && snapshot) { + setConfirm(true); + return; + } + if ((key.return || input === ' ') && offline) { + void act(() => client.start()); + return; + } + if ((key.return || input === ' ') && snapshot && rows[current]) { + const row = rows[current]; + void act(async () => { + const result = row.domain + ? await client.setDomainEnabled({ + registrationId: row.project.registrationId, + domainKey: row.domain.name.canonical, + enabled: !row.enabled, + }) + : await client.setProjectEnabled({ + registrationId: row.project.registrationId, + enabled: !row.enabled, + }); + if (!result.ok) throw new RpcError(result.error); + }); + } + }); + return ( + + + Cadder {VERSION} + + cadderd {snapshot ? 'online' : offline ? 'offline' : 'unavailable'} Caddy{' '} + {snapshot?.runtime.status ?? '-'} + + + + {offline ? ( + Press Enter to start cadderd. + ) : !snapshot ? ( + Connecting... + ) : rows.length === 0 ? ( + No projects registered. Run caddy run in your project. + ) : ( + rows.slice(offset, offset + visible).map((row, index) => ( + + {offset + index === current ? '> ' : ' '} + {row.enabled ? '\u25cf' : '\u25cb'} {terminalSafe(row.label)} + + )) + )} + + {confirm ? ( + Stop cadderd and its Caddy process? y / n + ) : pending ? ( + Working... + ) : error ? ( + + {error} + + ) : ( + + )} + + Enter / Space toggle s stop{' '} + r restart q quit + + + ); +} + +export async function openTui(client: ClientService): Promise { + let instance: ReturnType | undefined; + const stop = (): void => instance?.unmount(); + try { + instance = render(, { + exitOnCtrlC: true, + patchConsole: false, + alternateScreen: true, + }); + process.once('SIGTERM', stop); + await instance.waitUntilExit(); + } finally { + process.off('SIGTERM', stop); + instance?.unmount(); + } +} diff --git a/test/application-storage.test.ts b/test/application-storage.test.ts new file mode 100644 index 0000000..0cd703e --- /dev/null +++ b/test/application-storage.test.ts @@ -0,0 +1,185 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { afterEach, expect, it } from 'vitest'; +import { ApplicationDatabase } from '../src/storage/database.ts'; +import { ApplicationStorage } from '../src/storage/storage.ts'; +import { runtimeLogStream, type LogInput } from '../src/storage/schema.ts'; +import { redact, terminalSafe } from '../src/storage/redaction.ts'; +import { prepareRuntime, runtimeOwner } from '../src/platform/runtime-security.ts'; +import { normalizeRuntimeCreationOwner } from '../src/platform/runtime-creation-owner.ts'; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); +}); +async function root(): Promise { + const path = await mkdtemp(join(tmpdir(), 'cadder-storage-')); + roots.push(path); + return path; +} +function log(rawMessage: string, streamId = 'runtime-control'): LogInput { + return { + timestampUtc: new Date().toISOString(), + severity: 'info', + stream: { ...runtimeLogStream, streamId }, + attributionKind: 'runtimeControl', + entryKind: 'normal', + rawMessage, + domainKey: null, + sourceRegistrationId: null, + sourceInstanceId: null, + operation: null, + }; +} + +it('persists only validated intent, closes and reopens without a live lease', async () => { + const path = join(await root(), 'state.sqlite3'); + let db = new ApplicationDatabase(path); + expect(db.loadDesiredState()).toEqual({ projects: [] }); + const desired = { + projects: [ + { + projectKey: 'project', + sourceWorkingDirectory: '/project', + sourceConfigPath: '/project/Caddyfile', + enabled: false, + domains: [{ canonicalDomain: 'app.localhost', upstream: null, enabled: false }], + }, + ], + }; + db.persistDesiredState(desired); + db.close(); + db = new ApplicationDatabase(path); + try { + expect(db.loadDesiredState()).toEqual(desired); + const invalid = { projects: [{ ...desired.projects[0]!, lease: 'forbidden' }] }; + expect(() => db.persistDesiredState(invalid)).toThrow(); + expect(db.loadDesiredState()).toEqual(desired); + } finally { + db.close(); + } +}); + +it('rejects partial, newer, foreign and corrupt databases without changing their bytes', async () => { + for (const sql of [ + 'CREATE TABLE unrelated (value TEXT)', + 'PRAGMA user_version = 2', + 'CREATE TABLE logs (body TEXT)', + ]) { + const path = join(await root(), 'invalid.sqlite3'); + const seed = new DatabaseSync(path); + seed.exec(sql); + seed.close(); + const before = await readFile(path); + expect(() => new ApplicationDatabase(path)).toThrow(); + expect(await readFile(path)).toEqual(before); + } + const path = join(await root(), 'corrupt.sqlite3'); + await writeFile(path, 'not a database'); + expect(() => new ApplicationDatabase(path)).toThrow(); + expect(await readFile(path, 'utf8')).toBe('not a database'); +}); + +it('rolls back a failed persistence transaction instead of accepting lost state', async () => { + const path = join(await root(), 'failure.sqlite3'); + const db = new ApplicationDatabase(path); + const contender = new DatabaseSync(path); + try { + contender.exec('BEGIN EXCLUSIVE'); + expect(() => db.persistDesiredState({ projects: [] })).toThrow(); + contender.exec('ROLLBACK'); + expect(db.loadDesiredState()).toEqual({ projects: [] }); + } finally { + contender.close(); + db.close(); + } +}); + +it('redacts before retaining logs and returns newest bounded rows in ascending stream order', async () => { + const path = join(await root(), 'logs.sqlite3'); + const db = new ApplicationDatabase(path); + try { + for (let batch = 0; batch < 6; batch++) + db.append(Array.from({ length: 200 }, (_, i) => log(`row=${batch * 200 + i} token=hidden`))); + const rows = db.queryLogs(runtimeLogStream, 200); + expect(rows).toHaveLength(200); + expect(rows[0]?.rawMessage).toBe('row=1000 token=[redacted]'); + expect(rows.at(-1)?.sequenceNumber).toBe(1200); + db.append([log('independent', 'other')]); + expect(db.queryLogs(runtimeLogStream, 1)[0]?.sequenceNumber).toBe(1200); + expect(() => db.queryLogs(runtimeLogStream, 201)).toThrow(); + expect(() => db.append(Array.from({ length: 201 }, () => log('too many')))).toThrow(); + const inspect = new DatabaseSync(path); + try { + expect( + inspect + .prepare('SELECT count(*) AS total FROM logs WHERE stream = ?') + .get(JSON.stringify(['runtime-control', null, 'control']))?.total, + ).toBe(1000); + expect( + inspect.prepare("SELECT count(*) AS total FROM logs WHERE body LIKE '%hidden%'").get() + ?.total, + ).toBe(0); + for (let stream = 0; stream < 6; stream++) + for (let batch = 0; batch < 5; batch++) + db.append(Array.from({ length: 200 }, () => log('bounded', String(stream)))); + expect(inspect.prepare('SELECT count(*) AS total FROM logs').get()?.total).toBe(5000); + } finally { + inspect.close(); + } + } finally { + db.close(); + } +}); + +it('uses a real protected worker connection, drains writes and rejects work after close', async () => { + const directory = join(await root(), 'runtime'); + const owner = await runtimeOwner(); + await normalizeRuntimeCreationOwner(owner); + await prepareRuntime(directory, owner); + let storage = await ApplicationStorage.open(directory, owner); + expect(await storage.loadDesiredState()).toEqual({ ok: true, value: { projects: [] } }); + expect(await storage.persistDesiredState({ projects: [] })).toEqual({ + ok: true, + value: undefined, + }); + await storage.append([log('password=secret-value')]); + expect((await storage.queryLogs(runtimeLogStream, 10))[0]?.rawMessage).toBe( + 'password=[redacted]', + ); + await storage.close(); + await storage.close(); + expect((await storage.loadDesiredState()).ok).toBe(false); + await expect(storage.append([log('late')])).rejects.toThrow(); + storage = await ApplicationStorage.open(directory, owner); + try { + expect(await storage.queryLogs(runtimeLogStream, 1)).toHaveLength(1); + } finally { + await storage.close(); + } +}, 30000); + +it('removes complete credentials in supported forms and keeps terminal controls inert', () => { + const messages = [ + 'Authorization: Bearer hidden other words', + 'cookie: session=hidden', + 'Bearer hidden', + 'token=hidden', + 'access-token="hidden with spaces"', + 'api_token=hidden', + '{"password":"hidden with \\"quotes\\""}', + "'secret': 'hidden with spaces'", + '/?token=hidden&safe=ok', + '-----BEGIN RSA PRIVATE KEY-----\nhidden\n-----END RSA PRIVATE KEY-----', + '-----BEGIN PRIVATE KEY-----\nhidden', + ]; + for (const input of messages) { + const output = redact(input); + expect(output, input).not.toContain('hidden'); + expect(output).toContain('[redacted]'); + expect(redact(output)).toBe(output); + } + expect(terminalSafe('ok\x1b[31mred\x1b[0m\x00\x9b\r\n')).toBe('okred'); +}); diff --git a/test/caddy-admin-policy.test.ts b/test/caddy-admin-policy.test.ts index 3ce5a52..e9997c5 100644 --- a/test/caddy-admin-policy.test.ts +++ b/test/caddy-admin-policy.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { normalizeCaddyConfig, + configForRoutePlan, prepareSecureAdminPolicy, type SecureAdminPolicy, type SecureAdminPolicyOptions, @@ -191,6 +192,23 @@ async function refused(overrides: Partial, code?: stri expect(JSON.stringify(result)).not.toMatch(/SECRET|PRIVATE KEY|attacker|parser/); } +it('changes guarded routes while reusing the verified identity and immutable base policy', async () => { + const prepared = await policy(); + const before = prepared.config.adaptedConfig.body; + const empty = composeRoutePlan([]); + if (!empty.ok) throw new Error('Empty route plan failed.'); + const next = configForRoutePlan(prepared, empty.value); + const previous = JSON.parse(before); + const updated = JSON.parse(next.adaptedConfig.body); + expect(updated.admin).toEqual(previous.admin); + expect(updated.apps.pki).toEqual(previous.apps.pki); + expect(updated.apps.tls.certificates.automate).toEqual([]); + expect(updated.apps.http.servers.cadder_http.routes).toEqual([]); + expect(prepared.config.adaptedConfig.body).toBe(before); + expect(next.effectiveConfigHash).not.toBe(prepared.config.effectiveConfigHash); + expect(vi.mocked(loadOrCreateAdminMaterial)).toHaveBeenCalledOnce(); +}); + it('assembles native mutual-TLS policy, imported CA paths and real internal web issuance', async () => { const value = await policy(); const config = JSON.parse(value.config.adaptedConfig.body); diff --git a/test/caddy-log-ingestion.test.ts b/test/caddy-log-ingestion.test.ts new file mode 100644 index 0000000..f39e73f --- /dev/null +++ b/test/caddy-log-ingestion.test.ts @@ -0,0 +1,88 @@ +import { expect, it, vi } from 'vitest'; +import { CaddyLogs } from '../src/daemon/caddy-logs.ts'; +import { ApplicationDatabase } from '../src/storage/database.ts'; +import { runtimeLogStream, type LogInput } from '../src/storage/schema.ts'; +import { fakeRegistration } from './fixtures/rpc-data.ts'; + +it('decodes bounded native output, attributes actual domains and redacts before SQLite', async () => { + const database = new ApplicationDatabase(':memory:'); + const project = fakeRegistration(); + project.logStream = { streamId: 'entrypoint-fixture', domainKey: null, channel: 'caddy' }; + project.registeredDomains[0]!.logStream = { + streamId: 'domain-example.localhost', + domainKey: 'example.localhost', + channel: 'caddy', + }; + const failed = vi.fn(); + const logs = new CaddyLogs( + () => [project], + async (entries) => database.append(entries), + failed, + ); + const line = Buffer.from( + JSON.stringify({ + level: 'warn', + msg: 'Zażółć password=hidden', + request: { host: 'EXAMPLE.localhost:443' }, + }) + '\n', + ); + logs.accept('stderr', line.subarray(0, 29)); + logs.accept('stderr', line.subarray(29)); + logs.accept( + 'stdout', + Buffer.from('-----BEGIN PRIVATE KEY-----\nhidden\n-----END PRIVATE KEY-----\nplain tail'), + ); + await logs.close(); + try { + const attributed = database.queryLogs(project.registeredDomains[0]!.logStream, 10); + expect(attributed).toHaveLength(1); + expect(attributed[0]).toMatchObject({ + rawMessage: 'Zażółć password=[redacted]', + attributionKind: 'domain', + sourceRegistrationId: project.registrationId, + severity: 'warn', + }); + expect(database.queryLogs(project.logStream, 10)[0]?.attributionKind).toBe('entrypoint'); + const runtime = database.queryLogs(runtimeLogStream, 10); + expect(runtime.some((entry) => entry.rawMessage === 'plain tail')).toBe(true); + expect(JSON.stringify(runtime)).not.toContain('hidden'); + expect(failed).not.toHaveBeenCalled(); + } finally { + database.close(); + } +}); + +it('bounds incomplete lines and pending writes, and does not leak a key after overflow', async () => { + const entries: LogInput[] = []; + const settle: (() => void)[] = []; + const failed = vi.fn(); + const logs = new CaddyLogs( + () => [], + (batch) => { + entries.push(...batch); + return new Promise((resolve) => settle.push(resolve)); + }, + failed, + ); + logs.accept( + 'stderr', + Buffer.from( + 'x'.repeat(9000) + '-----begin private key-----\nhidden\n-----end private key-----\n', + ), + ); + logs.accept('stdout', Buffer.from('row\n'.repeat(70))); + expect(entries).toHaveLength(64); + expect(JSON.stringify(entries)).not.toContain('hidden'); + expect(failed).toHaveBeenCalled(); + for (const finish of settle) finish(); + await logs.close(); + const failing = new CaddyLogs( + () => [], + async () => { + throw new Error('Disk full'); + }, + failed, + ); + failing.accept('stderr', Buffer.from('null\n')); + await failing.close(); +}); diff --git a/test/caddy-owned-process.test.ts b/test/caddy-owned-process.test.ts index 0450f75..57396af 100644 --- a/test/caddy-owned-process.test.ts +++ b/test/caddy-owned-process.test.ts @@ -18,15 +18,22 @@ const execute = promisify(execFile); it('owned command preserves native argv and binary streams', async () => { const args = ['space value', 'quote"value', 'trailing\\', '', 'line\nvalue']; - const output = await runOwnedCommand(process.execPath, [ - '-e', - 'process.stdout.write(JSON.stringify(process.argv.slice(1))); process.stderr.write(Buffer.from([0,255,128]));', - '--', - ...args, - ]); + const observed: Record<'stdout' | 'stderr', Buffer[]> = { stdout: [], stderr: [] }; + const output = await runOwnedCommand( + process.execPath, + [ + '-e', + 'process.stdout.write(JSON.stringify(process.argv.slice(1))); process.stderr.write(Buffer.from([0,255,128]));', + '--', + ...args, + ], + { onOutput: (stream, bytes) => observed[stream].push(Buffer.from(bytes)) }, + ); expect(output.exitCode).toBe(0); expect(JSON.parse(output.stdout.toString())).toEqual(args); expect(output.stderr).toEqual(Buffer.from([0, 255, 128])); + expect(Buffer.concat(observed.stdout)).toEqual(output.stdout); + expect(Buffer.concat(observed.stderr)).toEqual(output.stderr); }); it('verified startup notification precedes immediate binary output without leaking handshake bytes', async () => { diff --git a/test/client-service.test.ts b/test/client-service.test.ts new file mode 100644 index 0000000..398e99d --- /dev/null +++ b/test/client-service.test.ts @@ -0,0 +1,120 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { ClientService } from '../src/client/service.ts'; +import { daemonCommand, launchDaemon } from '../src/client/launch.ts'; +import { rpc } from '../src/client/connection.ts'; +import { CadderError, RpcError } from '../src/protocol/errors.ts'; +import { fakeSnapshot } from './fixtures/rpc-data.ts'; +import { runtimeLogStream } from '../src/storage/schema.ts'; +import type { ChildProcess } from 'node:child_process'; + +vi.mock('../src/client/connection.ts', () => ({ rpc: vi.fn() })); +vi.mock('../src/client/launch.ts', async (original) => ({ + ...(await original()), + launchDaemon: vi.fn(), +})); +afterEach(() => { + vi.resetAllMocks(); + vi.restoreAllMocks(); +}); +const basic = { + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: 'Done', +}; +const ready = { ...basic, snapshot: fakeSnapshot() }; +const offline = () => new CadderError('daemon-unavailable', 'Offline'); + +it('shares the closed RPC operations and retains typed offline/rejection failures', async () => { + const client = new ClientService(); + vi.mocked(rpc).mockResolvedValue(ready); + expect((await client.queryState()).ok).toBe(true); + await client.queryLogs({ stream: runtimeLogStream, limit: 5 }); + await client.setProjectEnabled({ registrationId: 'project', enabled: false }); + await client.setDomainEnabled({ + registrationId: 'project', + domainKey: 'app.localhost', + enabled: false, + }); + await client.shutdown(); + expect(vi.mocked(rpc).mock.calls.map((call) => call[1])).toEqual([ + 'query-state-request', + 'query-logs-request', + 'set-entrypoint-enabled-request', + 'set-domain-enabled-request', + 'shutdown-daemon-request', + ]); + const conflict = new RpcError({ + kind: 'conflict', + code: 'conflict', + message: 'Conflict', + guidance: null, + retryable: false, + requestId: null, + }); + vi.mocked(rpc).mockRejectedValueOnce(conflict); + expect(await client.setProjectEnabled({ registrationId: 'project', enabled: true })).toEqual({ + ok: false, + error: conflict.protocolError, + }); + vi.mocked(rpc).mockRejectedValueOnce(offline()); + expect(await client.queryState()).toHaveProperty('error.code', 'daemon_unavailable'); +}); + +it('attaches first and launches only the packaged daemon when genuinely missing', async () => { + const client = new ClientService(); + vi.mocked(rpc).mockResolvedValue(ready); + await client.start(); + expect(launchDaemon).not.toHaveBeenCalled(); + vi.mocked(rpc).mockRejectedValueOnce(offline()); + await client.start(); + expect(launchDaemon).toHaveBeenCalledOnce(); + const command = await daemonCommand(); + expect(command.file).toBe(process.execPath); + expect(command.args.at(-1)).toMatch(/entrypoints[/\\]cadderd\.ts$/); + vi.mocked(rpc).mockRejectedValueOnce(new CadderError('authentication-failed', 'Wrong peer')); + await expect(client.start()).rejects.toMatchObject({ code: 'authentication-failed' }); + expect(launchDaemon).toHaveBeenCalledOnce(); +}); + +it('waits through owned shutdown before restart instead of treating closing as stopped', async () => { + const client = new ClientService(); + vi.mocked(rpc) + .mockResolvedValueOnce(basic) + .mockRejectedValueOnce(new CadderError('daemon_shutting_down', 'Closing')) + .mockRejectedValueOnce(offline()) + .mockRejectedValueOnce(offline()) + .mockRejectedValueOnce(offline()) + .mockRejectedValueOnce(offline()) + .mockResolvedValue(ready); + await client.restart(); + const methods = vi.mocked(rpc).mock.calls.map((call) => call[1]); + expect(methods[0]).toBe('shutdown-daemon-request'); + expect(methods.filter((method) => method === 'query-state-request')).toHaveLength(6); + expect(launchDaemon).toHaveBeenCalledOnce(); + vi.mocked(rpc).mockRejectedValueOnce(offline()); + await client.stop(); +}); + +it('waits for startup ACL publication without accepting an unsafe connection or masking later failures', async () => { + const client = new ClientService(); + const kill = vi.fn(); + vi.mocked(launchDaemon).mockResolvedValue({ exitCode: null, kill } as unknown as ChildProcess); + vi.mocked(rpc) + .mockRejectedValueOnce(offline()) + .mockRejectedValueOnce( + new CadderError('unsafe-runtime-permissions', 'ACL is not published yet'), + ) + .mockResolvedValueOnce(ready); + await client.start(); + expect(kill).not.toHaveBeenCalled(); + vi.mocked(rpc) + .mockRejectedValueOnce(offline()) + .mockRejectedValueOnce(new CadderError('authentication-failed', 'Wrong peer')); + await expect(client.start()).rejects.toMatchObject({ code: 'authentication-failed' }); + expect(kill).toHaveBeenCalledOnce(); + vi.mocked(rpc).mockRejectedValueOnce( + new CadderError('unsafe-runtime-permissions', 'Existing unsafe path'), + ); + await expect(client.start()).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(launchDaemon).toHaveBeenCalledTimes(2); +}); diff --git a/test/compatibility/catalog.test.ts b/test/compatibility/catalog.test.ts index 8a4c406..26695d7 100644 --- a/test/compatibility/catalog.test.ts +++ b/test/compatibility/catalog.test.ts @@ -1,518 +1,50 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parse as parseToml } from 'smol-toml'; -import { describe, expect, it } from 'vitest'; -import { errorResponseSchema, requestSchema, responseSchema } from '../../src/protocol/rpc.ts'; -import { - caddyResolutionAuthority, - caddyResolutionPrecedence, - caddyfileExamples, - cliCases, - compatibilityCatalogVersion, - configurationCases, - exitCodes, - humanOutput, - humanOutputFixture, - operationCatalog, - registrationFixture, - responseContract, - rpcExamples, - rpcOperationFixtures, - rpcSnapshotFixture, - shimAliases, - shimCommandCases, - shimRunMetadata, - sourceAnchors, - stateModelFields, -} from './catalog.ts'; - -function findRepositoryRoot(): string { - let candidate = resolve(dirname(fileURLToPath(import.meta.url))); - while (candidate !== dirname(candidate)) { - if (existsSync(join(candidate, 'crates')) && existsSync(join(candidate, 'openspec'))) { - return candidate; - } - candidate = dirname(candidate); - } - throw new Error('Could not locate the Cadder repository root'); -} - -const repositoryRoot = findRepositoryRoot(); - -function sourceText(path: string): string { - return readFileSync(join(repositoryRoot, path), 'utf8'); -} - -function hasMarker(path: string, marker: string): boolean { - return sourceText(path).includes(marker); -} - -function snakeCase(name: string): string { - return name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); -} - -function regexEscape(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function sourcePolicyKind(command: string): string | undefined { - const pattern = new RegExp( - `policy_entry\\(\\s*"${regexEscape(command)}",\\s*ShimCommandPolicyKind::(\\w+)`, - 'm', - ); - return pattern.exec(sourceText('crates/cadder-shim/src/command_policy.rs'))?.[1]; -} - -function operationConstantName(operation: string): string { - return operation.replaceAll('-', '_').toUpperCase(); -} - -function sourceOperationAccess(operation: string): string | undefined { - const pattern = new RegExp( - `message_types::${operationConstantName(operation)},\\s*OperationAccess::(\\w+)`, - 'm', - ); - return pattern.exec(sourceText('crates/cadder-ipc/src/operations.rs'))?.[1]; -} - -const policyKinds = { - managed: 'Managed', - 'read-only-inspection': 'ReadOnlyInspection', - 'explicit-passthrough': 'ExplicitPassthrough', - unsupported: 'Unsupported', -} as const; - -describe('released 1.0.5 compatibility catalog', () => { - it('is versioned and anchored to the complete current authority set', () => { - expect(compatibilityCatalogVersion).toBe('1.0.5-rust-baseline'); - for (const anchor of sourceAnchors) { - for (const marker of anchor.markers) { - expect(hasMarker(anchor.path, marker), `${anchor.path} is missing ${marker}`).toBe(true); - } - } - }); - - it('freezes CLI grammar, nested help, exits, and observable streams', () => { - const successful = cliCases.filter(({ expected }) => expected === 'success'); - const rejected = cliCases.filter(({ expected }) => expected === 'invalid-usage'); - expect(successful.length).toBeGreaterThan(20); - expect(rejected.length).toBeGreaterThan(10); - expect(new Set(cliCases.map(({ argv }) => argv.join('\u0000'))).size).toBe(cliCases.length); - - for (const testCase of cliCases) { - expect(testCase.argv[0]).toBe('cadder'); - expect(testCase.exitCode).toBe( - testCase.expected === 'success' ? exitCodes.success : exitCodes.invalidUsage, - ); - if (testCase.expected === 'invalid-usage') expect(testCase.stream).toBe('stderr'); - } - expect(cliCases.find(({ argv }) => argv.length === 1)).toMatchObject({ - expected: 'success', - exitCode: 0, - stream: 'stderr', - }); - for (const group of ['daemon', 'projects', 'domains', 'port', 'caddyfile', 'logs']) { - expect(cliCases.find(({ argv }) => argv.join(' ') === `cadder ${group}`)).toMatchObject({ - expected: 'success', - exitCode: 0, - stream: 'stderr', - }); - } - expect(cliCases.slice(1, 5).every(({ stream }) => stream === 'stdout')).toBe(true); - expect(cliCases.some(({ argv }) => argv.join(' ') === 'cadder daemon')).toBe(true); - expect(cliCases.some(({ argv }) => argv.join(' ') === 'cadder projects')).toBe(true); - expect(cliCases.some(({ argv }) => argv.join(' ') === 'cadder logs')).toBe(true); - expect( - cliCases.some( - ({ argv }) => - argv.join(' ') === 'cadder domains inspect app.localhost --caddyfile Caddyfile', - ), - ).toBe(true); - expect( - cliCases.some( - ({ argv }) => - argv.join(' ') === 'cadder logs domain app.localhost --caddyfile Caddyfile --limit 1', - ), - ).toBe(true); - expect( - cliCases - .filter(({ notes }) => notes.includes('removed')) - .every(({ exitCode, stream }) => exitCode === 2 && stream === 'stderr'), - ).toBe(true); - expect(sourceText('crates/cadder-client/src/main.rs')).toContain('let _ = error.print();'); - }); - - it('agrees with captured released non-TTY exit codes and output streams', () => { - const capture = JSON.parse( - readFileSync(new URL('./cli-output.json', import.meta.url), 'utf8'), - ) as { - version: string; - cases: { argv: string[]; exitCode: number; stdout: string; stderr: string }[]; +import { expect, it } from 'vitest'; +import { requestSchema, responseSchema, errorResponseSchema } from '../../src/protocol/rpc.ts'; +import { status, projects, domains, logs } from '../../src/cli/output.ts'; +import { fakeSnapshot } from '../fixtures/rpc-data.ts'; +import { humanOutputFixture, rpcExamples, rpcOperationFixtures } from './catalog.ts'; + +it('accepts the frozen released RPC catalog through the actual Node schemas', () => { + for (const fixture of rpcOperationFixtures) { + const request = { + protocolVersion: 3, + requestId: fixture.result.requestId, + method: fixture.method, + params: fixture.params, }; - expect(capture.version).toBe('1.0.5'); - for (const captured of capture.cases) { - const catalogCase = cliCases.find( - ({ argv }) => argv.slice(1).join('\u0000') === captured.argv.join('\u0000'), - ); - expect(catalogCase, `Missing captured argv: ${captured.argv.join(' ')}`).toBeDefined(); - expect(catalogCase?.exitCode).toBe(captured.exitCode); - expect(catalogCase?.stream).toBe(captured.stdout ? 'stdout' : 'stderr'); - expect(Boolean(captured.stdout)).not.toBe(Boolean(captured.stderr)); - } - }); - - it('freezes the released exit-code classes without introducing machine output', () => { - expect(exitCodes).toEqual({ - success: 0, - invalidUsage: 2, - daemonUnavailable: 3, - daemonStartFailure: 4, - targetNotFound: 5, - conflictOrRejected: 6, - permissionOrElevation: 7, - unsupportedOperation: 8, - ipcFailure: 9, - }); - expect(sourceText('crates/cadder-api/src/error.rs')).toContain('pub enum AppExit'); - }); - - it('freezes exact human output captures from the retained Rust renderer', () => { - const outputSource = sourceText('crates/cadder-client/src/cli/output.rs'); - expect(humanOutputFixture).toMatchObject({ - sourceRevision: '93942af84f555eab2bd68e0a4a9db487ff82d040', - version: '1.0.5', - platform: 'win32', - }); - expect(humanOutputFixture.capture).toContain('cargo test --locked -p cadder-client'); - expect(humanOutputFixture.capture).toContain('including its final LF'); - expect(humanOutputFixture.sourceState).toContain('fixed-time test inputs'); - expect(humanOutputFixture.portability).toContain('path separators are platform-specific'); - expect(humanOutputFixture.cases.map(({ name }) => name)).toEqual(humanOutput.captureNames); - expect(new Set(humanOutputFixture.cases.map(({ name }) => name)).size).toBe( - humanOutputFixture.cases.length, - ); - for (const { name, output } of humanOutputFixture.cases) { - expect(output, `${name} must contain rendered output`).not.toBe(''); - expect(output, `${name} must use stable LF captures`).not.toContain('\r'); - expect(output.endsWith('\n'), `${name} must preserve its final LF`).toBe(true); - expect(outputSource).toContain(name); - } - - const captures = new Map(humanOutputFixture.cases.map(({ name, output }) => [name, output])); - expect(captures.get(humanOutput.captureNames[0])).toContain( - `${humanOutput.status.join('\n')}\n`, - ); - expect(captures.get(humanOutput.captureNames[0])).toContain('No projects registered.'); - expect(captures.get(humanOutput.captureNames[0])).toContain('active.localhost'); - expect(captures.get(humanOutput.captureNames[1])).toContain('No matching routes.'); - expect(captures.get(humanOutput.captureNames[1])).toContain('socket inspection unavailable'); - expect(captures.get(humanOutput.captureNames[2])).toContain('Registered yes'); - expect(captures.get(humanOutput.captureNames[2])).toContain('Local upstream sockets'); - expect(captures.get(humanOutput.captureNames[3])).toContain('runtime-warning'); - expect(captures.get(humanOutput.captureNames[3])).toContain('2026-01-01T00:00:00+00:00'); - expect(captures.get(humanOutput.captureNames[3])).toContain( - `Status ${humanOutput.populatedLogs.status}\nChannel ${humanOutput.populatedLogs.channel}\nDomain ${humanOutput.populatedLogs.domain}\n`, - ); - - for (const line of [ - ...humanOutput.emptyProjects, - ...humanOutput.emptyDomains, - ...humanOutput.emptyLogs, - ...humanOutput.logHeader, - ...humanOutput.diagnosticHeaders, - ...humanOutput.populatedProjects.headers, - ...humanOutput.populatedDomains.headers, - ...humanOutput.populatedPort.sections, - ...humanOutput.populatedPort.ownerHeaders, - ...humanOutput.populatedCaddyfile.sections, - ...humanOutput.populatedCaddyfile.headers, - ]) { - expect(outputSource, `output.rs is missing ${line}`).toContain(line); - } - }); - - it('freezes shim classifications independently against each Rust policy entry', () => { - expect(shimCommandCases.filter(({ policy }) => policy === 'managed')).toHaveLength(1); - expect(shimCommandCases.filter(({ policy }) => policy === 'read-only-inspection')).toHaveLength( - 7, - ); - expect(shimCommandCases.filter(({ policy }) => policy === 'explicit-passthrough')).toHaveLength( - 5, + expect(requestSchema.parse(request)).toEqual(request); + const response = { protocolVersion: 3, requestId: request.requestId, result: fixture.result }; + expect(responseSchema(fixture.method).parse(response)).toEqual(response); + expect(responseSchema(fixture.method).safeParse({ ...response, extra: true }).success).toBe( + false, ); - expect(shimCommandCases.filter(({ policy }) => policy === 'unsupported')).toHaveLength(9); - for (const { command, policy } of shimCommandCases) { - if (command === 'not-a-caddy-command') { - expect(sourcePolicyKind(command)).toBeUndefined(); - } else { - expect(sourcePolicyKind(command)).toBe(policyKinds[policy]); - } - } - const policySource = sourceText('crates/cadder-shim/src/command_policy.rs'); - for (const alias of shimAliases) { - for (const argument of alias.argv) { - expect(policySource).toContain(`"${argument}"`); - } - } - expect(policySource).toContain('None | Some("--help" | "-h" | "help")'); - expect(policySource).toContain('Some("--version" | "-v" | "version")'); - expect(shimRunMetadata.delegation.classes).toEqual([ - 'read-only-inspection', - 'explicit-passthrough', - ]); - }); - - it('separates managed-run lifecycle outcomes from delegated child fidelity', () => { - const shimSource = sourceText('crates/cadder-shim/src/main.rs'); - expect(shimRunMetadata.managedRun).toMatchObject({ - streams: 'no child delegation; registration session owns no real-Caddy child', - exitCodes: { clean: 0, backendOrRecoveryFailure: 1 }, - missingDaemon: 'start managed daemon; never delegate to independent Caddy', - }); - expect(shimRunMetadata.delegation).toMatchObject({ - streams: ['stdin', 'stdout', 'stderr'], - exitStatus: 'status.code() as u8, or 1 when no status exists', - }); - expect(shimSource).toContain('run_managed_until'); - expect(shimSource).toContain('Ok(ExitCode::SUCCESS)'); - expect(shimSource).toContain('ExitCode::FAILURE'); - expect(shimSource).toContain('.stdin(Stdio::inherit())'); - expect(shimSource).toContain('status.code().unwrap_or(1) as u8'); - }); - - it('freezes config cases and verifies resolver precedence from source order', () => { - const configSource = sourceText('crates/cadder-daemon/src/config.rs'); - for (const configurationCase of configurationCases) { - expect(configSource).toContain(configurationCase.sourceMarker); - } - const resolverSource = sourceText('crates/cadder-daemon/src/caddy/resolver.rs'); - const uncachedSource = resolverSource.slice( - resolverSource.indexOf('fn resolve_uncached'), - resolverSource.indexOf('pub fn resolution_help'), - ); - expect(uncachedSource.indexOf('ExplicitDaemonOverride')).toBeGreaterThanOrEqual(0); - expect(uncachedSource.indexOf('self.configured_sources()')).toBeGreaterThan( - uncachedSource.indexOf('ExplicitDaemonOverride'), - ); - expect(uncachedSource.indexOf('resolve_caddy_on_path')).toBeGreaterThan( - uncachedSource.indexOf('self.configured_sources()'), - ); - const configuredSource = resolverSource.slice( - resolverSource.indexOf('fn configured_sources'), - resolverSource.indexOf('fn portable_config_path'), - ); - const configuredPositions = [ - 'PortableConfiguration', - 'UserConfiguration', - 'SystemConfiguration', - ].map((marker) => configuredSource.indexOf(marker)); - expect(configuredPositions.every((position) => position >= 0)).toBe(true); - expect(configuredPositions).toEqual( - [...configuredPositions].sort((left, right) => left - right), - ); - expect(caddyResolutionPrecedence).toHaveLength(5); - for (const test of caddyResolutionAuthority.tests) { - expect(sourceText(caddyResolutionAuthority.path)).toContain(`fn ${test}(`); - } - }); - - it('parses concrete supported TOML fixtures and rejects malformed syntax', () => { - expect(parseToml(configurationCases[0].toml)).toEqual({ - caddy: { real_command: 'caddy-real' }, - }); - expect(parseToml(configurationCases[1].toml)).toEqual({ caddy: { real_path: '/opt/caddy' } }); - expect(parseToml(configurationCases[2].toml)).toEqual({ - caddy: { real_command: 'caddy-real', real_path: '/opt/caddy' }, - }); - expect(parseToml(configurationCases[3].toml)).toEqual({ - defaults: { real_caddy: '/opt/caddy' }, - }); - expect(parseToml(configurationCases[4].toml)).toEqual({ caddy: { unknown: 'caddy' } }); - expect(() => parseToml(configurationCases[5].toml)).toThrow(); - }); - - it('freezes real Caddyfile newlines and inspection correlation boundaries', () => { - expect(caddyfileExamples).toHaveLength(4); - expect(caddyfileExamples.filter(({ expected }) => expected === 'accepted')).toHaveLength(3); - expect(caddyfileExamples.filter(({ expected }) => expected === 'rejected')).toHaveLength(1); - expect(caddyfileExamples.every(({ source }) => source.includes('\n'))).toBe(true); - expect(caddyfileExamples.every(({ source }) => !source.includes('\\n'))).toBe(true); - for (const { evidence } of caddyfileExamples) { - expect(evidence.aspect).not.toBe(''); - expect(sourceText(evidence.path)).toContain(`fn ${evidence.test}(`); - } - expect(caddyfileExamples[2].evidence.aspect).toContain('not acceptance of this Caddyfile'); - expect(caddyfileExamples[0]).toMatchObject({ - domain: 'app.localhost', - upstream: '127.0.0.1:3000', - localPort: 3000, - }); - expect(caddyfileExamples[1]).toMatchObject({ - domain: 'remote.example', - upstream: 'api.example.com:3000', - localPort: null, - }); - expect(caddyfileExamples[2]).toMatchObject({ domain: '', upstream: null, localPort: null }); - expect(caddyfileExamples[3]).toMatchObject({ domain: null, upstream: null, localPort: null }); - const adapterSource = sourceText('crates/cadder-daemon/src/caddy/tests.rs'); - expect(adapterSource).toContain('mock_adapter_reports_invalid_caddyfile'); - expect(adapterSource).toContain('app.localhost {\\n'); - const inspectionSource = sourceText('crates/cadder-client/src/inspection.rs'); - expect(inspectionSource).toContain('routes_for_port'); - expect(inspectionSource).toContain('local_upstream'); - expect(inspectionSource).toContain('canonical_domain'); - }); + } + expect( + errorResponseSchema.parse({ + protocolVersion: 3, + requestId: rpcExamples.protocolError.requestId, + error: rpcExamples.protocolError.error, + }), + ).toHaveProperty('error'); +}); - it('freezes nested registration identity, owner/session constraints, and log attribution', () => { - expect(registrationFixture.registrationId).toBe( - registrationFixture.entrypointInstance.instanceId, - ); - expect(registrationFixture.entrypointInstance.shimSessionNonce).toBe( - registrationFixture.ownerProcess.shimSessionNonce, - ); - expect(registrationFixture.registeredDomains[0].name.canonical).toBe('app.localhost'); - expect(registrationFixture.registeredDomains[0].logStream).toEqual({ - streamId: 'domain-app.localhost', - domainKey: 'app.localhost', +it('matches the released empty-state and bounded-log human output using the Node renderer', () => { + const snapshot = fakeSnapshot(); + snapshot.registrations = []; + snapshot.runtime.status = 'idle'; + const actual = [status(snapshot), projects(snapshot), domains(snapshot)].join('\n') + '\n'; + expect(humanOutputFixture.cases[0]!.output.startsWith(actual)).toBe(true); + const empty = logs({ + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: '', + stream: { + streamId: 'domain-active.localhost', + domainKey: 'active.localhost', channel: 'caddy', - }); - const invalidInstance = { ...registrationFixture, registrationId: 'different-registration' }; - expect(invalidInstance.registrationId).not.toBe(invalidInstance.entrypointInstance.instanceId); - const invalidNonce = { - ...registrationFixture, - ownerProcess: { ...registrationFixture.ownerProcess, shimSessionNonce: 'different-session' }, - }; - expect(invalidNonce.entrypointInstance.shimSessionNonce).not.toBe( - invalidNonce.ownerProcess.shimSessionNonce, - ); - const stateSource = sourceText('crates/cadder-ipc/src/state.rs'); - expect(stateSource).toContain('registration_id must match instance_id'); - expect(stateSource).toContain('entrypoint and owner shim session nonce values must match'); - expect(stateModelFields.entrypointInstance).toEqual([ - 'instanceId', - 'startedAtUtc', - 'shimSessionNonce', - ]); - }); - - it('freezes exactly eight operation payload/result shapes against defining Rust types', () => { - const operationSource = sourceText('crates/cadder-ipc/src/message_types.rs'); - const payloadSources: Record = { - RegisterEntrypointPayload: 'crates/cadder-ipc/src/mutations.rs', - UnregisterEntrypointPayload: 'crates/cadder-ipc/src/mutations.rs', - HeartbeatEntrypointPayload: 'crates/cadder-ipc/src/mutations.rs', - QueryStatePayload: 'crates/cadder-ipc/src/commands.rs', - SetEntrypointEnabledPayload: 'crates/cadder-ipc/src/mutations.rs', - SetDomainEnabledPayload: 'crates/cadder-ipc/src/mutations.rs', - QueryLogsPayload: 'crates/cadder-ipc/src/logs.rs', - ShutdownDaemonPayload: 'crates/cadder-ipc/src/mutations.rs', - }; - const resultSources: Record = { - RegisterEntrypointResponse: 'crates/cadder-ipc/src/responses.rs', - BasicResponse: 'crates/cadder-ipc/src/responses.rs', - QueryStateResponse: 'crates/cadder-ipc/src/responses.rs', - QueryLogsResponse: 'crates/cadder-ipc/src/logs.rs', - }; - expect(operationCatalog).toHaveLength(8); - expect(new Set(operationCatalog.map(({ operation }) => operation)).size).toBe(8); - for (const operation of operationCatalog) { - expect(operationSource).toContain(`"${operation.operation}"`); - expect(sourceOperationAccess(operation.operation)).toBe( - operation.access === 'read-only' ? 'ReadOnly' : 'Mutation', - ); - const payloadPath = payloadSources[operation.payloadType]; - if (!payloadPath) { - throw new Error(`Missing payload source mapping for ${operation.payloadType}`); - } - const payloadSource = sourceText(payloadPath); - expect(payloadSource).toContain(`pub struct ${operation.payloadType}`); - for (const field of operation.payloadFields) { - const fieldPattern = new RegExp(`pub\\s+${snakeCase(field.name)}\\s*:`); - expect(payloadSource).toMatch(fieldPattern); - if (field.optional) { - expect(payloadSource).toMatch( - new RegExp(`pub\\s+${snakeCase(field.name)}\\s*:\\s*Option<`), - ); - } - } - const resultPath = resultSources[operation.resultType]; - if (!resultPath) { - throw new Error(`Missing result source mapping for ${operation.resultType}`); - } - const resultSource = sourceText(resultPath); - expect(resultSource).toContain(`pub struct ${operation.resultType}`); - for (const field of operation.resultFields) { - expect(resultSource).toMatch(new RegExp(`pub\\s+${snakeCase(field.name)}\\s*:`)); - if (field.optional) { - expect(resultSource).toMatch( - new RegExp(`pub\\s+${snakeCase(field.name)}\\s*:\\s*Option<`), - ); - } - } - } - expect( - operationCatalog.find(({ operation }) => operation === 'query-logs-request')?.payloadFields, - ).toEqual([ - { name: 'stream', optional: false }, - { name: 'limit', optional: true }, - ]); - }); - - it('round trips every concrete request/result fixture through the closed Node DTO parser', () => { - for (const fixture of rpcOperationFixtures) { - const request = { - protocolVersion: 3, - requestId: '00000000-0000-4000-8000-000000000001', - method: fixture.method, - params: fixture.params, - }; - expect(requestSchema.parse(request)).toEqual(request); - const response = { - protocolVersion: 3, - requestId: request.requestId, - result: fixture.result, - }; - expect(responseSchema(fixture.method).parse(response)).toEqual(response); - expect(responseSchema(fixture.method).safeParse({ ...response, extra: true }).success).toBe( - false, - ); - } - expect(rpcSnapshotFixture.registrations[0]?.registrationId).toBe('shim-fixture-01'); - }); - - it('freezes correlated RPC examples, protocol errors, streams, and both log defaults', () => { - expect(responseContract).toMatchObject({ - requestFields: ['protocolVersion', 'operation', 'requestId', 'payload'], - successFields: ['protocolVersion', 'operation', 'requestId', 'result'], - errorFields: ['protocolVersion', 'operation', 'requestId', 'error'], - protocolErrorResponse: { name: 'protocol-error-response', fields: ['requestId', 'error'] }, - errorRequestIdRule: 'response and protocol error request IDs must match', - exactlyOneOutcome: true, - unknownFieldsRejected: true, - logLimit: { minimum: 1, maximum: 200, cliDefault: 50, rpcDefault: 100 }, - }); - expect(responseContract.streamStatuses).toEqual([ - 'unknown', - 'empty', - 'active', - 'stale', - 'removed', - 'readError', - ]); - expect(rpcExamples.queryLogsRequest.requestId).toBe(rpcExamples.queryLogsSuccess.requestId); - expect(rpcExamples.queryLogsSuccess).not.toHaveProperty('error'); - expect(rpcExamples.protocolError).not.toHaveProperty('result'); - expect(rpcExamples.protocolError.requestId).toBe(rpcExamples.protocolError.error.requestId); - expect( - errorResponseSchema.parse({ - protocolVersion: 3, - requestId: rpcExamples.protocolError.requestId, - error: rpcExamples.protocolError.error, - }), - ).toMatchObject({ requestId: rpcExamples.protocolError.requestId }); - expect(sourceText('crates/cadder-daemon/src/state/log_queries.rs')).toContain( - 'unwrap_or(100).clamp(1, 200)', - ); - expect(sourceText('crates/cadder-client/src/cli/mod.rs')).toContain('default_value_t = 50'); + }, + streamStatus: 'empty', + entries: [], }); + expect(humanOutputFixture.cases[3]!.output).toContain(empty + '\n'); }); diff --git a/test/daemon-application.test.ts b/test/daemon-application.test.ts new file mode 100644 index 0000000..1ae5cb8 --- /dev/null +++ b/test/daemon-application.test.ts @@ -0,0 +1,279 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, it, vi } from 'vitest'; +import { DaemonApplication } from '../src/daemon/application.ts'; +import { ConfigurationTransactions } from '../src/daemon/configuration-transactions.ts'; +import { normalizeCaddyConfig } from '../src/caddy/admin-policy.ts'; +import type { DesiredState, CaddyConfig, PortResult } from '../src/contracts/ports.ts'; +import { dispatchRpc, type RpcMethod, type RpcParams } from '../src/protocol/rpc.ts'; +import { fakeRegistration } from './fixtures/rpc-data.ts'; +import { PROTOCOL_VERSION } from '../src/protocol/version.ts'; +import { runtimeLogStream } from '../src/storage/schema.ts'; + +const roots: string[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + for (const path of roots.splice(0)) await rm(path, { recursive: true, force: true }); +}); +async function fixture(initial: DesiredState = { projects: [] }) { + const root = await mkdtemp(join(tmpdir(), 'cadder-app-')); + roots.push(root); + const config = join(root, 'Caddyfile'); + await writeFile(config, 'app.localhost { respond ok }'); + let durable = structuredClone(initial); + let active: string | null = null; + let rejected = false; + let persistFailed = false; + const queue = new ConfigurationTransactions( + { config: null, registrations: [], desiredState: durable }, + { + validate: async (candidate) => ({ + ok: true, + value: { effectiveConfigHash: candidate.effectiveConfigHash, diagnostics: [] }, + }), + }, + { + persistDesiredState: async (value) => { + if (persistFailed) + return { + ok: false, + error: { + kind: 'storage', + code: 'storage', + message: 'Rejected', + guidance: null, + retryable: false, + requestId: null, + }, + }; + durable = structuredClone(value); + return { ok: true, value: undefined }; + }, + }, + { + apply: async (value) => { + if (rejected) return { status: 'definitely-rejected' }; + active = value?.effectiveConfigHash ?? null; + return { status: 'applied' }; + }, + readActive: async () => ({ ok: true, value: active }), + }, + ); + const adapt = vi.fn( + async (): Promise> => ({ + ok: true, + value: normalizeCaddyConfig( + JSON.stringify({ + apps: { + http: { + servers: { + project: { + routes: [ + { + match: [{ host: ['app.localhost'] }], + handle: [{ handler: 'static_response', body: 'ok' }], + }, + ], + }, + }, + }, + }, + }), + ), + }), + ); + const stop = vi.fn(); + const app = new DaemonApplication({ + transactions: queue, + adapt, + secure: async (plan) => ({ ok: true, value: normalizeCaddyConfig(JSON.stringify({ plan })) }), + queryLogs: async () => [], + log: async () => {}, + shutdown: stop, + storage: null, + runtime: () => ({ + status: active ? 'running' : 'idle', + binaryPath: null, + version: null, + processId: null, + adminEndpoint: null, + diagnostics: [], + }), + }); + const registration = fakeRegistration(); + registration.entrypointInstance.shimSessionNonce = registration.ownerProcess.shimSessionNonce = + randomUUID(); + registration.sourceWorkingDirectory = { raw: root, canonical: null }; + registration.sourceConfigPath = { raw: config, canonical: null }; + async function call(method: M, params: RpcParams) { + return dispatchRpc( + { protocolVersion: PROTOCOL_VERSION, requestId: randomUUID(), method, params }, + app.handle, + ); + } + async function register() { + const response = await call('register-entrypoint-request', { registration }); + expect(response).not.toHaveProperty('error'); + return app.snapshot().registrations[0]!.registrationId; + } + return { + app, + call, + registration, + register, + adapt, + queue, + stop, + durable: () => durable, + active: () => active, + reject: () => { + rejected = true; + }, + failPersist: () => { + persistFailed = true; + }, + }; +} + +it('registers from actual canonical sources, derives domains and rejects a competing or stale session', async () => { + const f = await fixture(); + const id = await f.register(); + expect(f.app.snapshot().registrations[0]).toMatchObject({ + registrationId: id, + activationState: 'active', + registeredDomains: [{ name: { canonical: 'app.localhost' } }], + }); + const intruder = structuredClone(f.registration); + intruder.entrypointInstance.shimSessionNonce = intruder.ownerProcess.shimSessionNonce = 'other'; + expect(await f.call('register-entrypoint-request', { registration: intruder })).toHaveProperty( + 'error', + ); + expect( + await f.call('unregister-entrypoint-request', { + registrationId: id, + shimSessionNonce: 'other', + }), + ).toHaveProperty('error'); + expect( + await f.call('heartbeat-entrypoint-request', { registrationId: id, shimSessionNonce: 'other' }), + ).toHaveProperty('error'); + expect(f.app.snapshot().registrations).toHaveLength(1); + expect( + await f.call('heartbeat-entrypoint-request', { + registrationId: id, + shimSessionNonce: f.registration.entrypointInstance.shimSessionNonce, + }), + ).not.toHaveProperty('error'); + expect(await f.call('query-state-request', {})).toHaveProperty( + 'result.snapshot.registrations.0.registrationId', + id, + ); +}); + +it('toggles project/domain intent through apply and keeps disabled domains disabled after project re-enable', async () => { + const f = await fixture(); + const id = await f.register(); + expect( + await f.call('set-domain-enabled-request', { + registrationId: id, + domainKey: 'APP.localhost', + enabled: false, + }), + ).not.toHaveProperty('error'); + expect(f.active()).toBeNull(); + expect( + await f.call('set-entrypoint-enabled-request', { registrationId: id, enabled: false }), + ).not.toHaveProperty('error'); + expect( + await f.call('set-entrypoint-enabled-request', { registrationId: id, enabled: true }), + ).not.toHaveProperty('error'); + expect(f.app.snapshot().registrations[0]!.registeredDomains[0]?.activationState).toBe('inactive'); + expect(f.durable().projects[0]?.domains[0]?.enabled).toBe(false); + expect( + await f.call('set-domain-enabled-request', { + registrationId: id, + domainKey: 'missing.localhost', + enabled: true, + }), + ).toHaveProperty('error'); + expect( + await f.call('set-entrypoint-enabled-request', { registrationId: 'missing', enabled: true }), + ).toHaveProperty('error'); + expect( + await f.call('set-entrypoint-enabled-request', { + registrationId: id, + shimSessionNonce: 'stale', + enabled: false, + }), + ).toHaveProperty('error'); +}); + +it('preserves committed routes after apply rejection or storage failure and rolls back the real queue', async () => { + for (const mode of ['reject', 'persist'] as const) { + const f = await fixture(); + const id = await f.register(); + const before = f.active(); + if (mode === 'reject') f.reject(); + else f.failPersist(); + expect( + await f.call('set-entrypoint-enabled-request', { registrationId: id, enabled: false }), + ).toHaveProperty('error'); + expect(f.active()).toBe(before); + expect(f.app.snapshot().registrations[0]?.activationState).toBe('active'); + expect(f.durable().projects[0]?.enabled).toBe(true); + } +}); + +it('expires live sessions, preserves durable intent, and restores no lease on restart', async () => { + const f = await fixture(); + const id = await f.register(); + await f.app.expire(Date.now() + 1); + expect(f.app.snapshot().registrations).toEqual([]); + expect(f.active()).toBeNull(); + expect(f.durable().projects).toHaveLength(1); + const restarted = await fixture(f.durable()); + expect(restarted.app.snapshot().registrations).toEqual([]); + await f.register(); + expect( + await f.call('unregister-entrypoint-request', { + registrationId: id, + shimSessionNonce: f.registration.entrypointInstance.shimSessionNonce, + }), + ).not.toHaveProperty('error'); + expect(f.active()).toBeNull(); +}); + +it('rejects forged paths/domains and accepts only bounded canonical log selections', async () => { + const f = await fixture(); + const relative = structuredClone(f.registration); + relative.sourceConfigPath.raw = 'Caddyfile'; + expect(await f.call('register-entrypoint-request', { registration: relative })).toHaveProperty( + 'error', + ); + await f.register(); + for (const limit of [0, 201]) + expect(await f.call('query-logs-request', { stream: runtimeLogStream, limit })).toHaveProperty( + 'error', + ); + expect(await f.call('query-logs-request', { stream: runtimeLogStream })).toHaveProperty( + 'result.entries', + ); + expect( + await f.call('query-logs-request', { stream: { ...runtimeLogStream, streamId: 'forged' } }), + ).toHaveProperty('error'); + expect( + await f.call('query-logs-request', { + stream: f.app.snapshot().registrations[0]!.registeredDomains[0]!.logStream, + }), + ).toHaveProperty('result.entries'); +}); + +it('acknowledges shutdown before closing admission and scheduling owned teardown', async () => { + const f = await fixture(); + expect(await f.call('shutdown-daemon-request', {})).not.toHaveProperty('error'); + expect(await f.call('query-state-request', {})).toHaveProperty('error'); + expect(f.stop).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(f.stop).toHaveBeenCalledOnce()); +}); diff --git a/test/local-runtime-faults.test.ts b/test/local-runtime-faults.test.ts index 80ad538..58f5311 100644 --- a/test/local-runtime-faults.test.ts +++ b/test/local-runtime-faults.test.ts @@ -61,3 +61,42 @@ it('settles concurrent stop consistently while attempting listener and lock fina expect(close).toHaveBeenCalledOnce(); expect(release).toHaveBeenCalledOnce(); }); + +it('initializes before readiness and retains endpoint/exclusion until owned teardown settles', async () => { + let finish!: () => void; + const owned = { + close: vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ), + }; + const initialize = vi.fn(async () => owned); + const runtime = await startLocalRuntime({ runtimeDir: '/fault-runtime', initialize }, vi.fn()); + expect(initialize.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(serveIpc).mock.invocationCallOrder[0]!, + ); + const stopping = runtime.stop(); + expect(close).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); + finish(); + await stopping; + expect(close).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledOnce(); +}); + +it('does not release exclusion after unproved owned cleanup', async () => { + const owned = { + close: vi.fn(async () => { + throw failure; + }), + }; + const runtime = await startLocalRuntime( + { runtimeDir: '/fault-runtime', initialize: async () => owned }, + vi.fn(), + ); + await expect(runtime.stop()).rejects.toBe(failure); + expect(close).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); +}); diff --git a/test/native-inspection.test.ts b/test/native-inspection.test.ts new file mode 100644 index 0000000..44b15f8 --- /dev/null +++ b/test/native-inspection.test.ts @@ -0,0 +1,26 @@ +import { createServer } from 'node:net'; +import { once } from 'node:events'; +import { expect, it } from 'vitest'; +import { inspectPort, terminatePortOwner } from '../src/platform/inspection.ts'; + +it('observes a real listener and refuses a stale PID without affecting its owner', async () => { + const server = createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing listener.'); + const owners = await inspectPort(address.port); + expect(owners.some((owner) => owner.protocol === 'tcp' && owner.pid === process.pid)).toBe( + true, + ); + await expect(terminatePortOwner(address.port, 2147483646)).rejects.toMatchObject({ + code: 'target-conflict', + }); + expect(server.listening).toBe(true); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +}, 30000); diff --git a/test/operator-cli.test.ts b/test/operator-cli.test.ts new file mode 100644 index 0000000..4adcc2f --- /dev/null +++ b/test/operator-cli.test.ts @@ -0,0 +1,140 @@ +import { resolve } from 'node:path'; +import { afterEach, expect, it, vi } from 'vitest'; +import { runCli, exitCode } from '../src/cli/run.ts'; +import { ClientService } from '../src/client/service.ts'; +import { CadderError, RpcError } from '../src/protocol/errors.ts'; +import { fakeSnapshot } from './fixtures/rpc-data.ts'; +import { cliCases } from './compatibility/catalog.ts'; +import { inspectPort, terminatePortOwner } from '../src/platform/inspection.ts'; +import { localUpstreamPort } from '../src/client/selectors.ts'; +import { logs, table } from '../src/cli/output.ts'; + +vi.mock('../src/platform/inspection.ts', () => ({ + inspectPort: vi.fn(async () => []), + terminatePortOwner: vi.fn(async () => {}), +})); +afterEach(() => vi.restoreAllMocks()); +function fixture() { + const client = new ClientService(); + const snapshot = fakeSnapshot(); + const project = snapshot.registrations[0]!; + project.sourceWorkingDirectory.raw = process.cwd(); + project.sourceConfigPath.raw = project.sourceConfigPath.canonical = resolve('Caddyfile'); + project.registeredDomains[0]!.name = { raw: 'app.localhost', canonical: 'app.localhost' }; + project.registeredDomains[0]!.upstream = 'localhost:3000'; + const state = vi.spyOn(client, 'snapshot').mockResolvedValue(snapshot); + const start = vi.spyOn(client, 'start').mockResolvedValue(); + vi.spyOn(client, 'stop').mockResolvedValue(); + vi.spyOn(client, 'restart').mockResolvedValue(); + const basic = { + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: 'Done.', + }; + vi.spyOn(client, 'setProjectEnabled').mockResolvedValue({ ok: true, value: basic }); + vi.spyOn(client, 'setDomainEnabled').mockResolvedValue({ ok: true, value: basic }); + vi.spyOn(client, 'queryLogs').mockResolvedValue({ + ok: true, + value: { ...basic, stream: project.logStream, streamStatus: 'empty', entries: [] }, + }); + const run = async (argv: readonly string[]) => { + let stdout = ''; + let stderr = ''; + const code = await runCli(argv, client, { + out: (text) => { + stdout += text; + }, + error: (text) => { + stderr += text; + }, + }); + return { code, stdout, stderr }; + }; + return { client, state, start, run }; +} + +it('executes the retained command catalog and rejects unsupported grammar without new surfaces', async () => { + const f = fixture(); + for (const entry of cliCases.filter((entry) => !entry.argv.includes('tui'))) { + const result = await f.run(entry.argv.slice(1)); + expect(result.code, entry.argv.join(' ')).toBe(entry.exitCode); + expect(result[entry.stream], entry.argv.join(' ')).not.toBe(''); + } +}); + +it('bare/group help and attach-only reads never start a daemon and retain the offline exit', async () => { + const f = fixture(); + expect((await f.run([])).stderr).toContain('Usage: cadder'); + for (const group of ['daemon', 'projects', 'domains', 'logs', 'port', 'caddyfile']) + expect((await f.run([group])).code).toBe(0); + expect(f.start).not.toHaveBeenCalled(); + expect(f.state).not.toHaveBeenCalled(); + f.state.mockRejectedValue(new CadderError('daemon-unavailable', 'cadderd is not running.')); + expect((await f.run(['status'])).code).toBe(3); + expect(f.start).not.toHaveBeenCalled(); + const inspect = await f.run(['port', 'inspect', '3000']); + expect(inspect.code).toBe(0); + expect(inspect.stdout).toContain('Registration data unavailable.'); + expect(inspectPort).toHaveBeenCalledWith(3000); +}); + +it('validates bounded logs and expected PID before any operation and maps operation errors', async () => { + const f = fixture(); + vi.mocked(terminatePortOwner).mockClear(); + expect((await f.run(['logs', 'runtime', '--limit', '0'])).code).toBe(2); + expect((await f.run(['port', 'kill', '3000'])).code).toBe(2); + expect(terminatePortOwner).not.toHaveBeenCalled(); + expect((await f.run(['port', 'kill', '3000', '--pid', '42'])).code).toBe(0); + expect(terminatePortOwner).toHaveBeenCalledWith(3000, 42); + expect((await f.run(['domains', 'enable', 'absent.localhost'])).code).toBe(5); + for (const [code, expected] of [ + ['daemon-start-failed', 4], + ['target-not-found', 5], + ['target-conflict', 6], + ['unsafe-runtime-permissions', 7], + ['unsupported-operation', 8], + ['invalid-input', 2], + ] as const) + expect(exitCode(new CadderError(code, code))).toBe(expected); + expect( + exitCode( + new RpcError({ + kind: 'conflict', + code: 'conflict', + message: 'Conflict', + guidance: null, + retryable: false, + requestId: null, + }), + ), + ).toBe(6); +}); + +it('renders control-safe tables and bounded log output without terminal escape execution', () => { + expect(table(['NAME'], [['\x1b[31mvalue\x1b[0m\r\n']])).toContain('value'); + expect(table(['NAME'], [['\x1b[31mvalue\x1b[0m\r\n']])).not.toContain('\x1b'); + expect( + logs({ + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: '', + stream: { streamId: 'domain-app.localhost', domainKey: 'app.localhost', channel: 'caddy' }, + streamStatus: 'empty', + entries: [], + }), + ).toContain('No log entries.'); + for (const upstream of [ + ':3000', + '3000', + ' 127.0.0.2:3000 ', + '[0:0:0:0:0:0:0:1]:3000', + 'localhost:3000', + '127.0.0.1:3000', + '[::1]:3000', + 'http://localhost:3000/path', + ]) + expect(localUpstreamPort(upstream)).toBe(3000); + expect(localUpstreamPort('api.example.com:3000')).toBeUndefined(); + expect(localUpstreamPort('localhost')).toBeUndefined(); + expect(localUpstreamPort('unix//tmp/app.sock')).toBeUndefined(); +}); diff --git a/test/ports.test.ts b/test/ports.test.ts index fdcbb4e..2dfa2c6 100644 --- a/test/ports.test.ts +++ b/test/ports.test.ts @@ -1,221 +1,11 @@ import { readFile } from 'node:fs/promises'; -import { expect, expectTypeOf, it } from 'vitest'; -import type { ProtocolError } from '../src/protocol/errors.ts'; -import type { - CaddyApplyReceipt, - CaddyConfig, - CaddyPort, - CaddyValidation, - ClientServicePort, - DesiredState, - DomainActivationResult, - PlatformPort, - PortResult, - ProtectedPathKind, - ProjectActivationResult, - QueryLogsResult, - QueryStateResult, - RuntimeOwner, - ShutdownResult, - StoragePort, -} from '../src/contracts/ports.ts'; -import type { RpcParams } from '../src/protocol/rpc.ts'; +import { expect, it } from 'vitest'; -const validConfig: CaddyConfig = { - effectiveConfigHash: 'config-hash', - adaptedConfig: { - format: 'json', - body: JSON.stringify({ - apps: { http: { servers: { app: { routes: [{ match: [{ host: ['app.localhost'] }] }] } } } }, - }), - }, -}; -const validation: CaddyValidation = { effectiveConfigHash: 'config-hash', diagnostics: [] }; -const error: ProtocolError = { - kind: 'configuration', - code: 'invalid_configuration', - message: 'The fixture rejected the candidate.', - guidance: null, - retryable: false, - requestId: null, -}; -const ok = (value: T): PortResult => ({ ok: true, value }); -const fail = (): PortResult => ({ ok: false, error }); - -it('keeps the four ports independently mockable with typed result/error contracts', async () => { - const caddy: CaddyPort = { - validate: async (config) => (config === validConfig ? ok(validation) : fail()), - apply: async (config) => - config === validConfig ? ok({ effectiveConfigHash: config.effectiveConfigHash }) : fail(), - }; - const platform: PlatformPort = { - currentOwner: async () => ok({ id: 'uid:1000', uid: 1000, elevated: false }), - assertProtected: async () => ok(undefined), - }; - const desired: DesiredState = { - projects: [ - { - projectKey: 'project-a', - sourceWorkingDirectory: '/workspace/project-a', - sourceConfigPath: '/workspace/project-a/Caddyfile', - enabled: true, - domains: [{ canonicalDomain: 'app.localhost', upstream: '127.0.0.1:3000', enabled: true }], - }, - ], - }; - const storage: StoragePort = { - loadDesiredState: async () => ok(desired), - persistDesiredState: async (state) => (state === desired ? ok(undefined) : fail()), - }; - const service: ClientServicePort = { - queryState: async () => - ok({ - requestId: '00000000-0000-4000-8000-000000000001', - accepted: true, - message: 'ok', - snapshot: null, - }), - queryLogs: async (params) => - ok({ - requestId: '00000000-0000-4000-8000-000000000001', - accepted: true, - message: 'ok', - stream: { ...params.stream, domainKey: params.stream.domainKey ?? null }, - streamStatus: 'empty', - entries: [], - }), - setProjectEnabled: async (params) => - ok({ - ...params, - requestId: '00000000-0000-4000-8000-000000000001', - accepted: true, - message: 'ok', - }), - setDomainEnabled: async (params) => - ok({ - ...params, - requestId: '00000000-0000-4000-8000-000000000001', - accepted: true, - message: 'ok', - }), - shutdown: async () => - ok({ requestId: '00000000-0000-4000-8000-000000000001', accepted: true, message: 'ok' }), - }; - - expect((await caddy.validate(validConfig)).ok).toBe(true); - expect((await platform.currentOwner()).ok).toBe(true); - expect((await storage.loadDesiredState()).ok).toBe(true); - expect((await service.shutdown()).ok).toBe(true); - const rejected = fail(); - expect(rejected.ok).toBe(false); - if (!rejected.ok) expect(rejected.error).toEqual(error); -}); - -it('pins every port signature, result and shared error type', () => { - expectTypeOf().parameters.toEqualTypeOf<[config: CaddyConfig]>(); - expectTypeOf().returns.toEqualTypeOf< - Promise> - >(); - expectTypeOf().parameters.toEqualTypeOf<[config: CaddyConfig]>(); - expectTypeOf().returns.toEqualTypeOf< - Promise> - >(); - - expectTypeOf().toEqualTypeOf< - (explicitUid?: number) => Promise> - >(); - expectTypeOf().returns.toEqualTypeOf< - Promise> - >(); - expectTypeOf().parameters.toEqualTypeOf< - [path: string, owner: RuntimeOwner, kind: ProtectedPathKind] - >(); - expectTypeOf().returns.toEqualTypeOf< - Promise> - >(); - - expectTypeOf().parameters.toEqualTypeOf<[]>(); - expectTypeOf().returns.toEqualTypeOf< - Promise> - >(); - expectTypeOf().parameters.toEqualTypeOf< - [state: DesiredState] - >(); - expectTypeOf().returns.toEqualTypeOf< - Promise> - >(); - - expectTypeOf().parameters.toEqualTypeOf<[]>(); - expectTypeOf().returns.toEqualTypeOf< - Promise> - >(); - expectTypeOf().parameters.toEqualTypeOf< - [params: RpcParams<'query-logs-request'>] - >(); - expectTypeOf().returns.toEqualTypeOf< - Promise> - >(); - expectTypeOf().parameters.toEqualTypeOf< - [params: RpcParams<'set-entrypoint-enabled-request'>] - >(); - expectTypeOf().returns.toEqualTypeOf< - Promise> - >(); - expectTypeOf().parameters.toEqualTypeOf< - [params: RpcParams<'set-domain-enabled-request'>] - >(); - expectTypeOf().returns.toEqualTypeOf< - Promise> - >(); - expectTypeOf().parameters.toEqualTypeOf<[]>(); - expectTypeOf().returns.toEqualTypeOf< - Promise> - >(); - - expectTypeOf>().toEqualTypeOf< - Readonly<{ ok: true; value: never }> | Readonly<{ ok: false; error: ProtocolError }> - >(); -}); - -it('pins exact contract keys and excludes live ownership fields', () => { - expectTypeOf().toEqualTypeOf<'effectiveConfigHash' | 'adaptedConfig'>(); - expectTypeOf().toEqualTypeOf<'format' | 'body'>(); - expectTypeOf().toEqualTypeOf<'id' | 'uid' | 'elevated'>(); - expectTypeOf().toEqualTypeOf<'projects'>(); - expectTypeOf().toEqualTypeOf< - 'projectKey' | 'sourceWorkingDirectory' | 'sourceConfigPath' | 'enabled' | 'domains' - >(); - expectTypeOf().toEqualTypeOf< - 'canonicalDomain' | 'upstream' | 'enabled' - >(); - - const project = { - projectKey: 'project-a', - sourceWorkingDirectory: '/workspace/project-a', - sourceConfigPath: '/workspace/project-a/Caddyfile', - enabled: false, - domains: [{ canonicalDomain: 'app.localhost', upstream: null, enabled: false }], - } satisfies DesiredState['projects'][number]; - - expect(Object.keys(project)).not.toEqual( - expect.arrayContaining([ - 'registrationId', - 'entrypointInstance', - 'shimSessionNonce', - 'ownerProcess', - 'processId', - 'activeRoute', - 'lease', - ]), - ); -}); - -it('keeps contracts as a protocol-only leaf module', async () => { +it('keeps shared port contracts independent of runtime implementations', async () => { const source = await readFile(new URL('../src/contracts/ports.ts', import.meta.url), 'utf8'); const imports = [...source.matchAll(/\bfrom\s+['"]([^'"]+)['"]/g)].map( ([, specifier]) => specifier, ); - expect(imports).toHaveLength(2); + expect(imports.length).toBeGreaterThan(0); expect(imports.every((specifier) => specifier?.startsWith('../protocol/'))).toBe(true); - expect(source.match(/export interface /g)).toHaveLength(4); }); diff --git a/test/product-daemon.test.ts b/test/product-daemon.test.ts new file mode 100644 index 0000000..2c0e5de --- /dev/null +++ b/test/product-daemon.test.ts @@ -0,0 +1,37 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { startDaemon } from '../src/daemon/start.ts'; +import { rpc } from '../src/client/connection.ts'; +import { runtimeLogStream } from '../src/storage/schema.ts'; + +it('runs actual protected product handlers and storage through authenticated IPC, then releases the runtime', async () => { + const directory = await mkdtemp(join(tmpdir(), 'cadder-product-')); + let daemon: Awaited> | undefined; + try { + daemon = await startDaemon({ runtimeDir: directory }); + const state = await rpc(daemon.paths, 'query-state-request'); + expect(state.snapshot).toMatchObject({ + registrations: [], + runtime: { status: 'idle' }, + config: { status: 'idle' }, + storage: { backend: 'sqlite', schemaVersion: 1 }, + }); + const logs = await rpc(daemon.paths, 'query-logs-request', { + stream: runtimeLogStream, + limit: 10, + }); + expect(logs.entries[0]?.rawMessage).toBe('Daemon started.'); + expect((await rpc(daemon.paths, 'shutdown-daemon-request')).accepted).toBe(true); + await daemon.stop(); + await expect(rpc(daemon.paths, 'query-state-request')).rejects.toMatchObject({ + code: 'daemon-unavailable', + }); + daemon = await startDaemon({ runtimeDir: directory }); + expect((await rpc(daemon.paths, 'query-state-request')).snapshot?.registrations).toEqual([]); + } finally { + await daemon?.stop(); + await rm(directory, { recursive: true, force: true }); + } +}, 60000); diff --git a/test/routes-tui.test.tsx b/test/routes-tui.test.tsx new file mode 100644 index 0000000..062d6f6 --- /dev/null +++ b/test/routes-tui.test.tsx @@ -0,0 +1,65 @@ +import { render, cleanup } from 'ink-testing-library'; +import { afterEach, expect, it, vi } from 'vitest'; +import { RoutesApp } from '../src/tui/app.tsx'; +import { ClientService } from '../src/client/service.ts'; +import { CadderError } from '../src/protocol/errors.ts'; +import { fakeSnapshot } from './fixtures/rpc-data.ts'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); +it('renders real client state with tree rows and toggles the selected project/domain', async () => { + const client = new ClientService(); + vi.spyOn(client, 'snapshot').mockResolvedValue(fakeSnapshot()); + const result = { + ok: true as const, + value: { requestId: '00000000-0000-4000-8000-000000000001', accepted: true, message: 'Done' }, + }; + const project = vi.spyOn(client, 'setProjectEnabled').mockResolvedValue(result); + const domain = vi.spyOn(client, 'setDomainEnabled').mockResolvedValue(result); + const screen = render(); + await vi.waitFor(() => expect(screen.lastFrame()).toContain('example.localhost')); + expect(screen.lastFrame()).toContain('cadderd online'); + expect(screen.lastFrame()).not.toContain('runtime-control'); + screen.stdin.write(' '); + await vi.waitFor(() => + expect(project).toHaveBeenCalledWith({ registrationId: 'shim-fixture', enabled: true }), + ); + await vi.waitFor(() => expect(screen.lastFrame()).not.toContain('Working...')); + screen.stdin.write('\x1b[B'); + await vi.waitFor(() => expect(screen.lastFrame()).toMatch(/>.*example\.localhost/)); + screen.stdin.write('\r'); + await vi.waitFor(() => + expect(domain).toHaveBeenCalledWith({ + registrationId: 'shim-fixture', + domainKey: 'example.localhost', + enabled: false, + }), + ); +}); + +it('keeps offline entry attach-only, starts explicitly and confirms stop', async () => { + const client = new ClientService(); + const snapshot = vi + .spyOn(client, 'snapshot') + .mockRejectedValue(new CadderError('daemon-unavailable', 'Offline')); + const start = vi.spyOn(client, 'start').mockImplementation(async () => { + snapshot.mockResolvedValue(fakeSnapshot()); + }); + const stop = vi.spyOn(client, 'stop').mockResolvedValue(); + const screen = render(); + await vi.waitFor(() => expect(screen.lastFrame()).toContain('Press Enter')); + expect(start).not.toHaveBeenCalled(); + screen.stdin.write('\r'); + await vi.waitFor(() => expect(screen.lastFrame()).toContain('cadderd online')); + screen.stdin.write('s'); + await vi.waitFor(() => expect(screen.lastFrame()).toContain('y / n')); + expect(stop).not.toHaveBeenCalled(); + screen.stdin.write('n'); + await vi.waitFor(() => expect(screen.lastFrame()).not.toContain('y / n')); + screen.stdin.write('s'); + await vi.waitFor(() => expect(screen.lastFrame()).toContain('y / n')); + screen.stdin.write('y'); + await vi.waitFor(() => expect(stop).toHaveBeenCalledOnce()); +}); diff --git a/test/shim.test.ts b/test/shim.test.ts new file mode 100644 index 0000000..3973283 --- /dev/null +++ b/test/shim.test.ts @@ -0,0 +1,84 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { resolve } from 'node:path'; +import { buildRegistration, managedRun, shimPolicy } from '../src/shim/run.ts'; +import { ClientService } from '../src/client/service.ts'; +import { rpc } from '../src/client/connection.ts'; +import { CadderError } from '../src/protocol/errors.ts'; +import { registrationSchema } from '../src/protocol/dto.ts'; +import { shimCommandCases, shimAliases } from './compatibility/catalog.ts'; + +vi.mock('../src/client/connection.ts', () => ({ rpc: vi.fn() })); +vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn(async () => {}) })); +afterEach(() => { + vi.resetAllMocks(); + vi.restoreAllMocks(); +}); +const basic = { + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: 'Done', +}; + +it('implements the released shim policy instead of testing the old Rust source text', () => { + const policies = { + managed: 'managed', + 'read-only-inspection': 'inspection', + 'explicit-passthrough': 'passthrough', + unsupported: 'unsupported', + }; + for (const entry of shimCommandCases) + expect(shimPolicy([entry.command])).toBe(policies[entry.policy]); + for (const alias of shimAliases) expect(shimPolicy(alias.argv)).not.toBe('unsupported'); +}); + +it('keeps argv metadata exact and gives each real registration one matching fresh session identity', () => { + const argv = ['run', '--config', 'folder with spaces/Caddyfile', '--adapter', 'caddyfile']; + const registration = buildRegistration(argv); + expect(registrationSchema.parse(registration)).toEqual(registration); + expect(registration.registrationId).toBe(registration.entrypointInstance.instanceId); + expect(registration.ownerProcess.shimSessionNonce).toBe( + registration.entrypointInstance.shimSessionNonce, + ); + expect(registration.entrypointInstance.shimSessionNonce).not.toBe( + buildRegistration(argv).entrypointInstance.shimSessionNonce, + ); + expect(registration.sourceConfigPath.raw).toBe(resolve('folder with spaces/Caddyfile')); + expect(registration.shimRun?.rawArguments).toEqual(argv); + expect(() => buildRegistration(['run', '--config'])).toThrow(); +}); + +it('reconnects the same shim after a daemon restart and unregisters only its latest returned session', async () => { + const client = new ClientService(); + vi.spyOn(client, 'start').mockResolvedValue(); + const abort = new AbortController(); + let registrations = 0; + let heartbeats = 0; + vi.mocked(rpc).mockImplementation((async (_paths: unknown, method: string) => { + if (method === 'register-entrypoint-request') + return { ...basic, registrationId: `returned-${++registrations}` }; + if (method === 'heartbeat-entrypoint-request') { + heartbeats++; + if (heartbeats === 1) throw new CadderError('daemon-unavailable', 'Offline'); + if (heartbeats === 2) throw new CadderError('stale_session', 'Restarted'); + abort.abort(); + } + return basic; + }) as typeof rpc); + await managedRun(['run'], client, abort.signal); + expect(client.start).toHaveBeenCalledOnce(); + expect(registrations).toBe(2); + expect(vi.mocked(rpc).mock.calls.at(-1)?.[1]).toBe('unregister-entrypoint-request'); + expect(vi.mocked(rpc).mock.calls.at(-1)?.[2]).toMatchObject({ registrationId: 'returned-2' }); +}); + +it('never delegates to independent Caddy on failed managed startup or cancellation', async () => { + const client = new ClientService(); + const start = vi + .spyOn(client, 'start') + .mockRejectedValue(new CadderError('daemon-start-failed', 'Failed')); + await expect(managedRun(['run'], client)).rejects.toMatchObject({ code: 'daemon-start-failed' }); + expect(rpc).not.toHaveBeenCalled(); + start.mockClear(); + await managedRun(['run'], client, AbortSignal.abort()); + expect(start).not.toHaveBeenCalled(); +}); From 86eb71405ea05d150389a45f78880985bded4dc6 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Tue, 20 Oct 2026 13:38:31 +0200 Subject: [PATCH 23/29] test: make native migration checks portable Use physical fixture paths and distinct executable directories, drain fake-Caddy output before exit, and serialize Windows native fixtures. Add the actual-product Sandbox privilege procedure while keeping its interactive acceptance open. --- .github/workflows/node-runtime.yml | 1 - .../reset-cadder-architecture/verification.md | 30 +++++ .../windows-product-gate.md | 106 ++++++++++++++++++ scripts/prepare-product-sandbox.ts | 24 +++- test/caddy-owned-process.test.ts | 6 +- test/caddy-preparation.test.ts | 3 +- test/caddy-resolution.test.ts | 4 +- test/fixtures/caddy-preload.ts | 2 +- test/fixtures/caddy-preparation-preload.ts | 4 +- test/installation-identity.test.ts | 2 + test/native-inspection.test.ts | 4 +- test/runtime-descendants.test.ts | 4 +- vitest.config.ts | 2 + 13 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 openspec/changes/reset-cadder-architecture/windows-product-gate.md diff --git a/.github/workflows/node-runtime.yml b/.github/workflows/node-runtime.yml index a5e9ca7..6382840 100644 --- a/.github/workflows/node-runtime.yml +++ b/.github/workflows/node-runtime.yml @@ -4,7 +4,6 @@ on: push: branches: - master - - feat/node-runtime-migration pull_request: branches: - master diff --git a/openspec/changes/reset-cadder-architecture/verification.md b/openspec/changes/reset-cadder-architecture/verification.md index 5ca0d58..c702a11 100644 --- a/openspec/changes/reset-cadder-architecture/verification.md +++ b/openspec/changes/reset-cadder-architecture/verification.md @@ -810,6 +810,12 @@ product PATH. A repeat against the f298 candidate while the host full suite ran failed after shim crash with port 80 unavailable. That repeat is a failure, not a final-candidate pass; serial/native CI diagnosis remains open. +The subsequent serial Sandbox repeat against that same f298 Windows archive +passes the complete shared SEA journey with stock Caddy 2.11.4 and no Node on +product PATH, including crashed-alpha expiry while beta continues serving. +The earlier concurrent failure remains recorded; no lease or test deadline was +relaxed. This repeat still does not provide interactive UAC/account evidence. + The startup consumer probe also reproduced a Windows ACL-publication race. Post-launch readiness now waits for protection to finish without reading an unsafe secret or authenticating; pre-existing unsafe paths still fail directly. @@ -835,3 +841,27 @@ downloaded checksum-pinned upstream fixture. Configuring that workflow is not execution evidence. Rust deletion, release-tool cutover and publication remain gated. The user explicitly authorized commit and push for native CI; this does not authorize a PR, tag, registry publication or GitHub release. + +### First native CI pass and fixture corrections + +Commit `5b4daee` was pushed to the existing migration PR with explicit authority. +Docs CI passes. The first native Node matrix fails: Linux exposes a fixture file +named `upstream` colliding with its later directory, macOS exposes uncanonical +`/var` versus `/private/var` expectations, a modeled Linux test incorrectly walks +macOS aliases, and a namespace-only fixture exceeds the separately enforced +Unix socket limit. Windows also exposes short/long TEMP spelling expectations +and fixture timeouts under parallel native helper compilation. The fixes use +physical fixture paths, distinct file/directory names and serialized Windows +files, preserving existing runtime permission and timeout policy. + +A local 32-MiB+1 stderr fixture occasionally exited before its pending pipe +output settled. Disposable fake-Caddy preloads now exit naturally after output; +all eight exact/overflow stream-bound cases pass. macOS native socket inspection +still fails and now reports its actual owner rows for diagnosis. None of these +failed native runs closes G4/G6/G7. The duplicate push-trigger matrix is removed; +the existing PR matrix remains, alongside master push/manual triggers. + +The product Sandbox preparer now also emits a separate interactive privilege +configuration and the `windows-product-gate.md` procedure. Its four PowerShell +blocks parse and the generated Sandbox XML loads correctly. This is preparation +only: no UAC/account pass is claimed. diff --git a/openspec/changes/reset-cadder-architecture/windows-product-gate.md b/openspec/changes/reset-cadder-architecture/windows-product-gate.md new file mode 100644 index 0000000..5990060 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/windows-product-gate.md @@ -0,0 +1,106 @@ +# Interactive Windows product privilege acceptance + +Use the `privilege.wsb` produced by `scripts/prepare-product-sandbox.ts` for the +exact candidate being accepted. The package mounts candidate executables and +separate upstream Caddy read-only, disables networking, and writes only reports +back to the host. This procedure changes accounts only inside disposable Sandbox. +Do not execute its account block on the host. + +The automated routing report does not pass this gate. A real non-elevated client, +same-SID elevated daemon, UAC cancellation/approval and a successfully launched +different-account client are required. If Sandbox cannot provide a non-elevated +shell and a real UAC prompt, record BLOCKED. Do not bypass or disable UAC. + +## Same owner, cancellation and elevation + +In a non-elevated PowerShell inside Sandbox: + +```powershell +$cadderIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() +$cadderPrincipal = New-Object Security.Principal.WindowsPrincipal($cadderIdentity) +if ($cadderPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Use a non-elevated client shell.' } +$cadderGateRuntime = Join-Path $env:TEMP ('cadder-product-uac-' + [guid]::NewGuid().ToString('N')) +$env:CADDER_RUNTIME_DIR = $cadderGateRuntime +$cadderArguments = @('--runtime-dir', ('"' + $cadderGateRuntime + '"'), '--real-caddy', 'C:\CadderTools\caddy.exe') +Start-Process -FilePath C:\CadderProduct\cadderd.exe -Verb RunAs -WindowStyle Hidden -ArgumentList $cadderArguments +``` + +Cancel the actual UAC prompt. Verify that no discovery file exists and the actual +operator reports offline (exit 3): + +```powershell +if (Test-Path -LiteralPath (Join-Path $cadderGateRuntime 'v2\cadder-ipc.json')) { throw 'Cancelled elevation published a daemon.' } +& C:\CadderProduct\cadder.exe status +if ($LASTEXITCODE -ne 3) { throw 'Cancelled startup was not offline.' } +``` + +Repeat `Start-Process`, approve the prompt, then use the same non-elevated shell: + +```powershell +$cadderDeadline = (Get-Date).AddSeconds(45) +do { + & C:\CadderProduct\cadder.exe status + if ($LASTEXITCODE -eq 0) { break } + Start-Sleep -Milliseconds 300 +} while ((Get-Date) -lt $cadderDeadline) +if ($LASTEXITCODE -ne 0) { throw 'Ordinary client could not contact the product daemon.' } +$cadderDiscovery = Get-Content -LiteralPath (Join-Path $cadderGateRuntime 'v2\cadder-ipc.json') -Raw | ConvertFrom-Json +if (!$cadderDiscovery.elevated -or $cadderDiscovery.owner -ne $cadderIdentity.User.Value) { throw 'Expected the same SID and an elevated daemon.' } +if ($cadderPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'The client must remain non-elevated.' } +'SAME_OWNER_ELEVATED_PRODUCT_CONTACT_PASSED' +``` + +Record the candidate version/checksum and the observed cancellation/approval. +Do not export runtime secrets, account passwords or private key material. + +## Different account + +In an elevated PowerShell inside Sandbox, set `$cadderGateRuntime` to the exact +path from the first shell, then run: + +```powershell +if ($env:USERNAME -ne 'WDAGUtilityAccount') { throw 'Run only inside disposable Windows Sandbox.' } +$ErrorActionPreference = 'Stop' +$cadderAccount = $null +$cadderOutsider = 'cadder-gate-' + [guid]::NewGuid().ToString('N').Substring(0, 6) +$cadderPassword = ConvertTo-SecureString ('A9!' + [guid]::NewGuid().ToString('N')) -AsPlainText -Force +$cadderInputs = Join-Path $env:PUBLIC ('cadder-product-gate-' + [guid]::NewGuid().ToString('N')) +try { + $cadderAccount = New-LocalUser -Name $cadderOutsider -Password $cadderPassword + $cadderUsers = Get-LocalGroup -SID 'S-1-5-32-545' + Add-LocalGroupMember -Group $cadderUsers.Name -Member $cadderAccount + New-Item -ItemType Directory -Path $cadderInputs | Out-Null + Copy-Item -LiteralPath C:\CadderProduct\cadder.exe -Destination $cadderInputs + $cadderQuotedRuntime = $cadderGateRuntime.Replace("'", "''") + $cadderProbeSource = '$ErrorActionPreference = ''Continue''; $env:CADDER_RUNTIME_DIR = ''' + $cadderQuotedRuntime + '''; & (Join-Path $PSScriptRoot ''cadder.exe'') status; exit $LASTEXITCODE' + Set-Content -LiteralPath (Join-Path $cadderInputs 'probe.ps1') -Value $cadderProbeSource + $cadderInputAcl = Get-Acl -LiteralPath $cadderInputs + $cadderInputAcl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($cadderAccount.SID, 'ReadAndExecute', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + [IO.Directory]::SetAccessControl($cadderInputs, $cadderInputAcl) + foreach ($cadderReportName in @('out.txt', 'err.txt')) { + $cadderReport = Join-Path $cadderInputs $cadderReportName + New-Item -ItemType File -Path $cadderReport | Out-Null + $cadderReportAcl = Get-Acl -LiteralPath $cadderReport + $cadderReportAcl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($cadderAccount.SID, 'Write', 'Allow'))) + [IO.File]::SetAccessControl($cadderReport, $cadderReportAcl) + } + $cadderCredential = New-Object Management.Automation.PSCredential(($env:COMPUTERNAME + '\' + $cadderOutsider), $cadderPassword) + $cadderDenied = Start-Process -FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $cadderInputs 'probe.ps1')) -Credential $cadderCredential -LoadUserProfile -WorkingDirectory $cadderInputs -WindowStyle Hidden -RedirectStandardOutput (Join-Path $cadderInputs 'out.txt') -RedirectStandardError (Join-Path $cadderInputs 'err.txt') -Wait -PassThru + if ($cadderDenied.ExitCode -ne 7) { throw ('Expected access denial from the actual CLI, got exit ' + $cadderDenied.ExitCode) } + 'DIFFERENT_ACCOUNT_PRODUCT_DENIED' +} finally { + if ($null -ne $cadderAccount) { Remove-LocalUser -SID $cadderAccount.SID } + $cadderResolvedInputs = [IO.Path]::GetFullPath($cadderInputs) + $cadderPublicRoot = [IO.Path]::GetFullPath($env:PUBLIC).TrimEnd('\') + '\' + if (!$cadderResolvedInputs.StartsWith($cadderPublicRoot, [StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetFileName($cadderResolvedInputs) -notmatch '^cadder-product-gate-[0-9a-f]{32}$') { throw 'Unsafe test cleanup path.' } + if (Test-Path -LiteralPath $cadderResolvedInputs) { Remove-Item -LiteralPath $cadderResolvedInputs -Recurse -Force } +} +``` + +A failed account/process launch does not pass. Only exit 7 from the actual +operator proves the expected access-denied boundary. Back in the original +non-elevated shell, verify `cadder.exe status` still succeeds, then stop with +`C:\CadderProduct\cadder.exe daemon stop` and close Sandbox. + +Record all actual results before closing parent tasks 2.8 and 7.4. Preparation +and an elevated-client success alone do not constitute acceptance. diff --git a/scripts/prepare-product-sandbox.ts b/scripts/prepare-product-sandbox.ts index bb512a5..0c4ea7a 100644 --- a/scripts/prepare-product-sandbox.ts +++ b/scripts/prepare-product-sandbox.ts @@ -15,6 +15,15 @@ await mkdir(input); await mkdir(reports); await copyFile(process.execPath, join(input, 'node.exe')); await copyFile(resolve(upstream), join(input, 'caddy.exe')); +await copyFile( + fileURLToPath( + new URL( + '../openspec/changes/reset-cadder-architecture/windows-product-gate.md', + import.meta.url, + ), + ), + join(input, 'README.md'), +); await build({ entryPoints: [fileURLToPath(new URL('./verify-product-journey.ts', import.meta.url))], outfile: join(input, 'journey.mjs'), @@ -40,9 +49,7 @@ try { const escape = (value: string): string => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); const config = join(root, 'product.wsb'); -await writeFile( - config, - ` +const configuration = ` DisableDisableDisable DisableDisableDisable @@ -51,11 +58,20 @@ await writeFile( ${escape(reports)}C:\\CadderReportsfalse powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\\CadderTools\\bootstrap.ps1 -`, +`; +await writeFile(config, configuration); +const privilegeConfig = join(root, 'privilege.wsb'); +await writeFile( + privilegeConfig, + configuration.replace( + 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\\CadderTools\\bootstrap.ps1', + 'powershell.exe -NoProfile -NoExit -Command "Set-Location C:\\CadderTools; Get-Content README.md"', + ), ); process.stdout.write( JSON.stringify({ config, + privilegeConfig, reports, scope: 'Actual SEA product routing; final UAC/different-account acceptance remains separate.', }) + '\n', diff --git a/test/caddy-owned-process.test.ts b/test/caddy-owned-process.test.ts index 57396af..944f854 100644 --- a/test/caddy-owned-process.test.ts +++ b/test/caddy-owned-process.test.ts @@ -1,7 +1,7 @@ import { execFile, spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, normalize } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; @@ -184,7 +184,9 @@ it('owned command retains stream boundary, cwd, stdin EOF and nonzero native exi ['-e', 'process.stdout.write(process.cwd())'], { cwd: tmpdir() }, ); - expect(normalize(cwd.stdout.toString()).toLowerCase()).toBe(normalize(tmpdir()).toLowerCase()); + expect(normalize(cwd.stdout.toString()).toLowerCase()).toBe( + normalize(await realpath(tmpdir())).toLowerCase(), + ); }); it.each([0, 16])( diff --git a/test/caddy-preparation.test.ts b/test/caddy-preparation.test.ts index a1ce76f..f68453a 100644 --- a/test/caddy-preparation.test.ts +++ b/test/caddy-preparation.test.ts @@ -4,6 +4,7 @@ import { mkdtemp, readFile, readdir, + realpath, rename, rm, writeFile, @@ -38,7 +39,7 @@ const candidate = (body = '{}'): CaddyConfig => ({ adaptedConfig: { format: 'json', body }, }); async function setup() { - const directory = await mkdtemp(join(tmpdir(), 'cadder-preparation-test-')); + const directory = await realpath(await mkdtemp(join(tmpdir(), 'cadder-preparation-test-'))); cleanups.push(() => rm(directory, { recursive: true, force: true })); const root = join(directory, 'node_modules', 'cadder'); await mkdir(root, { recursive: true }); diff --git a/test/caddy-resolution.test.ts b/test/caddy-resolution.test.ts index 6ad520b..4875ffd 100644 --- a/test/caddy-resolution.test.ts +++ b/test/caddy-resolution.test.ts @@ -41,7 +41,7 @@ afterEach(async () => { }); const nativeName = (name: string) => (process.platform === 'win32' ? `${name}.exe` : name); async function setup() { - const directory = await mkdtemp(join(tmpdir(), 'cadder-caddy-resolution-')); + const directory = await realpath(await mkdtemp(join(tmpdir(), 'cadder-caddy-resolution-'))); cleanup.push(() => rm(directory, { recursive: true, force: true })); const root = join(directory, 'project', 'node_modules', 'cadder'); await mkdir(join(root, 'dist'), { recursive: true }); @@ -298,7 +298,7 @@ it('rejects hardlinked npm bin identities and skips aliased entries on PATH', as await expect(s.resolver({ explicitOverride: candidate }).pin()).rejects.toThrow( 'Cadder distribution', ); - const upstreamDir = join(s.directory, 'upstream'); + const upstreamDir = join(s.directory, 'upstream-bin'); await mkdir(upstreamDir); const upstream = join(upstreamDir, nativeName('caddy')); await rename(real, upstream); diff --git a/test/fixtures/caddy-preload.ts b/test/fixtures/caddy-preload.ts index 053ef9e..47cf971 100644 --- a/test/fixtures/caddy-preload.ts +++ b/test/fixtures/caddy-preload.ts @@ -28,7 +28,7 @@ function finish(): void { ); else if (args[0] === 'marker') writeFileSync(args[1]!, 'executed'); else process.stdout.write(behavior.output ?? '{}'); - process.exit(0); + process.exitCode = 0; } if (args[0] === 'version' && behavior.releaseVersion) { const timer = setInterval(() => { diff --git a/test/fixtures/caddy-preparation-preload.ts b/test/fixtures/caddy-preparation-preload.ts index e14a0bd..ddb605b 100644 --- a/test/fixtures/caddy-preparation-preload.ts +++ b/test/fixtures/caddy-preparation-preload.ts @@ -141,10 +141,10 @@ void main().then( // Tree/block modes deliberately retain the event loop until the owned helper kills them. const behavior = args[0] === 'adapt' ? settings.adapt : settings.validate; if (args[0] !== 'descendant' && behavior?.mode !== 'tree' && behavior?.mode !== 'block') - process.exit(process.exitCode ?? 0); + process.exitCode ??= 0; }, (error: unknown) => { console.error(error); - process.exit(1); + process.exitCode = 1; }, ); diff --git a/test/installation-identity.test.ts b/test/installation-identity.test.ts index c58e8f3..1ec0ea3 100644 --- a/test/installation-identity.test.ts +++ b/test/installation-identity.test.ts @@ -225,6 +225,8 @@ it('preserves existing empty override cwd resolution without installation anchor it('keeps dev namespaces beneath each installation and validates dev ids', async () => { const root = await fixture(); + // Socket length has its own boundary cases below. + if (process.platform !== 'win32') vi.mocked(homedir).mockReturnValue('/h'); const a = join(root, 'A'); const b = join(root, 'B'); await mkdir(a); diff --git a/test/native-inspection.test.ts b/test/native-inspection.test.ts index 44b15f8..c787ddc 100644 --- a/test/native-inspection.test.ts +++ b/test/native-inspection.test.ts @@ -11,8 +11,8 @@ it('observes a real listener and refuses a stale PID without affecting its owner const address = server.address(); if (!address || typeof address === 'string') throw new Error('Missing listener.'); const owners = await inspectPort(address.port); - expect(owners.some((owner) => owner.protocol === 'tcp' && owner.pid === process.pid)).toBe( - true, + expect(owners, `Native TCP owner for port ${address.port}, PID ${process.pid}`).toContainEqual( + expect.objectContaining({ protocol: 'tcp', pid: process.pid }), ); await expect(terminatePortOwner(address.port, 2147483646)).rejects.toMatchObject({ code: 'target-conflict', diff --git a/test/runtime-descendants.test.ts b/test/runtime-descendants.test.ts index 8c62887..4a0af9c 100644 --- a/test/runtime-descendants.test.ts +++ b/test/runtime-descendants.test.ts @@ -22,7 +22,9 @@ beforeEach(async (context) => { vi.mocked(fs.lstat).mockReset().mockImplementation(nativeFs.lstat); owner = await security.runtimeOwner(process.getuid?.()); if (process.platform === 'win32' && owner.elevated) context.skip(); - fixture = await fs.mkdtemp(join(tmpdir(), 'cadder-descendant-')); + fixture = await fs.realpath( + await fs.mkdtemp(join(process.platform === 'darwin' ? '/tmp' : tmpdir(), 'cadder-descendant-')), + ); root = join(fixture, 'protected'); await security.prepareRuntime(root, owner); }); diff --git a/vitest.config.ts b/vitest.config.ts index a5db37b..ee5041f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,8 @@ export default defineConfig({ test: { include: ['test/**/*.test.{ts,tsx}'], testTimeout: 15000, + // Native Windows fixtures compile helpers and change process/file state. + fileParallelism: process.platform !== 'win32', coverage: { provider: 'v8', include: ['src/**/*.{ts,tsx}'], From 0180d68cbd05fb1327bcd1b1670391f143dd985d Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Tue, 20 Oct 2026 13:43:08 +0200 Subject: [PATCH 24/29] test: expose native macOS failure diagnostics Retain the OS cause when owned-group observation fails and report direct lsof probes when the native listener check cannot find its owner. --- src/platform/owned-command.ts | 4 +++- test/native-inspection.test.ts | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/platform/owned-command.ts b/src/platform/owned-command.ts index 4bb66b5..7056f9c 100644 --- a/src/platform/owned-command.ts +++ b/src/platform/owned-command.ts @@ -375,7 +375,9 @@ export async function runOwnedCommand( process.kill(-child.pid, 0); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ESRCH') break; - failure = new OwnedCommandError('cleanup', 'Cannot observe owned group settlement.'); + failure = new OwnedCommandError('cleanup', 'Cannot observe owned group settlement.', { + cause: error, + }); break; } if (performance.now() >= deadline) { diff --git a/test/native-inspection.test.ts b/test/native-inspection.test.ts index c787ddc..16f44f7 100644 --- a/test/native-inspection.test.ts +++ b/test/native-inspection.test.ts @@ -2,6 +2,8 @@ import { createServer } from 'node:net'; import { once } from 'node:events'; import { expect, it } from 'vitest'; import { inspectPort, terminatePortOwner } from '../src/platform/inspection.ts'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; it('observes a real listener and refuses a stale PID without affecting its owner', async () => { const server = createServer(); @@ -11,6 +13,25 @@ it('observes a real listener and refuses a stale PID without affecting its owner const address = server.address(); if (!address || typeof address === 'string') throw new Error('Missing listener.'); const owners = await inspectPort(address.port); + if (process.platform === 'darwin' && !owners.some((owner) => owner.pid === process.pid)) { + const native = []; + for (const args of [ + [`-iTCP:${address.port}`, '-sTCP:LISTEN', `-iUDP:${address.port}`], + [`-iTCP:${address.port}`, '-sTCP:LISTEN'], + [`-iUDP:${address.port}`], + ]) { + try { + native.push({ + args, + ...(await promisify(execFile)('/usr/sbin/lsof', ['-nP', ...args, '-FpcPn'])), + }); + } catch (error) { + const failure = error as { code?: unknown; stdout?: string; stderr?: string }; + native.push({ args, code: failure.code, stdout: failure.stdout, stderr: failure.stderr }); + } + } + throw new Error(JSON.stringify({ pid: process.pid, port: address.port, owners, native })); + } expect(owners, `Native TCP owner for port ${address.port}, PID ${process.pid}`).toContainEqual( expect.objectContaining({ protocol: 'tcp', pid: process.pid }), ); From 5432a48c460e51fe190219b416783f3a91939a04 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Tue, 20 Oct 2026 13:57:23 +0200 Subject: [PATCH 25/29] fix: preserve macOS socket owners and complete candidate assets Read valid lsof output when one requested protocol has no matches, and cover real TCP and UDP owners. Include shared setup assets in npm and SEA packages and verify actual Caddy rollback after a SQLite write refusal. --- docs/NODE-CANDIDATE.md | 50 +++++++++++++++++++ .../content/docs/reference/node-candidate.mdx | 8 ++- .../reset-cadder-architecture/verification.md | 25 ++++++++++ scripts/pack-node.ts | 10 ++-- scripts/pack-sea.ts | 13 +++-- scripts/source-identity.ts | 9 +++- scripts/verify-product-journey.ts | 17 +++++++ scripts/verify-sea.ts | 9 +++- src/platform/inspection.ts | 6 ++- test/native-inspection.test.ts | 31 ++++-------- vitest.config.ts | 4 +- 11 files changed, 145 insertions(+), 37 deletions(-) create mode 100644 docs/NODE-CANDIDATE.md diff --git a/docs/NODE-CANDIDATE.md b/docs/NODE-CANDIDATE.md new file mode 100644 index 0000000..8279c18 --- /dev/null +++ b/docs/NODE-CANDIDATE.md @@ -0,0 +1,50 @@ +# Cadder 2.0 release candidate + +This is an unpublished 2.0.0-rc.1 candidate. Use the supplied npm tarball or the +standalone archive for your platform. Both provide `cadder`, `cadderd`, and the +`caddy` shim. Upstream Caddy is installed separately. + +The npm package requires Node 24 LTS, version 24.18.0 or later in the 24.x series: + +```sh +npm install --global ./cadder-2.0.0-rc.1.tgz +``` + +Standalone archives need no installed Node or npm. Verify the extracted files +against `SHA256SUMS`, keep the three executables and accompanying files together, +and add their directory to PATH. `BUILD.json` identifies the source and platform. + +## Configure Caddy + +Set one trusted upstream source in a per-user `cadder.toml`: + +- Windows: `%APPDATA%\Cadder\Cadder\config\cadder.toml`. +- macOS: `~/Library/Application Support/dev.Cadder.Cadder/cadder.toml`. +- Linux: `$XDG_CONFIG_HOME/cadder/cadder.toml`, or `~/.config/cadder/cadder.toml` when unset. + +Use the supplied `cadder.toml.example` as a starting point. For example, if +`caddy-real` is the name of your separately installed native Caddy executable: + +```toml +[caddy] +real_command = "caddy-real" +``` + +Alternatively, set `real_path` to its absolute path. Set only one selector. + +## Use Cadder + +Run `caddy run` from each project containing a Caddyfile. In another terminal, +use `cadder status`, `cadder projects list`, or `cadder tui`. + +In the TUI, Up/Down select a project or domain, Enter/Space toggle it, `s` asks +to stop the daemon, `r` restarts it, and `q` quits the TUI. Enter starts an +offline daemon. Run `cadder --help` for the current command surface. + +## Switch versions + +Stop the old daemon using its own `cadder daemon stop` before changing PATH. +Install a matching set of all three commands, then re-run `caddy run` in each +project. The candidate keeps separate runtime storage and does not import or +delete the Rust database. Keep the previous release for rollback; stop the +candidate before switching back and registering the projects again. diff --git a/docs/site/src/content/docs/reference/node-candidate.mdx b/docs/site/src/content/docs/reference/node-candidate.mdx index e238d72..903d138 100644 --- a/docs/site/src/content/docs/reference/node-candidate.mdx +++ b/docs/site/src/content/docs/reference/node-candidate.mdx @@ -26,7 +26,13 @@ Standalone executables include their Node runtime and do not require Node or npm ## Run a project -[Configure real Caddy](../../user-guide/cadder-toml/), then run the existing Caddyfile from its project directory: +Create a per-user `cadder.toml` from the supplied `cadder.toml.example`: + +- Windows: `%APPDATA%\Cadder\Cadder\config\cadder.toml`. +- macOS: `~/Library/Application Support/dev.Cadder.Cadder/cadder.toml`. +- Linux: `$XDG_CONFIG_HOME/cadder/cadder.toml`, or `~/.config/cadder/cadder.toml` when unset. + +Set `real_path` to the absolute path of your separately installed Caddy, or `real_command` to its program name on PATH. Set one selector. Then run the existing Caddyfile from its project directory: ```sh caddy run diff --git a/openspec/changes/reset-cadder-architecture/verification.md b/openspec/changes/reset-cadder-architecture/verification.md index c702a11..26f60cd 100644 --- a/openspec/changes/reset-cadder-architecture/verification.md +++ b/openspec/changes/reset-cadder-architecture/verification.md @@ -865,3 +865,28 @@ The product Sandbox preparer now also emits a separate interactive privilege configuration and the `windows-product-gate.md` procedure. Its four PowerShell blocks parse and the generated Sandbox XML loads correctly. This is preparation only: no UAC/account pass is claimed. + +### Native follow-up + +The Linux x64 Node job passes at `86eb714` and again at `0180d68`, including +coverage, compiled runtime/privilege fixtures, the packed npm local/global +consumer, real-Caddy multi-project HTTP/HTTPS and extracted SEA lifecycle plus +the same real-Caddy journey. These runs precede the final candidate-asset and +post-apply storage-failure additions; they do not close the final revision gate. + +macOS diagnostics identify a concrete adapter defect: lsof can return exit 1 +with valid TCP output when its UDP selector has no match. The adapter now parses +that stdout while still rejecting stderr errors. The existing native test checks +real TCP and UDP ownership separately; temporary raw-command diagnostics are +removed. Earlier intermittent macOS process-group observation failures remain +failures; OS causes are retained for diagnosis and native fixture files run +serially rather than competing during helper startup. No product deadline or +ownership check was weakened. + +Both candidate packages now carry the same portable README and configuration +example; standalone inventory/checksums include these assets. The source digest +also covers them. Local npm local/global verification and Astro check/build pass +after these changes. The shared real-Caddy catalog additionally injects one +SQLite desired-state write refusal, verifies actual post-apply rollback, removes +its test-only trigger and checks that a later mutation works. It introduces no +production fault flag or exported private runtime data. diff --git a/scripts/pack-node.ts b/scripts/pack-node.ts index 233765e..9c1e329 100644 --- a/scripts/pack-node.ts +++ b/scripts/pack-node.ts @@ -79,17 +79,15 @@ export async function packNode(): Promise<{ stage: string; archive: string }> { cadderd: 'lib/entrypoints/cadderd.js', caddy: 'lib/entrypoints/caddy.js', }, - files: ['lib', 'LICENSE', 'README.md'], + files: ['lib', 'LICENSE', 'README.md', 'cadder.toml.example'], }, null, 2, ) + '\n', ); await copyFile(join(repositoryRoot, 'LICENSE'), join(stage, 'LICENSE')); - await writeFile( - join(stage, 'README.md'), - '# Cadder 2.0 release candidate\n\nCoordinate project Caddyfiles through one local daemon. Upstream Caddy is a separate prerequisite. This unpublished migration candidate requires Node 24 LTS >=24.18.0.\n\nRun `cadder --help` for commands, `cadder tui` for the routes workspace, or `caddy run` from a project directory. Stop the previous daemon before switching versions. Existing Rust runtime data is preserved; projects register again from their Caddyfiles.\n', - ); + await copyFile(join(repositoryRoot, 'docs/NODE-CANDIDATE.md'), join(stage, 'README.md')); + await copyFile(join(repositoryRoot, 'cadder.toml.example'), join(stage, 'cadder.toml.example')); for (const entry of ['cadder', 'cadderd', 'caddy']) await chmod(join(stage, 'lib/entrypoints', `${entry}.js`), 0o755); const packed = await execute( @@ -104,7 +102,7 @@ export async function packNode(): Promise<{ stage: string; archive: string }> { !result || result.files.some( ({ path }) => - !['package.json', 'README.md', 'LICENSE'].includes(path) && + !['package.json', 'README.md', 'LICENSE', 'cadder.toml.example'].includes(path) && (!path.startsWith('lib/') || !path.endsWith('.js')), ) ) diff --git a/scripts/pack-sea.ts b/scripts/pack-sea.ts index 957fbd1..5be1f82 100644 --- a/scripts/pack-sea.ts +++ b/scripts/pack-sea.ts @@ -76,6 +76,8 @@ for (const entry of ['cadder', 'cadderd', 'caddy']) { artifacts.push(output); } await copyFile(join(root, 'LICENSE'), join(payload, 'LICENSE')); +await copyFile(join(root, 'docs/NODE-CANDIDATE.md'), join(payload, 'README.md')); +await copyFile(join(root, 'cadder.toml.example'), join(payload, 'cadder.toml.example')); let nodeLicense: string | undefined; for (const candidate of ['LICENSE', '../LICENSE', '../share/doc/node/LICENSE']) { try { @@ -144,9 +146,14 @@ await writeFile( const checksums: string[] = []; for (const path of [ ...artifacts, - ...['LICENSE', 'NODE-LICENSE', 'THIRD-PARTY-NOTICES', 'BUILD.json'].map((name) => - join(payload, name), - ), + ...[ + 'LICENSE', + 'NODE-LICENSE', + 'THIRD-PARTY-NOTICES', + 'BUILD.json', + 'README.md', + 'cadder.toml.example', + ].map((name) => join(payload, name)), ]) checksums.push( `${createHash('sha256') diff --git a/scripts/source-identity.ts b/scripts/source-identity.ts index de5886c..7518fe9 100644 --- a/scripts/source-identity.ts +++ b/scripts/source-identity.ts @@ -14,7 +14,14 @@ export async function sourceIdentity(root: string) { if (entry.isFile() && (/\.tsx?$/.test(entry.name) || entry.name.endsWith('-LICENSE'))) files.push(join(entry.parentPath, entry.name)); files.push( - ...['package.json', 'nub.lock', 'tsconfig.json', 'LICENSE'].map((name) => join(root, name)), + ...[ + 'package.json', + 'nub.lock', + 'tsconfig.json', + 'LICENSE', + 'docs/NODE-CANDIDATE.md', + 'cadder.toml.example', + ].map((name) => join(root, name)), ); const hash = createHash('sha256'); for (const file of files.sort()) diff --git a/scripts/verify-product-journey.ts b/scripts/verify-product-journey.ts index 55113ee..615e57d 100644 --- a/scripts/verify-product-journey.ts +++ b/scripts/verify-product-journey.ts @@ -7,6 +7,7 @@ import { dirname, join, resolve } from 'node:path'; import { get } from 'node:http'; import { get as getTls } from 'node:https'; import { setTimeout as delay } from 'node:timers/promises'; +import { DatabaseSync } from 'node:sqlite'; // Run on an isolated native machine with free loopback ports 80 and 443. const [directory, upstream, node] = process.argv.slice(2); @@ -131,6 +132,21 @@ try { assert.notEqual((await request('beta.localhost')).body, 'beta'); await run(['projects', 'enable', beta.config]); assert.equal((await request('beta.localhost')).body, 'beta'); + const database = new DatabaseSync(join(environment.CADDER_RUNTIME_DIR!, 'v2/runtime.sqlite3'), { + timeout: 1000, + }); + try { + database.exec( + "CREATE TRIGGER gate_reject_desired BEFORE UPDATE ON desired BEGIN SELECT RAISE(FAIL, 'acceptance persistence refusal'); END", + ); + await assert.rejects(run(['projects', 'disable', beta.config])); + assert.equal((await request('beta.localhost')).body, 'beta'); + } finally { + database.exec('DROP TRIGGER IF EXISTS gate_reject_desired'); + database.close(); + } + await run(['projects', 'enable', beta.config]); + assert.equal((await request('beta.localhost')).body, 'beta'); const conflict = await project('conflict', 'alpha.localhost {\n respond "conflict"\n}\n'); await until(async () => assert.equal(conflict.shim.exitCode, 1)); assert.equal((await request('alpha.localhost')).body, 'alpha'); @@ -179,6 +195,7 @@ try { 'multi-project HTTP/verified HTTPS', 'domain/project toggles', 'conflict and malformed rejection with last-known-good', + 'post-apply SQLite failure restores last-known-good', 'heartbeat and crashed-session expiry', 'unauthorized admin denial', 'owned shutdown', diff --git a/scripts/verify-sea.ts b/scripts/verify-sea.ts index 24b3273..64985c3 100644 --- a/scripts/verify-sea.ts +++ b/scripts/verify-sea.ts @@ -48,7 +48,14 @@ try { const allowed = new Set( ['cadder', 'cadderd', 'caddy'] .map((name) => (process.platform === 'win32' ? `${name}.exe` : name)) - .concat(['LICENSE', 'NODE-LICENSE', 'THIRD-PARTY-NOTICES', 'BUILD.json']), + .concat([ + 'LICENSE', + 'NODE-LICENSE', + 'THIRD-PARTY-NOTICES', + 'BUILD.json', + 'README.md', + 'cadder.toml.example', + ]), ); assert.deepEqual((await readdir(stage)).sort(), [...allowed, 'SHA256SUMS'].sort()); const checksums = (await readFile(join(stage, 'SHA256SUMS'), 'utf8')).trim().split('\n'); diff --git a/src/platform/inspection.ts b/src/platform/inspection.ts index 972f0f0..7a5133f 100644 --- a/src/platform/inspection.ts +++ b/src/platform/inspection.ts @@ -71,8 +71,10 @@ export async function inspectPort(port: number): Promise { ) ).stdout; } catch (error) { - if ((error as { code?: unknown }).code === 1) return []; - throw error; + const result = error as { code?: unknown; stdout?: string; stderr?: string }; + // macOS lsof returns 1 when only one requested protocol has matches. + if (result.code !== 1 || result.stderr?.trim()) throw error; + output = result.stdout ?? ''; } const owners: PortOwner[] = []; let pid: number | null = null; diff --git a/test/native-inspection.test.ts b/test/native-inspection.test.ts index 16f44f7..91be5e3 100644 --- a/test/native-inspection.test.ts +++ b/test/native-inspection.test.ts @@ -2,44 +2,33 @@ import { createServer } from 'node:net'; import { once } from 'node:events'; import { expect, it } from 'vitest'; import { inspectPort, terminatePortOwner } from '../src/platform/inspection.ts'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; +import { createSocket } from 'node:dgram'; it('observes a real listener and refuses a stale PID without affecting its owner', async () => { const server = createServer(); + const datagram = createSocket('udp4'); + let datagramBound = false; server.listen(0, '127.0.0.1'); await once(server, 'listening'); try { const address = server.address(); if (!address || typeof address === 'string') throw new Error('Missing listener.'); const owners = await inspectPort(address.port); - if (process.platform === 'darwin' && !owners.some((owner) => owner.pid === process.pid)) { - const native = []; - for (const args of [ - [`-iTCP:${address.port}`, '-sTCP:LISTEN', `-iUDP:${address.port}`], - [`-iTCP:${address.port}`, '-sTCP:LISTEN'], - [`-iUDP:${address.port}`], - ]) { - try { - native.push({ - args, - ...(await promisify(execFile)('/usr/sbin/lsof', ['-nP', ...args, '-FpcPn'])), - }); - } catch (error) { - const failure = error as { code?: unknown; stdout?: string; stderr?: string }; - native.push({ args, code: failure.code, stdout: failure.stdout, stderr: failure.stderr }); - } - } - throw new Error(JSON.stringify({ pid: process.pid, port: address.port, owners, native })); - } expect(owners, `Native TCP owner for port ${address.port}, PID ${process.pid}`).toContainEqual( expect.objectContaining({ protocol: 'tcp', pid: process.pid }), ); + datagram.bind(0, '127.0.0.1'); + await once(datagram, 'listening'); + datagramBound = true; + expect(await inspectPort(datagram.address().port)).toContainEqual( + expect.objectContaining({ protocol: 'udp', pid: process.pid }), + ); await expect(terminatePortOwner(address.port, 2147483646)).rejects.toMatchObject({ code: 'target-conflict', }); expect(server.listening).toBe(true); } finally { + if (datagramBound) datagram.close(); await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ); diff --git a/vitest.config.ts b/vitest.config.ts index ee5041f..e7f021c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,8 +6,8 @@ export default defineConfig({ test: { include: ['test/**/*.test.{ts,tsx}'], testTimeout: 15000, - // Native Windows fixtures compile helpers and change process/file state. - fileParallelism: process.platform !== 'win32', + // Native fixtures execute owned helpers and change process/file state. + fileParallelism: false, coverage: { provider: 'v8', include: ['src/**/*.{ts,tsx}'], From e3be9206560903c564b9e81d32e4136a146e2af9 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Tue, 20 Oct 2026 14:16:48 +0200 Subject: [PATCH 26/29] test: verify one npm artifact and native terminal journeys --- .github/workflows/node-runtime.yml | 32 ++++++++++++++- scripts/terminal-smoke.ts | 40 +++++++++++++++++++ scripts/verify-node.ts | 38 ++++++++++++++---- scripts/verify-product-journey.ts | 64 ++++++++++++++++++++++++++++-- scripts/verify-sea.ts | 2 + test/caddy-owned-process.test.ts | 2 +- 6 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 scripts/terminal-smoke.ts diff --git a/.github/workflows/node-runtime.yml b/.github/workflows/node-runtime.yml index 6382840..e418637 100644 --- a/.github/workflows/node-runtime.yml +++ b/.github/workflows/node-runtime.yml @@ -13,8 +13,33 @@ permissions: contents: read jobs: + npm-artifact: + name: Pack the single npm candidate + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + filename: ${{ steps.pack.outputs.filename }} + steps: + - uses: actions/checkout@v6.0.3 + - uses: actions/setup-node@v7.0.0 + with: + node-version: 24.18.0 + - run: npm install --global @nubjs/nub@0.7.5 + - run: nub ci + - name: Pack once outside checkout + id: pack + run: | + node scripts/pack-node.ts > "$RUNNER_TEMP/cadder-npm.json" + node --input-type=module -e 'import fs from "node:fs"; import path from "node:path"; const report = JSON.parse(fs.readFileSync(path.join(process.env.RUNNER_TEMP, "cadder-npm.json"), "utf8")); fs.appendFileSync(process.env.GITHUB_OUTPUT, "archive=" + report.archive + "\nfilename=" + path.basename(report.archive) + "\n");' + - uses: actions/upload-artifact@v7.0.1 + with: + name: cadder-npm-candidate + path: ${{ steps.pack.outputs.archive }} + if-no-files-found: error + - run: git diff --exit-code runtime: name: Node runtime (${{ matrix.os }}) + needs: npm-artifact runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: @@ -61,8 +86,13 @@ jobs: - name: Allow the separate Linux fixture to bind web ports if: runner.os == 'Linux' run: sudo setcap cap_net_bind_service=+ep "${{ steps.upstream.outputs.path }}" + - name: Fetch the exact npm candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v7.0.0 + with: + name: cadder-npm-candidate + path: ${{ runner.temp }}/cadder-npm-candidate - name: Verify packed npm and the shared real-Caddy catalog on the Node minimum - run: nub run verify:node "${{ steps.upstream.outputs.path }}" + run: nub run verify:node "${{ steps.upstream.outputs.path }}" "${{ runner.temp }}/cadder-npm-candidate/${{ needs.npm-artifact.outputs.filename }}" - name: Select the independently pinned SEA builder uses: actions/setup-node@v7.0.0 with: diff --git a/scripts/terminal-smoke.ts b/scripts/terminal-smoke.ts new file mode 100644 index 0000000..aabbea7 --- /dev/null +++ b/scripts/terminal-smoke.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +/** Native Unix PTY; Windows acceptance uses the actual ConPTY terminal. */ +export async function terminalSmoke( + file: string, + prefix: string[], + environment: NodeJS.ProcessEnv, +): Promise { + if (process.platform === 'win32') return; + const execute = promisify(execFile); + for (const args of [[], ['tui']]) { + const command = [file, ...prefix, ...args]; + const quoted = command.map((part) => "'" + part.replaceAll("'", "'\\''") + "'").join(' '); + const running = execute( + '/usr/bin/script', + process.platform === 'darwin' + ? ['-q', '/dev/null', ...command] + : ['-qec', quoted, '/dev/null'], + { + env: { ...environment, CI: 'false', TERM: 'xterm-256color' }, + timeout: 20000, + maxBuffer: 1024 * 1024, + }, + ); + let frame = ''; + let quit = false; + running.child.stdout?.on('data', (bytes: Buffer) => { + frame = (frame + bytes.toString()).slice(-8192); + if (args.length && !quit && frame.includes('Press Enter')) { + quit = true; + running.child.stdin?.write('q'); + } + }); + const { stdout } = await running; + assert.match(stdout, args.length ? /Press Enter/ : /Usage: cadder/); + if (args.length) assert.ok(stdout.includes('\x1b[?25h'), 'TUI must restore cursor visibility.'); + } +} diff --git a/scripts/verify-node.ts b/scripts/verify-node.ts index 87bb8f1..fa0b824 100644 --- a/scripts/verify-node.ts +++ b/scripts/verify-node.ts @@ -3,12 +3,15 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { mkdtemp, rm, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { packNode, npmCli } from './pack-node.ts'; +import { join, resolve } from 'node:path'; +import { packNode, npmCli, repositoryRoot } from './pack-node.ts'; import { fileURLToPath } from 'node:url'; +import { terminalSmoke } from './terminal-smoke.ts'; +import { sourceIdentity } from './source-identity.ts'; +import { VERSION } from '../src/protocol/version.ts'; const execute = promisify(execFile); -const packed = await packNode(); +const archive = process.argv[3] ? resolve(process.argv[3]) : (await packNode()).archive; const consumer = await mkdtemp(join(tmpdir(), 'cadder-node-consumer-')); const environment = { ...process.env, CADDER_RUNTIME_DIR: join(consumer, 'runtime') }; let command: string | undefined; @@ -24,11 +27,24 @@ try { '--no-audit', '--no-fund', '--package-lock=false', - packed.archive, + archive, ], { timeout: 120000, maxBuffer: 1024 * 1024 }, ); command = join(consumer, 'node_modules/cadder/lib/entrypoints/cadder.js'); + const manifest = JSON.parse( + await readFile(join(consumer, 'node_modules/cadder/package.json'), 'utf8'), + ) as { + name: string; + version: string; + license: string; + cadderBuild: { revision: string; dirty: boolean }; + }; + assert.equal(manifest.name, 'cadder'); + assert.equal(manifest.version, VERSION); + assert.equal(manifest.license, 'Apache-2.0'); + assert.equal(manifest.cadderBuild.revision, (await sourceIdentity(repositoryRoot)).revision); + if (process.argv[3]) assert.equal(manifest.cadderBuild.dirty, false); if (process.argv[2]) await execute( process.execPath, @@ -47,13 +63,14 @@ try { timeout: 45000, maxBuffer: 1024 * 1024, }); - assert.match((await run(['--version'])).stdout, /^2\.0\.0-rc\.1\s*$/); + assert.equal((await run(['--version'])).stdout.trim(), VERSION); assert.match((await run([])).stderr, /Usage: cadder/); await assert.rejects( run(['status']), (error: unknown) => (error as { code?: unknown }).code === 3, ); const prefix = join(consumer, 'global'); + await terminalSmoke(process.execPath, [command], environment); await execute( process.execPath, [ @@ -65,7 +82,7 @@ try { '--ignore-scripts', '--no-audit', '--no-fund', - packed.archive, + archive, ], { timeout: 120000, maxBuffer: 1024 * 1024 }, ); @@ -82,7 +99,12 @@ try { { env: environment, windowsVerbatimArguments: true }, ) : await execute(wrapper, arguments_, { env: environment }); - assert.match(result.stdout, /2\.0\.0-rc\.1/); + assert.equal( + name === 'caddy' + ? (JSON.parse(result.stdout) as { version: string }).version + : result.stdout.trim(), + VERSION, + ); } } const globalPackage = join( @@ -110,7 +132,7 @@ try { ); process.stdout.write( JSON.stringify({ - archive: packed.archive, + archive, result: 'PASS', scope: 'compiled npm local lifecycle, local/global three-bin wrappers, actual SQLite worker and CLI, no TS loader', diff --git a/scripts/verify-product-journey.ts b/scripts/verify-product-journey.ts index 615e57d..1b8e13d 100644 --- a/scripts/verify-product-journey.ts +++ b/scripts/verify-product-journey.ts @@ -35,6 +35,11 @@ environment.PATH = node if (node && process.platform === 'win32') environment.PATH += ';' + join(process.env.SystemRoot ?? 'C:\\Windows', 'System32'); environment.CADDER_RUNTIME_DIR = join(root, 'runtime'); +environment.PATH = [ + node ? join(stage, '../.bin') : stage, + dirname(resolve(upstream)), + environment.PATH, +].join(process.platform === 'win32' ? ';' : ':'); const execute = promisify(execFile); const run = (args: string[]) => { const entry = command('cadder', args); @@ -111,12 +116,64 @@ async function project(name: string, body: string) { return { path, config, shim: child('caddy', ['run', '--config', config], path) }; } try { - const daemon = child('cadderd', ['--real-caddy', resolve(upstream)], root); + const delegated = async (file: string, args: string[], input = '') => { + const operation = execute(file, args, { env: environment, cwd: root, timeout: 45000 }); + operation.child.stdin?.end(input); + return operation.then( + ({ stdout, stderr }) => ({ code: 0, stdout, stderr }), + (error: { code: unknown; stdout: string; stderr: string }) => ({ + code: error.code, + stdout: error.stdout, + stderr: error.stderr, + }), + ); + }; + for (const args of [ + ['fmt', '-'], + ['fmt', '--invalid-cadder-acceptance-flag'], + ]) { + const entry = command('caddy', args); + const input = args[1] === '-' ? 'stream.localhost {\nrespond "retained input"\n}\n' : ''; + const original = await delegated(resolve(upstream), args, input); + assert.equal(original.code, args[1] === '-' ? 0 : 1); + if (args[1] === '-') assert.match(original.stdout, /respond "retained input"/); + assert.deepEqual(await delegated(entry.file, entry.args, input), original); + } + const inspection = command('caddy', ['version']); + const inspected = await delegated(inspection.file, inspection.args); + assert.equal(inspected.code, 0); + assert.match(inspected.stdout, /v2\.11\.4/); + assert.match(inspected.stderr, /real-Caddy inspection/); + if (process.platform === 'darwin') { + // Native macOS web ports require an elevated daemon; clients retain their user identity. + const entry = command('cadderd', [ + '--runtime-owner', + String(process.getuid!()), + '--runtime-dir', + environment.CADDER_RUNTIME_DIR!, + '--real-caddy', + resolve(upstream), + ]); + const elevated = spawn('/usr/bin/sudo', ['-n', entry.file, ...entry.args], { + env: environment, + stdio: ['ignore', 'ignore', 'pipe'], + }); + elevated.stderr.on('data', (bytes: Buffer) => { + daemonErrors = (daemonErrors + bytes.toString()).slice(-8192); + }); + children.push(elevated); + await until(async () => assert.match((await run(['status'])).stdout, /cadderd {2}running/)); + const discovery = JSON.parse( + await readFile(join(environment.CADDER_RUNTIME_DIR!, 'v2/cadder-ipc.json'), 'utf8'), + ) as { elevated: boolean; owner: string }; + assert.equal(discovery.elevated, true); + assert.equal(discovery.owner, `uid:${process.getuid!()}`); + } + const alpha = await project('alpha', 'alpha.localhost {\n respond "alpha"\n}\n'); await until(async () => { - assert.equal(daemon.exitCode, null); + assert.equal(alpha.shim.exitCode, null); assert.match((await run(['status'])).stdout, /cadderd {2}running/); }); - const alpha = await project('alpha', 'alpha.localhost {\n respond "alpha"\n}\n'); await until(async () => assert.equal((await request('alpha.localhost')).body, 'alpha')); assert.equal((await request('alpha.localhost', true)).body, 'alpha'); const beta = await project('beta', 'beta.localhost {\n respond "beta"\n}\n'); @@ -192,6 +249,7 @@ try { distribution: node ? 'npm' : 'sea', checks: [ 'actual daemon/shim/operator', + 'shim stdin/stdout/stderr/exit fidelity and non-recursive PATH', 'multi-project HTTP/verified HTTPS', 'domain/project toggles', 'conflict and malformed rejection with last-known-good', diff --git a/scripts/verify-sea.ts b/scripts/verify-sea.ts index 64985c3..2d48dd7 100644 --- a/scripts/verify-sea.ts +++ b/scripts/verify-sea.ts @@ -5,6 +5,7 @@ import { createHash } from 'node:crypto'; import { lstat, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import { terminalSmoke } from './terminal-smoke.ts'; const execute = promisify(execFile); let stage = resolve(process.argv[2] ?? ''); @@ -95,6 +96,7 @@ try { assert.match((await run(['logs', 'runtime', '--limit', '1'])).stdout, /Daemon started/); assert.match((await run(['daemon', 'restart'])).stdout, /running/); assert.match((await run(['daemon', 'stop'])).stdout, /stopped/); + await terminalSmoke(binary('cadder'), [], environment); process.stdout.write( JSON.stringify({ stage, diff --git a/test/caddy-owned-process.test.ts b/test/caddy-owned-process.test.ts index 944f854..fc81242 100644 --- a/test/caddy-owned-process.test.ts +++ b/test/caddy-owned-process.test.ts @@ -184,7 +184,7 @@ it('owned command retains stream boundary, cwd, stdin EOF and nonzero native exi ['-e', 'process.stdout.write(process.cwd())'], { cwd: tmpdir() }, ); - expect(normalize(cwd.stdout.toString()).toLowerCase()).toBe( + expect(normalize(await realpath(cwd.stdout.toString())).toLowerCase()).toBe( normalize(await realpath(tmpdir())).toLowerCase(), ); }); From 281d07db25c757ef34a8e9ce0282f5e9eec0d887 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Tue, 20 Oct 2026 14:26:09 +0200 Subject: [PATCH 27/29] fix: support cold Windows startup and explicit Unix owners --- .../reset-cadder-architecture/verification.md | 27 +++++++++++++++++++ scripts/verify-product-journey.ts | 25 +++++++++++------ src/client/service.ts | 3 ++- src/platform/runtime-security.ts | 12 ++++++++- test/client-service.test.ts | 1 + test/fixtures/runtime-privilege-smoke.ts | 25 ++++++++++------- 6 files changed, 74 insertions(+), 19 deletions(-) diff --git a/openspec/changes/reset-cadder-architecture/verification.md b/openspec/changes/reset-cadder-architecture/verification.md index 26f60cd..7f7cfd4 100644 --- a/openspec/changes/reset-cadder-architecture/verification.md +++ b/openspec/changes/reset-cadder-architecture/verification.md @@ -890,3 +890,30 @@ after these changes. The shared real-Caddy catalog additionally injects one SQLite desired-state write refusal, verifies actual post-apply rollback, removes its test-only trigger and checks that a later mutation works. It introduces no production fault flag or exported private runtime data. + +### Exact npm candidate and native product follow-up + +At `e3be920`, CI packs one npm tarball and distributes those exact bytes to +the four native jobs, verifying clean source revision and version before use. +The shared catalog now checks actual delegated stdin/stdout/stderr/exit codes, +shim-first startup and Unix PTY help/TUI cleanup. Native execution remains open: +Linux exposes asynchronous certificate issuance before the first verified HTTPS +request; macOS requires an elevated daemon for web ports and exposes root-owned +new runtime ancestors; Windows still has a short/long TEMP comparison fixture. +The fixture comparison uses physical paths on both sides. + +A fresh Windows Sandbox shim-first run fails at the original 30-second startup +budget. A separate actual-product probe records failure after 34.6 seconds and +successful foreground readiness after another 28.6 seconds. Client startup now +allows 60 seconds for cold native ACL preparation; unsafe permissions still +refuse authentication and failed readiness still stops only its own child. +The existing client regression now crosses the old 30-second boundary. + +Root startup now assigns only newly created runtime ancestors to the explicitly +selected Unix owner. The compiled native Linux privilege probe passes with a +previously absent runtime parent, ordinary-owner access and different-account +denial. macOS product probes retain ordinary clients, start the actual daemon +with explicit owner under sudo, read only the public Caddy CA for verified TLS, +and clean their own elevated temporary data. HTTPS probes wait for actual +certificate readiness. These corrections require a fresh full native matrix and +Windows product repeat; they do not close G7 or authorize Rust removal. diff --git a/scripts/verify-product-journey.ts b/scripts/verify-product-journey.ts index 1b8e13d..d1fb33c 100644 --- a/scripts/verify-product-journey.ts +++ b/scripts/verify-product-journey.ts @@ -81,11 +81,18 @@ async function until(check: () => Promise, timeout = 120000): Promise { - const ca = tls - ? await readFile( - join(environment.CADDER_RUNTIME_DIR!, 'v2/caddy/data/caddy/pki/authorities/local/root.crt'), - ) - : undefined; + const certificate = join( + environment.CADDER_RUNTIME_DIR!, + 'v2/caddy/data/caddy/pki/authorities/local/root.crt', + ); + let ca: Buffer | string | undefined; + if (tls) { + // The elevated Caddy owns its storage; export only the public CA certificate to this probe. + ca = + process.platform === 'darwin' + ? (await execute('/usr/bin/sudo', ['-n', '/bin/cat', certificate])).stdout + : await readFile(certificate); + } return new Promise((resolveResponse, reject) => { const request_ = (tls ? getTls : get)( { @@ -175,11 +182,11 @@ try { assert.match((await run(['status'])).stdout, /cadderd {2}running/); }); await until(async () => assert.equal((await request('alpha.localhost')).body, 'alpha')); - assert.equal((await request('alpha.localhost', true)).body, 'alpha'); + await until(async () => assert.equal((await request('alpha.localhost', true)).body, 'alpha')); const beta = await project('beta', 'beta.localhost {\n respond "beta"\n}\n'); await until(async () => assert.equal((await request('beta.localhost')).body, 'beta')); assert.equal((await request('alpha.localhost')).body, 'alpha'); - assert.equal((await request('beta.localhost', true)).body, 'beta'); + await until(async () => assert.equal((await request('beta.localhost', true)).body, 'beta')); assert.notEqual((await request('unknown.localhost')).body, 'alpha'); await run(['domains', 'disable', 'alpha.localhost']); assert.notEqual((await request('alpha.localhost')).body, 'alpha'); @@ -285,5 +292,7 @@ try { } finally { await run(['daemon', 'stop']).catch(() => {}); for (const process_ of children) if (process_.exitCode === null) process_.kill(); - await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); + if (process.platform === 'darwin') + await execute('/usr/bin/sudo', ['-n', '/bin/rm', '-rf', '--', root]); + else await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); } diff --git a/src/client/service.ts b/src/client/service.ts index cbca2d0..541514f 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -42,7 +42,8 @@ export class ClientService implements ClientServicePort { let ready = false; let permissionsFailure: unknown; try { - const deadline = Date.now() + 30000; + // Cold Windows ACL preparation can exceed thirty seconds in an isolated VM. + const deadline = Date.now() + 60000; while (Date.now() < deadline) { try { await this.snapshot(); diff --git a/src/platform/runtime-security.ts b/src/platform/runtime-security.ts index cde685c..c45c0ec 100644 --- a/src/platform/runtime-security.ts +++ b/src/platform/runtime-security.ts @@ -101,7 +101,17 @@ export async function prepareRuntime(directory: string, owner: RuntimeOwner): Pr } catch (error) { if (errorCode(error) !== 'ENOENT') throw error; try { - await mkdir(dirname(directory), { mode: 0o700, recursive: true }); + const parent = dirname(directory); + const created = await mkdir(parent, { mode: 0o700, recursive: true }); + if (created && process.getuid?.() === 0) { + // Only newly created ancestors may be assigned to the explicit runtime owner. + let path = created; + await protectCreated(path, owner, true); + for (const component of relative(created, parent).split(sep).filter(Boolean)) { + path = join(path, component); + await protectCreated(path, owner, true); + } + } await mkdir(directory, { mode: 0o700 }); await protectCreated(directory, owner, true); } catch (error) { diff --git a/test/client-service.test.ts b/test/client-service.test.ts index 398e99d..893466b 100644 --- a/test/client-service.test.ts +++ b/test/client-service.test.ts @@ -105,6 +105,7 @@ it('waits for startup ACL publication without accepting an unsafe connection or new CadderError('unsafe-runtime-permissions', 'ACL is not published yet'), ) .mockResolvedValueOnce(ready); + vi.spyOn(Date, 'now').mockReturnValue(31000).mockReturnValueOnce(0); await client.start(); expect(kill).not.toHaveBeenCalled(); vi.mocked(rpc) diff --git a/test/fixtures/runtime-privilege-smoke.ts b/test/fixtures/runtime-privilege-smoke.ts index 4e3892a..61ca8c8 100644 --- a/test/fixtures/runtime-privilege-smoke.ts +++ b/test/fixtures/runtime-privilege-smoke.ts @@ -5,7 +5,6 @@ import { chmod, copyFile, mkdtemp, readFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { rpc } from '../../src/client/connection.ts'; import { resolvePaths } from '../../src/daemon/paths.ts'; -import { prepareRuntime, runtimeOwner } from '../../src/platform/runtime-security.ts'; if (process.platform === 'win32' || !process.getuid || process.getuid() === 0) throw new Error('Run this Unix privilege gate as a non-root user with sudo -n available.'); @@ -17,7 +16,8 @@ if (!daemonEntry || !deniedEntry) // Publish only the executable and test bundle in a disposable shared directory. const inputs = await mkdtemp('/tmp/cadder-privilege-inputs-'); const root = await mkdtemp('/tmp/cadder-privilege-runtime-'); -const paths = resolvePaths({ runtimeDir: root }); +const runtimeDirectory = join(root, 'new-runtime'); +const paths = resolvePaths({ runtimeDir: runtimeDirectory }); const environment = { ...process.env }; delete environment.NODE_OPTIONS; let daemon: ChildProcessWithoutNullStreams | undefined; @@ -31,10 +31,13 @@ try { await chmod(outsiderEntry, 0o644); await chmod(inputs, 0o755); await chmod(root, 0o755); - await prepareRuntime(paths.directory, await runtimeOwner()); - daemon = spawn('sudo', ['-n', process.execPath, daemonEntry, root, String(process.getuid())], { - env: environment, - }); + daemon = spawn( + 'sudo', + ['-n', process.execPath, daemonEntry, runtimeDirectory, String(process.getuid())], + { + env: environment, + }, + ); let error = ''; daemon.stderr.on('data', (data: Buffer) => { error += data.toString(); @@ -61,9 +64,13 @@ try { }; assert.equal(discovery.elevated, true); assert.equal(discovery.owner, `uid:${process.getuid()}`); - const outsider = spawn('sudo', ['-n', '-u', 'nobody', outsiderNode, outsiderEntry, root], { - env: environment, - }); + const outsider = spawn( + 'sudo', + ['-n', '-u', 'nobody', outsiderNode, outsiderEntry, runtimeDirectory], + { + env: environment, + }, + ); let deniedOutput = ''; let deniedError = ''; outsider.stdout.on('data', (data: Buffer) => { From 78700fc9a75f5895112058ca920aecd53752c913 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Tue, 20 Oct 2026 14:30:14 +0200 Subject: [PATCH 28/29] test: exercise packaged TUI lifecycle in native terminals --- scripts/terminal-smoke.ts | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/scripts/terminal-smoke.ts b/scripts/terminal-smoke.ts index aabbea7..9a53626 100644 --- a/scripts/terminal-smoke.ts +++ b/scripts/terminal-smoke.ts @@ -10,7 +10,9 @@ export async function terminalSmoke( ): Promise { if (process.platform === 'win32') return; const execute = promisify(execFile); - for (const args of [[], ['tui']]) { + for (const scenario of ['help', 'lifecycle', 'explicit', 'interrupt']) { + const args = scenario === 'help' ? [] : ['tui']; + if (scenario === 'explicit') args.push('--start-daemon'); const command = [file, ...prefix, ...args]; const quoted = command.map((part) => "'" + part.replaceAll("'", "'\\''") + "'").join(' '); const running = execute( @@ -25,16 +27,31 @@ export async function terminalSmoke( }, ); let frame = ''; - let quit = false; + let step = scenario === 'explicit' ? 1 : 0; running.child.stdout?.on('data', (bytes: Buffer) => { frame = (frame + bytes.toString()).slice(-8192); - if (args.length && !quit && frame.includes('Press Enter')) { - quit = true; - running.child.stdin?.write('q'); + if (scenario === 'help') return; + let input: string | undefined; + if (step === 0 && frame.includes('Press Enter')) { + input = scenario === 'interrupt' ? '\x03' : '\r'; + } else if (step === 1 && frame.includes('No projects registered')) { + input = 's'; + } else if (step === 2 && frame.includes('Stop cadderd')) { + input = 'y'; + } else if (step === 3 && frame.includes('Press Enter')) { + input = 'q'; + } + if (input !== undefined) { + frame = ''; + step++; + running.child.stdin?.write(input); } }); const { stdout } = await running; assert.match(stdout, args.length ? /Press Enter/ : /Usage: cadder/); - if (args.length) assert.ok(stdout.includes('\x1b[?25h'), 'TUI must restore cursor visibility.'); + if (args.length) { + assert.equal(step, scenario === 'interrupt' ? 1 : 4); + assert.ok(stdout.includes('\x1b[?25h'), 'TUI must restore cursor visibility.'); + } } } From e0a391b866d0032bd7d834e1b232e4289338c786 Mon Sep 17 00:00:00 2001 From: MrMaxie Date: Tue, 20 Oct 2026 14:32:29 +0200 Subject: [PATCH 29/29] test: use a portable native PTY for Unix product checks --- scripts/terminal-smoke.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/terminal-smoke.ts b/scripts/terminal-smoke.ts index 9a53626..72db744 100644 --- a/scripts/terminal-smoke.ts +++ b/scripts/terminal-smoke.ts @@ -14,12 +14,14 @@ export async function terminalSmoke( const args = scenario === 'help' ? [] : ['tui']; if (scenario === 'explicit') args.push('--start-daemon'); const command = [file, ...prefix, ...args]; - const quoted = command.map((part) => "'" + part.replaceAll("'", "'\\''") + "'").join(' '); + // System Python provides the native PTY; the product inherits only its restricted PATH. const running = execute( - '/usr/bin/script', - process.platform === 'darwin' - ? ['-q', '/dev/null', ...command] - : ['-qec', quoted, '/dev/null'], + '/usr/bin/python3', + [ + '-c', + 'import os,pty,sys;sys.exit(os.waitstatus_to_exitcode(pty.spawn(sys.argv[1:])))', + ...command, + ], { env: { ...environment, CI: 'false', TERM: 'xterm-256color' }, timeout: 20000,