Skip to main content

tuff_core/
cache.rs

1use std::path::{Path, PathBuf};
2
3use sha2::{Digest, Sha256};
4
5use crate::error::{Result, TuffError};
6
7pub fn cache_root(home: &Path) -> PathBuf {
8    crate::paths::user_cache(home).join("sha256")
9}
10
11pub fn cache_path(home: &Path, hash: &str) -> Result<PathBuf> {
12    validate_hash(hash)?;
13    Ok(cache_root(home).join(&hash[..2]).join(hash))
14}
15
16pub fn validate_hash(hash: &str) -> Result<()> {
17    if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
18        return Err(TuffError::new(format!("invalid capability hash: {hash}")));
19    }
20    Ok(())
21}
22
23/// Hashes a materialized directory using sorted relative paths and file bytes.
24/// Directory metadata and filesystem iteration order are intentionally ignored.
25pub fn hash_tree(root: &Path) -> Result<String> {
26    let mut files = Vec::new();
27    collect_files(root, root, &mut files)?;
28    files.sort_by(|a, b| a.0.cmp(&b.0));
29
30    let mut hasher = Sha256::new();
31    for (path, content) in files {
32        let path = path.to_string_lossy().replace('\\', "/");
33        hasher.update((path.len() as u64).to_be_bytes());
34        hasher.update(path.as_bytes());
35        hasher.update((content.len() as u64).to_be_bytes());
36        hasher.update(content);
37    }
38    Ok(format!("{:x}", hasher.finalize()))
39}
40
41pub fn populate(home: &Path, hash: &str, source: &Path) -> Result<PathBuf> {
42    let destination = cache_path(home, hash)?;
43    let actual = hash_tree(source)?;
44    if actual != hash {
45        return Err(TuffError::new(format!(
46            "materialized capability hash mismatch: expected {hash}, got {actual}"
47        )));
48    }
49
50    if destination.is_dir() {
51        if hash_tree(&destination)? == hash {
52            return Ok(destination);
53        }
54        if let Err(error) = std::fs::remove_dir_all(&destination) {
55            if error.kind() == std::io::ErrorKind::PermissionDenied {
56                return Ok(source.to_path_buf());
57            }
58            return Err(error.into());
59        }
60    }
61
62    let parent = destination
63        .parent()
64        .ok_or_else(|| TuffError::new("invalid cache destination"))?;
65    if let Err(error) = std::fs::create_dir_all(parent) {
66        if error.kind() == std::io::ErrorKind::PermissionDenied {
67            return Ok(source.to_path_buf());
68        }
69        return Err(error.into());
70    }
71    let temporary = match tempfile::Builder::new()
72        .prefix("tuff-cache-")
73        .tempdir_in(parent)
74    {
75        Ok(temporary) => temporary,
76        Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
77            return Ok(source.to_path_buf());
78        }
79        Err(error) => return Err(error.into()),
80    };
81    if let Err(error) = copy_tree(source, temporary.path()) {
82        if error.to_string().contains("Permission denied") {
83            return Ok(source.to_path_buf());
84        }
85        return Err(error);
86    }
87    let temporary_path = temporary.keep();
88    if let Err(error) = std::fs::rename(&temporary_path, &destination) {
89        if error.kind() == std::io::ErrorKind::PermissionDenied {
90            return Ok(source.to_path_buf());
91        }
92
93        // Another process may have populated the same content-addressed
94        // directory between our existence check and the rename. Reuse its
95        // verified result instead of treating that harmless race as a cache
96        // failure.
97        if destination.is_dir() && hash_tree(&destination).ok().as_deref() == Some(hash) {
98            let _ = std::fs::remove_dir_all(&temporary_path);
99            return Ok(destination);
100        }
101
102        return Err(error.into());
103    }
104    Ok(destination)
105}
106
107pub fn read_verified(home: &Path, hash: &str) -> Result<Option<PathBuf>> {
108    let path = cache_path(home, hash)?;
109    if !path.is_dir() {
110        return Ok(None);
111    }
112    if hash_tree(&path)? != hash {
113        return Ok(None);
114    }
115    Ok(Some(path))
116}
117
118pub fn clear(home: &Path) -> Result<()> {
119    let root = crate::paths::user_cache(home);
120    if root.exists() {
121        std::fs::remove_dir_all(root)?;
122    }
123    Ok(())
124}
125
126fn collect_files(root: &Path, current: &Path, output: &mut Vec<(PathBuf, Vec<u8>)>) -> Result<()> {
127    for entry in std::fs::read_dir(current)? {
128        let entry = entry?;
129        let path = entry.path();
130        if path.is_dir() {
131            collect_files(root, &path, output)?;
132        } else if path.is_file() {
133            let relative = path
134                .strip_prefix(root)
135                .map_err(|error| TuffError::new(error.to_string()))?
136                .to_path_buf();
137            output.push((relative, std::fs::read(path)?));
138        }
139    }
140    Ok(())
141}
142
143fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
144    std::fs::create_dir_all(destination)?;
145    for entry in std::fs::read_dir(source)? {
146        let entry = entry?;
147        let source_path = entry.path();
148        let destination_path = destination.join(entry.file_name());
149        if source_path.is_dir() {
150            copy_tree(&source_path, &destination_path)?;
151        } else if source_path.is_file() {
152            std::fs::copy(source_path, destination_path)?;
153        }
154    }
155    Ok(())
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use tempfile::TempDir;
162
163    #[test]
164    fn hash_tree_is_independent_of_creation_order() {
165        let left = TempDir::new().unwrap();
166        let right = TempDir::new().unwrap();
167        std::fs::create_dir_all(left.path().join("nested")).unwrap();
168        std::fs::create_dir_all(right.path().join("nested")).unwrap();
169        std::fs::write(left.path().join("a"), "a").unwrap();
170        std::fs::write(left.path().join("nested/b"), "b").unwrap();
171        std::fs::write(right.path().join("nested/b"), "b").unwrap();
172        std::fs::write(right.path().join("a"), "a").unwrap();
173        assert_eq!(
174            hash_tree(left.path()).unwrap(),
175            hash_tree(right.path()).unwrap()
176        );
177    }
178
179    #[test]
180    fn cache_round_trip_verifies_content() {
181        let home = TempDir::new().unwrap();
182        let source = TempDir::new().unwrap();
183        std::fs::write(source.path().join("file"), "content").unwrap();
184        let hash = hash_tree(source.path()).unwrap();
185        let path = populate(home.path(), &hash, source.path()).unwrap();
186        assert_eq!(read_verified(home.path(), &hash).unwrap(), Some(path));
187    }
188
189    #[test]
190    fn concurrent_populate_reuses_existing_content_addressed_directory() {
191        let home = TempDir::new().unwrap();
192        let source = TempDir::new().unwrap();
193        std::fs::write(source.path().join("file"), "content").unwrap();
194        let hash = hash_tree(source.path()).unwrap();
195        let home_path = home.path().to_path_buf();
196        let source_path = source.path().to_path_buf();
197
198        let workers = (0..8)
199            .map(|_| {
200                let home_path = home_path.clone();
201                let source_path = source_path.clone();
202                let hash = hash.clone();
203                std::thread::spawn(move || populate(&home_path, &hash, &source_path))
204            })
205            .collect::<Vec<_>>();
206
207        for worker in workers {
208            worker.join().unwrap().unwrap();
209        }
210
211        assert!(read_verified(home.path(), &hash).unwrap().is_some());
212    }
213}