Skip to main content

varve_core/
layerspec.rs

1//! `layer.toml` → assembler inputs (REQ-LAYERREPO-001).
2//!
3//! A realm's contents belong in the realm's own repository, not in varve's
4//! workflow file: bumping `rivet` to v0.34.0 should be a one-line reviewed diff
5//! in `pulseengine-layers`, not a commit to the tool that signs it. This module
6//! is the adapter that makes that possible — it reads the manifest and produces
7//! the environment `tools/build-deposit-spec.sh` already consumes.
8//!
9//! It lives HERE, next to the assembler it feeds, rather than in the layers
10//! repository, because a realm running a *copy* of the assembler gets none of
11//! the system testing varve does on it (REQ-PEL-ASSEMBLER-001). One writer, one
12//! set of tests, every realm.
13//!
14//! ## Why this refuses so much
15//!
16//! The assembler's inputs are space-separated lists of colon-separated fields.
17//! That encoding cannot represent a value containing a space or a colon, and
18//! the shell will not complain — it will silently split one tool into two, or
19//! truncate a version. A layer assembled from a mangled list is still signed,
20//! still verifies, and carries the wrong bytes. So every field that lands in
21//! the encoding is checked against the encoding's own alphabet BEFORE it gets
22//! there, and anything the assembler cannot faithfully carry is an error rather
23//! than a best-effort translation. This is the same reasoning that made
24//! `UNVERIFIED_INGEST` line-separated instead of punctuation-separated.
25
26use std::collections::BTreeSet;
27use std::fmt;
28
29/// The pinned varve release that builds this layer.
30#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct VarvePin {
33    pub version: String,
34}
35
36/// Which realm this layer belongs to, and where it is published.
37#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct ManifestRealm {
40    pub name: String,
41    pub channel: String,
42    pub registry: String,
43}
44
45/// How a tool's release presents its binaries.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Layout {
48    /// One `.tar.gz` per target triple — the PulseEngine norm.
49    Tarball,
50    /// Bare per-platform binaries with no archive (sigil ships `wsc` this way).
51    RawPerPlatform,
52}
53
54impl Layout {
55    fn parse(s: &str) -> Option<Layout> {
56        match s {
57            "tarball" => Some(Layout::Tarball),
58            "raw-per-platform" => Some(Layout::RawPerPlatform),
59            _ => None,
60        }
61    }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct ManifestTool {
67    pub name: String,
68    pub version: String,
69    /// `owner/repo`. Absent = `pulseengine/<name>`.
70    #[serde(default)]
71    pub repo: Option<String>,
72    /// The executable's name, when it differs from the tool's (kiln ships
73    /// `kilnd`). Absent = `<name>`.
74    #[serde(default)]
75    pub binary: Option<String>,
76    /// Release asset name template; `%V` bare version, `%T` Rust target triple,
77    /// `%U` short upstream platform tag. Absent = the assembler's default.
78    #[serde(default)]
79    pub asset: Option<String>,
80    /// Absent = `tarball`.
81    #[serde(default)]
82    pub layout: Option<String>,
83    /// Why this tool is ingested with NO proof of origin (REQ-INGEST-001
84    /// clause 3). Present only for a release that offers neither a
85    /// cosign-signed sums file nor a build attestation.
86    ///
87    /// The reason is not paperwork: it is signed into the layer and shown by
88    /// `varve inspect`, so every consumer reads the operator's words next to
89    /// the bytes they were written about. "We could not verify this" must
90    /// never be the silent path, which is why the field carries prose rather
91    /// than a boolean.
92    #[serde(rename = "unverified-reason", default)]
93    pub unverified_reason: Option<String>,
94    /// Asset name for one target triple, when no template can derive it.
95    ///
96    /// Some upstreams ship a musl binary as their only Linux build —
97    /// `wac-cli-x86_64-unknown-linux-musl` — and a static musl binary is the
98    /// right payload for a gnu platform even though nothing in the platform
99    /// name says so. Inventing a `%MUSL` placeholder would guess at a
100    /// convention; naming the file is exact, and wrong-by-typo rather than
101    /// wrong-by-inference.
102    #[serde(rename = "asset-for", default)]
103    pub asset_for: std::collections::BTreeMap<String, String>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct ManifestVsix {
109    pub name: String,
110    pub version: String,
111    #[serde(default)]
112    pub repo: Option<String>,
113    /// Asset template; `%V` bare version, `%P` VS Code platform tag. A template
114    /// with no `%P` is one portable package.
115    pub asset: String,
116}
117
118/// The whole manifest. `deny_unknown_fields` throughout is load-bearing: a
119/// mistyped `verison = "v0.34.0"` would otherwise leave the real `version`
120/// missing or stale, and the layer would ship the wrong release under a good
121/// signature.
122#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct LayerManifest {
125    pub varve: VarvePin,
126    pub realm: ManifestRealm,
127    #[serde(default, rename = "tool")]
128    pub tools: Vec<ManifestTool>,
129    #[serde(default, rename = "vsix")]
130    pub vsix: Vec<ManifestVsix>,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum LayerSpecError {
135    /// The TOML did not parse, or carried a field the schema does not define.
136    Parse(String),
137    /// A value cannot survive the assembler's encoding.
138    Unencodable {
139        field: String,
140        value: String,
141        why: &'static str,
142    },
143    /// `layout = "..."` is not one the assembler implements.
144    UnknownLayout { tool: String, layout: String },
145    /// The assembler carries exactly one raw-per-platform tool, as
146    /// `WSC_VERSION`. A second one has nowhere to go.
147    ManyRawPerPlatform { first: String, second: String },
148    /// The assembler's single raw-per-platform slot is not generic: it fetches
149    /// `wsc` from `pulseengine/sigil`. Any other tool put in it becomes a
150    /// request for the wrong tool from the wrong repository.
151    RawPerPlatformNotWsc { tool: String, repo: String },
152    /// The assembler hardcodes `pulseengine/` for extension repositories.
153    VsixForeignOwner { name: String, repo: String },
154    /// The assembler derives a tarball tool's identity from its REPOSITORY
155    /// basename, so a manifest `name` that disagrees with it is discarded.
156    RepoNameMismatch {
157        name: String,
158        repo: String,
159        basename: String,
160    },
161    /// Two entries would land under one name.
162    Duplicate { kind: &'static str, name: String },
163    /// An opt-in that states no reason.
164    UnverifiedWithoutReason { tool: String },
165    /// Two tools from one repository disagree about why it is unverified.
166    ConflictingReason {
167        repo: String,
168        first: String,
169        second: String,
170    },
171    /// The manifest describes nothing to deposit.
172    Empty,
173}
174
175impl fmt::Display for LayerSpecError {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        match self {
178            LayerSpecError::Parse(e) => write!(f, "layer.toml does not parse: {e}"),
179            LayerSpecError::Unencodable { field, value, why } => write!(
180                f,
181                "{field} = {value:?} cannot be passed to the assembler: {why}. \
182                 The assembler reads space-separated entries of colon-separated \
183                 fields, so such a value would be split or truncated silently \
184                 and the layer would carry the wrong bytes under a good signature."
185            ),
186            LayerSpecError::UnknownLayout { tool, layout } => write!(
187                f,
188                "tool {tool:?} declares layout = {layout:?}, which varve does not \
189                 implement. Use \"tarball\" (one .tar.gz per target triple) or \
190                 \"raw-per-platform\" (bare per-platform binaries)."
191            ),
192            LayerSpecError::ManyRawPerPlatform { first, second } => write!(
193                f,
194                "tools {first:?} and {second:?} both declare \
195                 layout = \"raw-per-platform\", and the assembler carries only \
196                 one (as WSC_VERSION). Depositing would silently drop one of \
197                 them. Teach the assembler a general raw-per-platform list \
198                 before adding the second."
199            ),
200            LayerSpecError::RawPerPlatformNotWsc { tool, repo } => write!(
201                f,
202                "tool {tool:?} from {repo:?} declares \
203                 layout = \"raw-per-platform\", but the assembler's only slot \
204                 for that layout is hardcoded to fetch `wsc` from \
205                 `pulseengine/sigil` — it would emit WSC_VERSION and download \
206                 the wrong tool, from the wrong repository, at this tool's \
207                 version, and deposit it under the wrong name. Teach the \
208                 assembler a general raw-per-platform list before carrying \
209                 this."
210            ),
211            LayerSpecError::VsixForeignOwner { name, repo } => write!(
212                f,
213                "vsix {name:?} names repo {repo:?}, but the assembler resolves \
214                 extension repositories as pulseengine/<name> and would fetch \
215                 the wrong release. Either publish it under pulseengine, or \
216                 teach the assembler an owner field for extensions."
217            ),
218            LayerSpecError::RepoNameMismatch {
219                name,
220                repo,
221                basename,
222            } => write!(
223                f,
224                "tool {name:?} names repo {repo:?}, but the assembler takes a \
225                 tarball tool's identity from the repository basename — it \
226                 would download, name the payload, and default the asset \
227                 template as {basename:?}, and {name:?} would mean nothing. A \
228                 consumer asking for {name:?} would then find no such tool in \
229                 a layer that deposited and verified. Rename the entry to \
230                 {basename:?}, or set `binary` if only the executable differs."
231            ),
232            LayerSpecError::Duplicate { kind, name } => {
233                write!(f, "two {kind} entries are both named {name:?}")
234            }
235            LayerSpecError::UnverifiedWithoutReason { tool } => write!(
236                f,
237                "tool {tool:?} sets an empty `unverified-reason`. \"We could \
238                 not verify this\" must never be the silent path: the reason \
239                 is what travels with the bytes into the signed layer, where \
240                 every consumer reads it. Say why this is acceptable and what \
241                 removes the need, or do not carry the tool."
242            ),
243            LayerSpecError::ConflictingReason {
244                repo,
245                first,
246                second,
247            } => write!(
248                f,
249                "two tools from {repo:?} give different reasons for ingesting \
250                 it unverified:\n  {first:?}\n  {second:?}\nThe opt-in is per \
251                 RELEASE, not per tool, so one of these would be recorded and \
252                 the other silently discarded. Give the repository one reason."
253            ),
254            LayerSpecError::Empty => write!(
255                f,
256                "layer.toml declares no [[tool]] and no [[vsix]]: there is \
257                 nothing to deposit. A layer with no payloads is signed, \
258                 published, and useless."
259            ),
260        }
261    }
262}
263
264impl std::error::Error for LayerSpecError {}
265
266/// What the assembler needs in its environment, derived from the manifest.
267///
268/// Rendered as `KEY=value` lines so a workflow can append them to `$GITHUB_ENV`
269/// — no `eval`, no quoting round-trip, and nothing that would let a manifest
270/// value execute.
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct AssemblerEnv {
273    pub layer_tools: String,
274    pub wsc_version: Option<String>,
275    pub vsix_packages: String,
276    pub realm: String,
277    pub channel: String,
278    pub registry: String,
279    pub varve_version: String,
280    /// `owner/repo=reason` lines for releases ingested with no proof.
281    pub unverified_ingest: Vec<(String, String)>,
282}
283
284impl AssemblerEnv {
285    /// `KEY=value` lines, newline-terminated, in a stable order.
286    pub fn render(&self) -> String {
287        let mut out = String::new();
288        out.push_str(&format!("TARBALL_TOOLS={}\n", self.layer_tools));
289        out.push_str(&format!(
290            "WSC_VERSION={}\n",
291            self.wsc_version.as_deref().unwrap_or("")
292        ));
293        out.push_str(&format!("VSIX_PACKAGES={}\n", self.vsix_packages));
294        out.push_str(&format!("VARVE_REALM={}\n", self.realm));
295        out.push_str(&format!("VARVE_CHANNEL={}\n", self.channel));
296        out.push_str(&format!("VARVE_REGISTRY={}\n", self.registry));
297        out.push_str(&format!("VARVE_VERSION={}\n", self.varve_version));
298        // UNVERIFIED_INGEST is LINE-separated, because a reason is prose and
299        // any punctuation separator can occur inside it — the assembler
300        // documents that choice and the reason it made it. A `KEY=value` line
301        // cannot carry newlines, so this uses $GITHUB_ENV's heredoc form.
302        //
303        // The delimiter is checked against the content rather than assumed: an
304        // operator's reason that happened to contain the delimiter would end
305        // the block early and inject whatever followed as further environment,
306        // which is the shape of an actual injection rather than a typo.
307        if !self.unverified_ingest.is_empty() {
308            let body: String = self
309                .unverified_ingest
310                .iter()
311                .map(|(repo, why)| format!("{repo}={why}\n"))
312                .collect();
313            let mut delim = String::from("VARVE_UNVERIFIED_EOF");
314            while body.contains(&delim) {
315                delim.push('_');
316            }
317            out.push_str(&format!("UNVERIFIED_INGEST<<{delim}\n{body}{delim}\n"));
318        }
319        out
320    }
321}
322
323pub fn parse_layer_manifest(text: &str) -> Result<LayerManifest, LayerSpecError> {
324    toml::from_str(text).map_err(|e| LayerSpecError::Parse(e.to_string()))
325}
326
327/// Reject anything the assembler's encoding cannot carry intact.
328///
329/// `%` is allowed: it is the asset templates' own metacharacter. `:` and
330/// whitespace are the encoding's separators, and an empty value would collapse
331/// a field position.
332fn encodable(field: &str, value: &str) -> Result<(), LayerSpecError> {
333    let unencodable = |why| LayerSpecError::Unencodable {
334        field: field.to_string(),
335        value: value.to_string(),
336        why,
337    };
338    if value.is_empty() {
339        return Err(unencodable("it is empty"));
340    }
341    if value.contains(':') {
342        return Err(unencodable("it contains ':', which separates fields"));
343    }
344    if value.chars().any(char::is_whitespace) {
345        return Err(unencodable(
346            "it contains whitespace, which separates entries",
347        ));
348    }
349    Ok(())
350}
351
352/// `owner/repo` → (owner, repo), defaulting the owner to `pulseengine`.
353fn split_repo(repo: &str, default_name: &str) -> (String, String) {
354    match repo.split_once('/') {
355        Some((owner, name)) => (owner.to_string(), name.to_string()),
356        None => ("pulseengine".to_string(), default_name.to_string()),
357    }
358}
359
360pub fn assembler_env(m: &LayerManifest) -> Result<AssemblerEnv, LayerSpecError> {
361    if m.tools.is_empty() && m.vsix.is_empty() {
362        return Err(LayerSpecError::Empty);
363    }
364    encodable("realm.name", &m.realm.name)?;
365    encodable("realm.channel", &m.realm.channel)?;
366    encodable("varve.version", &m.varve.version)?;
367
368    let mut unverified: Vec<(String, String)> = Vec::new();
369    let mut tarballs: Vec<String> = Vec::new();
370    let mut wsc_version: Option<String> = None;
371    let mut raw_owner: Option<String> = None;
372    let mut seen_tools: BTreeSet<&str> = BTreeSet::new();
373
374    for t in &m.tools {
375        encodable("tool.name", &t.name)?;
376        encodable("tool.version", &t.version)?;
377        if !seen_tools.insert(t.name.as_str()) {
378            return Err(LayerSpecError::Duplicate {
379                kind: "tool",
380                name: t.name.clone(),
381            });
382        }
383        let layout = match t.layout.as_deref() {
384            None => Layout::Tarball,
385            Some(s) => Layout::parse(s).ok_or_else(|| LayerSpecError::UnknownLayout {
386                tool: t.name.clone(),
387                layout: s.to_string(),
388            })?,
389        };
390        // The opt-in is per RELEASE, so it is keyed by repository; two tools
391        // from one repo must agree about why it is unverified, or one reason
392        // would be recorded and the other silently dropped.
393        if let Some(why) = &t.unverified_reason {
394            let why = why.trim();
395            if why.is_empty() {
396                return Err(LayerSpecError::UnverifiedWithoutReason {
397                    tool: t.name.clone(),
398                });
399            }
400            let full = match &t.repo {
401                Some(r) => r.clone(),
402                None => format!("pulseengine/{}", t.name),
403            };
404            if let Some((_, prev)) = unverified.iter().find(|(r, _)| *r == full) {
405                if prev != why {
406                    return Err(LayerSpecError::ConflictingReason {
407                        repo: full,
408                        first: prev.clone(),
409                        second: why.to_string(),
410                    });
411                }
412            } else {
413                unverified.push((full, why.to_string()));
414            }
415        }
416        let (owner, repo_name) = match &t.repo {
417            Some(r) => {
418                encodable("tool.repo", r)?;
419                split_repo(r, &t.name)
420            }
421            None => ("pulseengine".to_string(), t.name.clone()),
422        };
423
424        if layout == Layout::RawPerPlatform {
425            if let Some(first) = &raw_owner {
426                return Err(LayerSpecError::ManyRawPerPlatform {
427                    first: first.clone(),
428                    second: t.name.clone(),
429                });
430            }
431            // The slot is not generic. `wsc` is what the assembler fetches,
432            // from `pulseengine/sigil`; anything else silently becomes a
433            // request for that tool at this tool's version.
434            if t.name != "wsc" || owner != "pulseengine" || repo_name != "sigil" {
435                return Err(LayerSpecError::RawPerPlatformNotWsc {
436                    tool: t.name.clone(),
437                    repo: format!("{owner}/{repo_name}"),
438                });
439            }
440            raw_owner = Some(t.name.clone());
441            wsc_version = Some(t.version.clone());
442            continue;
443        }
444
445        // The assembler does `tool="${f_tool##*/}"` and then uses that name for
446        // the payload, the extract directory and the default asset template. A
447        // manifest whose `name` disagrees with the repository basename is
448        // therefore not translated — it is DISCARDED, and the layer deposits a
449        // payload under a name nobody asked for. `raw-per-platform` is exempt
450        // because the assembler hardcodes that one pairing (`wsc` from
451        // `pulseengine/sigil`) instead of deriving it.
452        if repo_name != t.name {
453            return Err(LayerSpecError::RepoNameMismatch {
454                name: t.name.clone(),
455                repo: t.repo.clone().unwrap_or_else(|| repo_name.clone()),
456                basename: repo_name.clone(),
457            });
458        }
459
460        // `[owner/]tool:version[:binary[:asset-template]]`. The owner prefix is
461        // omitted when it is the default, so a manifest that names no repos
462        // produces exactly the list the pre-migration workflow carried.
463        // `repo_name == t.name` holds by the check above, so testing it here
464        // too would be a condition no input could vary — cargo-mutants proved
465        // it dead by flipping it and killing nothing.
466        let head = if owner == "pulseengine" {
467            t.name.clone()
468        } else {
469            format!("{owner}/{repo_name}")
470        };
471        let mut entry = format!("{head}:{}", t.version);
472        // A template cannot be given without a binary: they are positional.
473        match (&t.binary, &t.asset) {
474            (None, None) => {}
475            (Some(b), None) => {
476                encodable("tool.binary", b)?;
477                entry.push(':');
478                entry.push_str(b);
479            }
480            (b, Some(a)) => {
481                encodable("tool.asset", a)?;
482                let bin = b.clone().unwrap_or_else(|| t.name.clone());
483                encodable("tool.binary", &bin)?;
484                entry.push(':');
485                entry.push_str(&bin);
486                entry.push(':');
487                entry.push_str(a);
488            }
489        }
490        tarballs.push(entry);
491    }
492
493    let mut vsix_entries: Vec<String> = Vec::new();
494    let mut seen_vsix: BTreeSet<&str> = BTreeSet::new();
495    for v in &m.vsix {
496        encodable("vsix.name", &v.name)?;
497        encodable("vsix.version", &v.version)?;
498        encodable("vsix.asset", &v.asset)?;
499        if !seen_vsix.insert(v.name.as_str()) {
500            return Err(LayerSpecError::Duplicate {
501                kind: "vsix",
502                name: v.name.clone(),
503            });
504        }
505        // The assembler builds `pulseengine/<repo_name>` itself, so a foreign
506        // owner cannot be expressed — and quietly dropping the owner would
507        // fetch a DIFFERENT repository's release of the same name.
508        let repo_name = match &v.repo {
509            Some(r) => {
510                encodable("vsix.repo", r)?;
511                let (owner, name) = split_repo(r, &v.name);
512                if owner != "pulseengine" {
513                    return Err(LayerSpecError::VsixForeignOwner {
514                        name: v.name.clone(),
515                        repo: r.clone(),
516                    });
517                }
518                name
519            }
520            None => v.name.clone(),
521        };
522        vsix_entries.push(format!("{repo_name}:{}:{}:{}", v.version, v.name, v.asset));
523    }
524
525    Ok(AssemblerEnv {
526        layer_tools: tarballs.join(" "),
527        wsc_version,
528        vsix_packages: vsix_entries.join(" "),
529        realm: m.realm.name.clone(),
530        channel: m.realm.channel.clone(),
531        registry: m.realm.registry.clone(),
532        varve_version: m.varve.version.clone(),
533        unverified_ingest: unverified,
534    })
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    /// The manifest `pulseengine-layers` actually carries, trimmed to the
542    /// shapes that differ from one another. Kept as one literal so the tests
543    /// exercise a document that could really be committed, not a fragment.
544    const REAL: &str = r#"
545[varve]
546version = "v0.28.0"
547
548[realm]
549name    = "pulseengine"
550channel = "rolling"
551registry = "oci://ghcr.io/pulseengine/varve/layers"
552
553[[tool]]
554name    = "rivet"
555version = "v0.34.0"
556
557[[tool]]
558name    = "kiln"
559version = "v0.4.4"
560binary  = "kilnd"
561
562[[tool]]
563name    = "wsc"
564repo    = "pulseengine/sigil"
565version = "v0.11.0"
566layout  = "raw-per-platform"
567
568[[vsix]]
569name    = "rivet-sdlc"
570repo    = "pulseengine/rivet"
571version = "v0.34.0"
572asset   = "rivet-sdlc-%V.vsix"
573
574[[vsix]]
575name    = "spar-aadl"
576repo    = "pulseengine/spar"
577version = "v0.40.0"
578asset   = "spar-aadl-%P-%V.vsix"
579"#;
580
581    fn env_of(text: &str) -> AssemblerEnv {
582        assembler_env(&parse_layer_manifest(text).expect("parses")).expect("converts")
583    }
584
585    // rivet: verifies REQ-LAYERADAPT-001
586    #[test]
587    fn a_plain_tool_becomes_name_and_version() {
588        assert!(env_of(REAL).layer_tools.starts_with("rivet:v0.34.0 "));
589    }
590
591    // rivet: verifies REQ-LAYERADAPT-001
592    #[test]
593    fn a_differing_binary_name_is_carried_as_the_third_field() {
594        assert!(env_of(REAL).layer_tools.contains("kiln:v0.4.4:kilnd"));
595    }
596
597    /// The one raw-per-platform tool leaves TARBALL_TOOLS entirely — putting it
598    /// there would have the assembler look for a tarball that does not exist.
599    // rivet: verifies REQ-LAYERADAPT-001
600    #[test]
601    fn the_raw_per_platform_tool_becomes_wsc_version_and_not_a_tarball() {
602        let e = env_of(REAL);
603        assert_eq!(e.wsc_version.as_deref(), Some("v0.11.0"));
604        assert!(!e.layer_tools.contains("wsc"), "{}", e.layer_tools);
605        assert!(!e.layer_tools.contains("sigil"), "{}", e.layer_tools);
606    }
607
608    // rivet: verifies REQ-LAYERADAPT-001
609    #[test]
610    fn vsix_entries_drop_the_default_owner_the_assembler_re_adds() {
611        let e = env_of(REAL);
612        assert_eq!(
613            e.vsix_packages,
614            "rivet:v0.34.0:rivet-sdlc:rivet-sdlc-%V.vsix \
615             spar:v0.40.0:spar-aadl:spar-aadl-%P-%V.vsix"
616        );
617    }
618
619    /// The manifest is the only place a version is written, so a typo there
620    /// must not be able to leave the real key at its previous value.
621    // rivet: verifies REQ-LAYERADAPT-001
622    #[test]
623    fn a_mistyped_key_is_refused_rather_than_ignored() {
624        let text = REAL.replace("name    = \"rivet\"", "nmae    = \"rivet\"");
625        let err = parse_layer_manifest(&text).unwrap_err();
626        assert!(
627            matches!(&err, LayerSpecError::Parse(m) if m.contains("nmae")),
628            "{err}"
629        );
630    }
631
632    // rivet: verifies REQ-LAYERADAPT-001
633    #[test]
634    fn an_unknown_layout_is_refused_and_names_the_two_that_work() {
635        let text = REAL.replace("raw-per-platform", "zipfile");
636        let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
637        let msg = err.to_string();
638        assert!(
639            msg.contains("zipfile") && msg.contains("raw-per-platform"),
640            "{msg}"
641        );
642    }
643
644    /// The assembler has exactly one slot. A second raw-per-platform tool would
645    /// otherwise be dropped from a layer that still signs and publishes.
646    // rivet: verifies REQ-LAYERADAPT-001
647    #[test]
648    fn a_second_raw_per_platform_tool_is_refused_rather_than_dropped() {
649        let text = format!(
650            "{REAL}\n[[tool]]\nname = \"other\"\nversion = \"v1.0.0\"\nlayout = \"raw-per-platform\"\n"
651        );
652        let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
653        assert_eq!(
654            err,
655            LayerSpecError::ManyRawPerPlatform {
656                first: "wsc".into(),
657                second: "other".into()
658            }
659        );
660    }
661
662    /// Found by actually trying to assemble a bytecodealliance manifest: the
663    /// assembler's one raw-per-platform slot fetches `wsc` from
664    /// `pulseengine/sigil`, so putting any other tool in it emitted
665    /// WSC_VERSION = that tool's version and would have downloaded wsc at
666    /// v0.10.1 — wrong tool, wrong repo, wrong version, deposited under the
667    /// wrong name, silently.
668    // rivet: verifies REQ-LAYERADAPT-001
669    #[test]
670    fn a_raw_per_platform_tool_that_is_not_wsc_is_refused() {
671        let text = format!(
672            "{REAL}\n[[tool]]\nname = \"wac\"\nrepo = \"bytecodealliance/wac\"\n\
673             version = \"v0.10.1\"\nlayout = \"raw-per-platform\"\n"
674        );
675        // The FIRST raw tool in REAL is wsc, so this trips the many-slot rule;
676        // remove wsc to isolate the identity rule.
677        let only = text.replace(
678            "[[tool]]\nname    = \"wsc\"\nrepo    = \"pulseengine/sigil\"\nversion = \"v0.11.0\"\nlayout  = \"raw-per-platform\"\n",
679            "",
680        );
681        assert!(!only.contains("wsc"), "the wsc block must be gone: {only}");
682        let err = assembler_env(&parse_layer_manifest(&only).unwrap()).unwrap_err();
683        assert_eq!(
684            err,
685            LayerSpecError::RawPerPlatformNotWsc {
686                tool: "wac".into(),
687                repo: "bytecodealliance/wac".into()
688            },
689            "{err}"
690        );
691        assert!(err.to_string().contains("wrong repository"), "{err}");
692    }
693
694    /// One wrong field is enough. The slot fetches `wsc` from
695    /// `pulseengine/sigil`, so a tool that matches two of those three and
696    /// misses the third still becomes a request for something else — and a
697    /// test that only varies all three at once cannot tell the guard from a
698    /// much weaker one. cargo-mutants proved that by narrowing it.
699    // rivet: verifies REQ-LAYERADAPT-001
700    #[test]
701    fn the_wsc_slot_rejects_a_tool_that_differs_in_any_single_field() {
702        let base = REAL.replace(
703            "[[tool]]\nname    = \"wsc\"\nrepo    = \"pulseengine/sigil\"\nversion = \"v0.11.0\"\nlayout  = \"raw-per-platform\"\n",
704            "",
705        );
706        for (name, repo, what) in [
707            ("wsc", "acme/sigil", "a different OWNER"),
708            ("wsc", "pulseengine/other", "a different REPOSITORY"),
709            ("other", "pulseengine/sigil", "a different TOOL NAME"),
710        ] {
711            let text = format!(
712                "{base}\n[[tool]]\nname = \"{name}\"\nrepo = \"{repo}\"\n\
713                 version = \"v1.0.0\"\nlayout = \"raw-per-platform\"\n"
714            );
715            let err = assembler_env(&parse_layer_manifest(&text).unwrap()).expect_err(what);
716            assert!(
717                matches!(err, LayerSpecError::RawPerPlatformNotWsc { .. }),
718                "{what} ({name} from {repo}) was not refused as a wsc-slot \
719                 mismatch: {err:?}"
720            );
721        }
722    }
723
724    /// Dropping the owner would fetch pulseengine's release of the same name —
725    /// a different repository's bytes, deposited under a good signature.
726    // rivet: verifies REQ-LAYERADAPT-001
727    #[test]
728    fn a_foreign_owner_on_an_extension_is_refused() {
729        let text = REAL.replace(
730            "repo    = \"pulseengine/rivet\"",
731            "repo    = \"acme/rivet\"",
732        );
733        let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
734        assert_eq!(
735            err,
736            LayerSpecError::VsixForeignOwner {
737                name: "rivet-sdlc".into(),
738                repo: "acme/rivet".into()
739            }
740        );
741    }
742
743    /// The encoding's separators cannot appear in the data. Both of these would
744    /// be split by the shell rather than reported.
745    // rivet: verifies REQ-LAYERADAPT-001
746    #[test]
747    fn a_value_carrying_a_separator_is_refused() {
748        for (bad, why) in [("v1.0 rc1", "whitespace"), ("v1.0:rc1", "':'")] {
749            let text = REAL.replace("v0.34.0\"\n\n[[tool]]", &format!("{bad}\"\n\n[[tool]]"));
750            let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
751            let msg = err.to_string();
752            assert!(msg.contains(why), "{bad}: {msg}");
753        }
754    }
755
756    /// `tarball` is the default, but the docs say you may write it, so writing
757    /// it must not be refused. cargo-mutants found this by deleting the match
758    /// arm: every test used the default or `raw-per-platform`, so nothing
759    /// noticed that spelling it out stopped working.
760    // rivet: verifies REQ-LAYERADAPT-001
761    #[test]
762    fn an_explicit_tarball_layout_means_the_same_as_omitting_it() {
763        let explicit = REAL.replace(
764            "name    = \"rivet\"\nversion = \"v0.34.0\"",
765            "name    = \"rivet\"\nversion = \"v0.34.0\"\nlayout  = \"tarball\"",
766        );
767        assert_ne!(explicit, REAL, "the fixture substitution must apply");
768        assert_eq!(env_of(&explicit).layer_tools, env_of(REAL).layer_tools);
769    }
770
771    /// A foreign OWNER is fine — `bytecodealliance/wasm-tools` is the whole
772    /// point of the second realm — as long as the basename still identifies
773    /// the tool.
774    // rivet: verifies REQ-LAYERADAPT-001
775    #[test]
776    fn a_foreign_owner_is_carried_as_a_qualified_repository() {
777        let text = format!(
778            "{REAL}\n[[tool]]\nname = \"wasm-tools\"\nrepo = \"bytecodealliance/wasm-tools\"\nversion = \"v1.257.1\"\n"
779        );
780        assert!(
781            env_of(&text)
782                .layer_tools
783                .contains("bytecodealliance/wasm-tools:v1.257.1"),
784            "{}",
785            env_of(&text).layer_tools
786        );
787    }
788
789    /// The assembler names the payload from the repository basename, so a
790    /// disagreeing `name` is discarded rather than translated — the layer would
791    /// deposit and verify while carrying a tool under a name nobody asked for.
792    /// cargo-mutants found this too: no fixture had a tarball tool whose repo
793    /// basename differed, because the only differing repo was exempt.
794    // rivet: verifies REQ-LAYERADAPT-001
795    #[test]
796    fn a_tool_whose_repo_basename_disagrees_with_its_name_is_refused() {
797        let text = format!(
798            "{REAL}\n[[tool]]\nname = \"wsc2\"\nrepo = \"pulseengine/sigil\"\nversion = \"v0.11.0\"\n"
799        );
800        let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
801        assert_eq!(
802            err,
803            LayerSpecError::RepoNameMismatch {
804                name: "wsc2".into(),
805                repo: "pulseengine/sigil".into(),
806                basename: "sigil".into(),
807            },
808            "{err}"
809        );
810    }
811
812    // rivet: verifies REQ-LAYERADAPT-001
813    #[test]
814    fn two_tools_of_one_name_are_refused() {
815        let text = format!("{REAL}\n[[tool]]\nname = \"rivet\"\nversion = \"v0.1.0\"\n");
816        let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
817        assert_eq!(
818            err,
819            LayerSpecError::Duplicate {
820                kind: "tool",
821                name: "rivet".into()
822            }
823        );
824    }
825
826    /// A release offering neither mechanism can be carried only with a stated
827    /// reason, and the reason is signed into the layer where every consumer
828    /// reads it. It belongs in the manifest beside the tool it excuses, not in
829    /// a workflow variable — split definitions are how versions drift (#106).
830    // rivet: verifies REQ-LAYERADAPT-001
831    // rivet: verifies REQ-INGEST-001
832    #[test]
833    fn an_unverified_reason_reaches_the_assembler_intact() {
834        let text = format!(
835            "{REAL}\n[[tool]]\nname = \"wac\"\nrepo = \"bytecodealliance/wac\"\n\
836             version = \"v0.10.1\"\nunverified-reason = \"publishes no sums, no cosign \
837             bundle and no attestation; tracked upstream, re-check each cut\"\n"
838        );
839        let env = env_of(&text);
840        assert_eq!(env.unverified_ingest.len(), 1);
841        assert_eq!(env.unverified_ingest[0].0, "bytecodealliance/wac");
842        assert!(env.unverified_ingest[0].1.contains("re-check each cut"));
843
844        // Rendered in $GITHUB_ENV's heredoc form, because the value is
845        // line-separated and a KEY=value line cannot carry newlines.
846        let r = env.render();
847        assert!(r.contains("UNVERIFIED_INGEST<<"), "{r}");
848        assert!(r.contains("bytecodealliance/wac=publishes no sums"), "{r}");
849    }
850
851    /// A reason containing the delimiter would end the heredoc early and let
852    /// whatever followed be read as further environment. That is an injection,
853    /// not a typo, so the delimiter is chosen against the content.
854    // rivet: verifies REQ-LAYERADAPT-001
855    #[test]
856    fn a_reason_containing_the_delimiter_cannot_close_the_block_early() {
857        let text = format!(
858            "{REAL}\n[[tool]]\nname = \"wac\"\nrepo = \"bytecodealliance/wac\"\n\
859             version = \"v0.10.1\"\nunverified-reason = \"VARVE_UNVERIFIED_EOF\\nPATH=/evil\"\n"
860        );
861        let r = env_of(&text).render();
862        let opened = r
863            .lines()
864            .find(|l| l.starts_with("UNVERIFIED_INGEST<<"))
865            .expect("heredoc opened");
866        let delim = opened.trim_start_matches("UNVERIFIED_INGEST<<");
867        // The delimiter must not appear inside the body it delimits.
868        let body = r.split(&format!("<<{delim}\n")).nth(1).expect("body");
869        let body = body.split(&format!("\n{delim}")).next().expect("closes");
870        assert!(
871            !body.contains(delim),
872            "delimiter occurs inside its own body"
873        );
874        assert!(
875            body.contains("PATH=/evil"),
876            "the reason must survive verbatim"
877        );
878    }
879
880    /// "We could not verify this" must never be the silent path.
881    // rivet: verifies REQ-LAYERADAPT-001
882    #[test]
883    fn an_empty_unverified_reason_is_refused() {
884        for bad in ["\"\"", "\"   \""] {
885            let text = format!(
886                "{REAL}\n[[tool]]\nname = \"wac\"\nversion = \"v1\"\nunverified-reason = {bad}\n"
887            );
888            let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
889            assert_eq!(
890                err,
891                LayerSpecError::UnverifiedWithoutReason { tool: "wac".into() },
892                "{bad}"
893            );
894        }
895    }
896
897    /// The opt-in is per RELEASE. Two tools from one repository giving
898    /// different reasons would record one and drop the other.
899    // rivet: verifies REQ-LAYERADAPT-001
900    #[test]
901    fn two_tools_from_one_repo_must_agree_on_the_reason() {
902        // `wsc` already comes from pulseengine/sigil as a raw-per-platform
903        // tool, which is exempt from the basename rule; a tarball tool named
904        // `sigil` from the same repo is the reachable way two payloads share
905        // one release. Two tarball tools cannot, by construction.
906        let text = REAL.replace(
907            "layout  = \"raw-per-platform\"",
908            "layout  = \"raw-per-platform\"\nunverified-reason = \"first\"",
909        ) + "\n[[tool]]\nname = \"sigil\"\nrepo = \"pulseengine/sigil\"\nversion = \"v0.11.0\"\n\
910             unverified-reason = \"second\"\n";
911        let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
912        assert!(
913            matches!(err, LayerSpecError::ConflictingReason { .. }),
914            "{err:?}"
915        );
916        // Agreeing is fine, and recorded once.
917        let ok = text.replace("\"second\"", "\"first\"");
918        assert_eq!(env_of(&ok).unverified_ingest.len(), 1);
919    }
920
921    /// A manifest with nothing unverified must not emit the variable at all —
922    /// an empty opt-in list and an absent one are different statements.
923    // rivet: verifies REQ-LAYERADAPT-001
924    #[test]
925    fn a_manifest_with_nothing_unverified_emits_no_opt_in() {
926        let r = env_of(REAL).render();
927        assert!(!r.contains("UNVERIFIED_INGEST"), "{r}");
928    }
929
930    // rivet: verifies REQ-LAYERADAPT-001
931    #[test]
932    fn a_manifest_with_no_payloads_is_refused() {
933        let text = "[varve]\nversion = \"v0.28.0\"\n\n[realm]\nname = \"p\"\nchannel = \"rolling\"\nregistry = \"oci://x\"\n";
934        assert_eq!(
935            assembler_env(&parse_layer_manifest(text).unwrap()).unwrap_err(),
936            LayerSpecError::Empty
937        );
938    }
939
940    /// VSIX_PACKAGES must be SET-but-empty rather than absent: the assembler
941    /// distinguishes "this layer carries no extensions" from "someone forgot".
942    // rivet: verifies REQ-LAYERADAPT-001
943    #[test]
944    fn a_layer_with_no_extensions_still_sets_the_variable() {
945        let text = REAL
946            .split("[[vsix]]")
947            .next()
948            .expect("has a tools section")
949            .to_string();
950        let rendered = env_of(&text).render();
951        assert!(rendered.contains("\nVSIX_PACKAGES=\n"), "{rendered}");
952    }
953
954    // rivet: verifies REQ-LAYERADAPT-001
955    #[test]
956    fn render_emits_one_key_per_line_in_a_stable_order() {
957        let rendered = env_of(REAL).render();
958        let keys: Vec<&str> = rendered
959            .lines()
960            .map(|l| l.split('=').next().unwrap_or(""))
961            .collect();
962        assert_eq!(
963            keys,
964            [
965                "TARBALL_TOOLS",
966                "WSC_VERSION",
967                "VSIX_PACKAGES",
968                "VARVE_REALM",
969                "VARVE_CHANNEL",
970                "VARVE_REGISTRY",
971                "VARVE_VERSION"
972            ]
973        );
974    }
975}