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::Node;
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 !filter.allows(entry.path()) {
67            walk.skip_current_dir();
68            continue;
69        }
70        if !entry.file_type().is_dir() {
71            continue;
72        }
73        if entry.depth() > 0 && excluded(entry.file_name().to_str().unwrap_or("")) {
74            walk.skip_current_dir();
75            continue;
76        }
77        if entry
78            .path()
79            .extension()
80            .is_some_and(|ext| ext == "xcassets")
81        {
82            walk.skip_current_dir();
83            inventory.catalogs += 1;
84            // The catalog parser may traverse child directories; reject catalogs
85            // containing symlinks before handing them to the parser.
86            let unsafe_tree = WalkDir::new(entry.path())
87                .follow_links(false)
88                .into_iter()
89                .any(|child| child.map_or(true, |child| child.file_type().is_symlink()));
90            if unsafe_tree {
91                inventory.diagnostics.push(format!(
92                    "skipped catalog with symlinks or unreadable entries: {}",
93                    entry.path().display()
94                ));
95                continue;
96            }
97            match xcassets::parse_catalog(entry.path()) {
98                Ok(report) => {
99                    for diagnostic in report.diagnostics {
100                        inventory.diagnostics.push(format!(
101                            "{}: {}",
102                            diagnostic.path.display(),
103                            diagnostic.message
104                        ));
105                    }
106                    visit(&report.catalog.children, entry.path(), &mut inventory)?;
107                }
108                Err(error) => inventory.diagnostics.push(error.to_string()),
109            }
110        }
111    }
112    // One filename may serve several renditions. Any exclusion wins.
113    let mut unique: BTreeMap<PathBuf, Asset> = BTreeMap::new();
114    for asset in inventory.assets.drain(..) {
115        if !filter.allows(&root.join(&asset.path))
116            || !filter.allows(&root.join(&asset.contents_path))
117        {
118            continue;
119        }
120        match unique.get(&asset.path) {
121            Some(previous) if !previous.eligible => {}
122            _ => {
123                unique.insert(asset.path.clone(), asset);
124            }
125        }
126    }
127    inventory.assets = unique.into_values().collect();
128    inventory.diagnostics.sort();
129    Ok(inventory)
130}
131
132pub(crate) fn excluded(name: &str) -> bool {
133    matches!(
134        name,
135        ".git"
136            | ".worktrees"
137            | ".worktree"
138            | ".build"
139            | ".swiftpm"
140            | ".resopt"
141            | "target"
142            | "build"
143            | "DerivedData"
144            | "Pods"
145            | "Carthage"
146            | "node_modules"
147    )
148}
149
150fn visit(nodes: &[Node], catalog: &Path, inventory: &mut Inventory) -> Result<()> {
151    for node in nodes {
152        match node {
153            Node::Group(group) => visit(&group.children, catalog, inventory)?,
154            Node::ImageSet(set) => {
155                visit_set(&set.contents, &set.relative_path, false, catalog, inventory)?
156            }
157            Node::AppIconSet(set) => {
158                visit_set(&set.contents, &set.relative_path, true, catalog, inventory)?
159            }
160            Node::Opaque(node) => inventory.diagnostics.push(format!(
161                "unsupported catalog node: {}",
162                catalog.join(&node.relative_path).display()
163            )),
164            Node::ColorSet(_) => {}
165        }
166    }
167    Ok(())
168}
169
170fn visit_set<T: Serialize + serde::de::DeserializeOwned + PartialEq>(
171    contents: &Option<T>,
172    relative: &Path,
173    app_icon: bool,
174    catalog: &Path,
175    inventory: &mut Inventory,
176) -> Result<()> {
177    let Some(contents) = contents else {
178        return Ok(());
179    };
180    let raw = serde_json::to_value(contents)?;
181    let Some(images) = raw.get("images").and_then(|value| value.as_array()) else {
182        return Ok(());
183    };
184    let directory = catalog.join(relative);
185    let contents_path = directory.join("Contents.json");
186    let relative_contents = contents_path.strip_prefix(&inventory.root)?.to_path_buf();
187    let contents_bytes = fs::read(&contents_path)?;
188    ensure!(
189        serde_json::from_slice::<T>(&contents_bytes)? == *contents,
190        "catalog changed while scanning: {}",
191        contents_path.display()
192    );
193    let contents_hash = hash(&contents_bytes);
194    let special = if app_icon {
195        Some("app_icon")
196    } else if has_key(&raw, "resizing") {
197        Some("resizing")
198    } else {
199        None
200    };
201    for image in images {
202        let Some(filename) = image.get("filename").and_then(|value| value.as_str()) else {
203            continue;
204        };
205        // Catalog rendition filenames must be a single basename.
206        let filename_path = Path::new(filename);
207        if filename_path.components().count() != 1
208            || !matches!(
209                filename_path.components().next(),
210                Some(std::path::Component::Normal(_))
211            )
212        {
213            inventory.diagnostics.push(format!(
214                "unsafe rendition filename in {}: {filename}",
215                contents_path.display()
216            ));
217            continue;
218        }
219        let path = directory
220            .join(filename)
221            .strip_prefix(&inventory.root)?
222            .to_path_buf();
223        let source = match contained_file(&inventory.root, &path) {
224            Ok(source) => source,
225            Err(error) => {
226                inventory.diagnostics.push(error.to_string());
227                continue;
228            }
229        };
230        let reason = special.or_else(|| {
231            if filename_path
232                .extension()
233                .is_some_and(|ext| ext.eq_ignore_ascii_case("png"))
234            {
235                None
236            } else {
237                Some("unsupported_format")
238            }
239        });
240        inventory.assets.push(Asset {
241            path,
242            bytes: fs::metadata(source)?.len(),
243            eligible: reason.is_none(),
244            reason: reason.map(str::to_string),
245            contents_path: relative_contents.clone(),
246            contents_sha256: contents_hash.clone(),
247        });
248    }
249    Ok(())
250}
251
252fn has_key(value: &serde_json::Value, key: &str) -> bool {
253    match value {
254        serde_json::Value::Object(values) => {
255            values.contains_key(key) || values.values().any(|value| has_key(value, key))
256        }
257        serde_json::Value::Array(values) => values.iter().any(|value| has_key(value, key)),
258        _ => false,
259    }
260}