1use std::fs::{self, File};
2use std::io::Read as _;
3
4use camino::{Utf8Path, Utf8PathBuf};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest as _, Sha256};
7use thiserror::Error;
8
9const DIGEST_DOMAIN: &[u8] = b"rustferry-artifact-digest-v1\0";
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum ArtifactDigestKind {
14 AndroidApk,
16 IosSimulatorApp,
18 IosPhysicalApp,
20}
21
22impl ArtifactDigestKind {
23 const fn label(self) -> &'static [u8] {
24 match self {
25 Self::AndroidApk => b"android-apk",
26 Self::IosSimulatorApp => b"ios-simulator-app",
27 Self::IosPhysicalApp => b"ios-physical-app",
28 }
29 }
30}
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
34pub struct ArtifactDigest {
35 pub sha256: String,
37 pub entries: u64,
39 pub bytes: u64,
41}
42
43#[derive(Debug, Error)]
45pub enum ArtifactDigestError {
46 #[error("could not {operation} `{path}`: {source}")]
48 Io {
49 operation: &'static str,
51 path: Utf8PathBuf,
53 #[source]
55 source: std::io::Error,
56 },
57 #[error("invalid artifact `{path}`: {message}")]
59 Invalid {
60 path: Utf8PathBuf,
62 message: String,
64 },
65}
66
67pub fn digest_artifact(
77 path: &Utf8Path,
78 kind: ArtifactDigestKind,
79) -> Result<ArtifactDigest, ArtifactDigestError> {
80 let original_metadata = fs::symlink_metadata(path)
81 .map_err(|source| io_error("inspect artifact root", path, source))?;
82 if original_metadata.file_type().is_symlink() {
83 return Err(invalid(path, "artifact root must not be a symbolic link"));
84 }
85 let canonical = path
86 .canonicalize_utf8()
87 .map_err(|source| io_error("resolve artifact root", path, source))?;
88 let metadata = fs::symlink_metadata(&canonical)
89 .map_err(|source| io_error("inspect canonical artifact root", &canonical, source))?;
90
91 let mut hasher = Sha256::new();
92 hasher.update(DIGEST_DOMAIN);
93 hasher.update(kind.label());
94 let mut entries = 0_u64;
95 let mut bytes = 0_u64;
96 if metadata.is_file() {
97 hash_file(
98 &canonical,
99 Utf8Path::new("artifact"),
100 &mut hasher,
101 &mut entries,
102 &mut bytes,
103 )?;
104 } else if metadata.is_dir() {
105 hash_record(
106 &mut hasher,
107 b'R',
108 Utf8Path::new("artifact"),
109 &permission_mode(&metadata).to_be_bytes(),
110 );
111 hash_directory(
112 &canonical,
113 &canonical,
114 &mut hasher,
115 &mut entries,
116 &mut bytes,
117 )?;
118 } else {
119 return Err(invalid(
120 &canonical,
121 "artifact root is not a regular file or directory",
122 ));
123 }
124
125 Ok(ArtifactDigest {
126 sha256: hex::encode(hasher.finalize()),
127 entries,
128 bytes,
129 })
130}
131
132fn hash_directory(
133 root: &Utf8Path,
134 directory: &Utf8Path,
135 hasher: &mut Sha256,
136 entries: &mut u64,
137 bytes: &mut u64,
138) -> Result<(), ArtifactDigestError> {
139 let mut children = fs::read_dir(directory)
140 .map_err(|source| io_error("read artifact directory", directory, source))?
141 .map(|entry| {
142 entry
143 .map_err(|source| io_error("read artifact entry", directory, source))
144 .and_then(|entry| {
145 Utf8PathBuf::from_path_buf(entry.path()).map_err(|path| {
146 invalid(
147 directory,
148 &format!("artifact entry is not UTF-8: {}", path.display()),
149 )
150 })
151 })
152 })
153 .collect::<Result<Vec<_>, _>>()?;
154 children.sort();
155
156 for child in children {
157 let relative = child
158 .strip_prefix(root)
159 .map_err(|_| invalid(&child, "artifact entry escaped its canonical root"))?;
160 let metadata = fs::symlink_metadata(&child)
161 .map_err(|source| io_error("inspect artifact entry", &child, source))?;
162 if metadata.file_type().is_symlink() {
163 let target = fs::read_link(&child)
164 .map_err(|source| io_error("read artifact symlink", &child, source))?;
165 let target = Utf8PathBuf::from_path_buf(target).map_err(|target| {
166 invalid(
167 &child,
168 &format!("artifact symlink target is not UTF-8: {}", target.display()),
169 )
170 })?;
171 let resolved = child
172 .parent()
173 .unwrap_or(root)
174 .join(&target)
175 .canonicalize_utf8()
176 .map_err(|source| io_error("resolve artifact symlink", &child, source))?;
177 if !resolved.starts_with(root) {
178 return Err(invalid(&child, "artifact symlink escapes its bundle root"));
179 }
180 hash_record(hasher, b'L', relative, target.as_str().as_bytes());
181 *entries = entries.saturating_add(1);
182 } else if metadata.is_dir() {
183 hash_record(
184 hasher,
185 b'D',
186 relative,
187 &permission_mode(&metadata).to_be_bytes(),
188 );
189 *entries = entries.saturating_add(1);
190 hash_directory(root, &child, hasher, entries, bytes)?;
191 } else if metadata.is_file() {
192 hash_file(&child, relative, hasher, entries, bytes)?;
193 } else {
194 return Err(invalid(
195 &child,
196 "artifact contains a non-file, non-directory filesystem entry",
197 ));
198 }
199 }
200 Ok(())
201}
202
203fn hash_file(
204 path: &Utf8Path,
205 relative: &Utf8Path,
206 hasher: &mut Sha256,
207 entries: &mut u64,
208 bytes: &mut u64,
209) -> Result<(), ArtifactDigestError> {
210 let mut file = File::open(path)
211 .map_err(|source| io_error("open artifact file for hashing", path, source))?;
212 let initial_metadata = file
213 .metadata()
214 .map_err(|source| io_error("inspect opened artifact file", path, source))?;
215 let length = initial_metadata.len();
216 let mode = permission_mode(&initial_metadata);
217 let mut attributes = [0_u8; 12];
218 attributes[..4].copy_from_slice(&mode.to_be_bytes());
219 attributes[4..].copy_from_slice(&length.to_be_bytes());
220 hash_record(hasher, b'F', relative, &attributes);
221
222 let mut observed = 0_u64;
223 let mut buffer = vec![0_u8; 64 * 1024];
224 loop {
225 let read = file
226 .read(&mut buffer)
227 .map_err(|source| io_error("hash artifact file", path, source))?;
228 if read == 0 {
229 break;
230 }
231 observed = observed.saturating_add(u64::try_from(read).unwrap_or(u64::MAX));
232 hasher.update(&buffer[..read]);
233 }
234 let final_metadata = file
235 .metadata()
236 .map_err(|source| io_error("reinspect opened artifact file", path, source))?;
237 if observed != length
238 || final_metadata.len() != length
239 || permission_mode(&final_metadata) != mode
240 {
241 return Err(invalid(
242 path,
243 "artifact file changed length or permissions while it was hashed",
244 ));
245 }
246 *entries = entries.saturating_add(1);
247 *bytes = bytes.saturating_add(length);
248 Ok(())
249}
250
251#[cfg(unix)]
252fn permission_mode(metadata: &fs::Metadata) -> u32 {
253 use std::os::unix::fs::MetadataExt as _;
254
255 metadata.mode() & 0o7777
256}
257
258#[cfg(not(unix))]
259const fn permission_mode(_metadata: &fs::Metadata) -> u32 {
260 0
261}
262
263fn hash_record(hasher: &mut Sha256, kind: u8, path: &Utf8Path, value: &[u8]) {
264 hasher.update([kind]);
265 hasher.update(
266 u64::try_from(path.as_str().len())
267 .unwrap_or(u64::MAX)
268 .to_be_bytes(),
269 );
270 hasher.update(path.as_str().as_bytes());
271 hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
272 hasher.update(value);
273}
274
275fn io_error(
276 operation: &'static str,
277 path: &Utf8Path,
278 source: std::io::Error,
279) -> ArtifactDigestError {
280 ArtifactDigestError::Io {
281 operation,
282 path: path.to_owned(),
283 source,
284 }
285}
286
287fn invalid(path: &Utf8Path, message: &str) -> ArtifactDigestError {
288 ArtifactDigestError::Invalid {
289 path: path.to_owned(),
290 message: message.to_owned(),
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn digest_changes_with_file_and_tree_content() {
300 let directory = tempfile::tempdir().expect("temporary directory");
301 let root =
302 Utf8PathBuf::from_path_buf(directory.path().join("Example.app")).expect("UTF-8 path");
303 fs::create_dir_all(root.join("Resources")).expect("create bundle");
304 fs::write(root.join("Info.plist"), b"plist").expect("write plist");
305 fs::write(root.join("Resources/icon.png"), b"icon-a").expect("write icon");
306 let original =
307 digest_artifact(&root, ArtifactDigestKind::IosSimulatorApp).expect("initial digest");
308 let repeated =
309 digest_artifact(&root, ArtifactDigestKind::IosSimulatorApp).expect("repeated digest");
310 assert_eq!(original, repeated);
311
312 fs::write(root.join("Resources/icon.png"), b"icon-b").expect("replace icon");
313 let changed =
314 digest_artifact(&root, ArtifactDigestKind::IosSimulatorApp).expect("changed digest");
315 assert_ne!(original, changed);
316 }
317
318 #[cfg(unix)]
319 #[test]
320 fn digest_rejects_a_symlinked_root() {
321 use std::os::unix::fs::symlink;
322
323 let directory = tempfile::tempdir().expect("temporary directory");
324 let actual =
325 Utf8PathBuf::from_path_buf(directory.path().join("actual.apk")).expect("UTF-8 path");
326 let linked =
327 Utf8PathBuf::from_path_buf(directory.path().join("linked.apk")).expect("UTF-8 path");
328 fs::write(&actual, b"PK\x03\x04payload").expect("write APK");
329 symlink(&actual, &linked).expect("create symlink");
330
331 assert!(matches!(
332 digest_artifact(&linked, ArtifactDigestKind::AndroidApk),
333 Err(ArtifactDigestError::Invalid { .. })
334 ));
335 }
336
337 #[cfg(unix)]
338 #[test]
339 fn digest_changes_with_executable_permissions() {
340 use std::os::unix::fs::PermissionsExt as _;
341
342 let directory = tempfile::tempdir().expect("temporary directory");
343 let root =
344 Utf8PathBuf::from_path_buf(directory.path().join("Example.app")).expect("UTF-8 path");
345 fs::create_dir_all(&root).expect("create bundle");
346 let executable = root.join("Example");
347 fs::write(&executable, b"Mach-O").expect("write executable");
348 fs::set_permissions(&executable, fs::Permissions::from_mode(0o644))
349 .expect("set non-executable mode");
350 let original =
351 digest_artifact(&root, ArtifactDigestKind::IosSimulatorApp).expect("initial digest");
352
353 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755))
354 .expect("set executable mode");
355 let executable_digest =
356 digest_artifact(&root, ArtifactDigestKind::IosSimulatorApp).expect("changed digest");
357 assert_ne!(original, executable_digest);
358 }
359}