zsign_rs/bundle/
code_resources.rs

1//! CodeResources generation for iOS app bundle signing.
2//!
3//! Generates the `_CodeSignature/CodeResources` plist containing cryptographic
4//! hashes of all files in an iOS/macOS app bundle. This file is required for
5//! code signature verification by the operating system.
6//!
7//! # Usage
8//!
9//! Use [`CodeResourcesBuilder`] to scan a bundle directory and generate the plist:
10//!
11//! ```no_run
12//! use zsign::bundle::CodeResourcesBuilder;
13//!
14//! let mut builder = CodeResourcesBuilder::new("/path/to/MyApp.app");
15//! builder.scan()?;
16//! let plist_bytes = builder.build()?;
17//! std::fs::write("/path/to/MyApp.app/_CodeSignature/CodeResources", plist_bytes)?;
18//! # Ok::<(), Box<dyn std::error::Error>>(())
19//! ```
20//!
21//! # Exclusions
22//!
23//! The following are automatically excluded from hashing:
24//! - `_CodeSignature/` directory and contents
25//! - Main executable (has embedded signature via `CFBundleExecutable`)
26//! - Custom patterns added via [`CodeResourcesBuilder::exclude`]
27
28use 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
38/// Builder for generating CodeResources plist files.
39///
40/// This builder scans an iOS/macOS app bundle, computes cryptographic hashes
41/// (SHA-1 and SHA-256) of all files, and produces the CodeResources plist
42/// required for code signing.
43///
44/// # Builder Pattern
45///
46/// ```no_run
47/// use zsign::bundle::CodeResourcesBuilder;
48///
49/// let plist = CodeResourcesBuilder::new("/path/to/App.app")
50///     .exclude("DebugResources/")
51///     .scan()?
52///     .build()?;
53/// # Ok::<(), zsign::Error>(())
54/// ```
55///
56/// # Automatic Exclusions
57///
58/// The builder automatically excludes:
59/// - `_CodeSignature/` directory (contains the signature itself)
60/// - The main executable specified in `Info.plist` (has embedded signature)
61pub struct CodeResourcesBuilder {
62    /// Root bundle path
63    bundle_path: PathBuf,
64    /// Files to include with their hashes
65    files: BTreeMap<String, FileEntry>,
66    /// Custom exclusion patterns
67    exclusions: Vec<String>,
68    /// Main executable name (excluded from CodeResources as it has embedded signature)
69    main_executable: Option<String>,
70}
71
72/// Entry for a file in CodeResources
73struct FileEntry {
74    /// SHA-1 hash (20 bytes) - for files, hash of content; for symlinks, hash of target path
75    sha1: [u8; 20],
76    /// SHA-256 hash (32 bytes) - for files, hash of content; for symlinks, hash of target path
77    sha256: [u8; 32],
78    /// Whether this is optional (can be missing)
79    #[allow(dead_code)]
80    optional: bool,
81    /// If this is a symlink, contains the target path
82    symlink_target: Option<String>,
83}
84
85/// Standard exclusion rules for CodeResources (legacy format).
86///
87/// Defines patterns for file inclusion, optional files, and omitted files.
88fn standard_rules() -> Dictionary {
89    let mut rules = Dictionary::new();
90
91    // Everything else is included by default
92    rules.insert("^.*".to_string(), Value::Boolean(true));
93
94    // .lproj directories are optional
95    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    // locversion.plist is omitted
101    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    // Base.lproj has higher weight
107    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    // version.plist is included
112    rules.insert("^version.plist$".to_string(), Value::Boolean(true));
113
114    rules
115}
116
117/// Modern rules2 for CodeResources.
118///
119/// Defines patterns for file inclusion with extended rules including
120/// .dSYM, .DS_Store, and embedded provisioning profile handling.
121fn standard_rules2() -> Dictionary {
122    let mut rules2 = Dictionary::new();
123
124    // Default rule for everything else
125    rules2.insert("^.*".to_string(), Value::Boolean(true));
126
127    // .dSYM directories
128    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    // .DS_Store files are omitted
133    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    // .lproj directories are optional
139    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    // locversion.plist is omitted
145    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    // Base.lproj has higher weight
151    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    // Info.plist is omitted from files2
156    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    // PkgInfo is omitted from files2
162    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    // embedded.provisionprofile (note: different from mobileprovision)
168    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    // version.plist
173    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    /// Creates a new [`CodeResourcesBuilder`] for the given bundle path.
182    ///
183    /// Automatically reads `Info.plist` to determine the main executable
184    /// (which is excluded from hashing as it has an embedded signature).
185    ///
186    /// # Examples
187    ///
188    /// ```no_run
189    /// use zsign::bundle::CodeResourcesBuilder;
190    ///
191    /// let builder = CodeResourcesBuilder::new("/path/to/MyApp.app");
192    /// ```
193    pub fn new(bundle_path: impl AsRef<Path>) -> Self {
194        let bundle_path = bundle_path.as_ref().to_path_buf();
195
196        // Log warning if Info.plist can't be read (but don't fail construction)
197        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    /// Read the main executable name from Info.plist (CFBundleExecutable)
214    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            // Info.plist not existing is OK for some bundle types
219            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    /// Adds a custom exclusion pattern.
237    ///
238    /// Files with paths starting with this pattern will be excluded from hashing.
239    ///
240    /// # Examples
241    ///
242    /// ```no_run
243    /// use zsign::bundle::CodeResourcesBuilder;
244    ///
245    /// let builder = CodeResourcesBuilder::new("/path/to/App.app")
246    ///     .exclude("DebugResources/")
247    ///     .exclude("TestData/");
248    /// ```
249    pub fn exclude(mut self, pattern: impl Into<String>) -> Self {
250        self.exclusions.push(pattern.into());
251        self
252    }
253
254    /// Check if a path should be excluded from hashing
255    fn should_exclude(&self, relative_path: &str) -> bool {
256        // Always exclude _CodeSignature directory
257        if relative_path.starts_with("_CodeSignature/") || relative_path == "_CodeSignature" {
258            return true;
259        }
260
261        // Exclude CodeResources file itself
262        if relative_path == "_CodeSignature/CodeResources" {
263            return true;
264        }
265
266        // Exclude the main executable (it has its own embedded signature)
267        if let Some(ref main_exec) = self.main_executable {
268            if relative_path == main_exec {
269                return true;
270            }
271        }
272
273        // Nested bundle files (Frameworks/*.framework/*, PlugIns/*.appex/*) are included
274        // in the parent's CodeResources. Nested bundles have separate signatures.
275
276        // Check custom exclusions
277        for pattern in &self.exclusions {
278            if relative_path.starts_with(pattern) {
279                return true;
280            }
281        }
282
283        false
284    }
285
286    /// Check if the path is inside a nested bundle.
287    ///
288    /// Detects paths within .app, .framework, .appex, or .xctest bundles.
289    #[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    /// Scans the bundle directory and hashes all files.
305    ///
306    /// Walks the bundle directory tree, computes SHA-1 and SHA-256 hashes for
307    /// each file (excluding directories and excluded paths), and stores them
308    /// for later plist generation.
309    ///
310    /// Files are processed in parallel using [`rayon`] for performance.
311    ///
312    /// # Errors
313    ///
314    /// Returns an error if:
315    /// - The bundle directory cannot be read
316    /// - A file cannot be read for hashing
317    /// - Symlink targets cannot be resolved (on Unix)
318    ///
319    /// # Examples
320    ///
321    /// ```no_run
322    /// use zsign::bundle::CodeResourcesBuilder;
323    ///
324    /// let mut builder = CodeResourcesBuilder::new("/path/to/App.app");
325    /// builder.scan()?;
326    /// println!("Scanned {} files", builder.file_count());
327    /// # Ok::<(), zsign::Error>(())
328    /// ```
329    pub fn scan(&mut self) -> Result<&mut Self> {
330        let bundle_path = self.bundle_path.clone();
331
332        // Collect all entries first (WalkDir is not Send, so we collect to Vec)
333        let entries: Vec<_> = WalkDir::new(&bundle_path)
334            .follow_links(false)
335            .into_iter()
336            .filter_map(|e| e.ok())
337            .collect();
338
339        // Process entries in parallel
340        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        // Insert results sequentially (BTreeMap is not thread-safe)
372        for (path, entry) in results {
373            self.files.insert(path, entry);
374        }
375
376        Ok(self)
377    }
378
379    /// Hash a single file with both SHA-1 and SHA-256
380    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    /// Hash a symlink by hashing its target path
405    #[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        // On non-Unix platforms, symlinks are rare in iOS bundles
436        // Return an error or handle as regular file
437        Err(Error::Io(std::io::Error::new(
438            std::io::ErrorKind::Unsupported,
439            "Symlink handling not supported on this platform",
440        )))
441    }
442
443    /// Computes SHA-1 and SHA-256 hashes of the given data.
444    ///
445    /// Utility method for hashing arbitrary byte slices.
446    ///
447    /// # Examples
448    ///
449    /// ```
450    /// use zsign::bundle::CodeResourcesBuilder;
451    ///
452    /// let (sha1, sha256) = CodeResourcesBuilder::hash_data(b"Hello, World!");
453    /// assert_eq!(sha1.len(), 20);
454    /// assert_eq!(sha256.len(), 32);
455    /// ```
456    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    /// Adds a file entry manually with pre-computed hashes.
474    ///
475    /// Useful for adding files with known hashes without reading from disk,
476    /// such as nested bundle CodeResources files.
477    ///
478    /// # Examples
479    ///
480    /// ```no_run
481    /// use zsign::bundle::CodeResourcesBuilder;
482    ///
483    /// let mut builder = CodeResourcesBuilder::new("/path/to/App.app");
484    /// let (sha1, sha256) = CodeResourcesBuilder::hash_data(b"file content");
485    /// builder.add_file("Resources/data.bin", sha1, sha256);
486    /// ```
487    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    /// Adds an optional file entry with pre-computed hashes.
505    ///
506    /// Optional files are marked in the plist and may be missing from the bundle
507    /// without invalidating the signature. Commonly used for localization files.
508    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    /// Builds the CodeResources plist as XML bytes.
526    ///
527    /// Generates the complete `_CodeSignature/CodeResources` plist containing:
528    /// - `files`: Legacy SHA-1 hashes for older iOS versions
529    /// - `files2`: Modern SHA-1 + SHA-256 hashes with metadata
530    /// - `rules` / `rules2`: Standard Apple inclusion/exclusion patterns
531    ///
532    /// # Errors
533    ///
534    /// Returns an error if plist serialization fails.
535    ///
536    /// # Examples
537    ///
538    /// ```no_run
539    /// use zsign::bundle::CodeResourcesBuilder;
540    ///
541    /// let mut builder = CodeResourcesBuilder::new("/path/to/App.app");
542    /// builder.scan()?;
543    /// let plist_bytes = builder.build()?;
544    /// std::fs::write("/path/to/App.app/_CodeSignature/CodeResources", plist_bytes)?;
545    /// # Ok::<(), Box<dyn std::error::Error>>(())
546    /// ```
547    pub fn build(&self) -> Result<Vec<u8>> {
548        let mut root = Dictionary::new();
549
550        // Build "files" dictionary (legacy, SHA-1 only)
551        // C++ Reference: bundle.cpp:177-184
552        // For .lproj files, use dict with hash+optional; for others, use plain hash
553        let mut files = Dictionary::new();
554        for (path, entry) in &self.files {
555            // Skip symlinks in legacy files dict (they weren't supported in old format)
556            if entry.symlink_target.is_some() {
557                continue;
558            }
559
560            if path.contains(".lproj/") {
561                // .lproj files get a dict with hash and optional flag
562                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                // Other files just get the hash directly
568                files.insert(path.clone(), Value::Data(entry.sha1.to_vec()));
569            }
570        }
571        root.insert("files".to_string(), Value::Dictionary(files));
572
573        // Build "files2" dictionary (modern, SHA-1 + SHA-256)
574        // C++ Reference: bundle.cpp:186-192
575        // Omits .DS_Store, Info.plist, PkgInfo from files2
576        // Adds optional flag for .lproj files
577        let mut files2 = Dictionary::new();
578        for (path, entry) in &self.files {
579            // Omit these from files2 (they are included in files)
580            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 this is a symlink, add symlink target instead of hashes
587            if let Some(ref target) = entry.symlink_target {
588                file_dict.insert("symlink".to_string(), Value::String(target.clone()));
589            } else {
590                // Add SHA-1 hash
591                file_dict.insert("hash".to_string(), Value::Data(entry.sha1.to_vec()));
592
593                // Add SHA-256 hash
594                file_dict.insert("hash2".to_string(), Value::Data(entry.sha256.to_vec()));
595            }
596
597            // Mark .lproj files as optional
598            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        // Add rules (legacy)
607        root.insert("rules".to_string(), Value::Dictionary(standard_rules()));
608
609        // Add rules2 (modern)
610        root.insert("rules2".to_string(), Value::Dictionary(standard_rules2()));
611
612        // Serialize to XML plist
613        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    /// Returns an iterator over all scanned files and their hashes.
621    ///
622    /// Each item contains the relative path, SHA-1 hash, and SHA-256 hash.
623    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    /// Returns the number of files that will be included in the plist.
630    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        // Verify SHA-1 hash is correct (known value for "Hello, World!")
647        assert_eq!(sha1.len(), 20);
648        assert_eq!(sha256.len(), 32);
649
650        // The hash should be non-zero
651        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        // Verify it's valid XML
661        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        // Add a test file
675        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        // Verify the file is in the plist
683        assert!(plist_str.contains("<key>test.txt</key>"));
684    }
685
686    #[test]
687    fn test_scan_bundle_directory() {
688        // Create a temporary bundle structure
689        let temp_dir = tempdir().unwrap();
690        let bundle_path = temp_dir.path().join("Test.app");
691        fs::create_dir(&bundle_path).unwrap();
692
693        // Create some test files
694        fs::write(bundle_path.join("Info.plist"), b"<plist></plist>").unwrap();
695        fs::write(bundle_path.join("PkgInfo"), b"APPL????").unwrap();
696
697        // Create a resources directory
698        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        // Create _CodeSignature directory (should be excluded)
703        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        // Scan the bundle
708        let mut builder = CodeResourcesBuilder::new(&bundle_path);
709        builder.scan().unwrap();
710
711        // Verify files were found
712        assert!(builder.file_count() >= 3); // Info.plist, PkgInfo, icon.png
713
714        // Verify _CodeSignature was excluded
715        let file_paths: Vec<_> = builder.files().map(|(p, _, _)| p.clone()).collect();
716        assert!(!file_paths.iter().any(|p| p.contains("_CodeSignature")));
717
718        // Verify expected files are included
719        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        // Create a temporary bundle with a nested framework
726        let temp_dir = tempdir().unwrap();
727        let bundle_path = temp_dir.path().join("Test.app");
728        fs::create_dir(&bundle_path).unwrap();
729
730        // Create main bundle files
731        fs::write(bundle_path.join("Info.plist"), b"main plist").unwrap();
732
733        // Create Frameworks directory with nested framework
734        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        // Scan the bundle
742        let mut builder = CodeResourcesBuilder::new(&bundle_path);
743        builder.scan().unwrap();
744
745        // Nested framework files are included in parent's CodeResources
746        let file_paths: Vec<_> = builder.files().map(|(p, _, _)| p.clone()).collect();
747
748        // Main Info.plist should be included
749        assert!(file_paths.contains(&"Info.plist".to_string()));
750
751        // Nested framework files should also be included (matching C++ zsign behavior)
752        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        // Verify expected rules exist
762        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        // Verify expected rules2 exist
774        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        // Create a target file
792        let target_file = bundle_path.join("RealFile.txt");
793        fs::write(&target_file, b"real content").unwrap();
794
795        // Create a symlink to the file
796        let link_path = bundle_path.join("LinkToFile.txt");
797        symlink("RealFile.txt", &link_path).unwrap();
798
799        // Create Frameworks structure with symlinks (typical iOS pattern)
800        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        // Create Current -> A symlink
805        let current_link = bundle_path.join("Frameworks/Test.framework/Versions/Current");
806        symlink("A", &current_link).unwrap();
807        
808        // Create root symlinks
809        let root_binary = bundle_path.join("Frameworks/Test.framework/Test");
810        symlink("Versions/Current/Test", &root_binary).unwrap();
811
812        // Scan the bundle
813        let mut builder = CodeResourcesBuilder::new(&bundle_path);
814        builder.scan().unwrap();
815
816        // Build the plist and check for symlink entries
817        let plist_data = builder.build().unwrap();
818        let plist_str = String::from_utf8(plist_data).unwrap();
819
820        // Symlinks should have a <key>symlink</key> entry in files2
821        assert!(plist_str.contains("<key>symlink</key>"), 
822            "Symlink entries should have symlink key in plist");
823    }
824}