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