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 cursor = hit + built.len();
273 continue;
274 };
275 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 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 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
321fn 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
341fn 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
364fn 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
379pub 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 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
430fn 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 let mut depth: isize = 0;
467 for component in rest.split('/') {
468 match component {
469 "" | "." => {}
470 ".." => {
471 depth -= 1;
472 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 let mut stack: Vec<&str> = member.split('/').collect();
487 stack.pop(); 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
502pub 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 check_destination_fits(built_prefix, &dest)?;
525 let members = read_members(archive)?;
526 export_members(&members, built_prefix, out)
527}
528
529pub 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 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 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 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 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 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 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 const SLACK: usize = 8;
659
660 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 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 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 #[test]
734 fn a_destination_longer_than_the_build_prefix_is_refused_before_anything_is_written() {
735 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 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 assert!(!too_long.exists(), "a refused export must write nothing");
766
767 let exact = out_of_len(tmp.path(), BUILT.len());
770 assert!(check_destination_fits(BUILT, &exact.to_string_lossy()).is_ok());
771 }
772
773 #[test]
775 fn a_relative_or_prefixless_destination_is_refused() {
776 assert!(matches!(
779 check_destination_fits(BUILT, "toolchains/poky"),
780 Err(SdkExportError::DestinationNotAbsolute(_))
781 ));
782 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 assert!(check_destination_fits("/opt/poky", "/opt/abcd/").is_ok());
794 }
795
796 #[test]
798 fn a_nul_padded_field_is_patched_in_place_and_the_file_length_is_preserved() {
799 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 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 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 assert!(r.bytes.ends_with(b"trailer\0"));
835 }
836
837 #[test]
839 fn a_field_too_small_for_the_new_path_is_refused_rather_than_truncated() {
840 let bytes = nul_field("/opt/a/ld.so", 14);
847 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 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 #[test]
870 fn a_text_file_is_substituted_and_may_change_length() {
871 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 #[test]
890 fn the_whole_synthetic_tree_lands_relocated_and_the_source_bytes_are_untouched() {
891 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 #[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 assert!(out.join("sysroots").is_dir());
925 assert_eq!(members, synthetic_sdk());
927 }
928
929 #[test]
931 fn a_member_whose_path_escapes_the_export_is_refused_and_nothing_is_written() {
932 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 assert!(
969 !out.join("good").exists(),
970 "member {bad:?}: the tree must be refused whole"
971 );
972 }
973 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 #[test]
981 fn a_symlink_that_leaves_the_export_is_refused_even_though_every_component_is_safe() {
982 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 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 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 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 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 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 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 #[test]
1134 fn two_members_claiming_one_path_are_refused_before_anything_is_written() {
1135 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 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 #[test]
1219 fn a_signed_archive_unpacks_and_relocates_and_the_archive_is_never_modified() {
1220 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 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 #[test]
1260 fn an_archive_member_that_escapes_is_refused_before_the_tree_is_written() {
1261 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 {
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 assert!(matches!(
1297 export_sdk(b"\x1f\x8bnot really gzip", BUILT, &tmp.path().join("s2")),
1298 Err(SdkExportError::Archive(_))
1299 ));
1300 }
1301}