Skip to main content

spec_driven_docs/release/
legacy.rs

1//! Releases that predate the projection declaration.
2//!
3//! A published crate is immutable, so a release that shipped before the
4//! declaration existed can never acquire one. Either the engine describes
5//! those releases from outside, or every plan toward one is a guess. This
6//! module is that description: a finite catalog, audited against the
7//! registry index and the published archives, and closed the day schema 1
8//! ships.
9//!
10//! Nothing here infers. A release below the capability floor, or one the
11//! catalog does not name, is unavailable as a destination and says so with
12//! the evidence. The current projection is never applied retroactively: a
13//! descriptor states what that release landed, and the adapter overlays it
14//! as metadata beside the archive's own bytes.
15
16use std::collections::BTreeMap;
17
18use serde::Deserialize;
19
20use crate::domain::ownership::Sha256;
21use crate::domain::projection::{DECLARATION_PATH, Declaration};
22use crate::error::AppError;
23
24/// The catalog index this binary carries.
25pub static INDEX_TOML: &str = include_str!("../../release-compat/index.toml");
26
27/// Every descriptor this binary carries, by version.
28static DESCRIPTORS: &[(&str, &str)] = &[
29    (
30        "0.6.6",
31        include_str!("../../release-compat/legacy/0.6.6.toml"),
32    ),
33    (
34        "0.7.0",
35        include_str!("../../release-compat/legacy/0.7.0.toml"),
36    ),
37    (
38        "0.7.1",
39        include_str!("../../release-compat/legacy/0.7.1.toml"),
40    ),
41    (
42        "0.7.2",
43        include_str!("../../release-compat/legacy/0.7.2.toml"),
44    ),
45    (
46        "0.8.0",
47        include_str!("../../release-compat/legacy/0.8.0.toml"),
48    ),
49    (
50        "0.8.1",
51        include_str!("../../release-compat/legacy/0.8.1.toml"),
52    ),
53];
54
55/// One release, as the audit classified it.
56#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct CatalogEntry {
59    /// The published version.
60    pub version: String,
61    /// The registry's checksum for its archive.
62    pub checksum: String,
63    /// Whether this engine can read it as a destination.
64    pub eligible: bool,
65    /// Why it cannot, where it cannot.
66    #[serde(default)]
67    pub reason: Option<String>,
68    /// The descriptor file that supplies its facts.
69    #[serde(default)]
70    pub descriptor: Option<String>,
71    /// That file's digest, so a correction invalidates an outstanding plan.
72    #[serde(default)]
73    pub descriptor_sha256: Option<String>,
74}
75
76/// The whole catalog.
77#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct CatalogIndex {
80    /// The catalog's own schema.
81    pub schema: u32,
82    /// The date the registry and the archives were read.
83    pub audited_on: String,
84    /// The lowest release this engine can read.
85    pub capability_floor: String,
86    /// Every published release, in registry order.
87    pub releases: Vec<CatalogEntry>,
88}
89
90/// What a descriptor states about one pre-schema release.
91#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct Descriptor {
94    /// The release the descriptor describes.
95    pub version: String,
96    /// The payload roots that release's archive carries.
97    pub payload_roots: Vec<String>,
98    /// What that release needed of the engine that landed it.
99    pub compatibility: crate::plan::compatibility::Compatibility,
100    /// What it landed, written in the schema this engine reads.
101    pub projection: Declaration,
102}
103
104/// What the adapter overlays onto a pre-schema archive.
105#[derive(Debug, Clone)]
106pub struct Adapted {
107    /// The schema the release itself declared, which is none.
108    pub payload_schema: u32,
109    /// The descriptor that supplied the facts.
110    pub descriptor_sha256: Sha256,
111    /// The virtual metadata the bundle serves beside its payload.
112    pub metadata: BTreeMap<String, Vec<u8>>,
113}
114
115/// The pre-schema protocol version, which no release declared.
116pub const PRE_SCHEMA: u32 = 0;
117
118/// The audited catalog of pre-schema releases.
119#[derive(Debug, Clone)]
120pub struct LegacyCatalog {
121    index: CatalogIndex,
122    descriptors: BTreeMap<String, String>,
123}
124
125impl LegacyCatalog {
126    /// The catalog this binary carries.
127    ///
128    /// A catalog that does not parse is a build defect rather than a state
129    /// a command can meet, and the canon suite parses the same bytes.
130    ///
131    /// # Panics
132    ///
133    /// Never in a shipped build, for the reason above.
134    #[expect(
135        clippy::expect_used,
136        reason = "the catalog is compiled in; a parse failure is a build defect the canon suite catches first"
137    )]
138    #[must_use]
139    pub fn embedded() -> Self {
140        Self {
141            index: toml::from_str(INDEX_TOML).expect("the embedded release catalog parses"),
142            descriptors: DESCRIPTORS
143                .iter()
144                .map(|(version, text)| ((*version).to_string(), (*text).to_string()))
145                .collect(),
146        }
147    }
148
149    /// What the catalog says about one version.
150    #[must_use]
151    pub fn entry(&self, version: &str) -> Option<&CatalogEntry> {
152        self.index
153            .releases
154            .iter()
155            .find(|entry| entry.version == version)
156    }
157
158    /// The index itself, for the canon suite and the diagnostics.
159    #[must_use]
160    pub const fn index(&self) -> &CatalogIndex {
161        &self.index
162    }
163
164    /// Every descriptor, by version.
165    #[must_use]
166    pub const fn descriptors(&self) -> &BTreeMap<String, String> {
167        &self.descriptors
168    }
169
170    /// The descriptor one version resolves to, checked against the index.
171    ///
172    /// # Errors
173    ///
174    /// [`AppError::Refused`] when the version is unavailable, uncataloged,
175    /// or whose descriptor no longer matches the digest the index records.
176    pub fn descriptor(&self, version: &str) -> Result<(Descriptor, Sha256), AppError> {
177        let entry = self.entry(version).ok_or_else(|| {
178            AppError::Refused(format!(
179                "{version} is not in the release catalog, and it declares no projection of its own, so this engine cannot say what it lands"
180            ))
181        })?;
182        if !entry.eligible {
183            let reason = entry.reason.as_deref().unwrap_or("it was not audited");
184            return Err(AppError::Refused(format!(
185                "{version} is unavailable as a destination: {reason}. The lowest release this engine reads is {}",
186                self.index.capability_floor
187            )));
188        }
189        let name = entry.descriptor.as_deref().ok_or_else(|| {
190            AppError::Refused(format!("{version} is eligible and names no descriptor"))
191        })?;
192        let text = self
193            .descriptors
194            .get(version)
195            .ok_or_else(|| AppError::Refused(format!("this engine does not carry {name}")))?;
196        let digest = Sha256::of(text.as_bytes());
197        let recorded = entry.descriptor_sha256.as_deref().unwrap_or_default();
198        if digest.as_str() != recorded {
199            return Err(AppError::Refused(format!(
200                "the descriptor for {version} hashes to {digest} and the catalog records {recorded}"
201            )));
202        }
203        let held: Descriptor = toml::from_str(text)
204            .map_err(|source| AppError::Refused(format!("{name} does not parse: {source}")))?;
205        if held.version != version {
206            return Err(AppError::Refused(format!(
207                "{name} describes {} and the catalog files it under {version}",
208                held.version
209            )));
210        }
211        Ok((held, digest))
212    }
213
214    /// Overlay a descriptor's facts onto one pre-schema archive.
215    ///
216    /// # Errors
217    ///
218    /// [`AppError::Refused`] when the version is not cataloged, when the
219    /// descriptor disagrees with the archive it describes, or when the
220    /// overlay cannot be rendered.
221    pub fn adapt(
222        &self,
223        version: &str,
224        files: &BTreeMap<String, Vec<u8>>,
225    ) -> Result<Adapted, AppError> {
226        let (descriptor, descriptor_sha256) = self.descriptor(version)?;
227        for root in &descriptor.payload_roots {
228            let prefix = format!("{root}/");
229            if !files.keys().any(|path| path.starts_with(&prefix)) {
230                return Err(AppError::Refused(format!(
231                    "the descriptor for {version} names the root {root}, and the published archive carries no file under it"
232                )));
233            }
234        }
235        for entry in descriptor
236            .projection
237            .managed
238            .iter()
239            .chain(&descriptor.projection.adopted)
240        {
241            if !files.contains_key(&entry.source) {
242                return Err(AppError::Refused(format!(
243                    "the descriptor for {version} projects {}, and the published archive does not carry it",
244                    entry.source
245                )));
246            }
247        }
248        let rendered = toml::to_string_pretty(&descriptor.projection).map_err(|source| {
249            AppError::Refused(format!(
250                "the descriptor for {version} did not render as a declaration: {source}"
251            ))
252        })?;
253        // The compatibility answer travels as virtual metadata too, so an
254        // adapted candidate answers the question the same way a native one
255        // does rather than being a second case in the planner.
256        let compatibility =
257            toml::to_string_pretty(&descriptor.compatibility).map_err(|source| {
258                AppError::Refused(format!(
259                    "the descriptor for {version} did not render its compatibility: {source}"
260                ))
261            })?;
262        Ok(Adapted {
263            payload_schema: PRE_SCHEMA,
264            descriptor_sha256,
265            metadata: BTreeMap::from([
266                (DECLARATION_PATH.to_string(), rendered.into_bytes()),
267                (
268                    crate::plan::compatibility::DECLARATION_PATH.to_string(),
269                    compatibility.into_bytes(),
270                ),
271            ]),
272        })
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    #![allow(
279        clippy::unwrap_used,
280        reason = "a test panics as its failure signal, not as control flow"
281    )]
282
283    use super::*;
284
285    #[test]
286    fn every_eligible_release_has_one_descriptor_and_no_other_does() {
287        let catalog = LegacyCatalog::embedded();
288        for entry in &catalog.index().releases {
289            if entry.eligible {
290                assert!(
291                    entry.descriptor.is_some(),
292                    "{} names no descriptor",
293                    entry.version
294                );
295                assert!(
296                    catalog.descriptors().contains_key(&entry.version),
297                    "{} is eligible and not carried",
298                    entry.version
299                );
300            } else {
301                assert!(
302                    entry.descriptor.is_none(),
303                    "{} is ineligible and named one",
304                    entry.version
305                );
306                assert!(
307                    !catalog.descriptors().contains_key(&entry.version),
308                    "{} is ineligible and carried",
309                    entry.version
310                );
311                assert!(
312                    entry.reason.is_some(),
313                    "{} refuses without a reason",
314                    entry.version
315                );
316            }
317        }
318        assert_eq!(
319            catalog.descriptors().len(),
320            catalog
321                .index()
322                .releases
323                .iter()
324                .filter(|e| e.eligible)
325                .count()
326        );
327    }
328
329    #[test]
330    fn no_version_appears_twice() {
331        let catalog = LegacyCatalog::embedded();
332        let mut seen: Vec<&str> = Vec::new();
333        for entry in &catalog.index().releases {
334            assert!(
335                !seen.contains(&entry.version.as_str()),
336                "{} twice",
337                entry.version
338            );
339            seen.push(&entry.version);
340        }
341    }
342
343    #[test]
344    fn release_0_6_5_is_unavailable_and_0_6_6_is_the_capability_floor() {
345        let catalog = LegacyCatalog::embedded();
346        assert_eq!(catalog.index().capability_floor, "0.6.6");
347        let error = catalog.descriptor("0.6.5").unwrap_err();
348        assert!(error.to_string().contains("unavailable"), "{error}");
349        assert!(error.to_string().contains("instance/seeds"), "{error}");
350        assert!(catalog.descriptor("0.6.6").is_ok());
351    }
352
353    #[test]
354    fn an_uncataloged_version_is_never_a_candidate() {
355        let error = LegacyCatalog::embedded().descriptor("9.9.9").unwrap_err();
356        assert!(
357            error.to_string().contains("not in the release catalog"),
358            "{error}"
359        );
360    }
361
362    #[test]
363    fn every_descriptor_matches_the_digest_the_index_records() {
364        let catalog = LegacyCatalog::embedded();
365        for (version, text) in catalog.descriptors() {
366            let entry = catalog.entry(version).unwrap();
367            assert_eq!(
368                entry.descriptor_sha256.as_deref(),
369                Some(Sha256::of(text.as_bytes()).as_str()),
370                "{version} descriptor digest drifted"
371            );
372        }
373    }
374
375    #[test]
376    fn an_overlay_needs_the_archive_to_carry_what_it_projects() {
377        let catalog = LegacyCatalog::embedded();
378        let error = catalog.adapt("0.6.6", &BTreeMap::new()).unwrap_err();
379        assert!(
380            error.to_string().contains("carries no file under it"),
381            "{error}"
382        );
383    }
384
385    #[test]
386    fn every_descriptor_carries_a_compatibility_section() {
387        let catalog = LegacyCatalog::embedded();
388        for version in catalog.descriptors().keys() {
389            let (descriptor, _) = catalog.descriptor(version).unwrap();
390            assert_eq!(
391                descriptor.compatibility.schema,
392                crate::plan::compatibility::SCHEMA
393            );
394            assert_eq!(
395                descriptor.compatibility.minimum_engine.to_string(),
396                *version
397            );
398        }
399    }
400
401    #[test]
402    fn an_overlay_renders_a_declaration_this_engine_reads() {
403        let catalog = LegacyCatalog::embedded();
404        let (descriptor, _) = catalog.descriptor("0.8.0").unwrap();
405        // Stand in for the archive: every projected source and one file
406        // under every declared root.
407        let mut files: BTreeMap<String, Vec<u8>> = descriptor
408            .projection
409            .managed
410            .iter()
411            .chain(&descriptor.projection.adopted)
412            .map(|entry| (entry.source.clone(), b"x".to_vec()))
413            .collect();
414        for root in &descriptor.payload_roots {
415            files.insert(format!("{root}/present.md"), b"x".to_vec());
416        }
417        let adapted = catalog.adapt("0.8.0", &files).unwrap();
418        assert_eq!(adapted.payload_schema, PRE_SCHEMA);
419        let rendered = adapted.metadata.get(DECLARATION_PATH).unwrap();
420        let read = Declaration::parse(rendered).unwrap();
421        assert_eq!(read, descriptor.projection);
422    }
423}