- frontend publishes on ${FRONTEND_PUBLISH_ADDR:-127.0.0.1} not 0.0.0.0 (M4).
- mem_limit on backend(4g)/postgres(1g)/frontend(512m), .env-overridable (M3).
- security_opt: no-new-privileges on app + postgres services.
New knobs documented + allowlisted in the config meta-test; compose validates.
Deferred (need host verification): image pinning, backend healthcheck, cap_drop
on postgres/tor, Postgres backup sidecar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1465 lines
64 KiB
Rust
1465 lines
64 KiB
Rust
use std::path::PathBuf;
|
||
use std::time::Duration;
|
||
|
||
use chrono::NaiveTime;
|
||
use chrono_tz::Tz;
|
||
|
||
use crate::crawler::browser::LaunchOptions;
|
||
use crate::crawler::safety::{DownloadAllowlist, DEFAULT_MAX_IMAGE_BYTES};
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct AuthConfig {
|
||
pub cookie_secure: bool,
|
||
pub cookie_domain: Option<String>,
|
||
pub session_ttl_days: i64,
|
||
pub rate_limit: crate::auth::rate_limit::RateLimitConfig,
|
||
/// When `false`, `POST /auth/register` returns 403
|
||
/// `registration_disabled` and the frontend hides its register
|
||
/// affordance. Admins can still mint accounts via
|
||
/// `POST /admin/users`. Defaults to `true` (open registration)
|
||
/// for backward compatibility.
|
||
pub allow_self_register: bool,
|
||
/// When `true`, every API path except a small allowlist
|
||
/// (`/health`, `/auth/config`, `/auth/login`, `/auth/logout`)
|
||
/// requires a valid session cookie or bearer token — anonymous
|
||
/// reads are rejected with 401. Self-registration is also
|
||
/// force-disabled regardless of [`Self::allow_self_register`]
|
||
/// so a private instance is locked down with a single switch.
|
||
/// Defaults to `false` (current public behaviour).
|
||
pub private_mode: bool,
|
||
/// Whether to trust a proxy-supplied `X-Forwarded-For` header as the
|
||
/// client IP for per-IP auth rate limiting. Enable ONLY when the backend
|
||
/// sits behind a trusted reverse proxy that overrides the header (the
|
||
/// compose deploy: SvelteKit's hooks.server.ts sets it from the real peer
|
||
/// address). When `false` (default), the header is ignored and the auth
|
||
/// limiter uses a single shared bucket — a directly-exposed backend must
|
||
/// keep this off or clients could spoof their IP to dodge the limit.
|
||
pub trusted_proxy: bool,
|
||
}
|
||
|
||
impl Default for AuthConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
cookie_secure: true,
|
||
cookie_domain: None,
|
||
session_ttl_days: 30,
|
||
// Disabled by default so the test harness inherits a
|
||
// non-throttling limiter. Production `from_env` overrides
|
||
// to the [`PRODUCTION_PER_SEC`]/[`PRODUCTION_BURST`]
|
||
// defaults.
|
||
rate_limit: crate::auth::rate_limit::RateLimitConfig::default(),
|
||
allow_self_register: true,
|
||
private_mode: false,
|
||
trusted_proxy: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct UploadConfig {
|
||
/// Total request size cap, enforced by axum's DefaultBodyLimit on the
|
||
/// upload routes. Rejected requests get a 413.
|
||
pub max_request_bytes: usize,
|
||
/// Per-image-part size cap, enforced after the part is read. Lets us
|
||
/// reject a single oversized cover/page without failing the whole
|
||
/// request just because the total happens to fit.
|
||
pub max_file_bytes: usize,
|
||
/// Max page images accepted in one chapter upload. Bounds how many
|
||
/// parts the handler will stage before giving up, so a client can't
|
||
/// pin a worker streaming an unbounded page count. `0` disables THIS
|
||
/// cap — the total is then bounded only by `max_request_bytes` (the
|
||
/// whole-request body limit), which stays the backstop either way.
|
||
/// Defaults to 2000. `MAX_PAGES_PER_CHAPTER`.
|
||
pub max_pages_per_chapter: usize,
|
||
}
|
||
|
||
impl Default for UploadConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
max_request_bytes: 200 * 1024 * 1024, // 200 MiB
|
||
max_file_bytes: 20 * 1024 * 1024, // 20 MiB
|
||
max_pages_per_chapter: 2000,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Postgres connection-pool sizing. One pool backs every HTTP handler plus
|
||
/// the crawler/analysis daemons, so it must be large enough not to starve
|
||
/// interactive reads and fail fast (rather than hang on the driver's silent
|
||
/// 30 s default) when genuinely saturated.
|
||
#[derive(Clone, Debug)]
|
||
pub struct DbConfig {
|
||
/// `DB_MAX_CONNECTIONS`. Upper bound on open connections.
|
||
pub max_connections: u32,
|
||
/// `DB_ACQUIRE_TIMEOUT_SECS`. How long a caller waits for a free
|
||
/// connection before erroring — short so overload surfaces as a fast
|
||
/// 500 instead of a 30 s hang.
|
||
pub acquire_timeout: Duration,
|
||
}
|
||
|
||
impl Default for DbConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
max_connections: 20,
|
||
acquire_timeout: Duration::from_secs(10),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl DbConfig {
|
||
pub fn from_env() -> Self {
|
||
let default = Self::default();
|
||
Self {
|
||
// `.max(1)`: a zero-size pool can never hand out a connection and
|
||
// would deadlock every query — clamp to at least one.
|
||
max_connections: env_u64("DB_MAX_CONNECTIONS", default.max_connections.into())
|
||
.max(1) as u32,
|
||
acquire_timeout: Duration::from_secs(env_u64(
|
||
"DB_ACQUIRE_TIMEOUT_SECS",
|
||
default.acquire_timeout.as_secs(),
|
||
)),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// How the worker asks the model to constrain its output. OpenAI-compatible
|
||
/// servers disagree here: LM Studio accepts only `json_schema` or `text`
|
||
/// (NOT `json_object`); vanilla OpenAI/vLLM accept `json_object` too. The
|
||
/// default `JsonSchema` is the most portable and the most reliable — it
|
||
/// also keeps "thinking" models (e.g. Gemma) from emitting an empty
|
||
/// `content` with the answer buried in `reasoning_content`.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum ResponseFormat {
|
||
JsonSchema,
|
||
JsonObject,
|
||
/// Send no `response_format` — rely on the prompt + the parser's
|
||
/// fence/brace extraction. Needed for servers that reject the field.
|
||
None,
|
||
}
|
||
|
||
impl ResponseFormat {
|
||
fn from_str(s: &str) -> ResponseFormat {
|
||
match s.trim().to_lowercase().as_str() {
|
||
"json_object" => ResponseFormat::JsonObject,
|
||
"none" | "text" | "off" | "" => ResponseFormat::None,
|
||
// Default (incl. "json_schema" and anything unrecognized).
|
||
_ => ResponseFormat::JsonSchema,
|
||
}
|
||
}
|
||
|
||
/// Canonical wire string, the inverse of [`Self::from_str`] for the
|
||
/// three modes the API exposes.
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
ResponseFormat::JsonSchema => "json_schema",
|
||
ResponseFormat::JsonObject => "json_object",
|
||
ResponseFormat::None => "none",
|
||
}
|
||
}
|
||
|
||
/// Parse a wire value, rejecting unknown modes (stricter than
|
||
/// [`Self::from_str`], which the env loader uses to stay lenient).
|
||
pub fn parse_strict(s: &str) -> Option<ResponseFormat> {
|
||
match s.trim().to_lowercase().as_str() {
|
||
"json_schema" => Some(ResponseFormat::JsonSchema),
|
||
"json_object" => Some(ResponseFormat::JsonObject),
|
||
"none" => Some(ResponseFormat::None),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Which engine the analysis worker dispatches each page through. A
|
||
/// deploy-time choice (the engine is either installed or not), so it lives in
|
||
/// env only and is not part of the admin-editable `AnalysisSettings`.
|
||
///
|
||
/// * `Ocr` — in-process [`crate::analysis::ocr`] (the `ocrs` engine): fast,
|
||
/// CPU-only, English text. Writes OCR text only (no tags/scene/safety).
|
||
/// * `Vision` — the local OpenAI-compatible LLM in [`crate::analysis::vision`]:
|
||
/// full OCR + tags + scene + safety, but heavy.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum AnalysisBackend {
|
||
Ocr,
|
||
Vision,
|
||
}
|
||
|
||
impl AnalysisBackend {
|
||
/// Lenient env parse. Defaults to `Ocr` (the Pi-friendly path) for any
|
||
/// unset or unrecognized value; `vision` opts back into the LLM engine.
|
||
fn from_str(s: &str) -> AnalysisBackend {
|
||
match s.trim().to_lowercase().as_str() {
|
||
"vision" | "llm" => AnalysisBackend::Vision,
|
||
// Default (incl. "ocr", "ocrs", and anything unrecognized).
|
||
_ => AnalysisBackend::Ocr,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// AI content-analysis worker configuration: the enable gate, the local
|
||
/// OpenAI-compatible vision endpoint, and the worker / request knobs.
|
||
#[derive(Clone, Debug)]
|
||
pub struct AnalysisConfig {
|
||
/// Master switch (`ANALYSIS_ENABLED`). When `false`, no analysis jobs
|
||
/// are enqueued and no worker runs. Defaults to `false`.
|
||
pub enabled: bool,
|
||
/// Which engine the worker dispatches through (`ANALYSIS_BACKEND`):
|
||
/// `ocr` (default, the in-process `ocrs` engine) or `vision` (the local
|
||
/// LLM). Deploy-time, env-only — see [`AnalysisBackend`].
|
||
pub backend: AnalysisBackend,
|
||
/// Path to the ocrs text-*detection* `.rten` model
|
||
/// (`OCRS_DETECTION_MODEL`). Only read when `backend == Ocr`.
|
||
pub ocr_detection_model: String,
|
||
/// Path to the ocrs text-*recognition* `.rten` model
|
||
/// (`OCRS_RECOGNITION_MODEL`). Only read when `backend == Ocr`.
|
||
pub ocr_recognition_model: String,
|
||
/// Number of concurrent analysis workers (`ANALYSIS_WORKERS`).
|
||
pub workers: usize,
|
||
/// OpenAI-compatible chat/completions URL (`ANALYSIS_VISION_URL`).
|
||
pub endpoint: String,
|
||
/// Optional vision readiness probe URL (`ANALYSIS_VISION_HEALTH_URL`),
|
||
/// e.g. `http://mangalord-vision:8000/health`. When set, the worker
|
||
/// refuses to lease a job until this answers 2xx — so an autoscaler that
|
||
/// idle-stops the vision container never lets a job burn its retries or
|
||
/// land a `failed` row. `None` (the default) disables the gate, matching
|
||
/// the prior behavior for always-on endpoints. Env-only, like `api_key`.
|
||
pub vision_health_url: Option<String>,
|
||
/// Model id to request (`ANALYSIS_VISION_MODEL`).
|
||
pub model: String,
|
||
/// Optional bearer token (`ANALYSIS_API_KEY`); local servers usually
|
||
/// don't need one.
|
||
pub api_key: Option<String>,
|
||
/// Per-request HTTP timeout (`ANALYSIS_REQUEST_TIMEOUT_SECS`).
|
||
pub request_timeout: Duration,
|
||
/// Whole-job timeout in the worker (`ANALYSIS_JOB_TIMEOUT_SECS`).
|
||
pub job_timeout: Duration,
|
||
/// Output token cap sent as `max_tokens` (`ANALYSIS_MAX_TOKENS`).
|
||
/// Must be generous enough to hold the full JSON for a text-dense page
|
||
/// — too low truncates the response mid-object (`finish_reason:
|
||
/// length`) and the parse fails. The page image + prompt are only a few
|
||
/// hundred tokens, so a large output budget still fits an 8k window.
|
||
pub max_tokens: u32,
|
||
/// Per-slice / per-image pixel budget (`ANALYSIS_MAX_PIXELS`). The model
|
||
/// resizes any input to roughly this many pixels anyway, so we slice/fit
|
||
/// to it at native resolution rather than pre-squashing by a fixed edge.
|
||
pub max_pixels: u32,
|
||
/// Minimum slice height (px), the aspect guard for very wide pages
|
||
/// (`ANALYSIS_MIN_SLICE_HEIGHT`). Implies `max_slice_width =
|
||
/// max_pixels / min_slice_height`.
|
||
pub min_slice_height: u32,
|
||
/// Vertical overlap between adjacent slices as a fraction of slice height
|
||
/// (`ANALYSIS_SLICE_OVERLAP`), so text straddling a cut survives.
|
||
pub slice_overlap: f64,
|
||
/// Slice only when `height/width` exceeds this (`ANALYSIS_TALL_ASPECT`);
|
||
/// normal-aspect pages take a single combined call.
|
||
pub tall_aspect_threshold: f64,
|
||
/// Hard cap on slices per page (`ANALYSIS_MAX_SLICES`); beyond it slices
|
||
/// grow coarser (and get downscaled to budget) rather than multiplying.
|
||
pub max_slices: usize,
|
||
/// Hard cap on a page image's stored size; larger pages are skipped
|
||
/// (`ANALYSIS_MAX_IMAGE_BYTES`).
|
||
pub max_image_bytes: usize,
|
||
/// Hard cap on a page image's **decoded** pixel count for the OCR backend
|
||
/// (`ANALYSIS_OCR_MAX_DECODE_PIXELS`). `max_image_bytes` only bounds the
|
||
/// *encoded* size; without a decode bound a tiny image declaring
|
||
/// 50000×50000 inflates to billions of bytes and OOM-kills the worker
|
||
/// (decompression bomb). Generous by default (100 MP) so legitimately
|
||
/// tall, un-sliced manga pages still decode.
|
||
pub ocr_max_decode_pixels: u64,
|
||
/// Output-constraint mode (`ANALYSIS_RESPONSE_FORMAT`):
|
||
/// `json_schema` (default) | `json_object` | `none`.
|
||
pub response_format: ResponseFormat,
|
||
/// Sampling `frequency_penalty` sent with each request
|
||
/// (`ANALYSIS_FREQUENCY_PENALTY`). A small positive value discourages
|
||
/// the repetition loops that otherwise run small models into the token
|
||
/// ceiling. `0` omits the field.
|
||
pub frequency_penalty: f64,
|
||
/// Sampling `temperature` sent with each request (`ANALYSIS_TEMPERATURE`).
|
||
/// Defaults to `0.0` (deterministic), which is the most reliable for
|
||
/// structured JSON output; some models behave better with a small
|
||
/// positive value.
|
||
pub temperature: f64,
|
||
/// System prompt for the single-call (normal-aspect) analysis path
|
||
/// (`ANALYSIS_SYSTEM_PROMPT`). Defaults to
|
||
/// [`crate::analysis::prompt::SYSTEM_PROMPT_DEFAULT`].
|
||
pub system_prompt: String,
|
||
/// Pass-A OCR-only prompt for tall-page slices (`ANALYSIS_OCR_PROMPT`).
|
||
/// Defaults to [`crate::analysis::prompt::OCR_PROMPT_DEFAULT`].
|
||
pub ocr_prompt: String,
|
||
/// Pass-B grounding prompt — tags/scene/safety (`ANALYSIS_GROUNDING_PROMPT`).
|
||
/// Defaults to [`crate::analysis::prompt::GROUNDING_PROMPT_DEFAULT`].
|
||
pub grounding_prompt: String,
|
||
}
|
||
|
||
impl Default for AnalysisConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: false,
|
||
backend: AnalysisBackend::Ocr,
|
||
ocr_detection_model: "/models/text-detection.rten".to_string(),
|
||
ocr_recognition_model: "/models/text-recognition.rten".to_string(),
|
||
workers: 1,
|
||
endpoint: "http://localhost:8000/v1/chat/completions".to_string(),
|
||
vision_health_url: None,
|
||
model: String::new(),
|
||
api_key: None,
|
||
request_timeout: Duration::from_secs(120),
|
||
// A sliced long page is N sequential calls under one job, so the
|
||
// whole-job budget must be generous (request_timeout stays
|
||
// per-call).
|
||
job_timeout: Duration::from_secs(600),
|
||
max_tokens: 4096,
|
||
max_pixels: 1_000_000,
|
||
min_slice_height: 640,
|
||
slice_overlap: 0.12,
|
||
tall_aspect_threshold: 1.6,
|
||
max_slices: 16,
|
||
max_image_bytes: 8 * 1024 * 1024,
|
||
ocr_max_decode_pixels: 100_000_000,
|
||
response_format: ResponseFormat::JsonSchema,
|
||
frequency_penalty: 0.3,
|
||
temperature: 0.0,
|
||
system_prompt: crate::analysis::prompt::SYSTEM_PROMPT_DEFAULT.to_string(),
|
||
ocr_prompt: crate::analysis::prompt::OCR_PROMPT_DEFAULT.to_string(),
|
||
grounding_prompt: crate::analysis::prompt::GROUNDING_PROMPT_DEFAULT.to_string(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl AnalysisConfig {
|
||
/// The backend the worker actually dispatches through.
|
||
///
|
||
/// Vision is **temporarily disabled**: the engine code (`analysis::vision`,
|
||
/// `RealAnalyzeDispatcher`, the readiness probe) is kept intact but never
|
||
/// selected. Until it's re-enabled, this returns [`AnalysisBackend::Ocr`]
|
||
/// regardless of the parsed `backend`, logging a warning if `vision` was
|
||
/// requested so an env override isn't silently ignored. Re-enabling vision
|
||
/// is then a one-line change here (return `self.backend`).
|
||
pub fn effective_backend(&self) -> AnalysisBackend {
|
||
if self.backend == AnalysisBackend::Vision {
|
||
tracing::warn!(
|
||
"ANALYSIS_BACKEND=vision requested but the vision backend is temporarily \
|
||
disabled; running OCR instead"
|
||
);
|
||
}
|
||
AnalysisBackend::Ocr
|
||
}
|
||
|
||
pub fn from_env() -> Self {
|
||
let d = AnalysisConfig::default();
|
||
Self {
|
||
enabled: env_bool("ANALYSIS_ENABLED", d.enabled),
|
||
backend: std::env::var("ANALYSIS_BACKEND")
|
||
.map(|s| AnalysisBackend::from_str(&s))
|
||
.unwrap_or(d.backend),
|
||
ocr_detection_model: std::env::var("OCRS_DETECTION_MODEL")
|
||
.ok()
|
||
.filter(|s| !s.is_empty())
|
||
.unwrap_or(d.ocr_detection_model),
|
||
ocr_recognition_model: std::env::var("OCRS_RECOGNITION_MODEL")
|
||
.ok()
|
||
.filter(|s| !s.is_empty())
|
||
.unwrap_or(d.ocr_recognition_model),
|
||
workers: env_usize("ANALYSIS_WORKERS", d.workers).max(1),
|
||
endpoint: std::env::var("ANALYSIS_VISION_URL").unwrap_or(d.endpoint),
|
||
vision_health_url: std::env::var("ANALYSIS_VISION_HEALTH_URL")
|
||
.ok()
|
||
.filter(|s| !s.is_empty()),
|
||
// Renamed from `ANALYSIS_MODEL` in 0.87.17 to match the
|
||
// `ANALYSIS_VISION_*` naming the docs, compose, and 0.87.12's
|
||
// regression test had already standardised on. Pre-0.87.17
|
||
// deploys reading the old `ANALYSIS_MODEL` will fall through
|
||
// to the default — explicitly noted so an operator hitting
|
||
// an empty `model` after upgrade can grep this file.
|
||
model: std::env::var("ANALYSIS_VISION_MODEL").unwrap_or(d.model),
|
||
api_key: std::env::var("ANALYSIS_API_KEY")
|
||
.ok()
|
||
.filter(|s| !s.is_empty()),
|
||
request_timeout: Duration::from_secs(env_u64(
|
||
"ANALYSIS_REQUEST_TIMEOUT_SECS",
|
||
d.request_timeout.as_secs(),
|
||
)),
|
||
job_timeout: Duration::from_secs(env_u64(
|
||
"ANALYSIS_JOB_TIMEOUT_SECS",
|
||
d.job_timeout.as_secs(),
|
||
)),
|
||
max_tokens: env_u64("ANALYSIS_MAX_TOKENS", d.max_tokens as u64) as u32,
|
||
max_pixels: env_u64("ANALYSIS_MAX_PIXELS", d.max_pixels as u64) as u32,
|
||
min_slice_height: env_u64("ANALYSIS_MIN_SLICE_HEIGHT", d.min_slice_height as u64)
|
||
.max(1) as u32,
|
||
slice_overlap: env_f64("ANALYSIS_SLICE_OVERLAP", d.slice_overlap).clamp(0.0, 0.9),
|
||
tall_aspect_threshold: env_f64("ANALYSIS_TALL_ASPECT", d.tall_aspect_threshold)
|
||
.max(1.0),
|
||
max_slices: env_usize("ANALYSIS_MAX_SLICES", d.max_slices).max(1),
|
||
max_image_bytes: env_usize("ANALYSIS_MAX_IMAGE_BYTES", d.max_image_bytes),
|
||
ocr_max_decode_pixels: env_u64(
|
||
"ANALYSIS_OCR_MAX_DECODE_PIXELS",
|
||
d.ocr_max_decode_pixels,
|
||
),
|
||
response_format: std::env::var("ANALYSIS_RESPONSE_FORMAT")
|
||
.map(|s| ResponseFormat::from_str(&s))
|
||
.unwrap_or(d.response_format),
|
||
frequency_penalty: env_f64("ANALYSIS_FREQUENCY_PENALTY", d.frequency_penalty),
|
||
temperature: env_f64("ANALYSIS_TEMPERATURE", d.temperature),
|
||
system_prompt: env_prompt("ANALYSIS_SYSTEM_PROMPT", d.system_prompt),
|
||
ocr_prompt: env_prompt("ANALYSIS_OCR_PROMPT", d.ocr_prompt),
|
||
grounding_prompt: env_prompt("ANALYSIS_GROUNDING_PROMPT", d.grounding_prompt),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Read a prompt override from env, falling back to the default when unset
|
||
/// or blank (so `ANALYSIS_SYSTEM_PROMPT=` doesn't wipe the prompt).
|
||
fn env_prompt(name: &str, default: String) -> String {
|
||
std::env::var(name)
|
||
.ok()
|
||
.filter(|s| !s.trim().is_empty())
|
||
.unwrap_or(default)
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct Config {
|
||
pub database_url: String,
|
||
pub bind_address: String,
|
||
pub storage_dir: PathBuf,
|
||
pub db: DbConfig,
|
||
pub auth: AuthConfig,
|
||
pub upload: UploadConfig,
|
||
pub cors_allowed_origins: Vec<String>,
|
||
/// Origins (scheme + host[:port]) that may issue browser-driven
|
||
/// mutating requests to `/api/v1/admin/*`. Defends against
|
||
/// SameSite=Lax CSRF on the admin cookie: a top-level form POST from
|
||
/// a malicious page still carries the cookie, but the middleware
|
||
/// rejects it when the `Origin` (or `Referer` fallback) is absent
|
||
/// from this list. Sourced from `ADMIN_ALLOWED_ORIGINS`
|
||
/// (comma-separated). Leave empty to skip the check entirely
|
||
/// (curl / server-to-server callers send neither header, so they
|
||
/// pass; same-origin browser requests don't have Origin set on
|
||
/// same-origin POSTs in some browsers either — operators on a
|
||
/// same-origin deploy can leave this empty, but doing so removes
|
||
/// the CSRF defence). Safe methods (GET/HEAD/OPTIONS) never trigger
|
||
/// the check.
|
||
pub admin_allowed_origins: Vec<String>,
|
||
pub crawler: CrawlerConfig,
|
||
pub analysis: AnalysisConfig,
|
||
/// `(username, password)` for the admin user provisioned at startup
|
||
/// when both `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set. `None`
|
||
/// skips the bootstrap entirely. See `repo::user::bootstrap_admin`
|
||
/// for the create-vs-promote semantics — notably the password here
|
||
/// is used only when creating a new row, never to overwrite an
|
||
/// existing one.
|
||
pub admin_bootstrap: Option<(String, String)>,
|
||
}
|
||
|
||
/// All crawler-daemon knobs read from env. Mirrors the env vars the
|
||
/// `bin/crawler` binary already reads, plus the new daemon-only knobs
|
||
/// (daily_at, tz, idle_timeout, retention_days, daemon_enabled).
|
||
///
|
||
/// `daemon_enabled = false` skips the daemon spawn entirely — used by
|
||
/// integration tests and dev runs that don't want background activity.
|
||
#[derive(Clone, Debug)]
|
||
pub struct CrawlerConfig {
|
||
pub daemon_enabled: bool,
|
||
pub daily_at: NaiveTime,
|
||
pub tz: Tz,
|
||
pub idle_timeout: Duration,
|
||
pub chapter_workers: usize,
|
||
pub retention_days: u32,
|
||
/// Days to keep `crawl_metrics` timing rows before reaping. `0`
|
||
/// disables the reaper. Defaults to 90 (volume is low — one row per
|
||
/// manga/chapter/pass, not per image). `CRAWL_METRICS_RETENTION_DAYS`.
|
||
pub metrics_retention_days: u32,
|
||
pub start_url: Option<String>,
|
||
pub rate_ms: u64,
|
||
pub cdn_host: Option<String>,
|
||
pub cdn_rate_ms: u64,
|
||
pub phpsessid: Option<String>,
|
||
pub cookie_domain: Option<String>,
|
||
pub user_agent: Option<String>,
|
||
pub proxy: Option<String>,
|
||
/// `tcp://host:port`, `host:port`, or bare `host` (default port
|
||
/// 9051). When `None`, TOR-recircuit-on-transient is disabled and
|
||
/// the crawler behaves identically to pre-TOR releases.
|
||
pub tor_control_url: Option<String>,
|
||
/// HashedControlPassword auth. Used only when
|
||
/// `tor_control_cookie_path` is `None`.
|
||
pub tor_control_password: Option<String>,
|
||
/// Cookie-file auth path (e.g.
|
||
/// `/var/lib/tor/control_auth_cookie`). Takes precedence over
|
||
/// password when both are set.
|
||
pub tor_control_cookie_path: Option<PathBuf>,
|
||
/// Maximum NEWNYM-and-retry cycles per recircuit-eligible failure.
|
||
/// Defaults to 3.
|
||
pub tor_recircuit_max_attempts: u32,
|
||
pub browser: LaunchOptions,
|
||
/// Hosts the crawler is allowed to download images / covers from.
|
||
/// Always seeded with the host of `start_url` and (when set) the
|
||
/// configured `cdn_host`. Additional hosts can be added via
|
||
/// `CRAWLER_DOWNLOAD_ALLOWLIST` (comma-separated).
|
||
pub download_allowlist: DownloadAllowlist,
|
||
/// Hard upper bound on a single image download. Defaults to 32 MiB.
|
||
pub max_image_bytes: usize,
|
||
/// Hard upper bound on the number of page images in one chapter. A
|
||
/// hostile reader page could otherwise list thousands of `<img>`
|
||
/// tags; `max_image_bytes` caps each one but not the count, so the
|
||
/// product is an unbounded disk-fill. A chapter exceeding this is
|
||
/// acked failed rather than downloaded. `0` disables the cap.
|
||
/// Defaults to 2000. `CRAWLER_MAX_IMAGES_PER_CHAPTER`.
|
||
pub max_images_per_chapter: usize,
|
||
/// Max manga detail fetches per metadata pass. `0` means no cap
|
||
/// (full sweep up to the source's own bound). Sourced from
|
||
/// `CRAWLER_LIMIT`, mirroring the CLI binary.
|
||
pub manga_limit: usize,
|
||
/// Hard upper bound on a single chapter-content job dispatch. A job
|
||
/// exceeding this is acked failed (exponential backoff) instead of
|
||
/// wedging a worker. Defaults to 600s. `CRAWLER_JOB_TIMEOUT_SECS`.
|
||
pub job_timeout: Duration,
|
||
/// Consecutive `fetch_manga` failures that abort a metadata pass
|
||
/// (circuit-breaker for a source outage). The pass does NOT mark a
|
||
/// clean exit, so the next tick does a recovery sweep. Defaults to
|
||
/// 10. `CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES`.
|
||
pub metadata_max_consecutive_failures: u32,
|
||
/// Consecutive transient chapter failures (after TOR recircuit is
|
||
/// exhausted) that trigger an automatic coordinated browser restart.
|
||
/// Defaults to 3. `CRAWLER_BROWSER_RESTART_THRESHOLD`.
|
||
pub browser_restart_threshold: u32,
|
||
/// CDP `Fetch` interception that re-validates every headless-browser
|
||
/// navigation/redirect/subresource against the SSRF check. Default `true`:
|
||
/// with it off, only the top-level URL string is validated, so a scraped
|
||
/// page's JS/subresources (which use Chromium's own network stack, not the
|
||
/// reqwest `SafeResolver`) can reach internal targets like the cloud
|
||
/// metadata service or postgres. The Fetch hook can't be exercised in CI (no
|
||
/// Chromium) — the `#[ignore]`d `ssrf_interception_does_not_wedge_allowed_navigation`
|
||
/// smoke test validates it against a real binary. `CRAWLER_SSRF_INTERCEPT`;
|
||
/// set `false` only as a break-glass if the hook destabilizes a deployment.
|
||
pub ssrf_intercept: bool,
|
||
}
|
||
|
||
impl Default for CrawlerConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
daemon_enabled: false,
|
||
daily_at: NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
|
||
tz: Tz::UTC,
|
||
idle_timeout: Duration::from_secs(600),
|
||
chapter_workers: 1,
|
||
retention_days: 7,
|
||
metrics_retention_days: 90,
|
||
start_url: None,
|
||
rate_ms: 1000,
|
||
cdn_host: None,
|
||
cdn_rate_ms: 1000,
|
||
phpsessid: None,
|
||
cookie_domain: None,
|
||
user_agent: None,
|
||
proxy: None,
|
||
tor_control_url: None,
|
||
tor_control_password: None,
|
||
tor_control_cookie_path: None,
|
||
tor_recircuit_max_attempts: 3,
|
||
browser: LaunchOptions::headless(),
|
||
download_allowlist: DownloadAllowlist::new(),
|
||
max_image_bytes: DEFAULT_MAX_IMAGE_BYTES,
|
||
max_images_per_chapter: 2000,
|
||
manga_limit: 0,
|
||
job_timeout: Duration::from_secs(600),
|
||
metadata_max_consecutive_failures: 10,
|
||
browser_restart_threshold: 3,
|
||
ssrf_intercept: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Config {
|
||
pub fn from_env() -> anyhow::Result<Self> {
|
||
Ok(Self {
|
||
database_url: std::env::var("DATABASE_URL")
|
||
.map_err(|_| anyhow::anyhow!("DATABASE_URL must be set"))?,
|
||
bind_address: std::env::var("BIND_ADDRESS")
|
||
.unwrap_or_else(|_| "0.0.0.0:8080".to_string()),
|
||
storage_dir: std::env::var("STORAGE_DIR")
|
||
.unwrap_or_else(|_| "./data/storage".to_string())
|
||
.into(),
|
||
db: DbConfig::from_env(),
|
||
auth: AuthConfig {
|
||
cookie_secure: env_bool("COOKIE_SECURE", true),
|
||
cookie_domain: std::env::var("COOKIE_DOMAIN")
|
||
.ok()
|
||
.filter(|s| !s.is_empty()),
|
||
session_ttl_days: env_i64("SESSION_TTL_DAYS", 30),
|
||
rate_limit: crate::auth::rate_limit::RateLimitConfig {
|
||
per_sec: env_u64(
|
||
"AUTH_RATE_PER_SEC",
|
||
crate::auth::rate_limit::PRODUCTION_PER_SEC.into(),
|
||
) as u32,
|
||
burst: env_u64(
|
||
"AUTH_RATE_BURST",
|
||
crate::auth::rate_limit::PRODUCTION_BURST.into(),
|
||
) as u32,
|
||
},
|
||
allow_self_register: env_bool("ALLOW_SELF_REGISTER", true),
|
||
private_mode: env_bool("PRIVATE_MODE", false),
|
||
trusted_proxy: env_bool("AUTH_TRUSTED_PROXY", false),
|
||
},
|
||
upload: UploadConfig {
|
||
max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024),
|
||
max_file_bytes: env_usize("MAX_FILE_BYTES", 20 * 1024 * 1024),
|
||
max_pages_per_chapter: env_usize("MAX_PAGES_PER_CHAPTER", 2000),
|
||
},
|
||
cors_allowed_origins: std::env::var("CORS_ALLOWED_ORIGINS")
|
||
.ok()
|
||
.map(|s| {
|
||
s.split(',')
|
||
.map(|o| o.trim().to_string())
|
||
.filter(|o| !o.is_empty())
|
||
.collect()
|
||
})
|
||
.unwrap_or_default(),
|
||
admin_allowed_origins: std::env::var("ADMIN_ALLOWED_ORIGINS")
|
||
.ok()
|
||
.map(|s| {
|
||
s.split(',')
|
||
.map(|o| o.trim().to_string())
|
||
.filter(|o| !o.is_empty())
|
||
.collect()
|
||
})
|
||
.unwrap_or_default(),
|
||
crawler: CrawlerConfig::from_env()?,
|
||
analysis: AnalysisConfig::from_env(),
|
||
admin_bootstrap: admin_bootstrap_from_env(),
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Returns `Some((username, password))` only when BOTH `ADMIN_USERNAME`
|
||
/// and `ADMIN_PASSWORD` are set and non-empty. Half-set configuration is
|
||
/// treated as "no bootstrap" rather than a hard error, so an operator
|
||
/// can comment out one env var without crashing the server. Emits a
|
||
/// warning in the half-set case — silent no-op startled operators when
|
||
/// the documented compose env vars finally landed in 0.87.12 (a typo
|
||
/// in ADMIN_USERNAME used to silently disable bootstrap with no log
|
||
/// trace explaining why no admin appeared).
|
||
fn admin_bootstrap_from_env() -> Option<(String, String)> {
|
||
let username = std::env::var("ADMIN_USERNAME").ok().filter(|s| !s.is_empty());
|
||
let password = std::env::var("ADMIN_PASSWORD").ok().filter(|s| !s.is_empty());
|
||
match (username, password) {
|
||
(Some(u), Some(p)) => Some((u, p)),
|
||
(Some(_), None) => {
|
||
tracing::warn!(
|
||
"ADMIN_USERNAME is set but ADMIN_PASSWORD is empty — admin bootstrap skipped (set both, or unset both to silence this warning)"
|
||
);
|
||
None
|
||
}
|
||
(None, Some(_)) => {
|
||
tracing::warn!(
|
||
"ADMIN_PASSWORD is set but ADMIN_USERNAME is empty — admin bootstrap skipped (set both, or unset both to silence this warning)"
|
||
);
|
||
None
|
||
}
|
||
(None, None) => None,
|
||
}
|
||
}
|
||
|
||
impl CrawlerConfig {
|
||
pub fn from_env() -> anyhow::Result<Self> {
|
||
// Parse CRAWLER_DAILY_AT (HH:MM, 24h). Invalid → fail fast.
|
||
let daily_at = match std::env::var("CRAWLER_DAILY_AT").ok().as_deref() {
|
||
None | Some("") => NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
|
||
Some(raw) => NaiveTime::parse_from_str(raw, "%H:%M").map_err(|e| {
|
||
anyhow::anyhow!("CRAWLER_DAILY_AT must be HH:MM (got {raw:?}): {e}")
|
||
})?,
|
||
};
|
||
let tz: Tz = match std::env::var("CRAWLER_TZ").ok().as_deref() {
|
||
None | Some("") => Tz::UTC,
|
||
Some(raw) => raw
|
||
.parse()
|
||
.map_err(|e| anyhow::anyhow!("CRAWLER_TZ must be a valid IANA TZ (got {raw:?}): {e}"))?,
|
||
};
|
||
let start_url = std::env::var("CRAWLER_START_URL")
|
||
.ok()
|
||
.filter(|s| !s.trim().is_empty());
|
||
let cdn_host = std::env::var("CRAWLER_CDN_HOST")
|
||
.ok()
|
||
.filter(|s| !s.trim().is_empty());
|
||
let download_allowlist =
|
||
build_download_allowlist(start_url.as_deref(), cdn_host.as_deref());
|
||
Ok(Self {
|
||
daemon_enabled: env_bool("CRAWLER_DAEMON", true),
|
||
daily_at,
|
||
tz,
|
||
idle_timeout: Duration::from_secs(env_u64("CRAWLER_IDLE_TIMEOUT_S", 600)),
|
||
chapter_workers: env_u64("CRAWLER_CHAPTER_WORKERS", 1).max(1) as usize,
|
||
retention_days: env_u64("CRAWLER_JOB_RETENTION_DAYS", 7) as u32,
|
||
metrics_retention_days: env_u64("CRAWL_METRICS_RETENTION_DAYS", 90) as u32,
|
||
start_url,
|
||
rate_ms: env_u64("CRAWLER_RATE_MS", 1000),
|
||
cdn_host,
|
||
cdn_rate_ms: env_u64("CRAWLER_CDN_RATE_MS", env_u64("CRAWLER_RATE_MS", 1000)),
|
||
phpsessid: std::env::var("CRAWLER_PHPSESSID")
|
||
.ok()
|
||
.filter(|s| !s.trim().is_empty()),
|
||
cookie_domain: std::env::var("CRAWLER_COOKIE_DOMAIN")
|
||
.ok()
|
||
.filter(|s| !s.trim().is_empty()),
|
||
user_agent: std::env::var("CRAWLER_USER_AGENT")
|
||
.ok()
|
||
.filter(|s| !s.trim().is_empty()),
|
||
proxy: std::env::var("CRAWLER_PROXY")
|
||
.ok()
|
||
.filter(|s| !s.trim().is_empty()),
|
||
tor_control_url: std::env::var("CRAWLER_TOR_CONTROL_URL")
|
||
.ok()
|
||
.filter(|s| !s.trim().is_empty()),
|
||
tor_control_password: std::env::var("CRAWLER_TOR_CONTROL_PASSWORD")
|
||
.ok()
|
||
.filter(|s| !s.trim().is_empty()),
|
||
tor_control_cookie_path: std::env::var("CRAWLER_TOR_CONTROL_COOKIE_PATH")
|
||
.ok()
|
||
.filter(|s| !s.trim().is_empty())
|
||
.map(PathBuf::from),
|
||
tor_recircuit_max_attempts: env_u64("CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS", 3)
|
||
.max(1) as u32,
|
||
browser: LaunchOptions::from_env(),
|
||
download_allowlist,
|
||
max_image_bytes: env_usize("CRAWLER_MAX_IMAGE_BYTES", DEFAULT_MAX_IMAGE_BYTES),
|
||
max_images_per_chapter: env_usize("CRAWLER_MAX_IMAGES_PER_CHAPTER", 2000),
|
||
manga_limit: env_usize("CRAWLER_LIMIT", 0),
|
||
job_timeout: Duration::from_secs(env_u64("CRAWLER_JOB_TIMEOUT_SECS", 600).max(1)),
|
||
metadata_max_consecutive_failures: env_u64(
|
||
"CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES",
|
||
10,
|
||
) as u32,
|
||
browser_restart_threshold: env_u64("CRAWLER_BROWSER_RESTART_THRESHOLD", 3).max(1)
|
||
as u32,
|
||
ssrf_intercept: env_bool("CRAWLER_SSRF_INTERCEPT", true),
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Build the download allowlist from env. Always includes
|
||
/// `CRAWLER_START_URL`'s host (so the crawler can fetch covers from
|
||
/// the catalog itself) and `CRAWLER_CDN_HOST` when set. Additional
|
||
/// hosts can be supplied via `CRAWLER_DOWNLOAD_ALLOWLIST` (comma-
|
||
/// separated). Empty by default — meaning the crawler refuses to
|
||
/// download anything when no source is configured, which is the safe
|
||
/// fail-closed posture.
|
||
///
|
||
/// `CRAWLER_ALLOW_ANY_HOST=true` short-circuits the host enumeration
|
||
/// for operators whose sources shard across numbered CDN subdomains.
|
||
/// Scheme + private-IP defenses still apply.
|
||
fn build_download_allowlist(
|
||
start_url: Option<&str>,
|
||
cdn_host: Option<&str>,
|
||
) -> DownloadAllowlist {
|
||
if env_bool("CRAWLER_ALLOW_ANY_HOST", false) {
|
||
return DownloadAllowlist::allow_any();
|
||
}
|
||
let mut allow = DownloadAllowlist::new();
|
||
if let Some(url) = start_url {
|
||
if let Ok(parsed) = reqwest::Url::parse(url) {
|
||
if let Some(h) = parsed.host_str() {
|
||
allow = allow.allow(h);
|
||
}
|
||
}
|
||
}
|
||
if let Some(host) = cdn_host {
|
||
allow = allow.allow(host);
|
||
}
|
||
if let Ok(extras) = std::env::var("CRAWLER_DOWNLOAD_ALLOWLIST") {
|
||
for piece in extras.split(',') {
|
||
let trimmed = piece.trim();
|
||
if !trimmed.is_empty() {
|
||
allow = allow.allow(trimmed);
|
||
}
|
||
}
|
||
}
|
||
allow
|
||
}
|
||
|
||
pub(crate) fn env_u64(name: &str, default: u64) -> u64 {
|
||
std::env::var(name)
|
||
.ok()
|
||
.and_then(|s| s.parse().ok())
|
||
.unwrap_or(default)
|
||
}
|
||
|
||
fn env_bool(name: &str, default: bool) -> bool {
|
||
match std::env::var(name).ok().as_deref() {
|
||
Some("1") | Some("true") | Some("TRUE") | Some("yes") => true,
|
||
Some("0") | Some("false") | Some("FALSE") | Some("no") => false,
|
||
_ => default,
|
||
}
|
||
}
|
||
|
||
fn env_i64(name: &str, default: i64) -> i64 {
|
||
std::env::var(name)
|
||
.ok()
|
||
.and_then(|s| s.parse().ok())
|
||
.unwrap_or(default)
|
||
}
|
||
|
||
fn env_f64(name: &str, default: f64) -> f64 {
|
||
std::env::var(name)
|
||
.ok()
|
||
.and_then(|s| s.parse().ok())
|
||
.unwrap_or(default)
|
||
}
|
||
|
||
fn env_usize(name: &str, default: usize) -> usize {
|
||
std::env::var(name)
|
||
.ok()
|
||
.and_then(|s| s.parse().ok())
|
||
.unwrap_or(default)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::sync::Mutex;
|
||
|
||
// Serialise env-touching tests so concurrent cargo-test threads don't
|
||
// race on the process-global env. Re-acquire on poison since a
|
||
// panicking test still leaves the env in a consistent state for us
|
||
// (we set/unset within each guard region).
|
||
static ENV_GUARD: Mutex<()> = Mutex::new(());
|
||
|
||
/// Ensure `DATABASE_URL` is set for `Config::from_env()` WITHOUT clobbering
|
||
/// or removing an existing value. The `#[sqlx::test]` lib tests in other
|
||
/// modules read `DATABASE_URL` from the same process env and run in
|
||
/// parallel with these `ENV_GUARD`-serialised tests; overwriting it (bad
|
||
/// host) or removing it ("NotPresent") flaked them. CI already exports the
|
||
/// real URL, so this is a no-op there; locally it provides a placeholder.
|
||
fn ensure_database_url() {
|
||
if std::env::var_os("DATABASE_URL").is_none() {
|
||
std::env::set_var("DATABASE_URL", "postgres://test");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn crawler_limit_env_populates_manga_limit() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
std::env::set_var("CRAWLER_LIMIT", "96");
|
||
let cfg = CrawlerConfig::from_env().expect("from_env");
|
||
std::env::remove_var("CRAWLER_LIMIT");
|
||
assert_eq!(cfg.manga_limit, 96);
|
||
}
|
||
|
||
#[test]
|
||
fn crawler_limit_unset_defaults_to_zero() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
std::env::remove_var("CRAWLER_LIMIT");
|
||
let cfg = CrawlerConfig::from_env().expect("from_env");
|
||
assert_eq!(cfg.manga_limit, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn reliability_knobs_default_when_unset() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
std::env::remove_var("CRAWLER_JOB_TIMEOUT_SECS");
|
||
std::env::remove_var("CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES");
|
||
std::env::remove_var("CRAWLER_BROWSER_RESTART_THRESHOLD");
|
||
let cfg = CrawlerConfig::from_env().expect("from_env");
|
||
assert_eq!(cfg.job_timeout, Duration::from_secs(600));
|
||
assert_eq!(cfg.metadata_max_consecutive_failures, 10);
|
||
assert_eq!(cfg.browser_restart_threshold, 3);
|
||
}
|
||
|
||
#[test]
|
||
fn reliability_knobs_parse_from_env() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
std::env::set_var("CRAWLER_JOB_TIMEOUT_SECS", "120");
|
||
std::env::set_var("CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES", "5");
|
||
std::env::set_var("CRAWLER_BROWSER_RESTART_THRESHOLD", "7");
|
||
let cfg = CrawlerConfig::from_env().expect("from_env");
|
||
std::env::remove_var("CRAWLER_JOB_TIMEOUT_SECS");
|
||
std::env::remove_var("CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES");
|
||
std::env::remove_var("CRAWLER_BROWSER_RESTART_THRESHOLD");
|
||
assert_eq!(cfg.job_timeout, Duration::from_secs(120));
|
||
assert_eq!(cfg.metadata_max_consecutive_failures, 5);
|
||
assert_eq!(cfg.browser_restart_threshold, 7);
|
||
}
|
||
|
||
#[test]
|
||
fn db_pool_defaults_when_unset() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
std::env::remove_var("DB_MAX_CONNECTIONS");
|
||
std::env::remove_var("DB_ACQUIRE_TIMEOUT_SECS");
|
||
let cfg = DbConfig::from_env();
|
||
assert_eq!(cfg.max_connections, 20);
|
||
assert_eq!(cfg.acquire_timeout, Duration::from_secs(10));
|
||
}
|
||
|
||
#[test]
|
||
fn db_pool_parses_from_env() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
std::env::set_var("DB_MAX_CONNECTIONS", "50");
|
||
std::env::set_var("DB_ACQUIRE_TIMEOUT_SECS", "3");
|
||
let cfg = DbConfig::from_env();
|
||
std::env::remove_var("DB_MAX_CONNECTIONS");
|
||
std::env::remove_var("DB_ACQUIRE_TIMEOUT_SECS");
|
||
assert_eq!(cfg.max_connections, 50);
|
||
assert_eq!(cfg.acquire_timeout, Duration::from_secs(3));
|
||
}
|
||
|
||
#[test]
|
||
fn db_pool_max_connections_clamps_to_at_least_one() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
std::env::set_var("DB_MAX_CONNECTIONS", "0");
|
||
let cfg = DbConfig::from_env();
|
||
std::env::remove_var("DB_MAX_CONNECTIONS");
|
||
assert_eq!(cfg.max_connections, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn analysis_config_defaults_when_unset() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
for k in [
|
||
"ANALYSIS_ENABLED",
|
||
"ANALYSIS_WORKERS",
|
||
"ANALYSIS_VISION_URL",
|
||
"ANALYSIS_VISION_MODEL",
|
||
"ANALYSIS_API_KEY",
|
||
"ANALYSIS_MAX_TOKENS",
|
||
"ANALYSIS_MAX_PIXELS",
|
||
"ANALYSIS_MIN_SLICE_HEIGHT",
|
||
"ANALYSIS_SLICE_OVERLAP",
|
||
"ANALYSIS_TALL_ASPECT",
|
||
"ANALYSIS_MAX_SLICES",
|
||
"ANALYSIS_RESPONSE_FORMAT",
|
||
"ANALYSIS_FREQUENCY_PENALTY",
|
||
"ANALYSIS_BACKEND",
|
||
"OCRS_DETECTION_MODEL",
|
||
"OCRS_RECOGNITION_MODEL",
|
||
] {
|
||
std::env::remove_var(k);
|
||
}
|
||
let cfg = AnalysisConfig::from_env();
|
||
assert!(!cfg.enabled);
|
||
// OCR is the default engine (the Pi-friendly path).
|
||
assert_eq!(cfg.backend, AnalysisBackend::Ocr);
|
||
assert_eq!(cfg.ocr_detection_model, "/models/text-detection.rten");
|
||
assert_eq!(cfg.ocr_recognition_model, "/models/text-recognition.rten");
|
||
assert_eq!(cfg.workers, 1);
|
||
assert_eq!(cfg.max_pixels, 1_000_000);
|
||
assert_eq!(cfg.min_slice_height, 640);
|
||
assert_eq!(cfg.max_slices, 16);
|
||
assert_eq!(cfg.tall_aspect_threshold, 1.6);
|
||
assert_eq!(cfg.frequency_penalty, 0.3);
|
||
assert!(cfg.api_key.is_none());
|
||
assert_eq!(cfg.response_format, ResponseFormat::JsonSchema);
|
||
}
|
||
|
||
#[test]
|
||
fn analysis_response_format_parses_modes() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
for (raw, want) in [
|
||
("json_object", ResponseFormat::JsonObject),
|
||
("none", ResponseFormat::None),
|
||
("text", ResponseFormat::None),
|
||
("json_schema", ResponseFormat::JsonSchema),
|
||
("anything-else", ResponseFormat::JsonSchema),
|
||
] {
|
||
std::env::set_var("ANALYSIS_RESPONSE_FORMAT", raw);
|
||
assert_eq!(AnalysisConfig::from_env().response_format, want, "raw={raw}");
|
||
}
|
||
std::env::remove_var("ANALYSIS_RESPONSE_FORMAT");
|
||
}
|
||
|
||
#[test]
|
||
fn analysis_backend_parses_and_defaults_to_ocr() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
for (raw, want) in [
|
||
("ocr", AnalysisBackend::Ocr),
|
||
("ocrs", AnalysisBackend::Ocr),
|
||
("vision", AnalysisBackend::Vision),
|
||
("llm", AnalysisBackend::Vision),
|
||
("anything-else", AnalysisBackend::Ocr),
|
||
] {
|
||
std::env::set_var("ANALYSIS_BACKEND", raw);
|
||
assert_eq!(AnalysisConfig::from_env().backend, want, "raw={raw}");
|
||
}
|
||
// Unset → OCR.
|
||
std::env::remove_var("ANALYSIS_BACKEND");
|
||
assert_eq!(AnalysisConfig::from_env().backend, AnalysisBackend::Ocr);
|
||
}
|
||
|
||
#[test]
|
||
fn effective_backend_is_ocr_even_when_vision_requested() {
|
||
// Vision is temporarily disabled: the worker always runs OCR no matter
|
||
// what `ANALYSIS_BACKEND` parsed to. `backend` still reflects the raw
|
||
// request (so the override is visible/loggable), but `effective_backend`
|
||
// is the value the daemon actually dispatches through.
|
||
let mut cfg = AnalysisConfig {
|
||
backend: AnalysisBackend::Vision,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(cfg.effective_backend(), AnalysisBackend::Ocr);
|
||
cfg.backend = AnalysisBackend::Ocr;
|
||
assert_eq!(cfg.effective_backend(), AnalysisBackend::Ocr);
|
||
}
|
||
|
||
#[test]
|
||
fn ocr_model_paths_parse_from_env() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
std::env::set_var("OCRS_DETECTION_MODEL", "/opt/det.rten");
|
||
std::env::set_var("OCRS_RECOGNITION_MODEL", "/opt/rec.rten");
|
||
let cfg = AnalysisConfig::from_env();
|
||
std::env::remove_var("OCRS_DETECTION_MODEL");
|
||
std::env::remove_var("OCRS_RECOGNITION_MODEL");
|
||
assert_eq!(cfg.ocr_detection_model, "/opt/det.rten");
|
||
assert_eq!(cfg.ocr_recognition_model, "/opt/rec.rten");
|
||
}
|
||
|
||
#[test]
|
||
fn analysis_config_parses_from_env() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
std::env::set_var("ANALYSIS_ENABLED", "true");
|
||
std::env::set_var("ANALYSIS_WORKERS", "4");
|
||
std::env::set_var("ANALYSIS_VISION_URL", "http://vis/v1/chat");
|
||
std::env::set_var("ANALYSIS_VISION_MODEL", "qwen2-vl");
|
||
std::env::set_var("ANALYSIS_MAX_PIXELS", "768000");
|
||
std::env::set_var("ANALYSIS_MAX_SLICES", "8");
|
||
std::env::set_var("ANALYSIS_SLICE_OVERLAP", "0.2");
|
||
let cfg = AnalysisConfig::from_env();
|
||
for k in [
|
||
"ANALYSIS_ENABLED",
|
||
"ANALYSIS_WORKERS",
|
||
"ANALYSIS_VISION_URL",
|
||
"ANALYSIS_VISION_MODEL",
|
||
"ANALYSIS_MAX_PIXELS",
|
||
"ANALYSIS_MAX_SLICES",
|
||
"ANALYSIS_SLICE_OVERLAP",
|
||
] {
|
||
std::env::remove_var(k);
|
||
}
|
||
assert!(cfg.enabled);
|
||
assert_eq!(cfg.workers, 4);
|
||
assert_eq!(cfg.endpoint, "http://vis/v1/chat");
|
||
assert_eq!(cfg.model, "qwen2-vl");
|
||
assert_eq!(cfg.max_pixels, 768_000);
|
||
assert_eq!(cfg.max_slices, 8);
|
||
assert_eq!(cfg.slice_overlap, 0.2);
|
||
}
|
||
|
||
#[test]
|
||
fn private_mode_env_parses_true() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
std::env::set_var("PRIVATE_MODE", "true");
|
||
// Don't clobber/unset DATABASE_URL: parallel #[sqlx::test] lib tests
|
||
// read it from the process env and would flake ("NotPresent") on the
|
||
// removal. Ensure it's present (CI sets it) without overwriting a real
|
||
// one, and never remove it.
|
||
ensure_database_url();
|
||
let cfg = Config::from_env().expect("from_env");
|
||
std::env::remove_var("PRIVATE_MODE");
|
||
assert!(cfg.auth.private_mode);
|
||
}
|
||
|
||
#[test]
|
||
fn private_mode_env_parses_false() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
std::env::set_var("PRIVATE_MODE", "false");
|
||
ensure_database_url();
|
||
let cfg = Config::from_env().expect("from_env");
|
||
std::env::remove_var("PRIVATE_MODE");
|
||
assert!(!cfg.auth.private_mode);
|
||
}
|
||
|
||
#[test]
|
||
fn private_mode_defaults_to_false() {
|
||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||
std::env::remove_var("PRIVATE_MODE");
|
||
ensure_database_url();
|
||
let cfg = Config::from_env().expect("from_env");
|
||
assert!(!cfg.auth.private_mode);
|
||
}
|
||
|
||
/// Vars that live in `.env.example` for operator context but that
|
||
/// are NOT consumed by the backend service (so don't need to be in
|
||
/// backend.environment). Each entry needs a rationale — if a future
|
||
/// var is added to `.env.example` without being wired into compose,
|
||
/// the test fails until either the wiring is added or the var is
|
||
/// listed here with a justification.
|
||
///
|
||
/// - POSTGRES_DB/USER/PASSWORD — composed into DATABASE_URL on the
|
||
/// compose RHS; the backend container never sees them by name.
|
||
/// - TOR_CONTROL_PASSWORD — wired through to BOTH tor and backend
|
||
/// under different names (PASSWORD / CRAWLER_TOR_CONTROL_PASSWORD).
|
||
/// - BACKEND_URL / BACKEND_PROXY_TIMEOUT_MS — consumed by the
|
||
/// SvelteKit frontend container, not the backend.
|
||
/// - VISION_MANAGER_DATABASE_URL — consumed by the vision-manager
|
||
/// sidecar, not the backend.
|
||
const NOT_BACKEND_CONSUMED_IN_ENV_EXAMPLE: &[&str] = &[
|
||
"POSTGRES_DB",
|
||
"POSTGRES_USER",
|
||
"POSTGRES_PASSWORD",
|
||
"TOR_CONTROL_PASSWORD",
|
||
"BACKEND_URL",
|
||
"BACKEND_PROXY_TIMEOUT_MS",
|
||
"VISION_MANAGER_DATABASE_URL",
|
||
// Compose-level deployment knobs (port publish interface + per-container
|
||
// memory ceilings) — consumed by docker-compose.yml itself, never read
|
||
// by the backend process, so they don't belong in its environment block.
|
||
"FRONTEND_PUBLISH_ADDR",
|
||
"BACKEND_MEM_LIMIT",
|
||
"FRONTEND_MEM_LIMIT",
|
||
"POSTGRES_MEM_LIMIT",
|
||
];
|
||
|
||
/// Keys whose compose RHS is intentionally NOT a `${KEY...}`
|
||
/// interpolation. Each entry needs a justification:
|
||
///
|
||
/// - BIND_ADDRESS / STORAGE_DIR — container-internal binding /
|
||
/// mount target; overriding from the host would un-net the
|
||
/// container or break the volume.
|
||
/// - DATABASE_URL — composed in compose from POSTGRES_USER /
|
||
/// PASSWORD / DB; the backend container never sees the raw
|
||
/// `${DATABASE_URL...}` placeholder.
|
||
/// - CRAWLER_TOR_CONTROL_PASSWORD — wired through TOR_CONTROL_PASSWORD
|
||
/// (single source of truth for the tor service's own PASSWORD).
|
||
const COMPOSE_RHS_EXEMPT: &[&str] = &[
|
||
"BIND_ADDRESS",
|
||
"STORAGE_DIR",
|
||
"DATABASE_URL",
|
||
"CRAWLER_TOR_CONTROL_PASSWORD",
|
||
];
|
||
|
||
/// Vars read by backend src code that are intentionally NOT
|
||
/// operator-facing — kept out of `.env.example` because they are
|
||
/// either system vars, dev-only debug knobs, or model-tuning hooks
|
||
/// only meaningful when forking the codebase. Each entry needs a
|
||
/// rationale (paired here as `(name, why)` for grep-ability):
|
||
const INTERNAL_NOT_CONFIGURABLE: &[(&str, &str)] = &[
|
||
("HOME", "system var, fallback for $HOME/.cache/mangalord/chromium when CRAWLER_CHROMIUM_DIR is unset (see crawler::browser::default_chromium_dir)"),
|
||
("CRAWLER_TOR_CONTROL_PASSWORD", "internal name; compose maps TOR_CONTROL_PASSWORD (.env) into this var so the operator sets one secret in one place"),
|
||
("ANALYSIS_SYSTEM_PROMPT", "multi-paragraph default; env-override impractical, fork to change"),
|
||
("ANALYSIS_OCR_PROMPT", "multi-paragraph default; env-override impractical, fork to change"),
|
||
("ANALYSIS_GROUNDING_PROMPT", "multi-paragraph default; env-override impractical, fork to change"),
|
||
("CRAWLER_BROWSER_MODE", "local-dev only: chooses between bundled fetcher and system chromium"),
|
||
("CRAWLER_BROWSER_ARGS", "local-dev only: extra chromium launch args"),
|
||
("CRAWLER_CHROMIUM_DIR", "local-dev only: override for chromium download cache dir"),
|
||
("CRAWLER_KEEP_BROWSER_OPEN", "local-dev only: keep headed chromium alive between runs"),
|
||
("CRAWLER_FORCE_REFETCH_CHAPTERS", "debug-only: re-fetches even already-downloaded chapters"),
|
||
("CRAWLER_SKIP_CHAPTER_CONTENT", "debug-only: skips page downloads, keeps metadata"),
|
||
("CRAWLER_SKIP_CHAPTERS", "debug-only: skips chapter pass entirely"),
|
||
];
|
||
|
||
/// Parse the env-var keys listed at column-0 of `.env.example`.
|
||
/// Skips comment-only lines and blank lines.
|
||
fn parse_env_example_keys(env_example: &str) -> std::collections::HashSet<String> {
|
||
let mut keys = std::collections::HashSet::new();
|
||
for line in env_example.lines() {
|
||
let trimmed = line.trim_start();
|
||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||
continue;
|
||
}
|
||
// Only column-0 lines count (avoid grabbing keys mentioned in
|
||
// mid-line prose inside a comment continuation).
|
||
if line.starts_with(|c: char| c.is_ascii_uppercase() || c == '_') {
|
||
if let Some((k, _)) = line.split_once('=') {
|
||
let k = k.trim();
|
||
if !k.is_empty()
|
||
&& k.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
|
||
{
|
||
keys.insert(k.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
keys
|
||
}
|
||
|
||
/// Regression test for the 0.87.1 compose env-wire-through gap, with
|
||
/// the 0.87.17 RHS-interpolation strengthening and the 0.87.25
|
||
/// list-derivation strengthening:
|
||
///
|
||
/// The 0.87.12 version substring-matched the env-var KEY in compose
|
||
/// — blind to whether the RHS substituted from `.env`. The 0.87.17
|
||
/// version added an RHS-interpolation check but still hardcoded the
|
||
/// `BACKEND_CONSUMED` list, which drifted (~22 code-read vars not
|
||
/// in compose). The 0.87.25 version derives the list from
|
||
/// `.env.example` so the operator-facing surface is the single
|
||
/// source of truth: anything documented there must reach the
|
||
/// container, and a new line in the example forces a wiring update.
|
||
///
|
||
/// Every key in `.env.example` that isn't in
|
||
/// `NOT_BACKEND_CONSUMED_IN_ENV_EXAMPLE` must:
|
||
/// * have a `KEY:` line under the backend service `environment:`, AND
|
||
/// * either be in `COMPOSE_RHS_EXEMPT` (intentional non-interp), OR
|
||
/// have its RHS substitute from `${KEY` so `.env` actually wins.
|
||
#[test]
|
||
fn docker_compose_wires_every_documented_backend_env_var() {
|
||
// Cargo run dir for `cargo test` is `backend/`; .env.example and
|
||
// compose file live one up.
|
||
let env_example = std::fs::read_to_string("../.env.example")
|
||
.expect("read ../.env.example");
|
||
let example_keys = parse_env_example_keys(&env_example);
|
||
let backend_consumed: Vec<&str> = example_keys
|
||
.iter()
|
||
.map(String::as_str)
|
||
.filter(|k| !NOT_BACKEND_CONSUMED_IN_ENV_EXAMPLE.contains(k))
|
||
.collect();
|
||
assert!(
|
||
!backend_consumed.is_empty(),
|
||
"parse_env_example_keys returned no keys — check .env.example formatting"
|
||
);
|
||
|
||
let compose = std::fs::read_to_string("../docker-compose.yml")
|
||
.expect("read ../docker-compose.yml");
|
||
// Extract the backend service block: everything from ` backend:`
|
||
// up to the next top-level service key (` <name>:` at 2-space
|
||
// indent, no further indent). Line-based scan instead of the
|
||
// 0.87.12 `\n frontend:\n` literal so adding/reordering services
|
||
// doesn't silently break the slicer.
|
||
let backend_block = extract_compose_service_block(&compose, "backend")
|
||
.expect("backend service block in docker-compose.yml");
|
||
|
||
let mut missing: Vec<String> = Vec::new();
|
||
let mut not_interpolated: Vec<(String, String)> = Vec::new();
|
||
for key in &backend_consumed {
|
||
// The env entries sit at exactly 6 spaces under `environment:`.
|
||
let needle = format!(" {key}:");
|
||
let Some(line_start) = backend_block.find(&needle) else {
|
||
missing.push((*key).to_string());
|
||
continue;
|
||
};
|
||
if COMPOSE_RHS_EXEMPT.contains(key) {
|
||
continue;
|
||
}
|
||
// RHS: everything from the colon to the end of line.
|
||
let from_colon = &backend_block[line_start + needle.len()..];
|
||
let rhs_end = from_colon.find('\n').unwrap_or(from_colon.len());
|
||
let rhs = from_colon[..rhs_end].trim();
|
||
// Expand-from-env requires `${KEY...}` where KEY matches this
|
||
// var's name. Accept both `${KEY:-default}` and `${KEY-default}`
|
||
// (and the bare `${KEY}` form for completeness).
|
||
let expected_prefixes =
|
||
[format!("${{{key}:-"), format!("${{{key}-"), format!("${{{key}}}")];
|
||
if !expected_prefixes.iter().any(|p| rhs.starts_with(p.as_str())) {
|
||
not_interpolated.push(((*key).to_string(), rhs.to_string()));
|
||
}
|
||
}
|
||
missing.sort();
|
||
not_interpolated.sort();
|
||
assert!(
|
||
missing.is_empty(),
|
||
"docker-compose.yml backend service is missing env wiring for: {missing:?}. \
|
||
Add `KEY: ${{KEY:-default}}` lines under backend.environment so the var \
|
||
reaches the container. (Source of truth is .env.example.)"
|
||
);
|
||
assert!(
|
||
not_interpolated.is_empty(),
|
||
"docker-compose.yml backend service has env entries whose RHS does NOT \
|
||
substitute from the matching `${{KEY...}}` placeholder, so an operator \
|
||
setting `KEY=…` in `.env` will be silently ignored. Either fix the RHS \
|
||
to `${{KEY:-default}}` or add the var to `COMPOSE_RHS_EXEMPT` with a \
|
||
rationale. Offenders: {not_interpolated:?}"
|
||
);
|
||
}
|
||
|
||
/// New in 0.87.25: every env::var / env_xxx call in backend/src/
|
||
/// must read a key that is EITHER documented in `.env.example`
|
||
/// (operator-tunable) OR listed in `INTERNAL_NOT_CONFIGURABLE` with
|
||
/// a rationale.
|
||
///
|
||
/// This catches the silent-no-op class of bug from the other side:
|
||
/// a new code path that reads e.g. `ANALYSIS_TEMPERATURE` would
|
||
/// previously compile and run fine, but an operator setting it in
|
||
/// `.env` would see no effect because `.env.example` and compose
|
||
/// never learned about it. Forcing the rationale (operator-facing
|
||
/// or internal-only?) at test time keeps the surface honest.
|
||
#[test]
|
||
fn every_backend_src_env_read_is_documented_or_allowlisted() {
|
||
let env_example = std::fs::read_to_string("../.env.example")
|
||
.expect("read ../.env.example");
|
||
let example_keys = parse_env_example_keys(&env_example);
|
||
let internal_keys: std::collections::HashSet<&str> = INTERNAL_NOT_CONFIGURABLE
|
||
.iter()
|
||
.map(|(k, _)| *k)
|
||
.collect();
|
||
|
||
// Walk backend/src/ for *.rs files.
|
||
let mut files: Vec<std::path::PathBuf> = Vec::new();
|
||
walk_rust_files(std::path::Path::new("src"), &mut files)
|
||
.expect("walk backend/src/");
|
||
|
||
let mut undocumented: std::collections::BTreeMap<String, Vec<String>> =
|
||
std::collections::BTreeMap::new();
|
||
for file in &files {
|
||
let src = std::fs::read_to_string(file).expect("read source");
|
||
for key in extract_env_reads(&src) {
|
||
if example_keys.contains(&key) || internal_keys.contains(key.as_str()) {
|
||
continue;
|
||
}
|
||
undocumented
|
||
.entry(key)
|
||
.or_default()
|
||
.push(file.display().to_string());
|
||
}
|
||
}
|
||
assert!(
|
||
undocumented.is_empty(),
|
||
"backend/src/ reads env vars that are neither in .env.example \
|
||
(operator-tunable) nor in INTERNAL_NOT_CONFIGURABLE (internal-only \
|
||
with rationale). For each entry below, either:\n\
|
||
(a) add it to .env.example AND wire it into docker-compose.yml's \
|
||
backend.environment as `KEY: ${{KEY:-default}}` so operators \
|
||
can override it, OR\n\
|
||
(b) add it to INTERNAL_NOT_CONFIGURABLE in this file with a \
|
||
one-line rationale (system var, dev-only knob, etc.).\n\
|
||
Offenders: {undocumented:#?}"
|
||
);
|
||
}
|
||
|
||
/// Recursively collect `*.rs` paths under `dir`. Test-only helper
|
||
/// for the env-read coverage scan above. Skips `target/` and any
|
||
/// hidden directory just in case.
|
||
fn walk_rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) -> std::io::Result<()> {
|
||
for entry in std::fs::read_dir(dir)? {
|
||
let entry = entry?;
|
||
let path = entry.path();
|
||
let name = entry.file_name();
|
||
let name_str = name.to_string_lossy();
|
||
if name_str.starts_with('.') || name_str == "target" {
|
||
continue;
|
||
}
|
||
if path.is_dir() {
|
||
walk_rust_files(&path, out)?;
|
||
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
|
||
out.push(path);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Find every env-read key in a Rust source string. Matches the
|
||
/// project's env helpers (`env_bool`, `env_i64`, …, `env_prompt`)
|
||
/// and the std calls (`std::env::var`, `std::env::var_os`). Returns
|
||
/// the literal keys passed as the first arg.
|
||
///
|
||
/// Treats `KEY` as `[A-Z0-9_]+` to avoid false positives like
|
||
/// `env::var(some_string)` (not literal). Skips any call with a
|
||
/// non-literal first arg.
|
||
fn extract_env_reads(src: &str) -> Vec<String> {
|
||
const PREFIXES: &[&str] = &[
|
||
"env::var(\"",
|
||
"env::var_os(\"",
|
||
"env_var(\"",
|
||
"env_var_os(\"",
|
||
"env_bool(\"",
|
||
"env_i64(\"",
|
||
"env_f64(\"",
|
||
"env_usize(\"",
|
||
"env_u64(\"",
|
||
"env_u32(\"",
|
||
"env_prompt(\"",
|
||
];
|
||
let mut out = Vec::new();
|
||
for prefix in PREFIXES {
|
||
for (i, _) in src.match_indices(prefix) {
|
||
let rest = &src[i + prefix.len()..];
|
||
let Some(end) = rest.find('"') else { continue };
|
||
let key = &rest[..end];
|
||
if !key.is_empty()
|
||
&& key
|
||
.chars()
|
||
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
|
||
{
|
||
out.push(key.to_string());
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Sanity test for the env-read extractor: hand-coded snippet
|
||
/// must yield the expected keys.
|
||
///
|
||
/// The snippet is built at runtime from format! parts so this
|
||
/// source file doesn't literally contain any prefix-then-key
|
||
/// pattern that the other coverage scan would otherwise pick up
|
||
/// and require allowlisting (the scan is text-based and oblivious
|
||
/// to comments).
|
||
#[test]
|
||
fn extract_env_reads_finds_helper_and_std_calls() {
|
||
let src = format!(
|
||
"let a = std::env::{v}(\"FOO_A\").unwrap();\n\
|
||
let b = std::env::{vo}(\"FOO_B\");\n\
|
||
let c = {eb}(\"FOO_C\", false);\n\
|
||
let d = {ei}(\"FOO_D\", 0);\n\
|
||
let e = {ep}(\"FOO_E\", String::new());\n\
|
||
// Non-literal call must be skipped:\n\
|
||
let _ = std::env::{v}(some_name).unwrap();\n\
|
||
// Mixed-case / lowercase keys are skipped (env vars are upper):\n\
|
||
let _ = std::env::{v}(\"Lowercase\");\n",
|
||
v = "var",
|
||
vo = "var_os",
|
||
eb = "env_bool",
|
||
ei = "env_i64",
|
||
ep = "env_prompt",
|
||
);
|
||
let mut keys = extract_env_reads(&src);
|
||
keys.sort();
|
||
keys.dedup();
|
||
assert_eq!(keys, vec!["FOO_A", "FOO_B", "FOO_C", "FOO_D", "FOO_E"]);
|
||
}
|
||
|
||
/// Extract a single service's block from a docker-compose YAML.
|
||
/// Reads from the line `^ <name>:$` (2-space top-level service
|
||
/// indent) up to the next top-level service header (`^ [A-Za-z…]:$`)
|
||
/// or end of file. Returns `None` if the service isn't found.
|
||
///
|
||
/// Line-based scan instead of `splitn("\n next:\n")` so a service
|
||
/// added between the target and the literal anchor doesn't silently
|
||
/// truncate the block. Tolerates blank lines and comments inside the
|
||
/// block (only the precise 2-space service-header shape ends it).
|
||
fn extract_compose_service_block<'a>(compose: &'a str, name: &str) -> Option<&'a str> {
|
||
let header = format!("\n {name}:\n");
|
||
let start = compose.find(&header).map(|i| i + 1)?; // include the header line
|
||
let body = &compose[start + header.len() - 1..]; // after the trailing \n
|
||
// Find the next 2-space top-level service-key line. The "next"
|
||
// candidate must be: a newline, then exactly two spaces, then a
|
||
// word char (services have alphanumeric names), then anything up
|
||
// to `:` followed by EOL. We scan line-by-line.
|
||
let mut offset: usize = 0;
|
||
for line in body.split('\n') {
|
||
// Detect a top-level service header at 2-space indent. Skip
|
||
// the first line iteration since `start` already points at
|
||
// our own header.
|
||
if offset > 0
|
||
&& line.starts_with(" ")
|
||
&& !line.starts_with(" ")
|
||
&& line.trim_end().ends_with(':')
|
||
&& line.trim_start().chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
||
{
|
||
return Some(&body[..offset.saturating_sub(1)]);
|
||
}
|
||
offset += line.len() + 1; // +1 for the newline
|
||
}
|
||
Some(body)
|
||
}
|
||
|
||
/// Sanity-check the slicer against a hand-built compose so a future
|
||
/// edit to the helper doesn't silently misframe the wiring test.
|
||
#[test]
|
||
fn extract_compose_service_block_finds_named_block() {
|
||
let yaml = "\
|
||
services:
|
||
postgres:
|
||
image: pg
|
||
backend:
|
||
environment:
|
||
KEY: ${KEY:-default}
|
||
expose:
|
||
- \"8080\"
|
||
frontend:
|
||
image: f
|
||
";
|
||
let backend = extract_compose_service_block(yaml, "backend").expect("found");
|
||
assert!(backend.contains("KEY: ${KEY:-default}"));
|
||
// Must NOT bleed into the next service.
|
||
assert!(!backend.contains("frontend"));
|
||
assert!(!backend.contains("image: f"));
|
||
}
|
||
}
|
||
|