Skip to main content

yapi_core/chest/
hash.rs

1//! The content hash chest locks for each installed entry.
2
3use std::io::Read as _;
4use std::path::{Path, PathBuf};
5
6use aws_lc_rs::digest::{Context, SHA256};
7use yapi_types::time::hex;
8
9use super::{ChestError, invalid, io};
10
11/// The content hash of the package tree at `root`, as `sha256-<hex>`.
12///
13/// It covers every regular file below `root`, leaving out every entry named
14/// `.git` at any depth, a folder or the file a worktree or submodule has, in
15/// the byte order of their `/`-separated relative paths.
16/// Each file adds its path, a NUL, `x` when it is executable (any execute
17/// bit on Unix, never elsewhere) or `-`, then the SHA-256 of its contents.
18/// A file at `root` hashes as a tree of that one file with an empty path.
19/// Fails on any symbolic link, `root` included, and on a path that is not
20/// UTF-8.
21pub fn tree_hash(root: &Path) -> Result<String, ChestError> {
22    let metadata = root.symlink_metadata().map_err(io(root))?;
23    if metadata.is_symlink() {
24        return Err(refused(root));
25    }
26    let mut files = Vec::new();
27    if metadata.is_dir() {
28        collect(root, "", &mut files)?;
29    } else {
30        files.push((String::new(), root.to_path_buf()));
31    }
32    files.sort();
33    let mut context = Context::new(&SHA256);
34    let mut buffer = vec![0; 64 * 1024];
35    for (relative, path) in files {
36        let metadata = path.symlink_metadata().map_err(io(&path))?;
37        context.update(relative.as_bytes());
38        context.update(b"\0");
39        context.update(if executable(&metadata) { b"x" } else { b"-" });
40        // The raw 32-byte digest, not its hex form. The scheme is modelled on
41        // APM's native-bundle hash, but chest does not aim to match APM's
42        // output.
43        let mut file = std::fs::File::open(&path).map_err(io(&path))?;
44        let mut contents = Context::new(&SHA256);
45        loop {
46            match file.read(&mut buffer) {
47                Ok(0) => break,
48                Ok(read) => contents.update(&buffer[..read]),
49                Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
50                Err(err) => return Err(io(&path)(err)),
51            }
52        }
53        context.update(contents.finish().as_ref());
54    }
55    Ok(format!("sha256-{}", hex(context.finish().as_ref())))
56}
57
58fn refused(path: &Path) -> ChestError {
59    invalid(format!(
60        "{}: symbolic links are not allowed in a package",
61        path.display()
62    ))
63}
64
65/// Adds the regular files below `dir`, whose relative path is `prefix`, to
66/// `files` as their relative paths and full paths.
67fn collect(dir: &Path, prefix: &str, files: &mut Vec<(String, PathBuf)>) -> Result<(), ChestError> {
68    for entry in std::fs::read_dir(dir).map_err(io(dir))? {
69        let entry = entry.map_err(io(dir))?;
70        let path = entry.path();
71        let kind = entry.file_type().map_err(io(&path))?;
72        let name = entry
73            .file_name()
74            .into_string()
75            .map_err(|_| invalid(format!("{}: the name is not UTF-8", path.display())))?;
76        // Copies leave `.git` out whatever it is, so the hash does too.
77        if name == ".git" {
78            continue;
79        }
80        let relative = if prefix.is_empty() {
81            name.clone()
82        } else {
83            format!("{prefix}/{name}")
84        };
85        if kind.is_symlink() {
86            return Err(refused(&path));
87        } else if kind.is_dir() {
88            collect(&path, &relative, files)?;
89        } else if kind.is_file() {
90            files.push((relative, path));
91        }
92    }
93    Ok(())
94}
95
96#[cfg(unix)]
97fn executable(metadata: &std::fs::Metadata) -> bool {
98    use std::os::unix::fs::PermissionsExt as _;
99    metadata.permissions().mode() & 0o111 != 0
100}
101
102#[cfg(not(unix))]
103fn executable(_: &std::fs::Metadata) -> bool {
104    false
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    fn scratch(name: &str) -> PathBuf {
112        let dir =
113            std::env::temp_dir().join(format!("yapi-chest-hash-{name}-{}", std::process::id()));
114        let _ = std::fs::remove_dir_all(&dir);
115        std::fs::create_dir_all(&dir).unwrap();
116        dir
117    }
118
119    fn write(root: &Path, path: &str, contents: &str) {
120        let path = root.join(path);
121        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
122        std::fs::write(path, contents).unwrap();
123    }
124
125    #[cfg(unix)]
126    fn chmod(root: &Path, path: &str, mode: u32) {
127        use std::os::unix::fs::PermissionsExt as _;
128        std::fs::set_permissions(root.join(path), std::fs::Permissions::from_mode(mode)).unwrap();
129    }
130
131    #[cfg(unix)]
132    #[test]
133    fn tree_hash_vector() {
134        let root = scratch("vector");
135        write(&root, "a.txt", "hello\n");
136        write(&root, "bin/run", "#!/bin/sh\n");
137        chmod(&root, "a.txt", 0o644);
138        chmod(&root, "bin/run", 0o755);
139        // Computed from the scheme with shell tools alone:
140        // { printf 'a.txt\0-'; printf 'hello\n' | shasum -a 256 | cut -c1-64 | xxd -r -p;
141        //   printf 'bin/run\0x'; printf '#!/bin/sh\n' | shasum -a 256 | cut -c1-64 | xxd -r -p;
142        // } | shasum -a 256
143        let vector = "sha256-8ec032806587aaad014b497c7476c1dd2321f8c3acc4e7b03c45101290abc097";
144        assert_eq!(tree_hash(&root).unwrap(), vector);
145
146        // `.git` folders at any depth are skipped, and so is the `.git` file
147        // of a worktree or submodule.
148        write(&root, ".git/HEAD", "ref: refs/heads/main\n");
149        write(&root, "bin/.git/config", "[core]\n");
150        write(&root, "sub/.git", "gitdir: ../.git/modules/sub\n");
151        assert_eq!(tree_hash(&root).unwrap(), vector);
152        // The execute bit counts, any of the three.
153        chmod(&root, "a.txt", 0o654);
154        assert_ne!(tree_hash(&root).unwrap(), vector);
155        chmod(&root, "a.txt", 0o644);
156        // A symbolic link is refused, wherever it is.
157        std::os::unix::fs::symlink("a.txt", root.join("bin/link")).unwrap();
158        let error = tree_hash(&root).unwrap_err().to_string();
159        assert!(
160            error.ends_with("bin/link: symbolic links are not allowed in a package"),
161            "{error}"
162        );
163        std::fs::remove_file(root.join("bin/link")).unwrap();
164        let link = scratch("vector-link");
165        std::fs::remove_dir(&link).unwrap();
166        std::os::unix::fs::symlink(&root, &link).unwrap();
167        assert!(tree_hash(&link).is_err());
168
169        // A file alone hashes with an empty path:
170        // { printf '\0-'; printf 'hello\n' | shasum -a 256 | cut -c1-64 | xxd -r -p; } | shasum -a 256
171        assert_eq!(
172            tree_hash(&root.join("a.txt")).unwrap(),
173            "sha256-89c31f91aca023cafd6de1c6c60492fe1c63117041231bdf5370629f71254cec"
174        );
175
176        // Paths and contents cannot trade bytes.
177        let (one, two) = (scratch("ab-c"), scratch("a-bc"));
178        write(&one, "ab", "c");
179        write(&two, "a", "bc");
180        assert_ne!(tree_hash(&one).unwrap(), tree_hash(&two).unwrap());
181        for dir in [root, link, one, two] {
182            let _ = std::fs::remove_dir_all(&dir);
183            let _ = std::fs::remove_file(&dir);
184        }
185    }
186}