Skip to main content

varve_core/
sdkexport.rs

1//! Tree-shaped payload export and relocation (REQ-SDK-001).
2//!
3//! A `sdk` payload is a TREE — a Yocto SDK is thousands of files — and it is
4//! the first payload varve cannot simply hand over verbatim. Yocto's installer
5//! runs `relocate_sdk.py`, which opens each file `"r+b"` and byte-patches in
6//! place: the `PT_INTERP` dynamic-loader path, the loader's own SYSDIRS string
7//! table and its parallel length array, and the `ld.so.cache` path. The wrapper
8//! `toolchain-shar-relocate.sh` additionally `sed -i`s every text file and
9//! re-points every symlink. After installation the bytes no longer hash to the
10//! signed digest.
11//!
12//! # Why the store keeps the archive and the tree lives in the export
13//!
14//! Relocation is bounded, and the bound is read from the script rather than
15//! assumed:
16//!
17//! ```text
18//! if (len(new_dl_path) >= p_filesz):
19//!     print("ERROR: could not relocate %s, interp size = %i and %i is needed.")
20//!     return False
21//! ```
22//!
23//! The new interpreter path must FIT the old one's field, so an SDK can only
24//! ever move to a path NO LONGER than the one it was built with. varve's store
25//! path is ~90 characters before any content
26//! (`…/realms/<16hex>/core/sha256-<64hex>/…`), so relocating INTO the store
27//! would frequently be impossible. That is one of two reasons the design is
28//! pristine-store-plus-relocated-export rather than in-place; the other is
29//! clause 2 — `verify` and `archive` hash ONE file against ONE signed digest,
30//! so the store must keep exactly the bytes the producer signed. varve
31//! therefore verifies only what was signed, never what relocation produced,
32//! and nothing here ever writes back into the store.
33//!
34//! # What this module owes the caller
35//!
36//! The tree is materialised HERE, from an archive whose member names come out
37//! of a signed blob. Signed means attributable, not benign: a tree has far more
38//! path components than a single payload name, and a symlink inside one can
39//! escape the export even when every component looks safe (the Cargo
40//! CVE-2026-5223 class — link out, then write through it). So every
41//! destination is resolved and validated BEFORE a byte is written, exactly as
42//! `Store::lay_down_payloads` does, and a tree that cannot be laid out whole is
43//! not laid out at all.
44
45use std::borrow::Cow;
46use std::collections::BTreeSet;
47use std::io::Read;
48use std::path::Path;
49
50/// The signed annotation naming the absolute path an SDK was BUILT for.
51///
52/// It is the producer's declaration, carried in the layer manifest and covered
53/// by the DSSE signature, because the relocation budget is derived from it: a
54/// destination longer than this prefix cannot be patched into the interpreter
55/// fields, and a consumer must not be able to talk varve into trying.
56pub const ANN_SDK_PREFIX: &str = "eu.pulseengine.varve.sdk.prefix";
57
58/// What one archive member is.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum MemberBody {
61    Dir,
62    File { mode: u32, bytes: Vec<u8> },
63    Symlink { target: String },
64}
65
66/// One validated member of a tree payload, with its path relative to the export
67/// root.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Member {
70    pub path: String,
71    pub body: MemberBody,
72}
73
74/// What an export actually did — reported rather than assumed, because
75/// "relocated 0 fields" and "relocated 40 000 fields" look identical on disk.
76#[derive(Debug, Clone, Default, PartialEq, Eq)]
77pub struct SdkExportReport {
78    pub dirs: usize,
79    pub files: usize,
80    pub symlinks: usize,
81    /// NUL-padded path fields patched in place in binaries (the `relocate_sdk.py`
82    /// half — total file length preserved).
83    pub patched_fields: usize,
84    /// Path occurrences substituted in text files (the `sed -i` half).
85    pub substitutions: usize,
86    /// Symlinks whose absolute target was re-pointed into the export.
87    pub relocated_symlinks: usize,
88}
89
90/// Why a tree payload could not be exported or relocated.
91#[derive(Debug, thiserror::Error)]
92pub enum SdkExportError {
93    #[error("io error at {path}")]
94    Io {
95        path: String,
96        #[source]
97        source: std::io::Error,
98    },
99    #[error("the sdk payload is not a readable tar archive: {0}")]
100    Archive(String),
101    #[error(
102        "the sdk declares no build-time prefix ({ANN_SDK_PREFIX}) — without it there is no \
103         relocation budget and no path to patch; re-deposit the sdk with the prefix it was \
104         built for"
105    )]
106    NoBuiltPrefix,
107    #[error(
108        "the export destination must be an absolute path, got {0:?} — the destination is \
109         PATCHED INTO the SDK's binaries, and a relative path there would resolve against \
110         whatever directory the compiler happens to run in"
111    )]
112    DestinationNotAbsolute(String),
113    #[error(
114        "cannot relocate this sdk to {dest}: the destination is {dest_len} characters and the \
115         sdk was built for {built_prefix} ({budget}). An SDK's interpreter path is patched IN \
116         PLACE into a fixed-size field, so it can only ever move to a path NO LONGER than the \
117         one it was built with. Choose a destination of at most {budget} characters."
118    )]
119    DestinationTooLong {
120        dest: String,
121        dest_len: usize,
122        built_prefix: String,
123        budget: usize,
124    },
125    #[error(
126        "{member}: the path field at offset {offset} needs {needed} bytes but the field holds \
127         {capacity} — this is `relocate_sdk.py`'s own limit (len(new) >= field size), reached \
128         after the destination-length check passed, so the sdk's fields are tighter than its \
129         build prefix implies"
130    )]
131    FieldTooSmall {
132        member: String,
133        offset: usize,
134        needed: usize,
135        capacity: usize,
136    },
137    #[error("sdk member {member:?} is not a usable path ({why}) — refusing to lay the tree down")]
138    UnsafeMember { member: String, why: String },
139    #[error(
140        "two members of this sdk both land on {path} — one would overwrite the other, and the \
141         survivor would carry the wrong bytes under the right name"
142    )]
143    Collision { path: String },
144    #[error(
145        "sdk member {member:?} would be written THROUGH the symlink {link:?} — a link out of \
146         the export followed by a write through it places bytes anywhere on the filesystem"
147    )]
148    WriteThroughSymlink { member: String, link: String },
149    #[error(
150        "sdk symlink {member:?} points at {target:?}, which is outside both the sdk and the \
151         export — a relocated SDK is self-contained, and a link to the host is neither \
152         verified nor reproducible"
153    )]
154    SymlinkEscapes { member: String, target: String },
155    #[error("this platform cannot create the symlink {member:?} an sdk tree requires")]
156    SymlinksUnsupported { member: String },
157}
158
159/// Trim a trailing `/` so `/opt/poky` and `/opt/poky/` mean one prefix and
160/// budget the same number of characters.
161fn normalise_prefix(p: &str) -> &str {
162    let t = p.trim_end_matches('/');
163    if t.is_empty() { p } else { t }
164}
165
166/// The relocation fit check (clause 4), as a pure function so it can be run
167/// EARLY — before the archive is even opened, let alone thousands of files
168/// written.
169///
170/// Every patched field holds `<prefix><suffix>`; relocation replaces only the
171/// prefix, so the new string fits every field it came from exactly when the new
172/// prefix is no longer than the built one. That single comparison is therefore
173/// complete for the whole tree — which is what makes the early refusal possible
174/// rather than a guess that has to be re-checked file by file.
175pub fn check_destination_fits(built_prefix: &str, dest: &str) -> Result<(), SdkExportError> {
176    let built = normalise_prefix(built_prefix);
177    if built.is_empty() {
178        return Err(SdkExportError::NoBuiltPrefix);
179    }
180    if !dest.starts_with('/') {
181        return Err(SdkExportError::DestinationNotAbsolute(dest.to_string()));
182    }
183    let dest_n = normalise_prefix(dest);
184    if dest_n.len() > built.len() {
185        return Err(SdkExportError::DestinationTooLong {
186            dest: dest_n.to_string(),
187            dest_len: dest_n.len(),
188            built_prefix: built.to_string(),
189            budget: built.len(),
190        });
191    }
192    Ok(())
193}
194
195/// First occurrence of `needle` in `haystack`, by bytes.
196fn find_sub(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
197    if needle.is_empty() || haystack.len() < needle.len() {
198        return None;
199    }
200    (from..=haystack.len() - needle.len()).find(|&i| &haystack[i..i + needle.len()] == needle)
201}
202
203/// What relocating one member's bytes produced.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct Relocation {
206    pub bytes: Vec<u8>,
207    /// In-place NUL-padded fields patched (binary, length preserved).
208    pub fields: usize,
209    /// Text occurrences substituted (length may change).
210    pub substitutions: usize,
211}
212
213/// Does this payload look like a binary? The same test
214/// `toolchain-shar-relocate.sh` makes with `grep -qIl`: a NUL byte anywhere.
215/// A binary is patched IN PLACE inside its fixed-size fields; a text file is
216/// rewritten freely, which is what lets `environment-setup-*` grow or shrink.
217fn is_binary(bytes: &[u8]) -> bool {
218    bytes.contains(&0)
219}
220
221/// Rewrite every occurrence of the SDK's build-time prefix in one member.
222///
223/// Binaries are patched the way `relocate_sdk.py` does: the C string containing
224/// the occurrence is rewritten IN PLACE and re-padded with NULs to its original
225/// field width, so the file's length and every offset in it are preserved. A
226/// string that would no longer fit is refused rather than truncated — a
227/// truncated interpreter path is a binary that fails to exec with no
228/// explanation.
229pub fn relocate_bytes(
230    member: &str,
231    bytes: &[u8],
232    built_prefix: &str,
233    dest_prefix: &str,
234) -> Result<Relocation, SdkExportError> {
235    let built = normalise_prefix(built_prefix).as_bytes();
236    let dest = normalise_prefix(dest_prefix).as_bytes();
237
238    if !is_binary(bytes) {
239        // The `sed -i` half: no field to overflow, so the text may change length.
240        let mut out = Vec::with_capacity(bytes.len());
241        let mut i = 0;
242        let mut substitutions = 0;
243        while let Some(hit) = find_sub(bytes, built, i) {
244            out.extend_from_slice(&bytes[i..hit]);
245            out.extend_from_slice(dest);
246            i = hit + built.len();
247            substitutions += 1;
248        }
249        out.extend_from_slice(&bytes[i..]);
250        return Ok(Relocation {
251            bytes: out,
252            fields: 0,
253            substitutions,
254        });
255    }
256
257    let mut out = bytes.to_vec();
258    let mut fields = 0;
259    let mut cursor = 0;
260    while let Some(hit) = find_sub(&out, built, cursor) {
261        // The C string the occurrence belongs to: from just past the previous
262        // NUL to the next one. `LD_LIBRARY_PATH=/opt/poky/…` is one string, and
263        // patching from the occurrence rather than the string start would leave
264        // the padding computation wrong.
265        let start = out[..hit]
266            .iter()
267            .rposition(|b| *b == 0)
268            .map(|p| p + 1)
269            .unwrap_or(0);
270        let Some(end) = out[hit..].iter().position(|b| *b == 0).map(|p| hit + p) else {
271            // No terminator between here and the end of the file — so there is
272            // none for any LATER occurrence either, since every later one
273            // searches a suffix of this same range. Nothing after this point
274            // can be a padded field, so stop rather than advance a cursor.
275            //
276            // This was `cursor = hit + built.len(); continue;`, which scanned
277            // on to find nothing. Mutation testing left `hit * built.len()`
278            // alive here and it was right to: any advance past `hit` reaches
279            // the same conclusion, so the arithmetic could not be observed.
280            // The arithmetic was the thing that did not need to exist.
281            break;
282        };
283        // Capacity is the string PLUS its NUL padding: exactly the `p_filesz`
284        // the script compares against.
285        // Counted, not accumulated: `pad_end += 1` neutralised by a mutation
286        // never grows, and the loop then runs forever — a hang rather than a
287        // wrong answer. `take_while` has no counter to neutralise, so the same
288        // mutation now produces an observable number instead of an infinite
289        // loop, and the capacity test can see it.
290        let pad_end = end + out[end..].iter().take_while(|b| **b == 0).count();
291        let capacity = pad_end - start;
292
293        // Replace every occurrence of the prefix WITHIN this one string.
294        let old = out[start..end].to_vec();
295        let mut new = Vec::with_capacity(old.len());
296        let mut i = 0;
297        while let Some(h) = find_sub(&old, built, i) {
298            new.extend_from_slice(&old[i..h]);
299            new.extend_from_slice(dest);
300            i = h + built.len();
301        }
302        new.extend_from_slice(&old[i..]);
303
304        // `if len(new_dl_path) >= p_filesz: ERROR` — transcribed, not
305        // paraphrased. The destination check has already made this
306        // unreachable for a well-formed SDK; it stays because the field is
307        // where truncation would actually happen, and a silent truncation here
308        // produces a binary that cannot exec with nothing to point at.
309        if new.len() >= capacity {
310            return Err(SdkExportError::FieldTooSmall {
311                member: member.to_string(),
312                offset: start,
313                needed: new.len() + 1,
314                capacity,
315            });
316        }
317        out[start..start + new.len()].copy_from_slice(&new);
318        for b in &mut out[start + new.len()..pad_end] {
319            *b = 0;
320        }
321        fields += 1;
322        cursor = pad_end;
323    }
324    Ok(Relocation {
325        bytes: out,
326        fields,
327        substitutions: 0,
328    })
329}
330
331/// Refuse a path component that is not a single, safe name — the same rule
332/// `Store::lay_down_payloads` applies to a payload name, applied to EVERY
333/// component of every member of the tree, because a tree has thousands of them
334/// and one is enough to escape.
335fn component_fault(value: &str) -> Option<String> {
336    if value.is_empty() {
337        return Some("an empty path component".into());
338    }
339    if value == "." || value == ".." {
340        return Some("a relative path element".into());
341    }
342    if let Some(c) = value
343        .chars()
344        .find(|c| matches!(c, '/' | '\\' | '\0') || c.is_control())
345    {
346        return Some(format!("contains {c:?}"));
347    }
348    None
349}
350
351/// Validate a member path and return it normalised (no trailing slash).
352fn safe_member_path(raw: &str) -> Result<String, SdkExportError> {
353    let unsafe_member = |why: &str| SdkExportError::UnsafeMember {
354        member: raw.to_string(),
355        why: why.to_string(),
356    };
357    if raw.starts_with('/') {
358        return Err(unsafe_member(
359            "absolute — it would place bytes outside the export",
360        ));
361    }
362    let trimmed = raw.trim_end_matches('/');
363    if trimmed.is_empty() {
364        return Err(unsafe_member("empty"));
365    }
366    for component in trimmed.split('/') {
367        if let Some(why) = component_fault(component) {
368            return Err(unsafe_member(&why));
369        }
370    }
371    Ok(trimmed.to_string())
372}
373
374/// Decompress a gzip or xz archive, or pass a plain tar through.
375///
376/// Decided by MAGIC, not by a file name — and deliberately the opposite of the
377/// producer, which chooses an unpacker from the asset name. The two are at
378/// different points in the same pipeline: the producer is picking a tool to
379/// run on bytes nobody has verified yet, so a name that disagrees with the
380/// content is a reason to stop; here the bytes have already been checked
381/// against the signed digest, so what they ARE is the only question left.
382///
383/// xz matters because it is not an edge case: every wasmtime archive and all
384/// 140 Zephyr SDK toolchains are .tar.xz. Decoding it with a pure-Rust
385/// implementation keeps a C build dependency out of the crate every consumer
386/// links (REQ-SDKDEPOSIT-001).
387fn decompress(archive: &[u8]) -> Result<Cow<'_, [u8]>, SdkExportError> {
388    if archive.starts_with(&[0x1f, 0x8b]) {
389        let mut out = Vec::new();
390        flate2::read::GzDecoder::new(archive)
391            .read_to_end(&mut out)
392            .map_err(|e| SdkExportError::Archive(e.to_string()))?;
393        return Ok(Cow::Owned(out));
394    }
395    // xz: FD 37 7A 58 5A 00
396    if archive.starts_with(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]) {
397        let mut out = Vec::new();
398        let mut input = std::io::BufReader::new(archive);
399        lzma_rs::xz_decompress(&mut input, &mut out)
400            .map_err(|e| SdkExportError::Archive(format!("xz: {e}")))?;
401        return Ok(Cow::Owned(out));
402    }
403    // bzip2: "BZh". Named rather than left to fail as a tar parse error,
404    // because "not a readable tar archive" sends a reader looking for a
405    // corrupt download when the real answer is that varve cannot open this
406    // compression at all. A payload varve cannot decode must say so.
407    if archive.starts_with(b"BZh") {
408        return Err(SdkExportError::Archive(
409            "this payload is bzip2-compressed, which varve cannot decode. It was \
410             deposited and its bytes verify; what is missing is a decoder. \
411             Re-deposit the sdk as .tar.gz or .tar.xz, or file for bzip2 support."
412                .into(),
413        ));
414    }
415    Ok(Cow::Borrowed(archive))
416}
417
418/// Read every member of a tree payload out of its archive.
419///
420/// Paths are carried through VERBATIM and judged in `export_members`, which is
421/// the only thing that writes. Validating in both places would look safer and
422/// be worse: two copies of one rule drift, and a defect in either is masked by
423/// the other, so neither can be shown to matter.
424pub fn read_members(archive: &[u8]) -> Result<Vec<Member>, SdkExportError> {
425    let raw = decompress(archive)?;
426    let mut tar = tar::Archive::new(raw.as_ref());
427    let entries = tar
428        .entries()
429        .map_err(|e| SdkExportError::Archive(e.to_string()))?;
430    let mut members = Vec::new();
431    for entry in entries {
432        let mut entry = entry.map_err(|e| SdkExportError::Archive(e.to_string()))?;
433        let raw_path = entry
434            .path()
435            .map_err(|e| SdkExportError::Archive(e.to_string()))?
436            .to_string_lossy()
437            .into_owned();
438        let header = entry.header().clone();
439        let body = match header.entry_type() {
440            tar::EntryType::Directory => MemberBody::Dir,
441            // A hard link is treated as a symlink: varve is materialising a
442            // fresh tree, and a link that must stay inside the export is the
443            // same question either way.
444            tar::EntryType::Symlink | tar::EntryType::Link => {
445                let target = entry
446                    .link_name()
447                    .map_err(|e| SdkExportError::Archive(e.to_string()))?
448                    .map(|t| t.to_string_lossy().into_owned())
449                    .unwrap_or_default();
450                MemberBody::Symlink { target }
451            }
452            _ => {
453                let mut bytes = Vec::new();
454                entry
455                    .read_to_end(&mut bytes)
456                    .map_err(|e| SdkExportError::Archive(e.to_string()))?;
457                let mode = header.mode().unwrap_or(0o644);
458                MemberBody::File { mode, bytes }
459            }
460        };
461        members.push(Member {
462            path: raw_path,
463            body,
464        });
465    }
466    Ok(members)
467}
468
469/// Where a symlink target lands, and whether it stays inside the export.
470///
471/// Three cases, and each has to be decided rather than defaulted:
472///   * absolute and under the SDK's build prefix — an SDK's own internal
473///     absolute link, re-pointed into the export;
474///   * absolute and anywhere else — a link to the HOST, refused: a relocated
475///     SDK is self-contained, and a link varve did not verify is not part of
476///     what the trust root anchored;
477///   * relative — resolved lexically against the link's own directory and
478///     refused if it climbs out of the export root.
479fn resolve_link_target(
480    member: &str,
481    target: &str,
482    built_prefix: &str,
483    dest_prefix: &str,
484) -> Result<(String, bool), SdkExportError> {
485    let escapes = || SdkExportError::SymlinkEscapes {
486        member: member.to_string(),
487        target: target.to_string(),
488    };
489    if target.is_empty() {
490        return Err(escapes());
491    }
492    if target.starts_with('/') {
493        let built = normalise_prefix(built_prefix);
494        let dest = normalise_prefix(dest_prefix);
495        if target == built {
496            return Ok((dest.to_string(), true));
497        }
498        if let Some(rest) = target.strip_prefix(&format!("{built}/")) {
499            // Being under the build prefix is not the same as staying under
500            // it. `<built>/../../../tmp/x` starts with the prefix and lands
501            // outside the export, so the remainder is walked exactly as the
502            // relative branch below walks its target — that branch always did,
503            // this one did not, and the asymmetry WAS the hole: the escape was
504            // accepted and counted as a re-pointed symlink, exit 0.
505            let mut depth: isize = 0;
506            for component in rest.split('/') {
507                match component {
508                    "" | "." => {}
509                    ".." => {
510                        depth -= 1;
511                        // Climbing above the export root, not merely within it.
512                        if depth < 0 {
513                            return Err(escapes());
514                        }
515                    }
516                    _ => depth += 1,
517                }
518            }
519            return Ok((format!("{dest}/{rest}"), true));
520        }
521        return Err(escapes());
522    }
523    // Relative: walk it against the link's own directory, lexically. `..` past
524    // the export root is the escape; `..` within it is ordinary and common.
525    let mut stack: Vec<&str> = member.split('/').collect();
526    stack.pop(); // the link itself
527    for component in target.split('/') {
528        match component {
529            "" | "." => {}
530            ".." => {
531                if stack.pop().is_none() {
532                    return Err(escapes());
533                }
534            }
535            other => stack.push(other),
536        }
537    }
538    Ok((target.to_string(), false))
539}
540
541/// Lay a verified tree payload out under `out`, relocated from its build-time
542/// prefix to `out` (clause 3).
543///
544/// `out` must be ABSOLUTE: the destination is patched into the SDK's binaries,
545/// so a relative one would resolve against whatever directory a compiler
546/// happens to run in. Every destination is validated and resolved before any
547/// byte is written, so a tree that cannot be laid out whole is not laid out at
548/// all.
549///
550/// This never touches the store. The signed archive stays exactly as the
551/// producer signed it, which is what `verify` and `archive` re-hash; the
552/// relocated tree is a DERIVED artifact, deliberately outside the trust path
553/// (clause 2).
554pub fn export_sdk(
555    archive: &[u8],
556    built_prefix: &str,
557    out: &Path,
558) -> Result<SdkExportReport, SdkExportError> {
559    let dest = out.to_string_lossy().into_owned();
560    // FIRST, and before the archive is even decompressed: an SDK that cannot
561    // reach this destination must be refused now, not after thousands of files
562    // have been written and patched (clause 4).
563    check_destination_fits(built_prefix, &dest)?;
564    let members = read_members(archive)?;
565    export_members(&members, built_prefix, out)
566}
567
568/// The write half, split out so a tree can be laid down from members obtained
569/// any way — and so the plan-then-write discipline is testable without a tar.
570pub fn export_members(
571    members: &[Member],
572    built_prefix: &str,
573    out: &Path,
574) -> Result<SdkExportReport, SdkExportError> {
575    let dest = out.to_string_lossy().into_owned();
576    check_destination_fits(built_prefix, &dest)?;
577
578    // ---- plan: resolve and validate EVERY destination before writing ----
579    let mut placed: BTreeSet<String> = BTreeSet::new();
580    let mut links: BTreeSet<String> = BTreeSet::new();
581    let mut planned: Vec<(String, &Member)> = Vec::with_capacity(members.len());
582    for m in members {
583        let path = safe_member_path(&m.path)?;
584        if !placed.insert(path.clone()) {
585            return Err(SdkExportError::Collision { path });
586        }
587        if let MemberBody::Symlink { .. } = m.body {
588            links.insert(path.clone());
589        }
590        planned.push((path, m));
591    }
592    // A symlink out of the tree followed by a write THROUGH it places bytes
593    // anywhere on the filesystem, and every component of the offending member
594    // still looks perfectly safe on its own. Refuse the pair, not the shape.
595    for (path, _) in &planned {
596        let mut prefix = String::new();
597        for component in path.split('/') {
598            if !prefix.is_empty() {
599                prefix.push('/');
600            }
601            prefix.push_str(component);
602            if prefix.len() < path.len() && links.contains(&prefix) {
603                return Err(SdkExportError::WriteThroughSymlink {
604                    member: path.clone(),
605                    link: prefix,
606                });
607            }
608        }
609    }
610    // Link targets, resolved and refused before anything exists on disk.
611    let mut resolved_links: Vec<(&str, String, bool)> = Vec::new();
612    for (path, m) in &planned {
613        if let MemberBody::Symlink { target } = &m.body {
614            let (t, relocated) = resolve_link_target(path, target, built_prefix, &dest)?;
615            resolved_links.push((path, t, relocated));
616        }
617    }
618    // Relocate every file's bytes before creating the export directory: a
619    // field that will not fit must leave the destination untouched.
620    let mut relocated_files: Vec<(&str, Relocation, u32)> = Vec::new();
621    for (path, m) in &planned {
622        if let MemberBody::File { mode, bytes } = &m.body {
623            let r = relocate_bytes(path, bytes, built_prefix, &dest)?;
624            relocated_files.push((path, r, *mode));
625        }
626    }
627
628    // ---- write ----
629    let io = |path: &Path, source: std::io::Error| SdkExportError::Io {
630        path: path.display().to_string(),
631        source,
632    };
633    let mut report = SdkExportReport::default();
634    std::fs::create_dir_all(out).map_err(|e| io(out, e))?;
635    for (rel, m) in &planned {
636        if matches!(m.body, MemberBody::Dir) {
637            let path = out.join(rel);
638            std::fs::create_dir_all(&path).map_err(|e| io(&path, e))?;
639            report.dirs += 1;
640        }
641    }
642    for (rel, relocation, mode) in &relocated_files {
643        let path = out.join(rel);
644        if let Some(parent) = path.parent() {
645            std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
646        }
647        std::fs::write(&path, &relocation.bytes).map_err(|e| io(&path, e))?;
648        #[cfg(unix)]
649        {
650            use std::os::unix::fs::PermissionsExt;
651            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode & 0o7777))
652                .map_err(|e| io(&path, e))?;
653        }
654        #[cfg(not(unix))]
655        let _ = mode;
656        report.files += 1;
657        report.patched_fields += relocation.fields;
658        report.substitutions += relocation.substitutions;
659    }
660    for (rel, target, relocated) in &resolved_links {
661        let path = out.join(rel);
662        if let Some(parent) = path.parent() {
663            std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
664        }
665        #[cfg(unix)]
666        std::os::unix::fs::symlink(target, &path).map_err(|e| io(&path, e))?;
667        #[cfg(not(unix))]
668        {
669            let _ = target;
670            return Err(SdkExportError::SymlinksUnsupported {
671                member: (*rel).to_string(),
672            });
673        }
674        report.symlinks += 1;
675        if *relocated {
676            report.relocated_symlinks += 1;
677        }
678    }
679    Ok(report)
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685    use std::path::PathBuf;
686
687    /// The absolute path this synthetic SDK was BUILT for.
688    ///
689    /// Deliberately long, and that is not cosmetic: a real Yocto SDK is built
690    /// for a long default installation directory PRECISELY so it can be
691    /// relocated afterwards, since relocation can only ever shorten a path. The
692    /// tests' own temporary directory has to fit inside that budget, which is
693    /// the same arithmetic a real user does.
694    const BUILT: &str = "/opt/poky/4.0.15/x86_64-pokysdk-linux/default-installation-directory-padded-so-a-temporary-directory-fits-inside-the-relocation-budget-which-can-only-ever-shrink-a-path-never-grow-it";
695
696    /// The slack a real `PT_INTERP` field carries past its terminator.
697    const SLACK: usize = 8;
698
699    /// A NUL-padded path field, the way an ELF `PT_INTERP` segment holds one:
700    /// the string, its terminator, and slack up to the field width.
701    fn nul_field(s: &str, width: usize) -> Vec<u8> {
702        let mut v = s.as_bytes().to_vec();
703        v.resize(width, 0);
704        v
705    }
706
707    /// A path field sized as an SDK's own build produced it.
708    fn field(s: &str) -> Vec<u8> {
709        nul_field(s, s.len() + SLACK)
710    }
711
712    fn interp() -> String {
713        format!("{BUILT}/sysroots/x86_64/lib/ld-linux.so.2")
714    }
715
716    fn fake_binary() -> Vec<u8> {
717        let mut v = b"\x7fELF".to_vec();
718        v.extend_from_slice(&field(&interp()));
719        v.extend_from_slice(&field(&format!("{BUILT}/sysroots/x86_64/usr/lib")));
720        v.extend_from_slice(b"\0\0trailer\0");
721        v
722    }
723
724    fn env_setup() -> Vec<u8> {
725        format!(
726            "export SDKTARGETSYSROOT={BUILT}/sysroots/aarch64\n\
727             export PATH={BUILT}/sysroots/x86_64/usr/bin:$PATH\n\
728             export CC=\"aarch64-poky-linux-gcc --sysroot={BUILT}/sysroots/aarch64\"\n"
729        )
730        .into_bytes()
731    }
732
733    fn synthetic_sdk() -> Vec<Member> {
734        vec![
735            Member {
736                path: "sysroots".into(),
737                body: MemberBody::Dir,
738            },
739            Member {
740                path: "sysroots/x86_64/usr/bin/aarch64-poky-linux-gcc".into(),
741                body: MemberBody::File {
742                    mode: 0o755,
743                    bytes: fake_binary(),
744                },
745            },
746            Member {
747                path: "environment-setup-aarch64-poky-linux".into(),
748                body: MemberBody::File {
749                    mode: 0o644,
750                    bytes: env_setup(),
751                },
752            },
753            Member {
754                path: "sysroots/x86_64/usr/bin/cc".into(),
755                body: MemberBody::Symlink {
756                    target: format!("{BUILT}/sysroots/x86_64/usr/bin/aarch64-poky-linux-gcc"),
757                },
758            },
759        ]
760    }
761
762    /// An export root of a chosen length, so the fit check is exercised against
763    /// a REAL path rather than a string that only looks like one.
764    fn out_of_len(base: &Path, len: usize) -> PathBuf {
765        let base_s = base.to_string_lossy().into_owned();
766        assert!(base_s.len() < len, "tempdir already longer than {len}");
767        let pad = len - base_s.len() - 1;
768        base.join("d".repeat(pad))
769    }
770
771    // rivet: verifies REQ-SDK-001
772    #[test]
773    fn a_destination_longer_than_the_build_prefix_is_refused_before_anything_is_written() {
774        // Clause 4, and the constraint the whole design turns on:
775        //   if (len(new_dl_path) >= p_filesz): ERROR
776        // The interpreter path is patched IN PLACE into a fixed-size field, so
777        // an SDK can only ever move to a path no longer than the one it was
778        // built with. Refusing here — before the archive is even opened — is
779        // the difference between a one-line error and thousands of files
780        // written and then abandoned.
781        let tmp = tempfile::tempdir().unwrap();
782        let too_long = out_of_len(tmp.path(), BUILT.len() + 1);
783        let err = export_members(&synthetic_sdk(), BUILT, &too_long).unwrap_err();
784        match &err {
785            SdkExportError::DestinationTooLong {
786                dest_len, budget, ..
787            } => {
788                assert_eq!(*dest_len, BUILT.len() + 1);
789                assert_eq!(*budget, BUILT.len());
790            }
791            other => panic!("expected DestinationTooLong, got {other}"),
792        }
793        // Refused BY NAME: the message must carry the destination, the budget,
794        // and why — an operator whose export directory is one character too
795        // long has to be able to fix it without reading relocate_sdk.py.
796        let msg = err.to_string();
797        assert!(msg.contains(&too_long.display().to_string()), "{msg}");
798        assert!(
799            msg.contains(BUILT),
800            "names the prefix it was built for: {msg}"
801        );
802        assert!(msg.contains("NO LONGER"), "states the rule: {msg}");
803        // And nothing was written: not one file, not the directory itself.
804        assert!(!too_long.exists(), "a refused export must write nothing");
805
806        // Exactly at the budget is allowed — the constraint is `no longer`, and
807        // an off-by-one here would refuse a legitimate destination.
808        let exact = out_of_len(tmp.path(), BUILT.len());
809        assert!(check_destination_fits(BUILT, &exact.to_string_lossy()).is_ok());
810    }
811
812    // rivet: verifies REQ-SDK-001
813    #[test]
814    fn a_relative_or_prefixless_destination_is_refused() {
815        // The destination is PATCHED INTO the binaries. A relative one would
816        // resolve against whatever directory the compiler happens to run in.
817        assert!(matches!(
818            check_destination_fits(BUILT, "toolchains/poky"),
819            Err(SdkExportError::DestinationNotAbsolute(_))
820        ));
821        // …and an SDK with no declared build prefix has no budget at all,
822        // which is a different fault with a different fix.
823        assert!(matches!(
824            check_destination_fits("", "/opt/x"),
825            Err(SdkExportError::NoBuiltPrefix)
826        ));
827        assert!(matches!(
828            check_destination_fits("/", "/opt/x"),
829            Err(SdkExportError::DestinationTooLong { .. })
830        ));
831        // A trailing slash is the same prefix, not a longer one.
832        assert!(check_destination_fits("/opt/poky", "/opt/abcd/").is_ok());
833    }
834
835    /// `matches!(c, '/' | '\\' | '\0') || c.is_control()` — the OR is what makes
836    /// this catch two different families. Narrowed to AND, only a character
837    /// that is BOTH a separator AND a control character is refused, which is
838    /// almost none of them: a component containing `/` sails through, and so
839    /// does a newline.
840    // rivet: verifies REQ-SDK-001
841    #[test]
842    fn a_component_is_refused_for_a_separator_or_for_a_control_character() {
843        // A separator, which is not a control character.
844        assert!(
845            super::component_fault("a/b").is_some(),
846            "a component containing a separator must be refused"
847        );
848        assert!(super::component_fault("a\\b").is_some());
849        // A control character, which is not a separator.
850        assert!(
851            super::component_fault("a\nb").is_some(),
852            "a control character must be refused even though it is not a separator"
853        );
854        assert!(super::component_fault("a\tb").is_some());
855        // And an ordinary name is still fine, or the check would refuse the world.
856        assert!(super::component_fault("libc.so.6").is_none());
857    }
858
859    /// `""` and `"."` contribute NO depth. Delete that arm and they count as
860    /// real components, so an escape that leans on them stops being detected —
861    /// `./..` climbs out of the export root while looking like it went nowhere.
862    // rivet: verifies REQ-SDK-001
863    #[test]
864    fn empty_and_dot_components_add_no_depth_to_the_escape_check() {
865        let r = super::resolve_link_target("m", "./..", "/opt/poky", "/opt/sdk");
866        assert!(
867            r.is_err(),
868            "`./..` climbs above the export root and must be refused, got {r:?}"
869        );
870        // `.//..` is still one step above the root — the empty and dot
871        // components must contribute nothing. (`a//./..` is NOT an escape: it
872        // resolves to `a/..`, which lands back at the root, and asserting
873        // otherwise was my error, caught by this test failing.)
874        let r = super::resolve_link_target("m", ".//..", "/opt/poky", "/opt/sdk");
875        assert!(r.is_err(), "empty and dot components must not fund a climb");
876    }
877
878    /// The ABSOLUTE branch walks its remainder too, and the two mutants that
879    /// survived my first attempt were there — I tested the relative branch and
880    /// assumed it covered both. Being under the build prefix is not the same as
881    /// staying under it: `<built>/../../tmp/x` starts with the prefix and lands
882    /// outside the export, which is the hole this walk was added to close.
883    // rivet: verifies REQ-SDK-001
884    #[test]
885    fn an_absolute_target_under_the_prefix_is_walked_not_merely_prefix_matched() {
886        let built = "/opt/poky";
887        // Empty and dot components contribute NO depth, so a climb that leans
888        // on them is still a climb.
889        for target in ["/opt/poky/./..", "/opt/poky/.//..", "/opt/poky/../.."] {
890            assert!(
891                super::resolve_link_target("m", target, built, "/opt/sdk").is_err(),
892                "{target} climbs out of the export root and must be refused"
893            );
894        }
895        // …and stepping down and back up is NOT an escape, so the test is
896        // `depth < 0` rather than `<= 0`. A real SDK is full of these.
897        let (t, _) = super::resolve_link_target("m", "/opt/poky/lib/..", built, "/opt/sdk")
898            .expect("returning to the root stays inside it");
899        assert_eq!(t, "/opt/sdk/lib/..");
900        super::resolve_link_target("m", "/opt/poky/usr/../lib/libc.so", built, "/opt/sdk")
901            .expect("an ordinary absolute SDK symlink must not be refused");
902    }
903
904    /// The escape test is `depth < 0`, not `<= 0`. Relaxed, a link that steps
905    /// down and back up to the root — `lib/../lib` and its kin, which are
906    /// everywhere in a real SDK — is refused as an escape and the export fails
907    /// on a correct tree.
908    // rivet: verifies REQ-SDK-001
909    #[test]
910    fn returning_to_the_root_is_not_an_escape() {
911        let (target, _) = super::resolve_link_target("m", "a/..", "/opt/poky", "/opt/sdk")
912            .expect("stepping down and back up stays inside the root");
913        assert_eq!(target, "a/..");
914        super::resolve_link_target("m", "lib/../lib/libc.so", "/opt/poky", "/opt/sdk")
915            .expect("a normal SDK symlink must not be refused");
916        // One step further out IS an escape.
917        assert!(super::resolve_link_target("m", "a/../..", "/opt/poky", "/opt/sdk").is_err());
918    }
919
920    /// The neighbouring string must survive byte-for-byte.
921    ///
922    /// Every arithmetic mutant in `relocate_bytes` — the `== 0` that finds the
923    /// previous NUL, the `p + 1` that steps past it, the `hit + p` that finds
924    /// the terminator, the `pad_end - start` that measures capacity — preserves
925    /// the file's LENGTH while moving where the patch lands. Asserting on
926    /// length and on counts cannot see any of them; only the bytes can. The
927    /// mutation gate found seven survivors here, and this is what they had in
928    /// common.
929    // rivet: verifies REQ-SDK-001
930    #[test]
931    fn patching_one_string_leaves_the_string_before_it_untouched() {
932        // Two NUL-terminated strings. The prefix appears mid-way through the
933        // SECOND, so `start` has to be found by walking back to the previous
934        // NUL — not by starting at the occurrence, and not by running into the
935        // first string.
936        let mut buf = b"KEEP-ME-EXACTLY".to_vec();
937        let head = buf.len();
938        let field = format!("LD_LIBRARY_PATH={BUILT}/sysroots/lib");
939        buf.extend_from_slice(&nul_field(&field, field.len() + 1 + SLACK));
940
941        let r = relocate_bytes("libc.so", &buf, BUILT, "/opt/sdk").unwrap();
942
943        assert_eq!(r.bytes.len(), buf.len(), "in-place patch preserves length");
944        assert_eq!(
945            &r.bytes[..head],
946            b"KEEP-ME-EXACTLY\0",
947            "the preceding string was corrupted — `start` walked past its own \
948             string boundary"
949        );
950        let patched = &r.bytes[head..];
951        let text = &patched[..patched.iter().position(|b| *b == 0).unwrap()];
952        assert_eq!(
953            text,
954            b"LD_LIBRARY_PATH=/opt/sdk/sysroots/lib",
955            "the patched field is wrong: {}",
956            String::from_utf8_lossy(text)
957        );
958        assert!(
959            patched[text.len()..].iter().all(|b| *b == 0),
960            "everything past the terminator must be NUL padding"
961        );
962        assert_eq!(r.fields, 1);
963    }
964
965    /// A prefix occurrence with NO terminator after it is not a field that can
966    /// be padded, so relocation steps over it. The cursor advance in that
967    /// branch had no test at all: leave it un-advanced and the same occurrence
968    /// is found forever.
969    // rivet: verifies REQ-SDK-001
970    #[test]
971    fn an_unterminated_occurrence_is_stepped_over_rather_than_scanned_forever() {
972        // NUL first so the member is treated as a binary, then the prefix
973        // running to the end of the buffer with nothing to terminate it.
974        let mut buf = vec![0u8];
975        buf.extend_from_slice(BUILT.as_bytes());
976
977        let r = relocate_bytes("weird.bin", &buf, BUILT, "/opt/sdk").unwrap();
978        assert_eq!(
979            r.fields, 0,
980            "an unterminated occurrence is not a padded field and must not be patched"
981        );
982        assert_eq!(r.bytes, buf, "and nothing about it may be rewritten");
983    }
984
985    /// Capacity is `pad_end - start`, and it decides whether a destination
986    /// FITS. Getting it wrong does not corrupt anything visibly — it accepts a
987    /// path that does not fit, or refuses one that does, and the SDK breaks
988    /// later on someone else's machine.
989    // rivet: verifies REQ-SDK-001
990    #[test]
991    fn capacity_is_measured_from_the_string_start_not_from_the_occurrence() {
992        // A field whose string begins BEFORE the prefix occurrence. If capacity
993        // were measured from the occurrence rather than the string start, this
994        // would appear to have more room than it has.
995        let field = format!("PATH={BUILT}");
996        // A PRECEDING string, so `start` is non-zero. With start == 0,
997        // `pad_end - start` and `pad_end + start` are the same number and the
998        // capacity arithmetic cannot be observed at all — which is why the
999        // first version of this test left that mutant alive.
1000        let head = b"PRECEDING\0";
1001        let mut buf = head.to_vec();
1002        // Exactly enough room for the string, its NUL, and nothing else.
1003        buf.extend_from_slice(&nul_field(&field, field.len() + 1));
1004
1005        // A destination the same length as the built prefix always fits.
1006        let same = "/x".repeat(BUILT.len() / 2);
1007        let r = relocate_bytes("x", &buf, BUILT, &same).unwrap();
1008        assert_eq!(r.bytes.len(), buf.len());
1009
1010        // One byte longer than the built prefix does NOT fit in a field with
1011        // no slack, and must be refused rather than truncated.
1012        let longer = format!("{same}Z");
1013        let err = relocate_bytes("x", &buf, BUILT, &longer).unwrap_err();
1014        let msg = err.to_string();
1015        // The refusal names both numbers, which is what makes it actionable —
1016        // and what pins the capacity arithmetic: a wrongly-measured field
1017        // would report a different pair.
1018        assert!(
1019            msg.contains(&format!("needs {} bytes", field.len() + 2)),
1020            "must say how much the destination needs: {msg}"
1021        );
1022        assert!(
1023            msg.contains(&format!("the field holds {}", buf.len() - head.len())),
1024            "the field is measured from the STRING START, not from the buffer \
1025             start — a capacity that included the preceding string would accept \
1026             a destination that does not fit and overrun the field: {msg}"
1027        );
1028        assert!(
1029            msg.contains(&format!("at offset {}", head.len())),
1030            "and the offset reported is the string start: {msg}"
1031        );
1032    }
1033
1034    /// `find_sub` guards with `haystack.len() < needle.len()`. Relaxed to
1035    /// `<=`, a needle that is exactly the whole haystack stops being found —
1036    /// and a prefix that fills its field entirely is the realistic case, since
1037    /// the built prefix is padded to be as long as the field allows.
1038    // rivet: verifies REQ-SDK-001
1039    #[test]
1040    fn a_needle_exactly_as_long_as_the_haystack_is_still_found() {
1041        assert_eq!(super::find_sub(b"abc", b"abc", 0), Some(0));
1042        assert_eq!(super::find_sub(b"abc", b"abcd", 0), None);
1043        assert_eq!(super::find_sub(b"xabc", b"abc", 0), Some(1));
1044        // And the `from` cursor is respected rather than ignored.
1045        assert_eq!(super::find_sub(b"abcabc", b"abc", 1), Some(3));
1046        assert_eq!(super::find_sub(b"abc", b"", 0), None);
1047    }
1048
1049    // rivet: verifies REQ-SDK-001
1050    #[test]
1051    fn a_nul_padded_field_is_patched_in_place_and_the_file_length_is_preserved() {
1052        // The heart of relocate_sdk.py: the field is opened "r+b" and written
1053        // over, so every offset in the binary stays valid. A rewrite that
1054        // changed the length would relocate the SDK and corrupt the ELF.
1055        let original = fake_binary();
1056        let r = relocate_bytes("gcc", &original, BUILT, "/opt/sdk").unwrap();
1057        assert_eq!(
1058            r.bytes.len(),
1059            original.len(),
1060            "an in-place patch must not change the file's length"
1061        );
1062        assert_eq!(r.fields, 2, "both path fields patched");
1063        assert_eq!(r.substitutions, 0, "a binary is patched, never sed'ed");
1064
1065        // The new path is there, NUL-terminated, and the old one is gone.
1066        let text = String::from_utf8_lossy(&r.bytes).into_owned();
1067        assert!(text.contains("/opt/sdk/sysroots/x86_64/lib/ld-linux.so.2"));
1068        assert!(
1069            !text.contains(BUILT),
1070            "the build-time prefix must not survive relocation: {text:?}"
1071        );
1072        // The slack really is NUL, not leftover bytes of the old path — a
1073        // truncating rewrite leaves the tail of the old path behind and execs
1074        // something that does not exist.
1075        let width = interp().len() + SLACK;
1076        let patched = &r.bytes[4..4 + width];
1077        let end = patched.iter().position(|b| *b == 0).unwrap();
1078        assert_eq!(
1079            &patched[..end],
1080            b"/opt/sdk/sysroots/x86_64/lib/ld-linux.so.2"
1081        );
1082        assert!(
1083            patched[end..].iter().all(|b| *b == 0),
1084            "the field must be re-padded with NUL"
1085        );
1086        // The trailer past the fields is untouched.
1087        assert!(r.bytes.ends_with(b"trailer\0"));
1088    }
1089
1090    // rivet: verifies REQ-SDK-001
1091    #[test]
1092    fn a_field_too_small_for_the_new_path_is_refused_rather_than_truncated() {
1093        // The script's own guard, transcribed: `len(new) >= p_filesz` fails.
1094        // A truncated interpreter path is a binary that cannot exec with
1095        // nothing at all to point at, so this must never silently succeed.
1096        // Field width 20 holds "/opt/a/ld.so" plus padding; "/opt/aaaaaaaaaa"
1097        // is longer than the prefix but the destination check is bypassed here
1098        // to reach the field guard directly.
1099        let bytes = nul_field("/opt/a/ld.so", 14);
1100        // 13 characters of path + terminator == 14: the last width that fits.
1101        let ok = relocate_bytes("x", &bytes, "/opt/a", "/opt/ab").unwrap();
1102        assert_eq!(ok.fields, 1);
1103        assert_eq!(ok.bytes.len(), bytes.len());
1104        // One more character and it does not.
1105        let err = relocate_bytes("libc.so", &bytes, "/opt/a", "/opt/abc").unwrap_err();
1106        match err {
1107            SdkExportError::FieldTooSmall {
1108                member,
1109                needed,
1110                capacity,
1111                ..
1112            } => {
1113                assert_eq!(member, "libc.so", "the refusal must name the FILE");
1114                assert_eq!(capacity, 14);
1115                assert_eq!(needed, 15);
1116            }
1117            other => panic!("expected FieldTooSmall, got {other}"),
1118        }
1119    }
1120
1121    // rivet: verifies REQ-SDK-001
1122    #[test]
1123    fn a_text_file_is_substituted_and_may_change_length() {
1124        // `toolchain-shar-relocate.sh` seds every text file, and
1125        // `environment-setup-*` is the one that matters: it is SOURCED, and a
1126        // stale SYSROOT in it silently builds against the wrong headers.
1127        let original = env_setup();
1128        let r = relocate_bytes("environment-setup", &original, BUILT, "/opt/sdk").unwrap();
1129        assert_eq!(r.fields, 0, "a text file has no fixed-size field");
1130        assert_eq!(r.substitutions, 3, "every occurrence, not just the first");
1131        let text = String::from_utf8(r.bytes).unwrap();
1132        assert!(text.contains("export SDKTARGETSYSROOT=/opt/sdk/sysroots/aarch64"));
1133        assert!(text.contains("--sysroot=/opt/sdk/sysroots/aarch64"));
1134        assert!(!text.contains(BUILT));
1135        assert!(
1136            text.len() < original.len(),
1137            "a text rewrite is free to change length"
1138        );
1139    }
1140
1141    // rivet: verifies REQ-SDK-001
1142    #[test]
1143    fn the_whole_synthetic_tree_lands_relocated_and_the_source_bytes_are_untouched() {
1144        // Clause 3 end to end, and clause 2 alongside it: what the producer
1145        // signed is not what the export contains, and varve never records a
1146        // post-relocation digest — the relocator stays outside the trust path.
1147        let tmp = tempfile::tempdir().unwrap();
1148        let out = tmp.path().join("sdk");
1149        let members = synthetic_sdk();
1150        let signed_binary = fake_binary();
1151
1152        let report = export_members(&members, BUILT, &out).unwrap();
1153        // `dirs` was the one field this assertion block never checked, so
1154        // `report.dirs += 1` survived mutation as `*= 1` — the counter stuck at
1155        // zero while every directory was still created. A report is evidence:
1156        // "created 0 directories" and "created 40 000" look identical on disk,
1157        // which is what the struct's own doc comment says.
1158        let expected_dirs = members
1159            .iter()
1160            .filter(|m| matches!(m.body, MemberBody::Dir))
1161            .count();
1162        assert!(
1163            expected_dirs > 0,
1164            "the fixture must contain directories or this asserts nothing"
1165        );
1166        assert_eq!(
1167            report.dirs, expected_dirs,
1168            "every directory in the tree is created AND counted"
1169        );
1170        assert_eq!(report.files, 2);
1171        assert_eq!(report.symlinks, 1);
1172        assert_eq!(report.relocated_symlinks, 1);
1173        assert_eq!(report.patched_fields, 2);
1174        assert_eq!(report.substitutions, 3);
1175
1176        let gcc = out.join("sysroots/x86_64/usr/bin/aarch64-poky-linux-gcc");
1177        let on_disk = std::fs::read(&gcc).unwrap();
1178        assert_eq!(on_disk.len(), signed_binary.len(), "in-place patch");
1179        assert_ne!(
1180            on_disk, signed_binary,
1181            "the relocated bytes are NOT the signed bytes — which is exactly why \
1182             the store keeps the archive and verify never hashes the export"
1183        );
1184        assert!(!String::from_utf8_lossy(&on_disk).contains(BUILT));
1185
1186        // The internal absolute symlink now points inside the export.
1187        #[cfg(unix)]
1188        {
1189            let link = out.join("sysroots/x86_64/usr/bin/cc");
1190            let target = std::fs::read_link(&link).unwrap();
1191            assert_eq!(target, gcc, "an SDK-internal link follows the SDK");
1192        }
1193        // The directory member is a directory.
1194        assert!(out.join("sysroots").is_dir());
1195        // And the members handed in are untouched: nothing wrote back.
1196        assert_eq!(members, synthetic_sdk());
1197    }
1198
1199    // rivet: verifies REQ-SDK-001
1200    #[test]
1201    fn a_member_whose_path_escapes_the_export_is_refused_and_nothing_is_written() {
1202        // Every component of every member gets the treatment a single payload
1203        // name gets in the store. A tree has thousands, and one is enough.
1204        let tmp = tempfile::tempdir().unwrap();
1205        let out = tmp.path().join("sdk");
1206        for bad in [
1207            "../../evil",
1208            "/etc/passwd",
1209            "a/../../evil",
1210            "a//b",
1211            "a/./b",
1212            "",
1213            "..",
1214        ] {
1215            let members = vec![
1216                Member {
1217                    path: "good".into(),
1218                    body: MemberBody::File {
1219                        mode: 0o644,
1220                        bytes: b"good".to_vec(),
1221                    },
1222                },
1223                Member {
1224                    path: bad.into(),
1225                    body: MemberBody::File {
1226                        mode: 0o644,
1227                        bytes: b"evil".to_vec(),
1228                    },
1229                },
1230            ];
1231            let err = export_members(&members, BUILT, &out).unwrap_err();
1232            assert!(
1233                matches!(err, SdkExportError::UnsafeMember { .. }),
1234                "member {bad:?} must be refused, got {err}"
1235            );
1236            // A partially written tree must not be possible: the good member
1237            // did not land either.
1238            assert!(
1239                !out.join("good").exists(),
1240                "member {bad:?}: the tree must be refused whole"
1241            );
1242        }
1243        // …and safe paths that merely look unusual still work.
1244        assert!(safe_member_path("a/b/c").is_ok());
1245        assert!(safe_member_path("a/b/").is_ok());
1246        assert_eq!(safe_member_path("a/b/").unwrap(), "a/b");
1247    }
1248
1249    // rivet: verifies REQ-SDK-001
1250    #[test]
1251    fn a_symlink_that_leaves_the_export_is_refused_even_though_every_component_is_safe() {
1252        // The escape a per-component check cannot see. `link` is a fine name
1253        // and `link/pwned` is a fine path; the escape is the LINK's target.
1254        let tmp = tempfile::tempdir().unwrap();
1255        let out = tmp.path().join("sdk");
1256        let outside = tmp.path().join("OUTSIDE");
1257        std::fs::create_dir_all(&outside).unwrap();
1258
1259        // 1. an absolute link to the host, under no prefix varve relocates.
1260        let err = export_members(
1261            &[Member {
1262                path: "bin/link".into(),
1263                body: MemberBody::Symlink {
1264                    target: outside.to_string_lossy().into_owned(),
1265                },
1266            }],
1267            BUILT,
1268            &out,
1269        )
1270        .unwrap_err();
1271        assert!(
1272            matches!(err, SdkExportError::SymlinkEscapes { .. }),
1273            "got {err}"
1274        );
1275
1276        // 2. a relative link that climbs out of the export root.
1277        let err = export_members(
1278            &[Member {
1279                path: "bin/link".into(),
1280                body: MemberBody::Symlink {
1281                    target: "../../OUTSIDE".into(),
1282                },
1283            }],
1284            BUILT,
1285            &out,
1286        )
1287        .unwrap_err();
1288        assert!(
1289            matches!(err, SdkExportError::SymlinkEscapes { .. }),
1290            "got {err}"
1291        );
1292
1293        // 2b. absolute, UNDER the build prefix, climbing out with `..`.
1294        //     The branch that re-points an SDK's own internal absolute links
1295        //     stripped the prefix and re-pointed the remainder WITHOUT walking
1296        //     it, so this shape was accepted and reported as "1 symlink(s)
1297        //     re-pointed" — exit 0. Found by clean-room review, reproduced
1298        //     end to end through the release binary. The relative branch below
1299        //     had always walked its target; only this one did not.
1300        let err = export_members(
1301            &[Member {
1302                path: "bin/link".into(),
1303                body: MemberBody::Symlink {
1304                    target: format!("{BUILT}/../../../../../../../../tmp/varve-pwned"),
1305                },
1306            }],
1307            BUILT,
1308            &out,
1309        )
1310        .unwrap_err();
1311        assert!(
1312            matches!(err, SdkExportError::SymlinkEscapes { .. }),
1313            "got {err}"
1314        );
1315
1316        // …while `..` that stays INSIDE the export is ordinary and must still
1317        // work, or the fix would just be a ban on `..`.
1318        let ok = export_members(
1319            &[
1320                Member {
1321                    path: "lib/sub/link".into(),
1322                    body: MemberBody::Symlink {
1323                        target: format!("{BUILT}/lib/sub/../real"),
1324                    },
1325                },
1326                Member {
1327                    path: "lib/real".into(),
1328                    body: MemberBody::File {
1329                        bytes: b"x".to_vec(),
1330                        mode: 0o644,
1331                    },
1332                },
1333            ],
1334            BUILT,
1335            &out,
1336        )
1337        .expect("`..` inside the export is legal");
1338        assert_eq!(ok.relocated_symlinks, 1);
1339
1340        // 3. …and the pair that is the actual CVE class: a link out, then a
1341        //    write THROUGH it. Refused as a pair — neither member is unsafe on
1342        //    its own, and the tar order does not decide it.
1343        let err = export_members(
1344            &[
1345                Member {
1346                    path: "bin/link".into(),
1347                    body: MemberBody::Symlink {
1348                        target: "../lib".into(),
1349                    },
1350                },
1351                Member {
1352                    path: "bin/link/pwned".into(),
1353                    body: MemberBody::File {
1354                        mode: 0o644,
1355                        bytes: b"PWNED".to_vec(),
1356                    },
1357                },
1358            ],
1359            BUILT,
1360            &out,
1361        )
1362        .unwrap_err();
1363        assert!(
1364            matches!(err, SdkExportError::WriteThroughSymlink { .. }),
1365            "got {err}"
1366        );
1367
1368        assert!(
1369            std::fs::read_dir(&outside).unwrap().next().is_none(),
1370            "nothing may be written outside the export"
1371        );
1372        assert!(!out.join("bin/link").exists(), "nothing written at all");
1373
1374        // A relative link INSIDE the tree is ordinary and must still work.
1375        let ok = export_members(
1376            &[
1377                Member {
1378                    path: "lib/libc.so.6".into(),
1379                    body: MemberBody::File {
1380                        mode: 0o644,
1381                        bytes: b"libc".to_vec(),
1382                    },
1383                },
1384                Member {
1385                    path: "bin/libc".into(),
1386                    body: MemberBody::Symlink {
1387                        target: "../lib/libc.so.6".into(),
1388                    },
1389                },
1390            ],
1391            BUILT,
1392            &out,
1393        )
1394        .unwrap();
1395        assert_eq!(ok.symlinks, 1);
1396        assert_eq!(
1397            ok.relocated_symlinks, 0,
1398            "a relative link needs no patching"
1399        );
1400    }
1401
1402    // rivet: verifies REQ-SDK-001
1403    #[test]
1404    fn two_members_claiming_one_path_are_refused_before_anything_is_written() {
1405        // The same invariant `Store::lay_down_payloads` holds, for a tree: the
1406        // survivor of a silent overwrite carries the wrong bytes under the
1407        // right name, and nothing downstream can tell.
1408        let tmp = tempfile::tempdir().unwrap();
1409        let out = tmp.path().join("sdk");
1410        let err = export_members(
1411            &[
1412                Member {
1413                    path: "bin/gcc".into(),
1414                    body: MemberBody::File {
1415                        mode: 0o755,
1416                        bytes: b"first".to_vec(),
1417                    },
1418                },
1419                Member {
1420                    path: "bin/gcc".into(),
1421                    body: MemberBody::File {
1422                        mode: 0o755,
1423                        bytes: b"second".to_vec(),
1424                    },
1425                },
1426            ],
1427            BUILT,
1428            &out,
1429        )
1430        .unwrap_err();
1431        assert!(matches!(err, SdkExportError::Collision { .. }), "got {err}");
1432        assert!(!out.join("bin/gcc").exists());
1433    }
1434
1435    /// The synthetic SDK as a gzip tar, the shape a producer actually signs.
1436    fn synthetic_tarball() -> Vec<u8> {
1437        use std::io::Write;
1438        let mut tar_bytes = Vec::new();
1439        {
1440            let mut b = tar::Builder::new(&mut tar_bytes);
1441            let mut dir = tar::Header::new_gnu();
1442            dir.set_entry_type(tar::EntryType::Directory);
1443            dir.set_size(0);
1444            dir.set_mode(0o755);
1445            b.append_data(&mut dir, "sysroots/", std::io::empty())
1446                .unwrap();
1447
1448            let bin = fake_binary();
1449            let mut f = tar::Header::new_gnu();
1450            f.set_size(bin.len() as u64);
1451            f.set_mode(0o755);
1452            b.append_data(
1453                &mut f,
1454                "sysroots/x86_64/usr/bin/aarch64-poky-linux-gcc",
1455                bin.as_slice(),
1456            )
1457            .unwrap();
1458
1459            let env = env_setup();
1460            let mut t = tar::Header::new_gnu();
1461            t.set_size(env.len() as u64);
1462            t.set_mode(0o644);
1463            b.append_data(
1464                &mut t,
1465                "environment-setup-aarch64-poky-linux",
1466                env.as_slice(),
1467            )
1468            .unwrap();
1469
1470            let mut link = tar::Header::new_gnu();
1471            link.set_entry_type(tar::EntryType::Symlink);
1472            link.set_size(0);
1473            link.set_mode(0o777);
1474            b.append_link(
1475                &mut link,
1476                "sysroots/x86_64/usr/bin/cc",
1477                format!("{BUILT}/sysroots/x86_64/usr/bin/aarch64-poky-linux-gcc"),
1478            )
1479            .unwrap();
1480            b.finish().unwrap();
1481        }
1482        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1483        gz.write_all(&tar_bytes).unwrap();
1484        gz.finish().unwrap()
1485    }
1486
1487    // rivet: verifies REQ-SDK-001
1488    #[test]
1489    fn a_signed_archive_unpacks_and_relocates_and_the_archive_is_never_modified() {
1490        // Clause 1 and 2 together, on the bytes a producer signs: the store
1491        // holds ONE blob with ONE digest — which is what makes an sdk payload
1492        // hold like any other, and what lets `verify` and `archive` keep
1493        // hashing a single file — and the tree exists only in the export.
1494        let tmp = tempfile::tempdir().unwrap();
1495        let out = tmp.path().join("sdk");
1496        let archive = synthetic_tarball();
1497        let before = archive.clone();
1498
1499        let report = export_sdk(&archive, BUILT, &out).unwrap();
1500        assert_eq!(report.files, 2);
1501        assert_eq!(report.symlinks, 1);
1502        assert_eq!(report.patched_fields, 2);
1503        assert_eq!(report.substitutions, 3);
1504        assert_eq!(archive, before, "the signed archive is read-only, always");
1505
1506        let gcc = out.join("sysroots/x86_64/usr/bin/aarch64-poky-linux-gcc");
1507        assert!(!String::from_utf8_lossy(&std::fs::read(&gcc).unwrap()).contains(BUILT));
1508        #[cfg(unix)]
1509        {
1510            use std::os::unix::fs::PermissionsExt;
1511            assert_eq!(
1512                std::fs::metadata(&gcc).unwrap().permissions().mode() & 0o777,
1513                0o755,
1514                "a compiler must survive the export executable"
1515            );
1516        }
1517        // A plain (uncompressed) tar is equally acceptable — an SDK ships as
1518        // either, and guessing from the file name is one more thing to get
1519        // wrong.
1520        let mut plain = Vec::new();
1521        flate2::read::GzDecoder::new(archive.as_slice())
1522            .read_to_end(&mut plain)
1523            .unwrap();
1524        let out2 = tmp.path().join("sdk2");
1525        assert_eq!(export_sdk(&plain, BUILT, &out2).unwrap(), report);
1526    }
1527
1528    // rivet: verifies REQ-SDK-001
1529    #[test]
1530    fn an_archive_member_that_escapes_is_refused_before_the_tree_is_written() {
1531        // The tar crate's own `unpack` sanitises; this path does not use it,
1532        // so the refusal has to be ours and has to be tested as ours.
1533        use std::io::Write;
1534        let mut tar_bytes = Vec::new();
1535        {
1536            let mut b = tar::Builder::new(&mut tar_bytes);
1537            let mut f = tar::Header::new_gnu();
1538            let payload = b"PWNED";
1539            f.set_size(payload.len() as u64);
1540            f.set_mode(0o644);
1541            // The name is written into the header DIRECTLY: `set_path` refuses
1542            // `..` itself, and an archive built by other software is under no
1543            // obligation to have used it. The refusal has to be varve's.
1544            {
1545                let gnu = f.as_gnu_mut().unwrap();
1546                let name = b"../../escape";
1547                gnu.name[..name.len()].copy_from_slice(name);
1548            }
1549            f.set_cksum();
1550            b.append(&f, &payload[..]).unwrap();
1551            b.finish().unwrap();
1552        }
1553        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1554        gz.write_all(&tar_bytes).unwrap();
1555        let evil = gz.finish().unwrap();
1556
1557        let tmp = tempfile::tempdir().unwrap();
1558        let err = export_sdk(&evil, BUILT, &tmp.path().join("sdk")).unwrap_err();
1559        assert!(
1560            matches!(err, SdkExportError::UnsafeMember { .. }),
1561            "got {err}"
1562        );
1563        assert!(!tmp.path().join("escape").exists());
1564        // Bytes that are not an archive at all are a distinct, named failure —
1565        // not a silent empty export.
1566        assert!(matches!(
1567            export_sdk(b"\x1f\x8bnot really gzip", BUILT, &tmp.path().join("s2")),
1568            Err(SdkExportError::Archive(_))
1569        ));
1570    }
1571}
1572
1573#[cfg(test)]
1574mod xz_tests {
1575    use super::*;
1576
1577    /// Build a real xz stream with the system `xz`, so this is not a fixture
1578    /// that agrees with my own encoder.
1579    fn xz(bytes: &[u8]) -> Option<Vec<u8>> {
1580        use std::io::Write;
1581        let mut c = std::process::Command::new("xz")
1582            .args(["-c", "-0"])
1583            .stdin(std::process::Stdio::piped())
1584            .stdout(std::process::Stdio::piped())
1585            .stderr(std::process::Stdio::null())
1586            .spawn()
1587            .ok()?;
1588        c.stdin.as_mut()?.write_all(bytes).ok()?;
1589        let out = c.wait_with_output().ok()?;
1590        out.status.success().then_some(out.stdout)
1591    }
1592
1593    /// Every wasmtime archive and all 140 Zephyr SDK toolchains are .tar.xz.
1594    /// Before this, `decompress` passed them through as though they were plain
1595    /// tar and the caller reported "not a readable tar archive" — a message
1596    /// that sends a reader looking for a corrupt download.
1597    // rivet: verifies REQ-SDKDEPOSIT-001
1598    #[test]
1599    fn an_xz_payload_is_decoded() {
1600        let plain = b"the tar bytes, near enough for a decoder test".repeat(40);
1601        let Some(compressed) = xz(&plain) else {
1602            eprintln!("system xz unavailable; skipping");
1603            return;
1604        };
1605        assert!(compressed.starts_with(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]));
1606        assert_ne!(compressed, plain, "the fixture is not actually compressed");
1607        let out = decompress(&compressed).expect("xz must decode");
1608        assert_eq!(out.as_ref(), plain.as_slice());
1609    }
1610
1611    // rivet: verifies REQ-SDKDEPOSIT-001
1612    #[test]
1613    fn gzip_and_plain_tar_still_work() {
1614        use std::io::Write;
1615        let plain = b"still a tar".repeat(30);
1616        let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1617        enc.write_all(&plain).unwrap();
1618        let gz = enc.finish().unwrap();
1619        assert_eq!(decompress(&gz).unwrap().as_ref(), plain.as_slice());
1620        assert_eq!(decompress(&plain).unwrap().as_ref(), plain.as_slice());
1621    }
1622
1623    /// A compression varve cannot decode must SAY so. Left to fall through, a
1624    /// bzip2 payload reaches the tar parser and reports "not a readable tar
1625    /// archive", which is true and useless: the download is fine and the
1626    /// decoder is missing.
1627    // rivet: verifies REQ-SDKDEPOSIT-001
1628    #[test]
1629    fn a_compression_varve_cannot_decode_names_itself() {
1630        let mut bz = b"BZh9".to_vec();
1631        bz.extend_from_slice(&[0x31, 0x41, 0x59, 0x26, 0x53, 0x59]);
1632        let e = decompress(&bz).expect_err("must refuse");
1633        let msg = e.to_string();
1634        assert!(msg.contains("bzip2"), "{msg}");
1635        assert!(msg.contains("what is missing is a decoder"), "{msg}");
1636    }
1637
1638    /// Truncated xz must fail, not yield a short tree. The bytes are
1639    /// digest-verified before they reach here, so this is a decoder-integrity
1640    /// check rather than a trust one — but a decoder that returns partial
1641    /// output on truncation would hand `export_members` an SDK missing files.
1642    // rivet: verifies REQ-SDKDEPOSIT-001
1643    #[test]
1644    fn a_truncated_xz_stream_is_an_error_not_a_short_tree() {
1645        let plain = b"a payload long enough to span blocks".repeat(200);
1646        let Some(compressed) = xz(&plain) else { return };
1647        let cut = &compressed[..compressed.len() / 2];
1648        assert!(
1649            decompress(cut).is_err(),
1650            "a truncated stream decoded anyway"
1651        );
1652    }
1653}