Skip to main content

spec_driven_docs/domain/
tracking.rs

1//! The tracking registry: the parsed, bounded shape of a perishable-facts file.
2//!
3//! The registry is `<root>/reference/tracking.yaml`: one YAML document,
4//! `schema_version: 1`, a `tracked` array. This module owns the versioned
5//! shape and the bounds that must hold before any semantic read, because the
6//! file is repository-controlled input a gate parses on every commit. What a
7//! path resolves to on disk, and whether an entry is overdue against a clock,
8//! is `services::tracking`'s business.
9
10use serde::Deserialize;
11use thiserror::Error;
12
13/// The schema version this binary reads.
14pub const SCHEMA_VERSION: u32 = 1;
15
16/// Bounds on untrusted input, applied before deserialization.
17const MAX_BYTES: usize = 256 * 1024;
18const MAX_LINES: usize = 5_000;
19const MAX_LINE_LEN: usize = 4_096;
20const MAX_INDENT_SPACES: usize = 64;
21const MAX_ENTRIES: usize = 1_000;
22
23/// A parsed registry.
24#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct Registry {
27    /// Always [`SCHEMA_VERSION`] once accepted.
28    pub schema_version: u32,
29    /// Every tracked source.
30    pub tracked: Vec<Entry>,
31}
32
33/// One tracked perishable source.
34#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Entry {
37    /// A unique slug.
38    pub id: String,
39    /// The repository-relative path of the tracked document.
40    pub path: String,
41    /// The ISO date the source was last checked.
42    pub last_checked: String,
43    /// How many days between checks.
44    pub cadence_days: u32,
45    /// Why the source expires.
46    pub why: String,
47    /// Ordered steps a person follows to revalidate the source.
48    pub revalidate: Vec<String>,
49    /// The local files that depend on the source.
50    pub dependents: Vec<String>,
51    /// The upstream Git derivation, where the source is one.
52    #[serde(default)]
53    pub source: Option<Source>,
54}
55
56/// An upstream Git derivation an entry pins.
57#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct Source {
60    /// Always `git` for schema version 1.
61    pub kind: String,
62    /// A credential-free `https://` repository URL.
63    pub repository: String,
64    /// A full `refs/...` reference.
65    pub reference: String,
66    /// A full 40- or 64-character Git object ID.
67    pub revision: String,
68    /// The upstream license identifier.
69    pub license: String,
70}
71
72/// Why a registry cannot be accepted.
73#[derive(Debug, Clone, PartialEq, Eq, Error)]
74pub enum TrackingError {
75    /// A bound on untrusted input was exceeded.
76    #[error("{0}")]
77    Bounds(String),
78    /// The document does not parse as the schema-version-1 shape.
79    #[error("invalid tracking registry: {0}")]
80    Shape(String),
81    /// A cross-field or uniqueness rule the schema cannot express.
82    #[error("{0}")]
83    Semantic(String),
84}
85
86/// True for a full 40- (SHA-1) or 64-character (SHA-256) hex object ID.
87#[must_use]
88pub fn is_object_id(value: &str) -> bool {
89    (value.len() == 40 || value.len() == 64)
90        && value
91            .bytes()
92            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
93}
94
95/// Reject the input before deserialization if any bound is exceeded.
96///
97/// The scan is line-based and quote- and comment-aware only enough to keep a
98/// hazard token from hiding: a scalar's contents are not YAML control input.
99fn check_bounds(text: &str) -> Result<(), TrackingError> {
100    let bound = |m: String| Err(TrackingError::Bounds(m));
101    if text.len() > MAX_BYTES {
102        return bound(format!("registry is larger than {MAX_BYTES} bytes"));
103    }
104    let lines: Vec<&str> = text.lines().collect();
105    if lines.len() > MAX_LINES {
106        return bound(format!("registry has more than {MAX_LINES} lines"));
107    }
108    // Sibling-scope duplicate-key detection: a stack of (indent, keys-seen).
109    let mut scopes: Vec<(usize, std::collections::BTreeSet<String>)> = Vec::new();
110    for (number, raw) in lines.iter().enumerate() {
111        let line = raw.trim_end();
112        if line.len() > MAX_LINE_LEN {
113            return bound(format!(
114                "line {} is longer than {MAX_LINE_LEN} characters",
115                number + 1
116            ));
117        }
118        let indent = line.len() - line.trim_start().len();
119        if indent > MAX_INDENT_SPACES {
120            return bound(format!(
121                "line {} nests past {MAX_INDENT_SPACES} spaces",
122                number + 1
123            ));
124        }
125        let content = line.trim_start();
126        if content.is_empty() || content.starts_with('#') {
127            continue;
128        }
129        if number > 0 && (content == "---" || content == "...") {
130            return bound("registry carries more than one document".to_string());
131        }
132        // Anchors, aliases, merge keys, and explicit tags are control input
133        // the registry has no use for and an attacker can fan out with.
134        if let Some(hazard) = control_hazard(content) {
135            return bound(format!("line {}: {hazard}", number + 1));
136        }
137        // A list item opens a fresh mapping scope for its own keys.
138        let (key_indent, key_part) = if let Some(rest) = content.strip_prefix("- ") {
139            (indent + 2, rest)
140        } else if content == "-" {
141            continue;
142        } else {
143            (indent, content)
144        };
145        let Some((key, _)) = key_part.split_once(':') else {
146            continue;
147        };
148        let key = key.trim();
149        if key.is_empty() || key.contains(' ') {
150            continue;
151        }
152        while scopes.last().is_some_and(|(scope, _)| *scope > key_indent) {
153            scopes.pop();
154        }
155        if scopes.last().is_none_or(|(scope, _)| *scope != key_indent) {
156            scopes.push((key_indent, std::collections::BTreeSet::new()));
157        }
158        if content.starts_with("- ") {
159            // Each list item is its own mapping; reset the keys at this scope.
160            if let Some(entry) = scopes.last_mut() {
161                entry.1.clear();
162            }
163        }
164        if let Some(entry) = scopes.last_mut()
165            && !entry.1.insert(key.to_string())
166        {
167            return Err(TrackingError::Bounds(format!(
168                "line {}: duplicate mapping key '{key}'",
169                number + 1
170            )));
171        }
172    }
173    Ok(())
174}
175
176/// The control token a line carries that the registry forbids, if any.
177fn control_hazard(content: &str) -> Option<&'static str> {
178    // Work on the value side of `key:` so a URL fragment is not a false hit.
179    let value = content.split_once(": ").map_or(content, |(_, v)| v);
180    let value = value.trim();
181    if value.starts_with('&') {
182        return Some("a YAML anchor is not allowed");
183    }
184    if value.starts_with('*') {
185        return Some("a YAML alias is not allowed");
186    }
187    if value.starts_with('!') {
188        return Some("a YAML tag is not allowed");
189    }
190    if content.trim_start().starts_with("<<") {
191        return Some("a YAML merge key is not allowed");
192    }
193    None
194}
195
196/// Validate the format rules the schema states but a validator must confirm
197/// for a `source` object: the transport, the reference shape, and the
198/// object-ID length.
199fn check_source(entry: &Entry) -> Result<(), TrackingError> {
200    let Some(source) = &entry.source else {
201        return Ok(());
202    };
203    let bad = |m: String| {
204        Err(TrackingError::Semantic(format!(
205            "entry '{}': {m}",
206            entry.id
207        )))
208    };
209    if source.kind != "git" {
210        return bad(format!("source.kind must be 'git', not '{}'", source.kind));
211    }
212    if !source.repository.starts_with("https://") {
213        return bad("source.repository must be a credential-free https:// URL".to_string());
214    }
215    if source.repository.contains('@') {
216        return bad("source.repository must carry no credentials".to_string());
217    }
218    if !source.reference.starts_with("refs/") {
219        return bad("source.reference must be a full refs/... reference".to_string());
220    }
221    if !is_object_id(&source.revision) {
222        return bad(format!(
223            "source.revision must be a full 40- or 64-character object ID, not '{}'",
224            source.revision
225        ));
226    }
227    if source.license.trim().is_empty() {
228        return bad("source.license must be a non-empty identifier".to_string());
229    }
230    Ok(())
231}
232
233/// Parse and bound a registry, then confirm the version and the source
234/// formats. Path existence and freshness are the service's checks.
235///
236/// # Errors
237///
238/// [`TrackingError::Bounds`] when a bound is exceeded, [`TrackingError::Shape`]
239/// when the document does not fit the schema, and [`TrackingError::Semantic`]
240/// for a version mismatch, a duplicate id or dependent, or a bad source field.
241pub fn parse(text: &str) -> Result<Registry, TrackingError> {
242    check_bounds(text)?;
243    let registry: Registry =
244        yaml_serde::from_str(text).map_err(|e| TrackingError::Shape(e.to_string()))?;
245    if registry.schema_version != SCHEMA_VERSION {
246        return Err(TrackingError::Semantic(format!(
247            "schema_version must be {SCHEMA_VERSION}, not {}",
248            registry.schema_version
249        )));
250    }
251    if registry.tracked.len() > MAX_ENTRIES {
252        return Err(TrackingError::Bounds(format!(
253            "registry has more than {MAX_ENTRIES} entries"
254        )));
255    }
256    let mut ids = std::collections::BTreeSet::new();
257    for entry in &registry.tracked {
258        if entry.cadence_days == 0 {
259            return Err(TrackingError::Semantic(format!(
260                "entry '{}': cadence_days must be a positive integer",
261                entry.id
262            )));
263        }
264        if !ids.insert(entry.id.clone()) {
265            return Err(TrackingError::Semantic(format!(
266                "duplicate entry id '{}'",
267                entry.id
268            )));
269        }
270        let mut dependents = std::collections::BTreeSet::new();
271        for dependent in &entry.dependents {
272            if !dependents.insert(dependent.clone()) {
273                return Err(TrackingError::Semantic(format!(
274                    "entry '{}': duplicate dependent '{dependent}'",
275                    entry.id
276                )));
277            }
278        }
279        check_source(entry)?;
280    }
281    Ok(registry)
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    const OK: &str = "schema_version: 1\ntracked:\n  - id: sample\n    path: reference/x.md\n    last_checked: 2026-01-01\n    cadence_days: 30\n    why: it moves\n    revalidate:\n      - re-fetch it\n    dependents: []\n";
289
290    #[test]
291    fn accepts_a_minimal_registry() {
292        let registry = parse(OK).unwrap();
293        assert_eq!(registry.schema_version, 1);
294        assert_eq!(registry.tracked.len(), 1);
295    }
296
297    #[test]
298    fn accepts_a_git_source() {
299        let text = format!(
300            "schema_version: 1\ntracked:\n  - id: sample\n    path: reference/x.md\n    last_checked: 2026-01-01\n    cadence_days: 30\n    why: it moves\n    revalidate:\n      - re-fetch it\n    dependents: []\n    source:\n      kind: git\n      repository: https://github.com/o/r\n      reference: refs/tags/v1\n      revision: {}\n      license: MIT\n",
301            "a".repeat(40)
302        );
303        let registry = parse(&text).unwrap();
304        assert_eq!(registry.tracked[0].source.as_ref().unwrap().kind, "git");
305    }
306
307    #[test]
308    fn rejects_a_wrong_schema_version() {
309        let text = OK.replace("schema_version: 1", "schema_version: 2");
310        assert!(matches!(parse(&text), Err(TrackingError::Semantic(_))));
311    }
312
313    #[test]
314    fn rejects_a_duplicate_id() {
315        let text = format!(
316            "{OK}  - id: sample\n    path: reference/y.md\n    last_checked: 2026-01-01\n    cadence_days: 30\n    why: also\n    revalidate:\n      - go\n    dependents: []\n"
317        );
318        assert!(matches!(parse(&text), Err(TrackingError::Semantic(_))));
319    }
320
321    #[test]
322    fn rejects_a_duplicate_mapping_key() {
323        let text = "schema_version: 1\nschema_version: 1\ntracked: []\n";
324        assert!(matches!(parse(text), Err(TrackingError::Bounds(_))));
325    }
326
327    #[test]
328    fn rejects_an_alias_and_an_anchor() {
329        let text = "schema_version: 1\ntracked: &all []\nother: *all\n";
330        assert!(matches!(parse(text), Err(TrackingError::Bounds(_))));
331    }
332
333    #[test]
334    fn rejects_a_second_document() {
335        let text = "schema_version: 1\ntracked: []\n---\nschema_version: 1\ntracked: []\n";
336        assert!(matches!(parse(text), Err(TrackingError::Bounds(_))));
337    }
338
339    #[test]
340    fn rejects_a_tag() {
341        let text = "schema_version: 1\ntracked: !!seq []\n";
342        assert!(matches!(parse(text), Err(TrackingError::Bounds(_))));
343    }
344
345    #[test]
346    fn rejects_a_branch_revision() {
347        let text = "schema_version: 1\ntracked:\n  - id: s\n    path: r/x.md\n    last_checked: 2026-01-01\n    cadence_days: 30\n    why: w\n    revalidate:\n      - go\n    dependents: []\n    source:\n      kind: git\n      repository: https://github.com/o/r\n      reference: refs/heads/main\n      revision: main\n      license: MIT\n";
348        assert!(matches!(parse(text), Err(TrackingError::Semantic(_))));
349    }
350
351    #[test]
352    fn rejects_credentials_in_the_repository() {
353        let text = "schema_version: 1\ntracked:\n  - id: s\n    path: r/x.md\n    last_checked: 2026-01-01\n    cadence_days: 30\n    why: w\n    revalidate:\n      - go\n    dependents: []\n    source:\n      kind: git\n      repository: https://user:pass@github.com/o/r\n      reference: refs/tags/v1\n      revision: 1111111111111111111111111111111111111111\n      license: MIT\n";
354        assert!(matches!(parse(text), Err(TrackingError::Semantic(_))));
355    }
356
357    #[test]
358    fn rejects_zero_cadence() {
359        let text = OK.replace("cadence_days: 30", "cadence_days: 0");
360        assert!(matches!(parse(&text), Err(TrackingError::Semantic(_))));
361    }
362
363    #[test]
364    fn rejects_an_oversized_file() {
365        let text = format!(
366            "schema_version: 1\ntracked: []\n# {}\n",
367            "x".repeat(MAX_LINE_LEN + 1)
368        );
369        assert!(matches!(parse(&text), Err(TrackingError::Bounds(_))));
370    }
371}