doiget_core/lib.rs
1//! # doiget-core
2//!
3//! Core library for [doiget](https://github.com/QAtlasHub/doiget): an Open Access
4//! first paper-fetcher with strict capability gating, fail-closed provenance logging,
5//! and a BiblioFetch.jl-compatible store layout.
6//!
7//! Phase 0 ships only this skeleton. Real implementations land in Phase 1.
8//! See `docs/PUBLIC_API.md` for the semver-locked surface and `docs/ARCHITECTURE.md`
9//! for the high-level design.
10
11#![warn(missing_docs)]
12#![forbid(unsafe_code)]
13
14use serde::{Deserialize, Serialize};
15use sha2::Digest;
16
17// --- Modules ---
18pub mod canonical;
19pub mod credentials;
20pub mod discovery;
21pub mod dry_run;
22pub mod http;
23pub mod orchestrator;
24pub mod paper_tex_source;
25pub mod paper_text;
26pub mod provenance;
27pub mod rate_limiter;
28pub mod refs;
29pub mod remediation;
30pub mod resolver_cache;
31pub mod source;
32pub mod sources;
33pub mod store;
34pub mod user_extension;
35pub mod verify_config;
36
37// Phase 4 citation graph (ADR-0010). Compile-gated by the `citation`
38// Cargo feature, which itself enables the `metadata` feature so the
39// Tier-2 source impls are available.
40#[cfg(feature = "citation")]
41pub mod citation_graph;
42
43// Re-export the canonical-tuple audit-identity types at the crate root
44// per ADR-0024 / `docs/PUBLIC_API.md` §1. The types themselves live in
45// the [`canonical`] submodule.
46pub use crate::canonical::{CanonicalRef, SourceType};
47
48/// Crate version. Used by `doiget-cli --version` and `doiget_health`.
49pub const VERSION: &str = env!("CARGO_PKG_VERSION");
50
51/// TOML schema version this build writes. See `docs/STORE.md` §3.
52pub const SCHEMA_VERSION: &str = "1.0";
53
54/// Hard-coded rate limit. See `docs/LEGAL.md` §6 safeguard 8.
55pub const MAX_CONCURRENT_FETCHES: u32 = 5;
56
57/// Hard-coded rate limit. See `docs/LEGAL.md` §6 safeguard 8.
58pub const MAX_FETCHES_PER_SECOND: f32 = 5.0;
59
60/// Maximum batch size for `doiget batch` and `doiget_batch_fetch`.
61pub const MCP_BATCH_MAX_SIZE: usize = 100;
62
63/// Slice 2 alias for [`MCP_BATCH_MAX_SIZE`] using the
64/// spec-language name (`docs/MCP_TOOLS.md` §1 / Slice 2 plan). The
65/// numeric value MUST equal [`MCP_BATCH_MAX_SIZE`]; an internal test
66/// pins the equivalence so the two constants cannot drift.
67pub const MAX_BATCH_REFS: usize = MCP_BATCH_MAX_SIZE;
68
69/// Maximum queued MCP requests beyond `MAX_CONCURRENT_FETCHES`. Excess returns
70/// `ErrorCode::RateLimited`. See `docs/SECURITY.md` §1.4 / `docs/MCP_TOOLS.md`.
71pub const MCP_QUEUE_DEPTH_MAX: usize = 100;
72
73/// MCP server stdin-EOF graceful-shutdown deadline, in seconds. See ADR-0001
74/// and `docs/MCP_TOOLS.md` §8.
75pub const MCP_STDIN_EOF_SHUTDOWN_SEC: u64 = 5;
76
77/// Maximum DOI suffix length accepted at validation. See `docs/SECURITY.md` §1.1.
78pub const DOI_SUFFIX_MAX_LEN: usize = 256;
79
80/// Maximum PDF body size accepted by the fetcher, in bytes. See
81/// `docs/SECURITY.md` §1.2 (Oversized PDF).
82pub const PDF_MAX_BYTES: u64 = 100_000_000;
83
84/// Time-to-live for entries in `~/.cache/doiget/resolver/`. See
85/// `docs/CACHE.md` §3.
86pub const RESOLVER_CACHE_TTL_DAYS: u32 = 7;
87
88/// Time-to-live for entries in `~/.cache/doiget/citations/`. See
89/// `docs/CACHE.md` §3.
90pub const CITATION_CACHE_TTL_DAYS: u32 = 30;
91
92// ---------------------------------------------------------------------------
93// Ref
94// ---------------------------------------------------------------------------
95
96/// A reference to a paper, either by DOI or arXiv id.
97///
98/// See `docs/SECURITY.md` §1.1 for input-validation rules.
99#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
100#[serde(rename_all = "lowercase", tag = "kind", content = "id")]
101pub enum Ref {
102 /// A DOI (e.g., `10.1234/example`).
103 Doi(Doi),
104 /// An arXiv id (e.g., `2401.12345`).
105 Arxiv(ArxivId),
106}
107
108/// A validated DOI string.
109///
110/// Construct via `Doi::parse(s)` (Phase 1+). The inner field is intentionally
111/// `pub(crate)` to forbid bypass construction; tests inside `doiget-core` may
112/// still use `Doi(s)` for fixture purposes.
113///
114/// Wire format: bare string (`#[serde(transparent)]`), e.g. `"10.1234/example"`.
115#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
116#[serde(transparent)]
117pub struct Doi(pub(crate) String);
118
119/// A validated arXiv id string.
120///
121/// Construct via `ArxivId::parse(s)` (Phase 1+). Inner field is `pub(crate)`.
122///
123/// Wire format: bare string (`#[serde(transparent)]`), e.g. `"2401.12345"`.
124#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
125#[serde(transparent)]
126pub struct ArxivId(pub(crate) String);
127
128impl Doi {
129 /// The DOI registrant prefix — everything before the first `/`, e.g.
130 /// `"10.1103"` for `10.1103/PhysRevLett.116.061102`.
131 ///
132 /// Used to scope publisher-specific Tier-3 TDM sources to the DOIs
133 /// their publisher actually registered (#442). `Doi` is only ever
134 /// constructed through [`Doi::parse`], which requires the
135 /// `10.<registrant>/<suffix>` shape, so the `/` is always present;
136 /// the fallback returns the whole string rather than panicking.
137 #[must_use]
138 pub fn prefix(&self) -> &str {
139 self.0.split_once('/').map_or(self.0.as_str(), |(p, _)| p)
140 }
141
142 /// Returns the DOI as a string slice.
143 pub fn as_str(&self) -> &str {
144 &self.0
145 }
146
147 /// Parses and validates a DOI string per `docs/SECURITY.md` §1.1.
148 ///
149 /// Accepts:
150 /// - Bare DOIs: `10.<registrant>/<suffix>` where `<registrant>` is 4–9
151 /// digits and `<suffix>` is a non-empty sequence of characters drawn
152 /// from `[A-Za-z0-9._/():-]` (the `:` covers legacy Kluwer
153 /// `10.1023/A:NNNN` and EDP Sciences `10.1051/jphys:NNNN` DOIs).
154 /// - The `doi:` URI scheme prefix; it is stripped before validation, so
155 /// the stored value never carries a scheme. (Matches the convention
156 /// established in `docs/SAFEKEY.md` §3 step 0.)
157 ///
158 /// Rejects:
159 /// - Inputs missing the literal `10.` prefix (after optional scheme
160 /// strip).
161 /// - Suffixes longer than [`DOI_SUFFIX_MAX_LEN`] bytes.
162 /// - Empty suffixes.
163 /// - Any character outside the suffix charset above (including control
164 /// characters, whitespace, and non-ASCII).
165 ///
166 /// # Errors
167 ///
168 /// Returns a [`RefParseError`] variant that names the specific rejection
169 /// category. Tier 1+ callers should map any [`RefParseError`] to
170 /// [`ErrorCode::InvalidRef`] when surfacing to MCP / CLI.
171 pub fn parse(s: &str) -> Result<Self, RefParseError> {
172 let stripped = parse::strip_doi_scheme(s);
173 parse::validate_doi(stripped)?;
174 Ok(Doi(stripped.to_string()))
175 }
176}
177
178impl std::fmt::Display for ArxivId {
179 /// Displays the validated id as its canonical string (e.g.
180 /// `2401.12345`) so it can be interpolated into messages — notably the
181 /// `FetchError::TextUnavailable` `#[error]` template (review #318).
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.write_str(&self.0)
184 }
185}
186
187impl ArxivId {
188 /// Returns the arXiv id as a string slice.
189 pub fn as_str(&self) -> &str {
190 &self.0
191 }
192
193 /// Parses and validates an arXiv id per `docs/SECURITY.md` §1.1 and the
194 /// pattern published in `docs/MCP_TOOLS.md`.
195 ///
196 /// Accepts:
197 /// - New-style ids: `YYMM.NNNNN[vN]` where the date block is 4 digits, the
198 /// sequence number is 4–5 digits, and the optional version `vN` is one
199 /// or more digits. Examples: `2401.12345`, `2401.12345v2`.
200 /// - Old-style ids: `subject-class/YYMMNNN[vN]` where the subject class
201 /// is a lowercase token (with optional internal hyphens and an
202 /// optional `.XX` two-uppercase-letter group), and the numeric body
203 /// is exactly 7 digits with optional `vN`. Examples:
204 /// `cond-mat/9501001`, `astro-ph.CO/0703123v2`.
205 /// - The `arxiv:` / `arXiv:` URI scheme prefix; it is stripped before
206 /// validation.
207 ///
208 /// Rejects:
209 /// - Inputs that match neither the new-style nor old-style shape.
210 /// - Inputs containing characters outside the per-shape charset
211 /// (control chars, whitespace, non-ASCII).
212 /// - Empty input.
213 ///
214 /// # Errors
215 ///
216 /// Returns a [`RefParseError`] variant that names the specific rejection
217 /// category.
218 pub fn parse(s: &str) -> Result<Self, RefParseError> {
219 let stripped = parse::strip_arxiv_scheme(s);
220 parse::validate_arxiv(stripped)?;
221 Ok(ArxivId(stripped.to_string()))
222 }
223}
224
225impl Ref {
226 /// Parses a string into a [`Ref`], auto-detecting DOI vs arXiv.
227 ///
228 /// Detection rules:
229 /// 1. If the input begins with the case-insensitive `doi:` scheme, the
230 /// remainder is parsed as a DOI.
231 /// 2. If the input begins with the `arxiv:` or `arXiv:` scheme, the
232 /// remainder is parsed as an arXiv id.
233 /// 3. Otherwise, if the input starts with `10.` it is treated as a bare
234 /// DOI; this matches the heuristic in `docs/SAFEKEY.md` §4 (Julia
235 /// reference) and is stable because DOIs always begin `10.`.
236 /// 4. Failing all of the above, parsing falls back to arXiv.
237 ///
238 /// The returned [`Ref`] never carries the URI scheme — `as_str()` on the
239 /// inner `Doi` / `ArxivId` is always the bare identifier.
240 ///
241 /// # Errors
242 ///
243 /// Returns a [`RefParseError`] from the underlying [`Doi::parse`] or
244 /// [`ArxivId::parse`] call. When the input has an explicit scheme
245 /// (`doi:` / `arxiv:`), the matching parser is dispatched and its error
246 /// surfaces directly. When the input is bare and ambiguous, the
247 /// heuristic in rule 3/4 selects the parser; an unparsable bare input
248 /// surfaces the arXiv parser's error (a non-`10.` ref that also fails
249 /// arXiv validation is never a valid DOI).
250 pub fn parse(s: &str) -> Result<Self, RefParseError> {
251 // Reject empty up front so all three parsers see a meaningful slice;
252 // without this, `strip_*_scheme("")` returns "" and we'd get a
253 // confusing "missing 10. prefix" error for empty input.
254 if s.is_empty() {
255 return Err(RefParseError::Empty);
256 }
257
258 if parse::has_doi_scheme(s) {
259 return Doi::parse(s).map(Ref::Doi);
260 }
261 if parse::has_arxiv_scheme(s) {
262 return ArxivId::parse(s).map(Ref::Arxiv);
263 }
264 if s.starts_with("10.") {
265 return Doi::parse(s).map(Ref::Doi);
266 }
267 // Last resort. The input declared no scheme and has no `10.`
268 // prefix, so trying arXiv is a guess -- and reporting the guess's
269 // failure verbatim tells a user who mistyped a DOI about arXiv
270 // (#477). Report what is actually known: it matched neither.
271 ArxivId::parse(s)
272 .map(Ref::Arxiv)
273 .map_err(|_| RefParseError::UnrecognisedShape)
274 }
275}
276
277// ---------------------------------------------------------------------------
278// Parser internals
279// ---------------------------------------------------------------------------
280
281mod parse {
282 use super::{RefParseError, DOI_SUFFIX_MAX_LEN};
283
284 /// Case-insensitive `doi:` prefix detector. Matches both `doi:` and
285 /// `DOI:` (and any case mix); the spec in `docs/SAFEKEY.md` §3 only
286 /// names the lowercase form, but the field convention is to be lenient
287 /// in what we accept (the scheme is dropped at the boundary anyway).
288 pub(crate) fn has_doi_scheme(s: &str) -> bool {
289 s.len() >= 4 && s.is_char_boundary(4) && s[..4].eq_ignore_ascii_case("doi:")
290 }
291
292 /// Case-insensitive `arxiv:` prefix detector. Accepts `arxiv:`,
293 /// `arXiv:` (the form used in `docs/MCP_TOOLS.md`), and any other case
294 /// mix.
295 pub(crate) fn has_arxiv_scheme(s: &str) -> bool {
296 s.len() >= 6 && s.is_char_boundary(6) && s[..6].eq_ignore_ascii_case("arxiv:")
297 }
298
299 pub(crate) fn strip_doi_scheme(s: &str) -> &str {
300 if has_doi_scheme(s) {
301 &s[4..]
302 } else {
303 s
304 }
305 }
306
307 pub(crate) fn strip_arxiv_scheme(s: &str) -> &str {
308 if has_arxiv_scheme(s) {
309 &s[6..]
310 } else {
311 s
312 }
313 }
314
315 /// DOI suffix charset per `docs/SECURITY.md` §1.1:
316 /// `[A-Za-z0-9._/():-]`. The forward slash is permitted inside the
317 /// suffix (e.g. `10.1016/...`); the registrant separator is the
318 /// *first* `/` and the suffix is everything after it.
319 ///
320 /// `:` is permitted because two large real publisher DOI families use
321 /// it in the suffix — legacy Kluwer/Springer (`10.1023/A:NNNNNNNNNN`)
322 /// and EDP Sciences / Journal de Physique
323 /// (`10.1051/jphys:NNNNNNNNNNNNNNNNN`). It adds no path-traversal
324 /// capability: traversal requires composing `/` and `.` into `../`,
325 /// and both characters are already in the suffix charset. In addition,
326 /// `safekey` independently escapes every char outside `[A-Za-z0-9._-]`
327 /// before any filesystem use, so `:` never reaches a path literally.
328 /// See ADR-0026 and `docs/SECURITY.md` §1.1.
329 fn is_doi_suffix_char(c: char) -> bool {
330 matches!(c,
331 'A'..='Z' | 'a'..='z' | '0'..='9'
332 | '.' | '_' | '/' | '(' | ')' | '-' | ':'
333 )
334 }
335
336 pub(crate) fn validate_doi(s: &str) -> Result<(), RefParseError> {
337 if s.is_empty() {
338 return Err(RefParseError::Empty);
339 }
340
341 // Must begin with literal "10."; the registrant is 4–9 digits up
342 // to the first '/'. After that, everything is suffix.
343 let rest = s
344 .strip_prefix("10.")
345 .ok_or(RefParseError::MissingDoiPrefix)?;
346 let slash_idx = rest
347 .find('/')
348 .ok_or(RefParseError::MissingDoiSuffixSeparator)?;
349 let registrant = &rest[..slash_idx];
350 let suffix = &rest[slash_idx + 1..];
351
352 // Registrant: 4–9 ASCII digits.
353 if registrant.len() < 4
354 || registrant.len() > 9
355 || !registrant.chars().all(|c| c.is_ascii_digit())
356 {
357 return Err(RefParseError::InvalidDoiRegistrant);
358 }
359
360 // Suffix: non-empty, charset-restricted, length-bounded.
361 if suffix.is_empty() {
362 return Err(RefParseError::EmptyDoiSuffix);
363 }
364 if suffix.len() > DOI_SUFFIX_MAX_LEN {
365 return Err(RefParseError::DoiSuffixTooLong {
366 len: suffix.len(),
367 max: DOI_SUFFIX_MAX_LEN,
368 });
369 }
370 if let Some(bad) = suffix.chars().find(|c| !is_doi_suffix_char(*c)) {
371 return Err(RefParseError::InvalidDoiSuffixChar { ch: bad });
372 }
373 Ok(())
374 }
375
376 /// Validates an arXiv id (with the `arxiv:` / `arXiv:` scheme already
377 /// stripped). Tries the new-style shape first, then the old-style.
378 pub(crate) fn validate_arxiv(s: &str) -> Result<(), RefParseError> {
379 if s.is_empty() {
380 return Err(RefParseError::Empty);
381 }
382 if validate_arxiv_new(s).is_ok() || validate_arxiv_old(s).is_ok() {
383 return Ok(());
384 }
385 Err(RefParseError::InvalidArxivShape)
386 }
387
388 /// New-style arXiv id: `YYMM.NNNNN[vN]`.
389 fn validate_arxiv_new(s: &str) -> Result<(), ()> {
390 let dot_idx = s.find('.').ok_or(())?;
391 let head = &s[..dot_idx];
392 let tail = &s[dot_idx + 1..];
393
394 // Head: exactly 4 ASCII digits.
395 if head.len() != 4 || !head.chars().all(|c| c.is_ascii_digit()) {
396 return Err(());
397 }
398
399 // Tail: 4–5 digits, then optional `v` followed by ≥1 digits.
400 let bytes = tail.as_bytes();
401 let mut i = 0;
402 while i < bytes.len() && bytes[i].is_ascii_digit() {
403 i += 1;
404 }
405 let digits_len = i;
406 if !(4..=5).contains(&digits_len) {
407 return Err(());
408 }
409 if i == bytes.len() {
410 return Ok(());
411 }
412 // Optional version suffix.
413 if bytes[i] != b'v' {
414 return Err(());
415 }
416 i += 1;
417 let v_start = i;
418 while i < bytes.len() && bytes[i].is_ascii_digit() {
419 i += 1;
420 }
421 if i == v_start || i != bytes.len() {
422 return Err(());
423 }
424 Ok(())
425 }
426
427 /// Old-style arXiv id: `subject-class/YYMMNNN[vN]`.
428 /// Subject class: `[a-z]([a-z-]*[a-z])?(\.[A-Z]{2})?`.
429 fn validate_arxiv_old(s: &str) -> Result<(), ()> {
430 let slash_idx = s.find('/').ok_or(())?;
431 let class = &s[..slash_idx];
432 let id = &s[slash_idx + 1..];
433
434 // Class: starts with [a-z], body is [a-z-], optional `.XX` (two
435 // ASCII upper).
436 let (core_class, dot_part) = match class.find('.') {
437 Some(d) => (&class[..d], Some(&class[d + 1..])),
438 None => (class, None),
439 };
440 if core_class.is_empty()
441 || !core_class
442 .chars()
443 .all(|c| c.is_ascii_lowercase() || c == '-')
444 || core_class.starts_with('-')
445 || core_class.ends_with('-')
446 {
447 return Err(());
448 }
449 if let Some(dp) = dot_part {
450 if dp.len() != 2 || !dp.chars().all(|c| c.is_ascii_uppercase()) {
451 return Err(());
452 }
453 }
454
455 // Id: 7 digits, optional `vN`.
456 let bytes = id.as_bytes();
457 let mut i = 0;
458 while i < bytes.len() && bytes[i].is_ascii_digit() {
459 i += 1;
460 }
461 if i != 7 {
462 return Err(());
463 }
464 if i == bytes.len() {
465 return Ok(());
466 }
467 if bytes[i] != b'v' {
468 return Err(());
469 }
470 i += 1;
471 let v_start = i;
472 while i < bytes.len() && bytes[i].is_ascii_digit() {
473 i += 1;
474 }
475 if i == v_start || i != bytes.len() {
476 return Err(());
477 }
478 Ok(())
479 }
480}
481
482// ---------------------------------------------------------------------------
483// RefParseError
484// ---------------------------------------------------------------------------
485
486/// Reasons a `Doi::parse` / `ArxivId::parse` / `Ref::parse` call can fail.
487///
488/// Each variant maps to one rejection category in `docs/SECURITY.md` §1.1.
489/// All variants funnel to [`ErrorCode::InvalidRef`] when surfacing to MCP /
490/// CLI; the granular shape is preserved for tests and for future log
491/// breadcrumbs. The `From<RefParseError> for ErrorCode` impl below makes
492/// `?` propagation collapse to `INVALID_REF` automatically, satisfying
493/// `docs/PUBLIC_API.md` §4.
494///
495/// Marked `#[non_exhaustive]` so adding new categories is a non-breaking
496/// change. Pattern-match with a wildcard arm.
497#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
498#[non_exhaustive]
499pub enum RefParseError {
500 /// Input was empty.
501 #[error("empty input")]
502 Empty,
503 /// Input did not begin with the required `10.` literal (after any
504 /// scheme strip).
505 #[error("DOI must begin with '10.'")]
506 MissingDoiPrefix,
507 /// Input started with `10.` but had no `/` separator between
508 /// registrant and suffix.
509 #[error("DOI must contain '/' between registrant and suffix")]
510 MissingDoiSuffixSeparator,
511 /// Registrant was not 4–9 ASCII digits.
512 #[error("DOI registrant must be 4–9 ASCII digits")]
513 InvalidDoiRegistrant,
514 /// DOI suffix was empty.
515 #[error("DOI suffix is empty")]
516 EmptyDoiSuffix,
517 /// DOI suffix exceeded `DOI_SUFFIX_MAX_LEN` bytes.
518 #[error("DOI suffix is {len} bytes; maximum is {max}")]
519 DoiSuffixTooLong {
520 /// Observed suffix length, in bytes.
521 len: usize,
522 /// Hard upper bound (always [`DOI_SUFFIX_MAX_LEN`]).
523 max: usize,
524 },
525 /// DOI suffix contained a character outside `[A-Za-z0-9._/():-]`.
526 #[error("DOI suffix contains invalid character {ch:?}")]
527 InvalidDoiSuffixChar {
528 /// The first offending character.
529 ch: char,
530 },
531 /// Input matched neither the new-style nor old-style arXiv shape.
532 #[error("input does not match any known arXiv id shape")]
533 InvalidArxivShape,
534 /// Input carried no scheme and no `10.` prefix, so it could have been
535 /// either kind of ref, and it was neither.
536 ///
537 /// #477: the fall-through used to report [`Self::InvalidArxivShape`],
538 /// so someone who mistyped a DOI was told about arXiv. The input names
539 /// no shape, so neither should the error.
540 #[error("input is neither a DOI (expected '10.<registrant>/<suffix>') nor an arXiv id")]
541 UnrecognisedShape,
542}
543
544impl From<RefParseError> for ErrorCode {
545 fn from(_: RefParseError) -> Self {
546 // All parse failures collapse to INVALID_REF at the public boundary,
547 // matching `docs/PUBLIC_API.md` §4 and `docs/SECURITY.md` §1.1.
548 ErrorCode::InvalidRef
549 }
550}
551
552// ---------------------------------------------------------------------------
553// Safekey
554// ---------------------------------------------------------------------------
555
556/// A filesystem-safe key derived deterministically from a `Ref`.
557///
558/// See `docs/SAFEKEY.md` for the full algorithm and reference test vectors.
559/// Construct via `Ref::safekey()` (Phase 1+); inner field is `pub(crate)`.
560///
561/// Wire format: bare string (`#[serde(transparent)]`), e.g. `"doi_10.1234_example"`.
562#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
563#[serde(transparent)]
564pub struct Safekey(pub(crate) String);
565
566impl Safekey {
567 /// Returns the safekey as a string slice.
568 pub fn as_str(&self) -> &str {
569 &self.0
570 }
571}
572
573impl Ref {
574 /// Returns the bare identifier string usable as a provenance `ref` field.
575 ///
576 /// Equivalent to `Doi::as_str` / `ArxivId::as_str` dispatched on the
577 /// variant — the URI scheme (`doi:` / `arxiv:`) is never present in the
578 /// inner identifiers (it is stripped at parse time), so the result is
579 /// always the bare DOI or arXiv id. Used by the CLI / MCP orchestrators
580 /// to populate the `ref` column of provenance log rows
581 /// (`docs/PROVENANCE_LOG.md` §3) without re-matching the variant.
582 pub fn as_input_str(&self) -> &str {
583 match self {
584 Ref::Doi(d) => d.as_str(),
585 Ref::Arxiv(a) => a.as_str(),
586 }
587 }
588
589 /// Derives a deterministic, filesystem-safe key from this reference.
590 ///
591 /// The algorithm is the NORMATIVE binding spec in `docs/SAFEKEY.md` §3.
592 /// Both Rust and Julia implementations MUST produce bit-identical output
593 /// for every entry in `tests/fixtures/safekey/vectors.json`.
594 ///
595 /// # Algorithm summary
596 ///
597 /// 1. Prefix with `doi_` or `arxiv_` (per variant).
598 /// 2. Replace any character outside `[A-Za-z0-9._-]` with `_`.
599 /// 3. Collapse consecutive `_` runs to a single `_`.
600 /// 4. Trim leading/trailing `_`.
601 /// 5. If the result exceeds 192 bytes, take the first 192 bytes plus
602 /// `_` plus the first 8 hex chars of `SHA-256(raw)` (where `raw` is
603 /// the step-1 output, before escaping).
604 ///
605 /// The bound on `as_str()` after step 4 is pure ASCII (steps 1-3 produce
606 /// only ASCII bytes), so the byte-slice in step 5 cannot split a
607 /// multibyte char.
608 pub fn safekey(&self) -> Safekey {
609 // Step 0: prefix per variant. Doi/ArxivId hold the bare identifier
610 // (no `doi:` / `arxiv:` URI scheme — that is stripped by Ref::parse,
611 // not relevant here).
612 let raw = match self {
613 Ref::Doi(d) => format!("doi_{}", d.as_str()),
614 Ref::Arxiv(a) => format!("arxiv_{}", a.as_str()),
615 };
616
617 // Step 1: replace unsafe chars with '_'. Non-ASCII chars (emitted by
618 // String::chars() as full Unicode code points) all hit the wildcard
619 // arm and become a single '_'.
620 let escaped: String = raw
621 .chars()
622 .map(|c| match c {
623 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' => c,
624 _ => '_',
625 })
626 .collect();
627
628 // Step 2: collapse consecutive '_' runs to a single '_'.
629 let mut collapsed = String::with_capacity(escaped.len());
630 let mut last_was_underscore = false;
631 for c in escaped.chars() {
632 if c == '_' {
633 if !last_was_underscore {
634 collapsed.push('_');
635 }
636 last_was_underscore = true;
637 } else {
638 collapsed.push(c);
639 last_was_underscore = false;
640 }
641 }
642
643 // Step 3: trim leading/trailing '_'.
644 let trimmed = collapsed.trim_matches('_');
645
646 // Step 4: length-bound. After steps 1-3 `trimmed` is pure ASCII, so
647 // `len()` (bytes) == char count and `&trimmed[..192]` is char-safe.
648 let key = if trimmed.len() > 192 {
649 let digest = sha2::Sha256::digest(raw.as_bytes());
650 let hash = hex::encode(&digest[..4]);
651 format!("{}_{}", &trimmed[..192], hash)
652 } else {
653 trimmed.to_string()
654 };
655
656 Safekey(key)
657 }
658}
659
660// ---------------------------------------------------------------------------
661// ErrorCode
662// ---------------------------------------------------------------------------
663
664/// The closed set of error codes doiget surfaces.
665///
666/// See `docs/ERRORS.md` for the persona × code matrix.
667///
668/// Marked `#[non_exhaustive]` so adding new variants is a minor (not major)
669/// version bump.
670#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
671#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
672#[non_exhaustive]
673pub enum ErrorCode {
674 /// DOI / arXiv id failed validation.
675 InvalidRef,
676 /// Tier 1 sources reported no OA URL.
677 NoOaAvailable,
678 /// Internal rate cap or upstream 429.
679 RateLimited,
680 /// Transport / DNS / TLS failure.
681 NetworkError,
682 /// A metadata source authoritatively reported that the identifier
683 /// does not exist. Network-independent and reproducible, so `doiget
684 /// verify` treats it as a definite dead reference (fails the run even
685 /// without `--strict`) rather than a tolerable blip — distinct from
686 /// the transient [`Self::NetworkError`], [`Self::RateLimited`], and
687 /// [`Self::FetchTimeout`].
688 ///
689 /// Sources: an HTTP `404` / `410` / `451` from a metadata API, or a
690 /// source-specific absence signal (e.g. arXiv returns HTTP 200 with an
691 /// empty `<feed>` for an unknown id, surfaced via `FetchError::NotFound`).
692 ///
693 /// Caveat (DOI fan-out): for a DOI this is emitted only when the
694 /// configured metadata sources (Crossref, then Unpaywall) all fail to
695 /// resolve it and at least one authoritatively 404s. A DOI registered
696 /// only outside that set (e.g. a DataCite-only dataset DOI) can
697 /// therefore be reported `NotFound` even though it exists in a
698 /// registry doiget does not query.
699 NotFound,
700 /// A name filter (author / venue / publisher) matched MORE than one
701 /// entity with no clear winner, so it could not be resolved to a single
702 /// id. Distinct from [`Self::NotFound`] ("matched nothing"): an agent
703 /// should *narrow* the name (add a first name / fuller title) rather
704 /// than conclude the entity does not exist. The accompanying error
705 /// message lists the candidate matches. Wire form: `"AMBIGUOUS"`.
706 /// Raised by `doiget search`'s name-filter resolution (ADR-0031 D5).
707 Ambiguous,
708 /// Filesystem write failed.
709 StoreError,
710 /// Provenance log write failed; the fetch was aborted.
711 LogError,
712 /// Source not granted by the runtime `CapabilityProfile`.
713 CapabilityDenied,
714 /// Per-request timeout exceeded.
715 FetchTimeout,
716 /// Store entry's `schema_version` is ahead of this build.
717 SchemaTooNew,
718 /// Could not acquire `flock` within 5 s.
719 LockTimeout,
720 /// Bug — please open an issue.
721 InternalError,
722 /// Feature is spec'd but not yet wired in this Phase. Distinct from
723 /// [`Self::InternalError`] (which signals a bug) and
724 /// [`Self::CapabilityDenied`] (which signals a runtime config gate).
725 /// Returned by stubs that exist to pin the public surface ahead of
726 /// orchestrator implementation, so an agent can react with "wait for
727 /// next minor release" rather than "report a bug" or "tweak my
728 /// capability profile". Wire form: `"NOT_IMPLEMENTED"`.
729 NotImplemented,
730 /// The identifier is valid and resolvable, but the **requested
731 /// representation** is not available from its source — currently the
732 /// ar5iv HTML render consulted by `doiget text` (a 200 with no
733 /// extractable prose: the paper was never converted to HTML).
734 ///
735 /// Deliberately distinct from the neighbouring codes so an agent does
736 /// not misdiagnose a missing render as a bad reference (issue #302):
737 /// it is NOT [`Self::NotFound`] (the id *does* exist), NOT
738 /// [`Self::NoOaAvailable`] (the paper may well be OA — only this one
739 /// representation is missing), and NOT [`Self::NetworkError`] (the
740 /// fetch succeeded). The actionable branch is "fetch the PDF instead",
741 /// not "fix the identifier". Wire form: `"TEXT_UNAVAILABLE"`.
742 TextUnavailable,
743}
744
745impl ErrorCode {
746 /// The `SCREAMING_SNAKE_CASE` wire token for this code, as a
747 /// `&'static str`. Identical to the serde representation but
748 /// allocation-free and usable where a borrowed string with a
749 /// `'static` lifetime is required — notably the provenance log
750 /// `error_code` column (`docs/PROVENANCE_LOG.md` §3), so a failure
751 /// row records the *actual* mapped code instead of a hand-written
752 /// literal that can drift from this enum (issue #118).
753 #[must_use]
754 pub fn as_wire(&self) -> &'static str {
755 match self {
756 ErrorCode::InvalidRef => "INVALID_REF",
757 ErrorCode::NoOaAvailable => "NO_OA_AVAILABLE",
758 ErrorCode::RateLimited => "RATE_LIMITED",
759 ErrorCode::NetworkError => "NETWORK_ERROR",
760 ErrorCode::NotFound => "NOT_FOUND",
761 ErrorCode::Ambiguous => "AMBIGUOUS",
762 ErrorCode::StoreError => "STORE_ERROR",
763 ErrorCode::LogError => "LOG_ERROR",
764 ErrorCode::CapabilityDenied => "CAPABILITY_DENIED",
765 ErrorCode::FetchTimeout => "FETCH_TIMEOUT",
766 ErrorCode::SchemaTooNew => "SCHEMA_TOO_NEW",
767 ErrorCode::LockTimeout => "LOCK_TIMEOUT",
768 ErrorCode::InternalError => "INTERNAL_ERROR",
769 ErrorCode::NotImplemented => "NOT_IMPLEMENTED",
770 ErrorCode::TextUnavailable => "TEXT_UNAVAILABLE",
771 }
772 }
773}
774
775// ---------------------------------------------------------------------------
776// DenialReason / DenialContext (ADR-0023)
777// ---------------------------------------------------------------------------
778
779/// Closed-set reasons a denial-class error envelope can carry on its
780/// optional `denial_context.reason` field.
781///
782/// Wire form (JSON / MCP) is `snake_case` — e.g. `"redirect_not_in_allowlist"`.
783/// The set is **closed** per ADR-0023 §2: adding a new variant is a minor
784/// semver bump; renaming or repurposing one is a breaking change. Mirrors
785/// the stability rule that already governs [`ErrorCode`].
786///
787/// See [`DenialContext`] for the surrounding struct, `docs/ERRORS.md` §3.1
788/// for the wire surface, and `docs/PUBLIC_API.md` §8 for the
789/// semver-locked surface contract.
790#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
791#[serde(rename_all = "snake_case")]
792pub enum DenialReason {
793 /// Redirect target host did not match the source's allowlist
794 /// (`HttpError::RedirectDenied`).
795 RedirectNotInAllowlist,
796 /// Redirect target had a non-HTTPS scheme (`HttpError::InsecureRedirect`).
797 InsecureScheme,
798 /// Source produced a URL whose host is on a future blocklist.
799 ///
800 /// Reserved — no producer wired yet. Will be emitted by the future
801 /// per-source URL host-blocklist guard once that component lands
802 /// (post-Phase-1 supply-chain hardening; see
803 /// `docs/REDIRECT_ALLOWLIST.md` §4 for the staging plan).
804 HostInBlockList,
805 /// Body exceeded [`PDF_MAX_BYTES`] (`HttpError::OversizedBody`).
806 SizeCapExceeded,
807 /// Store entry's `schema_version` is ahead of this binary.
808 ///
809 /// Reserved — no producer wired yet. Will be emitted by the
810 /// `FsStore` schema-rejection path once the read-side bump check
811 /// lands (it currently only writes the current `SCHEMA_VERSION`).
812 SchemaDrift,
813 /// Source not in the runtime [`CapabilityProfile`]
814 /// (`FetchError::NotEligible`).
815 CapabilityNotGranted,
816 /// Rate limiter rejected the call inside the current window.
817 ///
818 /// Reserved — no producer wired yet. Will be emitted by
819 /// [`RateLimiter`](crate::rate_limiter::RateLimiter) once the
820 /// limiter surfaces structured denials (Phase 2+; today the
821 /// limiter only sleeps to enforce the window).
822 RateLimitWindow,
823 /// SSRF guard rejected a private / link-local / cloud-metadata address.
824 ///
825 /// Reserved — no producer wired yet. Will be emitted by the
826 /// future SSRF pre-flight check (post-Phase-1 supply-chain
827 /// hardening; the workspace currently relies on rustls + the
828 /// HTTPS-only redirect policy to keep the attack surface small).
829 SsrfPrivateAddress,
830 /// Response Content-Type / magic-byte mismatch (`HttpError::NotAPdf`).
831 ContentTypeMismatch,
832}
833
834/// Structured machine-parseable companion to `error.message` for
835/// recoverable denials.
836///
837/// The field is **optional and additive** on the public error envelope —
838/// every previously-shipped `{code, message}` envelope remains valid, and
839/// agents that ignore this struct continue to work. When present, it
840/// carries the concrete parameters an LLM agent can use to plan a recovery
841/// (e.g. "the redirect to `evil.example.com` was denied because it is not
842/// in the crossref allowlist") without text-mining `error.message`.
843///
844/// ## Wire shape
845///
846/// `#[serde(deny_unknown_fields)]`: forward-compatible field additions on
847/// the wire are forbidden by design — adding a field to this struct is a
848/// **breaking** change. This is why the type is **not** `#[non_exhaustive]`
849/// (per `docs/PUBLIC_API.md` §8): both production rules — Rust struct
850/// construction outside the crate AND wire-level extension — must agree.
851///
852/// All fields except `reason` are optional. Producers populate the fields
853/// relevant to the reason and leave the rest at `None`; consumers MUST
854/// tolerate any subset of fields being present. Optional fields are
855/// skipped on serialize but accepted as missing on deserialize via
856/// `#[serde(default, skip_serializing_if = "Option::is_none")]`.
857///
858/// [`Self::expected`] is `Option<Vec<String>>` rather than `Vec<String>`
859/// so the producer can distinguish "this reason has no allowlist channel"
860/// (`None` → field absent on the wire) from "this is the explicit list of
861/// acceptable values, possibly empty" (`Some(vec![])` → `"expected":[]` on
862/// the wire). The previous `Vec<String>` shape collapsed both states
863/// into "field omitted", which an LLM agent could not safely disambiguate.
864///
865/// Mapping table: see ADR-0023 §4, plus the
866/// `From<&HttpError> for Option<DenialContext>` and
867/// `From<&FetchError> for Option<DenialContext>` impls in
868/// [`crate::http`] / [`crate::source`].
869#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
870#[serde(deny_unknown_fields)]
871pub struct DenialContext {
872 /// Closed-enum reason code; the only required field.
873 pub reason: DenialReason,
874 /// Resolver source key (e.g. `"crossref"`) when one is in scope.
875 #[serde(default, skip_serializing_if = "Option::is_none")]
876 pub source: Option<String>,
877 /// Concrete value the producer attempted (host, path, hex magic bytes,
878 /// scheme prefix). Shape is reason-specific; consumers MUST treat it
879 /// as opaque text.
880 #[serde(default, skip_serializing_if = "Option::is_none")]
881 pub attempted: Option<String>,
882 /// Allowlist entries / acceptable values. `Option<Vec<String>>` so the
883 /// producer can distinguish "this reason has no allowlist channel"
884 /// (`None`, field absent on the wire) from "this is the explicit list
885 /// of acceptable values, possibly empty" (`Some(vec![])`, `"expected":[]`
886 /// on the wire). The inner `Vec<String>` is used even when only one
887 /// value is meaningful (e.g. `Some(vec!["%PDF-".into()])`) so the
888 /// format does not have to flip when multiple values are acceptable.
889 #[serde(default, skip_serializing_if = "Option::is_none")]
890 pub expected: Option<Vec<String>>,
891 /// Redirect-chain hop position, 0-indexed. `u8` because the chain is
892 /// hard-capped at [`crate::http`]'s `MAX_REDIRECTS` (= 10) and any
893 /// larger value indicates a bug.
894 #[serde(default, skip_serializing_if = "Option::is_none")]
895 pub hop_index: Option<u8>,
896 /// Size or rate cap value (e.g. [`PDF_MAX_BYTES`]).
897 #[serde(default, skip_serializing_if = "Option::is_none")]
898 pub cap: Option<u64>,
899 /// Observed value (e.g. response bytes when [`Self::cap`] is the byte
900 /// cap, or row schema_version when [`Self::cap`] is the binary's).
901 #[serde(default, skip_serializing_if = "Option::is_none")]
902 pub actual: Option<u64>,
903}
904
905// ---------------------------------------------------------------------------
906// ResolvedCandidate / ResolveResult (Issue #242)
907// ---------------------------------------------------------------------------
908
909/// A candidate paper resolved from a bibliographic citation string.
910#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
911pub struct ResolvedCandidate {
912 /// Resolved DOI.
913 pub doi: String,
914 /// Title of the resolved candidate.
915 pub title: String,
916 /// First author or primary author representation.
917 pub author: String,
918 /// Publication year, if resolved.
919 pub year: Option<i32>,
920 /// Token similarity overlap score in `0.0..=1.0`.
921 pub score: f64,
922 /// Resolving metadata source (e.g. `"crossref"`).
923 pub source: String,
924}
925
926/// The result structure returned by bibliographic citation resolution.
927#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
928pub struct ResolveResult {
929 /// The original query bibliographic citation string.
930 pub query: String,
931 /// Ranked candidate list (highest score first, thresholded to >= 0.5).
932 pub candidates: Vec<ResolvedCandidate>,
933}
934
935// ---------------------------------------------------------------------------
936// CapabilityProfile (placeholder; full impl in Phase 1)
937// ---------------------------------------------------------------------------
938
939/// Marker for the always-on Open Access tier. See `docs/CAPABILITY.md`.
940#[derive(Debug, Clone, Copy)]
941pub struct AlwaysOn;
942
943/// Which Tier 2 metadata sources are enabled this session. See `docs/CAPABILITY.md`.
944#[derive(Debug, Clone, Default)]
945#[non_exhaustive]
946pub struct MetadataAccess {
947 /// Phase 4+; enabled by `DOIGET_ENABLE_OPENALEX`.
948 pub openalex: bool,
949 /// Phase 4+; enabled by `DOIGET_ENABLE_S2`.
950 pub semantic_scholar: bool,
951 /// Phase 4+; enabled by `DOIGET_ENABLE_DOAJ`.
952 pub doaj: bool,
953 /// DOI **resolution** for DataCite-registered DOIs (Zenodo / figshare /
954 /// Dryad / OSF / most institutional repositories); enabled by
955 /// `DOIGET_ENABLE_DATACITE`.
956 ///
957 /// Unlike its siblings this is not enrichment — Crossref and Unpaywall
958 /// simply do not index these DOIs, so without it a live, open record is
959 /// reported [`ErrorCode::NotFound`] (ADR-0040, #414).
960 pub datacite: bool,
961 /// HAL — the French national OA repository, holding maths / physics /
962 /// CS deposits that Crossref-centric indexes miss; enabled by
963 /// `DOIGET_ENABLE_HAL` (ADR-0040, #418).
964 pub hal: bool,
965 /// OpenAIRE — European institutional / funder repository aggregation;
966 /// enabled by `DOIGET_ENABLE_OPENAIRE` (ADR-0040, #416).
967 pub openaire: bool,
968 /// CORE — cross-repository OA aggregation, the last fallback in the
969 /// optional chain; enabled by `DOIGET_ENABLE_CORE`. An optional free
970 /// key in `DOIGET_CORE_API_KEY` raises the rate limit but is not
971 /// required (ADR-0040, #417).
972 pub core: bool,
973 /// Europe PMC — biomedical OA full text that Unpaywall does not index;
974 /// enabled by `DOIGET_ENABLE_EUROPE_PMC` (ADR-0040, #415).
975 pub europe_pmc: bool,
976}
977
978/// Process-wide rate limits. Hard-coded; not configurable.
979///
980/// Construct only via [`RateLimits::HARD_CODED`]. The struct fields are
981/// `pub(crate)` so downstream code cannot synthesize a `RateLimits` with
982/// different values, which would weaken `docs/LEGAL.md` §6 safeguard 8.
983#[derive(Debug, Clone, Copy)]
984#[non_exhaustive]
985pub struct RateLimits {
986 pub(crate) max_concurrent_fetches: u32,
987 pub(crate) max_fetches_per_second: f32,
988 pub(crate) per_source_backoff_ms: u64,
989}
990
991impl RateLimits {
992 /// The single, hard-coded set of rate limits. There is no other public
993 /// constructor — see the type-level docs.
994 pub const HARD_CODED: Self = Self {
995 max_concurrent_fetches: MAX_CONCURRENT_FETCHES,
996 max_fetches_per_second: MAX_FETCHES_PER_SECOND,
997 per_source_backoff_ms: 200,
998 };
999
1000 /// Maximum number of concurrent fetches in flight.
1001 pub const fn max_concurrent_fetches(&self) -> u32 {
1002 self.max_concurrent_fetches
1003 }
1004
1005 /// Maximum fetch attempts per second across all sources.
1006 pub const fn max_fetches_per_second(&self) -> f32 {
1007 self.max_fetches_per_second
1008 }
1009
1010 /// Per-source backoff in milliseconds between consecutive requests.
1011 ///
1012 /// The floor that applies to every source. A source whose vendor
1013 /// publishes something stricter gets that instead -- see
1014 /// [`Self::backoff_ms_for`].
1015 pub const fn per_source_backoff_ms(&self) -> u64 {
1016 self.per_source_backoff_ms
1017 }
1018
1019 /// The minimum gap between two requests to `source`, in milliseconds.
1020 ///
1021 /// [`Self::per_source_backoff_ms`] unless [`SOURCE_RATE_OVERRIDES`]
1022 /// names a stricter value, in which case the stricter one wins. Never
1023 /// looser: `docs/SOURCES.md` promises doiget adopts a stricter vendor
1024 /// guideline at the per-source level rather than relaxing the global
1025 /// cap, and `max` here is what makes that promise structural instead of
1026 /// a matter of getting every table entry right.
1027 #[must_use]
1028 pub fn backoff_ms_for(&self, source: &str) -> u64 {
1029 match source_rate(source) {
1030 Some(r) => r.min_interval_ms.max(self.per_source_backoff_ms),
1031 None => self.per_source_backoff_ms,
1032 }
1033 }
1034
1035 /// The concurrency ceiling for `source`.
1036 ///
1037 /// Clamped to [`Self::max_concurrent_fetches`], so a table entry can
1038 /// only ever tighten the global cap.
1039 #[must_use]
1040 pub fn max_concurrent_for(&self, source: &str) -> u32 {
1041 match source_rate(source) {
1042 Some(r) => r.max_concurrent.min(self.max_concurrent_fetches),
1043 None => self.max_concurrent_fetches,
1044 }
1045 }
1046}
1047
1048/// A vendor-published rate guideline stricter than the global cap.
1049///
1050/// Library constants selected by source key, never caller-supplied:
1051/// `docs/LEGAL.md` §6a safeguard 5 makes [`RateLimits`] unsynthesizable by
1052/// downstream code on purpose, and a per-source table that took values from
1053/// a caller would hand back exactly what that safeguard withholds.
1054#[derive(Debug, Clone, Copy)]
1055#[non_exhaustive]
1056pub struct SourceRate {
1057 /// Minimum milliseconds between two requests to this source.
1058 pub min_interval_ms: u64,
1059 /// Maximum simultaneous requests to this source.
1060 pub max_concurrent: u32,
1061}
1062
1063/// Sources whose published terms are stricter than the global cap.
1064///
1065/// #493. The global cap is 5 requests/second and 5 concurrent, against
1066/// arXiv's published *"make no more than one request every three seconds,
1067/// and limit requests to a single connection at a time"* -- 15x the rate
1068/// and 5x the concurrency. Three places in the tree asserted the global cap
1069/// "comfortably respects" it.
1070///
1071/// A table rather than a config knob, and consulted through
1072/// [`RateLimits::backoff_ms_for`] rather than read directly, so an entry can
1073/// only ever tighten.
1074///
1075/// Keys are [`crate::source::Source::name`] values.
1076pub const SOURCE_RATE_OVERRIDES: &[(&str, SourceRate)] = &[(
1077 // <https://info.arxiv.org/help/api/tou.html>, read 2026-08-25. The
1078 // limit is collective across every machine under the caller's control,
1079 // and circumventing it may have access blocked.
1080 "arxiv",
1081 SourceRate {
1082 min_interval_ms: 3_000,
1083 max_concurrent: 1,
1084 },
1085)];
1086
1087/// The override for `source`, if any.
1088#[must_use]
1089pub fn source_rate(source: &str) -> Option<SourceRate> {
1090 SOURCE_RATE_OVERRIDES
1091 .iter()
1092 .find(|(k, _)| *k == source)
1093 .map(|(_, r)| *r)
1094}
1095
1096/// A successful TDM grant.
1097///
1098/// Carries the validated API key (`docs/CAPABILITY.md` §1) so that the key
1099/// flows from the startup capability gate into the source, rather than each
1100/// TDM source re-reading the env var at fetch time (issue #153 — an env
1101/// mutation between startup and fetch is otherwise undetectable).
1102///
1103/// The `api_key` field exists only when at least one `tdm-*` Cargo feature
1104/// is compiled in (the `secrecy` dependency is `optional = true` and gated
1105/// on those features per ADR-0002, so default release binaries contain no
1106/// TDM code path at all). The struct is `#[non_exhaustive]`; the
1107/// `tdm-*`-gated `api_key` field is therefore additive, not breaking, for
1108/// builds that toggle the feature set.
1109///
1110/// `docs/CAPABILITY.md` §1 specifies the type as `Secret<String>`; that is
1111/// the `secrecy` 0.9 spelling. The workspace pins `secrecy` 0.10, whose
1112/// equivalent owned-string secret type is `secrecy::SecretString`
1113/// (`= SecretBox<str>`). CAPABILITY.md §1 has been updated to match the
1114/// 0.10 API. `Debug` redacts the value.
1115///
1116/// Implements `Default` so in-crate test fixtures using
1117/// `TdmGrant { agree_env_var: ..., ..Default::default() }` keep compiling;
1118/// the default `api_key` is an empty secret.
1119#[derive(Debug, Clone)]
1120#[non_exhaustive]
1121pub struct TdmGrant {
1122 /// The publisher API key, validated present at startup by
1123 /// [`CapabilityProfile::from_env`]. Wrapped in
1124 /// `secrecy::SecretString` so `Debug` never prints it; use
1125 /// `secrecy::ExposeSecret::expose_secret` at the point of use.
1126 ///
1127 /// Only present when a `tdm-*` feature is compiled in (see the
1128 /// type-level docs and ADR-0002).
1129 #[cfg(any(
1130 feature = "tdm-elsevier",
1131 feature = "tdm-aps",
1132 feature = "tdm-springer",
1133 feature = "tdm-ieee"
1134 ))]
1135 pub api_key: secrecy::SecretString,
1136 /// Which env var the user used to acknowledge the publisher's ToS.
1137 pub agree_env_var: String,
1138 /// When the agreement env var was first observed at startup.
1139 pub agreed_at: chrono::DateTime<chrono::Utc>,
1140}
1141
1142impl Default for TdmGrant {
1143 fn default() -> Self {
1144 Self {
1145 #[cfg(any(
1146 feature = "tdm-elsevier",
1147 feature = "tdm-aps",
1148 feature = "tdm-springer",
1149 feature = "tdm-ieee"
1150 ))]
1151 api_key: secrecy::SecretString::from(String::new()),
1152 agree_env_var: String::new(),
1153 agreed_at: chrono::Utc::now(),
1154 }
1155 }
1156}
1157
1158/// Runtime gate for which sources may be invoked. See `docs/CAPABILITY.md`.
1159///
1160/// Marked `#[non_exhaustive]` so adding new capability classes is non-breaking.
1161/// Pattern-match only against the documented variants and use a wildcard arm.
1162///
1163/// **Construction**: external callers use [`CapabilityProfile::from_env()`].
1164/// Struct-literal construction is blocked outside this crate by
1165/// `#[non_exhaustive]`; this is intentional — the type's safety guarantees
1166/// rely on the resolution rules in `from_env`. `Default` is **not yet**
1167/// implemented; Phase 1 will add it once the field set stabilizes.
1168#[derive(Debug, Clone)]
1169///
1170/// **Correction (#468 review).** An earlier version of the note above said
1171/// the type's safety guarantees "rely on the resolution rules in
1172/// `from_env`", protected by `#[non_exhaustive]`. That overstates what the
1173/// attribute does: it blocks struct-literal construction across a crate
1174/// boundary, but every field here is `pub`, and `from_env` hands back an
1175/// owned value — so a downstream caller has always been able to obtain a
1176/// profile and then assign to `tdm_aps` directly. `#[non_exhaustive]` buys
1177/// forward-compatibility for adding fields, not an authorization boundary.
1178/// Whether one is wanted is tracked separately; it is not a property this
1179/// type has today, and claiming it did was the problem.
1180#[non_exhaustive]
1181pub struct CapabilityProfile {
1182 /// Tier 1 OA sources are always permitted.
1183 pub oa: AlwaysOn,
1184 /// Tier 2 metadata access (Phase 4+).
1185 pub metadata: MetadataAccess,
1186 /// Tier 3 grants are populated only when both env var and feature compile-in are set.
1187 pub tdm_elsevier: Option<TdmGrant>,
1188 /// Tier 3 grants are populated only when both env var and feature compile-in are set.
1189 pub tdm_aps: Option<TdmGrant>,
1190 /// Tier 3 grants are populated only when both env var and feature compile-in are set.
1191 pub tdm_springer: Option<TdmGrant>,
1192 /// Tier 3 grants are populated only when both env var and feature compile-in are set.
1193 pub tdm_ieee: Option<TdmGrant>,
1194 /// Hard-coded rate limits for this process.
1195 pub rate_limits: RateLimits,
1196}
1197
1198/// Errors that can arise during `CapabilityProfile::from_env`.
1199#[derive(Debug, thiserror::Error)]
1200pub enum CapabilityError {
1201 /// User set the agree env var but provided no key. See `docs/CAPABILITY.md` §2.
1202 #[error("env {agree_var} is set but {key_var} is missing")]
1203 AgreedButNoKey {
1204 /// The agreement env var the user set.
1205 agree_var: String,
1206 /// The key env var that should accompany it.
1207 key_var: String,
1208 },
1209 /// Key env var is set but user has not agreed. See `docs/CAPABILITY.md` §2.
1210 #[error("key for {agree_var} is present but {agree_var} is not set to '1'")]
1211 KeyButNotAgreed {
1212 /// The agreement env var the user must set to `1` before the key takes effect.
1213 agree_var: String,
1214 },
1215}
1216
1217impl CapabilityProfile {
1218 /// The profile a clean environment produces, built WITHOUT reading the
1219 /// environment (#456).
1220 ///
1221 /// Most tests want "a default profile", not "whatever the environment
1222 /// says". Calling [`Self::from_env`] for that couples them to a
1223 /// process-global they do not control, and the coupling is not
1224 /// hypothetical: `from_env` returns `Err(KeyButNotAgreed)` while any
1225 /// other test holds `DOIGET_KEY_*` set without its agreement var, so a
1226 /// reader that lands inside that window panics on `.expect("profile")`.
1227 /// `#[serial]` on the writer cannot help — it serialises marked tests
1228 /// against each other, and the readers were unmarked.
1229 ///
1230 /// It also makes the tests deterministic on a developer machine that
1231 /// happens to export `DOIGET_KEY_ELSEVIER`, which `#[serial]` cannot fix
1232 /// at all.
1233 ///
1234 /// Tests that genuinely exercise env resolution must keep
1235 /// [`Self::from_env`] **and** carry `#[serial_test::serial]`.
1236 ///
1237 /// Deliberately not `Default`: the type-level docs defer that to Phase 1
1238 /// "once the field set stabilizes", and a public `Default` would invite
1239 /// production code to skip the resolution rules.
1240 ///
1241 /// `#[cfg(test)]`, not merely `#[doc(hidden)]`. The #468 review pointed
1242 /// out that `#[doc(hidden)] pub` hides a function from rendered docs and
1243 /// from nothing else — it would still be compiled into every published
1244 /// build of this crate and callable by any downstream consumer, which is
1245 /// exactly what the paragraph above says a public constructor must not
1246 /// be. All 47 call sites are unit tests inside this crate (no
1247 /// integration test, no fuzz target, no other crate), so the gate costs
1248 /// nothing and the constructor does not exist in a release build.
1249 #[cfg(test)]
1250 #[must_use]
1251 pub(crate) fn for_tests() -> Self {
1252 Self {
1253 oa: AlwaysOn,
1254 // Every `DOIGET_ENABLE_*` unset — the same all-false shape
1255 // `from_env` produces with a clean environment.
1256 metadata: MetadataAccess::default(),
1257 tdm_elsevier: None,
1258 tdm_aps: None,
1259 tdm_springer: None,
1260 tdm_ieee: None,
1261 rate_limits: RateLimits::HARD_CODED,
1262 }
1263 }
1264
1265 /// Read the runtime profile from environment variables.
1266 ///
1267 /// Implements the resolution algorithm specified in
1268 /// [`docs/CAPABILITY.md`](../../../docs/CAPABILITY.md) §2.
1269 ///
1270 /// # Tier 1 (Open Access)
1271 ///
1272 /// Always permitted; not gated on any env var or feature.
1273 ///
1274 /// # Tier 2 (metadata)
1275 ///
1276 /// Each metadata source becomes available when its env var is set
1277 /// (presence-checked, value ignored) **and** the `metadata` Cargo feature
1278 /// was compiled in. If the env var is set but the feature is not compiled
1279 /// in, a `tracing::warn!` is emitted and the source is left disabled —
1280 /// this is not an error so that users can move binaries between machines
1281 /// (or switch feature sets between cargo invocations) without breaking
1282 /// startup. See `docs/CAPABILITY.md` §3 for the env var list.
1283 ///
1284 /// # Tier 3 (TDM)
1285 ///
1286 /// For each publisher in `{ELSEVIER, APS, SPRINGER}`, the
1287 /// `DOIGET_AGREE_TDM_<X>` agreement env var is paired with
1288 /// `DOIGET_KEY_<X>`. Resolution rules (per `docs/CAPABILITY.md` §2):
1289 ///
1290 /// - both unset → `tdm_<x> = None` (no error);
1291 /// - `agree == "1"` and key set → `Some(TdmGrant { .. })` (subject to the
1292 /// feature gate below);
1293 /// - `agree == "1"` and key unset → [`CapabilityError::AgreedButNoKey`];
1294 /// - key set but `agree` unset (or `agree != "1"`) →
1295 /// [`CapabilityError::KeyButNotAgreed`].
1296 ///
1297 /// When both env vars are set correctly **but** the corresponding
1298 /// `tdm-<x>` Cargo feature is not compiled in, this function emits a
1299 /// `tracing::warn!` and sets the grant to `None` rather than returning an
1300 /// error — same rationale as for the Tier 2 warn-and-skip behavior.
1301 ///
1302 /// # Precondition: tracing subscriber must be installed first
1303 ///
1304 /// Warn breadcrumbs are delivered via `tracing::warn!`. Callers MUST
1305 /// install a `tracing-subscriber` (or equivalent) **before** invoking
1306 /// this function, otherwise warnings are silently dropped. The
1307 /// `doiget-cli` binary does this in `main.rs`.
1308 ///
1309 /// # Errors
1310 ///
1311 /// Returns [`CapabilityError::AgreedButNoKey`] or
1312 /// [`CapabilityError::KeyButNotAgreed`] when the TDM env-var pair for any
1313 /// publisher is misconfigured. See the variant docs for the precise
1314 /// trigger conditions.
1315 ///
1316 /// # Note on `api_key` storage
1317 ///
1318 /// When a `tdm-*` feature is compiled in, [`TdmGrant`] carries the
1319 /// validated key as `secrecy::SecretString` (issue #153). The key is
1320 /// read exactly once here, at startup; TDM sources consume it from the
1321 /// grant and never re-read the env var at fetch time. This makes the
1322 /// grant a true startup attestation — an env mutation between startup
1323 /// and fetch can no longer silently change the credential in flight.
1324 /// See the [`TdmGrant`] doc-comment and `docs/CAPABILITY.md` §1/§2.
1325 pub fn from_env() -> Result<Self, CapabilityError> {
1326 // Issue #153: the validated API key is now threaded through
1327 // `TdmGrant` (as `secrecy::SecretString`, behind the `tdm-*`
1328 // features) by `resolve_tdm_grant` below — sources no longer
1329 // re-read the key env var at fetch time. See the `TdmGrant`
1330 // doc-comment and `docs/CAPABILITY.md` §1/§2.
1331
1332 // -- Tier 2 metadata -------------------------------------------------
1333 let metadata = MetadataAccess {
1334 openalex: resolve_metadata_flag(
1335 "DOIGET_ENABLE_OPENALEX",
1336 "metadata",
1337 cfg!(feature = "metadata"),
1338 ),
1339 semantic_scholar: resolve_metadata_flag(
1340 "DOIGET_ENABLE_S2",
1341 "metadata",
1342 cfg!(feature = "metadata"),
1343 ),
1344 doaj: resolve_metadata_flag(
1345 "DOIGET_ENABLE_DOAJ",
1346 "metadata",
1347 cfg!(feature = "metadata"),
1348 ),
1349 datacite: resolve_metadata_flag(
1350 "DOIGET_ENABLE_DATACITE",
1351 "metadata",
1352 cfg!(feature = "metadata"),
1353 ),
1354 hal: resolve_metadata_flag("DOIGET_ENABLE_HAL", "metadata", cfg!(feature = "metadata")),
1355 openaire: resolve_metadata_flag(
1356 "DOIGET_ENABLE_OPENAIRE",
1357 "metadata",
1358 cfg!(feature = "metadata"),
1359 ),
1360 core: resolve_metadata_flag(
1361 "DOIGET_ENABLE_CORE",
1362 "metadata",
1363 cfg!(feature = "metadata"),
1364 ),
1365 europe_pmc: resolve_metadata_flag(
1366 "DOIGET_ENABLE_EUROPE_PMC",
1367 "metadata",
1368 cfg!(feature = "metadata"),
1369 ),
1370 };
1371
1372 // -- Tier 3 TDM grants ----------------------------------------------
1373 // #509: the key may also come from `credentials.toml`, which
1374 // `docs/CONFIG.md` §6 has specified in full since 0.7 and which
1375 // nothing read. Loaded once — one file, one reader, so `config
1376 // doctor` and a fetch can never describe different files (#441's
1377 // lesson). The **agreement** stays environment-only; see the
1378 // `credentials` module docs and `docs/LEGAL.md` §6a.2.
1379 let creds = crate::credentials::load_or_default();
1380 let tdm_elsevier = resolve_tdm_grant(
1381 AgreeVar::new("DOIGET_AGREE_TDM_ELSEVIER"),
1382 KeyVar::new("DOIGET_KEY_ELSEVIER"),
1383 "tdm-elsevier",
1384 cfg!(feature = "tdm-elsevier"),
1385 creds.api_key("elsevier"),
1386 )?;
1387 let tdm_aps = resolve_tdm_grant(
1388 AgreeVar::new("DOIGET_AGREE_TDM_APS"),
1389 KeyVar::new("DOIGET_KEY_APS"),
1390 "tdm-aps",
1391 cfg!(feature = "tdm-aps"),
1392 creds.api_key("aps"),
1393 )?;
1394 let tdm_springer = resolve_tdm_grant(
1395 AgreeVar::new("DOIGET_AGREE_TDM_SPRINGER"),
1396 KeyVar::new("DOIGET_KEY_SPRINGER"),
1397 "tdm-springer",
1398 cfg!(feature = "tdm-springer"),
1399 creds.api_key("springer"),
1400 )?;
1401 let tdm_ieee = resolve_tdm_grant(
1402 AgreeVar::new("DOIGET_AGREE_TDM_IEEE"),
1403 KeyVar::new("DOIGET_KEY_IEEE"),
1404 "tdm-ieee",
1405 cfg!(feature = "tdm-ieee"),
1406 creds.api_key("ieee"),
1407 )?;
1408
1409 Ok(Self {
1410 oa: AlwaysOn,
1411 metadata,
1412 tdm_elsevier,
1413 tdm_aps,
1414 tdm_springer,
1415 tdm_ieee,
1416 rate_limits: RateLimits::HARD_CODED,
1417 })
1418 }
1419}
1420
1421/// Resolve a Tier 2 metadata flag from its env var and compile-in feature.
1422///
1423/// Returns `true` only when both the env var is present and the feature is
1424/// compiled in. When the env var is set without the feature, emits a
1425/// `tracing::warn!` and returns `false` — see [`CapabilityProfile::from_env`]
1426/// for the rationale (binaries may move between hosts / feature sets).
1427fn resolve_metadata_flag(env_var: &str, feature: &str, feature_enabled: bool) -> bool {
1428 let env_set = std::env::var_os(env_var).is_some();
1429 match (env_set, feature_enabled) {
1430 (true, true) => true,
1431 (true, false) => {
1432 tracing::warn!(
1433 env_var,
1434 feature,
1435 "{} is set but feature {} was not compiled in; the source will be unavailable",
1436 env_var,
1437 feature
1438 );
1439 false
1440 }
1441 (false, _) => false,
1442 }
1443}
1444
1445/// The env var carrying the per-publisher agreement.
1446///
1447/// A newtype because `agree_var` and `key_var` were adjacent `&str`
1448/// parameters: transposing them at a call site type-checked, and the
1449/// resulting build would treat the KEY as the agreement signal and the
1450/// AGREEMENT as the key. `docs/LEGAL.md` §6a.2 makes that agreement an
1451/// enforced control, so "nothing stops a fifth publisher's call site from
1452/// being copy-pasted wrong" is not a risk worth carrying for two saved
1453/// characters.
1454///
1455/// `pub(crate)` with a private field: the only consumer is a private `fn`
1456/// in this module, so a public tuple struct added semver surface nothing
1457/// outside the crate can reach. The private field also closes the variant
1458/// the newtype alone did not — `AgreeVar("DOIGET_KEY_ELSEVIER")` is the
1459/// same transposition expressed as content rather than position, and it
1460/// compiled. [`AgreeVar::new`] refuses it.
1461#[derive(Debug, Clone, Copy)]
1462pub(crate) struct AgreeVar(&'static str);
1463
1464/// The env var carrying the per-publisher API key. See [`AgreeVar`].
1465#[derive(Debug, Clone, Copy)]
1466pub(crate) struct KeyVar(&'static str);
1467
1468impl AgreeVar {
1469 /// # Panics
1470 ///
1471 /// If `var` is not a `DOIGET_AGREE_TDM_*` name. Every argument is a
1472 /// literal in this file, so this is a typo caught at the first test
1473 /// run, not a runtime failure mode.
1474 pub(crate) fn new(var: &'static str) -> Self {
1475 assert!(
1476 var.starts_with("DOIGET_AGREE_TDM_"),
1477 "{var} is not an agreement variable"
1478 );
1479 Self(var)
1480 }
1481}
1482
1483impl KeyVar {
1484 /// # Panics
1485 ///
1486 /// If `var` is not a `DOIGET_KEY_*` name. See [`AgreeVar::new`].
1487 pub(crate) fn new(var: &'static str) -> Self {
1488 assert!(
1489 var.starts_with("DOIGET_KEY_"),
1490 "{var} is not a key variable"
1491 );
1492 Self(var)
1493 }
1494}
1495
1496/// Resolve a Tier 3 TDM grant from the agreement env var, the key (env var
1497/// or `credentials.toml`), and the per-publisher Cargo feature.
1498///
1499/// Implements the rules in `docs/CAPABILITY.md` §2:
1500///
1501/// - both unset → `Ok(None)`.
1502/// - `agree == "1"` and a key → `Ok(Some(TdmGrant { .. }))` (when the
1503/// feature is enabled), or warn-and-`Ok(None)` (when the feature is not
1504/// compiled in).
1505/// - `agree == "1"` and no key → [`CapabilityError::AgreedButNoKey`].
1506/// - a key, and `agree` unset OR set to anything other than `"1"` →
1507/// [`CapabilityError::KeyButNotAgreed`].
1508///
1509/// `file_key` is `[tdm.<publisher>] api_key` from `credentials.toml`, one
1510/// rung **below** `DOIGET_KEY_<PUBLISHER>` (#509). The two rules above are
1511/// unchanged by its existence: a key from the file still needs the
1512/// agreement, and the agreement still comes only from the environment, so
1513/// `KeyButNotAgreed` now also fires for a file-supplied key with no
1514/// `DOIGET_AGREE_TDM_<PUBLISHER>=1`. That is the point — `docs/LEGAL.md`
1515/// §6a.2 is an enforced control, and a convenience must not dilute it.
1516fn resolve_tdm_grant(
1517 agree: AgreeVar,
1518 key: KeyVar,
1519 feature: &str,
1520 feature_enabled: bool,
1521 file_key: Option<&str>,
1522) -> Result<Option<TdmGrant>, CapabilityError> {
1523 let (agree_var, key_var) = (agree.0, key.0);
1524 // `agree` is "agreed" iff the value is exactly the literal "1"; any other
1525 // value (including "true", "yes", empty) is treated as not-agreed per
1526 // `docs/CAPABILITY.md` §2.
1527 let agree_raw = std::env::var(agree_var).ok();
1528 let agreed = matches!(agree_raw.as_deref(), Some("1"));
1529 let agree_present = agree_raw.is_some();
1530 // Read the key value once, at startup, so the validated key flows
1531 // through `TdmGrant` and sources never re-read the env (issue #153).
1532 // An empty value is treated as "not set" — an empty API key cannot
1533 // authenticate, and silently constructing a grant around it would
1534 // mask the misconfiguration the AgreedButNoKey rule exists to surface.
1535 //
1536 // Env above file (#509), matching `docs/CONFIG.md` §1 and the
1537 // `store_root` / `contact_email` rungs. `credentials.toml` has already
1538 // applied the same blank-is-unset rule.
1539 let key_value = std::env::var(key_var)
1540 .ok()
1541 .filter(|v| !v.trim().is_empty())
1542 .or_else(|| file_key.map(str::to_string));
1543
1544 match (agreed, agree_present, key_value) {
1545 (true, _, Some(key)) => {
1546 if feature_enabled {
1547 Ok(Some(build_tdm_grant(agree_var, key)))
1548 } else {
1549 // `key` is dropped here; under no-tdm builds it is the only
1550 // consumer of the owned `String`, which is intended.
1551 let _ = key;
1552 tracing::warn!(
1553 env_var = agree_var,
1554 feature,
1555 "{} is set but feature {} was not compiled in; the source will be unavailable",
1556 agree_var,
1557 feature
1558 );
1559 Ok(None)
1560 }
1561 }
1562 (true, _, None) => Err(CapabilityError::AgreedButNoKey {
1563 agree_var: agree_var.to_string(),
1564 key_var: key_var.to_string(),
1565 }),
1566 // agree set to non-"1", key also set: KeyButNotAgreed (the key would
1567 // otherwise authorize the source without an explicit agreement).
1568 (false, true, Some(_)) => Err(CapabilityError::KeyButNotAgreed {
1569 agree_var: agree_var.to_string(),
1570 }),
1571 // agree unset, key set: KeyButNotAgreed (same rule).
1572 (false, false, Some(_)) => Err(CapabilityError::KeyButNotAgreed {
1573 agree_var: agree_var.to_string(),
1574 }),
1575 // agree set to non-"1" and no key: treat as no-grant. The user
1576 // expressed something but did not opt in and provided no credential,
1577 // so silent skip is the safe default (no source enabled).
1578 (false, true, None) => Ok(None),
1579 // Neither env var set: no grant, no error.
1580 (false, false, None) => Ok(None),
1581 }
1582}
1583
1584/// Construct a [`TdmGrant`] from the validated agreement var and key value.
1585///
1586/// Split out so the `tdm-*`-gated `api_key` field is populated in exactly
1587/// one place. When no `tdm-*` feature is compiled in the `key` is consumed
1588/// (dropped) here — the grant is still produced so that startup attestation
1589/// behavior (the warn-and-skip path) does not change shape between feature
1590/// sets.
1591fn build_tdm_grant(agree_var: &str, key: String) -> TdmGrant {
1592 #[cfg(any(
1593 feature = "tdm-elsevier",
1594 feature = "tdm-aps",
1595 feature = "tdm-springer",
1596 feature = "tdm-ieee"
1597 ))]
1598 {
1599 TdmGrant {
1600 api_key: secrecy::SecretString::from(key),
1601 agree_env_var: agree_var.to_string(),
1602 agreed_at: chrono::Utc::now(),
1603 }
1604 }
1605 #[cfg(not(any(
1606 feature = "tdm-elsevier",
1607 feature = "tdm-aps",
1608 feature = "tdm-springer",
1609 feature = "tdm-ieee"
1610 )))]
1611 {
1612 let _ = key;
1613 TdmGrant {
1614 agree_env_var: agree_var.to_string(),
1615 agreed_at: chrono::Utc::now(),
1616 }
1617 }
1618}
1619
1620// ---------------------------------------------------------------------------
1621// Tests — one smoke test per legally-load-bearing constant. See
1622// `docs/LEGAL.md` §6 safeguard 8 and `docs/PHASES.md` §4. These also keep the
1623// `cargo test --workspace` job from being a false-green during Phase 0.
1624// ---------------------------------------------------------------------------
1625
1626// `expect`/`unwrap` are idiomatic in tests where panics double as assertions.
1627// The workspace lints deny them in production code; relax for the test module
1628// only.
1629#[cfg(test)]
1630#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1631mod tests {
1632 use super::*;
1633
1634 #[test]
1635 fn rate_limits_hard_coded_match_legal_safeguards() {
1636 // docs/LEGAL.md §6 safeguard 8 names these exact values.
1637 assert_eq!(RateLimits::HARD_CODED.max_concurrent_fetches(), 5);
1638 assert!((RateLimits::HARD_CODED.max_fetches_per_second() - 5.0).abs() < f32::EPSILON);
1639 assert_eq!(RateLimits::HARD_CODED.per_source_backoff_ms(), 200);
1640 }
1641
1642 #[test]
1643 fn batch_size_caps_match_security_doc() {
1644 // docs/SECURITY.md §1.4 + docs/MCP_TOOLS.md.
1645 assert_eq!(MCP_BATCH_MAX_SIZE, 100);
1646 assert_eq!(MCP_QUEUE_DEPTH_MAX, 100);
1647 assert_eq!(DOI_SUFFIX_MAX_LEN, 256);
1648 assert_eq!(MCP_STDIN_EOF_SHUTDOWN_SEC, 5);
1649 // Slice 2: spec-language alias for MCP_BATCH_MAX_SIZE must
1650 // numerically agree with the original constant.
1651 assert_eq!(MAX_BATCH_REFS, MCP_BATCH_MAX_SIZE);
1652 }
1653
1654 #[test]
1655 fn schema_version_is_pinned_to_1_0() {
1656 // docs/STORE.md §3 — Phase 0/1 writes 1.0 exactly.
1657 // A bump to 1.1 (minor, backward-compat additions) requires updating
1658 // both this test and the cross-tool compat fixtures simultaneously.
1659 assert_eq!(SCHEMA_VERSION, "1.0");
1660 }
1661
1662 // -----------------------------------------------------------------
1663 // CapabilityProfile::from_env — Phase 1 resolution algorithm tests.
1664 //
1665 // These tests mutate process-global env state via std::env::set_var /
1666 // remove_var, so each test holds an `EnvGuard` RAII drop guard that
1667 // captures the pre-test value of every env var it touches and restores
1668 // it on drop (even on panic). They also use `#[serial_test::serial]` so
1669 // that no two tests in this module touch env state concurrently — the
1670 // workspace's test runner defaults to multi-threaded.
1671 //
1672 // Spec: docs/CAPABILITY.md §2 (resolution algorithm) and §3 (env var
1673 // reference table).
1674 // -----------------------------------------------------------------
1675
1676 /// RAII guard that captures the prior value of an env var on construction
1677 /// and restores it on drop. Use one guard per touched var per test.
1678 struct EnvGuard {
1679 var: &'static str,
1680 prior: Option<std::ffi::OsString>,
1681 }
1682
1683 impl EnvGuard {
1684 /// Capture and clear `var`. Use `set` afterwards to install a value.
1685 fn unset(var: &'static str) -> Self {
1686 let prior = std::env::var_os(var);
1687 // SAFETY (env mutation): tests are serialized via
1688 // `#[serial_test::serial]`. `remove_var` is sound when no other
1689 // thread reads or writes the environment concurrently.
1690 std::env::remove_var(var);
1691 EnvGuard { var, prior }
1692 }
1693
1694 /// Capture, then set `var` to `value`.
1695 fn set(var: &'static str, value: &str) -> Self {
1696 let prior = std::env::var_os(var);
1697 std::env::set_var(var, value);
1698 EnvGuard { var, prior }
1699 }
1700 }
1701
1702 impl Drop for EnvGuard {
1703 fn drop(&mut self) {
1704 match &self.prior {
1705 Some(v) => std::env::set_var(self.var, v),
1706 None => std::env::remove_var(self.var),
1707 }
1708 }
1709 }
1710
1711 /// Point every config-dir rung at `dir`, so `credentials.toml` and
1712 /// `config.toml` resolve there. Returns guards restoring prior values.
1713 fn scoped_config_home(dir: &str) -> Vec<EnvGuard> {
1714 ["XDG_CONFIG_HOME", "APPDATA", "HOME", "USERPROFILE"]
1715 .iter()
1716 .map(|v| EnvGuard::set(v, dir))
1717 .collect()
1718 }
1719
1720 /// Convenience: unset every Tier 2 / Tier 3 env var the resolution
1721 /// algorithm reads, returning a vector of guards that restore them on
1722 /// drop. Callers can then `EnvGuard::set` individual vars on top.
1723 ///
1724 /// The caller MUST also scope the config directory — see
1725 /// [`isolated_capability_env`]. Since #509 the TDM key has a
1726 /// `credentials.toml` rung, so a test that only clears the environment
1727 /// reads the developer's real credentials file: green in CI and red on
1728 /// the one machine that has TDM configured, which is the least useful
1729 /// place for a test to fail.
1730 fn unset_all_capability_env_vars() -> Vec<EnvGuard> {
1731 [
1732 "DOIGET_ENABLE_OPENALEX",
1733 "DOIGET_ENABLE_S2",
1734 "DOIGET_ENABLE_DOAJ",
1735 "DOIGET_AGREE_TDM_ELSEVIER",
1736 "DOIGET_KEY_ELSEVIER",
1737 "DOIGET_AGREE_TDM_APS",
1738 "DOIGET_KEY_APS",
1739 "DOIGET_AGREE_TDM_SPRINGER",
1740 "DOIGET_KEY_SPRINGER",
1741 "DOIGET_AGREE_TDM_IEEE",
1742 "DOIGET_KEY_IEEE",
1743 ]
1744 .iter()
1745 .map(|v| EnvGuard::unset(v))
1746 .collect()
1747 }
1748
1749 /// Clean environment AND an empty config directory, so
1750 /// `CapabilityProfile::from_env` sees neither an env var nor a
1751 /// credentials file. Hold the returned tuple for the test's lifetime.
1752 fn isolated_capability_env() -> (tempfile::TempDir, Vec<EnvGuard>, Vec<EnvGuard>) {
1753 isolated_env_with(None)
1754 }
1755
1756 /// As [`isolated_capability_env`], optionally writing `credentials.toml`.
1757 fn isolated_env_with(
1758 credentials: Option<&str>,
1759 ) -> (tempfile::TempDir, Vec<EnvGuard>, Vec<EnvGuard>) {
1760 let td = tempfile::TempDir::new().expect("tempdir");
1761 let dir = camino::Utf8PathBuf::from_path_buf(td.path().to_path_buf())
1762 .expect("temp path is UTF-8");
1763 if let Some(body) = credentials {
1764 std::fs::create_dir_all(dir.join("doiget").as_std_path()).expect("mkdir");
1765 std::fs::write(
1766 dir.join("doiget").join("credentials.toml").as_std_path(),
1767 body,
1768 )
1769 .expect("write credentials.toml");
1770 }
1771 let env = unset_all_capability_env_vars();
1772 let home = scoped_config_home(dir.as_str());
1773 (td, env, home)
1774 }
1775
1776 /// #509: `credentials.toml` supplies the KEY, and the agreement still
1777 /// comes only from the environment.
1778 ///
1779 /// Asserts the production path (`CapabilityProfile::from_env`), not the
1780 /// parser — the parser was never the missing part. #442, #454 and #458
1781 /// were each a correct component nothing reached, and a file reader
1782 /// with no caller would be that defect again.
1783 ///
1784 /// Holds in the shipped `oa-only` build: `KeyButNotAgreed` fires before
1785 /// any feature gate, so this proves the file is read without needing a
1786 /// `tdm-*` feature compiled.
1787 #[test]
1788 #[serial_test::serial]
1789 fn a_key_from_credentials_toml_is_read_and_still_needs_the_agreement() {
1790 let (_td, _env, _home) = isolated_env_with(Some(
1791 "[tdm.elsevier]
1792api_key = \"file-key\"
1793",
1794 ));
1795
1796 match CapabilityProfile::from_env() {
1797 Err(CapabilityError::KeyButNotAgreed { agree_var }) => {
1798 assert_eq!(agree_var, "DOIGET_AGREE_TDM_ELSEVIER");
1799 }
1800 other => panic!(
1801 "a key in credentials.toml must be READ, so the missing agreement is reported. Before #509 this was Ok(no grant) because the file was never opened. Got {other:?}"
1802 ),
1803 }
1804 }
1805
1806 /// The half that must NOT work: `agreed = true` in the file is not an
1807 /// agreement (`docs/LEGAL.md` §6a.2). Key from the file, no
1808 /// `DOIGET_AGREE_TDM_ELSEVIER` — still `KeyButNotAgreed`.
1809 #[test]
1810 #[serial_test::serial]
1811 fn agreed_in_credentials_toml_does_not_grant_anything() {
1812 let (_td, _env, _home) = isolated_env_with(Some(
1813 "[tdm.elsevier]
1814api_key = \"file-key\"
1815agreed = true
1816",
1817 ));
1818
1819 match CapabilityProfile::from_env() {
1820 Err(CapabilityError::KeyButNotAgreed { agree_var }) => {
1821 assert_eq!(agree_var, "DOIGET_AGREE_TDM_ELSEVIER");
1822 }
1823 other => panic!(
1824 "`agreed` in the file must not substitute for the environment agreement; got {other:?}"
1825 ),
1826 }
1827 }
1828
1829 /// Env above file, per `docs/CONFIG.md` §1: the agreement plus either
1830 /// key resolves, and a blank env key falls through to the file rather
1831 /// than counting as a key (the blank-is-unset rule every rung uses).
1832 #[test]
1833 #[serial_test::serial]
1834 fn the_env_key_outranks_the_file_and_a_blank_one_falls_through() {
1835 let _g = unset_all_capability_env_vars();
1836
1837 let granted = resolve_tdm_grant(
1838 AgreeVar::new("DOIGET_AGREE_TDM_ELSEVIER"),
1839 KeyVar::new("DOIGET_KEY_ELSEVIER"),
1840 "tdm-elsevier",
1841 false,
1842 Some("file-key"),
1843 );
1844 match granted {
1845 Err(CapabilityError::KeyButNotAgreed { .. }) => {}
1846 other => panic!("a file key with no agreement is KeyButNotAgreed; got {other:?}"),
1847 }
1848
1849 let _key = EnvGuard::set("DOIGET_KEY_ELSEVIER", " ");
1850 match resolve_tdm_grant(
1851 AgreeVar::new("DOIGET_AGREE_TDM_ELSEVIER"),
1852 KeyVar::new("DOIGET_KEY_ELSEVIER"),
1853 "tdm-elsevier",
1854 false,
1855 Some("file-key"),
1856 ) {
1857 Err(CapabilityError::KeyButNotAgreed { .. }) => {}
1858 other => panic!("a blank env key must fall through to the file; got {other:?}"),
1859 }
1860
1861 let _agree = EnvGuard::set("DOIGET_AGREE_TDM_ELSEVIER", "1");
1862 assert!(
1863 resolve_tdm_grant(
1864 AgreeVar::new("DOIGET_AGREE_TDM_ELSEVIER"),
1865 KeyVar::new("DOIGET_KEY_ELSEVIER"),
1866 "tdm-elsevier",
1867 false,
1868 Some("file-key"),
1869 )
1870 .is_ok(),
1871 "agreement + a file key is a valid configuration"
1872 );
1873 }
1874
1875 #[test]
1876 #[serial_test::serial]
1877 fn from_env_no_env_vars_set_returns_tier_1_only() {
1878 // Rule: with every relevant env var unset, the resolved profile has
1879 // all TDM grants `None` and all metadata flags `false`. Hard-coded
1880 // rate limits still apply. (Replaces the old Phase 0 stub test.)
1881 let (_td, _g, _home) = isolated_capability_env();
1882
1883 let p = CapabilityProfile::from_env().expect("clean env never errors");
1884 assert!(p.tdm_elsevier.is_none());
1885 assert!(p.tdm_aps.is_none());
1886 assert!(p.tdm_springer.is_none());
1887 assert!(!p.metadata.openalex);
1888 assert!(!p.metadata.semantic_scholar);
1889 assert!(!p.metadata.doaj);
1890 assert_eq!(p.rate_limits.max_concurrent_fetches(), 5);
1891 }
1892
1893 #[test]
1894 #[serial_test::serial]
1895 fn from_env_no_tdm_returns_tier_1_profile() {
1896 // Rule (CAPABILITY.md §2): with every TDM env var unset, all
1897 // `tdm_*` fields are `None` and no error is produced.
1898 let (_td, _g, _home) = isolated_capability_env();
1899
1900 let p = CapabilityProfile::from_env().expect("no TDM env -> Ok");
1901 assert!(p.tdm_elsevier.is_none());
1902 assert!(p.tdm_aps.is_none());
1903 assert!(p.tdm_springer.is_none());
1904 }
1905
1906 #[test]
1907 #[serial_test::serial]
1908 fn from_env_agreed_but_no_key_errs() {
1909 // Rule (CAPABILITY.md §2): agree=1 + key unset -> AgreedButNoKey.
1910 let (_td, _g, _home) = isolated_capability_env();
1911 let _agree = EnvGuard::set("DOIGET_AGREE_TDM_ELSEVIER", "1");
1912
1913 let result = CapabilityProfile::from_env();
1914 match result {
1915 Err(CapabilityError::AgreedButNoKey { agree_var, key_var }) => {
1916 assert_eq!(agree_var, "DOIGET_AGREE_TDM_ELSEVIER");
1917 assert_eq!(key_var, "DOIGET_KEY_ELSEVIER");
1918 }
1919 other => panic!("expected AgreedButNoKey, got {:?}", other),
1920 }
1921 }
1922
1923 #[test]
1924 #[serial_test::serial]
1925 fn from_env_agreed_but_empty_key_errs() {
1926 // Security-adjacent (PR #161 review): an *empty* key string is
1927 // treated as "not set" by `resolve_tdm_grant`. With agree=1 and
1928 // DOIGET_KEY_ELSEVIER="" the misconfiguration must surface as
1929 // AgreedButNoKey, not silently build a grant around an empty
1930 // secret that could never authenticate.
1931 let (_td, _g, _home) = isolated_capability_env();
1932 let _agree = EnvGuard::set("DOIGET_AGREE_TDM_ELSEVIER", "1");
1933 let _key = EnvGuard::set("DOIGET_KEY_ELSEVIER", "");
1934
1935 let result = CapabilityProfile::from_env();
1936 match result {
1937 Err(CapabilityError::AgreedButNoKey { agree_var, key_var }) => {
1938 assert_eq!(agree_var, "DOIGET_AGREE_TDM_ELSEVIER");
1939 assert_eq!(key_var, "DOIGET_KEY_ELSEVIER");
1940 }
1941 other => panic!("expected AgreedButNoKey for empty key, got {:?}", other),
1942 }
1943 }
1944
1945 #[test]
1946 #[serial_test::serial]
1947 fn from_env_empty_key_without_agree_is_no_grant() {
1948 // Security-adjacent (PR #161 review): an empty key with the
1949 // agree var unset is indistinguishable from "no key at all".
1950 // It must resolve to Ok(None) (no grant, no error) — an empty
1951 // string must NOT trip the KeyButNotAgreed leaked-credential
1952 // rule, since there is no credential.
1953 let (_td, _g, _home) = isolated_capability_env();
1954 let _key = EnvGuard::set("DOIGET_KEY_ELSEVIER", "");
1955
1956 let p = CapabilityProfile::from_env()
1957 .expect("empty key + agree unset must be Ok(None), not an error");
1958 assert!(
1959 p.tdm_elsevier.is_none(),
1960 "empty DOIGET_KEY_ELSEVIER with no agree var must yield no grant"
1961 );
1962 assert!(p.tdm_aps.is_none());
1963 assert!(p.tdm_springer.is_none());
1964 }
1965
1966 #[test]
1967 #[serial_test::serial]
1968 fn from_env_key_but_not_agreed_errs() {
1969 // Rule (CAPABILITY.md §2): key set + agree unset -> KeyButNotAgreed.
1970 // A leaked DOIGET_KEY_ELSEVIER must not silently enable a source.
1971 let (_td, _g, _home) = isolated_capability_env();
1972 let _key = EnvGuard::set("DOIGET_KEY_ELSEVIER", "sk-test");
1973
1974 let result = CapabilityProfile::from_env();
1975 match result {
1976 Err(CapabilityError::KeyButNotAgreed { agree_var }) => {
1977 assert_eq!(agree_var, "DOIGET_AGREE_TDM_ELSEVIER");
1978 }
1979 other => panic!("expected KeyButNotAgreed, got {:?}", other),
1980 }
1981 }
1982
1983 #[test]
1984 #[serial_test::serial]
1985 fn from_env_agree_not_one_errs() {
1986 // Rule (CAPABILITY.md §2): the agree var must be exactly "1". Any
1987 // other value (here: "true") is treated as not-agreed; combined
1988 // with a key set, that triggers KeyButNotAgreed.
1989 let (_td, _g, _home) = isolated_capability_env();
1990 let _agree = EnvGuard::set("DOIGET_AGREE_TDM_ELSEVIER", "true");
1991 let _key = EnvGuard::set("DOIGET_KEY_ELSEVIER", "sk-test");
1992
1993 let result = CapabilityProfile::from_env();
1994 match result {
1995 Err(CapabilityError::KeyButNotAgreed { agree_var }) => {
1996 assert_eq!(agree_var, "DOIGET_AGREE_TDM_ELSEVIER");
1997 }
1998 other => panic!("expected KeyButNotAgreed, got {:?}", other),
1999 }
2000 }
2001
2002 #[test]
2003 #[serial_test::serial]
2004 fn from_env_both_set_correctly_returns_grant() {
2005 // Rule (CAPABILITY.md §2): agree=1 + key set -> Some(TdmGrant) when
2006 // the corresponding feature is compiled in; else None (warn-and-skip).
2007 // The feature gate for elsevier is `tdm-elsevier`; this test asserts
2008 // both branches via `cfg!`.
2009 let _g = unset_all_capability_env_vars();
2010 let _agree = EnvGuard::set("DOIGET_AGREE_TDM_ELSEVIER", "1");
2011 let _key = EnvGuard::set("DOIGET_KEY_ELSEVIER", "sk-test");
2012
2013 let p = CapabilityProfile::from_env().expect("agree=1 + key -> Ok");
2014
2015 if cfg!(feature = "tdm-elsevier") {
2016 let grant = p
2017 .tdm_elsevier
2018 .as_ref()
2019 .expect("feature tdm-elsevier compiled in -> Some(TdmGrant)");
2020 assert_eq!(grant.agree_env_var, "DOIGET_AGREE_TDM_ELSEVIER");
2021 // Issue #153 / PR #161 review: prove the key was actually
2022 // threaded into TdmGrant::api_key at startup (not just that
2023 // the agree var was recorded). The field is cfg-gated to
2024 // the same `tdm-*` set as the assertion below, so gate the
2025 // check identically.
2026 #[cfg(any(
2027 feature = "tdm-elsevier",
2028 feature = "tdm-aps",
2029 feature = "tdm-springer",
2030 feature = "tdm-ieee"
2031 ))]
2032 {
2033 use secrecy::ExposeSecret as _;
2034 assert_eq!(
2035 grant.api_key.expose_secret(),
2036 "sk-test",
2037 "the DOIGET_KEY_ELSEVIER value must be threaded into \
2038 TdmGrant::api_key (issue #153)"
2039 );
2040 }
2041 } else {
2042 assert!(
2043 p.tdm_elsevier.is_none(),
2044 "feature tdm-elsevier NOT compiled in -> None (warn-and-skip)"
2045 );
2046 }
2047 }
2048
2049 #[test]
2050 #[serial_test::serial]
2051 fn from_env_metadata_env_warns_without_feature() {
2052 // Rule (CAPABILITY.md §2): metadata env var without the `metadata`
2053 // feature -> source disabled (warn-and-skip, not an error).
2054 // We don't capture the tracing warn here; we just assert the field
2055 // is `false` when the feature is absent and `true` when present.
2056 let _g = unset_all_capability_env_vars();
2057 let _enable = EnvGuard::set("DOIGET_ENABLE_OPENALEX", "1");
2058
2059 let p = CapabilityProfile::from_env().expect("metadata env never errors");
2060
2061 if cfg!(feature = "metadata") {
2062 assert!(p.metadata.openalex);
2063 } else {
2064 assert!(!p.metadata.openalex);
2065 }
2066 }
2067
2068 // -----------------------------------------------------------------
2069 // Safekey reference vectors (docs/SAFEKEY.md §3, NORMATIVE).
2070 //
2071 // The vectors.json file is the binding cross-tool contract with
2072 // BiblioFetch.jl: every entry MUST round-trip identically through
2073 // both implementations. Phase 0 ships 13 entries; the full 100-entry
2074 // set is gated on the BiblioFetch.jl pre-flight (ADR-0007 Status:
2075 // Proposed at the time of this Phase 1 implementation).
2076 //
2077 // `Ref::parse` is concurrent W3-A work and is not on `main` yet, so
2078 // this test branches on the input prefix (`doi:` / `arxiv:`) and
2079 // constructs the variant directly via the in-crate `pub(crate)`
2080 // tuple constructor.
2081 // -----------------------------------------------------------------
2082
2083 #[derive(Deserialize)]
2084 struct SafekeyVector {
2085 input: String,
2086 expected: String,
2087 }
2088
2089 #[derive(Deserialize)]
2090 struct SafekeyVectorFile {
2091 vectors: Vec<SafekeyVector>,
2092 }
2093
2094 /// In-crate test helper: build a `Ref` from the user-facing form used
2095 /// in the vectors file, by stripping the `doi:` / `arxiv:` URI scheme
2096 /// and wrapping the remainder. This bypasses validation; it is fine
2097 /// here because the vectors are hand-curated and the test asserts the
2098 /// derivation algorithm, not parser semantics.
2099 fn ref_from_vector_input(input: &str) -> Ref {
2100 if let Some(rest) = input.strip_prefix("doi:") {
2101 Ref::Doi(Doi(rest.to_string()))
2102 } else if let Some(rest) = input.strip_prefix("arxiv:") {
2103 Ref::Arxiv(ArxivId(rest.to_string()))
2104 } else {
2105 panic!(
2106 "vectors.json entry has unknown ref scheme (expected doi: or arxiv: prefix): {}",
2107 input
2108 );
2109 }
2110 }
2111
2112 #[test]
2113 fn safekey_matches_reference_vectors() {
2114 // include_str! resolves relative to the file containing this macro
2115 // call (crates/doiget-core/src/lib.rs), so we go up three levels
2116 // to reach the workspace root, then down to tests/fixtures.
2117 let raw = include_str!("../../../tests/fixtures/safekey/vectors.json");
2118 let parsed: SafekeyVectorFile =
2119 serde_json::from_str(raw).expect("vectors.json is valid JSON matching schema");
2120
2121 // Phase 0 final ships the full NORMATIVE 100-entry set
2122 // (docs/SAFEKEY.md §5). The fixture is the binding cross-tool
2123 // contract with BiblioFetch.jl; tightening the count guard to
2124 // `== 100` ensures the set cannot silently grow or shrink without
2125 // a coordinated ADR bump (per docs/SAFEKEY.md status block).
2126 assert_eq!(
2127 parsed.vectors.len(),
2128 100,
2129 "vectors.json MUST be exactly 100 entries (NORMATIVE per docs/SAFEKEY.md §5); got {}",
2130 parsed.vectors.len()
2131 );
2132
2133 let mut failures: Vec<String> = Vec::new();
2134 for v in &parsed.vectors {
2135 let r = ref_from_vector_input(&v.input);
2136 let got = r.safekey().as_str().to_string();
2137 if got != v.expected {
2138 failures.push(format!(
2139 "input={:?}\n expected={:?}\n got ={:?}",
2140 v.input, v.expected, got
2141 ));
2142 }
2143 }
2144
2145 assert!(
2146 failures.is_empty(),
2147 "{}/{} safekey reference vectors failed:\n{}",
2148 failures.len(),
2149 parsed.vectors.len(),
2150 failures.join("\n")
2151 );
2152 }
2153
2154 #[test]
2155 fn safekey_truncates_long_inputs_with_sha256_suffix() {
2156 // Construct a synthetic DOI whose suffix produces a `trimmed` longer than
2157 // 192 chars after step 3. 220 ASCII-safe chars + the `doi_10.1234/`
2158 // prefix easily exceeds 192. The resulting key must be exactly 201 chars:
2159 // 192 (trimmed prefix) + 1 (`_` separator) + 8 (hex of first 4 bytes of
2160 // SHA-256(raw)). Per docs/SAFEKEY.md §3 step 5.
2161 let suffix = "a".repeat(220);
2162 let doi = Doi(format!("10.1234/{}", suffix));
2163 let key = Ref::Doi(doi).safekey();
2164 let s = key.as_str();
2165
2166 // Shape: <192 ASCII chars from {A-Za-z0-9._-}> + "_" + <8 hex chars>
2167 assert_eq!(
2168 s.len(),
2169 201,
2170 "expected 201-char truncated key, got {}: {}",
2171 s.len(),
2172 s
2173 );
2174 assert_eq!(&s[192..193], "_", "expected '_' separator at byte 192");
2175 let hash_part = &s[193..];
2176 assert_eq!(hash_part.len(), 8, "hash suffix must be 8 hex chars");
2177 assert!(
2178 hash_part
2179 .chars()
2180 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
2181 "hash suffix must be lowercase hex: {}",
2182 hash_part
2183 );
2184
2185 // Determinism: same input twice must produce the same key.
2186 let key2 = Ref::Doi(Doi(format!("10.1234/{}", "a".repeat(220)))).safekey();
2187 assert_eq!(s, key2.as_str(), "safekey must be deterministic");
2188
2189 // Hash content: must equal hex(sha256(raw)[..4]) where raw is the
2190 // pre-escape prefixed form per docs/SAFEKEY.md §3 step 5.
2191 use sha2::Digest;
2192 let raw = format!("doi_10.1234/{}", "a".repeat(220));
2193 let expected_hash = {
2194 let digest = sha2::Sha256::digest(raw.as_bytes());
2195 format!(
2196 "{:02x}{:02x}{:02x}{:02x}",
2197 digest[0], digest[1], digest[2], digest[3]
2198 )
2199 };
2200 assert_eq!(
2201 hash_part, expected_hash,
2202 "hash must match SHA-256 of raw form"
2203 );
2204 }
2205
2206 // -----------------------------------------------------------------
2207 // Doi::parse / ArxivId::parse / Ref::parse — Phase 1 W3-A.
2208 // Spec: docs/SECURITY.md §1.1 (input validation). The rejection
2209 // category set is the binding contract; each test case below names
2210 // which rule it exercises in a comment.
2211 // -----------------------------------------------------------------
2212
2213 // ---- Doi::parse happy paths (≥6) --------------------------------
2214
2215 #[test]
2216 fn doi_parse_accepts_bare_canonical_form() {
2217 // Rule: "10.<registrant>/<suffix>" is the canonical bare form.
2218 let d = Doi::parse("10.1234/example").expect("canonical bare DOI");
2219 assert_eq!(d.as_str(), "10.1234/example");
2220 }
2221
2222 #[test]
2223 fn doi_parse_accepts_doi_uri_scheme() {
2224 // Rule: the `doi:` scheme is stripped at construction; as_str
2225 // never carries it (matches docs/SAFEKEY.md §3 step 0).
2226 let d = Doi::parse("doi:10.1234/example").expect("doi: scheme accepted");
2227 assert_eq!(d.as_str(), "10.1234/example");
2228 }
2229
2230 #[test]
2231 fn doi_parse_accepts_complex_real_world_suffix() {
2232 // Rule: suffix charset includes `.`, `(`, `)`, `-`. From a real
2233 // PhysRevLett DOI used elsewhere in the test fixture set.
2234 let d = Doi::parse("10.1103/PhysRevLett.130.200601").expect("real-world PhysRev DOI");
2235 assert_eq!(d.as_str(), "10.1103/PhysRevLett.130.200601");
2236 }
2237
2238 #[test]
2239 fn doi_parse_accepts_parens_in_suffix() {
2240 // Rule: `(` and `)` are explicitly listed in the spec charset.
2241 let d = Doi::parse("10.1016/S0370-1573(98)00122-3").expect("parens in suffix");
2242 assert_eq!(d.as_str(), "10.1016/S0370-1573(98)00122-3");
2243 }
2244
2245 #[test]
2246 fn doi_parse_accepts_nested_slashes_in_suffix() {
2247 // Rule: `/` is a suffix character; only the first `/` is the
2248 // registrant/suffix separator.
2249 let d = Doi::parse("10.1234/foo/bar/baz").expect("nested slashes");
2250 assert_eq!(d.as_str(), "10.1234/foo/bar/baz");
2251 }
2252
2253 #[test]
2254 fn doi_parse_accepts_colon_in_legacy_kluwer_suffix() {
2255 // #194: legacy Kluwer/Springer DOIs (`10.1023/A:NNNNNNNNNN`)
2256 // carry a `:` in the suffix. Real DOI: "Entanglement, Quantum
2257 // Phase Transitions, and DMRG" (Kluwer, 2002).
2258 let d = Doi::parse("10.1023/A:1019601218492").expect("legacy Kluwer colon DOI");
2259 assert_eq!(d.as_str(), "10.1023/A:1019601218492");
2260 }
2261
2262 #[test]
2263 fn doi_parse_accepts_colon_in_edp_jphys_suffix() {
2264 // #194: EDP Sciences / Journal de Physique legacy corpus uses
2265 // `10.1051/jphys:NNNNNNNNNNNNNNNNN`. Real DOIs from the dogfood
2266 // Ising-RG run; both resolve at doi.org and via Crossref.
2267 let d = Doi::parse("10.1051/jphys:0198900500120136500").expect("EDP jphys colon DOI");
2268 assert_eq!(d.as_str(), "10.1051/jphys:0198900500120136500");
2269 let d2 = Doi::parse("doi:10.1051/jphys:0198500460100164500").expect("scheme + colon");
2270 assert_eq!(d2.as_str(), "10.1051/jphys:0198500460100164500");
2271 }
2272
2273 #[test]
2274 fn doi_parse_rejects_semicolon_in_suffix() {
2275 // #194 / ADR-0026: `;` is the natural ASCII neighbor of `:` and
2276 // is explicitly EXCLUDED from the suffix charset extension
2277 // (ADR-0026 §"Out of scope"). This test guards against an
2278 // over-broad `matches!` arm (e.g. an accidental `':'..=';'` range
2279 // typo) re-admitting `;` along with `:`.
2280 let result = Doi::parse("10.1234/foo;bar");
2281 assert!(
2282 matches!(result, Err(RefParseError::InvalidDoiSuffixChar { ch: ';' })),
2283 "expected InvalidDoiSuffixChar with ch=';', got {:?}",
2284 result
2285 );
2286 }
2287
2288 #[test]
2289 fn doi_parse_accepts_suffix_at_max_len_boundary() {
2290 // Rule: a suffix of exactly DOI_SUFFIX_MAX_LEN bytes is accepted;
2291 // 1 byte more is rejected (covered separately below).
2292 let suffix = "a".repeat(DOI_SUFFIX_MAX_LEN);
2293 let input = format!("10.1234/{}", suffix);
2294 let d = Doi::parse(&input).expect("suffix at max len");
2295 assert_eq!(d.as_str().len(), "10.1234/".len() + DOI_SUFFIX_MAX_LEN);
2296 }
2297
2298 #[test]
2299 fn doi_parse_uri_scheme_is_case_insensitive() {
2300 // Rule: be lenient on scheme casing; the scheme is stripped
2301 // either way so the stored form is identical.
2302 let d = Doi::parse("DOI:10.1234/example").expect("uppercase scheme");
2303 assert_eq!(d.as_str(), "10.1234/example");
2304 }
2305
2306 // ---- Doi::parse rejection paths (≥6) ----------------------------
2307
2308 #[test]
2309 fn doi_parse_rejects_missing_10_prefix() {
2310 // Rule: must start with "10." literal.
2311 assert_eq!(
2312 Doi::parse("11.1234/example"),
2313 Err(RefParseError::MissingDoiPrefix)
2314 );
2315 }
2316
2317 #[test]
2318 fn doi_parse_rejects_empty_input() {
2319 // Rule: empty inputs are not valid DOIs.
2320 assert_eq!(Doi::parse(""), Err(RefParseError::Empty));
2321 }
2322
2323 #[test]
2324 fn doi_parse_rejects_missing_suffix_separator() {
2325 // Rule: must contain a `/` between registrant and suffix.
2326 assert_eq!(
2327 Doi::parse("10.1234"),
2328 Err(RefParseError::MissingDoiSuffixSeparator)
2329 );
2330 }
2331
2332 #[test]
2333 fn doi_parse_rejects_empty_suffix() {
2334 // Rule: suffix must be non-empty.
2335 assert_eq!(Doi::parse("10.1234/"), Err(RefParseError::EmptyDoiSuffix));
2336 }
2337
2338 #[test]
2339 fn doi_parse_rejects_invalid_registrant_too_short() {
2340 // Rule: registrant must be 4–9 digits.
2341 assert_eq!(
2342 Doi::parse("10.12/example"),
2343 Err(RefParseError::InvalidDoiRegistrant)
2344 );
2345 }
2346
2347 #[test]
2348 fn doi_parse_rejects_non_digit_registrant() {
2349 // Rule: registrant chars must all be ASCII digits.
2350 assert_eq!(
2351 Doi::parse("10.12ab/example"),
2352 Err(RefParseError::InvalidDoiRegistrant)
2353 );
2354 }
2355
2356 #[test]
2357 fn doi_parse_rejects_control_char_in_suffix() {
2358 // Rule (from docs/SECURITY.md §1.1, log-injection mitigation):
2359 // control chars are not in the suffix charset; reject before they
2360 // can reach the provenance log.
2361 let result = Doi::parse("10.1234/foo\nbar");
2362 assert!(
2363 matches!(
2364 result,
2365 Err(RefParseError::InvalidDoiSuffixChar { ch: '\n' })
2366 ),
2367 "got {:?}",
2368 result
2369 );
2370 }
2371
2372 #[test]
2373 fn doi_parse_rejects_suffix_over_max_len() {
2374 // Rule: DOI_SUFFIX_MAX_LEN + 1 bytes is rejected.
2375 let suffix = "a".repeat(DOI_SUFFIX_MAX_LEN + 1);
2376 let input = format!("10.1234/{}", suffix);
2377 let result = Doi::parse(&input);
2378 match result {
2379 Err(RefParseError::DoiSuffixTooLong { len, max }) => {
2380 assert_eq!(len, DOI_SUFFIX_MAX_LEN + 1);
2381 assert_eq!(max, DOI_SUFFIX_MAX_LEN);
2382 }
2383 other => panic!("expected DoiSuffixTooLong, got {:?}", other),
2384 }
2385 }
2386
2387 #[test]
2388 fn doi_parse_rejects_non_ascii_in_suffix() {
2389 // Rule: spec charset is ASCII-only; non-ASCII becomes an
2390 // InvalidDoiSuffixChar (consistent with safekey behavior of
2391 // collapsing such chars to '_', which is a downstream concern).
2392 let result = Doi::parse("10.1234/物理学");
2393 assert!(
2394 matches!(result, Err(RefParseError::InvalidDoiSuffixChar { .. })),
2395 "got {:?}",
2396 result
2397 );
2398 }
2399
2400 // ---- ArxivId::parse happy paths (≥6) ----------------------------
2401
2402 #[test]
2403 fn arxiv_parse_accepts_new_style_4_digit_seq() {
2404 // Rule: new-style YYMM.NNNN (4-digit sequence number).
2405 let a = ArxivId::parse("0704.0001").expect("new-style 4-digit seq");
2406 assert_eq!(a.as_str(), "0704.0001");
2407 }
2408
2409 #[test]
2410 fn arxiv_parse_accepts_new_style_5_digit_seq() {
2411 // Rule: new-style YYMM.NNNNN (5-digit sequence number, post-2015).
2412 let a = ArxivId::parse("2401.12345").expect("new-style 5-digit seq");
2413 assert_eq!(a.as_str(), "2401.12345");
2414 }
2415
2416 #[test]
2417 fn arxiv_parse_accepts_new_style_with_version() {
2418 // Rule: optional `vN` version suffix.
2419 let a = ArxivId::parse("2401.12345v2").expect("with version");
2420 assert_eq!(a.as_str(), "2401.12345v2");
2421 }
2422
2423 #[test]
2424 fn arxiv_parse_accepts_old_style() {
2425 // Rule: old-style subject-class/YYMMNNN.
2426 let a = ArxivId::parse("cond-mat/9501001").expect("old-style cond-mat");
2427 assert_eq!(a.as_str(), "cond-mat/9501001");
2428 }
2429
2430 #[test]
2431 fn arxiv_parse_accepts_old_style_with_subclass_and_version() {
2432 // Rule: old-style subject-class may have a `.XX` two-upper subclass
2433 // and an optional `vN` suffix.
2434 let a = ArxivId::parse("astro-ph.CO/0703123v2").expect("old-style with subclass + version");
2435 assert_eq!(a.as_str(), "astro-ph.CO/0703123v2");
2436 }
2437
2438 #[test]
2439 fn arxiv_parse_accepts_arxiv_uri_scheme() {
2440 // Rule: `arxiv:` / `arXiv:` scheme is stripped at construction.
2441 let a = ArxivId::parse("arxiv:2401.12345").expect("arxiv: scheme");
2442 assert_eq!(a.as_str(), "2401.12345");
2443 }
2444
2445 #[test]
2446 fn arxiv_parse_accepts_arxiv_uri_scheme_mixed_case() {
2447 // Rule: scheme case-insensitive; matches the `arXiv:` form named
2448 // in docs/MCP_TOOLS.md.
2449 let a = ArxivId::parse("arXiv:2401.12345v2").expect("arXiv: scheme");
2450 assert_eq!(a.as_str(), "2401.12345v2");
2451 }
2452
2453 // ---- ArxivId::parse rejection paths (≥6) ------------------------
2454
2455 #[test]
2456 fn arxiv_parse_rejects_empty_input() {
2457 // Rule: empty rejected up-front.
2458 assert_eq!(ArxivId::parse(""), Err(RefParseError::Empty));
2459 }
2460
2461 #[test]
2462 fn arxiv_parse_rejects_no_dot_or_slash() {
2463 // Rule: must contain `.` (new-style) or `/` (old-style).
2464 assert_eq!(
2465 ArxivId::parse("notanarxivid"),
2466 Err(RefParseError::InvalidArxivShape)
2467 );
2468 }
2469
2470 #[test]
2471 fn arxiv_parse_rejects_new_style_wrong_head_length() {
2472 // Rule: head must be exactly 4 digits.
2473 assert_eq!(
2474 ArxivId::parse("240.12345"),
2475 Err(RefParseError::InvalidArxivShape)
2476 );
2477 }
2478
2479 #[test]
2480 fn arxiv_parse_rejects_new_style_seq_too_short() {
2481 // Rule: seq must be 4–5 digits.
2482 assert_eq!(
2483 ArxivId::parse("2401.123"),
2484 Err(RefParseError::InvalidArxivShape)
2485 );
2486 }
2487
2488 #[test]
2489 fn arxiv_parse_rejects_old_style_wrong_id_length() {
2490 // Rule: old-style id is exactly 7 digits.
2491 assert_eq!(
2492 ArxivId::parse("cond-mat/95001"),
2493 Err(RefParseError::InvalidArxivShape)
2494 );
2495 }
2496
2497 #[test]
2498 fn arxiv_parse_rejects_invalid_version_suffix() {
2499 // Rule: version suffix is `v` followed by ≥1 digits, nothing else.
2500 assert_eq!(
2501 ArxivId::parse("2401.12345v"),
2502 Err(RefParseError::InvalidArxivShape)
2503 );
2504 }
2505
2506 #[test]
2507 fn arxiv_parse_rejects_control_char() {
2508 // Rule (docs/SECURITY.md §1.1 log-injection): no control chars.
2509 assert_eq!(
2510 ArxivId::parse("2401.12345\n"),
2511 Err(RefParseError::InvalidArxivShape)
2512 );
2513 }
2514
2515 #[test]
2516 fn arxiv_parse_rejects_non_ascii() {
2517 // Rule: ASCII-only.
2518 assert_eq!(
2519 ArxivId::parse("2401.物理"),
2520 Err(RefParseError::InvalidArxivShape)
2521 );
2522 }
2523
2524 // ---- Ref::parse happy paths (≥6) --------------------------------
2525
2526 #[test]
2527 fn ref_parse_dispatches_doi_scheme_to_doi() {
2528 // Detection rule 1: explicit `doi:` scheme.
2529 match Ref::parse("doi:10.1234/example").expect("doi: dispatched to Doi") {
2530 Ref::Doi(d) => assert_eq!(d.as_str(), "10.1234/example"),
2531 other => panic!("expected Ref::Doi, got {:?}", other),
2532 }
2533 }
2534
2535 #[test]
2536 fn ref_parse_dispatches_arxiv_scheme_to_arxiv() {
2537 // Detection rule 2: explicit `arxiv:` scheme.
2538 match Ref::parse("arxiv:2401.12345").expect("arxiv: dispatched to Arxiv") {
2539 Ref::Arxiv(a) => assert_eq!(a.as_str(), "2401.12345"),
2540 other => panic!("expected Ref::Arxiv, got {:?}", other),
2541 }
2542 }
2543
2544 #[test]
2545 fn ref_parse_dispatches_arxiv_mixed_case_scheme() {
2546 // Detection rule 2 (case-insensitive): `arXiv:` form.
2547 match Ref::parse("arXiv:cond-mat/9501001").expect("arXiv: dispatched") {
2548 Ref::Arxiv(a) => assert_eq!(a.as_str(), "cond-mat/9501001"),
2549 other => panic!("expected Ref::Arxiv, got {:?}", other),
2550 }
2551 }
2552
2553 #[test]
2554 fn ref_parse_bare_doi_resolves_to_doi() {
2555 // Detection rule 3: bare input starting with `10.` is a DOI.
2556 match Ref::parse("10.1234/foo").expect("bare DOI") {
2557 Ref::Doi(d) => assert_eq!(d.as_str(), "10.1234/foo"),
2558 other => panic!("expected Ref::Doi, got {:?}", other),
2559 }
2560 }
2561
2562 #[test]
2563 fn ref_parse_bare_arxiv_new_resolves_to_arxiv() {
2564 // Detection rule 4: bare input not starting with `10.` falls
2565 // through to arXiv. Tests the ambiguous-input branch named in the
2566 // PR brief: `2401.12345` should resolve to ArxivId.
2567 match Ref::parse("2401.12345").expect("bare new-style arXiv") {
2568 Ref::Arxiv(a) => assert_eq!(a.as_str(), "2401.12345"),
2569 other => panic!("expected Ref::Arxiv, got {:?}", other),
2570 }
2571 }
2572
2573 #[test]
2574 fn ref_parse_bare_arxiv_old_resolves_to_arxiv() {
2575 // Detection rule 4: bare old-style arXiv id.
2576 match Ref::parse("cond-mat/9501001").expect("bare old-style arXiv") {
2577 Ref::Arxiv(a) => assert_eq!(a.as_str(), "cond-mat/9501001"),
2578 other => panic!("expected Ref::Arxiv, got {:?}", other),
2579 }
2580 }
2581
2582 // ---- Ref::parse rejection paths (≥6) ----------------------------
2583
2584 #[test]
2585 fn ref_parse_rejects_empty() {
2586 // Rule: empty up-front.
2587 assert_eq!(Ref::parse(""), Err(RefParseError::Empty));
2588 }
2589
2590 #[test]
2591 fn ref_parse_doi_scheme_with_invalid_doi_propagates_doi_error() {
2592 // When the scheme is explicit, we surface the parser's error
2593 // verbatim — not a generic "shape mismatch".
2594 assert_eq!(
2595 Ref::parse("doi:10.1234"),
2596 Err(RefParseError::MissingDoiSuffixSeparator)
2597 );
2598 }
2599
2600 #[test]
2601 fn ref_parse_arxiv_scheme_with_invalid_arxiv_propagates_arxiv_error() {
2602 assert_eq!(
2603 Ref::parse("arxiv:notanid"),
2604 Err(RefParseError::InvalidArxivShape)
2605 );
2606 }
2607
2608 #[test]
2609 fn ref_parse_bare_with_10_prefix_uses_doi_errors() {
2610 // Bare `10.…` heuristic: DOI parser is dispatched and its error
2611 // surfaces (here: bad registrant).
2612 assert_eq!(
2613 Ref::parse("10.12/x"),
2614 Err(RefParseError::InvalidDoiRegistrant)
2615 );
2616 }
2617
2618 #[test]
2619 fn ref_parse_bare_without_10_prefix_reports_neither_shape() {
2620 // The comment on this test always said the right thing -- "`1.2.3`
2621 // is neither a DOI nor an arXiv shape" -- while the assertion said
2622 // `InvalidArxivShape`, which is the fallback parser's verdict
2623 // rather than the truth about the input (#477). Someone who
2624 // mistyped a DOI was told about arXiv id shapes.
2625 assert_eq!(Ref::parse("1.2.3"), Err(RefParseError::UnrecognisedShape));
2626 }
2627
2628 #[test]
2629 fn an_explicit_arxiv_scheme_still_reports_the_arxiv_shape_error() {
2630 // The narrowing in #477 applies ONLY to the ambiguous fall-through.
2631 // When the caller declared `arxiv:`, the arXiv parser's verdict IS
2632 // the truth about the input, and generalising it there would lose
2633 // information rather than gain it.
2634 assert_eq!(
2635 Ref::parse("arxiv:1.2.3"),
2636 Err(RefParseError::InvalidArxivShape)
2637 );
2638 }
2639
2640 #[test]
2641 fn ref_parse_rejects_doi_scheme_with_oversized_suffix() {
2642 // Length-bound: DOI suffix > DOI_SUFFIX_MAX_LEN through Ref::parse
2643 // surfaces DoiSuffixTooLong, not a generic InvalidArxivShape.
2644 let suffix = "a".repeat(DOI_SUFFIX_MAX_LEN + 5);
2645 let input = format!("doi:10.1234/{}", suffix);
2646 match Ref::parse(&input) {
2647 Err(RefParseError::DoiSuffixTooLong { .. }) => {}
2648 other => panic!("expected DoiSuffixTooLong, got {:?}", other),
2649 }
2650 }
2651
2652 #[test]
2653 fn ref_parse_round_trip_via_serde_preserves_inner_string() {
2654 // Wire-format check: Doi/ArxivId are #[serde(transparent)], and a
2655 // round-trip through Ref::parse → serde_json → Ref must preserve
2656 // the inner identifier. Guards against accidental scheme leakage
2657 // into the stored form.
2658 let r = Ref::parse("doi:10.1234/example").expect("parse ok");
2659 let json = serde_json::to_string(&r).expect("serialize");
2660 // The transparent inner value is the bare identifier (no `doi:`).
2661 assert!(
2662 json.contains("10.1234/example") && !json.contains("doi:"),
2663 "scheme leaked into wire form: {}",
2664 json
2665 );
2666 }
2667
2668 #[test]
2669 fn ref_parse_error_maps_to_invalid_ref_error_code() {
2670 // Public-API contract (docs/PUBLIC_API.md §4): all parse failures
2671 // collapse to ErrorCode::InvalidRef at the public boundary.
2672 let err: ErrorCode = RefParseError::Empty.into();
2673 assert_eq!(err, ErrorCode::InvalidRef);
2674 let err2: ErrorCode = RefParseError::MissingDoiPrefix.into();
2675 assert_eq!(err2, ErrorCode::InvalidRef);
2676 }
2677
2678 // -----------------------------------------------------------------
2679 // DenialReason / DenialContext (ADR-0023) — wire-shape tests.
2680 // -----------------------------------------------------------------
2681
2682 #[test]
2683 fn denial_reason_serializes_snake_case() {
2684 // ADR-0023 §2 / docs/PUBLIC_API.md §8: wire form is snake_case.
2685 let s = serde_json::to_string(&DenialReason::RedirectNotInAllowlist).expect("ser");
2686 assert_eq!(s, "\"redirect_not_in_allowlist\"");
2687 let s = serde_json::to_string(&DenialReason::SizeCapExceeded).expect("ser");
2688 assert_eq!(s, "\"size_cap_exceeded\"");
2689 let s = serde_json::to_string(&DenialReason::ContentTypeMismatch).expect("ser");
2690 assert_eq!(s, "\"content_type_mismatch\"");
2691 }
2692
2693 #[test]
2694 fn denial_reason_round_trip_via_serde() {
2695 // Round-trip every closed-set variant so adding a new variant
2696 // forces this test to be updated (the closed-set contract).
2697 for r in [
2698 DenialReason::RedirectNotInAllowlist,
2699 DenialReason::InsecureScheme,
2700 DenialReason::HostInBlockList,
2701 DenialReason::SizeCapExceeded,
2702 DenialReason::SchemaDrift,
2703 DenialReason::CapabilityNotGranted,
2704 DenialReason::RateLimitWindow,
2705 DenialReason::SsrfPrivateAddress,
2706 DenialReason::ContentTypeMismatch,
2707 ] {
2708 let s = serde_json::to_string(&r).expect("ser");
2709 let back: DenialReason = serde_json::from_str(&s).expect("de");
2710 assert_eq!(back, r, "round-trip mismatch for {:?} -> {}", r, s);
2711 }
2712 }
2713
2714 #[test]
2715 fn denial_context_round_trips_full_shape() {
2716 // A populated context (the redirect-denied case from ADR-0023 §1
2717 // example) survives a JSON round-trip. Whole-struct equality
2718 // exercises the `PartialEq` derive added per ADR-0023 §3 (added
2719 // in the multi-agent review feedback PR — see ADR-0023 history).
2720 let dc = DenialContext {
2721 reason: DenialReason::RedirectNotInAllowlist,
2722 source: Some("crossref".to_string()),
2723 attempted: Some("evil.example.com".to_string()),
2724 expected: Some(vec![
2725 "api.crossref.org".to_string(),
2726 "*.crossref.org".to_string(),
2727 ]),
2728 hop_index: Some(1),
2729 cap: None,
2730 actual: None,
2731 };
2732 let s = serde_json::to_string(&dc).expect("ser");
2733 let back: DenialContext = serde_json::from_str(&s).expect("de");
2734 assert_eq!(back, dc);
2735 }
2736
2737 #[test]
2738 fn denial_context_serialize_elides_empty_fields() {
2739 // `skip_serializing_if = "Option::is_none"` must keep the wire form
2740 // lean: every `None` field MUST NOT appear on the wire. Reason is
2741 // always present.
2742 let dc = DenialContext {
2743 reason: DenialReason::CapabilityNotGranted,
2744 source: None,
2745 attempted: None,
2746 expected: None,
2747 hop_index: None,
2748 cap: None,
2749 actual: None,
2750 };
2751 let s = serde_json::to_string(&dc).expect("ser");
2752 assert_eq!(s, "{\"reason\":\"capability_not_granted\"}");
2753 }
2754
2755 #[test]
2756 fn denial_context_expected_some_empty_vec_preserves_explicit_empty_allowlist() {
2757 // Post-refinement disambiguation: `expected: Some(vec![])` is the
2758 // "explicit empty allowlist" signal and MUST survive the wire as
2759 // `"expected":[]`. Only `expected: None` is skipped on serialize.
2760 // This is the bug the previous `Vec<String>` shape masked.
2761 let dc = DenialContext {
2762 reason: DenialReason::RedirectNotInAllowlist,
2763 source: Some("crossref".to_string()),
2764 attempted: Some("evil.example.com".to_string()),
2765 expected: Some(Vec::new()),
2766 hop_index: None,
2767 cap: None,
2768 actual: None,
2769 };
2770 let s = serde_json::to_string(&dc).expect("ser");
2771 assert!(
2772 s.contains("\"expected\":[]"),
2773 "expected:[] must survive on the wire (got: {s})"
2774 );
2775 let back: DenialContext = serde_json::from_str(&s).expect("de");
2776 assert_eq!(back.expected, Some(Vec::new()));
2777 }
2778
2779 #[test]
2780 fn denial_context_deserialize_tolerates_missing_optional_fields() {
2781 // Consumer-side contract (ADR-0023 §3): consumers MUST tolerate
2782 // any subset of fields being present. Missing optional fields
2783 // deserialize to their defaults via `#[serde(default)]`.
2784 let wire = r#"{"reason":"size_cap_exceeded","cap":104857600,"actual":209715200}"#;
2785 let dc: DenialContext = serde_json::from_str(wire).expect("de");
2786 assert_eq!(dc.reason, DenialReason::SizeCapExceeded);
2787 assert_eq!(dc.cap, Some(104857600));
2788 assert_eq!(dc.actual, Some(209715200));
2789 assert!(dc.source.is_none());
2790 assert!(dc.attempted.is_none());
2791 assert!(dc.expected.is_none());
2792 assert!(dc.hop_index.is_none());
2793 }
2794
2795 #[test]
2796 fn full_error_envelope_with_denial_context_serializes_to_pinned_json() {
2797 // Pins the byte-exact wire shape of the full failure envelope
2798 // documented in docs/ERRORS.md §3 + §3.1 and ADR-0023 §1. A
2799 // future regression that flips key order or skip-rules anywhere
2800 // in the chain breaks this test loudly.
2801 //
2802 // Note: serde_json's `Map` (used by `json!`) sorts keys
2803 // alphabetically when the `preserve_order` feature is NOT
2804 // enabled (we do not enable it). Embedding a `DenialContext`
2805 // via `json!` first re-serialises it through the same alphabet-
2806 // sorted Map path, so the inner field order is also alphabetical
2807 // here — NOT the struct field-order produced by direct
2808 // `to_string(&DenialContext)`. This is by design: the public
2809 // wire shape is canonicalised by serde_json's Map ordering, so
2810 // the byte-exact pin below documents that exact canonicalisation.
2811 let denial = DenialContext {
2812 reason: DenialReason::RedirectNotInAllowlist,
2813 source: Some("crossref".into()),
2814 attempted: Some("evil.example.com".into()),
2815 expected: Some(vec!["api.crossref.org".into(), "*.crossref.org".into()]),
2816 hop_index: Some(1),
2817 cap: None,
2818 actual: None,
2819 };
2820 let envelope = serde_json::json!({
2821 "ok": false,
2822 "error": {
2823 "code": ErrorCode::NetworkError,
2824 "message": "redirect target evil.example.com not in allowlist for source crossref",
2825 "denial_context": denial,
2826 }
2827 });
2828 let actual = serde_json::to_string(&envelope).expect("serialize envelope");
2829 let expected = r#"{"error":{"code":"NETWORK_ERROR","denial_context":{"attempted":"evil.example.com","expected":["api.crossref.org","*.crossref.org"],"hop_index":1,"reason":"redirect_not_in_allowlist","source":"crossref"},"message":"redirect target evil.example.com not in allowlist for source crossref"},"ok":false}"#;
2830 assert_eq!(actual, expected);
2831 }
2832
2833 #[test]
2834 fn denial_context_rejects_unknown_fields() {
2835 // `#[serde(deny_unknown_fields)]` (ADR-0023 §3, PUBLIC_API.md §8):
2836 // an unknown field on the wire MUST be a deserialize error so
2837 // forward-compat field additions stay a breaking change.
2838 let wire = r#"{"reason":"capability_not_granted","banana":1}"#;
2839 let result: Result<DenialContext, _> = serde_json::from_str(wire);
2840 assert!(
2841 result.is_err(),
2842 "deny_unknown_fields must reject 'banana': {:?}",
2843 result.map(|d| d.reason),
2844 );
2845 }
2846}