1use std::{
40 collections::{BTreeMap, BTreeSet},
41 io::{Cursor, Write},
42 path::{Path, PathBuf},
43};
44
45use apple_xar::{
46 format::XarHeader,
47 table_of_contents::{
48 Checksum, ChecksumType, File as XarTocFile, FileChecksum, FileData, FileEncoding, FileType,
49 TableOfContents,
50 },
51};
52use flate2::{Compression, write::ZlibEncoder};
53use quick_xml::{
54 Writer as XmlWriter,
55 events::{BytesDecl, BytesText, Event},
56};
57use scroll::IOwrite;
58
59mod bom_builder;
60
61#[derive(Debug, thiserror::Error)]
62pub enum Error {
63 #[error("I/O error: {0}")]
64 Io(#[from] std::io::Error),
65
66 #[error("xar error: {0}")]
67 Xar(#[from] apple_xar::Error),
68
69 #[error("BOM error: {0}")]
70 Bom(#[from] apple_bom::Error),
71
72 #[error("invalid BOM path: {0}")]
73 BadBomPath(String),
74
75 #[error("pkg: no files to package")]
76 NoFiles,
77
78 #[error("pkg dest {0:?} must be a relative /-separated path without empty or '..' segments")]
79 InvalidDest(String),
80
81 #[error("pkg: two entries share the dest {0:?}")]
82 DuplicateDest(String),
83
84 #[error("pkg dest {0:?} is both a file and a directory")]
85 DestConflict(String),
86
87 #[error("pkg dest {dest:?}: mode {mode:#o} must be permission bits only (0o000-0o777)")]
88 InvalidMode { dest: String, mode: u32 },
89
90 #[error("pkg install-location {0:?} must be an absolute path")]
91 InvalidInstallLocation(String),
92
93 #[error("pkg scripts {0} must be an existing directory")]
94 ScriptsNotDir(PathBuf),
95
96 #[error("pkg script {0} must be executable (installer cannot run it otherwise)")]
97 ScriptNotExecutable(PathBuf),
98}
99
100pub type Result<T> = std::result::Result<T, Error>;
101
102const EPOCH_2000: i64 = 946_684_800;
104
105const SHA1_LEN: u64 = 20;
107
108#[derive(Debug, Clone)]
111pub struct FileSpec {
112 pub src: PathBuf,
113 pub dest: String,
114 pub mode: Option<u32>,
117}
118
119#[derive(Debug, Clone)]
122pub struct DirSpec {
123 pub dest: String,
124 pub mode: Option<u32>,
126}
127
128#[derive(Debug, Clone)]
131pub struct PkgSpec {
132 pub name: String,
134 pub display_name: String,
136 pub identifier: String,
138 pub version: String,
139 pub install_location: String,
141 pub enable_user_home: bool,
145 pub files: Vec<FileSpec>,
146 pub directories: Vec<DirSpec>,
147 pub scripts: Option<PathBuf>,
151}
152
153enum Node {
155 Dir { mode: u32 },
156 File { bytes: Vec<u8>, mode: u32 },
157}
158
159const DIR_MODE: u32 = 0o755;
161
162pub fn build(spec: &PkgSpec, out: &Path) -> Result<()> {
166 validate(spec)?;
167
168 let mut nodes: BTreeMap<String, Node> = BTreeMap::new();
171 let implied_dir = || Node::Dir { mode: DIR_MODE };
172 for file in &spec.files {
173 for ancestor in ancestors(&file.dest) {
174 nodes
175 .entry(ancestor.to_string())
176 .or_insert_with(implied_dir);
177 }
178 let bytes = std::fs::read(&file.src)?;
179 let mode = match file.mode {
180 Some(mode) => mode,
181 None if executable(&file.src)? => 0o755,
182 None => 0o644,
183 };
184 nodes.insert(file.dest.clone(), Node::File { bytes, mode });
185 }
186 for dir in &spec.directories {
189 for ancestor in ancestors(&dir.dest) {
190 nodes
191 .entry(ancestor.to_string())
192 .or_insert_with(implied_dir);
193 }
194 let mode = dir.mode.unwrap_or(DIR_MODE);
195 nodes.insert(dir.dest.clone(), Node::Dir { mode });
196 }
197
198 let mut blocks = 0u64;
204 for node in nodes.values() {
205 blocks += match node {
206 Node::Dir { .. } => 1,
207 Node::File { bytes, .. } => (bytes.len() as u64).div_ceil(512),
208 };
209 }
210 let number_of_files = 1 + nodes.len() as u32;
211 let install_kbytes = blocks.div_ceil(2);
212
213 let bom = bom_from_nodes(&nodes, 0, 0)?;
215
216 let payload = gzip(&payload_cpio(&nodes))?;
217 let scripts = spec.scripts.as_deref().map(script_nodes).transpose()?;
218 let script_archive = scripts
219 .as_ref()
220 .map(|s| gzip(&payload_cpio(s)))
221 .transpose()?;
222 let hooks: Vec<&str> = ["preinstall", "postinstall"]
224 .into_iter()
225 .filter(|name| {
226 scripts
227 .as_ref()
228 .is_some_and(|s| matches!(s.get(*name), Some(Node::File { .. })))
229 })
230 .collect();
231 let package_info = package_info_xml(spec, number_of_files, install_kbytes, &hooks)?;
232 let distribution = distribution_xml(spec, install_kbytes)?;
233
234 let component = format!("{}.pkg", spec.name);
240 let mut children = vec![
241 XarEntry {
242 name: "Bom",
243 kind: XarEntryKind::File {
244 data: &bom,
245 mode: 0o644,
246 store: false,
247 },
248 },
249 XarEntry {
250 name: "Payload",
251 kind: XarEntryKind::File {
252 data: &payload,
253 mode: 0o644,
254 store: true,
255 },
256 },
257 ];
258 if let Some(script_archive) = &script_archive {
259 children.push(XarEntry {
260 name: "Scripts",
261 kind: XarEntryKind::File {
262 data: script_archive,
263 mode: 0o644,
264 store: true,
265 },
266 });
267 }
268 children.push(XarEntry {
269 name: "PackageInfo",
270 kind: XarEntryKind::File {
271 data: &package_info,
272 mode: 0o644,
273 store: false,
274 },
275 });
276 let entries = [
277 XarEntry {
278 name: &component,
279 kind: XarEntryKind::Directory {
280 mode: 0o700,
281 children,
282 },
283 },
284 XarEntry {
285 name: "Distribution",
286 kind: XarEntryKind::File {
287 data: &distribution,
288 mode: 0o644,
289 store: false,
290 },
291 },
292 ];
293
294 if let Some(parent) = out.parent() {
295 if !parent.as_os_str().is_empty() {
296 std::fs::create_dir_all(parent)?;
297 }
298 }
299 let mut archive = Vec::new();
300 write_xar(&entries, &mut archive)?;
301 std::fs::write(out, archive)?;
302 Ok(())
303}
304
305pub fn validate(spec: &PkgSpec) -> Result<()> {
310 if spec.files.is_empty() {
311 return Err(Error::NoFiles);
312 }
313 if !spec.install_location.starts_with('/') {
314 return Err(Error::InvalidInstallLocation(spec.install_location.clone()));
315 }
316 let entries = spec
317 .files
318 .iter()
319 .map(|f| (&f.dest, f.mode))
320 .chain(spec.directories.iter().map(|d| (&d.dest, d.mode)));
321 let mut seen = BTreeSet::new();
322 for (dest, mode) in entries {
323 if dest.split('/').any(|s| s.is_empty() || s == "..") {
324 return Err(Error::InvalidDest(dest.clone()));
325 }
326 if !seen.insert(dest.as_str()) {
327 return Err(Error::DuplicateDest(dest.clone()));
328 }
329 if let Some(mode) = mode.filter(|m| *m > 0o777) {
332 return Err(Error::InvalidMode {
333 dest: dest.clone(),
334 mode,
335 });
336 }
337 }
338 let dirs: BTreeSet<&str> = spec
341 .directories
342 .iter()
343 .map(|d| d.dest.as_str())
344 .chain(spec.files.iter().flat_map(|f| ancestors(&f.dest)))
345 .chain(spec.directories.iter().flat_map(|d| ancestors(&d.dest)))
346 .collect();
347 for file in &spec.files {
348 if dirs.contains(file.dest.as_str()) {
349 return Err(Error::DestConflict(file.dest.clone()));
350 }
351 }
352 Ok(())
353}
354
355fn script_nodes(dir: &Path) -> Result<BTreeMap<String, Node>> {
360 if !dir.is_dir() {
361 return Err(Error::ScriptsNotDir(dir.to_path_buf()));
362 }
363 let mut nodes = BTreeMap::new();
364 collect_nodes(dir, "", &mut nodes)?;
365 for hook in ["preinstall", "postinstall"] {
366 if let Some(Node::File { mode, .. }) = nodes.get(hook) {
367 if mode & 0o111 == 0 {
368 return Err(Error::ScriptNotExecutable(dir.join(hook)));
369 }
370 }
371 }
372 Ok(nodes)
373}
374
375fn collect_nodes(dir: &Path, prefix: &str, nodes: &mut BTreeMap<String, Node>) -> Result<()> {
376 for entry in std::fs::read_dir(dir)? {
377 let entry = entry?;
378 let name = entry.file_name().to_string_lossy().into_owned();
379 let rel = if prefix.is_empty() {
380 name
381 } else {
382 format!("{prefix}/{name}")
383 };
384 let path = entry.path();
386 if std::fs::metadata(&path)?.is_dir() {
387 nodes.insert(rel.clone(), Node::Dir { mode: DIR_MODE });
388 collect_nodes(&path, &rel, nodes)?;
389 } else {
390 let mode = if executable(&path)? { 0o755 } else { 0o644 };
391 let bytes = std::fs::read(&path)?;
392 nodes.insert(rel, Node::File { bytes, mode });
393 }
394 }
395 Ok(())
396}
397
398fn ancestors(dest: &str) -> impl Iterator<Item = &str> {
400 dest.char_indices()
401 .filter(|(_, c)| *c == '/')
402 .map(|(i, _)| &dest[..i])
403}
404
405#[cfg(unix)]
409fn executable(path: &Path) -> Result<bool> {
410 use std::os::unix::fs::PermissionsExt;
411 Ok(std::fs::metadata(path)?.permissions().mode() & 0o111 != 0)
412}
413
414#[cfg(windows)]
423fn executable(path: &Path) -> Result<bool> {
424 use std::io::Read;
425
426 let mut head = [0u8; 4];
427 let mut fh = std::fs::File::open(path)?;
428 let mut filled = 0;
429 while filled < head.len() {
430 match fh.read(&mut head[filled..])? {
431 0 => break,
432 n => filled += n,
433 }
434 }
435 Ok(looks_executable(&head[..filled]))
436}
437
438#[cfg(any(windows, test))]
441fn looks_executable(head: &[u8]) -> bool {
442 if head.starts_with(b"#!") {
443 return true;
444 }
445 let Some(magic) = head.get(..4) else {
446 return false;
447 };
448 matches!(
449 u32::from_be_bytes(magic.try_into().expect("4 bytes")),
450 0xfeed_face | 0xcefa_edfe | 0xfeed_facf | 0xcffa_edfe | 0xcafe_babe | 0xbeba_feca
454 )
455}
456
457fn payload_cpio(nodes: &BTreeMap<String, Node>) -> Vec<u8> {
465 let mut child_count: BTreeMap<&str, u32> = BTreeMap::new();
467 for path in nodes.keys() {
468 let parent = match path.rfind('/') {
469 Some(i) => &path[..i],
470 None => "",
471 };
472 *child_count.entry(parent).or_insert(0) += 1;
473 }
474 let children = |path: &str| child_count.get(path).copied().unwrap_or(0);
475
476 let mtime = EPOCH_2000 as u64;
477 let mut out = Vec::new();
478 let mut inode = 0;
479 odc_record(&mut out, ".", 0o40755, 2 + children(""), inode, mtime, &[]);
480 for (path, node) in nodes {
481 inode += 1;
482 let name = format!("./{path}");
483 match node {
484 Node::Dir { mode } => {
485 let nlink = 2 + children(path.as_str());
486 odc_record(&mut out, &name, 0o40000 | mode, nlink, inode, mtime, &[]);
487 }
488 Node::File { bytes, mode } => {
489 odc_record(&mut out, &name, 0o100000 | mode, 1, inode, mtime, bytes);
490 }
491 }
492 }
493 odc_record(&mut out, "TRAILER!!!", 0, 1, inode + 1, 0, &[]);
494 let padded = out.len().div_ceil(512) * 512;
495 out.resize(padded, 0);
496 out
497}
498
499fn odc_record(
501 out: &mut Vec<u8>,
502 name: &str,
503 mode: u32,
504 nlink: u32,
505 inode: u32,
506 mtime: u64,
507 data: &[u8],
508) {
509 let header = format!(
510 "070707{dev:06o}{inode:06o}{mode:06o}{uid:06o}{gid:06o}{nlink:06o}{rdev:06o}\
511 {mtime:011o}{namesize:06o}{filesize:011o}",
512 dev = 0,
513 uid = 0,
514 gid = 0,
515 rdev = 0,
516 namesize = name.len() + 1,
517 filesize = data.len(),
518 );
519 out.extend_from_slice(header.as_bytes());
520 out.extend_from_slice(name.as_bytes());
521 out.push(0);
522 out.extend_from_slice(data);
523}
524
525fn gzip(data: &[u8]) -> std::io::Result<Vec<u8>> {
527 let mut encoder = flate2::GzBuilder::new()
528 .mtime(0)
529 .write(Vec::new(), Compression::default());
530 encoder.write_all(data)?;
531 encoder.finish()
532}
533
534type XmlResult = std::result::Result<Vec<u8>, std::io::Error>;
539
540fn xml_writer() -> std::result::Result<XmlWriter<Cursor<Vec<u8>>>, std::io::Error> {
541 let mut writer = XmlWriter::new_with_indent(Cursor::new(Vec::new()), b' ', 4);
542 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("utf-8"), None)))?;
543 Ok(writer)
544}
545
546fn package_info_xml(
547 spec: &PkgSpec,
548 number_of_files: u32,
549 install_kbytes: u64,
550 hooks: &[&str],
551) -> XmlResult {
552 let mut writer = xml_writer()?;
553 writer
554 .create_element("pkg-info")
555 .with_attribute(("overwrite-permissions", "true"))
556 .with_attribute(("relocatable", "false"))
557 .with_attribute(("identifier", spec.identifier.as_str()))
558 .with_attribute(("postinstall-action", "none"))
559 .with_attribute(("version", spec.version.as_str()))
560 .with_attribute(("format-version", "2"))
561 .with_attribute(("install-location", spec.install_location.as_str()))
562 .with_attribute(("auth", "root"))
563 .write_inner_content(|w| {
564 w.create_element("payload")
565 .with_attribute(("numberOfFiles", number_of_files.to_string().as_str()))
566 .with_attribute(("installKBytes", install_kbytes.to_string().as_str()))
567 .write_empty()?;
568 for tag in [
569 "bundle-version",
570 "upgrade-bundle",
571 "update-bundle",
572 "atomic-update-bundle",
573 "strict-identifier",
574 "relocate",
575 ] {
576 w.create_element(tag).write_empty()?;
577 }
578 if !hooks.is_empty() {
581 w.create_element("scripts").write_inner_content(|w| {
582 for hook in hooks {
583 w.create_element(*hook)
584 .with_attribute(("file", format!("./{hook}").as_str()))
585 .with_attribute(("timeout", "600"))
586 .write_empty()?;
587 }
588 Ok::<(), std::io::Error>(())
589 })?;
590 }
591 Ok::<(), std::io::Error>(())
592 })?;
593 Ok(writer.into_inner().into_inner())
594}
595
596fn distribution_xml(spec: &PkgSpec, install_kbytes: u64) -> XmlResult {
597 let id = spec.identifier.as_str();
598 let mut writer = xml_writer()?;
599 writer
600 .create_element("installer-gui-script")
601 .with_attribute(("minSpecVersion", "2"))
602 .write_inner_content(|w| {
603 w.create_element("title")
604 .write_text_content(BytesText::new(&spec.display_name))?;
605 let domains = w
606 .create_element("domains")
607 .with_attribute(("enable_localSystem", "true"));
608 if spec.enable_user_home {
609 domains
610 .with_attribute(("enable_currentUserHome", "true"))
611 .write_empty()?;
612 } else {
613 domains.write_empty()?;
614 }
615 w.create_element("options")
619 .with_attribute(("customize", "never"))
620 .with_attribute(("require-scripts", "false"))
621 .with_attribute(("hostArchitectures", "x86_64,arm64"))
622 .write_empty()?;
623 w.create_element("choices-outline")
624 .write_inner_content(|w| {
625 w.create_element("line")
626 .with_attribute(("choice", "default"))
627 .write_inner_content(|w| {
628 w.create_element("line")
629 .with_attribute(("choice", id))
630 .write_empty()?;
631 Ok::<(), std::io::Error>(())
632 })?;
633 Ok::<(), std::io::Error>(())
634 })?;
635 w.create_element("choice")
636 .with_attribute(("id", "default"))
637 .write_empty()?;
638 w.create_element("choice")
639 .with_attribute(("id", id))
640 .with_attribute(("visible", "false"))
641 .write_inner_content(|w| {
642 w.create_element("pkg-ref")
643 .with_attribute(("id", id))
644 .write_empty()?;
645 Ok::<(), std::io::Error>(())
646 })?;
647 w.create_element("pkg-ref")
648 .with_attribute(("id", id))
649 .with_attribute(("version", spec.version.as_str()))
650 .with_attribute(("onConclusion", "none"))
651 .with_attribute(("installKBytes", install_kbytes.to_string().as_str()))
652 .with_attribute(("updateKBytes", "0"))
653 .write_text_content(BytesText::new(&format!("#{}.pkg", spec.name)))?;
654 Ok::<(), std::io::Error>(())
655 })?;
656 Ok(writer.into_inner().into_inner())
657}
658
659pub struct XarEntry<'a> {
664 pub name: &'a str,
666 pub kind: XarEntryKind<'a>,
667}
668
669pub enum XarEntryKind<'a> {
670 File {
675 data: &'a [u8],
676 mode: u32,
677 store: bool,
678 },
679 Directory {
681 mode: u32,
682 children: Vec<XarEntry<'a>>,
683 },
684}
685
686const TOC_TEMPLATE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
690<xar><toc>
691 <creation-time>2000-01-01T00:00:00</creation-time>
692 <checksum style="sha1"><offset>0</offset><size>20</size></checksum>
693 <file id="1"><type>file</type><name>placeholder</name></file>
694</toc></xar>"#;
695
696fn hex(data: &[u8]) -> String {
697 data.iter().map(|b| format!("{b:02x}")).collect()
698}
699
700fn zlib_compress(data: &[u8]) -> std::io::Result<Vec<u8>> {
701 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
702 encoder.write_all(data)?;
703 encoder.finish()
704}
705
706fn sha1_checksum(data: &[u8]) -> Result<FileChecksum> {
707 Ok(FileChecksum {
708 style: ChecksumType::Sha1,
709 checksum: hex(&ChecksumType::Sha1.digest_data(data)?),
710 })
711}
712
713pub fn write_xar<W: Write>(entries: &[XarEntry<'_>], mut writer: W) -> Result<()> {
720 let mut toc = TableOfContents::from_reader(TOC_TEMPLATE.as_bytes())?;
721
722 let mut heap = Vec::new();
723 let mut next_id = 1;
724 let mut files = Vec::with_capacity(entries.len());
725 for entry in entries {
726 files.push(toc_file(entry, &mut next_id, &mut heap)?);
727 }
728
729 toc.creation_time = "2000-01-01T00:00:00".to_string();
730 toc.checksum = Checksum {
731 style: ChecksumType::Sha1,
732 offset: 0,
733 size: SHA1_LEN,
734 };
735 toc.files = files;
736 toc.signature = None;
737 toc.x_signature = None;
738
739 let toc_xml = toc.to_xml()?;
740 let toc_compressed = zlib_compress(&toc_xml)?;
741 let toc_digest = ChecksumType::Sha1.digest_data(&toc_compressed)?;
742
743 let header = XarHeader {
744 magic: 0x7861_7221, size: 28,
746 version: 1,
747 toc_length_compressed: toc_compressed.len() as u64,
748 toc_length_uncompressed: toc_xml.len() as u64,
749 checksum_algorithm_id: 1, };
751
752 writer.iowrite_with(header, scroll::BE)?;
753 writer.write_all(&toc_compressed)?;
754 writer.write_all(&toc_digest)?;
755 writer.write_all(&heap)?;
756
757 Ok(())
758}
759
760fn toc_file(entry: &XarEntry<'_>, next_id: &mut u64, heap: &mut Vec<u8>) -> Result<XarTocFile> {
763 let id = *next_id;
764 *next_id += 1;
765
766 let blank = XarTocFile {
767 id,
768 ctime: None,
769 mtime: None,
770 atime: None,
771 names: vec![entry.name.to_string()],
772 file_type: FileType::File,
773 mode: None,
774 deviceno: None,
775 inode: None,
776 uid: Some(0),
777 gid: Some(0),
778 user: None,
779 group: None,
780 size: None,
781 data: None,
782 ea: None,
783 finder_create_time: None,
784 files: vec![],
785 };
786
787 match &entry.kind {
788 XarEntryKind::File { data, mode, store } => {
789 let (archived, encoding) = if *store {
790 (data.to_vec(), "application/octet-stream")
791 } else {
792 (zlib_compress(data)?, "application/x-gzip")
793 };
794 let file = XarTocFile {
795 mode: Some(format!("{:04o}", mode & 0o7777)),
796 size: Some(data.len() as u64),
797 data: Some(FileData {
798 offset: SHA1_LEN + heap.len() as u64,
799 size: data.len() as u64,
800 length: archived.len() as u64,
801 extracted_checksum: sha1_checksum(data)?,
802 archived_checksum: sha1_checksum(&archived)?,
803 encoding: FileEncoding {
804 style: encoding.to_string(),
805 },
806 }),
807 ..blank
808 };
809 heap.extend_from_slice(&archived);
810 Ok(file)
811 }
812 XarEntryKind::Directory { mode, children } => {
813 let mut files = Vec::with_capacity(children.len());
814 for child in children {
815 files.push(toc_file(child, next_id, heap)?);
816 }
817 Ok(XarTocFile {
818 file_type: FileType::Directory,
819 mode: Some(format!("{:04o}", mode & 0o7777)),
820 files,
821 ..blank
822 })
823 }
824 }
825}
826
827fn bom_from_nodes(nodes: &BTreeMap<String, Node>, uid: u32, gid: u32) -> Result<Vec<u8>> {
837 let mtime = chrono::DateTime::from_timestamp(EPOCH_2000, 0).expect("fixed timestamp is valid");
838
839 let mut builder = bom_builder::BomBuilder::default();
840 builder.default_user_id(uid);
841 builder.default_group_id(gid);
842 builder.default_mtime(mtime);
843
844 for (path, node) in nodes {
848 match node {
849 Node::Dir { mode } => {
850 builder.add_directory(path, (0o40000 | (mode & 0o7777)) as u16)?;
851 }
852 Node::File { bytes, mode } => {
853 let entry = builder.add_file_from_bytes(path, bytes)?;
854 entry.set_file_mode((0o100000 | (mode & 0o7777)) as u16);
855 entry.set_modified_time(mtime);
856 }
857 }
858 }
859
860 builder.build_bom()
861}
862
863#[cfg(unix)]
874pub fn write_bom(root: &Path, uid: u32, gid: u32) -> Result<Vec<u8>> {
875 use std::os::unix::fs::PermissionsExt;
876
877 let mtime = chrono::DateTime::from_timestamp(EPOCH_2000, 0).expect("fixed timestamp is valid");
878
879 let mut builder = bom_builder::BomBuilder::default();
880 builder.default_user_id(uid);
881 builder.default_group_id(gid);
882 builder.default_mtime(mtime);
883
884 let mut files = Vec::new();
885 collect_files(root, PathBuf::new(), &mut files)?;
886 files.sort();
887
888 for relative in files {
889 let full = root.join(&relative);
890 let mode = full.metadata()?.permissions().mode();
891
892 let entry = builder.add_file_from_path(relative.to_string_lossy(), &full)?;
893 entry.set_file_mode((0o100000 | (mode & 0o7777)) as u16);
894 entry.set_modified_time(mtime);
895 }
896
897 builder.build_bom()
898}
899
900#[cfg(unix)]
902fn collect_files(dir: &Path, prefix: PathBuf, files: &mut Vec<PathBuf>) -> Result<()> {
903 for entry in std::fs::read_dir(dir)? {
904 let entry = entry?;
905 let relative = prefix.join(entry.file_name());
906 let file_type = entry.file_type()?;
907
908 if file_type.is_dir() {
909 collect_files(&entry.path(), relative, files)?;
910 } else if file_type.is_file() {
911 files.push(relative);
912 }
913 }
914
915 Ok(())
916}
917
918#[cfg(test)]
919mod tests {
920 use super::looks_executable;
921
922 #[test]
926 fn sniff_accepts_mach_o_and_scripts_only() {
927 assert!(looks_executable(&[0xcf, 0xfa, 0xed, 0xfe]));
929 assert!(looks_executable(&[0xca, 0xfe, 0xba, 0xbe]));
931 assert!(looks_executable(b"#!/bin/sh\necho hi\n"));
932
933 assert!(!looks_executable(b"read me\n"));
934 assert!(!looks_executable(b"#"), "a lone '#' is not a shebang");
935 assert!(!looks_executable(b""));
936 }
937}