1use std::borrow::Cow;
46use std::collections::BTreeSet;
47use std::io::Read;
48use std::path::Path;
49
50pub const ANN_SDK_PREFIX: &str = "eu.pulseengine.varve.sdk.prefix";
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum MemberBody {
61 Dir,
62 File { mode: u32, bytes: Vec<u8> },
63 Symlink { target: String },
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Member {
70 pub path: String,
71 pub body: MemberBody,
72}
73
74#[derive(Debug, Clone, Default, PartialEq, Eq)]
77pub struct SdkExportReport {
78 pub dirs: usize,
79 pub files: usize,
80 pub symlinks: usize,
81 pub patched_fields: usize,
84 pub substitutions: usize,
86 pub relocated_symlinks: usize,
88}
89
90#[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
159fn normalise_prefix(p: &str) -> &str {
162 let t = p.trim_end_matches('/');
163 if t.is_empty() { p } else { t }
164}
165
166pub 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
195fn 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#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct Relocation {
206 pub bytes: Vec<u8>,
207 pub fields: usize,
209 pub substitutions: usize,
211}
212
213fn is_binary(bytes: &[u8]) -> bool {
218 bytes.contains(&0)
219}
220
221pub 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 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 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 break;
282 };
283 let pad_end = end + out[end..].iter().take_while(|b| **b == 0).count();
291 let capacity = pad_end - start;
292
293 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 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
331fn 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
351fn 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
374fn 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 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 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
418pub 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 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
469fn 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 let mut depth: isize = 0;
506 for component in rest.split('/') {
507 match component {
508 "" | "." => {}
509 ".." => {
510 depth -= 1;
511 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 let mut stack: Vec<&str> = member.split('/').collect();
526 stack.pop(); 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
541pub 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 check_destination_fits(built_prefix, &dest)?;
564 let members = read_members(archive)?;
565 export_members(&members, built_prefix, out)
566}
567
568pub 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 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 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 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 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 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 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 const SLACK: usize = 8;
698
699 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 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 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 #[test]
773 fn a_destination_longer_than_the_build_prefix_is_refused_before_anything_is_written() {
774 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 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 assert!(!too_long.exists(), "a refused export must write nothing");
805
806 let exact = out_of_len(tmp.path(), BUILT.len());
809 assert!(check_destination_fits(BUILT, &exact.to_string_lossy()).is_ok());
810 }
811
812 #[test]
814 fn a_relative_or_prefixless_destination_is_refused() {
815 assert!(matches!(
818 check_destination_fits(BUILT, "toolchains/poky"),
819 Err(SdkExportError::DestinationNotAbsolute(_))
820 ));
821 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 assert!(check_destination_fits("/opt/poky", "/opt/abcd/").is_ok());
833 }
834
835 #[test]
842 fn a_component_is_refused_for_a_separator_or_for_a_control_character() {
843 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 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 assert!(super::component_fault("libc.so.6").is_none());
857 }
858
859 #[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 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 #[test]
885 fn an_absolute_target_under_the_prefix_is_walked_not_merely_prefix_matched() {
886 let built = "/opt/poky";
887 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 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 #[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 assert!(super::resolve_link_target("m", "a/../..", "/opt/poky", "/opt/sdk").is_err());
918 }
919
920 #[test]
931 fn patching_one_string_leaves_the_string_before_it_untouched() {
932 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 #[test]
971 fn an_unterminated_occurrence_is_stepped_over_rather_than_scanned_forever() {
972 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 #[test]
991 fn capacity_is_measured_from_the_string_start_not_from_the_occurrence() {
992 let field = format!("PATH={BUILT}");
996 let head = b"PRECEDING\0";
1001 let mut buf = head.to_vec();
1002 buf.extend_from_slice(&nul_field(&field, field.len() + 1));
1004
1005 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 let longer = format!("{same}Z");
1013 let err = relocate_bytes("x", &buf, BUILT, &longer).unwrap_err();
1014 let msg = err.to_string();
1015 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 #[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 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 #[test]
1051 fn a_nul_padded_field_is_patched_in_place_and_the_file_length_is_preserved() {
1052 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 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 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 assert!(r.bytes.ends_with(b"trailer\0"));
1088 }
1089
1090 #[test]
1092 fn a_field_too_small_for_the_new_path_is_refused_rather_than_truncated() {
1093 let bytes = nul_field("/opt/a/ld.so", 14);
1100 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 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 #[test]
1123 fn a_text_file_is_substituted_and_may_change_length() {
1124 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 #[test]
1143 fn the_whole_synthetic_tree_lands_relocated_and_the_source_bytes_are_untouched() {
1144 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 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 #[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 assert!(out.join("sysroots").is_dir());
1195 assert_eq!(members, synthetic_sdk());
1197 }
1198
1199 #[test]
1201 fn a_member_whose_path_escapes_the_export_is_refused_and_nothing_is_written() {
1202 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 assert!(
1239 !out.join("good").exists(),
1240 "member {bad:?}: the tree must be refused whole"
1241 );
1242 }
1243 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 #[test]
1251 fn a_symlink_that_leaves_the_export_is_refused_even_though_every_component_is_safe() {
1252 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 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 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 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 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 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 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 #[test]
1404 fn two_members_claiming_one_path_are_refused_before_anything_is_written() {
1405 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 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 #[test]
1489 fn a_signed_archive_unpacks_and_relocates_and_the_archive_is_never_modified() {
1490 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 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 #[test]
1530 fn an_archive_member_that_escapes_is_refused_before_the_tree_is_written() {
1531 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 {
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 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 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 #[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 #[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 #[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 #[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}