diff --git a/Cargo.lock b/Cargo.lock index a90240d..f6fb961 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -946,6 +946,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "toml", "tracing", "tracing-subscriber", "walkdir", diff --git a/OPERATIONS.md b/OPERATIONS.md index 232f244..bebbc46 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -7,8 +7,9 @@ SPDX-FileCopyrightText: 2026 Mohamed Hammad Operational procedures for Loran maintainers. This document is the canonical reference for the **trust-pinned publisher key** lifecycle — -how to plan a normal rotation, run a parallel-key transition window, -and recover from an emergency compromise. +how the upstream catalog is built and signed (§2), how to plan a normal +rotation, run a parallel-key transition window, and recover from an +emergency compromise. It resolves **PRD Open Question 1** ("How do we rotate the publisher's signing key?") and operationalises **Spec §2 decision #6** (minisign + @@ -17,7 +18,7 @@ ed25519 over a trust-pinned key baked into the binary). ## Audience - Loran release engineers: every section. -- Spacecraft Software Operations: §3 (emergency rotation). +- Spacecraft Software Operations: §4 (emergency rotation). - Downstream operators (Bravais OS, Ferrite OS, …): §1.4 reading list so an in-the-wild compromise is recognisable from the user side. @@ -33,21 +34,21 @@ consulted by `signing::verify_any` during every `loran update`. For releases that ship a single active publisher key, the slice is length-one. For rotation windows, the slice contains **both** the outgoing and incoming keys — `verify_any` accepts a tarball whose -signature matches *any* of them (§2.2). +signature matches *any* of them (§3.2). ### 1.2 Why baked-in, not fetched A trust-pinned key that's fetched at runtime trades cryptographic guarantee for first-use trust. Loran refuses that trade: the only way to change the trust root is to ship a new Loran release. The -consequence — and the reason §2 and §3 exist — is that the rotation +consequence — and the reason §3 and §4 exist — is that the rotation procedure must be planned well before the trust root has to change. ### 1.3 Key inventory | Constant in `loran-core::pipeline` | Used for | Notes | |------------------------------------|--------------------|----------------------------------| -| `PUBLISHER_PUBLIC_KEY` | Upstream pages tarball | Placeholder; real key lands at Sub-phase 2D launch | +| `PUBLISHER_PUBLIC_KEY` | Upstream pages tarball | Placeholder; replace with the real key at first launch (§2.4) | When parallel keys are active the `default_publisher` constructor returns both via the `LORAN_PAGES_PUBLIC_KEY` env var (comma-separated) @@ -57,21 +58,90 @@ or by editing the embedded constant set to a `Vec` of base64 strings. - An unexpected `TARBALL_VERIFY_FAILED` (exit 11) from `loran update` is the first signal of a key mismatch. Compare the running Loran - version against the [release advisories](#5-release-advisories) — + version against the [release advisories](#6-release-advisories) — a parallel-key window means a stale Loran has been left running past the cut-over. - A signed Spacecraft Software release announcement is the only authoritative source for the active publisher key. Operators should not import keys obtained any other way. -## 2. Normal rotation procedure +## 2. Producing & publishing the pages tarball + +The upstream catalog is authored in the **`loran-pages`** content repo +(), not in this +repository. That repo's CI is the **producer** side of the +`loran update` mechanism; this section documents how it builds and signs +the artifacts that `update_pages` consumes. + +### 2.1 Artifacts and endpoint + +`scripts/build-pages.sh` in `loran-pages` produces three files under +`dist/`, and `publish.yml` uploads them to the rolling `pages-latest` +GitHub Release: + +| Asset | Role | +|-------|------| +| `pages.tar.gz` | gzip tarball; category dirs at the archive root, extracted verbatim into `$XDG_DATA_HOME/loran/pages/`. | +| `pages.json` | manifest — `{version, etag, sha256, size}`. | +| `pages.tar.gz.minisig` | detached minisign signature over `pages.tar.gz`. | + +They are served from +`https://github.com/Spacecraft-Software/loran-pages/releases/latest/download/` +— exactly the URLs baked into +`loran-core::pipeline::PUBLISHER_PAGES_{MANIFEST,TARBALL,SIG}_URL`. The +build is deterministic (sorted entries, pinned `mtime`, `gzip -n`), so +unchanged content yields identical bytes; the published asset's ETag +stays stable across rebuilds and keeps client `304`s cheap. + +### 2.2 Where the signing key lives + +The publisher **secret** key is held in the Spacecraft Software release +vault (§1.1) and mirrored into the `loran-pages` repository's Actions +secrets so `publish.yml` can sign unattended: + +- `MINISIGN_SECRET_KEY` — the minisign secret-key file contents. +- `MINISIGN_PASSWORD` — its password (may be empty). + +The matching **public** key is baked into the Loran binary as +`PUBLISHER_PUBLIC_KEY` (§1.1). The two are always a pair: changing +either is a key rotation (§3 / §4), never an isolated edit. + +### 2.3 Publish cadence + +`publish.yml` runs on every push to `loran-pages` `main` that touches +`pages/**` (and on manual dispatch). It validates (`loran validate +pages/`), packs, signs, and rolls the `pages-latest` release. Clients +pick the change up on their next `loran update` — manual, or the opt-in +auto-update once the cached catalog passes its staleness interval. + +### 2.4 First-launch checklist + +The pipeline currently ships a development **placeholder** key (§1.3, +§5). To go live: + +1. Generate the production keypair: + `nix shell nixpkgs#minisign -c minisign -G` +2. Store the secret key in the release vault; add `MINISIGN_SECRET_KEY` + and `MINISIGN_PASSWORD` to the `loran-pages` repo's Actions secrets. +3. Replace `PUBLISHER_PUBLIC_KEY` in `loran-core::pipeline` with the new + public key (and keep `signing::tests::TEST_PUBLIC_KEY` distinct — + §5). +4. Cut a Loran release carrying the real key, so installed clients trust + the publisher (the baked placeholder never validated a real tarball, + so there is no breakage for existing users). +5. Trigger `publish.yml` (push to `loran-pages`, or manual dispatch) to + cut the first signed `pages-latest` release. +6. Verify end-to-end against a clean `$XDG_DATA_HOME`: `loran update`, + then `loran show `. + +## 3. Normal rotation procedure Loran rotates the publisher key on a **planned annual cadence** as the default. The rotation is announced in advance, executed during a parallel-key transition window, and concluded with a release that drops the outgoing key. -### 2.1 Pre-rotation (T-30 days) +### 3.1 Pre-rotation (T-30 days) 1. Generate the new keypair with the upstream-blessed `minisign` binary (Nix-provided to avoid supply-chain drift): @@ -81,7 +151,7 @@ drops the outgoing key. 3. Open a tracking issue. Announce the planned cut-over date and the parallel-key window length (≥14 days recommended). -### 2.2 Parallel-key window (T-0 → T+14d) +### 3.2 Parallel-key window (T-0 → T+14d) 1. Cut a Loran release whose embedded `PUBLISHER_PUBLIC_KEY` array contains **both** the outgoing and incoming public keys. The @@ -94,7 +164,7 @@ drops the outgoing key. 4. Publish a release advisory naming the parallel-key window's start and the announced cut-over date. -### 2.3 Cut-over (T+14d) +### 3.3 Cut-over (T+14d) 1. Cut a follow-up Loran release whose embedded `PUBLISHER_PUBLIC_KEY` array contains **only** the incoming key. @@ -104,20 +174,20 @@ drops the outgoing key. `verify_any → SignError::Mismatch` from the cut-over release forward, which surfaces as exit code 11 (`TARBALL_VERIFY_FAILED`). -### 2.4 Post-rotation +### 3.4 Post-rotation 1. Destroy the outgoing secret key from the release vault. The advisory should reference the destruction timestamp. 2. Schedule the next rotation's tracking issue. -## 3. Emergency rotation (compromise) +## 4. Emergency rotation (compromise) Emergency rotation is used when the outgoing key is known or strongly suspected to be compromised. The procedure differs from a normal rotation in two ways: there is no parallel-key window, and the advisory is published before the new release ships. -### 3.1 Containment (T-0, hour 1) +### 4.1 Containment (T-0, hour 1) 1. Pause every signing pipeline. Do **not** sign any further tarball with the compromised key. @@ -126,9 +196,9 @@ the advisory is published before the new release ships. notice. 3. Open an incident channel; assign an incident commander. -### 3.2 Replacement (hours 2-24) +### 4.2 Replacement (hours 2-24) -1. Generate a new keypair (§2.1). +1. Generate a new keypair (§3.1). 2. Cut an emergency Loran release whose embedded `PUBLISHER_PUBLIC_KEY` array contains **only** the new key. The compromised key is omitted — every tarball it signed becomes @@ -137,7 +207,7 @@ the advisory is published before the new release ships. 4. Update the advisory with the new release version and the new public key fingerprint. -### 3.3 Recovery (days 2-7) +### 4.3 Recovery (days 2-7) 1. Operators upgrade Loran to the emergency release; this is the only safe way to obtain a tarball signed by the new key. @@ -146,7 +216,7 @@ the advisory is published before the new release ships. 3. Destroy the compromised secret key if it hasn't already been; verify backups don't retain it. -## 4. Test fixtures and key reuse +## 5. Test fixtures and key reuse - `crates/loran-core/src/signing.rs::tests::TEST_PUBLIC_KEY` is a test-only key with a documented purpose. **It must never be the @@ -154,11 +224,11 @@ the advisory is published before the new release ships. placeholder `PUBLISHER_PUBLIC_KEY` precisely so an operator who accidentally runs `loran update` against the placeholder URL gets a clean diagnostic rather than a surprising decode failure. -- When the real publisher pipeline launches (Sub-phase 2D), the - release engineer must swap `PUBLISHER_PUBLIC_KEY` to the real key - and update `signing::tests::TEST_PUBLIC_KEY` to remain distinct. +- At first launch (§2.4), the release engineer must swap + `PUBLISHER_PUBLIC_KEY` to the real key and update + `signing::tests::TEST_PUBLIC_KEY` to remain distinct. -## 5. Release advisories +## 6. Release advisories All publisher-key advisories — planned and emergency — are published to: @@ -178,7 +248,7 @@ Each advisory must include: - Recommended operator action (parallel-key window timeline, or upgrade-now-and-halt-updates). -## 6. References +## 7. References - Spec §2 decision #6 — trust-pinned key constraint. - Spec §11 — tldr-pages explicitly *not* covered by this procedure @@ -188,3 +258,7 @@ Each advisory must include: primitive. - `loran-core::pipeline::UpdateOpts::public_keys` — the runtime trust-pinned set consulted by `update_pages`. +- PRD NG-09 — the publisher pipeline this section realizes (originally + scoped as a separate, out-of-PRD project). +- `loran-pages` repo (`scripts/build-pages.sh`, `.github/workflows/{validate,publish}.yml`) + — the producer implementation described in §2. diff --git a/crates/loran-core/src/lib.rs b/crates/loran-core/src/lib.rs index 84ab465..5cece39 100644 --- a/crates/loran-core/src/lib.rs +++ b/crates/loran-core/src/lib.rs @@ -58,4 +58,4 @@ pub use search::{ScoredMatch, SearchResult, resolve_search}; pub use show::{BodyBlock, IntroBlock, ShowResult, resolve_show, resolve_show_with_tldr}; pub use signing::{SignError, verify as verify_minisign, verify_any as verify_minisign_any}; pub use tldr::{DEFAULT_PLATFORMS, NoTldr, TldrCache, TldrLookup}; -pub use xdg::{cache_home, data_home}; +pub use xdg::{cache_home, config_home, data_home}; diff --git a/crates/loran-core/src/pipeline.rs b/crates/loran-core/src/pipeline.rs index 219b4be..6b49827 100644 --- a/crates/loran-core/src/pipeline.rs +++ b/crates/loran-core/src/pipeline.rs @@ -51,30 +51,41 @@ pub const TLDR_PAGES_URL: &str = "https://tldr-pages.github.io/assets/tldr.zip"; /// Publisher manifest URL. /// -/// **Placeholder.** The real upstream CDN endpoint is selected when -/// the publisher pipeline (Sub-phase 2D) launches. Until then this URL -/// will fail a manifest fetch — that's by design, so a `loran update` -/// against an un-launched publisher fails loud rather than silently. +/// Served from the `Spacecraft-Software/loran-pages` content repo as a +/// GitHub Release asset on the moving `latest` release, so the URL +/// always resolves to the most recently published catalog (the same +/// `releases/latest/download/…` pattern tldr-pages clients use). The +/// 302 redirect to GitHub's asset CDN carries an `ETag`, which the +/// conditional-GET path in [`FetchClient::fetch_manifest`] relies on. +/// +/// Until the publisher pipeline cuts its first release this asset 404s +/// — by design, so a `loran update` against an un-launched publisher +/// fails loud rather than silently. pub const PUBLISHER_PAGES_MANIFEST_URL: &str = - "https://Loran.SpacecraftSoftware.org/pages/v1/pages.json"; + "https://github.com/Spacecraft-Software/loran-pages/releases/latest/download/pages.json"; -/// Publisher tarball URL. Placeholder; see [`PUBLISHER_PAGES_MANIFEST_URL`]. +/// Publisher tarball URL. See [`PUBLISHER_PAGES_MANIFEST_URL`]. pub const PUBLISHER_PAGES_TARBALL_URL: &str = - "https://Loran.SpacecraftSoftware.org/pages/v1/pages.tar.gz"; + "https://github.com/Spacecraft-Software/loran-pages/releases/latest/download/pages.tar.gz"; /// Publisher detached signature URL (minisign `.minisig`). -/// Placeholder; see [`PUBLISHER_PAGES_MANIFEST_URL`]. -pub const PUBLISHER_PAGES_SIG_URL: &str = - "https://Loran.SpacecraftSoftware.org/pages/v1/pages.tar.gz.minisig"; +/// See [`PUBLISHER_PAGES_MANIFEST_URL`]. +pub const PUBLISHER_PAGES_SIG_URL: &str = "https://github.com/Spacecraft-Software/loran-pages/releases/latest/download/pages.tar.gz.minisig"; /// Publisher's minisign public key. /// -/// **Placeholder — development key, NOT for production use.** Real -/// upstream releases replace this with the publisher's actual public -/// key as part of the Sub-phase 2D launch. Until then this constant -/// is the same test key as `signing::tests::TEST_PUBLIC_KEY`, embedded -/// here so the orchestration code compiles and unit tests can exercise -/// the verify step. +/// **Placeholder — development key, NOT for production use.** +/// +/// TODO(launch): replace with the real `loran-pages` publisher public +/// key before the first signed release. Generate the keypair with +/// `nix shell nixpkgs#minisign -c minisign -G`, paste the public half +/// here, and store the secret key + password as the `MINISIGN_SECRET_KEY` +/// / `MINISIGN_PASSWORD` secrets in the `loran-pages` repo (see +/// `OPERATIONS.md` → "Producing & publishing the pages tarball"). +/// Until then this constant is the same test key as +/// `signing::tests::TEST_PUBLIC_KEY`, embedded here so the +/// orchestration code compiles and unit tests can exercise the verify +/// step. /// /// Key rotation: a new Loran release ships a new value here. Older /// binaries fetching tarballs signed by a rotated key fail with diff --git a/crates/loran-core/src/xdg.rs b/crates/loran-core/src/xdg.rs index 1fd6d7e..a5f2cde 100644 --- a/crates/loran-core/src/xdg.rs +++ b/crates/loran-core/src/xdg.rs @@ -39,6 +39,17 @@ pub fn cache_home() -> Option { env_path("XDG_CACHE_HOME").or_else(dirs::cache_dir) } +/// Resolve the base config-home directory (where Loran reads its +/// `config.toml`). +/// +/// Order of precedence: +/// 1. `$XDG_CONFIG_HOME` (any platform, when non-empty). +/// 2. `dirs::config_dir()` (native convention per platform). +#[must_use] +pub fn config_home() -> Option { + env_path("XDG_CONFIG_HOME").or_else(dirs::config_dir) +} + fn env_path(key: &str) -> Option { std::env::var_os(key) .map(PathBuf::from) diff --git a/crates/loran/Cargo.toml b/crates/loran/Cargo.toml index 6fb8af2..96f8671 100644 --- a/crates/loran/Cargo.toml +++ b/crates/loran/Cargo.toml @@ -34,6 +34,7 @@ loran-render = { path = "../loran-render", version = "0.4.0" } loran-tui = { path = "../loran-tui", version = "0.4.0" } serde = { workspace = true } serde_json = { workspace = true } +toml = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } walkdir = { workspace = true } diff --git a/crates/loran/src/autoupdate.rs b/crates/loran/src/autoupdate.rs new file mode 100644 index 0000000..f318fa7 --- /dev/null +++ b/crates/loran/src/autoupdate.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Mohamed Hammad + +//! Opt-in catalog auto-update. +//! +//! When the user has enabled `[update] auto_update` (see +//! [`crate::config`]), the catalog read verbs call [`maybe_refresh`] +//! before building the index. If the cached upstream catalog is older +//! than the configured interval, Loran performs one best-effort +//! `loran update`-equivalent refresh of the upstream pages, reusing the +//! stored `ETag` so an unchanged catalog costs a single 304 round trip. +//! The freshly extracted tree is then picked up by +//! [`crate::index_loader`] for the in-flight command. +//! +//! The refresh is **never fatal**: any failure (offline, publisher +//! down, signature mismatch) is logged at `debug` and the command +//! proceeds against the cached/bundled catalog. `--offline` (or a +//! non-empty `LORAN_OFFLINE`) suppresses it entirely. + +use std::time::Duration; + +use loran_core::{ + FetchClient, SOURCE_UPSTREAM_PAGES, SourceMetaStore, UpdateOpts, UpdateOutcome, + default_pages_target, update_pages, +}; + +use crate::cli::Cli; +use crate::config::Config; + +/// Refresh the upstream catalog if auto-update is enabled and the cache +/// is stale. Silent on the happy path; best-effort and non-fatal. +pub(crate) fn maybe_refresh(cli: &Cli) { + if is_offline(cli) { + return; + } + let config = Config::load(); + if !config.update.auto_update { + return; + } + + let Ok(store) = SourceMetaStore::with_default_path() else { + return; + }; + if !is_stale(&store, config.update.interval) { + return; + } + let Some(target) = default_pages_target() else { + return; + }; + + let client = FetchClient::new(); + let opts = UpdateOpts::default_publisher(target); + match update_pages(&client, &store, &opts) { + Ok(UpdateOutcome::Updated { version, .. }) => { + if !cli.global.quiet { + eprintln!("loran: catalog auto-updated to {version}"); + } + } + // Already current (NotModified) — or a DryRun we never request + // here. Nothing to report. + Ok(_) => {} + Err(err) => { + // Background best-effort: stay quiet on stderr and leave a + // trace for `-v`. The command continues against the cached + // catalog. + tracing::debug!(error = %err, "catalog auto-update failed; using cached catalog"); + } + } +} + +/// Whether network access is suppressed for this invocation. +fn is_offline(cli: &Cli) -> bool { + cli.global.offline + || std::env::var("LORAN_OFFLINE") + .ok() + .is_some_and(|v| !v.is_empty() && v != "0") +} + +/// Stale when the upstream catalog has never been fetched, or its last +/// fetch is older than `interval`. +fn is_stale(store: &SourceMetaStore, interval: Duration) -> bool { + let Ok(file) = store.load() else { + // Can't read the meta file — don't trigger network churn on + // every command; treat as fresh and let an explicit + // `loran update` recover. + return false; + }; + let Some(fetched_at) = file + .sources + .get(SOURCE_UPSTREAM_PAGES) + .and_then(|m| m.fetched_at) + else { + // Never fetched: only the bundled core is in play, so a first + // refresh is worthwhile. + return true; + }; + let now = jiff::Timestamp::now(); + let age_secs = now.as_second().saturating_sub(fetched_at.as_second()); + let interval_secs = i64::try_from(interval.as_secs()).unwrap_or(i64::MAX); + age_secs >= interval_secs +} diff --git a/crates/loran/src/cli.rs b/crates/loran/src/cli.rs index f55c823..f5adf60 100644 --- a/crates/loran/src/cli.rs +++ b/crates/loran/src/cli.rs @@ -83,6 +83,12 @@ pub(crate) struct GlobalFlags { #[arg(long, global = true)] pub yes: bool, + /// Suppress all network access for this invocation: disables the + /// opt-in catalog auto-update. (`loran update` still errors loudly + /// rather than silently succeeding.) + #[arg(long, global = true)] + pub offline: bool, + /// Pin the active distro overlay layer by name (e.g. `bravais`, /// `ferrite`). Highest-precedence override — beats /// `LORAN_DISTRO_OVERRIDE` and `/etc/os-release`. Useful for @@ -284,7 +290,14 @@ pub(crate) struct UpdateArgs { } #[derive(Debug, Args)] -pub(crate) struct ValidateArgs {} +pub(crate) struct ValidateArgs { + /// Validate this directory as an upstream-strict pages tree (full + /// pages only) instead of the on-disk overlay roots under + /// `$XDG_DATA_HOME/loran/`. Intended for CI gating a pages repo, + /// e.g. `loran validate pages/`. + #[arg(value_name = "ROOT")] + pub root: Option, +} #[derive(Debug, Args)] pub(crate) struct SchemaArgs { diff --git a/crates/loran/src/cmd/validate.rs b/crates/loran/src/cmd/validate.rs index 31cbd28..29c44b9 100644 --- a/crates/loran/src/cmd/validate.rs +++ b/crates/loran/src/cmd/validate.rs @@ -57,27 +57,38 @@ struct ValidateData { errors: Vec, } -pub(crate) fn run(cli: &Cli, _args: &ValidateArgs) -> ExitCode { - let Some(data_dir) = loran_core::data_home() else { - emit_no_data_dir(cli); - return ExitCode::from(LoranExit::PermissionDenied.to_process_code()); +pub(crate) fn run(cli: &Cli, args: &ValidateArgs) -> ExitCode { + // Explicit `loran validate `: validate a single tree as + // upstream-strict (full pages only). Lets CI gate a pages repo + // without provisioning `$XDG_DATA_HOME`. Otherwise fall back to + // walking the on-disk overlay roots. + let roots: Vec<(&'static str, PathBuf, LayerKind)> = if let Some(root) = &args.root { + if !root.is_dir() { + emit_bad_root(cli, root); + return ExitCode::from(LoranExit::UsageError.to_process_code()); + } + vec![("upstream", root.clone(), LayerKind::Upstream)] + } else { + let Some(data_dir) = loran_core::data_home() else { + emit_no_data_dir(cli); + return ExitCode::from(LoranExit::PermissionDenied.to_process_code()); + }; + let loran_root = data_dir.join("loran"); + let distro = active_distro(); + vec![ + ("upstream", loran_root.join("pages"), LayerKind::Upstream), + ( + "distro", + loran_root.join("overlays").join(&distro), + LayerKind::Overlay, + ), + ( + "user", + loran_root.join("overlays").join("user"), + LayerKind::Overlay, + ), + ] }; - let loran_root = data_dir.join("loran"); - let distro = active_distro(); - - let roots: [(&'static str, PathBuf, LayerKind); 3] = [ - ("upstream", loran_root.join("pages"), LayerKind::Upstream), - ( - "distro", - loran_root.join("overlays").join(&distro), - LayerKind::Overlay, - ), - ( - "user", - loran_root.join("overlays").join("user"), - LayerKind::Overlay, - ), - ]; let mut data = ValidateData { valid: 0, @@ -334,3 +345,26 @@ fn emit_no_data_dir(cli: &Cli) { } } } + +fn emit_bad_root(cli: &Cli, root: &Path) { + let code = LoranExit::UsageError; + let msg = format!("validate root is not a directory: {}", root.display()); + let hint = "pass a directory of pages, e.g. `loran validate pages/`"; + match cli.output_format() { + Format::Json => { + let envelope = ErrorEnvelope::new( + code.name(), + code.numeric(), + &msg, + hint, + "loran validate", + None, + ); + let _ = JsonEmitter::stdio().emit_error(&envelope); + } + Format::Human => { + eprintln!("error: {msg}"); + eprintln!(" hint: {hint}"); + } + } +} diff --git a/crates/loran/src/config.rs b/crates/loran/src/config.rs new file mode 100644 index 0000000..aee2fb4 --- /dev/null +++ b/crates/loran/src/config.rs @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Mohamed Hammad + +//! User configuration loaded from `$XDG_CONFIG_HOME/loran/config.toml`. +//! +//! Only the `[update]` section exists today. Catalog auto-update is +//! **opt-in and off by default** — Loran never reaches the network on a +//! read verb unless the user explicitly enables it here (or via the +//! `LORAN_AUTO_UPDATE` env var), keeping the no-surprise-network posture +//! the workspace is built around. +//! +//! ```toml +//! [update] +//! auto_update = true +//! auto_update_interval = "30d" +//! ``` +//! +//! Environment overrides (highest precedence, useful for scripting and +//! hermetic tests): +//! +//! - `LORAN_AUTO_UPDATE` — `1`/`true`/`yes`/`on` enable, anything else +//! disables. +//! - `LORAN_AUTO_UPDATE_INTERVAL` — duration string (e.g. `7d`, `12h`). + +use std::time::Duration; + +use serde::Deserialize; + +/// Default auto-update interval when enabled without an explicit value. +/// +/// 30 days mirrors the tldr-pages client default cache age — long +/// enough that the network hit is rare, short enough that a daily-driven +/// catalog never drifts far. +const DEFAULT_AUTO_UPDATE_INTERVAL: Duration = Duration::from_secs(30 * 24 * 60 * 60); + +/// Resolved Loran configuration. +#[derive(Debug, Clone)] +pub(crate) struct Config { + pub update: UpdateConfig, +} + +/// Resolved `[update]` settings. +#[derive(Debug, Clone)] +pub(crate) struct UpdateConfig { + /// Whether read verbs may refresh a stale catalog over the network. + pub auto_update: bool, + /// How old the cached catalog must be before an auto-refresh fires. + pub interval: Duration, +} + +impl Default for UpdateConfig { + fn default() -> Self { + Self { + auto_update: false, + interval: DEFAULT_AUTO_UPDATE_INTERVAL, + } + } +} + +/// On-disk form. Every field optional so a partial file is valid. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawConfig { + #[serde(default)] + update: RawUpdate, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawUpdate { + auto_update: Option, + auto_update_interval: Option, +} + +impl Config { + /// Load `config.toml`, then apply environment overrides. + /// + /// Infallible by design: a missing file yields defaults, and a + /// malformed file is logged at `warn` and falls back to defaults + /// rather than bricking every read verb over a stray typo. + #[must_use] + pub(crate) fn load() -> Self { + let raw = read_raw_config(); + let env_auto = env_bool("LORAN_AUTO_UPDATE"); + let env_interval = std::env::var_os("LORAN_AUTO_UPDATE_INTERVAL") + .and_then(|v| v.to_str().and_then(parse_interval)); + Self::resolve(&raw, env_auto, env_interval) + } + + /// Pure resolution: file values overlaid by environment overrides. + /// Kept free of `std::env` reads so it is unit-testable without + /// touching process state. + fn resolve(raw: &RawConfig, env_auto: Option, env_interval: Option) -> Self { + let auto_update = env_auto.or(raw.update.auto_update).unwrap_or(false); + let interval = env_interval + .or_else(|| { + raw.update + .auto_update_interval + .as_deref() + .and_then(parse_interval) + }) + .unwrap_or(DEFAULT_AUTO_UPDATE_INTERVAL); + Self { + update: UpdateConfig { + auto_update, + interval, + }, + } + } +} + +/// Read and parse `$XDG_CONFIG_HOME/loran/config.toml`, tolerating +/// absence and malformed content (logged, not fatal). +fn read_raw_config() -> RawConfig { + let Some(dir) = loran_core::config_home() else { + return RawConfig::default(); + }; + let path = dir.join("loran").join("config.toml"); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return RawConfig::default(), + Err(err) => { + tracing::warn!(path = %path.display(), error = %err, "could not read config; using defaults"); + return RawConfig::default(); + } + }; + match toml::from_str(&text) { + Ok(raw) => raw, + Err(err) => { + tracing::warn!(path = %path.display(), error = %err, "config is not valid TOML; using defaults"); + RawConfig::default() + } + } +} + +/// Parse a truthy/falsey environment variable. Returns `None` when the +/// variable is unset so callers can fall back to the file value. +fn env_bool(key: &str) -> Option { + let raw = std::env::var(key).ok()?; + Some(matches!( + raw.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + )) +} + +/// Parse a small human duration: an integer with an optional unit +/// suffix (`s`, `m`, `h`, `d`, `w`). A bare integer is seconds. Returns +/// `None` for unparseable or zero-length input. +fn parse_interval(raw: &str) -> Option { + let s = raw.trim(); + if s.is_empty() { + return None; + } + let (digits, unit_secs) = match s.as_bytes().last()? { + b's' => (&s[..s.len() - 1], 1u64), + b'm' => (&s[..s.len() - 1], 60), + b'h' => (&s[..s.len() - 1], 60 * 60), + b'd' => (&s[..s.len() - 1], 24 * 60 * 60), + b'w' => (&s[..s.len() - 1], 7 * 24 * 60 * 60), + b'0'..=b'9' => (s, 1), + _ => return None, + }; + let value: u64 = digits.trim().parse().ok()?; + value.checked_mul(unit_secs).map(Duration::from_secs) +} + +#[cfg(test)] +mod tests { + use super::{Config, RawConfig, RawUpdate, parse_interval}; + use std::time::Duration; + + #[test] + fn parse_interval_handles_units_and_bare_seconds() { + assert_eq!(parse_interval("30d"), Some(Duration::from_secs(2_592_000))); + assert_eq!(parse_interval("12h"), Some(Duration::from_secs(43_200))); + assert_eq!(parse_interval("90"), Some(Duration::from_secs(90))); + assert_eq!(parse_interval("2w"), Some(Duration::from_secs(1_209_600))); + assert_eq!(parse_interval(""), None); + assert_eq!(parse_interval("nonsense"), None); + } + + #[test] + fn defaults_to_disabled_auto_update() { + let cfg = Config::resolve(&RawConfig::default(), None, None); + assert!(!cfg.update.auto_update); + assert_eq!(cfg.update.interval, Duration::from_secs(2_592_000)); + } + + #[test] + fn file_values_are_honoured() { + let raw = RawConfig { + update: RawUpdate { + auto_update: Some(true), + auto_update_interval: Some("7d".to_owned()), + }, + }; + let cfg = Config::resolve(&raw, None, None); + assert!(cfg.update.auto_update); + assert_eq!(cfg.update.interval, Duration::from_secs(604_800)); + } + + #[test] + fn env_overrides_win_over_file() { + let raw = RawConfig { + update: RawUpdate { + auto_update: Some(true), + auto_update_interval: Some("7d".to_owned()), + }, + }; + let cfg = Config::resolve(&raw, Some(false), Some(Duration::from_secs(60))); + assert!(!cfg.update.auto_update, "env disable overrides file enable"); + assert_eq!(cfg.update.interval, Duration::from_secs(60)); + } +} diff --git a/crates/loran/src/index_loader.rs b/crates/loran/src/index_loader.rs index a8ad07b..259cd69 100644 --- a/crates/loran/src/index_loader.rs +++ b/crates/loran/src/index_loader.rs @@ -4,17 +4,22 @@ //! Shared index builder for the read-side verbs (`show`, `list`, //! `find`, `search`, `categories`). //! -//! Combines three precedence layers per Spec §5.1: +//! Combines the following precedence layers per Spec §5.1 (lowest to +//! highest): //! //! 1. Bundled upstream pages — compiled into the binary via -//! [`BundledPagesIngestor`]. -//! 2. `$XDG_DATA_HOME/loran/overlays//` — distro overlay, +//! [`BundledPagesIngestor`]. The always-available offline core. +//! 2. `$XDG_DATA_HOME/loran/pages/` — the downloaded upstream catalog +//! written by `loran update`. Overrides bundled pages by name and +//! contributes any pages the offline core doesn't carry. Absent +//! until the first successful update. +//! 3. `$XDG_DATA_HOME/loran/overlays//` — distro overlay, //! resolved from `/etc/os-release` or the `LORAN_DISTRO_OVERRIDE` //! env var. -//! 3. `$XDG_DATA_HOME/loran/overlays/user/` — user overlay. +//! 4. `$XDG_DATA_HOME/loran/overlays/user/` — user overlay. //! -//! Either overlay root is skipped silently when its directory doesn't -//! exist (fresh install, no user overlay yet, …). +//! Every on-disk layer is skipped silently when its directory doesn't +//! exist (fresh install, no update yet, no user overlay yet, …). //! //! Optionally augmented at the lowest precedence by auto-synthesised //! Spacecraft Software-CLI pages from [`DescribeIngestor`] when the @@ -23,7 +28,8 @@ use loran_core::BundledPagesIngestor; use loran_index::{ - DescribeIngestor, Index, Ingestor, LayeredIngestor, OverlayLayer, detect_distro_id, + DescribeIngestor, Index, Ingestor, LayeredIngestor, MarkdownPagesIngestor, OverlayLayer, + detect_distro_id, }; /// Build the merged read-side index, optionally overriding the @@ -61,6 +67,25 @@ pub(crate) fn build_layered_index_with_overlay( for page in bundled { by_name.insert(page.name.clone(), page); } + + // The downloaded upstream catalog (`loran update` extracts it to + // `$XDG_DATA_HOME/loran/pages/`) overrides the compiled-in bundled + // pages by name and supplies any the offline core lacks. This is + // what closes the update→read loop: without it, `loran update` + // would refresh a tree nothing ever reads. Skipped until the first + // successful update creates the directory. + if let Some(data_dir) = loran_core::data_home() { + let pages_dir = data_dir.join("loran").join("pages"); + if pages_dir.is_dir() { + let downloaded = MarkdownPagesIngestor::new(&pages_dir) + .ingest() + .map_err(|e| format!("upstream pages ingest failed: {e}"))?; + for page in downloaded { + by_name.insert(page.name.clone(), page); + } + } + } + let merged_base: Vec = by_name.into_values().collect(); let layers = on_disk_overlay_layers(overlay_override); diff --git a/crates/loran/src/main.rs b/crates/loran/src/main.rs index 83aa204..2f9400d 100644 --- a/crates/loran/src/main.rs +++ b/crates/loran/src/main.rs @@ -13,9 +13,11 @@ //! in Sub-phases 1C–1D per `loran-plan-v0_1.md`. mod agent; +mod autoupdate; mod cli; mod cmd; mod color; +mod config; mod envelope; mod exit; mod index_loader; @@ -43,6 +45,11 @@ fn main() -> ExitCode { } if let Some(cmd) = cli.command.as_ref() { + // Opt-in: refresh a stale catalog before the catalog read verbs + // build their index, so they see the freshly downloaded pages. + if is_catalog_read_verb(cmd) { + autoupdate::maybe_refresh(&cli); + } match cmd { Command::Categories(args) => cmd::categories::run(&cli, args), Command::Describe(args) => cmd::describe::run(&cli, args), @@ -74,6 +81,22 @@ fn main() -> ExitCode { } } +/// Whether a sub-command reads the curated catalog index (and so +/// benefits from an opt-in pre-read auto-update). `update`, `new`, +/// `validate`, `describe`, `schema`, `help`, and the `mcp` server are +/// deliberately excluded — the maintenance verbs manage the catalog +/// themselves and `mcp` must stay free of surprise network I/O. +fn is_catalog_read_verb(cmd: &Command) -> bool { + matches!( + cmd, + Command::Categories(_) + | Command::Find(_) + | Command::List(_) + | Command::Search(_) + | Command::Show(_) + ) +} + /// Decide whether to launch the TUI for the no-subcommand path. /// /// Mirrors the SFRS §5 agent cascade: explicit JSON / agent env / pipe @@ -101,6 +124,8 @@ fn should_launch_tui(cli: &Cli) -> bool { } fn launch_tui(cli: &Cli) -> ExitCode { + // The interactive browser is a catalog read surface too. + autoupdate::maybe_refresh(cli); let index = match index_loader::build_layered_index_with_overlay(cli.global.overlay.as_deref()) { Ok(idx) => idx, diff --git a/crates/loran/tests/autoupdate.rs b/crates/loran/tests/autoupdate.rs new file mode 100644 index 0000000..665cd09 --- /dev/null +++ b/crates/loran/tests/autoupdate.rs @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Mohamed Hammad + +//! Opt-in catalog auto-update integration tests. +//! +//! Hermetic: every XDG path is a per-test tempdir, and the publisher +//! URLs point at `127.0.0.1:1` (RST on connect) so an enabled +//! auto-update attempt fails fast without real network egress. The +//! contract under test is that auto-update is opt-in and that a failed +//! refresh never breaks a read verb. + +use assert_cmd::Command; +use tempfile::tempdir; + +fn loran() -> Command { + Command::cargo_bin("loran").expect("loran binary built") +} + +/// Point the pages publisher at a dead port so any refresh attempt +/// fails immediately. +fn with_dead_publisher(cmd: &mut Command) { + cmd.env("LORAN_PAGES_MANIFEST_URL", "http://127.0.0.1:1/pages.json") + .env("LORAN_PAGES_TARBALL_URL", "http://127.0.0.1:1/pages.tar.gz") + .env( + "LORAN_PAGES_SIG_URL", + "http://127.0.0.1:1/pages.tar.gz.minisig", + ); +} + +#[test] +fn autoupdate_failure_is_non_fatal() { + let data_home = tempdir().unwrap(); + let cache_home = tempdir().unwrap(); + + let mut cmd = loran(); + cmd.args(["show", "eza"]) + .env("XDG_DATA_HOME", data_home.path()) + .env("XDG_CACHE_HOME", cache_home.path()) + .env("LORAN_DISTRO_OVERRIDE", "generic") + .env("LORAN_AUTO_UPDATE", "1") + .env("LORAN_AUTO_UPDATE_INTERVAL", "0s"); + with_dead_publisher(&mut cmd); + + // The auto-update attempt hits a dead port and fails silently; the + // command still resolves `eza` from the bundled catalog. + cmd.assert() + .success() + .stdout(predicates::str::contains("Modern ls replacement")); +} + +#[test] +fn offline_flag_suppresses_autoupdate() { + let data_home = tempdir().unwrap(); + let cache_home = tempdir().unwrap(); + + let mut cmd = loran(); + cmd.args(["show", "eza", "--offline"]) + .env("XDG_DATA_HOME", data_home.path()) + .env("XDG_CACHE_HOME", cache_home.path()) + .env("LORAN_DISTRO_OVERRIDE", "generic") + .env("LORAN_AUTO_UPDATE", "1") + .env("LORAN_AUTO_UPDATE_INTERVAL", "0s"); + with_dead_publisher(&mut cmd); + + cmd.assert() + .success() + .stdout(predicates::str::contains("Modern ls replacement")); +} + +#[test] +fn autoupdate_disabled_by_default() { + let data_home = tempdir().unwrap(); + let cache_home = tempdir().unwrap(); + + // No LORAN_AUTO_UPDATE: the read verb must not touch the network at + // all, so even a dead publisher is irrelevant. + let mut cmd = loran(); + cmd.args(["show", "eza"]) + .env("XDG_DATA_HOME", data_home.path()) + .env("XDG_CACHE_HOME", cache_home.path()) + .env("LORAN_DISTRO_OVERRIDE", "generic"); + with_dead_publisher(&mut cmd); + + cmd.assert() + .success() + .stdout(predicates::str::contains("Modern ls replacement")); +} diff --git a/crates/loran/tests/show.rs b/crates/loran/tests/show.rs index 35091cb..a54689e 100644 --- a/crates/loran/tests/show.rs +++ b/crates/loran/tests/show.rs @@ -189,3 +189,104 @@ fn show_miss_json_mode_emits_error_envelope_with_hint() { .expect("timestamp present"); assert!(ts.ends_with('Z'), "timestamp must end with Z: {ts}"); } + +/// Closed-loop proof: a tool present only in the downloaded upstream +/// tree (`$XDG_DATA_HOME/loran/pages/`, where `loran update` extracts +/// the verified tarball) must surface from `loran show`. Before the +/// `index_loader` fix this directory was written but never read. +#[test] +fn show_surfaces_downloaded_upstream_introduced_tool() { + let xdg = tempfile::TempDir::new().unwrap(); + let upstream = xdg + .path() + .join("loran") + .join("pages") + .join("data-processing"); + std::fs::create_dir_all(&upstream).unwrap(); + std::fs::write( + upstream.join("dasel.md"), + "+++\n\ + name = \"dasel\"\n\ + category = \"data-processing\"\n\ + summary = \"Query and modify data structures from the shell.\"\n\ + +++\n", + ) + .unwrap(); + + let assert = loran() + .args(["show", "dasel", "--json"]) + .env("XDG_DATA_HOME", xdg.path()) + .env("LORAN_DISTRO_OVERRIDE", "generic") + .assert() + .success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let envelope: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + envelope.pointer("/data/name").and_then(|v| v.as_str()), + Some("dasel"), + "downloaded upstream pages must surface in the read index" + ); +} + +/// Precedence chain per Spec §5.1: bundled < downloaded upstream < +/// user overlay. A downloaded page overrides the compiled-in bundled +/// one, and the user overlay overrides the downloaded one in turn. +#[test] +fn show_precedence_bundled_below_downloaded_below_user_overlay() { + let xdg = tempfile::TempDir::new().unwrap(); + let loran_root = xdg.path().join("loran"); + + // Downloaded upstream override of the bundled `eza` page. + let upstream = loran_root.join("pages").join("file-listing"); + std::fs::create_dir_all(&upstream).unwrap(); + std::fs::write( + upstream.join("eza.md"), + "+++\n\ + name = \"eza\"\n\ + category = \"file-listing\"\n\ + summary = \"Downloaded upstream summary.\"\n\ + +++\n", + ) + .unwrap(); + + // Downloaded upstream beats bundled. + let assert = loran() + .args(["show", "eza", "--json"]) + .env("XDG_DATA_HOME", xdg.path()) + .env("LORAN_DISTRO_OVERRIDE", "generic") + .assert() + .success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let envelope: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + envelope.pointer("/data/summary").and_then(|v| v.as_str()), + Some("Downloaded upstream summary."), + "downloaded upstream must override bundled" + ); + + // Now add a user overlay; it must win over the downloaded page. + let user_overlay = loran_root + .join("overlays") + .join("user") + .join("file-listing"); + std::fs::create_dir_all(&user_overlay).unwrap(); + std::fs::write( + user_overlay.join("eza.md"), + "+++\nname = \"eza\"\nsummary = \"User-pinned summary.\"\n+++\n", + ) + .unwrap(); + + let assert = loran() + .args(["show", "eza", "--json"]) + .env("XDG_DATA_HOME", xdg.path()) + .env("LORAN_DISTRO_OVERRIDE", "generic") + .assert() + .success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let envelope: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + envelope.pointer("/data/summary").and_then(|v| v.as_str()), + Some("User-pinned summary."), + "user overlay must override the downloaded upstream page" + ); +} diff --git a/crates/loran/tests/validate.rs b/crates/loran/tests/validate.rs index ef42893..e5b0820 100644 --- a/crates/loran/tests/validate.rs +++ b/crates/loran/tests/validate.rs @@ -205,3 +205,62 @@ fn validate_categorises_invalid_toml_with_line_number() { Some("INVALID_TOML") ); } + +// `loran validate ` — validate an arbitrary tree as +// upstream-strict, the CI gate for a pages repo. + +#[test] +fn validate_root_arg_succeeds_on_valid_tree() { + let dir = TempDir::new().unwrap(); + write(dir.path(), "file-listing/eza.md", good_page()); + + loran() + .args(["validate", dir.path().to_str().unwrap()]) + .assert() + .success() + .stdout(predicates::str::contains("1 pages OK")); +} + +#[test] +fn validate_root_arg_exits_8_on_broken_page() { + let dir = TempDir::new().unwrap(); + write(dir.path(), "broken.md", missing_summary()); + + loran() + .args(["validate", dir.path().to_str().unwrap()]) + .assert() + .failure() + .code(8); +} + +#[test] +fn validate_root_arg_is_upstream_strict_rejecting_partial_overlay() { + // A partial overlay (no category/summary) is accepted in the user + // overlay root but must be rejected when ROOT is validated + // upstream-strict. + let dir = TempDir::new().unwrap(); + write(dir.path(), "eza.md", "+++\nname = \"eza\"\n+++\n"); + + loran() + .args(["validate", dir.path().to_str().unwrap()]) + .assert() + .failure() + .code(8); +} + +#[test] +fn validate_root_arg_exits_2_on_nonexistent_dir() { + let dir = TempDir::new().unwrap(); + let missing = dir.path().join("does-not-exist"); + + let assert = loran() + .args(["validate", missing.to_str().unwrap()]) + .assert() + .failure() + .code(2); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!( + stderr.contains("not a directory"), + "stderr should explain the bad root:\n{stderr}" + ); +}