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