Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 19 additions & 35 deletions .cargo/audit.toml
Original file line number Diff line number Diff line change
@@ -1,49 +1,33 @@
# cargo-audit configuration
# https://github.com/rustsec/rustsec/blob/main/cargo-audit/README.md
# cargo-audit configuration.
#
# WHY: Mirrors `deny.toml` `[advisories].ignore` so that `cargo audit`
# (run with `--deny unmaintained --deny unsound --deny yanked`) and
# `cargo deny check advisories` agree on which RUSTSEC IDs are
# pre-acknowledged. Each suppression must cite the dep chain and the
# reason no safer alternative is available; revisit at the next MSRV
# bump or when upstream publishes a fix.
# WHY: This file is DERIVED from deny.toml [[advisories.ignore]].
# Do not edit the ignore list here; edit deny.toml and run
# `kanon audit derive-ignores --apply`. See standards/SUPPLY-CHAIN.md.

[advisories]
ignore = [
# WHY: rsa timing side-channel via jsonwebtoken/exousia — no safe
# upgrade, local-only use. Pre-existing harmonia ignore.
# rsa timing side-channel via jsonwebtoken/exousia — no safe upgrade, local-only use
"RUSTSEC-2023-0071",

# WHY: backoff unmaintained — transitive dep via librqbit.
# backoff unmaintained — transitive dep via librqbit
"RUSTSEC-2025-0012",

# WHY: bincode unmaintained — transitive dep via librqbit-peer-protocol.
# bincode unmaintained — transitive dep via librqbit-peer-protocol
"RUSTSEC-2025-0141",

# WHY: crypto-hash unmaintained — transitive dep via
# librqbit-sha1-wrapper.
# crypto-hash unmaintained — transitive dep via librqbit-sha1-wrapper
"RUSTSEC-2025-0060",

# WHY: instant unmaintained — transitive dep via backoff/librqbit,
# no maintained alternative on the librqbit chain.
# instant unmaintained — transitive dep via backoff/librqbit, no alternative
"RUSTSEC-2024-0384",

# WHY: paste unmaintained (dtolnay archived) — transitive dep via
# lofty/taxis.
# paste unmaintained (dtolnay archived) — transitive dep via lofty/taxis
"RUSTSEC-2024-0436",

# WHY: time RFC2822 stack exhaustion (RUSTSEC-2026-0009) fix
# requires Rust 1.88; harmonia CI is pinned to Rust 1.85 until MSRV
# policy changes.
# time RFC2822 stack exhaustion fix requires Rust 1.88; Harmonia CI is pinned to Rust 1.85 until MSRV policy changes
"RUSTSEC-2026-0009",

# WHY: audiopus_sys unmaintained via opus/akouo-core; no safe
# upgrade available.
# audiopus_sys unmaintained via opus/akouo-core; no safe upgrade available
"RUSTSEC-2026-0150",

# WHY: rand unsound with custom logger using `rand::rng()`
# (RUSTSEC-2026-0097) — surfaces via sqlx/axum/quinn/reqwest;
# harmonia does not install a custom logger that re-enters
# `rand::rng()`, so the unsound path is unreachable.
# rand unsound with custom logger using `rand::rng()` — surfaces via sqlx/axum/quinn/reqwest; harmonia installs no custom logger that re-enters rand::rng(), so the unsound path is unreachable
"RUSTSEC-2026-0097",
# quick-xml < 0.41 quadratic-attribute DoS. The direct indexer-XML parser (zetesis) is on 0.41; the remaining 0.37.5 is transitively pinned by feed-rs (RSS/podcast) and librqbit-upnp (LAN UPnP), neither of which yet targets 0.41. No safe upgrade until upstream bumps.
# review-by: 2026-10-01
"RUSTSEC-2026-0194",
# quick-xml < 0.41 NsReader namespace-allocation DoS. Same transitive pin as RUSTSEC-2026-0194: the direct zetesis parser is on 0.41; feed-rs and librqbit-upnp still require 0.37. No safe upgrade until upstream bumps.
# review-by: 2026-10-01
"RUSTSEC-2026-0195",
]
7 changes: 4 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ clap = { version = "4", features = ["derive"] }


# ── XML ───────────────────────────────────────────────────────────────────────
quick-xml = { version = "0.40", features = ["serialize"] }
quick-xml = { version = "0.41", features = ["serialize"] }

# ── Concurrency ──────────────────────────────────────────────────────────────
dashmap = "6"
Expand Down
136 changes: 134 additions & 2 deletions crates/archon/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::{Instrument, info};
use zetesis::SearchIndexerService;
use zetesis::cf_bypass::CloudflareProxy;
use zetesis::cf_bypass::byparr::ByparrProxy;
use zetesis::cf_bypass::noop::NoProxy;

use crate::cli::ServeArgs;
Expand Down Expand Up @@ -656,14 +658,18 @@ pub async fn run_serve(args: ServeArgs, out: &mut impl Write) -> Result<(), Host
let shutdown_token = CancellationToken::new();

