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 before end of file — not a field we can pad.
272            cursor = hit + built.len();
273            continue;
274        };
275        // Capacity is the string PLUS its NUL padding: exactly the `p_filesz`
276        // the script compares against.
277        let mut pad_end = end;
278        while pad_end < out.len() && out[pad_end] == 0 {
279            pad_end += 1;
280        }
281        let capacity = pad_end - start;
282
283        // Replace every occurrence of the prefix WITHIN this one string.
284        let old = out[start..end].to_vec();
285        let mut new = Vec::with_capacity(old.len());
286        let mut i = 0;
287        while let Some(h) = find_sub(&old, built, i) {
288            new.extend_from_slice(&old[i..h]);
289            new.extend_from_slice(dest);
290            i = h + built.len();
291        }
292        new.extend_from_slice(&old[i..]);
293
294        // `if len(new_dl_path) >= p_filesz: ERROR` — transcribed, not
295        // paraphrased. The destination check has already made this
296        // unreachable for a well-formed SDK; it stays because the field is
297        // where truncation would actually happen, and a silent truncation here
298        // produces a binary that cannot exec with nothing to point at.
299        if new.len() >= capacity {
300            return Err(SdkExportError::FieldTooSmall {
301                member: member.to_string(),
302                offset: start,
303                needed: new.len() + 1,
304                capacity,
305            });
306        }
307        out[start..start + new.len()].copy_from_slice(&new);
308        for b in &mut out[start + new.len()..pad_end] {
309            *b = 0;
310        }
311        fields += 1;
312        cursor = pad_end;
313    }
314    Ok(Relocation {
315        bytes: out,
316        fields,
317        substitutions: 0,
318    })
319}
320
321/// Refuse a path component that is not a single, safe name — the same rule
322/// `Store::lay_down_payloads` applies to a payload name, applied to EVERY
323/// component of every member of the tree, because a tree has thousands of them
324/// and one is enough to escape.
325fn component_fault(value: &str) -> Option<String> {
326    if value.is_empty() {
327        return Some("an empty path component".into());
328    }
329    if value == "." || value == ".." {
330        return Some("a relative path element".into());
331    }
332    if let Some(c) = value
333        .chars()
334        .find(|c| matches!(c, '/' | '\\' | '\0') || c.is_control())
335    {
336        return Some(format!("contains {c:?}"));
337    }
338    None
339}
340
341/// Validate a member path and return it normalised (no trailing slash).
342fn safe_member_path(raw: &str) -> Result<String, SdkExportError> {
343    let unsafe_member = |why: &str| SdkExportError::UnsafeMember {
344        member: raw.to_string(),
345        why: why.to_string(),
346    };
347    if raw.starts_with('/') {
348        return Err(unsafe_member(
349            "absolute — it would place bytes outside the export",
350        ));
351    }
352    let trimmed = raw.trim_end_matches('/');
353    if trimmed.is_empty() {
354        return Err(unsafe_member("empty"));
355    }
356    for component in trimmed.split('/') {
357        if let Some(why) = component_fault(component) {
358            return Err(unsafe_member(&why));
359        }
360    }
361    Ok(trimmed.to_string())
362}
363
364/// Decompress a gzip archive, or pass a plain tar through. An SDK ships as
365/// either, and guessing from the file name would be one more thing to get
366/// wrong.
367fn decompress(archive: &[u8]) -> Result<Cow<'_, [u8]>, SdkExportError> {
368    if archive.starts_with(&[0x1f, 0x8b]) {
369        let mut out = Vec::new();
370        flate2::read::GzDecoder::new(archive)
371            .read_to_end(&mut out)
372            .map_err(|e| SdkExportError::Archive(e.to_string()))?;
373        Ok(Cow::Owned(out))
374    } else {
375        Ok(Cow::Borrowed(archive))
376    }
377}
378
379/// Read every member of a tree payload out of its archive.
380///
381/// Paths are carried through VERBATIM and judged in `export_members`, which is
382/// the only thing that writes. Validating in both places would look safer and
383/// be worse: two copies of one rule drift, and a defect in either is masked by
384/// the other, so neither can be shown to matter.
385pub fn read_members(archive: &[u8]) -> Result<Vec<Member>, SdkExportError> {
386    let raw = decompress(archive)?;
387    let mut tar = tar::Archive::new(raw.as_ref());
388    let entries = tar
389        .entries()
390        .map_err(|e| SdkExportError::Archive(e.to_string()))?;
391    let mut members = Vec::new();
392    for entry in entries {
393        let mut entry = entry.map_err(|e| SdkExportError::Archive(e.to_string()))?;
394        let raw_path = entry
395            .path()
396            .map_err(|e| SdkExportError::Archive(e.to_string()))?
397            .to_string_lossy()
398            .into_owned();
399        let header = entry.header().clone();
400        let body = match header.entry_type() {
401            tar::EntryType::Directory => MemberBody::Dir,
402            // A hard link is treated as a symlink: varve is materialising a
403            // fresh tree, and a link that must stay inside the export is the
404            // same question either way.
405            tar::EntryType::Symlink | tar::EntryType::Link => {
406                let target = entry
407                    .link_name()
408                    .map_err(|e| SdkExportError::Archive(e.to_string()))?
409                    .map(|t| t.to_string_lossy().into_owned())
410                    .unwrap_or_default();
411                MemberBody::Symlink { target }
412            }
413            _ => {
414                let mut bytes = Vec::new();
415                entry
416                    .read_to_end(&mut bytes)
417                    .map_err(|e| SdkExportError::Archive(e.to_string()))?;
418                let mode = header.mode().unwrap_or(0o644);
419                MemberBody::File { mode, bytes }
420            }
421        };
422        members.push(Member {
423            path: raw_path,
424            body,
425        });
426    }
427    Ok(members)
428}
429
430/// Where a symlink target lands, and whether it stays inside the export.
431///
432/// Three cases, and each has to be decided rather than defaulted:
433///   * absolute and under the SDK's build prefix — an SDK's own internal
434///     absolute link, re-pointed into the export;
435///   * absolute and anywhere else — a link to the HOST, refused: a relocated
436///     SDK is self-contained, and a link varve did not verify is not part of
437///     what the trust root anchored;
438///   * relative — resolved lexically against the link's own directory and
439///     refused if it climbs out of the export root.
440fn resolve_link_target(
441    member: &str,
442    target: &str,
443    built_prefix: &str,
444    dest_prefix: &str,
445) -> Result<(String, bool), SdkExportError> {
446    let escapes = || SdkExportError::SymlinkEscapes {
447        member: member.to_string(),
448        target: target.to_string(),
449    };
450    if target.is_empty() {
451        return Err(escapes());
452    }
453    if target.starts_with('/') {
454        let built = normalise_prefix(built_prefix);
455        let dest = normalise_prefix(dest_prefix);
456        if target == built {
457            return Ok((dest.to_string(), true));
458        }
459        if let Some(rest) = target.strip_prefix(&format!("{built}/")) {
460            // Being under the build prefix is not the same as staying under
461            // it. `<built>/../../../tmp/x` starts with the prefix and lands
462            // outside the export, so the remainder is walked exactly as the
463            // relative branch below walks its target — that branch always did,
464            // this one did not, and the asymmetry WAS the hole: the escape was
465            // accepted and counted as a re-pointed symlink, exit 0.
466            let mut depth: isize = 0;
467            for component in rest.split('/') {
468                match component {
469                    "" | "." => {}
470                    ".." => {
471                        depth -= 1;
472                        // Climbing above the export root, not merely within it.
473                        if depth < 0 {
474                            return Err(escapes());
475                        }
476                    }
477                    _ => depth += 1,
478                }
479            }
480            return Ok((format!("{dest}/{rest}"), true));
481        }
482        return Err(escapes());
483    }
484    // Relative: walk it against the link's own directory, lexically. `..` past
485    // the export root is the escape; `..` within it is ordinary and common.
486    let mut stack: Vec<&str> = member.split('/').collect();
487    stack.pop(); // the link itself
488    for component in target.split('/') {
489        match component {
490            "" | "." => {}
491            ".." => {
492                if stack.pop().is_none() {
493                    return Err(escapes());
494                }
495            }
496            other => stack.push(other),
497        }
498    }
499    Ok((target.to_string(), false))
500}
501
502/// Lay a verified tree payload out under `out`, relocated from its build-time
503/// prefix to `out` (clause 3).
504///
505/// `out` must be ABSOLUTE: the destination is patched into the SDK's binaries,
506/// so a relative one would resolve against whatever directory a compiler
507/// happens to run in. Every destination is validated and resolved before any
508/// byte is written, so a tree that cannot be laid out whole is not laid out at
509/// all.
510///
511/// This never touches the store. The signed archive stays exactly as the
512/// producer signed it, which is what `verify` and `archive` re-hash; the
513/// relocated tree is a DERIVED artifact, deliberately outside the trust path
514/// (clause 2).
515pub fn export_sdk(
516    archive: &[u8],
517    built_prefix: &str,
518    out: &Path,
519) -> Result<SdkExportReport, SdkExportError> {
520    let dest = out.to_string_lossy().into_owned();
521    // FIRST, and before the archive is even decompressed: an SDK that cannot
522    // reach this destination must be refused now, not after thousands of files
523    // have been written and patched (clause 4).
524    check_destination_fits(built_prefix, &dest)?;
525    let members = read_members(archive)?;
526    export_members(&members, built_prefix, out)
527}
528
529/// The write half, split out so a tree can be laid down from members obtained
530/// any way — and so the plan-then-write discipline is testable without a tar.
531pub fn export_members(
532    members: &[Member],
533    built_prefix: &str,
534    out: &Path,
535) -> Result<SdkExportReport, SdkExportError> {
536    let dest = out.to_string_lossy().into_owned();
537    check_destination_fits(built_prefix, &dest)?;
538
539    // ---- plan: resolve and validate EVERY destination before writing ----
540    let mut placed: BTreeSet<String> = BTreeSet::new();
541    let mut links: BTreeSet<String> = BTreeSet::new();
542    let mut planned: Vec<(String, &Member)> = Vec::with_capacity(members.len());
543    for m in members {
544        let path = safe_member_path(&m.path)?;
545        if !placed.insert(path.clone()) {
546            return Err(SdkExportError::Collision { path });
547        }
548        if let MemberBody::Symlink { .. } = m.body {
549            links.insert(path.clone());
550        }
551        planned.push((path, m));
552    }
553    // A symlink out of the tree followed by a write THROUGH it places bytes
554    // anywhere on the filesystem, and every component of the offending member
555    // still looks perfectly safe on its own. Refuse the pair, not the shape.
556    for (path, _) in &planned {
557        let mut prefix = String::new();
558        for component in path.split('/') {
559            if !prefix.is_empty() {
560                prefix.push('/');
561            }
562            prefix.push_str(component);
563            if prefix.len() < path.len() && links.contains(&prefix) {
564                return Err(SdkExportError::WriteThroughSymlink {
565                    member: path.clone(),
566                    link: prefix,
567                });
568            }
569        }
570    }
571    // Link targets, resolved and refused before anything exists on disk.
572    let mut resolved_links: Vec<(&str, String, bool)> = Vec::new();
573    for (path, m) in &planned {
574        if let MemberBody::Symlink { target } = &m.body {
575            let (t, relocated) = resolve_link_target(path, target, built_prefix, &dest)?;
576            resolved_links.push((path, t, relocated));
577        }
578    }
579    // Relocate every file's bytes before creating the export directory: a
580    // field that will not fit must leave the destination untouched.
581    let mut relocated_files: Vec<(&str, Relocation, u32)> = Vec::new();
582    for (path, m) in &planned {
583        if let MemberBody::File { mode, bytes } = &m.body {
584            let r = relocate_bytes(path, bytes, built_prefix, &dest)?;
585            relocated_files.push((path, r, *mode));
586        }
587    }
588
589    // ---- write ----
590    let io = |path: &Path, source: std::io::Error| SdkExportError::Io {
591        path: path.display().to_string(),
592        source,
593    };
594    let mut report = SdkExportReport::default();
595    std::fs::create_dir_all(out).map_err(|e| io(out, e))?;
596    for (rel, m) in &planned {
597        if matches!(m.body, MemberBody::Dir) {
598            let path = out.join(rel);
599            std::fs::create_dir_all(&path).map_err(|e| io(&path, e))?;
600            report.dirs += 1;
601        }
602    }
603    for (rel, relocation, mode) in &relocated_files {
604        let path = out.join(rel);
605        if let Some(parent) = path.parent() {
606            std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
607        }
608        std::fs::write(&path, &relocation.bytes).map_err(|e| io(&path, e))?;
609        #[cfg(unix)]
610        {
611            use std::os::unix::fs::PermissionsExt;
612            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode & 0o7777))
613                .map_err(|e| io(&path, e))?;
614        }
615        #[cfg(not(unix))]
616        let _ = mode;
617        report.files += 1;
618        report.patched_fields += relocation.fields;
619        report.substitutions += relocation.substitutions;
620    }
621    for (rel, target, relocated) in &resolved_links {
622        let path = out.join(rel);
623        if let Some(parent) = path.parent() {
624            std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
625        }
626        #[cfg(unix)]
627        std::os::unix::fs::symlink(target, &path).map_err(|e| io(&path, e))?;
628        #[cfg(not(unix))]
629        {
630            let _ = target;
631            return Err(SdkExportError::SymlinksUnsupported {
632                member: (*rel).to_string(),
633            });
634        }
635        report.symlinks += 1;
636        if *relocated {
637            report.relocated_symlinks += 1;
638        }
639    }
640    Ok(report)
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use std::path::PathBuf;
647
648    /// The absolute path this synthetic SDK was BUILT for.
649    ///
650    /// Deliberately long, and that is not cosmetic: a real Yocto SDK is built
651    /// for a long default installation directory PRECISELY so it can be
652    /// relocated afterwards, since relocation can only ever shorten a path. The
653    /// tests' own temporary directory has to fit inside that budget, which is
654    /// the same arithmetic a real user does.
655    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";
656
657    /// The slack a real `PT_INTERP` field carries past its terminator.
658    const SLACK: usize = 8;
659
660    /// A NUL-padded path field, the way an ELF `PT_INTERP` segment holds one:
661    /// the string, its terminator, and slack up to the field width.
662    fn nul_field(s: &str, width: usize) -> Vec<u8> {
663        let mut v = s.as_bytes().to_vec();
664        v.resize(width, 0);
665        v
666    }
667
668    /// A path field sized as an SDK's own build produced it.
669    fn field(s: &str) -> Vec<u8> {
670        nul_field(s, s.len() + SLACK)
671    }
672
673    fn interp() -> String {
674        format!("{BUILT}/sysroots/x86_64/lib/ld-linux.so.2")
675    }
676
677    fn fake_binary() -> Vec<u8> {
678        let mut v = b"\x7fELF".to_vec();
679        v.extend_from_slice(&field(&interp()));
680        v.extend_from_slice(&field(&format!("{BUILT}/sysroots/x86_64/usr/lib")));
681        v.extend_from_slice(b"\0\0trailer\0");
682        v
683    }
684
685    fn env_setup() -> Vec<u8> {
686        format!(
687            "export SDKTARGETSYSROOT={BUILT}/sysroots/aarch64\n\
688             export PATH={BUILT}/sysroots/x86_64/usr/bin:$PATH\n\
689             export CC=\"aarch64-poky-linux-gcc --sysroot={BUILT}/sysroots/aarch64\"\n"
690        )
691        .into_bytes()
692    }
693
694    fn synthetic_sdk() -> Vec<Member> {
695        vec![
696            Member {
697                path: "sysroots".into(),
698                body: MemberBody::Dir,
699            },
700            Member {
701                path: "sysroots/x86_64/usr/bin/aarch64-poky-linux-gcc".into(),
702                body: MemberBody::File {
703                    mode: 0o755,
704                    bytes: fake_binary(),
705                },
706            },
707            Member {
708                path: "environment-setup-aarch64-poky-linux".into(),
709                body: MemberBody::File {
710                    mode: 0o644,
711                    bytes: env_setup(),
712                },
713            },
714            Member {
715                path: "sysroots/x86_64/usr/bin/cc".into(),
716                body: MemberBody::Symlink {
717                    target: format!("{BUILT}/sysroots/x86_64/usr/bin/aarch64-poky-linux-gcc"),
718                },
719            },
720        ]
721    }
722
723    /// An export root of a chosen length, so the fit check is exercised against
724    /// a REAL path rather than a string that only looks like one.
725    fn out_of_len(base: &Path, len: usize) -> PathBuf {
726        let base_s = base.to_string_lossy().into_owned();
727        assert!(base_s.len() < len, "tempdir already longer than {len}");
728        let pad = len - base_s.len() - 1;
729        base.join("d".repeat(pad))
730    }
731
732    // rivet: verifies REQ-SDK-001
733    #[test]
734    fn a_destination_longer_than_the_build_prefix_is_refused_before_anything_is_written() {
735        // Clause 4, and the constraint the whole design turns on:
736        //   if (len(new_dl_path) >= p_filesz): ERROR
737        // The interpreter path is patched IN PLACE into a fixed-size field, so
738        // an SDK can only ever move to a path no longer than the one it was
739        // built with. Refusing here — before the archive is even opened — is
740        // the difference between a one-line error and thousands of files
741        // written and then abandoned.
742        let tmp = tempfile::tempdir().unwrap();
743        let too_long = out_of_len(tmp.path(), BUILT.len() + 1);
744        let err = export_members(&synthetic_sdk(), BUILT, &too_long).unwrap_err();
745        match &err {
746            SdkExportError::DestinationTooLong {
747                dest_len, budget, ..
748            } => {
749                assert_eq!(*dest_len, BUILT.len() + 1);
750                assert_eq!(*budget, BUILT.len());
751            }
752            other => panic!("expected DestinationTooLong, got {other}"),
753        }
754        // Refused BY NAME: the message must carry the destination, the budget,
755        // and why — an operator whose export directory is one character too
756        // long has to be able to fix it without reading relocate_sdk.py.
757        let msg = err.to_string();
758        assert!(msg.contains(&too_long.display().to_string()), "{msg}");
759        assert!(
760            msg.contains(BUILT),
761            "names the prefix it was built for: {msg}"
762        );
763        assert!(msg.contains("NO LONGER"), "states the rule: {msg}");
764        // And nothing was written: not one file, not the directory itself.
765        assert!(!too_long.exists(), "a refused export must write nothing");
766
767        // Exactly at the budget is allowed — the constraint is `no longer`, and
768        // an off-by-one here would refuse a legitimate destination.
769        let exact = out_of_len(tmp.path(), BUILT.len());
770        assert!(check_destination_fits(BUILT, &exact.to_string_lossy()).is_ok());
771    }
772
773    // rivet: verifies REQ-SDK-001
774    #[test]
775    fn a_relative_or_prefixless_destination_is_refused() {
776        // The destination is PATCHED INTO the binaries. A relative one would
777        // resolve against whatever directory the compiler happens to run in.
778        assert!(matches!(
779            check_destination_fits(BUILT, "toolchains/poky"),
780            Err(SdkExportError::DestinationNotAbsolute(_))
781        ));
782        // …and an SDK with no declared build prefix has no budget at all,
783        // which is a different fault with a different fix.
784        assert!(matches!(
785            check_destination_fits("", "/opt/x"),
786            Err(SdkExportError::NoBuiltPrefix)
787        ));
788        assert!(matches!(
789            check_destination_fits("/", "/opt/x"),
790            Err(SdkExportError::DestinationTooLong { .. })
791        ));
792        // A trailing slash is the same prefix, not a longer one.
793        assert!(check_destination_fits("/opt/poky", "/opt/abcd/").is_ok());
794    }
795
796    // rivet: verifies REQ-SDK-001
797    #[test]
798    fn a_nul_padded_field_is_patched_in_place_and_the_file_length_is_preserved() {
799        // The heart of relocate_sdk.py: the field is opened "r+b" and written
800        // over, so every offset in the binary stays valid. A rewrite that
801        // changed the length would relocate the SDK and corrupt the ELF.
802        let original = fake_binary();
803        let r = relocate_bytes("gcc", &original, BUILT, "/opt/sdk").unwrap();
804        assert_eq!(
805            r.bytes.len(),
806            original.len(),
807            "an in-place patch must not change the file's length"
808        );
809        assert_eq!(r.fields, 2, "both path fields patched");
810        assert_eq!(r.substitutions, 0, "a binary is patched, never sed'ed");
811
812        // The new path is there, NUL-terminated, and the old one is gone.
813        let text = String::from_utf8_lossy(&r.bytes).into_owned();
814        assert!(text.contains("/opt/sdk/sysroots/x86_64/lib/ld-linux.so.2"));
815        assert!(
816            !text.contains(BUILT),
817            "the build-time prefix must not survive relocation: {text:?}"
818        );
819        // The slack really is NUL, not leftover bytes of the old path — a
820        // truncating rewrite leaves the tail of the old path behind and execs
821        // something that does not exist.
822        let width = interp().len() + SLACK;
823        let patched = &r.bytes[4..4 + width];
824        let end = patched.iter().position(|b| *b == 0).unwrap();
825        assert_eq!(
826            &patched[..end],
827            b"/opt/sdk/sysroots/x86_64/lib/ld-linux.so.2"
828        );
829        assert!(
830            patched[end..].iter().all(|b| *b == 0),
831            "the field must be re-padded with NUL"
832        );
833        // The trailer past the fields is untouched.
834        assert!(r.bytes.ends_with(b"trailer\0"));
835    }
836
837    // rivet: verifies REQ-SDK-001
838    #[test]
839    fn a_field_too_small_for_the_new_path_is_refused_rather_than_truncated() {
840        // The script's own guard, transcribed: `len(new) >= p_filesz` fails.
841        // A truncated interpreter path is a binary that cannot exec with
842        // nothing at all to point at, so this must never silently succeed.
843        // Field width 20 holds "/opt/a/ld.so" plus padding; "/opt/aaaaaaaaaa"
844        // is longer than the prefix but the destination check is bypassed here
845        // to reach the field guard directly.
846        let bytes = nul_field("/opt/a/ld.so", 14);
847        // 13 characters of path + terminator == 14: the last width that fits.
848        let ok = relocate_bytes("x", &bytes, "/opt/a", "/opt/ab").unwrap();
849        assert_eq!(ok.fields, 1);
850        assert_eq!(ok.bytes.len(), bytes.len());
851        // One more character and it does not.
852        let err = relocate_bytes("libc.so", &bytes, "/opt/a", "/opt/abc").unwrap_err();
853        match err {
854            SdkExportError::FieldTooSmall {
855                member,
856                needed,
857                capacity,
858                ..
859            } => {
860                assert_eq!(member, "libc.so", "the refusal must name the FILE");
861                assert_eq!(capacity, 14);
862                assert_eq!(needed, 15);
863            }
864            other => panic!("expected FieldTooSmall, got {other}"),
865        }
866    }
867
868    // rivet: verifies REQ-SDK-001
869    #[test]
870    fn a_text_file_is_substituted_and_may_change_length() {
871        // `toolchain-shar-relocate.sh` seds every text file, and
872        // `environment-setup-*` is the one that matters: it is SOURCED, and a
873        // stale SYSROOT in it silently builds against the wrong headers.
874        let original = env_setup();
875        let r = relocate_bytes("environment-setup", &original, BUILT, "/opt/sdk").unwrap();
876        assert_eq!(r.fields, 0, "a text file has no fixed-size field");
877        assert_eq!(r.substitutions, 3, "every occurrence, not just the first");
878        let text = String::from_utf8(r.bytes).unwrap();
879        assert!(text.contains("export SDKTARGETSYSROOT=/opt/sdk/sysroots/aarch64"));
880        assert!(text.contains("--sysroot=/opt/sdk/sysroots/aarch64"));
881        assert!(!text.contains(BUILT));
882        assert!(
883            text.len() < original.len(),
884            "a text rewrite is free to change length"
885        );
886    }
887
888    // rivet: verifies REQ-SDK-001
889    #[test]
890    fn the_whole_synthetic_tree_lands_relocated_and_the_source_bytes_are_untouched() {
891        // Clause 3 end to end, and clause 2 alongside it: what the producer
892        // signed is not what the export contains, and varve never records a
893        // post-relocation digest — the relocator stays outside the trust path.
894        let tmp = tempfile::tempdir().unwrap();
895        let out = tmp.path().join("sdk");
896        let members = synthetic_sdk();
897        let signed_binary = fake_binary();
898
899        let report = export_members(&members, BUILT, &out).unwrap();
900        assert_eq!(report.files, 2);
901        assert_eq!(report.symlinks, 1);
902        assert_eq!(report.relocated_symlinks, 1);
903        assert_eq!(report.patched_fields, 2);
904        assert_eq!(report.substitutions, 3);
905
906        let gcc = out.join("sysroots/x86_64/usr/bin/aarch64-poky-linux-gcc");
907        let on_disk = std::fs::read(&gcc).unwrap();
908        assert_eq!(on_disk.len(), signed_binary.len(), "in-place patch");
909        assert_ne!(
910            on_disk, signed_binary,
911            "the relocated bytes are NOT the signed bytes — which is exactly why \
912             the store keeps the archive and verify never hashes the export"
913        );
914        assert!(!String::from_utf8_lossy(&on_disk).contains(BUILT));
915
916        // The internal absolute symlink now points inside the export.
917        #[cfg(unix)]
918        {
919            let link = out.join("sysroots/x86_64/usr/bin/cc");
920            let target = std::fs::read_link(&link).unwrap();
921            assert_eq!(target, gcc, "an SDK-internal link follows the SDK");
922        }
923        // The directory member is a directory.
924        assert!(out.join("sysroots").is_dir());
925        // And the members handed in are untouched: nothing wrote back.
926        assert_eq!(members, synthetic_sdk());
927    }
928
929    // rivet: verifies REQ-SDK-001
930    #[test]
931    fn a_member_whose_path_escapes_the_export_is_refused_and_nothing_is_written() {
932        // Every component of every member gets the treatment a single payload
933        // name gets in the store. A tree has thousands, and one is enough.
934        let tmp = tempfile::tempdir().unwrap();
935        let out = tmp.path().join("sdk");
936        for bad in [
937            "../../evil",
938            "/etc/passwd",
939            "a/../../evil",
940            "a//b",
941            "a/./b",
942            "",
943            "..",
944        ] {
945            let members = vec![
946                Member {
947                    path: "good".into(),
948                    body: MemberBody::File {
949                        mode: 0o644,
950                        bytes: b"good".to_vec(),
951                    },
952                },
953                Member {
954                    path: bad.into(),
955                    body: MemberBody::File {
956                        mode: 0o644,
957                        bytes: b"evil".to_vec(),
958                    },
959                },
960            ];
961            let err = export_members(&members, BUILT, &out).unwrap_err();
962            assert!(
963                matches!(err, SdkExportError::UnsafeMember { .. }),
964                "member {bad:?} must be refused, got {err}"
965            );
966            // A partially written tree must not be possible: the good member
967            // did not land either.
968            assert!(
969                !out.join("good").exists(),
970                "member {bad:?}: the tree must be refused whole"
971            );
972        }
973        // …and safe paths that merely look unusual still work.
974        assert!(safe_member_path("a/b/c").is_ok());
975        assert!(safe_member_path("a/b/").is_ok());
976        assert_eq!(safe_member_path("a/b/").unwrap(), "a/b");
977    }
978
979    // rivet: verifies REQ-SDK-001
980    #[test]
981    fn a_symlink_that_leaves_the_export_is_refused_even_though_every_component_is_safe() {
982        // The escape a per-component check cannot see. `link` is a fine name
983        // and `link/pwned` is a fine path; the escape is the LINK's target.
984        let tmp = tempfile::tempdir().unwrap();
985        let out = tmp.path().join("sdk");
986        let outside = tmp.path().join("OUTSIDE");
987        std::fs::create_dir_all(&outside).unwrap();
988
989        // 1. an absolute link to the host, under no prefix varve relocates.
990        let err = export_members(
991            &[Member {
992                path: "bin/link".into(),
993                body: MemberBody::Symlink {
994                    target: outside.to_string_lossy().into_owned(),
995                },
996            }],
997            BUILT,
998            &out,
999        )
1000        .unwrap_err();
1001        assert!(
1002            matches!(err, SdkExportError::SymlinkEscapes { .. }),
1003            "got {err}"
1004        );
1005
1006        // 2. a relative link that climbs out of the export root.
1007        let err = export_members(
1008            &[Member {
1009                path: "bin/link".into(),
1010                body: MemberBody::Symlink {
1011                    target: "../../OUTSIDE".into(),
1012                },
1013            }],
1014            BUILT,
1015            &out,
1016        )
1017        .unwrap_err();
1018        assert!(
1019            matches!(err, SdkExportError::SymlinkEscapes { .. }),
1020            "got {err}"
1021        );
1022
1023        // 2b. absolute, UNDER the build prefix, climbing out with `..`.
1024        //     The branch that re-points an SDK's own internal absolute links
1025        //     stripped the prefix and re-pointed the remainder WITHOUT walking
1026        //     it, so this shape was accepted and reported as "1 symlink(s)
1027        //     re-pointed" — exit 0. Found by clean-room review, reproduced
1028        //     end to end through the release binary. The relative branch below
1029        //     had always walked its target; only this one did not.
1030        let err = export_members(
1031            &[Member {
1032                path: "bin/link".into(),
1033                body: MemberBody::Symlink {
1034                    target: format!("{BUILT}/../../../../../../../../tmp/varve-pwned"),
1035                },
1036            }],
1037            BUILT,
1038            &out,
1039        )
1040        .unwrap_err();
1041        assert!(
1042            matches!(err, SdkExportError::SymlinkEscapes { .. }),
1043            "got {err}"
1044        );
1045
1046        // …while `..` that stays INSIDE the export is ordinary and must still
1047        // work, or the fix would just be a ban on `..`.
1048        let ok = export_members(
1049            &[
1050                Member {
1051                    path: "lib/sub/link".into(),
1052                    body: MemberBody::Symlink {
1053                        target: format!("{BUILT}/lib/sub/../real"),
1054                    },
1055                },
1056                Member {
1057                    path: "lib/real".into(),
1058                    body: MemberBody::File {
1059                        bytes: b"x".to_vec(),
1060                        mode: 0o644,
1061                    },
1062                },
1063            ],
1064            BUILT,
1065            &out,
1066        )
1067        .expect("`..` inside the export is legal");
1068        assert_eq!(ok.relocated_symlinks, 1);
1069
1070        // 3. …and the pair that is the actual CVE class: a link out, then a
1071        //    write THROUGH it. Refused as a pair — neither member is unsafe on
1072        //    its own, and the tar order does not decide it.
1073        let err = export_members(
1074            &[
1075                Member {
1076                    path: "bin/link".into(),
1077                    body: MemberBody::Symlink {
1078                        target: "../lib".into(),
1079                    },
1080                },
1081                Member {
1082                    path: "bin/link/pwned".into(),
1083                    body: MemberBody::File {
1084                        mode: 0o644,
1085                        bytes: b"PWNED".to_vec(),
1086                    },
1087                },
1088            ],
1089            BUILT,
1090            &out,
1091        )
1092        .unwrap_err();
1093        assert!(
1094            matches!(err, SdkExportError::WriteThroughSymlink { .. }),
1095            "got {err}"
1096        );
1097
1098        assert!(
1099            std::fs::read_dir(&outside).unwrap().next().is_none(),
1100            "nothing may be written outside the export"
1101        );
1102        assert!(!out.join("bin/link").exists(), "nothing written at all");
1103
1104        // A relative link INSIDE the tree is ordinary and must still work.
1105        let ok = export_members(
1106            &[
1107                Member {
1108                    path: "lib/libc.so.6".into(),
1109                    body: MemberBody::File {
1110                        mode: 0o644,
1111                        bytes: b"libc".to_vec(),
1112                    },
1113                },
1114                Member {
1115                    path: "bin/libc".into(),
1116                    body: MemberBody::Symlink {
1117                        target: "../lib/libc.so.6".into(),
1118                    },
1119                },
1120            ],
1121            BUILT,
1122            &out,
1123        )
1124        .unwrap();
1125        assert_eq!(ok.symlinks, 1);
1126        assert_eq!(
1127            ok.relocated_symlinks, 0,
1128            "a relative link needs no patching"
1129        );
1130    }
1131
1132    // rivet: verifies REQ-SDK-001
1133    #[test]
1134    fn two_members_claiming_one_path_are_refused_before_anything_is_written() {
1135        // The same invariant `Store::lay_down_payloads` holds, for a tree: the
1136        // survivor of a silent overwrite carries the wrong bytes under the
1137        // right name, and nothing downstream can tell.
1138        let tmp = tempfile::tempdir().unwrap();
1139        let out = tmp.path().join("sdk");
1140        let err = export_members(
1141            &[
1142                Member {
1143                    path: "bin/gcc".into(),
1144                    body: MemberBody::File {
1145                        mode: 0o755,
1146                        bytes: b"first".to_vec(),
1147                    },
1148                },
1149                Member {
1150                    path: "bin/gcc".into(),
1151                    body: MemberBody::File {
1152                        mode: 0o755,
1153                        bytes: b"second".to_vec(),
1154                    },
1155                },
1156            ],
1157            BUILT,
1158            &out,
1159        )
1160        .unwrap_err();
1161        assert!(matches!(err, SdkExportError::Collision { .. }), "got {err}");
1162        assert!(!out.join("bin/gcc").exists());
1163    }
1164
1165    /// The synthetic SDK as a gzip tar, the shape a producer actually signs.
1166    fn synthetic_tarball() -> Vec<u8> {
1167        use std::io::Write;
1168        let mut tar_bytes = Vec::new();
1169        {
1170            let mut b = tar::Builder::new(&mut tar_bytes);
1171            let mut dir = tar::Header::new_gnu();
1172            dir.set_entry_type(tar::EntryType::Directory);
1173            dir.set_size(0);
1174            dir.set_mode(0o755);
1175            b.append_data(&mut dir, "sysroots/", std::io::empty())
1176                .unwrap();
1177
1178            let bin = fake_binary();
1179            let mut f = tar::Header::new_gnu();
1180            f.set_size(bin.len() as u64);
1181            f.set_mode(0o755);
1182            b.append_data(
1183                &mut f,
1184                "sysroots/x86_64/usr/bin/aarch64-poky-linux-gcc",
1185                bin.as_slice(),
1186            )
1187            .unwrap();
1188
1189            let env = env_setup();
1190            let mut t = tar::Header::new_gnu();
1191            t.set_size(env.len() as u64);
1192            t.set_mode(0o644);
1193            b.append_data(
1194                &mut t,
1195                "environment-setup-aarch64-poky-linux",
1196                env.as_slice(),
1197            )
1198            .unwrap();
1199
1200            let mut link = tar::Header::new_gnu();
1201            link.set_entry_type(tar::EntryType::Symlink);
1202            link.set_size(0);
1203            link.set_mode(0o777);
1204            b.append_link(
1205                &mut link,
1206                "sysroots/x86_64/usr/bin/cc",
1207                format!("{BUILT}/sysroots/x86_64/usr/bin/aarch64-poky-linux-gcc"),
1208            )
1209            .unwrap();
1210            b.finish().unwrap();
1211        }
1212        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1213        gz.write_all(&tar_bytes).unwrap();
1214        gz.finish().unwrap()
1215    }
1216
1217    // rivet: verifies REQ-SDK-001
1218    #[test]
1219    fn a_signed_archive_unpacks_and_relocates_and_the_archive_is_never_modified() {
1220        // Clause 1 and 2 together, on the bytes a producer signs: the store
1221        // holds ONE blob with ONE digest — which is what makes an sdk payload
1222        // hold like any other, and what lets `verify` and `archive` keep
1223        // hashing a single file — and the tree exists only in the export.
1224        let tmp = tempfile::tempdir().unwrap();
1225        let out = tmp.path().join("sdk");
1226        let archive = synthetic_tarball();
1227        let before = archive.clone();
1228
1229        let report = export_sdk(&archive, BUILT, &out).unwrap();
1230        assert_eq!(report.files, 2);
1231        assert_eq!(report.symlinks, 1);
1232        assert_eq!(report.patched_fields, 2);
1233        assert_eq!(report.substitutions, 3);
1234        assert_eq!(archive, before, "the signed archive is read-only, always");
1235
1236        let gcc = out.join("sysroots/x86_64/usr/bin/aarch64-poky-linux-gcc");
1237        assert!(!String::from_utf8_lossy(&std::fs::read(&gcc).unwrap()).contains(BUILT));
1238        #[cfg(unix)]
1239        {
1240            use std::os::unix::fs::PermissionsExt;
1241            assert_eq!(
1242                std::fs::metadata(&gcc).unwrap().permissions().mode() & 0o777,
1243                0o755,
1244                "a compiler must survive the export executable"
1245            );
1246        }
1247        // A plain (uncompressed) tar is equally acceptable — an SDK ships as
1248        // either, and guessing from the file name is one more thing to get
1249        // wrong.
1250        let mut plain = Vec::new();
1251        flate2::read::GzDecoder::new(archive.as_slice())
1252            .read_to_end(&mut plain)
1253            .unwrap();
1254        let out2 = tmp.path().join("sdk2");
1255        assert_eq!(export_sdk(&plain, BUILT, &out2).unwrap(), report);
1256    }
1257
1258    // rivet: verifies REQ-SDK-001
1259    #[test]
1260    fn an_archive_member_that_escapes_is_refused_before_the_tree_is_written() {
1261        // The tar crate's own `unpack` sanitises; this path does not use it,
1262        // so the refusal has to be ours and has to be tested as ours.
1263        use std::io::Write;
1264        let mut tar_bytes = Vec::new();
1265        {
1266            let mut b = tar::Builder::new(&mut tar_bytes);
1267            let mut f = tar::Header::new_gnu();
1268            let payload = b"PWNED";
1269            f.set_size(payload.len() as u64);
1270            f.set_mode(0o644);
1271            // The name is written into the header DIRECTLY: `set_path` refuses
1272            // `..` itself, and an archive built by other software is under no
1273            // obligation to have used it. The refusal has to be varve's.
1274            {
1275                let gnu = f.as_gnu_mut().unwrap();
1276                let name = b"../../escape";
1277                gnu.name[..name.len()].copy_from_slice(name);
1278            }
1279            f.set_cksum();
1280            b.append(&f, &payload[..]).unwrap();
1281            b.finish().unwrap();
1282        }
1283        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1284        gz.write_all(&tar_bytes).unwrap();
1285        let evil = gz.finish().unwrap();
1286
1287        let tmp = tempfile::tempdir().unwrap();
1288        let err = export_sdk(&evil, BUILT, &tmp.path().join("sdk")).unwrap_err();
1289        assert!(
1290            matches!(err, SdkExportError::UnsafeMember { .. }),
1291            "got {err}"
1292        );
1293        assert!(!tmp.path().join("escape").exists());
1294        // Bytes that are not an archive at all are a distinct, named failure —
1295        // not a silent empty export.
1296        assert!(matches!(
1297            export_sdk(b"\x1f\x8bnot really gzip", BUILT, &tmp.path().join("s2")),
1298            Err(SdkExportError::Archive(_))
1299        ));
1300    }
1301}