Skip to main content

resopt/
catalog.rs

1use crate::filesystem::{contained_file, hash};
2use anyhow::{Context, Result, ensure};
3use serde::{Deserialize, Serialize};
4use std::{
5    collections::BTreeMap,
6    fs,
7    path::{Path, PathBuf},
8};
9use walkdir::WalkDir;
10use xcassets::{RenditionSet, RenditionSetKind};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Asset {
14    /// Relative to the inventory root. Only files referenced by catalog JSON.
15    pub path: PathBuf,
16    pub bytes: u64,
17    pub eligible: bool,
18    pub reason: Option<String>,
19    pub contents_path: PathBuf,
20    pub contents_sha256: String,
21}
22
23#[derive(Debug, Serialize, Deserialize)]
24pub struct Inventory {
25    pub schema_version: u32,
26    pub root: PathBuf,
27    pub catalogs: usize,
28    pub assets: Vec<Asset>,
29    pub diagnostics: Vec<String>,
30}
31
32/// Discover catalogs recursively without following symlinks or build caches.
33/// This inventories disk resources; it does not prove target membership.
34pub fn scan(root: impl AsRef<Path>) -> Result<Inventory> {
35    scan_with_options(root, crate::ScanOptions::default())
36}
37
38pub fn scan_with_options(root: impl AsRef<Path>, options: crate::ScanOptions) -> Result<Inventory> {
39    let root = fs::canonicalize(root.as_ref()).context("resolving project root")?;
40    let filter = crate::scan_options::ScanFilter::new(&root, options)?;
41    scan_filtered(&root, &filter)
42}
43
44pub(crate) fn scan_filtered(
45    root: &Path,
46    filter: &crate::scan_options::ScanFilter,
47) -> Result<Inventory> {
48    let root = root.to_path_buf();
49    ensure!(root.is_dir(), "scan root must be a directory");
50    let mut inventory = Inventory {
51        schema_version: 1,
52        root: root.clone(),
53        catalogs: 0,
54        assets: vec![],
55        diagnostics: filter.diagnostics.clone(),
56    };
57    let mut walk = WalkDir::new(&root).follow_links(false).into_iter();
58    while let Some(entry) = walk.next() {
59        let entry = match entry {
60            Ok(entry) => entry,
61            Err(error) => {
62                inventory.diagnostics.push(error.to_string());
63                continue;
64            }
65        };
66        if !entry.file_type().is_dir() {
67            continue;
68        }
69        // Only prune directories: skipping on a file would drop its siblings.
70        if !filter.allows(entry.path()) {
71            walk.skip_current_dir();
72            continue;
73        }
74        if entry.depth() > 0 && excluded(entry.file_name().to_str().unwrap_or("")) {
75            walk.skip_current_dir();
76            continue;
77        }
78        if entry
79            .path()
80            .extension()
81            .is_some_and(|ext| ext == "xcassets")
82        {
83            walk.skip_current_dir();
84            inventory.catalogs += 1;
85            // The catalog parser may traverse child directories; reject catalogs
86            // containing symlinks before handing them to the parser.
87            let unsafe_tree = WalkDir::new(entry.path())
88                .follow_links(false)
89                .into_iter()
90                .any(|child| child.map_or(true, |child| child.file_type().is_symlink()));
91            if unsafe_tree {
92                inventory.diagnostics.push(format!(
93                    "skipped catalog with symlinks or unreadable entries: {}",
94                    entry.path().display()
95                ));
96                continue;
97            }
98            match xcassets::parse_catalog(entry.path()) {
99                Ok(report) => {
100                    for diagnostic in report.diagnostics {
101                        inventory.diagnostics.push(format!(
102                            "{}: {}",
103                            diagnostic.path.display(),
104                            diagnostic.message
105                        ));
106                    }
107                    let index = xcassets::index_renditions(&report.catalog);
108                    for path in index.unsupported_nodes {
109                        inventory.diagnostics.push(format!(
110                            "unsupported catalog node: {}",
111                            entry.path().join(path).display()
112                        ));
113                    }
114                    for set in index.sets {
115                        visit_set(&set, entry.path(), &mut inventory)?;
116                    }
117                }
118                Err(error) => inventory.diagnostics.push(error.to_string()),
119            }
120        }
121    }
122    // One filename may serve several renditions. Any exclusion wins.
123    let mut unique: BTreeMap<PathBuf, Asset> = BTreeMap::new();
124    for asset in inventory.assets.drain(..) {
125        if !filter.allows(&root.join(&asset.path))
126            || !filter.allows(&root.join(&asset.contents_path))
127        {
128            continue;
129        }
130        match unique.get(&asset.path) {
131            Some(previous) if !previous.eligible => {}
132            _ => {
133                unique.insert(asset.path.clone(), asset);
134            }
135        }
136    }
137    inventory.assets = unique.into_values().collect();
138    inventory.diagnostics.sort();
139    Ok(inventory)
140}
141
142pub(crate) fn excluded(name: &str) -> bool {
143    matches!(
144        name,
145        ".git"
146            | ".worktrees"
147            | ".worktree"
148            | ".build"
149            | ".swiftpm"
150            | ".resopt"
151            | "target"
152            | "build"
153            | "DerivedData"
154            | "Pods"
155            | "Carthage"
156            | "node_modules"
157    )
158}
159
160fn visit_set(set: &RenditionSet<'_>, catalog: &Path, inventory: &mut Inventory) -> Result<()> {
161    let directory = catalog.join(set.relative_path);
162    let contents_path = directory.join("Contents.json");
163    let relative_contents = contents_path.strip_prefix(&inventory.root)?.to_path_buf();
164    let contents_bytes = fs::read(&contents_path)?;
165    ensure!(
166        set.matches_contents(&contents_bytes)?,
167        "catalog changed while scanning: {}",
168        contents_path.display()
169    );
170    let contents_hash = hash(&contents_bytes);
171    let special = if set.kind == RenditionSetKind::AppIcon {
172        Some("app_icon")
173    } else if set.has_resizing {
174        Some("resizing")
175    } else {
176        None
177    };
178    for image in set.images {
179        let Some(filename) = image.filename.as_deref() else {
180            continue;
181        };
182        // Catalog rendition filenames must be a single basename.
183        let filename_path = Path::new(filename);
184        if !xcassets::is_rendition_filename(filename) {
185            inventory.diagnostics.push(format!(
186                "unsafe rendition filename in {}: {filename}",
187                contents_path.display()
188            ));
189            continue;
190        }
191        let path = directory
192            .join(filename)
193            .strip_prefix(&inventory.root)?
194            .to_path_buf();
195        let source = match contained_file(&inventory.root, &path) {
196            Ok(source) => source,
197            Err(error) => {
198                inventory.diagnostics.push(error.to_string());
199                continue;
200            }
201        };
202        let reason = special.or_else(|| {
203            if filename_path
204                .extension()
205                .is_some_and(|ext| ext.eq_ignore_ascii_case("png"))
206            {
207                None
208            } else {
209                Some("unsupported_format")
210            }
211        });
212        inventory.assets.push(Asset {
213            path,
214            bytes: fs::metadata(source)?.len(),
215            eligible: reason.is_none(),
216            reason: reason.map(str::to_string),
217            contents_path: relative_contents.clone(),
218            contents_sha256: contents_hash.clone(),
219        });
220    }
221    Ok(())
222}