// Layer 0: Zetesis (indexer protocol)
let cf_proxy = build_cf_proxy(&config.zetesis)?;
let zetesis = Arc::new(SearchIndexerService::new(
db.read.clone(),
db.write.clone(),
Arc::new(NoProxy),
cf_proxy,
config.zetesis.clone(),
event_tx.clone(),
));
info!("zetesis (indexer search) initialized");
info!(
cloudflare_bypass = config.zetesis.cloudflare_bypass_enabled,
"zetesis (indexer search) initialized"
);

// Layer 1: Ergasia (download execution)
let ergasia_session = Arc::new(
Expand Down Expand Up @@ -872,6 +878,36 @@ fn resolve_listen_addr(listen_addr: &str, port: u16) -> Result<std::net::SocketA
Ok(std::net::SocketAddr::new(ip, port))
}

/// Builds the Cloudflare-bypass proxy from config.
///
/// WHY: fail loud on `cloudflare_bypass_enabled` without a proxy URL —
/// silently falling back to `NoProxy` would turn every cf_bypass indexer
/// into a permanent `NoCfBypass` failure at search time.
fn build_cf_proxy(
config: &horismos::SearchSubsystemConfig,
) -> Result<Arc<dyn CloudflareProxy>, HostError> {
if !config.cloudflare_bypass_enabled {
return Ok(Arc::new(NoProxy));
}
let url = config
.cf_proxy_url
.as_deref()
.map(str::trim)
.filter(|url| !url.is_empty())
.ok_or_else(|| HostError::Config {
source: horismos::HorismosError::Validation {
message: "zetesis.cloudflare_bypass_enabled requires zetesis.cf_proxy_url"
.to_string(),
location: snafu::location!(),
},
location: snafu::location!(),
})?;
Ok(Arc::new(ByparrProxy::new(
url.to_string(),
std::time::Duration::from_secs(config.cf_proxy_timeout_seconds),
)))
}

fn validate_download_dir(config: &horismos::Config) -> Result<(), HostError> {
let dir = &config.ergasia.download_dir;
if !dir.exists() {
Expand Down Expand Up @@ -1061,6 +1097,102 @@ mod tests {
assert_eq!(result, Err("import pipeline not wired".to_string()));
}

// ── Cloudflare-bypass proxy wiring ──────────────────────────────────────

#[tokio::test]
async fn build_cf_proxy_disabled_returns_no_proxy() {
let config = horismos::SearchSubsystemConfig::default();
assert!(!config.cloudflare_bypass_enabled);

let proxy = build_cf_proxy(&config).expect("disabled bypass builds NoProxy");
let err = proxy
.get("https://example.com", CancellationToken::new())
.await
.expect_err("NoProxy always errors");
assert!(matches!(
err,
zetesis::SearchIndexerError::NoCfBypass { .. }
));
}

#[tokio::test]
async fn build_cf_proxy_enabled_without_url_errors() {
let config = horismos::SearchSubsystemConfig {
cloudflare_bypass_enabled: true,
cf_proxy_url: None,
..horismos::SearchSubsystemConfig::default()
};

let Err(err) = build_cf_proxy(&config) else {
panic!("enabled bypass without URL must fail loud");
};
assert!(matches!(err, HostError::Config { .. }));
assert!(err.to_string().contains("cf_proxy_url"));
}

#[tokio::test]
async fn build_cf_proxy_enabled_with_blank_url_errors() {
let config = horismos::SearchSubsystemConfig {
cloudflare_bypass_enabled: true,
cf_proxy_url: Some(" ".to_string()),
..horismos::SearchSubsystemConfig::default()
};

let Err(err) = build_cf_proxy(&config) else {
panic!("blank URL must fail loud");
};
assert!(err.to_string().contains("cf_proxy_url"));
}

#[tokio::test]
async fn build_cf_proxy_enabled_posts_to_byparr_endpoint() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};

// One-shot Byparr stub: answers a single POST /v1 with a solved page.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind stub listener");
let endpoint = format!("http://{}", listener.local_addr().expect("local addr"));
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept");
let mut buf = vec![0u8; 8192];
let n = stream.read(&mut buf).await.expect("read request head");
let request = String::from_utf8_lossy(&buf[..n]).into_owned();
let body = r#"{"status":"ok","message":"solved","solution":{"url":"https://example.com","status":200,"response":"<html>ok</html>","cookies":[],"userAgent":"UA"}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
stream
.write_all(response.as_bytes())
.await
.expect("write response");
request
});

let config = horismos::SearchSubsystemConfig {
cloudflare_bypass_enabled: true,
cf_proxy_url: Some(endpoint),
cf_proxy_timeout_seconds: 5,
..horismos::SearchSubsystemConfig::default()
};

let proxy = build_cf_proxy(&config).expect("enabled bypass builds ByparrProxy");
let response = proxy
.get("https://cf-protected.example.com", CancellationToken::new())
.await
.expect("Byparr stub answers");

