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