Skip to main content

doiget_core/
user_extension.rs

1//! User-extensible capability gate (ADR-0028, #220).
2//!
3//! The single reader for the user's `config.toml`. Parses
4//! `[[network.additional_hosts]]`, the two `[network]` trust flags, and
5//! `[store] root` (#441) — one opener, so no two commands can disagree
6//! about which file they are describing.
7//!
8//! Parses the `[[network.additional_hosts]]` array-of-tables from the
9//! user's `config.toml`, validates each entry against the restricted
10//! ADR-0028 D2-1 pattern grammar, and merges the user-added hosts into
11//! the orchestrator's `oa-publisher` [`crate::http::SourceAllowlist`].
12//!
13//! This module is intentionally minimal — it does NOT layer
14//! `config.toml` with env vars or implement the full
15//! `docs/CONFIG.md` resolution ladder. The full reader is a separate
16//! slice (S3b); this slice ships only the surface needed for
17//! ADR-0028 D2 (user-extensible allowlist) to actually work end-to-end.
18//!
19//! # Wire contract (ADR-0028 D2-1)
20//!
21//! ```toml
22//! [[network.additional_hosts]]
23//! host = "ruj.uj.edu.pl"
24//! note = "Jagiellonian University Repository — Green OA"
25//!
26//! [[network.additional_hosts]]
27//! host = "*.uj.edu.pl"          # single-suffix wildcard, allowed
28//! ```
29//!
30//! Every rejection class enforced by [`validate_pattern`]:
31//!
32//! - empty string
33//! - leading or trailing whitespace
34//! - bare wildcard (`*`)
35//! - multi-segment glob (`*.edu.*`, `*.*`, `*.foo.*`)
36//! - mid-string wildcard (`foo.*.org`, `f*o.bar`, `*foo.bar`)
37//! - no `.` (single-label hostnames)
38//! - non-host characters (`@`, `/`, `:`, port suffixes, scheme prefix)
39//! - empty leading / inner / trailing label
40//! - label starting or ending with `-`
41//!
42//! Each rejection maps to a [`PatternError`] variant for downstream
43//! consumers (S3b `doiget config doctor`) to branch on programmatically.
44//!
45//! # Provenance & doctor (deferred to S3b)
46//!
47//! ADR-0028 D2-2 / D2-3 / D2-4 (the `verified_by = "user"` provenance
48//! field, `doiget config doctor` surface, `doiget capabilities`
49//! `user_extension_count`) ship in the S3b follow-up. The
50//! MCP-server-side merge in `doiget-mcp` is also deferred — this
51//! slice wires user extensions only through the CLI production path
52//! (`commands::fetch::build_http_client`). A user who configures
53//! `[[network.additional_hosts]]` and invokes via `doiget serve`
54//! (MCP) currently sees no effect; that is the load-bearing item
55//! S3b closes.
56
57use camino::{Utf8Path, Utf8PathBuf};
58use serde::Deserialize;
59
60use crate::http::SourceAllowlist;
61
62/// A validated host pattern from `[[network.additional_hosts]]`.
63///
64/// Construction goes through [`HostPattern::new`] (or the
65/// `TryFrom<&str>` / `TryFrom<String>` impls), which run
66/// [`validate_pattern`] internally. The serde `Deserialize` impl
67/// also goes through `TryFrom<String>`, so any path that produces a
68/// `HostPattern` value — including TOML deserialisation — has
69/// passed validation. The invariant "this is a syntactically valid
70/// ADR-0028 D2-1 pattern" is therefore type-level.
71#[derive(Debug, Clone, PartialEq, Eq, Hash)]
72pub struct HostPattern(String);
73
74impl HostPattern {
75    /// Construct after validation. Most callers prefer the
76    /// `TryFrom` impls.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`PatternError`] for any input that fails
81    /// [`validate_pattern`].
82    pub fn new(raw: impl Into<String>) -> Result<Self, PatternError> {
83        let s: String = raw.into();
84        validate_pattern(&s)?;
85        Ok(Self(s))
86    }
87
88    /// Borrow the validated pattern as a `&str`.
89    pub fn as_str(&self) -> &str {
90        &self.0
91    }
92}
93
94impl TryFrom<&str> for HostPattern {
95    type Error = PatternError;
96    fn try_from(value: &str) -> Result<Self, Self::Error> {
97        Self::new(value)
98    }
99}
100
101impl TryFrom<String> for HostPattern {
102    type Error = PatternError;
103    fn try_from(value: String) -> Result<Self, Self::Error> {
104        Self::new(value)
105    }
106}
107
108impl<'de> serde::Deserialize<'de> for HostPattern {
109    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
110        let raw = String::deserialize(d)?;
111        Self::new(raw).map_err(serde::de::Error::custom)
112    }
113}
114
115/// One user-added host entry from `[[network.additional_hosts]]`.
116///
117/// The `note` field is free-text user documentation (e.g.
118/// "Jagiellonian University Repository — Green OA"); it is recorded
119/// in the provenance log alongside the host (S3b) but never consulted
120/// for matching.
121#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
122#[non_exhaustive]
123pub struct UserExtensionHost {
124    /// Validated host pattern (literal FQDN or single-suffix
125    /// wildcard `*.foo.bar`). Construction is type-enforced via
126    /// [`HostPattern`].
127    pub host: HostPattern,
128    /// Optional free-text note.
129    #[serde(default)]
130    pub note: Option<String>,
131}
132
133impl UserExtensionHost {
134    /// Test-only constructor. Production callers go through [`load`].
135    #[cfg(test)]
136    #[allow(clippy::expect_used)]
137    pub(crate) fn for_test(host: &str) -> Self {
138        Self {
139            host: HostPattern::new(host).expect("test host must be valid"),
140            note: None,
141        }
142    }
143}
144
145/// Closed-enum classification of a single pattern's rejection
146/// reason. Used by [`validate_pattern`] and carried as the `kind`
147/// field on [`InvalidPatternIssue`]. S3b's `doiget config doctor`
148/// surface branches on this for actionable per-error rendering.
149#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
150#[non_exhaustive]
151pub enum PatternError {
152    /// Empty string.
153    #[error("empty pattern")]
154    Empty,
155    /// Leading or trailing whitespace.
156    #[error("pattern has leading or trailing whitespace")]
157    Whitespace,
158    /// Bare `*` with no leading-segment suffix.
159    #[error("bare wildcard `*` is not allowed")]
160    BareWildcard,
161    /// `*` appearing anywhere other than the very first character
162    /// followed by `.`.
163    #[error("wildcard `*` is only allowed as the first character followed by `.`")]
164    MisplacedWildcard,
165    /// Multi-segment glob (a second `*` after stripping `*.` prefix).
166    #[error("multi-segment globs are not allowed; use a single `*.<suffix>`")]
167    MultiSegmentGlob,
168    /// Nothing after the `*.` prefix (`*.` alone).
169    #[error("nothing after wildcard prefix `*.`")]
170    EmptySuffix,
171    /// No `.` in the host portion (single-label hostnames rejected).
172    #[error("host must contain at least one `.`")]
173    NoDot,
174    /// A label is empty (consecutive `.`s or leading/trailing `.`).
175    #[error("empty label (consecutive `.` or leading/trailing `.`)")]
176    EmptyLabel,
177    /// A label starts or ends with `-`.
178    #[error("label `{label}` starts or ends with `-`")]
179    LabelHyphenBorder {
180        /// The offending label.
181        label: String,
182    },
183    /// A label contains a character outside the allowed host charset.
184    #[error("label `{label}` contains a non-host character (allowed: A-Z a-z 0-9 - .)")]
185    BadChar {
186        /// The offending label.
187        label: String,
188    },
189}
190
191/// Errors from loading `config.toml`'s user-extension section.
192#[derive(Debug, thiserror::Error)]
193#[non_exhaustive]
194pub enum UserExtensionError {
195    /// Filesystem read failed (other than "not found", which is
196    /// treated as "no user extensions" — see [`load`]).
197    #[error("io reading {path}: {source}")]
198    Io {
199        /// The path we tried to read.
200        path: String,
201        /// The underlying I/O error.
202        #[source]
203        source: std::io::Error,
204    },
205    /// TOML deserialisation failed (malformed file).
206    #[error("toml parse of {path}: {source}")]
207    Parse {
208        /// The path that failed to parse.
209        path: String,
210        /// The underlying TOML deserialiser error.
211        #[source]
212        source: toml::de::Error,
213    },
214    /// One or more `host` patterns failed grammar validation per
215    /// ADR-0028 D2-1. The vec collects every offending entry so the
216    /// user sees all errors in a single pass rather than fixing them
217    /// one at a time (review pass I6).
218    #[error("invalid host pattern(s) in {path}: {issues:?}")]
219    InvalidPatterns {
220        /// The path containing the offending entries.
221        path: String,
222        /// All rejected patterns in this file.
223        issues: Vec<InvalidPatternIssue>,
224    },
225}
226
227/// One per-entry rejection inside [`UserExtensionError::InvalidPatterns`].
228#[derive(Debug, Clone, PartialEq, Eq)]
229#[non_exhaustive]
230pub struct InvalidPatternIssue {
231    /// The raw pattern string from the TOML.
232    pub pattern: String,
233    /// Closed-enum rejection reason.
234    pub kind: PatternError,
235}
236
237impl std::fmt::Display for InvalidPatternIssue {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        write!(f, "`{}`: {}", self.pattern, self.kind)
240    }
241}
242
243/// Load user-extension hosts from a `config.toml` path.
244///
245/// Returns an empty `Vec` if the path does not exist (the user simply
246/// has not extended the gate). Returns `Err` only when the file
247/// exists but cannot be read, parsed, or contains invalid pattern(s).
248///
249/// Every entry in the returned vec has its `host` already validated
250/// at the type level via [`HostPattern`].
251///
252/// # Errors
253///
254/// - [`UserExtensionError::Io`] for filesystem errors other than
255///   not-found.
256/// - [`UserExtensionError::Parse`] for malformed TOML.
257/// - [`UserExtensionError::InvalidPatterns`] for invalid patterns;
258///   the variant carries *every* offending entry, not just the first
259///   (review pass I6).
260pub fn load(config_path: &Utf8Path) -> Result<UserExtensionConfig, UserExtensionError> {
261    let text = match std::fs::read_to_string(config_path.as_std_path()) {
262        Ok(s) => s,
263        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
264            return Ok(UserExtensionConfig::default())
265        }
266        Err(e) => {
267            return Err(UserExtensionError::Io {
268                path: config_path.to_string(),
269                source: e,
270            })
271        }
272    };
273    parse_str(&text, config_path)
274}
275
276/// Raw TOML shape used internally so we can collect every bad
277/// pattern in a single pass (vs failing on the first via
278/// `HostPattern`'s deserialize). The outer config / network tables
279/// tolerate unknown keys via `IgnoredAny`-flatten — the S3b full
280/// reader will own `deny_unknown_fields` on a complete schema.
281#[derive(Debug, Default, Deserialize)]
282struct RawConfig {
283    #[serde(default)]
284    network: Option<RawNetwork>,
285    #[serde(default)]
286    store: Option<RawStore>,
287    #[serde(default)]
288    cite: Option<RawCite>,
289    #[serde(flatten)]
290    _other: serde::de::IgnoredAny,
291}
292
293/// `[cite]` -- defaults for `doiget cite` / `doiget bib` keys (#610).
294#[derive(Debug, Default, Deserialize)]
295struct RawCite {
296    #[serde(default)]
297    key_template: Option<String>,
298    #[serde(default)]
299    file_field: Option<String>,
300    #[serde(flatten)]
301    _other: serde::de::IgnoredAny,
302}
303
304/// `[store]` — read here rather than by a second reader so there is exactly
305/// one answer to "which config.toml" (#441). A separate opener is how
306/// `doiget fetch` and `doiget config doctor` once disagreed about the file
307/// they were describing.
308#[derive(Debug, Default, Deserialize)]
309struct RawStore {
310    /// `[store] root` — the central library path. Documented in
311    /// `docs/CONFIG.md` §3 and ADR-0036 since 0.7, and parsed by nobody
312    /// until #441: `resolve_store_root` read the env var and then fell
313    /// straight through to the cwd default, so the file rung the docs,
314    /// `config init` and `config doctor` all pointed at did nothing.
315    #[serde(default)]
316    root: Option<String>,
317    #[serde(flatten)]
318    _other: serde::de::IgnoredAny,
319}
320
321#[derive(Debug, Default, Deserialize)]
322struct RawNetwork {
323    #[serde(default)]
324    additional_hosts: Vec<RawHost>,
325    /// `[network] trust_academic_repos = true` — activates the built-in curated
326    /// academic institution allowlist (issue #323). See [`academic_repo_hosts`].
327    #[serde(default)]
328    trust_academic_repos: bool,
329    /// `[network] trust_oa_registries = true` — activates the built-in curated
330    /// open-access registry allowlist (issue #405). See [`oa_registry_hosts`].
331    #[serde(default)]
332    trust_oa_registries: bool,
333    /// `[network] contact_email` — the polite-pool contact address (#504).
334    #[serde(default)]
335    contact_email: Option<String>,
336    /// `[network] unpaywall_email` — written by `config init` since 0.7 and
337    /// parsed by nobody until #504, so every fetch on a machine configured
338    /// from the template went out as `doiget@localhost`.
339    #[serde(default)]
340    unpaywall_email: Option<String>,
341    #[serde(flatten)]
342    _other: serde::de::IgnoredAny,
343}
344
345/// The `[[network.additional_hosts]]` table itself is the load-
346/// bearing schema and DOES forbid unknown keys — that's the level
347/// where typos like `hsot = "..."` should fail loudly.
348#[derive(Debug, Deserialize)]
349#[serde(deny_unknown_fields)]
350struct RawHost {
351    host: String,
352    #[serde(default)]
353    note: Option<String>,
354}
355
356/// Parsed result from a `config.toml` user-extension section (issue #323).
357///
358/// Returned by [`load`]; callers merge [`Self::additional_hosts`] into the
359/// allowlist unconditionally, and optionally merge [`academic_repo_hosts`]
360/// when [`Self::trust_academic_repos`] is `true`.
361#[derive(Debug, Default)]
362#[non_exhaustive]
363pub struct UserExtensionConfig {
364    /// Hosts from `[[network.additional_hosts]]`.
365    pub additional_hosts: Vec<UserExtensionHost>,
366    /// `true` when `[network] trust_academic_repos = true` is set in
367    /// `config.toml`. Callers that respect this flag SHOULD merge
368    /// [`academic_repo_hosts`] into their allowlist.
369    pub trust_academic_repos: bool,
370    /// `true` when `[network] trust_oa_registries = true` is set in
371    /// `config.toml`. Callers that respect this flag SHOULD merge
372    /// [`oa_registry_hosts`] into their allowlist.
373    pub trust_oa_registries: bool,
374    /// `[store] root`, verbatim, when present and non-empty (#441).
375    ///
376    /// Returned as the raw string rather than a path: expanding it needs a
377    /// home directory, and this crate has no home-directory dependency.
378    /// The CLI and MCP resolvers own that step, and both place this rung
379    /// below `DOIGET_STORE_ROOT` and above the cwd default (ADR-0036).
380    pub store_root: Option<String>,
381    /// `[network] contact_email`, trimmed, when present and non-empty
382    /// (#504). Rung below `DOIGET_CONTACT_EMAIL`.
383    pub contact_email: Option<String>,
384    /// `[network] unpaywall_email`, trimmed, when present and non-empty
385    /// (#504). Rung below `DOIGET_UNPAYWALL_EMAIL`, itself above
386    /// [`Self::contact_email`].
387    pub unpaywall_email: Option<String>,
388    /// `[cite] key_template` -- the default `--key-template` (#610). Blank
389    /// is absent.
390    pub cite_key_template: Option<String>,
391    /// `[cite] file_field` -- the default `--file-field` pattern (#610).
392    pub cite_file_field: Option<String>,
393}
394
395/// Returns the built-in curated set of academic institution host patterns.
396///
397/// Activated via `[network] trust_academic_repos = true` in `config.toml`
398/// (issue #323). Each entry is a single-suffix wildcard matching the
399/// TLD-style registration block used by institutions in that country.
400///
401/// All patterns are valid per [`validate_pattern`] (single-suffix wildcards
402/// only; no multi-segment globs). The set is intentionally conservative —
403/// covering major national academic TLD patterns used for institutional
404/// Green OA repositories — while keeping the security posture minimal.
405pub fn academic_repo_hosts() -> Vec<UserExtensionHost> {
406    const PATTERNS: &[(&str, &str)] = &[
407        ("*.ac.uk", "UK academic institutions (Universities UK)"),
408        ("*.ac.jp", "Japanese academic institutions (NII)"),
409        ("*.jst.go.jp", "J-STAGE / JST academic platform (Japan)"),
410        ("*.edu.au", "Australian universities (TEQSA)"),
411        ("*.edu.cn", "Chinese universities (MoE)"),
412        ("*.ac.cn", "Chinese academic institutions"),
413        ("*.edu.pl", "Polish universities (MEiN)"),
414        ("*.ac.nz", "New Zealand universities"),
415        ("*.ac.za", "South African universities (DHET)"),
416        ("*.ac.in", "Indian academic institutions"),
417        ("*.edu.br", "Brazilian universities (CAPES)"),
418        ("*.edu.tw", "Taiwanese universities (MoE)"),
419        ("*.edu.tr", "Turkish universities (YÖK)"),
420        ("*.edu.ar", "Argentine universities (SPU)"),
421        ("*.edu.mx", "Mexican universities (SEP)"),
422    ];
423    PATTERNS
424        .iter()
425        .filter_map(|(pat, note)| {
426            HostPattern::new(*pat).ok().map(|host| UserExtensionHost {
427                host,
428                note: Some(note.to_string()),
429            })
430        })
431        .collect()
432}
433
434/// Returns the built-in curated set of open-access **registry** host
435/// patterns.
436///
437/// Activated via `[network] trust_oa_registries = true` in `config.toml`
438/// (issue #405). Companion to [`academic_repo_hosts`], and deliberately a
439/// separate flag: that set is "institutions publish their own Green OA
440/// here", this one is "cross-publisher registries and repositories index
441/// or host Gold OA here". They are different trust arguments, so a user
442/// can take either without the other.
443///
444/// Why this exists: the default allowlist covers publishers, so a Green-OA
445/// copy on an institutional repository was reachable behind one flag while
446/// cross-publisher OA registries were not reachable at all.
447///
448/// DOAJ is deliberately NOT in this set. ADR-0037 promoted `doaj.org` to the
449/// default `oa_publisher_allowlist` unconditionally, because the project had
450/// already trusted it under the `"doaj"` metadata key and the two keys simply
451/// disagreed. The hosts below are different: none of them appears anywhere in
452/// `http.rs`, so for them this flag is genuinely new trust and opt-in is
453/// correct.
454///
455/// Every entry is a registry or repository whose *purpose* is open
456/// distribution -- or, since ADR-0066, a national open-access platform
457/// whose default is free access (J-STAGE) -- and never a commercial
458/// publisher platform: enabling this must not become a way to reach
459/// paywalled content. Both the apex and the `*.` wildcard
460/// are listed where the apex itself serves content: a single-suffix
461/// wildcard does not match the apex ([`validate_pattern`]), and the DOAJ
462/// redirect in #405 targeted the bare apex.
463pub fn oa_registry_hosts() -> Vec<UserExtensionHost> {
464    const PATTERNS: &[(&str, &str)] = &[
465        ("scielo.org", "SciELO — Latin American / Iberian OA network"),
466        ("*.scielo.org", "SciELO national portals"),
467        ("*.scielo.br", "SciELO Brazil"),
468        ("zenodo.org", "Zenodo — CERN general-purpose OA repository"),
469        ("*.zenodo.org", "Zenodo subdomains"),
470        ("osf.io", "OSF / OSF Preprints (Center for Open Science)"),
471        ("*.osf.io", "OSF preprint servers (psyarxiv, socarxiv, ...)"),
472        ("hal.science", "HAL — French national OA repository"),
473        ("*.hal.science", "HAL institutional portals"),
474        (
475            "core.ac.uk",
476            "CORE — OA aggregator (Open University / Jisc)",
477        ),
478        // #646 / ADR-0066: a national platform whose default is free access.
479        // Unpaywall reports its PDFs as gold / bronze; a restricted article
480        // answers an HTML login page, which the %PDF- check refuses.
481        (
482            "www.jstage.jst.go.jp",
483            "J-STAGE — Japan's national journal platform (JST)",
484        ),
485    ];
486    PATTERNS
487        .iter()
488        .filter_map(|(pat, note)| {
489            HostPattern::new(*pat).ok().map(|host| UserExtensionHost {
490                host,
491                note: Some(note.to_string()),
492            })
493        })
494        .collect()
495}
496
497/// Parse a `config.toml` body string. Pure function; the path is used
498/// only for error messages.
499fn parse_str(
500    text: &str,
501    config_path: &Utf8Path,
502) -> Result<UserExtensionConfig, UserExtensionError> {
503    let raw: RawConfig = toml::from_str(text).map_err(|e| UserExtensionError::Parse {
504        path: config_path.to_string(),
505        source: e,
506    })?;
507    let raw_net = raw.network.unwrap_or_default();
508    let trust_academic_repos = raw_net.trust_academic_repos;
509    let trust_oa_registries = raw_net.trust_oa_registries;
510    // Same "blank means unset" rule as `[store] root` below: an empty
511    // address would be sent as an empty `mailto:` and read to the upstream
512    // as no contact at all, which is the state the field exists to avoid.
513    let trim_nonempty =
514        |v: Option<String>| v.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
515    let contact_email = trim_nonempty(raw_net.contact_email);
516    let unpaywall_email = trim_nonempty(raw_net.unpaywall_email);
517    let raw_hosts = raw_net.additional_hosts;
518
519    // Two-phase: collect ALL invalid patterns rather than failing on
520    // the first. Saves the user an iterative edit-run-error cycle
521    // when migrating a large additional_hosts block (review pass I6).
522    let mut issues = Vec::new();
523    let mut validated = Vec::with_capacity(raw_hosts.len());
524    for raw_host in raw_hosts {
525        match HostPattern::new(raw_host.host.clone()) {
526            Ok(host) => validated.push(UserExtensionHost {
527                host,
528                note: raw_host.note,
529            }),
530            Err(kind) => issues.push(InvalidPatternIssue {
531                pattern: raw_host.host,
532                kind,
533            }),
534        }
535    }
536    if !issues.is_empty() {
537        return Err(UserExtensionError::InvalidPatterns {
538            path: config_path.to_string(),
539            issues,
540        });
541    }
542    // An empty `root = ""` is treated as absent rather than as "the empty
543    // path": it would otherwise resolve to a store at the filesystem root
544    // or to a panic downstream, and a blank value plainly means "unset".
545    let store_root = raw
546        .store
547        .and_then(|st| st.root)
548        .map(|r| r.trim().to_string())
549        .filter(|r| !r.is_empty());
550
551    let nonblank = |v: Option<String>| v.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
552    let (cite_key_template, cite_file_field) = match raw.cite {
553        Some(c) => (nonblank(c.key_template), nonblank(c.file_field)),
554        None => (None, None),
555    };
556
557    Ok(UserExtensionConfig {
558        additional_hosts: validated,
559        trust_academic_repos,
560        trust_oa_registries,
561        store_root,
562        contact_email,
563        unpaywall_email,
564        cite_key_template,
565        cite_file_field,
566    })
567}
568
569/// Why the config directory could not be resolved.
570#[derive(Debug, thiserror::Error)]
571#[non_exhaustive]
572pub enum ConfigDirError {
573    /// An environment variable is set to a non-UTF-8 value.
574    ///
575    /// An error rather than a fall-through: silently trying the next rung
576    /// would resolve a *different* file from the one the user configured,
577    /// and report success.
578    #[error("{key} is not valid UTF-8")]
579    NotUnicode {
580        /// The offending variable.
581        key: &'static str,
582    },
583    /// None of `XDG_CONFIG_HOME`, `APPDATA`, `HOME` or `USERPROFILE` is set.
584    #[error("neither HOME nor USERPROFILE is set")]
585    NoHome,
586}
587
588/// Read an env var, treating blank as unset and non-UTF-8 as an error.
589fn env_utf8(key: &'static str) -> Result<Option<String>, ConfigDirError> {
590    match std::env::var(key) {
591        Ok(s) if s.is_empty() => Ok(None),
592        Ok(s) => Ok(Some(s)),
593        Err(std::env::VarError::NotPresent) => Ok(None),
594        Err(std::env::VarError::NotUnicode(_)) => Err(ConfigDirError::NotUnicode { key }),
595    }
596}
597
598/// Config-directory resolution: `XDG_CONFIG_HOME`, then `APPDATA`
599/// (Windows), then `$HOME/.config`.
600///
601/// **The single source of truth.** This existed as two hand-maintained
602/// copies — `doiget-cli::commands::fetch::config_dir_utf8` and
603/// `doiget-mcp::config_dir_utf8` — whose own doc comment warned that
604/// divergence "would silently desync the user-extension allowlist
605/// surfaces". They had already diverged: the CLI copy accepted
606/// `XDG_CONFIG_HOME=""` and resolved a *relative* `doiget/config.toml`
607/// under the cwd, while the MCP copy treated blank as unset. Blank-is-
608/// unset is the answer kept here, because a relative config path is a
609/// silent wrong answer of exactly the #441 / #504 kind.
610///
611/// It lives in `doiget-core` because the fetch path needs it: with the
612/// resolver one crate up, `orchestrator` could not read `config.toml` at
613/// all, so every file-based setting had to be re-implemented in the CLI
614/// or go unread. `[network] unpaywall_email` went unread for four
615/// releases for that reason (#504).
616///
617/// No `dirs` dependency — every new dep adds cargo-vet exemption churn,
618/// and `expand_store_root` below already reads `HOME` directly.
619///
620/// # Errors
621///
622/// [`ConfigDirError::NotUnicode`] if a consulted variable is set to a
623/// non-UTF-8 value; [`ConfigDirError::NoHome`] if none is set.
624pub fn config_dir() -> Result<Utf8PathBuf, ConfigDirError> {
625    if let Some(s) = env_utf8("XDG_CONFIG_HOME")? {
626        return Ok(Utf8PathBuf::from(s));
627    }
628    if let Some(s) = env_utf8("APPDATA")? {
629        return Ok(Utf8PathBuf::from(s));
630    }
631    if let Some(s) = env_utf8("HOME")? {
632        return Ok(Utf8PathBuf::from(s).join(".config"));
633    }
634    if let Some(s) = env_utf8("USERPROFILE")? {
635        return Ok(Utf8PathBuf::from(s).join(".config"));
636    }
637    Err(ConfigDirError::NoHome)
638}
639
640/// `<config_dir>/doiget/config.toml` — the one file every reader means.
641///
642/// # Errors
643///
644/// As [`config_dir`].
645pub fn config_path() -> Result<Utf8PathBuf, ConfigDirError> {
646    Ok(config_dir()?.join("doiget").join("config.toml"))
647}
648
649/// The `config.toml` settings, or defaults when it is missing or
650/// unreadable.
651///
652/// Never fails: a caller on the fetch path wants the setting or the
653/// default, and one bad line in a config file must not take a fetch down.
654/// A read or parse failure is logged at `warn` — not swallowed — for the
655/// reason `store_root_from_config` records: TOML fails the whole document,
656/// so a typo anywhere makes every file rung disappear at once, and the
657/// resulting behaviour is indistinguishable from "nothing was configured".
658#[must_use]
659pub fn load_or_default() -> UserExtensionConfig {
660    let path = match config_path() {
661        Ok(p) => p,
662        Err(e) => {
663            tracing::debug!(error = %e, "no config directory; config.toml not read");
664            return UserExtensionConfig::default();
665        }
666    };
667    match load(&path) {
668        Ok(c) => c,
669        Err(e) => {
670            tracing::warn!(
671                path = %path,
672                error = %e,
673                "config.toml could not be read; its settings are ignored and defaults used \
674                 instead. Run `doiget config doctor` to see what is in effect."
675            );
676            UserExtensionConfig::default()
677        }
678    }
679}
680
681/// Turn a raw `[store] root` value into a path, expanding a leading `~`.
682///
683/// A config file has no shell, so `~/papers` written there would otherwise
684/// become a literal directory named `~` next to the cwd — a silent wrong
685/// answer of exactly the kind #441 was about. `DOIGET_STORE_ROOT` needs no
686/// equivalent because the shell expands it before the process starts.
687///
688/// Kept out of the parser so parsing stays a pure function of the file
689/// text; both the CLI and the MCP resolver call this at the point of use,
690/// which is also what keeps their two answers identical.
691///
692/// Falls back to the value verbatim when neither `HOME` nor `USERPROFILE`
693/// is set — a wrong path the user can see beats a silent substitution.
694#[must_use]
695pub fn expand_store_root(raw: &str) -> Utf8PathBuf {
696    let rest = match raw.strip_prefix('~') {
697        Some(r) => r,
698        None => return Utf8PathBuf::from(raw),
699    };
700    // Only `~` and `~/...` — `~alice/...` is another user's home, which is
701    // not something this can resolve, so it is left verbatim.
702    // A backslash char literal is written as a unicode escape so no tool
703    // that rewrites this file can mangle it.
704    if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with('\u{5c}')) {
705        return Utf8PathBuf::from(raw);
706    }
707    let home = std::env::var("HOME")
708        .ok()
709        .filter(|h| !h.is_empty())
710        .or_else(|| std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty()));
711    match home {
712        Some(h) => {
713            let trimmed = rest.trim_start_matches(['/', '\u{5c}']);
714            if trimmed.is_empty() {
715                Utf8PathBuf::from(h)
716            } else {
717                Utf8PathBuf::from(h).join(trimmed)
718            }
719        }
720        None => Utf8PathBuf::from(raw),
721    }
722}
723
724/// Validate a host pattern per ADR-0028 D2-1.
725///
726/// Accepted shapes:
727///
728/// - Literal FQDN: `example.org`, `ruj.uj.edu.pl`. Only
729///   `[A-Za-z0-9.-]`, at least one `.`, no empty / hyphen-bordering
730///   labels.
731/// - Single-suffix wildcard: `*.example.org`. The `*` MUST be the
732///   first character, MUST be followed by `.`, and MUST be the only
733///   `*` in the pattern.
734///
735/// # Errors
736///
737/// Returns the closed-enum [`PatternError`] for any rejected input.
738pub fn validate_pattern(pattern: &str) -> Result<(), PatternError> {
739    if pattern.is_empty() {
740        return Err(PatternError::Empty);
741    }
742    if pattern.trim() != pattern {
743        return Err(PatternError::Whitespace);
744    }
745    if pattern == "*" {
746        return Err(PatternError::BareWildcard);
747    }
748    let body = match pattern.strip_prefix("*.") {
749        Some(rest) => {
750            // A `*` in the body after stripping `*.` means a second
751            // wildcard (e.g. `*.edu.*` → body = `edu.*`).
752            if rest.contains('*') {
753                return Err(PatternError::MultiSegmentGlob);
754            }
755            rest
756        }
757        None if pattern.contains('*') => {
758            // A `*` not in leading `*.` position (e.g. `foo.*.org`,
759            // `f*o.bar`, `*foo.bar`).
760            return Err(PatternError::MisplacedWildcard);
761        }
762        None => pattern,
763    };
764    if body.is_empty() {
765        return Err(PatternError::EmptySuffix);
766    }
767    validate_fqdn(body)
768}
769
770fn validate_fqdn(body: &str) -> Result<(), PatternError> {
771    if !body.contains('.') {
772        return Err(PatternError::NoDot);
773    }
774    for label in body.split('.') {
775        if label.is_empty() {
776            return Err(PatternError::EmptyLabel);
777        }
778        if label.starts_with('-') || label.ends_with('-') {
779            return Err(PatternError::LabelHyphenBorder {
780                label: label.to_string(),
781            });
782        }
783        if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
784            return Err(PatternError::BadChar {
785                label: label.to_string(),
786            });
787        }
788    }
789    Ok(())
790}
791
792/// Merge a slice of [`UserExtensionHost`] into the `oa-publisher`
793/// entry of an existing allowlist vector.
794///
795/// Duplicates are de-duplicated against the existing `redirect_hosts`
796/// to keep the host list minimal and the future `verified_by`
797/// provenance count honest (review pass I9). If the vector contains
798/// no `oa-publisher` entry, one is created.
799///
800/// A no-op when `user_hosts` is empty.
801///
802/// The `note` field is intentionally dropped at the merge boundary —
803/// it remains on [`UserExtensionHost`] for S3b's provenance plumbing
804/// to consume from the same parsed vector.
805pub fn merge_into_allowlists(
806    allowlists: &mut Vec<SourceAllowlist>,
807    user_hosts: &[UserExtensionHost],
808) {
809    if user_hosts.is_empty() {
810        return;
811    }
812    if let Some(oa) = allowlists.iter_mut().find(|a| a.source == "oa-publisher") {
813        for h in user_hosts {
814            let s = h.host.as_str();
815            if !oa.redirect_hosts.iter().any(|p| p == s) {
816                oa.redirect_hosts.push(s.to_string());
817            }
818        }
819        return;
820    }
821    let mut new_patterns: Vec<String> = Vec::with_capacity(user_hosts.len());
822    for h in user_hosts {
823        let s = h.host.as_str().to_string();
824        if !new_patterns.contains(&s) {
825            new_patterns.push(s);
826        }
827    }
828    allowlists.push(SourceAllowlist::new("oa-publisher", new_patterns));
829}
830
831#[cfg(test)]
832#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
833mod tests {
834    use super::*;
835
836    fn p(s: &str) -> &Utf8Path {
837        Utf8Path::new(s)
838    }
839
840    // ---- validate_pattern (typed PatternError) ----------------------
841
842    #[test]
843    fn validate_pattern_accepts_literal_fqdn() {
844        assert!(validate_pattern("ruj.uj.edu.pl").is_ok());
845        assert!(validate_pattern("example.org").is_ok());
846        assert!(validate_pattern("a.b.c.d.e").is_ok());
847    }
848
849    #[test]
850    fn validate_pattern_accepts_single_suffix_wildcard() {
851        assert!(validate_pattern("*.uj.edu.pl").is_ok());
852        assert!(validate_pattern("*.aps.org").is_ok());
853    }
854
855    #[test]
856    fn validate_pattern_rejects_empty() {
857        assert_eq!(validate_pattern(""), Err(PatternError::Empty));
858    }
859
860    #[test]
861    fn validate_pattern_rejects_whitespace() {
862        assert_eq!(
863            validate_pattern("  example.org"),
864            Err(PatternError::Whitespace)
865        );
866        assert_eq!(
867            validate_pattern("example.org  "),
868            Err(PatternError::Whitespace)
869        );
870    }
871
872    #[test]
873    fn validate_pattern_rejects_bare_wildcard() {
874        assert_eq!(validate_pattern("*"), Err(PatternError::BareWildcard));
875    }
876
877    #[test]
878    fn validate_pattern_rejects_multi_segment_globs() {
879        for bad in ["*.edu.*", "*.ac.*", "*.*", "*.example.*"] {
880            assert_eq!(
881                validate_pattern(bad),
882                Err(PatternError::MultiSegmentGlob),
883                "{bad} should be MultiSegmentGlob"
884            );
885        }
886    }
887
888    #[test]
889    fn validate_pattern_rejects_misplaced_wildcards() {
890        for bad in ["foo.*.org", "f*o.bar", "*foo.bar"] {
891            assert_eq!(
892                validate_pattern(bad),
893                Err(PatternError::MisplacedWildcard),
894                "{bad} should be MisplacedWildcard"
895            );
896        }
897    }
898
899    #[test]
900    fn validate_pattern_rejects_non_host_chars() {
901        for bad in ["user@host.com", "host.com/", "host.com:80", "https://x.y"] {
902            assert!(
903                matches!(
904                    validate_pattern(bad),
905                    Err(PatternError::BadChar { .. }) | Err(PatternError::EmptyLabel)
906                ),
907                "{bad} should be BadChar or EmptyLabel; got {:?}",
908                validate_pattern(bad)
909            );
910        }
911    }
912
913    #[test]
914    fn validate_pattern_rejects_no_dot() {
915        assert_eq!(validate_pattern("singlelabel"), Err(PatternError::NoDot));
916    }
917
918    #[test]
919    fn validate_pattern_rejects_empty_label_classes() {
920        for bad in [".example.org", "example..org", "example.org."] {
921            assert_eq!(
922                validate_pattern(bad),
923                Err(PatternError::EmptyLabel),
924                "{bad} should be EmptyLabel"
925            );
926        }
927    }
928
929    #[test]
930    fn validate_pattern_rejects_hyphen_bordering_labels() {
931        for (bad, label) in [
932            ("-foo.example.org", "-foo"),
933            ("foo.-example.org", "-example"),
934            ("foo.example-.org", "example-"),
935        ] {
936            assert_eq!(
937                validate_pattern(bad),
938                Err(PatternError::LabelHyphenBorder {
939                    label: label.to_string()
940                }),
941                "{bad} should be LabelHyphenBorder({label})"
942            );
943        }
944    }
945
946    #[test]
947    fn validate_pattern_rejects_empty_suffix_after_wildcard() {
948        assert_eq!(validate_pattern("*."), Err(PatternError::EmptySuffix));
949    }
950
951    // ---- HostPattern type ------------------------------------------
952
953    #[test]
954    fn host_pattern_new_validates() {
955        assert!(HostPattern::new("ruj.uj.edu.pl").is_ok());
956        assert_eq!(HostPattern::new(""), Err(PatternError::Empty));
957    }
958
959    #[test]
960    fn host_pattern_try_from_str_and_string() {
961        let from_str: HostPattern = "*.aps.org".try_into().expect("ok");
962        let from_string: HostPattern = String::from("*.aps.org").try_into().expect("ok");
963        assert_eq!(from_str, from_string);
964    }
965
966    #[test]
967    fn host_pattern_deserialize_validates() {
968        // The serde impl runs validate_pattern; an invalid value
969        // fails deserialisation (review pass C1 — the invariant is
970        // type-level, not only enforced post-deserialise).
971        let bad = toml::from_str::<HostPattern>("\"*.edu.*\"");
972        assert!(bad.is_err(), "TOML deserialize MUST validate the pattern");
973    }
974
975    // ---- parse_str -------------------------------------------------
976
977    #[test]
978    fn parse_empty_config_returns_no_hosts() {
979        let cfg = parse_str("", p("config.toml")).unwrap();
980        assert_eq!(cfg.additional_hosts, vec![]);
981        assert!(!cfg.trust_academic_repos);
982    }
983
984    #[test]
985    fn parse_config_without_network_section_returns_no_hosts() {
986        let toml = r#"
987            [store]
988            root = "/tmp"
989        "#;
990        let cfg = parse_str(toml, p("config.toml")).unwrap();
991        assert_eq!(cfg.additional_hosts, vec![]);
992        assert!(!cfg.trust_academic_repos);
993    }
994
995    #[test]
996    fn parse_config_with_unknown_network_fields_is_accepted() {
997        // S3b's full reader will own deny_unknown_fields on a
998        // complete schema; until then we tolerate the rest of the
999        // [network] table (`contact_email`, future `cooldown_ms`,
1000        // etc.) so an existing user config still loads.
1001        let toml = r#"
1002            [network]
1003            contact_email = "x@y.org"
1004            cooldown_ms = 250
1005        "#;
1006        let cfg = parse_str(toml, p("config.toml")).unwrap();
1007        assert_eq!(cfg.additional_hosts, vec![]);
1008        assert!(!cfg.trust_academic_repos);
1009    }
1010
1011    #[test]
1012    fn parse_rejects_unknown_field_inside_additional_hosts_entry() {
1013        // The [[network.additional_hosts]] table itself uses
1014        // deny_unknown_fields, so typos and stray keys surface as a
1015        // parse error (review pass I5). The unknown-key string is
1016        // chosen so the typos lint doesn't fight us — `notez`
1017        // doesn't trip any English-word dictionary while still
1018        // exercising the deny_unknown_fields code path.
1019        let toml = r#"
1020            [[network.additional_hosts]]
1021            host = "ruj.uj.edu.pl"
1022            notez = "typo"
1023        "#;
1024        let err = parse_str(toml, p("config.toml")).expect_err("typo must fail");
1025        assert!(matches!(err, UserExtensionError::Parse { .. }));
1026    }
1027
1028    #[test]
1029    fn parse_one_literal_host_with_note() {
1030        let toml = r#"
1031            [[network.additional_hosts]]
1032            host = "ruj.uj.edu.pl"
1033            note = "Jagiellonian University Repository"
1034        "#;
1035        let got = parse_str(toml, p("config.toml")).unwrap();
1036        assert_eq!(got.additional_hosts.len(), 1);
1037        assert_eq!(got.additional_hosts[0].host.as_str(), "ruj.uj.edu.pl");
1038        assert_eq!(
1039            got.additional_hosts[0].note.as_deref(),
1040            Some("Jagiellonian University Repository")
1041        );
1042    }
1043
1044    #[test]
1045    fn parse_multiple_hosts_mixed_literal_and_wildcard() {
1046        let toml = r#"
1047            [[network.additional_hosts]]
1048            host = "ruj.uj.edu.pl"
1049
1050            [[network.additional_hosts]]
1051            host = "*.aps.org"
1052            note = "user override"
1053        "#;
1054        let got = parse_str(toml, p("config.toml")).unwrap();
1055        assert_eq!(got.additional_hosts.len(), 2);
1056        assert_eq!(got.additional_hosts[0].host.as_str(), "ruj.uj.edu.pl");
1057        assert!(got.additional_hosts[0].note.is_none());
1058        assert_eq!(got.additional_hosts[1].host.as_str(), "*.aps.org");
1059        assert_eq!(
1060            got.additional_hosts[1].note.as_deref(),
1061            Some("user override")
1062        );
1063    }
1064
1065    #[test]
1066    fn parse_collects_all_invalid_patterns_not_just_first() {
1067        // Review pass I6: every offender appears in the error.
1068        let toml = r#"
1069            [[network.additional_hosts]]
1070            host = "*.edu.*"
1071
1072            [[network.additional_hosts]]
1073            host = "ok.example.org"
1074
1075            [[network.additional_hosts]]
1076            host = "user@host.com"
1077        "#;
1078        let err = parse_str(toml, p("/home/u/.config/doiget/config.toml"))
1079            .expect_err("invalid patterns must error");
1080        match err {
1081            UserExtensionError::InvalidPatterns { path, issues } => {
1082                assert_eq!(path, "/home/u/.config/doiget/config.toml");
1083                assert_eq!(issues.len(), 2, "both bad patterns collected");
1084                assert_eq!(issues[0].pattern, "*.edu.*");
1085                assert_eq!(issues[0].kind, PatternError::MultiSegmentGlob);
1086                assert_eq!(issues[1].pattern, "user@host.com");
1087                assert!(matches!(
1088                    issues[1].kind,
1089                    PatternError::BadChar { .. } | PatternError::EmptyLabel
1090                ));
1091            }
1092            other => panic!("expected InvalidPatterns, got {other:?}"),
1093        }
1094    }
1095
1096    #[test]
1097    fn parse_rejects_malformed_toml() {
1098        let err = parse_str("[[network.additional_hosts\nhost=\"foo\"", p("config.toml"))
1099            .expect_err("malformed toml must error");
1100        assert!(matches!(err, UserExtensionError::Parse { .. }));
1101    }
1102
1103    // ---- load -------------------------------------------------------
1104
1105    #[test]
1106    fn load_returns_empty_when_file_missing() {
1107        let td = tempfile::TempDir::new().unwrap();
1108        let path = Utf8Path::from_path(td.path()).unwrap().join("missing.toml");
1109        let got = load(&path).expect("missing file MUST be Ok(empty)");
1110        assert_eq!(got.additional_hosts, vec![]);
1111        assert!(!got.trust_academic_repos);
1112    }
1113
1114    #[test]
1115    fn load_reads_real_file() {
1116        use std::io::Write;
1117        let td = tempfile::TempDir::new().unwrap();
1118        let path = Utf8Path::from_path(td.path()).unwrap().join("config.toml");
1119        let mut f = std::fs::File::create(path.as_std_path()).unwrap();
1120        f.write_all(
1121            br#"
1122[[network.additional_hosts]]
1123host = "ruj.uj.edu.pl"
1124note = "Jagiellonian"
1125"#,
1126        )
1127        .unwrap();
1128        let got = load(&path).expect("ok");
1129        assert_eq!(got.additional_hosts.len(), 1);
1130        assert_eq!(got.additional_hosts[0].host.as_str(), "ruj.uj.edu.pl");
1131    }
1132
1133    // ---- merge_into_allowlists -------------------------------------
1134
1135    #[test]
1136    fn merge_appends_to_existing_oa_publisher_entry() {
1137        let mut allowlists = vec![
1138            SourceAllowlist::new("crossref", vec!["api.crossref.org".into()]),
1139            SourceAllowlist::new("oa-publisher", vec!["pmc.ncbi.nlm.nih.gov".into()]),
1140        ];
1141        let user_hosts = vec![UserExtensionHost::for_test("ruj.uj.edu.pl")];
1142        merge_into_allowlists(&mut allowlists, &user_hosts);
1143
1144        let oa = allowlists
1145            .iter()
1146            .find(|a| a.source == "oa-publisher")
1147            .unwrap();
1148        assert_eq!(
1149            oa.redirect_hosts,
1150            vec![
1151                "pmc.ncbi.nlm.nih.gov".to_string(),
1152                "ruj.uj.edu.pl".to_string()
1153            ]
1154        );
1155        assert_eq!(allowlists.len(), 2);
1156    }
1157
1158    #[test]
1159    fn merge_creates_oa_publisher_entry_if_missing() {
1160        let mut allowlists = vec![SourceAllowlist::new(
1161            "crossref",
1162            vec!["api.crossref.org".into()],
1163        )];
1164        let user_hosts = vec![UserExtensionHost::for_test("ruj.uj.edu.pl")];
1165        merge_into_allowlists(&mut allowlists, &user_hosts);
1166        assert_eq!(allowlists.len(), 2);
1167        let oa = allowlists
1168            .iter()
1169            .find(|a| a.source == "oa-publisher")
1170            .unwrap();
1171        assert_eq!(oa.redirect_hosts, vec!["ruj.uj.edu.pl".to_string()]);
1172    }
1173
1174    #[test]
1175    fn merge_is_noop_on_empty_user_hosts() {
1176        let mut allowlists = vec![SourceAllowlist::new(
1177            "crossref",
1178            vec!["api.crossref.org".into()],
1179        )];
1180        let snapshot: Vec<(String, Vec<String>)> = allowlists
1181            .iter()
1182            .map(|a| (a.source.clone(), a.redirect_hosts.clone()))
1183            .collect();
1184        merge_into_allowlists(&mut allowlists, &[]);
1185        let after: Vec<(String, Vec<String>)> = allowlists
1186            .iter()
1187            .map(|a| (a.source.clone(), a.redirect_hosts.clone()))
1188            .collect();
1189        assert_eq!(snapshot, after);
1190    }
1191
1192    #[test]
1193    fn merge_dedupes_against_existing_entries() {
1194        // Review pass I9.
1195        let mut allowlists = vec![SourceAllowlist::new(
1196            "oa-publisher",
1197            vec!["ruj.uj.edu.pl".into()],
1198        )];
1199        let user_hosts = vec![
1200            UserExtensionHost::for_test("ruj.uj.edu.pl"),
1201            UserExtensionHost::for_test("*.uj.edu.pl"),
1202            UserExtensionHost::for_test("*.uj.edu.pl"),
1203        ];
1204        merge_into_allowlists(&mut allowlists, &user_hosts);
1205        let oa = allowlists
1206            .iter()
1207            .find(|a| a.source == "oa-publisher")
1208            .unwrap();
1209        assert_eq!(
1210            oa.redirect_hosts,
1211            vec!["ruj.uj.edu.pl".to_string(), "*.uj.edu.pl".to_string()]
1212        );
1213    }
1214
1215    #[test]
1216    fn merge_dedupes_when_creating_new_entry() {
1217        let mut allowlists = Vec::new();
1218        let user_hosts = vec![
1219            UserExtensionHost::for_test("ruj.uj.edu.pl"),
1220            UserExtensionHost::for_test("ruj.uj.edu.pl"),
1221        ];
1222        merge_into_allowlists(&mut allowlists, &user_hosts);
1223        assert_eq!(allowlists.len(), 1);
1224        assert_eq!(allowlists[0].redirect_hosts, vec!["ruj.uj.edu.pl"]);
1225    }
1226
1227    #[test]
1228    fn merged_pattern_is_matched_by_source_allowlist() {
1229        let parsed = parse_str(
1230            r#"
1231[[network.additional_hosts]]
1232host = "*.uj.edu.pl"
1233"#,
1234            p("config.toml"),
1235        )
1236        .unwrap();
1237        let mut allowlists = vec![SourceAllowlist::new("oa-publisher", vec![])];
1238        merge_into_allowlists(&mut allowlists, &parsed.additional_hosts);
1239        let oa = allowlists
1240            .iter()
1241            .find(|a| a.source == "oa-publisher")
1242            .unwrap();
1243        assert!(oa.matches("ruj.uj.edu.pl"));
1244        assert!(oa.matches("alpha.uj.edu.pl"));
1245        assert!(!oa.matches("ruj.uj.edu.ru"));
1246    }
1247
1248    // ---- trust_academic_repos ----------------------------------------
1249
1250    #[test]
1251    fn parse_trust_academic_repos_false_by_default() {
1252        let cfg = parse_str("[network]\ncooldown_ms = 100", p("config.toml")).unwrap();
1253        assert!(!cfg.trust_academic_repos);
1254    }
1255
1256    #[test]
1257    fn parse_trust_academic_repos_true_when_set() {
1258        let toml = "[network]\ntrust_academic_repos = true";
1259        let cfg = parse_str(toml, p("config.toml")).unwrap();
1260        assert!(cfg.trust_academic_repos);
1261        assert_eq!(cfg.additional_hosts, vec![]);
1262    }
1263
1264    // ---- trust_oa_registries (#405) ----------------------------------
1265
1266    #[test]
1267    fn parse_trust_oa_registries_false_by_default() {
1268        let cfg = parse_str("[network]\n", p("config.toml")).unwrap();
1269        assert!(!cfg.trust_oa_registries);
1270    }
1271
1272    #[test]
1273    fn parse_trust_oa_registries_true_when_set() {
1274        let toml = "[network]\ntrust_oa_registries = true";
1275        let cfg = parse_str(toml, p("config.toml")).unwrap();
1276        assert!(cfg.trust_oa_registries);
1277        // Independent of the academic flag — the two trust arguments are
1278        // different, so taking one must not imply the other.
1279        assert!(!cfg.trust_academic_repos);
1280        assert_eq!(cfg.additional_hosts, vec![]);
1281    }
1282
1283    #[test]
1284    fn oa_registry_hosts_are_valid_patterns() {
1285        let hosts = oa_registry_hosts();
1286        assert!(
1287            !hosts.is_empty(),
1288            "at least one OA registry pattern expected"
1289        );
1290        for h in &hosts {
1291            // Every entry must survive the ADR-0028 D2-1 validator. Unlike
1292            // the academic set these include bare apexes (`osf.io`), so
1293            // the assertion is validity, not wildcard shape.
1294            validate_pattern(h.host.as_str()).unwrap_or_else(|e| {
1295                panic!("invalid OA registry pattern {}: {e:?}", h.host.as_str())
1296            });
1297            assert!(
1298                h.note.is_some(),
1299                "every curated entry carries a note: {}",
1300                h.host.as_str()
1301            );
1302        }
1303    }
1304
1305    /// ADR-0037 moved DOAJ out of this set and into the DEFAULT
1306    /// `oa_publisher_allowlist`, so the flag must NOT carry it — otherwise
1307    /// the two places disagree again, in the other direction.
1308    #[test]
1309    fn oa_registry_hosts_exclude_doaj_which_is_now_a_default() {
1310        let hosts = oa_registry_hosts();
1311        let patterns: Vec<&str> = hosts.iter().map(|h| h.host.as_str()).collect();
1312        for gone in &["doaj.org", "*.doaj.org"] {
1313            assert!(
1314                !patterns.contains(gone),
1315                "{gone} belongs to oa_publisher_allowlist since ADR-0037, not to this flag;                  got {patterns:?}"
1316            );
1317        }
1318        for expected in &[
1319            "scielo.org",
1320            "zenodo.org",
1321            "osf.io",
1322            "hal.science",
1323            "core.ac.uk",
1324            "www.jstage.jst.go.jp",
1325        ] {
1326            assert!(
1327                patterns.contains(expected),
1328                "expected OA registry pattern {expected} not found in {patterns:?}"
1329            );
1330        }
1331    }
1332
1333    /// `trust_oa_registries` alone lets a J-STAGE PDF through (ADR-0066),
1334    /// offline: the default list does not, and the flag's hosts do, without
1335    /// `trust_academic_repos` (#649 review: only the live suite checked it).
1336    #[test]
1337    fn trust_oa_registries_alone_admits_jstage() {
1338        const JSTAGE: &str = "www.jstage.jst.go.jp";
1339        let oa = |a: &Vec<SourceAllowlist>| {
1340            a.iter()
1341                .find(|x| x.source == "oa-publisher")
1342                .expect("oa-publisher allowlist")
1343                .matches(JSTAGE)
1344        };
1345        let mut allowlists = crate::http::oa_publisher_allowlist();
1346        assert!(!oa(&allowlists), "J-STAGE is not a default host");
1347        merge_into_allowlists(&mut allowlists, &oa_registry_hosts());
1348        assert!(oa(&allowlists), "the flag's own hosts admit J-STAGE");
1349    }
1350
1351    /// The two curated sets must stay disjoint: an entry in both would make
1352    /// one flag silently widen what the other advertises.
1353    #[test]
1354    fn curated_sets_are_disjoint() {
1355        let academic: Vec<String> = academic_repo_hosts()
1356            .iter()
1357            .map(|h| h.host.as_str().to_string())
1358            .collect();
1359        for h in oa_registry_hosts() {
1360            assert!(
1361                !academic.contains(&h.host.as_str().to_string()),
1362                "{} is in both curated sets",
1363                h.host.as_str()
1364            );
1365        }
1366    }
1367
1368    #[test]
1369    fn academic_repo_hosts_are_valid_patterns() {
1370        let hosts = academic_repo_hosts();
1371        assert!(
1372            !hosts.is_empty(),
1373            "at least one academic host pattern expected"
1374        );
1375        // Every returned entry must round-trip through HostPattern::new
1376        for h in &hosts {
1377            assert!(
1378                h.host.as_str().starts_with("*."),
1379                "academic patterns are single-suffix wildcards: {}",
1380                h.host.as_str()
1381            );
1382        }
1383    }
1384
1385    #[test]
1386    fn academic_repo_hosts_match_expected_domains() {
1387        let hosts = academic_repo_hosts();
1388        let patterns: Vec<&str> = hosts.iter().map(|h| h.host.as_str()).collect();
1389        for expected in &["*.ac.uk", "*.ac.jp", "*.edu.au", "*.edu.cn", "*.edu.br"] {
1390            assert!(
1391                patterns.contains(expected),
1392                "expected academic pattern {expected} not found"
1393            );
1394        }
1395    }
1396    /// #441: `[store] root` reaches the caller at all.
1397    #[test]
1398    fn store_root_is_parsed_from_the_same_file_as_the_network_gate() {
1399        let cfg = parse_str(
1400            "[store]\nroot = \"/home/alice/papers\"\n\n[network]\ntrust_academic_repos = true\n",
1401            Utf8Path::new("test.toml"),
1402        )
1403        .expect("parses");
1404        assert_eq!(cfg.store_root.as_deref(), Some("/home/alice/papers"));
1405        assert!(
1406            cfg.trust_academic_repos,
1407            "the network section must still be read from the same pass"
1408        );
1409    }
1410
1411    /// A blank value is "unset", not the empty path — which would resolve
1412    /// to the filesystem root.
1413    #[test]
1414    fn cite_section_parses_and_blank_values_are_absent() {
1415        // #610 (review of #622).
1416        let cfg = parse_str(
1417            "[cite]\nkey_template = \"{author}{year}\"\nfile_field = \"  \"\n",
1418            Utf8Path::new("t.toml"),
1419        )
1420        .expect("parses");
1421        assert_eq!(cfg.cite_key_template.as_deref(), Some("{author}{year}"));
1422        assert_eq!(cfg.cite_file_field, None, "a blank value is absent");
1423        let cfg = parse_str("", Utf8Path::new("t.toml")).expect("parses");
1424        assert_eq!((cfg.cite_key_template, cfg.cite_file_field), (None, None));
1425    }
1426
1427    #[test]
1428    fn blank_store_root_parses_as_absent() {
1429        let cfg =
1430            parse_str("[store]\nroot = \"  \"\n", Utf8Path::new("test.toml")).expect("parses");
1431        assert_eq!(cfg.store_root, None);
1432    }
1433
1434    /// No `[store]` table at all is fine, and must not disturb anything.
1435    #[test]
1436    fn absent_store_table_is_not_an_error() {
1437        let cfg = parse_str(
1438            "[network]\ntrust_oa_registries = true\n",
1439            Utf8Path::new("t.toml"),
1440        )
1441        .expect("parses");
1442        assert_eq!(cfg.store_root, None);
1443        assert!(cfg.trust_oa_registries);
1444    }
1445
1446    /// A config file has no shell, so `~` must be expanded here or it
1447    /// becomes a literal directory named `~`.
1448    #[test]
1449    #[serial_test::serial]
1450    fn expand_store_root_resolves_a_leading_tilde() {
1451        let prior_home = std::env::var("HOME").ok();
1452        let prior_profile = std::env::var("USERPROFILE").ok();
1453        std::env::set_var("HOME", "/home/alice");
1454        std::env::remove_var("USERPROFILE");
1455
1456        assert_eq!(
1457            expand_store_root("~/papers")
1458                .as_str()
1459                .replace('\u{5c}', "/"),
1460            "/home/alice/papers"
1461        );
1462        assert_eq!(expand_store_root("~").as_str(), "/home/alice");
1463        // Absolute and relative paths pass through untouched.
1464        assert_eq!(expand_store_root("/srv/papers").as_str(), "/srv/papers");
1465        assert_eq!(expand_store_root("papers").as_str(), "papers");
1466        // `~alice` is another user's home; not something this can resolve,
1467        // so it must be left alone rather than silently mangled.
1468        assert_eq!(expand_store_root("~bob/papers").as_str(), "~bob/papers");
1469
1470        match prior_home {
1471            Some(v) => std::env::set_var("HOME", v),
1472            None => std::env::remove_var("HOME"),
1473        }
1474        if let Some(v) = prior_profile {
1475            std::env::set_var("USERPROFILE", v);
1476        }
1477    }
1478}