Skip to main content

libxml_rs/compatibility/profiles/
mod.rs

1//! Capability epochs and compatibility profiles (§68, §85 Phase 11, 11.1-R).
2//!
3//! Historical behavior differences must flow through deliberate compatibility
4//! structures, never through scattered `if version == ...` branches. Each
5//! behavioral capability whose semantics changed at a documented upstream
6//! boundary is modelled as a *capability epoch*; a [`CompatibilityProfile`]
7//! resolves every capability for a target upstream version pair.
8//!
9//! The epoch boundaries are derived from the evidence in
10//! `atlas/SEMANTIC_EPOCHS.md` (E-001..E-008) and the surface delta engine
11//! (`tools/evidence/surface_delta_engine.py` -> `atlas/HISTORICAL_SURFACE_EPOCHS.json`).
12//! The candidate currently implements the current-system behavior
13//! (libxml2 2.15.3 / libxslt 1.1.45); the resolver exists so that future
14//! historical-emulation work (and any regression triage against older oracles)
15//! addresses one deliberate structure instead of ad-hoc version checks.
16//!
17//! # Capabilities
18//!
19//! | Capability | Upstream evidence | Boundary |
20//! |---|---|---|
21//! | `XPathNodeSetSerialization` | E-001 (xmllint --xpath output) | 2.9.10 |
22//! | `ParserDiagnostic` | E-002 (second parse-error diagnostic) | 2.12.x |
23//! | `EntityCompactStorage` | E-004 (entity content debug node) | 2.13.0 |
24//! | `ValidationExit` | E-005 (parser/validation exit codes) | 2.13.0 |
25//! | `XpathAttrEmptyExit` | E-003 (empty node-set exit code) | 2.11.0 / 2.12.6 |
26//! | `HtmlSerializer` | E-007 (HTML dump newlines) | 2.15.0 |
27//! | `ValidationNoDtdExit` | E-006 (--valid without DTD) | 2.15.0 |
28//! | `GlobalStateInit` | 2.12 lazy-init rework | 2.12.0 |
29//! | `XslTransform` | E-008 (libxslt output frozen) | stable since ≤1.1.26 |
30//!
31//! # Upstream contract
32//!
33//! This module encodes the *behavioral* contract of upstream across
34//! versions. The upstream evidence files are parser.c, xpath.c, HTMLtree.c,
35//! valid.c and xmllint.c (SRC-LIBXML2-GIT, archaeology/libxml2-git): each
36//! capability above is the observable change a specific upstream commit or
37//! release made in one of those files. There is no upstream
38//! "capabilities" API — this module is the candidate own single
39//! structure for representing those changes, and it is not part of the C
40//! ABI.
41//!
42//! # Conceptual behavior
43//!
44//! A capability epoch is a maximal version span over which one observable
45//! behavior is byte-identical; `capabilities_for_libxml2` resolves every
46//! tracked capability for a target version, and `CompatibilityProfile`
47//! bundles the resolved set for a (libxml2, libxslt) pair. The model is
48//! deliberately epoch-based: the resolver is a pure function of the version
49//! string, so historical emulation and regression triage address one
50//! structure instead of ad-hoc version checks.
51//!
52//! # Ownership & safety invariants
53//!
54//! Every type in this module is a plain `Copy` value; capabilities are
55//! resolved at profile construction and never mutated afterwards. There is
56//! no shared state, no heap ownership and no unsafe code in the resolver:
57//! `capabilities_for_libxml2` is a pure function of the version string, so
58//! profiles can be resolved concurrently from any thread. The one safety
59//! invariant is the fail-fast `assert!` in `CompatibilityProfile::for_libxml2`:
60//! versions newer than the system oracle panic instead of silently
61//! resolving an unmeasured epoch.
62//!
63//! # Historical quirks & epochs
64//!
65//! The capability table is the executable encoding of the E-epoch findings
66//! (atlas/SEMANTIC_EPOCHS.md): E-001 (da35eeae, 2.9.10), E-002 (de5b624f
67//! fix / 2.12 rework), E-003 (e85f9b98 2.11.0 then 387a952b 2.12.6),
68//! E-004 (8d04f0ee, 2.13.0), E-005 (2.13.0), E-006 (2.15.0), E-007
69//! (2.15.0) and E-008 (stable since ≤1.1.26). Each boundary is pinned by
70//! the historical oracle matrix and by the unit tests below, which assert
71//! the exact version where each behavior flipped.
72//!
73//! # Deliberate oddities
74//!
75//! - `XslTransform` has a single value (Stable): E-008 proved byte-
76//!   identical output across 1.1.26..1.1.45, so the enum documents the
77//!   proven absence of a boundary rather than inventing one.
78//! - `GlobalStateInit` models the 2.12 lazy-init rework, which upstream
79//!   never announced as a behavioral change; representing it keeps
80//!   emulation uniform.
81//! - The resolver uses numeric tuple comparison on parsed (major, minor,
82//!   patch) rather than semver crates — a deliberate dependency-free choice
83//!   matching the evidence tables.
84//!
85//! # Proving courts
86//!
87//! The unit tests in this module (`cargo test`) assert every boundary;
88//! the differential court families that exercise the modeled behaviors
89//! are CLI-XMLLINT-* (E-001/E-003/E-005/E-006), XPATH, PARSER, DTD,
90//! HTML, C14N, XINCLUDE, XSD, RELAXNG, SCHEMATRON and XPOINTER, plus the
91//! HIST-EPOCH-* historical casefiles and the matrix receipts
92//! (courts/receipts/historical-matrix-*).
93//!
94//! # Tempting simplifications that would break parity
95//!
96//! - Replacing this resolver with scattered version checks in each
97//!   subsystem would scatter the boundaries and make epoch regression
98//!   triage un-auditable.
99//! - Collapsing the three-value enums (e.g. `XpathAttrEmptyExit`) to a
100//!   bool would lose the middle 2.11.0..2.12.5 window (E-003).
101//! - Letting `for_libxml2` accept versions beyond 2.15.3 would fabricate
102//!   epochs for versions no oracle has measured — a hazard for the
103//!   completeness claim.
104
105use core::fmt;
106
107/// Value of the XPath node-set serialization capability (E-001).
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum XPathNodeSetSerialization {
110    /// `xmllint --xpath` prints nodes concatenated (<= 2.9.4).
111    Concatenated,
112    /// `xmllint --xpath` prints one node per line with a final newline
113    /// (>= 2.9.10; upstream-documented breaking change, commit da35eeae).
114    NewlineSeparated,
115}
116
117/// Value of the parser-diagnostic capability (E-002).
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum ParserDiagnostic {
120    /// Two diagnostics for unexpected EOF ("Premature end of data in tag ..."
121    /// as the second line) — libxml2 <= 2.9.4 and >= 2.9.11 (fix de5b624f).
122    Dual,
123    /// The 2.9.10 regression variant (EndTag close-tag-not-found diagnostic).
124    Regression,
125    /// Single diagnostic; the second line was dropped in the 2.12.x error
126    /// handling rework (>= 2.12.6; the current epoch of the crate).
127    Single,
128}
129
130/// Value of the entity-content storage capability (E-004).
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum EntityCompactStorage {
133    /// `--debug --noent` dumps the entity content child as `TEXT` (<= 2.12.6).
134    Plain,
135    /// Dumps as `TEXT compact` (>= 2.13.0, commit 8d04f0ee).
136    Compact,
137}
138
139/// Value of the validation-exit-code capability (E-005).
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum ValidationExit {
142    /// parse-error/undeclared exit 1, valid-invalid exit 4 (<= 2.12.6).
143    Legacy,
144    /// parse-error/undeclared exit 4, valid-invalid exit 3 (>= 2.13.0).
145    Reworked,
146}
147
148/// Value of the `xpath-attr` empty node-set exit code (E-003).
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum XpathAttrEmptyExit {
151    /// Exit 10 (<= 2.9.x era; "XPath set is empty").
152    Legacy,
153    /// Exit 0 (2.11.0..2.12.5, commit e85f9b98).
154    NoError,
155    /// Exit 11 (>= 2.12.6, commit 387a952b).
156    Error11,
157}
158
159/// Value of the HTML serialization capability (E-007).
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum HtmlSerializer {
162    /// Newline after elements in the dump path (<= 2.14.1).
163    Formatted,
164    /// Single-line output (>= 2.15.0; newline writes removed from HTMLtree.c).
165    SingleLine,
166}
167
168/// Value of the `--valid`-without-DTD exit capability (E-006).
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum ValidationNoDtdExit {
171    /// Exit 3 (2.13.0..2.14.1).
172    Error3,
173    /// Exit 0 (>= 2.15.0).
174    Ok0,
175}
176
177/// Value of the global-state initialisation capability (2.12 rework).
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum GlobalStateInit {
180    /// Eager static initialisation (<= 2.11.x).
181    Eager,
182    /// Lazy per-context initialisation (>= 2.12.0).
183    Lazy,
184}
185
186/// Value of the libxslt transform output capability (E-008).
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum XslTransform {
189    /// Transform output frozen; byte-identical 1.1.26 .. 1.1.45.
190    Stable,
191}
192
193/// Every capability the profiles module tracks, with its resolved value.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub struct Capabilities {
196    /// Resolved E-001 capability: how `xmllint --xpath` serializes node-sets.
197    pub xpath_node_set_serialization: XPathNodeSetSerialization,
198    /// Resolved E-002 capability: parse-error diagnostic behavior.
199    pub parser_diagnostic: ParserDiagnostic,
200    /// Resolved E-004 capability: entity content storage in debug output.
201    pub entity_compact_storage: EntityCompactStorage,
202    /// Resolved E-005 capability: parser/validation exit codes.
203    pub validation_exit: ValidationExit,
204    /// Resolved E-003 capability: exit code for empty `xpath-attr` node-sets.
205    pub xpath_attr_empty_exit: XpathAttrEmptyExit,
206    /// Resolved E-007 capability: HTML dump newline behavior.
207    pub html_serializer: HtmlSerializer,
208    /// Resolved E-006 capability: `--valid` exit code without a DTD.
209    pub validation_no_dtd_exit: ValidationNoDtdExit,
210    /// Resolved capability: eager vs. lazy global-state initialisation
211    /// (2.12 rework).
212    pub global_state_init: GlobalStateInit,
213    /// Resolved E-008 capability: libxslt transform output stability.
214    pub xsl_transform: XslTransform,
215}
216
217/// Parse a version string like `"2.15.3"` into `(major, minor, patch)`.
218fn parse_version(v: &str) -> (u32, u32, u32) {
219    let mut it = v.trim_start_matches('v').split('.');
220    let major = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
221    let minor = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
222    let patch = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
223    (major, minor, patch)
224}
225
226fn at_least(version: &str, major: u32, minor: u32) -> bool {
227    let (maj, min, _) = parse_version(version);
228    (maj, min) >= (major, minor)
229}
230
231/// Resolve the capability values for a target libxml2 version.
232///
233/// Boundaries are evidence-backed (E-001..E-008); see the module docs for
234/// the exact upstream commits/releases that created each change.
235pub fn capabilities_for_libxml2(version: &str) -> Capabilities {
236    Capabilities {
237        xpath_node_set_serialization: {
238            let (maj, min, pat) = parse_version(version);
239            if (maj, min) > (2, 9) || (maj == 2 && min == 9 && pat >= 10) {
240                XPathNodeSetSerialization::NewlineSeparated
241            } else {
242                XPathNodeSetSerialization::Concatenated
243            }
244        },
245        parser_diagnostic: {
246            let (maj, min, pat) = parse_version(version);
247            if (maj, min, pat) >= (2, 9, 10) && (maj, min, pat) < (2, 9, 11) {
248                ParserDiagnostic::Regression
249            } else if (maj, min) >= (2, 12) {
250                ParserDiagnostic::Single
251            } else {
252                ParserDiagnostic::Dual
253            }
254        },
255        entity_compact_storage: if at_least(version, 2, 13) {
256            EntityCompactStorage::Compact
257        } else {
258            EntityCompactStorage::Plain
259        },
260        validation_exit: if at_least(version, 2, 13) {
261            ValidationExit::Reworked
262        } else {
263            ValidationExit::Legacy
264        },
265        xpath_attr_empty_exit: {
266            let (maj, min) = (parse_version(version).0, parse_version(version).1);
267            if (maj, min) < (2, 11) {
268                XpathAttrEmptyExit::Legacy
269            } else if (maj, min) < (2, 12)
270                || (maj == 2 && min == 12 && parse_version(version).2 < 6)
271            {
272                XpathAttrEmptyExit::NoError
273            } else {
274                XpathAttrEmptyExit::Error11
275            }
276        },
277        html_serializer: if at_least(version, 2, 15) {
278            HtmlSerializer::SingleLine
279        } else {
280            HtmlSerializer::Formatted
281        },
282        validation_no_dtd_exit: if at_least(version, 2, 15) {
283            ValidationNoDtdExit::Ok0
284        } else {
285            ValidationNoDtdExit::Error3
286        },
287        global_state_init: if at_least(version, 2, 12) {
288            GlobalStateInit::Lazy
289        } else {
290            GlobalStateInit::Eager
291        },
292        xsl_transform: XslTransform::Stable,
293    }
294}
295
296/// A resolved compatibility profile for a target upstream version pair.
297///
298/// The current target of the candidate is the system oracle
299/// (libxml2 2.15.3 / libxslt 1.1.45); emulating older releases resolves this
300/// profile against the capability table instead of ad-hoc version branches.
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub struct CompatibilityProfile {
303    /// Target libxml2 version, e.g. "2.15.3".
304    pub libxml2_version: &'static str,
305    /// Target libxslt version, e.g. "1.1.45".
306    pub libxslt_version: &'static str,
307    /// Resolved capabilities.
308    pub capabilities: Capabilities,
309}
310
311impl CompatibilityProfile {
312    /// The current-system profile of the candidate (libxml2 2.15.3 / libxslt 1.1.45).
313    pub fn current() -> CompatibilityProfile {
314        CompatibilityProfile {
315            libxml2_version: "2.15.3",
316            libxslt_version: "1.1.45",
317            capabilities: capabilities_for_libxml2("2.15.3"),
318        }
319    }
320
321    /// Resolve a profile for an explicit libxml2 version (libxslt assumed
322    /// at its matching current version). Panics on versions newer than the
323    /// system oracle to avoid inventing unverifiable epochs.
324    pub fn for_libxml2(version: &str) -> CompatibilityProfile {
325        assert!(
326            parse_version(version) <= parse_version("2.15.3"),
327            "no evidence-backed epoch for libxml2 {version}"
328        );
329        CompatibilityProfile {
330            libxml2_version: "2.15.3",
331            libxslt_version: "1.1.45",
332            capabilities: capabilities_for_libxml2(version),
333        }
334    }
335}
336
337impl fmt::Display for CompatibilityProfile {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        write!(
340            f,
341            "profile(libxml2 {}, libxslt {}, caps={:?})",
342            self.libxml2_version, self.libxslt_version, self.capabilities
343        )
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    /// The boundary table below is the executable encoding of the E-epoch
352    /// findings (atlas/SEMANTIC_EPOCHS.md); each assertion is evidence-backed.
353    #[test]
354    fn e001_xpath_node_set_boundary() {
355        assert_eq!(
356            capabilities_for_libxml2("2.9.4").xpath_node_set_serialization,
357            XPathNodeSetSerialization::Concatenated
358        );
359        assert_eq!(
360            capabilities_for_libxml2("2.9.10").xpath_node_set_serialization,
361            XPathNodeSetSerialization::NewlineSeparated
362        );
363        assert_eq!(
364            CompatibilityProfile::current()
365                .capabilities
366                .xpath_node_set_serialization,
367            XPathNodeSetSerialization::NewlineSeparated
368        );
369    }
370
371    #[test]
372    fn e002_parser_diagnostic_window() {
373        assert_eq!(
374            capabilities_for_libxml2("2.9.4").parser_diagnostic,
375            ParserDiagnostic::Dual
376        );
377        assert_eq!(
378            capabilities_for_libxml2("2.9.10").parser_diagnostic,
379            ParserDiagnostic::Regression
380        );
381        assert_eq!(
382            capabilities_for_libxml2("2.12.6").parser_diagnostic,
383            ParserDiagnostic::Single
384        );
385    }
386
387    #[test]
388    fn e004_entity_compact_boundary() {
389        assert_eq!(
390            capabilities_for_libxml2("2.12.6").entity_compact_storage,
391            EntityCompactStorage::Plain
392        );
393        assert_eq!(
394            capabilities_for_libxml2("2.13.0").entity_compact_storage,
395            EntityCompactStorage::Compact
396        );
397    }
398
399    #[test]
400    fn e005_validation_exit_boundary() {
401        assert_eq!(
402            capabilities_for_libxml2("2.12.6").validation_exit,
403            ValidationExit::Legacy
404        );
405        assert_eq!(
406            capabilities_for_libxml2("2.13.0").validation_exit,
407            ValidationExit::Reworked
408        );
409    }
410
411    #[test]
412    fn e003_xpath_attr_empty_exit_chain() {
413        assert_eq!(
414            capabilities_for_libxml2("2.9.14").xpath_attr_empty_exit,
415            XpathAttrEmptyExit::Legacy
416        );
417        assert_eq!(
418            capabilities_for_libxml2("2.11.5").xpath_attr_empty_exit,
419            XpathAttrEmptyExit::NoError
420        );
421        assert_eq!(
422            capabilities_for_libxml2("2.12.6").xpath_attr_empty_exit,
423            XpathAttrEmptyExit::Error11
424        );
425    }
426
427    #[test]
428    fn e006_e007_boundaries() {
429        assert_eq!(
430            capabilities_for_libxml2("2.14.1").validation_no_dtd_exit,
431            ValidationNoDtdExit::Error3
432        );
433        assert_eq!(
434            capabilities_for_libxml2("2.15.0").validation_no_dtd_exit,
435            ValidationNoDtdExit::Ok0
436        );
437        assert_eq!(
438            capabilities_for_libxml2("2.14.1").html_serializer,
439            HtmlSerializer::Formatted
440        );
441        assert_eq!(
442            capabilities_for_libxml2("2.15.0").html_serializer,
443            HtmlSerializer::SingleLine
444        );
445    }
446
447    #[test]
448    fn global_state_init_boundary() {
449        assert_eq!(
450            capabilities_for_libxml2("2.11.5").global_state_init,
451            GlobalStateInit::Eager
452        );
453        assert_eq!(
454            capabilities_for_libxml2("2.12.0").global_state_init,
455            GlobalStateInit::Lazy
456        );
457    }
458
459    #[test]
460    fn xslt_transform_stable_epoch() {
461        // E-008: byte-identical output 1.1.26 .. 1.1.45.
462        assert_eq!(
463            capabilities_for_libxml2("2.15.3").xsl_transform,
464            XslTransform::Stable
465        );
466    }
467
468    #[test]
469    fn current_profile_resolves_current_epochs() {
470        let p = CompatibilityProfile::current();
471        assert_eq!(p.libxml2_version, "2.15.3");
472        assert_eq!(p.capabilities.parser_diagnostic, ParserDiagnostic::Single);
473        assert_eq!(p.capabilities.html_serializer, HtmlSerializer::SingleLine);
474        assert_eq!(
475            p.capabilities.validation_no_dtd_exit,
476            ValidationNoDtdExit::Ok0
477        );
478    }
479}