assert_eq!(response.status, 200);
assert_eq!(response.body, "<html>ok</html>");
let request = server.await.expect("stub request captured");
assert!(
request.starts_with("POST /v1"),
"expected POST /v1, got: {}",
request.lines().next().unwrap_or_default()
);
}

#[tokio::test]
async fn subtitle_adapter_calls_live_prostheke_for_movie_path() {
let pool = SqlitePool::connect("sqlite::memory:")
Expand Down
37 changes: 37 additions & 0 deletions crates/horismos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ mod tests {
assert_eq!(config.aggelia.buffer_size, 1024);
assert_eq!(config.aggelia.download_queue_size, 512);
assert_eq!(config.zetesis.request_timeout_secs, 30);
assert_eq!(config.zetesis.max_response_body_bytes, 16 * 1024 * 1024);
assert_eq!(config.epignosis.cache_ttl_secs, 86400);
assert_eq!(config.kritike.scan_interval_hours, 24);
}
Expand Down Expand Up @@ -274,6 +275,42 @@ mod tests {
assert!(err.to_string().contains("port"));
}

// ── Zetesis limits validation ─────────────────────────────────────────────

#[test]
fn validation_rejects_zero_max_response_body_bytes() {
let mut config = config_with_jwt(valid_jwt_secret());
config.zetesis.max_response_body_bytes = 0;
let err = validate_config(&config).unwrap_err();
assert!(err.to_string().contains("max_response_body_bytes"));
}

#[test]
fn validation_rejects_cf_bypass_without_proxy_url() {
let mut config = config_with_jwt(valid_jwt_secret());
config.zetesis.cloudflare_bypass_enabled = true;
config.zetesis.cf_proxy_url = None;
let err = validate_config(&config).unwrap_err();
assert!(err.to_string().contains("cf_proxy_url"));
}

#[test]
fn validation_rejects_cf_bypass_with_blank_proxy_url() {
let mut config = config_with_jwt(valid_jwt_secret());
config.zetesis.cloudflare_bypass_enabled = true;
config.zetesis.cf_proxy_url = Some(" ".to_string());
let err = validate_config(&config).unwrap_err();
assert!(err.to_string().contains("cf_proxy_url"));
}

#[test]
fn validation_accepts_cf_bypass_with_proxy_url() {
let mut config = config_with_jwt(valid_jwt_secret());
config.zetesis.cloudflare_bypass_enabled = true;
config.zetesis.cf_proxy_url = Some("http://byparr:8191".to_string());
assert!(validate_config(&config).is_ok());
}

// ── Serialize/Deserialize roundtrip ───────────────────────────────────────

#[test]
Expand Down
6 changes: 6 additions & 0 deletions crates/horismos/src/subsystems.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ impl Default for AggeliaConfig {
pub struct SearchSubsystemConfig {
pub request_timeout_secs: u64,
pub max_results_per_indexer: usize,
/// Byte cap on any single indexer HTTP response body.
///
/// WHY: indexer responses are third-party data — an unbounded read lets a
/// hostile or broken indexer exhaust memory with one response.
pub max_response_body_bytes: u64,
pub cloudflare_bypass_enabled: bool,
pub max_concurrent_searches: usize,
pub per_indexer_rate_limit_requests: u32,
Expand All @@ -239,6 +244,7 @@ impl Default for SearchSubsystemConfig {
Self {
request_timeout_secs: 30,
max_results_per_indexer: 100,
max_response_body_bytes: 16 * 1024 * 1024,
cloudflare_bypass_enabled: false,
max_concurrent_searches: 10,
per_indexer_rate_limit_requests: 5,
Expand Down
20 changes: 20 additions & 0 deletions crates/horismos/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ fn validate_limits(config: &Config) -> Result<(), HorismosError> {
}
.fail();
}
if config.zetesis.max_response_body_bytes == 0 {
return ValidationSnafu {
message: "zetesis.max_response_body_bytes must be greater than 0".to_string(),
}
.fail();
}
if config.zetesis.cloudflare_bypass_enabled
&& config
.zetesis
.cf_proxy_url
.as_deref()
.is_none_or(|url| url.trim().is_empty())
{
return ValidationSnafu {
message:
"zetesis.cf_proxy_url must be set when zetesis.cloudflare_bypass_enabled is true"
.to_string(),
}
.fail();
}
Ok(())
}

Expand Down
6 changes: 5 additions & 1 deletion crates/zetesis/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ dashmap.workspace = true
tokio-util.workspace = true
futures.workspace = true
bytes.workspace = true
url.workspace = true
uuid.workspace = true
jiff.workspace = true
sqlx.workspace = true

[dev-dependencies]
rstest.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

[dev-dependencies.tokio]
workspace = true
features = ["rt-multi-thread", "macros", "test-util"]

[package.metadata.kanon]
maturity = "alpha"
Expand Down
Loading