diff --git a/.kanon-lint-ignore b/.kanon-lint-ignore index b44f28ad..1432afcd 100644 --- a/.kanon-lint-ignore +++ b/.kanon-lint-ignore @@ -99,7 +99,6 @@ RUST/as-cast:crates/syndesis/src/clock/** RUST/as-cast:crates/syndesis/src/protocol/** RUST/as-cast:crates/syndesis/src/client/** RUST/as-cast:crates/syndesis/src/server/** -RUST/as-cast:crates/archon/src/render/** RUST/as-cast:crates/paroche/src/subsonic/** RUST/as-cast:crates/apotheke/src/repo/** RUST/as-cast:crates/ergasia/src/** @@ -160,10 +159,6 @@ RUST/struct-too-many-fields:crates/** # now would force a cascade of inline-docstring writing for 1000+ public items. ARCHITECTURE/no-deny-missing-docs:crates/** -# WHY: no-migration-checksum fires on sqlx migration files whose hashes -# are embedded in the migrations table. Altering the files would corrupt -# every existing database; migrations are immutable by protocol. -STORAGE/no-migration-checksum:crates/**/migrations/** # WHY: the migration-checksum rule fires on a name heuristic — it flags every # source file in apotheke because the crate carries a `migrate` module. The # actual migration runner (apotheke::migrate) uses sqlx::migrate!() which @@ -210,7 +205,6 @@ RUST/allow-not-expect:crates/** # image in one workspace. Reducing requires architectural split across # multiple binaries, tracked separately. MANIFEST/dep-count:Cargo.lock -MANIFEST/dep-count:Cargo.toml MANIFEST/dep-count:crates/theatron/desktop/Cargo.lock # WHY: aggregate-status-without-detail applies to two health endpoints that @@ -409,6 +403,20 @@ RUST/validate-returns-unit:crates/aitesis/src/workflow.rs # tweak whose result every caller discards. Tracked in issue #696. RUST/validate-returns-unit:crates/eksetasis/src/client/cardigann/template.rs +# WHY: the retry-after tests assert on tokio::time::Instant under an +# explicitly paused clock (tokio::time::pause()) — the measured "elapsed" +# is virtual time, exact and deterministic, never wall latency. The rule's +# flake concern does not apply to paused-clock assertions; rewriting them +# to advance()+is_finished polling would obscure the clamp contract they +# document. +TESTING/wall-clock-assertion:crates/eksetasis/src/search/tests.rs + +# WHY: the hybrid-gate reusable workflow is called as @main BY FLEET DESIGN — +# forkwright/.github is operator-owned, so the supply-chain surface the +# unpinned-action rule guards is the fleet itself, and @main is the deliberate +# single-update channel that propagates gate fixes to every caller repo. +SHELL/unpinned-action:.github/workflows/gate-attestation.yml + # WHY: ergasia/pipeline.rs uses std::process::Command("df") to query available # disk space before archive extraction. This is a POSIX system utility call with # no injection surface (all arguments are typed Path/&str literals, not runtime diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30d32e46..8e74c3bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Harmonia uses the self-hosted kanon forge as the authoritative PR surface. GitHu ## Push target ``` -origin = http://kanon.lan/forkwright/harmonia.git (authoritative) +origin = http:///forkwright/harmonia.git (authoritative) github = git@github.com:forkwright/harmonia.git (mirror) ``` @@ -15,7 +15,7 @@ Push to `origin`. The forge post-receive hook runs CI (`.kanon-ci.toml`) and mir Two paths, same effect: -**Stoa UI.** Open `http://kanon.lan/prs/forkwright/harmonia`, click "New PR", pick base + head refs, review diff, submit. +**Stoa UI.** Open `http:///prs/forkwright/harmonia`, click "New PR", pick base + head refs, review diff, submit. **CLI.** diff --git a/crates/archon/src/mcp_bridge.rs b/crates/archon/src/mcp_bridge.rs index a70d24df..2d8a11ec 100644 --- a/crates/archon/src/mcp_bridge.rs +++ b/crates/archon/src/mcp_bridge.rs @@ -373,18 +373,19 @@ async fn handle_enqueue(arguments: &Value, ctx: &BridgeContext) -> Value { // only in eksetasis's in-memory cache; persisting it here, ahead of // enqueue, is what makes it a durable identifier rather than a // process-local key. + let metadata = match paroche::routes::download::ReleaseMetadata::new( + indexer_id, + title, + size_bytes, + download_url.clone(), + protocol.clone(), + info_hash.clone(), + ) { + Ok(metadata) => metadata, + Err(error) => return tool_error(format!("invalid release metadata: {error:?}")), + }; if let Err(error) = paroche::routes::download::persist_release_before_enqueue( - &ctx.db, - want_id, - release_id, - paroche::routes::download::ReleaseMetadata { - indexer_id, - title, - size_bytes, - download_url: download_url.clone(), - protocol: protocol.clone(), - info_hash: info_hash.clone(), - }, + &ctx.db, want_id, release_id, metadata, ) .await { @@ -398,6 +399,7 @@ async fn handle_enqueue(arguments: &Value, ctx: &BridgeContext) -> Value { paroche::routes::download::ReleasePersistError::Database(e) => { format!("failed to persist release {release_id}: {e}") } + other => format!("failed to persist release {release_id}: {other:?}"), }); } diff --git a/crates/eksetasis/src/rate_limit.rs b/crates/eksetasis/src/rate_limit.rs index d66c0448..580f2ac8 100644 --- a/crates/eksetasis/src/rate_limit.rs +++ b/crates/eksetasis/src/rate_limit.rs @@ -205,22 +205,20 @@ mod tests { assert!(limiter.acquire(2, &CancellationToken::new()).await); } - #[tokio::test] + #[tokio::test(start_paused = true)] async fn acquire_unblocks_on_cancellation_before_refill() { let limiter = RateLimiter::new(1, Duration::from_secs(600)); assert!(limiter.acquire(1, &CancellationToken::new()).await); let ct = CancellationToken::new(); ct.cancel(); - let start = Instant::now(); - let acquired = limiter.acquire(1, &ct).await; - let elapsed = start.elapsed(); - + // WHY: under a paused clock a wrongly-pending acquire burns the full + // virtual second and surfaces as Elapsed; a working cancellation + // returns without waiting out any timer. No wall-clock measurement. + let acquired = tokio::time::timeout(Duration::from_secs(1), limiter.acquire(1, &ct)) + .await + .expect("cancellation must not wait out a 600s refill"); assert!(!acquired, "expected cancellation, not acquisition"); - assert!( - elapsed < Duration::from_secs(1), - "expected prompt unblock on cancel, got {elapsed:?}" - ); } #[tokio::test] diff --git a/crates/horismos/.kanon-lint-ignore b/crates/horismos/.kanon-lint-ignore deleted file mode 100644 index 0c4e9e0b..00000000 --- a/crates/horismos/.kanon-lint-ignore +++ /dev/null @@ -1,5 +0,0 @@ -# Kanon lint suppressions for crates/horismos. -# -# WHY: OAuth2 protocol uses 'client_id' as the field name by spec; this is -# an external credential identifier, not an internal domain ID. -RUST/primitive-for-domain-id:src/subsystems.rs diff --git a/crates/komide/src/fetch.rs b/crates/komide/src/fetch.rs index 3c5889c7..3e092faf 100644 --- a/crates/komide/src/fetch.rs +++ b/crates/komide/src/fetch.rs @@ -455,9 +455,16 @@ mod tests { .build() .unwrap(); - let start = std::time::Instant::now(); - let result = fetch_feed(&client, &format!("http://{addr}"), None, None, CAP).await; - let elapsed = start.elapsed(); + // WHY: the outer timeout IS the bound — if the client-configured + // 100ms timeout fails to fire, this call hangs on the server's 5s + // stall and the wrapper returns Elapsed, failing the test without + // any wall-clock measurement. + let result = tokio::time::timeout( + std::time::Duration::from_secs(2), + fetch_feed(&client, &format!("http://{addr}"), None, None, CAP), + ) + .await + .expect("the client timeout must fire long before the server's 5s hold"); // WHY: FetchResult (the Ok payload) does not derive Debug, so // matching directly avoids requiring it just for this assertion. @@ -468,10 +475,6 @@ mod tests { matches!(err, KomideError::FeedFetch { .. }), "a stalled body must time out as a FeedFetch error, not hang forever: {err:?}" ); - assert!( - elapsed < std::time::Duration::from_secs(2), - "the client-configured timeout must bound the stall well under the server's 5s hold, got {elapsed:?}" - ); } #[tokio::test] diff --git a/crates/paroche/src/error.rs b/crates/paroche/src/error.rs index 2f25cf8f..987bf890 100644 --- a/crates/paroche/src/error.rs +++ b/crates/paroche/src/error.rs @@ -106,6 +106,12 @@ impl From for ParocheError { crate::routes::download::ReleasePersistError::Database(source) => { ParocheError::Database { source } } + crate::routes::download::ReleasePersistError::EmptyMetadata => { + ParocheError::Validation { + message: "release metadata requires a non-empty title and download URL" + .to_string(), + } + } } } } diff --git a/crates/paroche/src/routes/download.rs b/crates/paroche/src/routes/download.rs index 9be8b343..e1b7c9cf 100644 --- a/crates/paroche/src/routes/download.rs +++ b/crates/paroche/src/routes/download.rs @@ -187,16 +187,46 @@ pub struct ReleaseMetadata { pub info_hash: Option, } +impl ReleaseMetadata { + /// The one construction path for a releases row. `title` and + /// `download_url` must be non-empty — every call site arrives here with + /// an SSRF-validated URL and a resolved-or-placeholder title, and this + /// is where that contract is enforced rather than re-stated. + pub fn new( + indexer_id: i64, + title: String, + size_bytes: Option, + download_url: String, + protocol: String, + info_hash: Option, + ) -> Result { + if title.is_empty() || download_url.is_empty() { + return Err(ReleasePersistError::EmptyMetadata); + } + Ok(Self { + indexer_id, + title, + size_bytes, + download_url, + protocol, + info_hash, + }) + } +} + /// Outcomes of `persist_release_before_enqueue` other than success. Kept /// distinct from `ParocheError`/the MCP bridge's `tool_error` so this /// module stays decoupled from either surface's error-rendering convention /// — each caller maps these to its own shape. #[derive(Debug)] +#[non_exhaustive] pub enum ReleasePersistError { /// `want_id` has no row in `wants` — the caller must create it first. WantNotFound, /// `release_id` already exists but is recorded under a DIFFERENT want. ReleaseWantConflict, + /// Title or download URL arrived empty — the constructor's contract. + EmptyMetadata, Database(apotheke::DbError), } @@ -414,14 +444,14 @@ pub async fn enqueue_download( &state.db, want_id, release_id, - ReleaseMetadata { + ReleaseMetadata::new( indexer_id, title, size_bytes, - download_url: download_url.clone(), - protocol: protocol.clone(), - info_hash: info_hash.clone(), - }, + download_url.clone(), + protocol.clone(), + info_hash.clone(), + )?, ) .await?; diff --git a/docs/integrations.md b/docs/integrations.md index 7f382ec6..2e6fe434 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -28,13 +28,13 @@ KOSync is a simple HTTP protocol for syncing ebook reading position across devic 2. **Open KOReader settings** → **Syncing** → **KOSync** → **Enable**. 3. **Set custom server URL:** - - Server URL: `http://:` (e.g., `http://harmonia.lan:7654`) + - Server URL: `http://:` (e.g., `http://harmonia.example.com:7654`) - Username: (your username from step 4) - Password: (your password from step 4) 4. **Register user** (one time): ```bash - curl -X POST http://harmonia.lan:7654/kosync/users/create \ + curl -X POST http://harmonia.example.com:7654/kosync/users/create \ -H "Content-Type: application/json" \ -d '{"username": "reader1", "password": "yourpassword"}' ``` @@ -59,7 +59,7 @@ PASSWORD="mypassword" SHA1=$(echo -n "$PASSWORD" | sha1sum | cut -d' ' -f1) curl -H "x-auth-user: reader1" \ -H "x-auth-key: $SHA1" \ - http://harmonia.lan:7654/kosync/syncs/progress/5d41402abc4b2a76b9719d911017c592 + http://harmonia.example.com:7654/kosync/syncs/progress/5d41402abc4b2a76b9719d911017c592 ``` ### Data Model