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 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
32pub 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 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 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 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}