Skip to main content

rustferry_core/
artifact.rs

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/// Artifact family included in the integrity-digest domain.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum ArtifactDigestKind {
14    /// Android application package.
15    AndroidApk,
16    /// Ad-hoc-signed iOS Simulator application bundle.
17    IosSimulatorApp,
18    /// Development-signed physical iOS application bundle.
19    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/// Deterministic SHA-256 identity of one artifact file or directory tree.
33#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
34pub struct ArtifactDigest {
35    /// Lowercase hexadecimal SHA-256 digest.
36    pub sha256: String,
37    /// Number of regular files, directories, and symbolic links represented.
38    pub entries: u64,
39    /// Total bytes across regular files.
40    pub bytes: u64,
41}
42
43/// Failure while producing a deterministic artifact digest.
44#[derive(Debug, Error)]
45pub enum ArtifactDigestError {
46    /// Artifact filesystem access failed.
47    #[error("could not {operation} `{path}`: {source}")]
48    Io {
49        /// Operation being attempted.
50        operation: &'static str,
51        /// Affected artifact path.
52        path: Utf8PathBuf,
53        /// Operating-system failure.
54        #[source]
55        source: std::io::Error,
56    },
57    /// Artifact shape cannot be safely represented by the digest.
58    #[error("invalid artifact `{path}`: {message}")]
59    Invalid {
60        /// Rejected artifact path.
61        path: Utf8PathBuf,
62        /// Exact failed invariant.
63        message: String,
64    },
65}
66
67/// Hash a complete APK or Apple application tree with stable path ordering and framing.
68///
69/// The root and every traversed entry are inspected without following a root symlink. Internal
70/// bundle symlinks are represented by their target text and must resolve within the bundle.
71///
72/// # Errors
73///
74/// Returns an error for missing paths, unsafe symlinks, unsupported filesystem entries,
75/// non-UTF-8 paths, concurrent truncation, or filesystem I/O failures.
76pub 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}