Skip to content

fix(zetesis,paroche,archon): credentialed enqueue-by-reference with a results cache#614

Merged
forkwright merged 1 commit into
mainfrom
fix/enqueue-by-reference
Jul 9, 2026
Merged

fix(zetesis,paroche,archon): credentialed enqueue-by-reference with a results cache#614
forkwright merged 1 commit into
mainfrom
fix/enqueue-by-reference

Conversation

@forkwright

Copy link
Copy Markdown
Owner

Closes #608. A private-tracker search hit whose download_url embeds credentials can now be enqueued through the API without the client ever seeing the credential.

Search results were ephemeral (dropped on response), so a redacted download_url had no server-side path back to the real URL. This adds an in-memory TTL results cache in zetesis (bounded, no new deps, credentials never on disk): each result gets a server-minted release_id; POST /api/v1/downloads accepts release_id with no download_url and resolves the unredacted URL server-side (SSRF-validated on the resolved URL too — by-reference is not an SSRF bypass). Raw download_url input still works for magnets/manual URLs. The previously-dead GET /api/v1/search/{query_id}/results route is wired (redacted).

Security invariant (compile-enforced + tested): the unredacted URL crosses the HTTP boundary only in requests — ResolvedRelease has no Serialize derive (a leak is a compile error), every response path stays redacted, and route tests assert the credential never appears in a response body. Gate green (full workspace); 3-lens adversarial security review clean (0 findings).

… results cache

Search redacts indexer credentials from download_url on every response
(correct — credentials must not reach clients), but there was no
server-side path to resolve the real URL at enqueue time, so a
credentialed Torznab/Newznab hit could not be submitted back to
POST /api/v1/downloads. Magnets were unaffected. Search results were
also fully ephemeral: SearchIndexerService::search minted a QueryId,
fanned out, deduped, and returned a bare Vec<SearchResult> — nothing
was stored, and SearchResult carried no stable server key. The
existing `releases` table was unusable for this (want_id NOT NULL FK,
zero production callers of insert_release).

The cache (crates/zetesis/src/results_cache.rs): a hand-rolled
ResultsCache(std::sync::Mutex<CacheInner>) — never locked across an
await, matching the crate's existing swap-behind-std-lock discipline.
CacheInner tracks insertion order (also age order), a QueryId -> deduped
results map, and a release Uuid -> (QueryId, index) map. search() mints
one themelion::ReleaseId per deduped result and inserts the whole query
under its QueryId; insert purges expired queries and evicts the oldest
beyond result_cache_max_queries (dropping the evicted query's release
entries with it). Lookups are lazy-TTL: a stale entry reads as absent
without being physically swept until the next insert. No new
dependencies, no DB — credentials never leave process memory or touch
disk.

New types (crates/zetesis/src/types.rs): CataloguedResult (a
SearchResult plus its release_id, Serialize, flattened on the wire) and
SearchOutcome (a QueryId plus its CataloguedResults) are what
SearchIndexerService::search now returns instead of a bare Vec —
additive JSON, no client breakage. ResolvedRelease (download_url,
protocol, info_hash, indexer_id) is deliberately NOT Serialize: a
compile error is the guard against ever placing a credentialed URL on
an HTTP response path. Its Debug redacts download_url; SearchOutcome
and CataloguedResult both Deref for ergonomic Vec/struct-like access at
existing call sites.

SearchIndexerService gained cached_results(query_id) and
resolve_release(release_id) — both idempotent and non-consuming, so a
retry after a failed enqueue still resolves.

The enqueue resolve branch (crates/paroche/src/routes/download.rs):
EnqueueRequest.download_url is now Option<String>. A present non-empty
value takes today's path verbatim (client-supplied protocol/info_hash);
an absent value resolves the release server-side via
state.search.resolve_release(release_id) and uses the RESOLVED
download_url, protocol, and info_hash (falling back to the body's
info_hash only when the resolved release carries none) — the resolved
protocol always wins over the body's default. A present-but-empty or
whitespace-only download_url still 422s, unchanged. A resolve miss maps
ServiceError::NotFound -> 404 via the existing error.rs conversion.
DynSearchService gained cached_results/resolve_release (paroche::state
carries its own decoupled ResolvedRelease mirror, same pattern as
EnqueueItem/DynQueueManager staying decoupled from syntaxis types);
archon's SearchAdapter implements both against the live zetesis
service. NullSearch and every test stub were updated to match.

Security invariant: an unredacted download_url crosses the paroche HTTP
boundary only in REQUESTS, never in responses or logs. The only
unredacted egress from the cache is resolve_release, consumed
exclusively inside enqueue_download, and its type cannot be
accidentally serialized. net_validate::validate_download_url runs on
the RESOLVED url exactly as it does on a client-supplied one — by-
reference enqueue must not become an SSRF bypass (covered by
enqueue_download_rejects_resolved_url_to_private_space). Every route
test that touches a credentialed URL asserts the response body never
contains the literal secret.

Also wired the dead GET /api/v1/search/{query_id}/results route
(previously always 404 via a hack in search_query_from_json, whose only
caller was this same dead route — deleted along with its now-obsolete
test): it parses the path UUID (InvalidId -> 400), calls
cached_results, 404s on a miss, and keeps the existing credential-
redaction walk over the response byte-for-byte, since cached results
carry unredacted URLs and this endpoint is member-visible.

horismos: SearchSubsystemConfig gained result_cache_ttl_seconds
(default 1800) and result_cache_max_queries (default 32), both LIVE —
read straight from the Section on every cache operation, no rebuild.
Classified in diff.rs's LIVE list and documented in
config-reload.md/configuration.md.

Tests: zetesis unit coverage for the cache (insert/resolve round-trip,
cached_results parity, TTL=0 immediate miss, cap eviction including
by_release cleanup, ResolvedRelease Debug redaction) plus an end-to-end
test through a mock credentialed Torznab indexer proving search() ->
resolve_release returns the exact credentialed URL and cached_results
agrees. paroche route tests cover the done-when scenario (admin
enqueues by release_id with no download_url, the queue receives the
credentialed URL, the HTTP response is redacted), resolve miss (404,
nothing enqueued), SSRF-on-resolved (private-space resolved URL is
422, nothing enqueued), resolved-protocol-wins, and the wired GET route
(redacted hit, 404 miss, 400 on a garbage id).

Closes #608

Gate-Passed: kanon 0.1.6 +stages:fmt,check,clippy,nextest,lint sha:79fd0df24cc47d8b883611d01dcd7a1f9cf00943
@forkwright forkwright merged commit 7f4141c into main Jul 9, 2026
12 checks passed
@forkwright forkwright deleted the fix/enqueue-by-reference branch July 9, 2026 02:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

search: credential redaction makes returned download_url un-enqueueable for credentialed indexers (no server-side resolve path)

1 participant