Skip to main content

wows_data_mgr/
cas.rs

1//! Content-addressed storage for deduplicated VFS file storage.
2//!
3//! Files are stored by truncated SHA-256 hash in a git-style fanout directory:
4//! `common/ab/cdef1234567890ab1234`
5//!
6//! Build directories contain symlinks (or copies as fallback) pointing to the
7//! shared CAS objects, avoiding duplication across game versions.
8
9use std::collections::HashSet;
10use std::path::Path;
11use std::path::PathBuf;
12
13use rootcause::prelude::*;
14use sha2::Digest;
15use sha2::Sha256;
16
17/// Number of hex characters to keep from the SHA-256 hash.
18/// 20 hex chars = 80 bits, plenty for content addressing.
19const HASH_LEN: usize = 20;
20
21/// Directory name of the content-addressed store within a dump base. All builds
22/// in a dump base share this one store, deduplicating files across versions.
23pub const CAS_DIR: &str = "common";
24
25/// Legacy name of the content-addressed store, migrated to [`CAS_DIR`].
26pub const LEGACY_CAS_DIR: &str = "vfs_common";
27
28/// Path to the content-addressed store within a dump base.
29pub fn cas_root(output_base: &Path) -> PathBuf {
30    output_base.join(CAS_DIR)
31}
32
33/// Compute a truncated SHA-256 hash of the given data.
34/// Returns a lowercase hex string of `HASH_LEN` characters.
35pub fn hash_bytes(data: &[u8]) -> String {
36    let digest = Sha256::digest(data);
37    let full_hex = format!("{digest:x}");
38    full_hex[..HASH_LEN].to_string()
39}
40
41/// Compute the truncated SHA-256 hash of a file's contents.
42///
43/// Streams the file rather than reading it whole: content objects run to
44/// hundreds of megabytes, and auditing a store means doing this thousands of
45/// times.
46pub fn hash_file(path: &Path) -> std::io::Result<String> {
47    use std::io::Read;
48
49    let mut file = std::fs::File::open(path)?;
50    let mut hasher = Sha256::new();
51    let mut buffer = vec![0u8; 64 * 1024];
52    loop {
53        let read = file.read(&mut buffer)?;
54        if read == 0 {
55            break;
56        }
57        hasher.update(&buffer[..read]);
58    }
59    let full_hex = format!("{:x}", hasher.finalize());
60    Ok(full_hex[..HASH_LEN].to_string())
61}
62
63/// Returns the path within the CAS root for a given hash.
64/// Uses the first 2 hex characters as a fanout directory.
65/// e.g. `cas_root/ab/cdef1234567890ab1234`
66pub fn cas_path(cas_root: &Path, hash: &str) -> PathBuf {
67    cas_root.join(&hash[..2]).join(&hash[2..])
68}
69
70/// Whether a content object with the given hash is already stored.
71pub fn object_exists(cas_root: &Path, hash: &str) -> bool {
72    cas_path(cas_root, hash).exists()
73}
74
75/// Store data into the CAS. Returns the hash.
76///
77/// Idempotent, and safe to call concurrently for the same content from several
78/// threads or processes. Builds share most of their objects, so two downloads
79/// running at once routinely reach this for the same hash; writing straight to
80/// the final path would let both write it at once and leave an interleaved file
81/// that [`object_exists`] then reports as present forever. Instead the bytes go
82/// to a uniquely-named temporary in the same directory and are renamed onto the
83/// final path, which is atomic within a directory on Windows and Unix alike: a
84/// concurrent reader sees either no file or a complete one.
85pub fn store(cas_root: &Path, data: &[u8]) -> Result<String, rootcause::Report> {
86    let hash = hash_bytes(data);
87    let path = cas_path(cas_root, &hash);
88    if path.exists() {
89        return Ok(hash);
90    }
91    let Some(parent) = path.parent() else {
92        bail!("CAS object path {} has no parent directory", path.display());
93    };
94    std::fs::create_dir_all(parent).attach_with(|| format!("Failed to create CAS directory {}", parent.display()))?;
95
96    let temp_path = parent.join(temp_name(&hash));
97    std::fs::write(&temp_path, data).attach_with(|| format!("Failed to write CAS object {}", temp_path.display()))?;
98
99    // Losing the rename race is success: the destination is named after the
100    // content hash, so whatever got there first holds these exact bytes.
101    match std::fs::rename(&temp_path, &path) {
102        Ok(()) => Ok(hash),
103        Err(_) if path.exists() => {
104            let _ = std::fs::remove_file(&temp_path);
105            Ok(hash)
106        }
107        Err(e) => {
108            let _ = std::fs::remove_file(&temp_path);
109            Err(e).attach_with(|| format!("Failed to store CAS object {}", path.display()))?
110        }
111    }
112}
113
114/// Name for a partially-written object, unique per writer so two threads or
115/// processes storing the same content never share a temporary.
116fn temp_name(hash: &str) -> String {
117    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
118    let ticket = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
119    format!(".{}.{}.{}.tmp", &hash[2..], std::process::id(), ticket)
120}
121
122/// Create a symlink from `link_path` pointing to the CAS object.
123///
124/// Uses a relative symlink so that the archive is relocatable and works
125/// when created from WSL but consumed on Windows (or vice versa).
126/// Returns an error if the symlink cannot be created.
127pub fn link_file(cas_root: &Path, hash: &str, link_path: &Path) -> Result<(), rootcause::Report> {
128    let target = cas_path(cas_root, hash);
129    if let Some(parent) = link_path.parent() {
130        std::fs::create_dir_all(parent)
131            .attach_with(|| format!("Failed to create parent directory {}", parent.display()))?;
132    }
133
134    // Compute a relative path from the link's parent directory to the CAS object.
135    // Both paths share the same archive root, so we walk up from the link and back
136    // down to the target.
137    let link_parent = link_path.parent().unwrap_or(Path::new("."));
138    let rel_target = relative_path(link_parent, &target);
139
140    // Replace any existing entry so re-extraction (e.g. completing a build's gui
141    // dir) is idempotent rather than failing on the already-present link.
142    if link_path.symlink_metadata().is_ok() {
143        let _ = std::fs::remove_file(link_path);
144    }
145
146    try_symlink(&rel_target, link_path)
147        .attach_with(|| format!("Failed to create symlink {} -> {}", link_path.display(), rel_target.display()))?;
148    Ok(())
149}
150
151/// Compute a relative path from `from_dir` to `to_path`.
152fn relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
153    // Canonicalize-lite: just use the paths as-is since they share a common root.
154    let from_components: Vec<_> = from_dir.components().collect();
155    let to_components: Vec<_> = to_path.components().collect();
156
157    // Find the common prefix length
158    let common = from_components.iter().zip(to_components.iter()).take_while(|(a, b)| a == b).count();
159
160    // Walk up from `from_dir` for each remaining component
161    let ups = from_components.len() - common;
162    let mut rel = PathBuf::new();
163    for _ in 0..ups {
164        rel.push("..");
165    }
166    // Walk down to `to_path`
167    for comp in &to_components[common..] {
168        rel.push(comp);
169    }
170    rel
171}
172
173/// Try to create a symlink. Returns Ok on success, Err on failure.
174fn try_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
175    #[cfg(target_os = "windows")]
176    {
177        std::os::windows::fs::symlink_file(target, link)
178    }
179    #[cfg(not(target_os = "windows"))]
180    {
181        std::os::unix::fs::symlink(target, link)
182    }
183}
184
185/// Collect garbage: remove CAS objects not in the `live_hashes` set.
186/// Returns the number of files removed.
187pub fn gc(cas_root: &Path, live_hashes: &HashSet<String>) -> Result<usize, rootcause::Report> {
188    let mut removed = 0;
189    if !cas_root.exists() {
190        return Ok(0);
191    }
192
193    // Walk fanout directories (2-char hex prefixes)
194    for fanout_entry in std::fs::read_dir(cas_root)?.flatten() {
195        if !fanout_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
196            continue;
197        }
198        let prefix = fanout_entry.file_name();
199        let prefix_str = prefix.to_string_lossy();
200
201        for file_entry in std::fs::read_dir(fanout_entry.path())?.flatten() {
202            if !file_entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
203                continue;
204            }
205            let suffix = file_entry.file_name();
206            let hash = format!("{}{}", prefix_str, suffix.to_string_lossy());
207
208            if !live_hashes.contains(&hash) {
209                if let Err(e) = std::fs::remove_file(file_entry.path()) {
210                    tracing::warn!("Failed to remove CAS object {}: {e}", file_entry.path().display());
211                } else {
212                    removed += 1;
213                }
214            }
215        }
216
217        // Clean up empty fanout directory
218        if std::fs::read_dir(fanout_entry.path()).map(|mut d| d.next().is_none()).unwrap_or(false) {
219            let _ = std::fs::remove_dir(fanout_entry.path());
220        }
221    }
222
223    Ok(removed)
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn hash_is_deterministic_and_truncated() {
232        let hash = hash_bytes(b"hello world");
233        assert_eq!(hash.len(), HASH_LEN);
234        assert_eq!(hash, hash_bytes(b"hello world"));
235        assert_ne!(hash, hash_bytes(b"hello world!"));
236    }
237
238    /// Streaming a file in chunks must agree with hashing its bytes in one go,
239    /// including across a buffer boundary.
240    #[test]
241    fn hashing_a_file_matches_hashing_its_bytes() {
242        let dir = tempfile::tempdir().unwrap();
243        let cas_root = dir.path().join("common");
244
245        let data: Vec<u8> = (0..200_000).map(|i| (i % 251) as u8).collect();
246        let hash = store(&cas_root, &data).unwrap();
247
248        assert_eq!(hash_file(&cas_path(&cas_root, &hash)).unwrap(), hash_bytes(&data));
249    }
250
251    #[test]
252    fn hashing_a_missing_file_reports_not_found() {
253        let dir = tempfile::tempdir().unwrap();
254        let err = hash_file(&dir.path().join("nothing")).unwrap_err();
255        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
256    }
257
258    #[test]
259    fn store_and_retrieve() {
260        let dir = tempfile::tempdir().unwrap();
261        let cas_root = dir.path().join("common");
262
263        let data = b"test file contents";
264        let hash = store(&cas_root, data).unwrap();
265
266        let stored_path = cas_path(&cas_root, &hash);
267        assert!(stored_path.exists());
268        assert_eq!(std::fs::read(&stored_path).unwrap(), data);
269
270        // Idempotent
271        let hash2 = store(&cas_root, data).unwrap();
272        assert_eq!(hash, hash2);
273    }
274
275    /// The hazard is not two writers producing wrong bytes -- they write
276    /// identical content, so interleaving them is invisible -- it is that a
277    /// direct write publishes the object at its final path progressively.
278    /// `object_exists` then reports it present from the first byte, a
279    /// concurrent `store` returns success on its fast path, and every reader
280    /// downstream (the CAS-backed VFS, `validate_cache`, the next build's
281    /// dedup check) can read a file that is not all there.
282    ///
283    /// So a reader is what asserts here, because a reader is what breaks, and
284    /// it samples the published length rather than reading the whole object:
285    /// an 8 MiB read is far too slow to land inside the window it is looking
286    /// for. Sampling the length catches it. Verified to fail against a direct
287    /// write, which tears on the order of ten times per run.
288    #[test]
289    fn an_object_is_never_visible_before_it_is_complete() {
290        use std::sync::atomic::AtomicBool;
291        use std::sync::atomic::AtomicUsize;
292        use std::sync::atomic::Ordering;
293
294        let dir = tempfile::tempdir().unwrap();
295        let cas_root = dir.path().join("common");
296
297        // Large enough that publishing it is not instantaneous, so a reader
298        // spinning alongside genuinely lands inside the write.
299        let data: Vec<u8> = (0..8 * 1024 * 1024).map(|i| (i % 251) as u8).collect();
300        let expected = hash_bytes(&data);
301        let expected_len = data.len() as u64;
302        let path = cas_path(&cas_root, &expected);
303
304        let torn = AtomicUsize::new(0);
305        let complete = AtomicUsize::new(0);
306        let done = AtomicBool::new(false);
307
308        std::thread::scope(|scope| {
309            for _ in 0..3 {
310                let path = path.clone();
311                let (torn, complete, done) = (&torn, &complete, &done);
312                scope.spawn(move || {
313                    loop {
314                        // Read after sampling the flag, so the last pass always
315                        // sees the finished object and `complete` cannot be 0
316                        // just because the writer won the race.
317                        let finished = done.load(Ordering::Acquire);
318                        if let Ok(meta) = std::fs::metadata(&path) {
319                            if meta.len() == expected_len {
320                                complete.fetch_add(1, Ordering::Relaxed);
321                            } else {
322                                torn.fetch_add(1, Ordering::Relaxed);
323                            }
324                        }
325                        if finished {
326                            break;
327                        }
328                    }
329                });
330            }
331
332            // Removed between rounds so the `path.exists()` fast path does not
333            // turn every round after the first into a no-op.
334            for _ in 0..8 {
335                let _ = std::fs::remove_file(&path);
336                store(&cas_root, &data).unwrap();
337            }
338            done.store(true, Ordering::Release);
339        });
340
341        assert!(complete.load(Ordering::Relaxed) > 0, "readers never saw the object at all, so nothing was checked");
342        assert_eq!(torn.load(Ordering::Relaxed), 0, "a reader saw the published object before all of it was there");
343
344        let stored = std::fs::read(&path).unwrap();
345        assert_eq!(hash_bytes(&stored), expected, "the stored object does not hash to its own name");
346
347        // No half-written temporaries left behind, and exactly one object.
348        let fanout = cas_root.join(&expected[..2]);
349        let entries: Vec<_> = std::fs::read_dir(&fanout).unwrap().flatten().map(|e| e.file_name()).collect();
350        assert_eq!(entries.len(), 1, "expected one object in the fanout directory, found {entries:?}");
351    }
352
353    #[test]
354    fn link_creates_readable_file() {
355        let dir = tempfile::tempdir().unwrap();
356        let cas_root = dir.path().join("common");
357
358        let data = b"linked file";
359        let hash = store(&cas_root, data).unwrap();
360
361        let link_path = dir.path().join("build/vfs/some/file.txt");
362        link_file(&cas_root, &hash, &link_path).unwrap();
363
364        assert!(link_path.exists());
365        assert_eq!(std::fs::read(&link_path).unwrap(), data);
366    }
367
368    #[test]
369    fn gc_removes_orphans() {
370        let dir = tempfile::tempdir().unwrap();
371        let cas_root = dir.path().join("common");
372
373        let hash_a = store(&cas_root, b"file a").unwrap();
374        let hash_b = store(&cas_root, b"file b").unwrap();
375
376        let mut live = HashSet::new();
377        live.insert(hash_a.clone());
378
379        let removed = gc(&cas_root, &live).unwrap();
380        assert_eq!(removed, 1);
381
382        assert!(cas_path(&cas_root, &hash_a).exists());
383        assert!(!cas_path(&cas_root, &hash_b).exists());
384    }
385}