1use crate::{Error, Result};
29use plist::{Dictionary, Value};
30use sha1::{Digest, Sha1};
31use sha2::Sha256;
32use std::collections::BTreeMap;
33use std::fs;
34use std::path::{Path, PathBuf};
35use rayon::prelude::*;
36use walkdir::WalkDir;
37
38pub struct CodeResourcesBuilder {
62 bundle_path: PathBuf,
64 files: BTreeMap<String, FileEntry>,
66 exclusions: Vec<String>,
68 main_executable: Option<String>,
70}
71
72struct FileEntry {
74 sha1: [u8; 20],
76 sha256: [u8; 32],
78 #[allow(dead_code)]
80 optional: bool,
81 symlink_target: Option<String>,
83}
84
85fn standard_rules() -> Dictionary {
89 let mut rules = Dictionary::new();
90
91 rules.insert("^.*".to_string(), Value::Boolean(true));
93
94 let mut lproj = Dictionary::new();
96 lproj.insert("optional".to_string(), Value::Boolean(true));
97 lproj.insert("weight".to_string(), Value::Real(1000.0));
98 rules.insert("^.*\\.lproj/".to_string(), Value::Dictionary(lproj));
99
100 let mut locversion = Dictionary::new();
102 locversion.insert("omit".to_string(), Value::Boolean(true));
103 locversion.insert("weight".to_string(), Value::Real(1100.0));
104 rules.insert("^.*\\.lproj/locversion.plist$".to_string(), Value::Dictionary(locversion));
105
106 let mut base_lproj = Dictionary::new();
108 base_lproj.insert("weight".to_string(), Value::Real(1010.0));
109 rules.insert("^Base\\.lproj/".to_string(), Value::Dictionary(base_lproj));
110
111 rules.insert("^version.plist$".to_string(), Value::Boolean(true));
113
114 rules
115}
116
117fn standard_rules2() -> Dictionary {
122 let mut rules2 = Dictionary::new();
123
124 rules2.insert("^.*".to_string(), Value::Boolean(true));
126
127 let mut dsym = Dictionary::new();
129 dsym.insert("weight".to_string(), Value::Real(11.0));
130 rules2.insert(".*\\.dSYM($|/)".to_string(), Value::Dictionary(dsym));
131
132 let mut ds_store = Dictionary::new();
134 ds_store.insert("omit".to_string(), Value::Boolean(true));
135 ds_store.insert("weight".to_string(), Value::Real(2000.0));
136 rules2.insert("^(.*/)?\\.DS_Store$".to_string(), Value::Dictionary(ds_store));
137
138 let mut lproj = Dictionary::new();
140 lproj.insert("optional".to_string(), Value::Boolean(true));
141 lproj.insert("weight".to_string(), Value::Real(1000.0));
142 rules2.insert("^.*\\.lproj/".to_string(), Value::Dictionary(lproj));
143
144 let mut locversion = Dictionary::new();
146 locversion.insert("omit".to_string(), Value::Boolean(true));
147 locversion.insert("weight".to_string(), Value::Real(1100.0));
148 rules2.insert("^.*\\.lproj/locversion.plist$".to_string(), Value::Dictionary(locversion));
149
150 let mut base_lproj = Dictionary::new();
152 base_lproj.insert("weight".to_string(), Value::Real(1010.0));
153 rules2.insert("^Base\\.lproj/".to_string(), Value::Dictionary(base_lproj));
154
155 let mut info_plist = Dictionary::new();
157 info_plist.insert("omit".to_string(), Value::Boolean(true));
158 info_plist.insert("weight".to_string(), Value::Real(20.0));
159 rules2.insert("^Info\\.plist$".to_string(), Value::Dictionary(info_plist));
160
161 let mut pkg_info = Dictionary::new();
163 pkg_info.insert("omit".to_string(), Value::Boolean(true));
164 pkg_info.insert("weight".to_string(), Value::Real(20.0));
165 rules2.insert("^PkgInfo$".to_string(), Value::Dictionary(pkg_info));
166
167 let mut provision = Dictionary::new();
169 provision.insert("weight".to_string(), Value::Real(20.0));
170 rules2.insert("^embedded\\.provisionprofile$".to_string(), Value::Dictionary(provision));
171
172 let mut version_plist = Dictionary::new();
174 version_plist.insert("weight".to_string(), Value::Real(20.0));
175 rules2.insert("^version\\.plist$".to_string(), Value::Dictionary(version_plist));
176
177 rules2
178}
179
180impl CodeResourcesBuilder {
181 pub fn new(bundle_path: impl AsRef<Path>) -> Self {
194 let bundle_path = bundle_path.as_ref().to_path_buf();
195
196 let main_executable = match Self::read_main_executable(&bundle_path) {
198 Ok(exec) => exec,
199 Err(e) => {
200 eprintln!("Warning: Failed to read main executable from Info.plist: {}", e);
201 None
202 }
203 };
204
205 Self {
206 bundle_path,
207 files: BTreeMap::new(),
208 exclusions: Vec::new(),
209 main_executable,
210 }
211 }
212
213 fn read_main_executable(bundle_path: &Path) -> Result<Option<String>> {
215 let info_plist_path = bundle_path.join("Info.plist");
216
217 if !info_plist_path.exists() {
218 return Ok(None);
220 }
221
222 let data = fs::read(&info_plist_path)?;
223
224 let plist: plist::Value = plist::from_bytes(&data)?;
225
226 let dict = plist.as_dictionary()
227 .ok_or_else(|| Error::Io(
228 std::io::Error::new(std::io::ErrorKind::InvalidData, "Info.plist is not a dictionary")
229 ))?;
230
231 Ok(dict.get("CFBundleExecutable")
232 .and_then(|v| v.as_string())
233 .map(|s| s.to_string()))
234 }
235
236 pub fn exclude(mut self, pattern: impl Into<String>) -> Self {
250 self.exclusions.push(pattern.into());
251 self
252 }
253
254 fn should_exclude(&self, relative_path: &str) -> bool {
256 if relative_path.starts_with("_CodeSignature/") || relative_path == "_CodeSignature" {
258 return true;
259 }
260
261 if relative_path == "_CodeSignature/CodeResources" {
263 return true;
264 }
265
266 if let Some(ref main_exec) = self.main_executable {
268 if relative_path == main_exec {
269 return true;
270 }
271 }
272
273 for pattern in &self.exclusions {
278 if relative_path.starts_with(pattern) {
279 return true;
280 }
281 }
282
283 false
284 }
285
286 #[allow(dead_code)]
290 fn is_nested_bundle(&self, relative_path: &str) -> bool {
291 let bundle_extensions = [".app/", ".framework/", ".appex/", ".xctest/"];
292
293 for ext in &bundle_extensions {
294 if let Some(pos) = relative_path.find(ext) {
295 if pos > 0 {
296 return true;
297 }
298 }
299 }
300
301 false
302 }
303
304 pub fn scan(&mut self) -> Result<&mut Self> {
330 let bundle_path = self.bundle_path.clone();
331
332 let entries: Vec<_> = WalkDir::new(&bundle_path)
334 .follow_links(false)
335 .into_iter()
336 .filter_map(|e| e.ok())
337 .collect();
338
339 let results: Vec<_> = entries
341 .par_iter()
342 .filter_map(|entry| {
343 let path = entry.path();
344 let metadata = fs::symlink_metadata(path).ok()?;
345 let is_symlink = metadata.file_type().is_symlink();
346
347 if !is_symlink && metadata.is_dir() {
348 return None;
349 }
350
351 let relative_path = path
352 .strip_prefix(&bundle_path)
353 .ok()?
354 .to_string_lossy()
355 .to_string();
356
357 if self.should_exclude(&relative_path) {
358 return None;
359 }
360
361 let file_entry = if is_symlink {
362 self.hash_symlink(path).ok()?
363 } else {
364 self.hash_file(path).ok()?
365 };
366
367 Some((relative_path, file_entry))
368 })
369 .collect();
370
371 for (path, entry) in results {
373 self.files.insert(path, entry);
374 }
375
376 Ok(self)
377 }
378
379 fn hash_file(&self, path: &Path) -> Result<FileEntry> {
381 let data = fs::read(path)?;
382
383 let mut sha1_hasher = Sha1::new();
384 sha1_hasher.update(&data);
385 let sha1_result = sha1_hasher.finalize();
386
387 let mut sha256_hasher = Sha256::new();
388 sha256_hasher.update(&data);
389 let sha256_result = sha256_hasher.finalize();
390
391 let mut sha1 = [0u8; 20];
392 let mut sha256 = [0u8; 32];
393 sha1.copy_from_slice(&sha1_result);
394 sha256.copy_from_slice(&sha256_result);
395
396 Ok(FileEntry {
397 sha1,
398 sha256,
399 optional: false,
400 symlink_target: None,
401 })
402 }
403
404 #[cfg(unix)]
406 fn hash_symlink(&self, path: &Path) -> Result<FileEntry> {
407 use std::os::unix::ffi::OsStrExt;
408
409 let target = fs::read_link(path)?;
410 let target_bytes = target.as_os_str().as_bytes();
411
412 let mut sha1_hasher = Sha1::new();
413 sha1_hasher.update(target_bytes);
414 let sha1_result = sha1_hasher.finalize();
415
416 let mut sha256_hasher = Sha256::new();
417 sha256_hasher.update(target_bytes);
418 let sha256_result = sha256_hasher.finalize();
419
420 let mut sha1 = [0u8; 20];
421 let mut sha256 = [0u8; 32];
422 sha1.copy_from_slice(&sha1_result);
423 sha256.copy_from_slice(&sha256_result);
424
425 Ok(FileEntry {
426 sha1,
427 sha256,
428 optional: false,
429 symlink_target: Some(target.to_string_lossy().to_string()),
430 })
431 }
432
433 #[cfg(not(unix))]
434 fn hash_symlink(&self, _path: &Path) -> Result<FileEntry> {
435 Err(Error::Io(std::io::Error::new(
438 std::io::ErrorKind::Unsupported,
439 "Symlink handling not supported on this platform",
440 )))
441 }
442
443 pub fn hash_data(data: &[u8]) -> ([u8; 20], [u8; 32]) {
457 let mut sha1_hasher = Sha1::new();
458 sha1_hasher.update(data);
459 let sha1_result = sha1_hasher.finalize();
460
461 let mut sha256_hasher = Sha256::new();
462 sha256_hasher.update(data);
463 let sha256_result = sha256_hasher.finalize();
464
465 let mut sha1 = [0u8; 20];
466 let mut sha256 = [0u8; 32];
467 sha1.copy_from_slice(&sha1_result);
468 sha256.copy_from_slice(&sha256_result);
469
470 (sha1, sha256)
471 }
472
473 pub fn add_file(
488 &mut self,
489 relative_path: impl Into<String>,
490 sha1: [u8; 20],
491 sha256: [u8; 32],
492 ) {
493 self.files.insert(
494 relative_path.into(),
495 FileEntry {
496 sha1,
497 sha256,
498 optional: false,
499 symlink_target: None,
500 },
501 );
502 }
503
504 pub fn add_optional_file(
509 &mut self,
510 relative_path: impl Into<String>,
511 sha1: [u8; 20],
512 sha256: [u8; 32],
513 ) {
514 self.files.insert(
515 relative_path.into(),
516 FileEntry {
517 sha1,
518 sha256,
519 optional: true,
520 symlink_target: None,
521 },
522 );
523 }
524
525 pub fn build(&self) -> Result<Vec<u8>> {
548 let mut root = Dictionary::new();
549
550 let mut files = Dictionary::new();
554 for (path, entry) in &self.files {
555 if entry.symlink_target.is_some() {
557 continue;
558 }
559
560 if path.contains(".lproj/") {
561 let mut file_dict = Dictionary::new();
563 file_dict.insert("hash".to_string(), Value::Data(entry.sha1.to_vec()));
564 file_dict.insert("optional".to_string(), Value::Boolean(true));
565 files.insert(path.clone(), Value::Dictionary(file_dict));
566 } else {
567 files.insert(path.clone(), Value::Data(entry.sha1.to_vec()));
569 }
570 }
571 root.insert("files".to_string(), Value::Dictionary(files));
572
573 let mut files2 = Dictionary::new();
578 for (path, entry) in &self.files {
579 if path == "Info.plist" || path == "PkgInfo" || path.ends_with(".DS_Store") {
581 continue;
582 }
583
584 let mut file_dict = Dictionary::new();
585
586 if let Some(ref target) = entry.symlink_target {
588 file_dict.insert("symlink".to_string(), Value::String(target.clone()));
589 } else {
590 file_dict.insert("hash".to_string(), Value::Data(entry.sha1.to_vec()));
592
593 file_dict.insert("hash2".to_string(), Value::Data(entry.sha256.to_vec()));
595 }
596
597 if path.contains(".lproj/") {
599 file_dict.insert("optional".to_string(), Value::Boolean(true));
600 }
601
602 files2.insert(path.clone(), Value::Dictionary(file_dict));
603 }
604 root.insert("files2".to_string(), Value::Dictionary(files2));
605
606 root.insert("rules".to_string(), Value::Dictionary(standard_rules()));
608
609 root.insert("rules2".to_string(), Value::Dictionary(standard_rules2()));
611
612 let mut buf = Vec::new();
614 plist::to_writer_xml(&mut buf, &Value::Dictionary(root))
615 .map_err(Error::Plist)?;
616
617 Ok(buf)
618 }
619
620 pub fn files(&self) -> impl Iterator<Item = (&String, &[u8; 20], &[u8; 32])> {
624 self.files
625 .iter()
626 .map(|(path, entry)| (path, &entry.sha1, &entry.sha256))
627 }
628
629 pub fn file_count(&self) -> usize {
631 self.files.len()
632 }
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use std::fs;
639 use tempfile::tempdir;
640
641 #[test]
642 fn test_hash_data() {
643 let data = b"Hello, World!";
644 let (sha1, sha256) = CodeResourcesBuilder::hash_data(data);
645
646 assert_eq!(sha1.len(), 20);
648 assert_eq!(sha256.len(), 32);
649
650 assert!(sha1.iter().any(|&b| b != 0));
652 assert!(sha256.iter().any(|&b| b != 0));
653 }
654
655 #[test]
656 fn test_build_plist_structure() {
657 let builder = CodeResourcesBuilder::new("/fake/path");
658 let plist_data = builder.build().unwrap();
659
660 let plist_str = String::from_utf8(plist_data).unwrap();
662 assert!(plist_str.contains("<?xml"));
663 assert!(plist_str.contains("<plist"));
664 assert!(plist_str.contains("<key>files</key>"));
665 assert!(plist_str.contains("<key>files2</key>"));
666 assert!(plist_str.contains("<key>rules</key>"));
667 assert!(plist_str.contains("<key>rules2</key>"));
668 }
669
670 #[test]
671 fn test_plist_with_files() {
672 let mut builder = CodeResourcesBuilder::new("/fake/path");
673
674 let sha1 = [1u8; 20];
676 let sha256 = [2u8; 32];
677 builder.add_file("test.txt", sha1, sha256);
678
679 let plist_data = builder.build().unwrap();
680 let plist_str = String::from_utf8(plist_data).unwrap();
681
682 assert!(plist_str.contains("<key>test.txt</key>"));
684 }
685
686 #[test]
687 fn test_scan_bundle_directory() {
688 let temp_dir = tempdir().unwrap();
690 let bundle_path = temp_dir.path().join("Test.app");
691 fs::create_dir(&bundle_path).unwrap();
692
693 fs::write(bundle_path.join("Info.plist"), b"<plist></plist>").unwrap();
695 fs::write(bundle_path.join("PkgInfo"), b"APPL????").unwrap();
696
697 let resources = bundle_path.join("Resources");
699 fs::create_dir(&resources).unwrap();
700 fs::write(resources.join("icon.png"), b"fake png data").unwrap();
701
702 let code_sig = bundle_path.join("_CodeSignature");
704 fs::create_dir(&code_sig).unwrap();
705 fs::write(code_sig.join("CodeResources"), b"should be excluded").unwrap();
706
707 let mut builder = CodeResourcesBuilder::new(&bundle_path);
709 builder.scan().unwrap();
710
711 assert!(builder.file_count() >= 3); let file_paths: Vec<_> = builder.files().map(|(p, _, _)| p.clone()).collect();
716 assert!(!file_paths.iter().any(|p| p.contains("_CodeSignature")));
717
718 assert!(file_paths.contains(&"Info.plist".to_string()));
720 assert!(file_paths.contains(&"PkgInfo".to_string()));
721 }
722
723 #[test]
724 fn test_inclusion_of_nested_bundle_files() {
725 let temp_dir = tempdir().unwrap();
727 let bundle_path = temp_dir.path().join("Test.app");
728 fs::create_dir(&bundle_path).unwrap();
729
730 fs::write(bundle_path.join("Info.plist"), b"main plist").unwrap();
732
733 let frameworks = bundle_path.join("Frameworks");
735 fs::create_dir_all(&frameworks).unwrap();
736 let framework = frameworks.join("Test.framework");
737 fs::create_dir(&framework).unwrap();
738 fs::write(framework.join("Test"), b"framework binary").unwrap();
739 fs::write(framework.join("Info.plist"), b"framework plist").unwrap();
740
741 let mut builder = CodeResourcesBuilder::new(&bundle_path);
743 builder.scan().unwrap();
744
745 let file_paths: Vec<_> = builder.files().map(|(p, _, _)| p.clone()).collect();
747
748 assert!(file_paths.contains(&"Info.plist".to_string()));
750
751 assert!(file_paths.iter().any(|p| p.contains(".framework/")));
753 assert!(file_paths.contains(&"Frameworks/Test.framework/Test".to_string()));
754 assert!(file_paths.contains(&"Frameworks/Test.framework/Info.plist".to_string()));
755 }
756
757 #[test]
758 fn test_rules_structure() {
759 let rules = standard_rules();
760
761 assert!(rules.contains_key("^.*"));
763 assert!(rules.contains_key("^.*\\.lproj/"));
764 assert!(rules.contains_key("^.*\\.lproj/locversion.plist$"));
765 assert!(rules.contains_key("^Base\\.lproj/"));
766 assert!(rules.contains_key("^version.plist$"));
767 }
768
769 #[test]
770 fn test_rules2_structure() {
771 let rules2 = standard_rules2();
772
773 assert!(rules2.contains_key("^.*"));
775 assert!(rules2.contains_key(".*\\.dSYM($|/)"));
776 assert!(rules2.contains_key("^(.*/)?\\.DS_Store$"));
777 assert!(rules2.contains_key("^.*\\.lproj/"));
778 assert!(rules2.contains_key("^Info\\.plist$"));
779 assert!(rules2.contains_key("^PkgInfo$"));
780 }
781
782 #[test]
783 #[cfg(unix)]
784 fn test_scan_bundle_with_symlinks() {
785 use std::os::unix::fs::symlink;
786
787 let temp_dir = tempdir().unwrap();
788 let bundle_path = temp_dir.path().join("Test.app");
789 fs::create_dir(&bundle_path).unwrap();
790
791 let target_file = bundle_path.join("RealFile.txt");
793 fs::write(&target_file, b"real content").unwrap();
794
795 let link_path = bundle_path.join("LinkToFile.txt");
797 symlink("RealFile.txt", &link_path).unwrap();
798
799 let framework_dir = bundle_path.join("Frameworks/Test.framework/Versions/A");
801 fs::create_dir_all(&framework_dir).unwrap();
802 fs::write(framework_dir.join("Test"), b"binary").unwrap();
803
804 let current_link = bundle_path.join("Frameworks/Test.framework/Versions/Current");
806 symlink("A", ¤t_link).unwrap();
807
808 let root_binary = bundle_path.join("Frameworks/Test.framework/Test");
810 symlink("Versions/Current/Test", &root_binary).unwrap();
811
812 let mut builder = CodeResourcesBuilder::new(&bundle_path);
814 builder.scan().unwrap();
815
816 let plist_data = builder.build().unwrap();
818 let plist_str = String::from_utf8(plist_data).unwrap();
819
820 assert!(plist_str.contains("<key>symlink</key>"),
822 "Symlink entries should have symlink key in plist");
823 }
824}