Skip to main content

spec_driven_docs/
release.rs

1//! The release boundary: what a release is, and how one is obtained.
2//!
3//! Two small interfaces sit here and nothing else. A [`ReleaseBundle`]
4//! answers with a manifest and with blobs by digest, and it does not say
5//! where the bytes came from. A [`ReleaseResolver`] turns a selector into
6//! exactly one verified bundle, once.
7//!
8//! They are separate on purpose. Resolution reaches the network and freezes
9//! an identity; content access never does. An apply that could resolve a
10//! second time could apply a release the plan never described.
11//!
12//! Parsing what a bundle carries is the domain's work, not this layer's.
13//! The boundary hands over bytes; [`crate::domain::projection`] says what
14//! the projection declaration in them means.
15
16pub mod crates_io;
17pub mod embedded;
18pub mod fixture;
19pub mod legacy;
20
21use std::collections::BTreeMap;
22
23use serde::Serialize;
24
25use crate::domain::ownership::Sha256;
26use crate::domain::projection::{DECLARATION_PATH, Declaration};
27use crate::domain::version::CanonVersion;
28use crate::error::AppError;
29
30/// The version type the registry speaks, which admits a prerelease where
31/// [`CanonVersion`] admits only a released triple.
32pub use semver::Version;
33
34/// The machine schema `sdd payload --json` declares.
35///
36/// Independent of the status schema, the installed-record schema, and the
37/// payload schema. They version different things and move on their own
38/// dates, and one number for four of them would tie every reader to every
39/// change.
40pub const MANIFEST_SCHEMA: &str = "sdd.payload/1";
41
42/// Where a release's facts came from.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum Provenance {
46    /// The release declares itself.
47    Native,
48    /// The release predates the declaration, and an audited catalog entry
49    /// supplies its projection facts.
50    LegacyAdapted,
51}
52
53/// What one artifact is to the engine.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "kebab-case")]
56pub enum Role {
57    /// A file the release can project into a target.
58    Payload,
59    /// A fact about the release that no target receives.
60    Metadata,
61}
62
63/// One file a bundle carries.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct Artifact {
66    /// The logical path, as the projection names it.
67    pub path: String,
68    /// Whether a target can receive it.
69    pub role: Role,
70    /// How many bytes it is.
71    pub bytes: u64,
72    /// Its digest, which is also its key in the blob store.
73    pub sha256: Sha256,
74}
75
76/// What a bundle says about itself.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78pub struct ReleaseManifest {
79    /// The machine schema of this object.
80    pub schema: &'static str,
81    /// The release this bundle is.
82    pub version: Version,
83    /// The protocol version between the engine and this bundle.
84    pub payload_schema: u32,
85    /// Where the release's facts came from.
86    pub provenance: Provenance,
87    /// The catalog descriptor used, where one was.
88    pub descriptor_sha256: Option<Sha256>,
89    /// A digest over every artifact's path and digest, in path order.
90    pub payload_sha256: Sha256,
91    /// Every file the bundle carries, in path order.
92    pub artifacts: Vec<Artifact>,
93}
94
95impl ReleaseManifest {
96    /// The digest one logical path resolves to.
97    #[must_use]
98    pub fn digest_of(&self, path: &str) -> Option<&Sha256> {
99        self.artifacts
100            .iter()
101            .find(|artifact| artifact.path == path)
102            .map(|artifact| &artifact.sha256)
103    }
104
105    /// A digest over every artifact's path and digest, in path order.
106    ///
107    /// Identity over content rather than over the archive: two bundles that
108    /// carry the same files are the same release to the planner, whichever
109    /// transport served them.
110    #[must_use]
111    pub fn payload_digest(artifacts: &[Artifact]) -> Sha256 {
112        let mut joined = String::new();
113        for artifact in artifacts {
114            joined.push_str(&artifact.path);
115            joined.push('\0');
116            joined.push_str(artifact.sha256.as_str());
117            joined.push('\n');
118        }
119        Sha256::of(joined.as_bytes())
120    }
121}
122
123/// One release's bytes, however they were obtained.
124pub trait ReleaseBundle: Send + Sync {
125    /// What this bundle says about itself.
126    ///
127    /// # Errors
128    ///
129    /// [`AppError`] when the bundle cannot describe itself.
130    fn manifest(&self) -> Result<ReleaseManifest, AppError>;
131
132    /// One artifact's bytes, by digest.
133    ///
134    /// # Errors
135    ///
136    /// [`AppError::Refused`] when the bundle carries no such digest.
137    fn blob(&self, digest: &Sha256) -> Result<Vec<u8>, AppError>;
138
139    /// One artifact's bytes, by the path the manifest names.
140    ///
141    /// # Errors
142    ///
143    /// [`AppError::Refused`] when the bundle carries no such path.
144    fn artifact(&self, path: &str) -> Result<Vec<u8>, AppError> {
145        let manifest = self.manifest()?;
146        let digest = manifest.digest_of(path).ok_or_else(|| {
147            AppError::Refused(format!("release {} carries no {path}", manifest.version))
148        })?;
149        self.blob(digest)
150    }
151
152    /// What this release declares it lands.
153    ///
154    /// # Errors
155    ///
156    /// [`AppError::Refused`] when the bundle carries no declaration or the
157    /// declaration is one this engine does not decode.
158    fn declaration(&self) -> Result<Declaration, AppError> {
159        let bytes = self.artifact(DECLARATION_PATH)?;
160        Declaration::parse(&bytes).map_err(|source| AppError::Refused(source.to_string()))
161    }
162}
163
164/// Which release a caller asked for.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum Selector {
167    /// The release this binary carries.
168    Embedded,
169    /// The highest stable release the registry serves.
170    Latest,
171    /// Exactly this version.
172    Exact(Version),
173}
174
175impl std::fmt::Display for Selector {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        match self {
178            Self::Embedded => f.write_str("embedded"),
179            Self::Latest => f.write_str("latest"),
180            Self::Exact(version) => write!(f, "{version}"),
181        }
182    }
183}
184
185/// One selector, frozen to one verified bundle.
186pub struct ResolvedRelease {
187    /// What the caller asked for.
188    pub selector: Selector,
189    /// The exact release the selector resolved to.
190    pub version: Version,
191    /// The registry's checksum for the archive, where a registry served it.
192    pub registry_checksum: Option<Sha256>,
193    /// The digest over the bundle's content.
194    pub payload_sha256: Sha256,
195    /// Whether the registry marks this release yanked.
196    pub yanked: bool,
197    /// The bundle itself.
198    pub bundle: Box<dyn ReleaseBundle>,
199}
200
201impl std::fmt::Debug for ResolvedRelease {
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        f.debug_struct("ResolvedRelease")
204            .field("selector", &self.selector)
205            .field("version", &self.version)
206            .field("registry_checksum", &self.registry_checksum)
207            .field("payload_sha256", &self.payload_sha256)
208            .field("yanked", &self.yanked)
209            .finish_non_exhaustive()
210    }
211}
212
213impl ResolvedRelease {
214    /// The resolved version as the installed record spells it.
215    ///
216    /// # Errors
217    ///
218    /// [`AppError::Refused`] where the release is a prerelease or carries
219    /// build metadata. A record holds a released triple, so a plan toward
220    /// anything else has nothing to write into one.
221    pub fn canon_version(&self) -> Result<CanonVersion, AppError> {
222        self.version.to_string().parse().map_err(|_| {
223            AppError::Refused(format!(
224                "{} is not a released triple, so no instance record can name it",
225                self.version
226            ))
227        })
228    }
229}
230
231/// Turn a selector into exactly one verified bundle.
232pub trait ReleaseResolver {
233    /// Resolve once.
234    ///
235    /// # Errors
236    ///
237    /// [`AppError`] when the selector cannot be resolved, when the bundle
238    /// cannot be verified, or when the network is needed and refused.
239    fn resolve(&self, selector: &Selector) -> Result<ResolvedRelease, AppError>;
240}
241
242/// Build a manifest from a set of logical paths and their bytes.
243///
244/// Shared by every bundle, so identity is computed one way whichever
245/// transport served the bytes.
246#[must_use]
247pub fn manifest_from(
248    version: Version,
249    payload_schema: u32,
250    provenance: Provenance,
251    descriptor_sha256: Option<Sha256>,
252    files: &BTreeMap<String, Vec<u8>>,
253    metadata: &BTreeMap<String, Vec<u8>>,
254) -> ReleaseManifest {
255    let mut artifacts: Vec<Artifact> = files
256        .iter()
257        .map(|(path, bytes)| artifact_of(path, bytes, Role::Payload))
258        .chain(
259            metadata
260                .iter()
261                .map(|(path, bytes)| artifact_of(path, bytes, Role::Metadata)),
262        )
263        .collect();
264    artifacts.sort_by(|left, right| left.path.cmp(&right.path));
265    ReleaseManifest {
266        schema: MANIFEST_SCHEMA,
267        version,
268        payload_schema,
269        provenance,
270        descriptor_sha256,
271        payload_sha256: ReleaseManifest::payload_digest(&artifacts),
272        artifacts,
273    }
274}
275
276fn artifact_of(path: &str, bytes: &[u8], role: Role) -> Artifact {
277    Artifact {
278        path: path.to_string(),
279        role,
280        bytes: bytes.len() as u64,
281        sha256: Sha256::of(bytes),
282    }
283}
284
285/// Serve a blob out of a byte map keyed by path.
286///
287/// # Errors
288///
289/// [`AppError::Refused`] when no artifact carries that digest.
290pub fn blob_from(
291    files: &BTreeMap<String, Vec<u8>>,
292    metadata: &BTreeMap<String, Vec<u8>>,
293    digest: &Sha256,
294) -> Result<Vec<u8>, AppError> {
295    files
296        .values()
297        .chain(metadata.values())
298        .find(|bytes| &Sha256::of(bytes) == digest)
299        .cloned()
300        .ok_or_else(|| AppError::Refused(format!("the bundle carries no blob {digest}")))
301}
302
303#[cfg(test)]
304mod tests {
305    #![allow(
306        clippy::unwrap_used,
307        reason = "a test panics as its failure signal, not as control flow"
308    )]
309
310    use super::*;
311
312    fn current() -> Version {
313        CanonVersion::current().to_string().parse().unwrap()
314    }
315
316    fn files() -> BTreeMap<String, Vec<u8>> {
317        BTreeMap::from([
318            ("b.md".to_string(), b"two".to_vec()),
319            ("a.md".to_string(), b"one".to_vec()),
320        ])
321    }
322
323    #[test]
324    fn a_manifest_lists_every_artifact_in_path_order() {
325        let manifest = manifest_from(
326            current(),
327            1,
328            Provenance::Native,
329            None,
330            &files(),
331            &BTreeMap::new(),
332        );
333        let paths: Vec<&str> = manifest
334            .artifacts
335            .iter()
336            .map(|artifact| artifact.path.as_str())
337            .collect();
338        assert_eq!(paths, ["a.md", "b.md"]);
339        assert_eq!(manifest.schema, MANIFEST_SCHEMA);
340        assert_eq!(manifest.digest_of("a.md"), Some(&Sha256::of(b"one")));
341        assert_eq!(manifest.digest_of("absent.md"), None);
342    }
343
344    #[test]
345    fn the_payload_digest_reads_the_content_and_not_the_order_it_was_given() {
346        let one = manifest_from(
347            current(),
348            1,
349            Provenance::Native,
350            None,
351            &files(),
352            &BTreeMap::new(),
353        );
354        let mut reversed = BTreeMap::new();
355        reversed.insert("a.md".to_string(), b"one".to_vec());
356        reversed.insert("b.md".to_string(), b"two".to_vec());
357        let two = manifest_from(
358            current(),
359            1,
360            Provenance::Native,
361            None,
362            &reversed,
363            &BTreeMap::new(),
364        );
365        assert_eq!(one.payload_sha256, two.payload_sha256);
366    }
367
368    #[test]
369    fn metadata_joins_the_manifest_as_a_second_role() {
370        let metadata = BTreeMap::from([("m.toml".to_string(), b"x".to_vec())]);
371        let manifest = manifest_from(
372            current(),
373            1,
374            Provenance::LegacyAdapted,
375            Some(Sha256::of(b"descriptor")),
376            &files(),
377            &metadata,
378        );
379        let roles: Vec<Role> = manifest
380            .artifacts
381            .iter()
382            .map(|artifact| artifact.role)
383            .collect();
384        assert_eq!(roles, [Role::Payload, Role::Payload, Role::Metadata]);
385        assert_eq!(manifest.provenance, Provenance::LegacyAdapted);
386    }
387
388    #[test]
389    fn a_blob_is_served_by_digest_and_an_unknown_digest_refuses() {
390        let held = files();
391        assert_eq!(
392            blob_from(&held, &BTreeMap::new(), &Sha256::of(b"one")).unwrap(),
393            b"one"
394        );
395        assert!(blob_from(&held, &BTreeMap::new(), &Sha256::of(b"nope")).is_err());
396    }
397}