aube_store/cas.rs
1use crate::{Error, Store, StoredFile};
2use decmpfs::Gate;
3use std::cell::RefCell;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::Ordering;
6
7thread_local! {
8 static B3_HASHER: RefCell<blake3::Hasher> = RefCell::new(blake3::Hasher::new());
9}
10
11/// Per-shard mutex array used by the macOS CAS fast path to serialize
12/// concurrent writers within a single process. Indexed by the first
13/// byte of the file's BLAKE3 hash (matching the on-disk 2-char shard
14/// layout), so two threads writing the same hash always collide; threads
15/// writing different hashes typically don't. The array is process-global
16/// rather than per-`Store` because there is at most one active store
17/// per install, and a static avoids carrying 256 mutexes in every cheap
18/// `Store::clone()` along the fetch pipeline.
19///
20/// macOS-gated rather than `not(linux)` because the fast-path block
21/// itself uses `OpenOptionsExt::mode`, which only exists on Unix —
22/// Windows would fail to compile under `not(linux)`. Linux already has
23/// `O_TMPFILE + linkat` (atomic-by-construction, faster than either
24/// alternative); Windows keeps the tempfile + persist_noclobber path.
25#[cfg(target_os = "macos")]
26static FAST_PATH_SHARD_LOCKS: [std::sync::Mutex<()>; 256] =
27 [const { std::sync::Mutex::new(()) }; 256];
28
29/// Recursively copy `src` into `dst`. Used only by the one-shot
30/// legacy-index migration fallback when `rename` fails (typically
31/// cross-filesystem). Not a hot path; correctness > speed.
32pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
33 std::fs::create_dir_all(dst)?;
34 for entry in std::fs::read_dir(src)? {
35 let entry = entry?;
36 let file_type = entry.file_type()?;
37 let from = entry.path();
38 let to = dst.join(entry.file_name());
39 if file_type.is_dir() {
40 copy_dir_recursive(&from, &to)?;
41 } else if file_type.is_file() {
42 std::fs::copy(&from, &to)?;
43 }
44 // Symlinks and other types are skipped — the index cache only
45 // ever contains regular JSON files (optionally under a single
46 // level of integrity-shard subdirs).
47 }
48 Ok(())
49}
50
51pub(crate) fn blake3_hex(content: &[u8]) -> String {
52 B3_HASHER.with(|cell| {
53 let mut h = cell.borrow_mut();
54 h.reset();
55 h.update(content);
56 h.finalize().to_hex().to_string()
57 })
58}
59
60pub(crate) fn cas_file_matches_len(path: &Path, expected_len: u64) -> bool {
61 path.metadata()
62 .map(|metadata| metadata.len() == expected_len)
63 .unwrap_or(false)
64}
65
66fn wait_for_cas_file_len(path: &Path, expected_len: u64) {
67 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(50);
68 while !cas_file_matches_len(path, expected_len) && std::time::Instant::now() < deadline {
69 std::thread::sleep(std::time::Duration::from_micros(250));
70 }
71}
72
73/// The opt-in store-compression gate, resolved once from the
74/// environment. Returns `Some(gate)` only when `AUBE_COMPRESS_STORE` is
75/// set; otherwise `None` and the CAS write path is byte-for-byte the
76/// pre-existing one.
77///
78/// The toggle's value selects the gate:
79/// - `AUBE_COMPRESS_STORE=1` (or any non-`size:`/non-`glob:` value) →
80/// the fleet default `**/*.node`, no size floor.
81/// - `AUBE_COMPRESS_STORE="glob:<pat>"` → that glob, no size floor.
82/// - `AUBE_COMPRESS_STORE="size:<pred>"` → `**/*.node` AND a size
83/// predicate (e.g. `size:>= 1MB`).
84/// - `AUBE_COMPRESS_STORE="glob:<pat>;size:<pred>"` → both.
85///
86/// A malformed size predicate disables compression (returns `None`)
87/// rather than silently widening the gate; the addon still lands plain.
88pub(crate) fn store_compression_gate() -> Option<&'static Gate> {
89 static GATE: std::sync::OnceLock<Option<Gate>> = std::sync::OnceLock::new();
90 GATE.get_or_init(|| {
91 let raw = aube_util::env::embedder_env("COMPRESS_STORE")?;
92 parse_compress_store_gate(&raw.to_string_lossy())
93 })
94 .as_ref()
95}
96
97/// Pure parse of an `AUBE_COMPRESS_STORE` value into a `Gate`. Split out
98/// so the directive grammar is unit-testable without the process-global
99/// env `OnceLock`. `None` means "no gate" (compression off): the env var
100/// being *unset* short-circuits in the caller, but a set-but-empty value
101/// (`AUBE_COMPRESS_STORE=`) reaches here and is treated as affirmative
102/// (the default `**/*.node` gate). A malformed size predicate fails closed.
103pub(crate) fn parse_compress_store_gate(spec: &str) -> Option<Gate> {
104 let trimmed = spec.trim();
105 // A bare/affirmative value means "use the fleet default gate".
106 if trimmed.is_empty() || matches!(trimmed, "1" | "true" | "on" | "yes") {
107 return Some(Gate::default());
108 }
109 let mut glob: Option<&str> = None;
110 let mut size: Option<&str> = None;
111 for part in trimmed.split(';') {
112 let part = part.trim();
113 if let Some(rest) = part.strip_prefix("glob:") {
114 glob = Some(rest.trim());
115 } else if let Some(rest) = part.strip_prefix("size:") {
116 size = Some(rest.trim());
117 }
118 }
119 // No recognized directive → treat the value as affirmative.
120 if glob.is_none() && size.is_none() {
121 return Some(Gate::default());
122 }
123 match Gate::new(glob.or(Some(decmpfs::DEFAULT_GLOB)), size) {
124 Ok(gate) => Some(gate),
125 Err(err) => {
126 warn!(
127 "AUBE_COMPRESS_STORE has an invalid size predicate ({err}); \
128 store compression disabled"
129 );
130 None
131 }
132 }
133}
134
135#[cfg(test)]
136mod compress_gate_tests {
137 use super::parse_compress_store_gate;
138
139 #[test]
140 fn affirmative_and_directives_yield_a_gate() {
141 // Bare/affirmative or unrecognized values → the fleet default gate.
142 for spec in ["", "1", "true", "on", "yes", "whatever"] {
143 assert!(
144 parse_compress_store_gate(spec).is_some(),
145 "spec {spec:?} should produce a gate"
146 );
147 }
148 // Explicit glob / size directives parse into a gate.
149 assert!(parse_compress_store_gate("glob:**/*.so").is_some());
150 assert!(parse_compress_store_gate("size:>= 1MB").is_some());
151 assert!(parse_compress_store_gate("glob:**/*.node;size:>= 512KB").is_some());
152 }
153
154 #[test]
155 fn malformed_size_predicate_fails_closed() {
156 // A bad size predicate disables compression (None) rather than
157 // silently widening the gate — the addon still lands plain.
158 assert!(parse_compress_store_gate("size:banana").is_none());
159 }
160}
161
162/// Outcome of `create_cas_file`. `Created` means we wrote the bytes
163/// at the final path; `AlreadyExisted` means another writer (or a
164/// previous import) had already committed bit-identical content. The
165/// distinction lets `import_bytes` skip the post-write length check
166/// on the freshly-created path — the file IS exactly the bytes we
167/// just wrote.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169enum CasWriteOutcome {
170 Created,
171 AlreadyExisted,
172}
173
174impl Store {
175 /// Ensure every two-char shard directory under the CAS root exists.
176 /// CAS files live under `<root>/<ab>/<cdef...>` for 256 possible
177 /// prefixes. Running this once before a batch of `import_bytes`
178 /// calls lets the per-file hot path skip the `mkdirp(parent)` stat
179 /// entirely (the parent is guaranteed to exist). On APFS that
180 /// removes ~7.5k redundant `stat` syscalls per cold install — the
181 /// `mkdirp` inside `xx::file::write` was the #1 stat hotspot in a
182 /// dtrace profile.
183 ///
184 /// Cheap to call repeatedly: each `create_dir_all` is a no-op when
185 /// the directory already exists, but callers should still hoist the
186 /// call out of tight loops.
187 pub fn ensure_shards_exist(&self) -> Result<(), Error> {
188 self.prepare_for_write()?;
189 std::fs::create_dir_all(&self.root).map_err(|e| Error::Io(self.root.clone(), e))?;
190 // Windows Defender and Search both touch every file in the
191 // store on default installs. Setting this attribute makes
192 // them skip. Non-NTFS volumes ignore it harmlessly.
193 aube_util::fs::set_not_content_indexed(&self.root);
194 let mut buf = [0u8; 2];
195 for hi in 0u8..16 {
196 for lo in 0u8..16 {
197 buf[0] = hex_digit(hi);
198 buf[1] = hex_digit(lo);
199 // SAFETY: every byte in `buf` comes from `hex_digit`,
200 // which only emits `0-9` / `a-f` — always valid UTF-8.
201 let shard = std::str::from_utf8(&buf).unwrap();
202 let path = self.root.join(shard);
203 std::fs::create_dir_all(&path).map_err(|e| Error::Io(path, e))?;
204 }
205 }
206 Ok(())
207 }
208
209 /// Atomically create `path` without overwriting an existing CAS entry.
210 /// `AlreadyExists` is a no-op here; callers that know the expected content
211 /// length must verify it before trusting a reused path. Non-empty files are
212 /// written through a sibling temp file and persisted with no-clobber
213 /// semantics so an interrupted import cannot leave a torn file at the
214 /// content-addressed path. We intentionally do not fsync every CAS file:
215 /// cold installs import tens of thousands of files, and package-index
216 /// loading rejects missing/truncated entries so they can be fetched again.
217 /// `NotFound` means a concurrent prune or a missed `ensure_shards_exist`
218 /// removed the parent shard; recreate it and retry exactly once before
219 /// surfacing.
220 fn create_cas_file(
221 &self,
222 path: &Path,
223 content: Option<&[u8]>,
224 ) -> Result<CasWriteOutcome, Error> {
225 fn do_create_and_write(
226 this: &Store,
227 path: &Path,
228 content: Option<&[u8]>,
229 ) -> Result<CasWriteOutcome, Error> {
230 if let Some(bytes) = content {
231 // O_TMPFILE creates anon file in parent, linkat
232 // publishes atomically. Skips mkstemp uniqueness probe
233 // and post-write fchmod. Docker overlayfs hits the
234 // EOPNOTSUPP fallback. AUBE_DISABLE_O_TMPFILE for
235 // regression cover.
236 #[cfg(target_os = "linux")]
237 {
238 static O_TMPFILE_DISABLED: std::sync::OnceLock<bool> =
239 std::sync::OnceLock::new();
240 let disabled = *O_TMPFILE_DISABLED.get_or_init(|| {
241 aube_util::env::embedder_env("DISABLE_O_TMPFILE").is_some()
242 });
243 if !disabled {
244 match try_o_tmpfile_publish(path, bytes) {
245 Ok(outcome) => return Ok(outcome),
246 Err(OTmpfileFallback::Unsupported) => {}
247 Err(OTmpfileFallback::Hard(e)) => return Err(e),
248 }
249 }
250 }
251
252 // macOS fast path: direct O_CREAT|O_EXCL at the final
253 // content-addressed path, no tempfile dance. Caller (the
254 // install command) flips `fast_path` on only after
255 // acquiring an exclusive store-level lock against other
256 // aube processes. We additionally serialize writers
257 // *within* this process per shard: two threads importing
258 // the same hash (a CAS-dedupe across packages, 35% of
259 // files on dep-heavy graphs like MUI/CodeMirror) would
260 // otherwise both attempt create_new — the loser sees an
261 // EEXIST against the winner's still-empty fd and the
262 // caller's size-mismatch recovery in `import_bytes` would
263 // unlink the file out from under the still-writing
264 // winner. The shard mutex sequences the open+write so the
265 // loser only observes the file at its final size.
266 //
267 // Crashed-predecessor recovery (the unlink+rewrite path
268 // that the slow path defers to `import_bytes`) runs here
269 // while the mutex is still held, so the caller's recovery
270 // can safely no-op for fast-path writes.
271 //
272 // On APFS the fast path is ~2.25x faster than
273 // tempfile+chmod+persist (~64µs/file vs ~145µs/file in
274 // isolation). macOS-gated rather than `not(linux)`
275 // because `OpenOptionsExt::mode` is unix-only — Windows
276 // keeps the tempfile path.
277 #[cfg(target_os = "macos")]
278 if this.fast_path.load(Ordering::Acquire) {
279 use std::io::Write;
280 use std::os::unix::fs::OpenOptionsExt;
281
282 let shard_idx = path
283 .parent()
284 .and_then(|p| p.file_name())
285 .and_then(|s| s.to_str())
286 .and_then(|s| u8::from_str_radix(s, 16).ok())
287 .map(|b| b as usize);
288 // Every path produced by `file_path_from_hex` lives
289 // under a 2-char hex shard, so this is the contract
290 // every fast-path caller satisfies today. The assert
291 // pins the invariant; if a future caller hands in a
292 // non-CAS path, release builds skip the fast path
293 // (falling through to the safe tempfile branch)
294 // rather than do an unsynchronized write that could
295 // race with another thread on the same hash.
296 debug_assert!(
297 shard_idx.is_some(),
298 "fast-path CAS write to path without a valid hex shard parent: {}",
299 path.display()
300 );
301 if let Some(i) = shard_idx {
302 // Mutex poisoning is impossible here — the guard
303 // is dropped at end of scope without us panicking
304 // inside, so we either return cleanly or propagate
305 // an `Err` while still releasing the lock. If a
306 // future caller panics inside, `unwrap_or_else`
307 // recovers the guard anyway.
308 let _shard_guard = FAST_PATH_SHARD_LOCKS[i]
309 .lock()
310 .unwrap_or_else(|p| p.into_inner());
311
312 // `OpenOptionsExt::mode(0o644)` is masked by the
313 // process umask, so a non-default umask (e.g.
314 // 0o077) would give CAS files 0o600. The
315 // tempfile path uses `fchmod`, which ignores
316 // umask. Match it with an explicit
317 // `set_permissions` so the same store can't end
318 // up with mixed-mode files depending on which
319 // path wrote each entry.
320 use std::os::unix::fs::PermissionsExt;
321 let force_mode = std::fs::Permissions::from_mode(0o644);
322 let open_result = std::fs::OpenOptions::new()
323 .mode(0o644)
324 .create_new(true)
325 .write(true)
326 .open(path);
327 match open_result {
328 Ok(mut f) => {
329 f.set_permissions(force_mode.clone())
330 .map_err(|e| Error::Io(path.to_path_buf(), e))?;
331 f.write_all(bytes)
332 .map_err(|e| Error::Io(path.to_path_buf(), e))?;
333 return Ok(CasWriteOutcome::Created);
334 }
335 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
336 // Holding the shard lock, so any in-process
337 // writer for this hash already finished. If
338 // the file size matches, it's a genuine
339 // dedupe. If not, it's a crashed-predecessor
340 // remnant — unlink and rewrite inline.
341 if cas_file_matches_len(path, bytes.len() as u64) {
342 return Ok(CasWriteOutcome::AlreadyExisted);
343 }
344 let _ = xx::file::remove_file(path);
345 match std::fs::OpenOptions::new()
346 .mode(0o644)
347 .create_new(true)
348 .write(true)
349 .open(path)
350 {
351 Ok(mut f) => {
352 f.set_permissions(force_mode)
353 .map_err(|e| Error::Io(path.to_path_buf(), e))?;
354 f.write_all(bytes)
355 .map_err(|e| Error::Io(path.to_path_buf(), e))?;
356 return Ok(CasWriteOutcome::Created);
357 }
358 Err(e) => {
359 return Err(Error::Io(path.to_path_buf(), e));
360 }
361 }
362 }
363 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
364 // Shard dir missing — fall through to the slow
365 // path; the outer wrapper will create_dir_all
366 // and retry once.
367 }
368 Err(e) => return Err(Error::Io(path.to_path_buf(), e)),
369 }
370 }
371 }
372
373 // Tempfile + persist_noclobber gives atomic crash
374 // semantics: a partial write on `tmp` is dropped by
375 // tempfile's Drop impl, so the final path either
376 // contains the complete bytes or doesn't exist. A
377 // direct O_CREAT|O_EXCL write to the final path was
378 // tried (faster path, ~3 syscalls per file) but
379 // raced with concurrent installs in CI where two
380 // processes saw the same partial file in different
381 // orders and clobbered each other's recovery. The
382 // fast-path branch above re-enables it under an
383 // exclusive store lock.
384 let _ = this; // suppress unused warning on Linux
385 let parent = path.parent().ok_or_else(|| {
386 Error::Io(path.to_path_buf(), std::io::ErrorKind::NotFound.into())
387 })?;
388 let mut tmp = tempfile::Builder::new()
389 .prefix(".aube-cas-")
390 .tempfile_in(parent)
391 .map_err(|e| Error::Io(path.to_path_buf(), e))?;
392 use std::io::Write;
393 tmp.write_all(bytes)
394 .map_err(|e| Error::Io(path.to_path_buf(), e))?;
395 #[cfg(unix)]
396 {
397 use std::os::unix::fs::PermissionsExt;
398 tmp.as_file()
399 .set_permissions(std::fs::Permissions::from_mode(0o644))
400 .map_err(|e| Error::Io(path.to_path_buf(), e))?;
401 }
402 return match tmp.persist_noclobber(path) {
403 Ok(_) => Ok(CasWriteOutcome::Created),
404 Err(e) if e.error.kind() == std::io::ErrorKind::AlreadyExists => {
405 Ok(CasWriteOutcome::AlreadyExisted)
406 }
407 Err(e) => Err(Error::Io(path.to_path_buf(), e.error)),
408 };
409 }
410
411 match std::fs::OpenOptions::new()
412 .write(true)
413 .create_new(true)
414 .open(path)
415 {
416 Ok(_) => Ok(CasWriteOutcome::Created),
417 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
418 Ok(CasWriteOutcome::AlreadyExisted)
419 }
420 Err(e) => Err(Error::Io(path.to_path_buf(), e)),
421 }
422 }
423
424 match do_create_and_write(self, path, content) {
425 Ok(outcome) => Ok(outcome),
426 Err(Error::Io(_, ref ioe)) if ioe.kind() == std::io::ErrorKind::NotFound => {
427 // Shard dir missing. `ensure_shards_exist` normally
428 // pre-creates all 256 shards; this only fires when the
429 // caller didn't call it or a concurrent prune wiped
430 // the tree mid-install.
431 if let Some(parent) = path.parent() {
432 std::fs::create_dir_all(parent)
433 .map_err(|e| Error::Io(parent.to_path_buf(), e))?;
434 }
435 do_create_and_write(self, path, content)
436 }
437 Err(e) => Err(e),
438 }
439 }
440
441 /// Publish a tempfile that was written and BLAKE3-hashed while its tar
442 /// entry was decoded. The tempfile lives under the CAS root, so
443 /// `persist_noclobber` is an atomic same-filesystem publish and does not
444 /// copy the entry a second time.
445 pub(crate) fn import_hashed_tempfile(
446 &self,
447 mut temp: tempfile::NamedTempFile,
448 hex_hash: String,
449 len: u64,
450 executable: bool,
451 ) -> Result<StoredFile, Error> {
452 let store_path = self.file_path_from_hex(&hex_hash);
453 let parent = store_path
454 .parent()
455 .ok_or_else(|| Error::Io(store_path.clone(), std::io::ErrorKind::NotFound.into()))?;
456 std::fs::create_dir_all(parent).map_err(|e| Error::Io(parent.to_path_buf(), e))?;
457
458 #[cfg(unix)]
459 {
460 use std::os::unix::fs::PermissionsExt;
461 temp.as_file()
462 .set_permissions(std::fs::Permissions::from_mode(0o644))
463 .map_err(|e| Error::Io(store_path.clone(), e))?;
464 }
465
466 // The macOS byte importer publishes directly into the final path while
467 // holding this same shard lock. Hold it through publication and any
468 // recovery so a streamed writer cannot unlink an in-progress file.
469 #[cfg(target_os = "macos")]
470 let _shard_guard = hex_hash
471 .get(..2)
472 .and_then(|shard| u8::from_str_radix(shard, 16).ok())
473 .map(|shard| {
474 FAST_PATH_SHARD_LOCKS[shard as usize]
475 .lock()
476 .unwrap_or_else(|poisoned| poisoned.into_inner())
477 });
478 #[cfg(target_os = "macos")]
479 let mut recovery_lock = None;
480
481 let mut retried_missing_parent = false;
482 let mut retried_torn_entry = false;
483 let outcome = loop {
484 match temp.persist_noclobber(&store_path) {
485 Ok(_) => break CasWriteOutcome::Created,
486 Err(e) if e.error.kind() == std::io::ErrorKind::NotFound => {
487 temp = e.file;
488 if retried_missing_parent {
489 return Err(Error::Io(store_path.clone(), e.error));
490 }
491 // A concurrent prune may remove the shard after the
492 // initial create. Match the buffered importer by
493 // recreating it and retrying publication once.
494 std::fs::create_dir_all(parent)
495 .map_err(|e| Error::Io(parent.to_path_buf(), e))?;
496 retried_missing_parent = true;
497 }
498 Err(e) if e.error.kind() == std::io::ErrorKind::AlreadyExists => {
499 temp = e.file;
500 if !cas_file_matches_len(&store_path, len) {
501 wait_for_cas_file_len(&store_path, len);
502 }
503 // A slow-path process can observe a partial final file
504 // owned by another process that holds the macOS install
505 // lock and is writing directly. Wait for that process
506 // before deciding the entry is torn. The in-process shard
507 // mutex above separately serializes recovery threads in
508 // this process.
509 #[cfg(target_os = "macos")]
510 if !self.fast_path.load(Ordering::Acquire)
511 && !cas_file_matches_len(&store_path, len)
512 && recovery_lock.is_none()
513 {
514 let lock_dir = self
515 .root
516 .parent()
517 .map(Path::to_path_buf)
518 .unwrap_or_else(|| self.root.clone());
519 std::fs::create_dir_all(&lock_dir)
520 .map_err(|e| Error::Io(lock_dir.clone(), e))?;
521 let lock_path = lock_dir.join(".install.lock");
522 let file = std::fs::OpenOptions::new()
523 .create(true)
524 .truncate(false)
525 .write(true)
526 .open(&lock_path)
527 .map_err(|e| Error::Io(lock_path.clone(), e))?;
528 file.lock().map_err(|e| Error::Io(lock_path.clone(), e))?;
529 recovery_lock = Some(file);
530 }
531 if cas_file_matches_len(&store_path, len) {
532 break CasWriteOutcome::AlreadyExisted;
533 } else if retried_torn_entry {
534 return Err(Error::Io(store_path.clone(), e.error));
535 } else {
536 // Match `import_bytes` recovery for a crashed predecessor:
537 // retain our complete staging file, remove the torn CAS
538 // entry, and retry the no-clobber publish once.
539 let _ = xx::file::remove_file(&store_path);
540 retried_torn_entry = true;
541 }
542 }
543 Err(e) => return Err(Error::Io(store_path.clone(), e.error)),
544 }
545 };
546
547 if aube_util::diag::enabled() {
548 let name = match outcome {
549 CasWriteOutcome::Created => "cas_miss",
550 CasWriteOutcome::AlreadyExisted => "cas_hit",
551 };
552 aube_util::diag::instant_lazy(aube_util::diag::Category::Store, name, || {
553 format!(r#"{{"size":{len}}}"#)
554 });
555 }
556
557 if executable {
558 self.write_exec_marker(&store_path)?;
559 }
560 Ok(StoredFile {
561 hex_hash,
562 store_path,
563 executable,
564 size: Some(len),
565 })
566 }
567
568 /// Import a single file's content into the store. Returns the stored file info.
569 ///
570 /// Hot path on cold installs: callers should invoke
571 /// [`Store::ensure_shards_exist`] once before a batch of imports so
572 /// this function can skip the per-file `mkdirp`. When shards don't
573 /// exist yet, the `create_new` open will fail with `NotFound`; we
574 /// fall back to the slow path for correctness.
575 pub fn import_bytes(&self, content: &[u8], executable: bool) -> Result<StoredFile, Error> {
576 self.prepare_for_write()?;
577 let hash_t0 = std::time::Instant::now();
578 let hex_hash = blake3_hex(content);
579 if aube_util::diag::enabled() {
580 aube_util::diag::event_lazy(
581 aube_util::diag::Category::Store,
582 "blake3_hash",
583 hash_t0.elapsed(),
584 || format!(r#"{{"size":{}}}"#, content.len()),
585 );
586 }
587
588 let store_path = self.file_path_from_hex(&hex_hash);
589 let _diag_write =
590 aube_util::diag::Span::new(aube_util::diag::Category::Store, "import_bytes_write")
591 .with_meta_fn(|| format!(r#"{{"size":{}}}"#, content.len()));
592
593 // Fast path: open-with-create-new combines the existence check
594 // and the open into a single syscall. On a cold CAS this does
595 // one open(O_CREAT|O_EXCL|O_WRONLY) per file and replaces the
596 // previous stat+create pair (~15k redundant stats per cold
597 // install). On a warm CAS, concurrent writers are safe: EEXIST
598 // means another writer already materialized this content (same
599 // hash = same bytes), so we skip and share the entry.
600 //
601 // `Created` means we just wrote the bytes — they are exactly
602 // `content.len()` by construction, no need to re-stat. Only
603 // the `AlreadyExisted` branch can produce a torn file (from a
604 // crashed predecessor) so the length check runs there only.
605 let outcome = self.create_cas_file(&store_path, Some(content))?;
606 // Surface CAS dedup hit/miss to diag so cold vs warm vs partial
607 // installs can be classified post-hoc. `cas_hit` fires when an
608 // identical-content file already lived in the store; `cas_miss`
609 // fires when we just wrote new bytes.
610 if aube_util::diag::enabled() {
611 let name = match outcome {
612 CasWriteOutcome::Created => "cas_miss",
613 CasWriteOutcome::AlreadyExisted => "cas_hit",
614 };
615 aube_util::diag::instant_lazy(aube_util::diag::Category::Store, name, || {
616 format!(r#"{{"size":{}}}"#, content.len())
617 });
618 }
619 // The macOS fast path verifies the file size inline under its
620 // shard mutex before returning `AlreadyExisted`, so this
621 // recovery only needs to run when we took the tempfile path.
622 // Skipping it there also prevents a race where the recovery
623 // unlinks a file that another in-process thread is concurrently
624 // re-creating after observing the same crashed-predecessor.
625 //
626 // `cfg!(target_os = "macos")` matches the cfg gate on the only
627 // code path that flips `fast_path` to true (and on the inline
628 // recovery inside `create_cas_file`). Without the cfg!, a future
629 // caller setting the flag on Linux would silently disable this
630 // recovery — the Linux O_TMPFILE branch has no inline
631 // length-check substitute, so torn CAS files would be accepted.
632 let fast_path_handled_recovery =
633 cfg!(target_os = "macos") && self.fast_path.load(Ordering::Acquire);
634 if outcome == CasWriteOutcome::AlreadyExisted && !fast_path_handled_recovery {
635 // A length mismatch from this branch can mean either
636 // (a) a crashed predecessor left a torn file (the recovery
637 // case this code was originally written for), or
638 // (b) on macOS, another *process* is currently writing to
639 // the same path via the fast path (no atomic publish
640 // at the final path, so its in-progress fd is visible
641 // by name to other writers).
642 // Burning the file in case (b) would unlink the active
643 // writer's inode and trigger a cascading recovery race. Wait
644 // briefly (50ms is dozens of typical small-file writes) for
645 // the partial file to settle. If it stays mismatched past
646 // the deadline, treat it as (a) and recover.
647 if !cas_file_matches_len(&store_path, content.len() as u64) {
648 wait_for_cas_file_len(&store_path, content.len() as u64);
649 }
650 if !cas_file_matches_len(&store_path, content.len() as u64) {
651 let _ = xx::file::remove_file(&store_path);
652 self.create_cas_file(&store_path, Some(content))?;
653 if !cas_file_matches_len(&store_path, content.len() as u64) {
654 let actual_len = store_path.metadata().map(|metadata| metadata.len()).ok();
655 return Err(Error::Io(
656 store_path.clone(),
657 std::io::Error::other(format!(
658 "CAS entry has wrong size after import: expected {} bytes, got {}",
659 content.len(),
660 actual_len
661 .map(|len| format!("{len} bytes"))
662 .unwrap_or_else(|| "missing file".to_owned())
663 )),
664 ));
665 }
666 }
667 }
668
669 if executable {
670 // Behavior note: this branch now runs unconditionally when
671 // `executable=true`, including when the content file
672 // already existed (`AlreadyExists` above). Previously the
673 // marker was only written in the fresh-content branch.
674 // The new shape is strictly more correct — if the same
675 // bytes are imported twice, once with `executable=false`
676 // and once with `true`, the marker should exist after the
677 // second call. Auditing the callers of the `-exec` marker:
678 // - `aube-store::import_bytes` (this function, the only
679 // writer).
680 // - `aube-store` tests (assert the marker exists after
681 // an `executable=true` import).
682 // - `aube::commands::store` (`aube store prune`)
683 // uses the marker to skip bumping the "freed bytes"
684 // counter when unlinking exec-marker sidecars.
685 // No code path reads the marker to decide executability —
686 // that's carried in `StoredFile.executable`, threaded
687 // through the `PackageIndex` and the linker. So flipping
688 // a marker-absent-to-present for a shared hash is safe.
689 self.write_exec_marker(&store_path)?;
690 }
691
692 Ok(StoredFile {
693 hex_hash,
694 store_path,
695 executable,
696 size: Some(content.len() as u64),
697 })
698 }
699
700 /// Import a tar entry's content, applying OS-level transparent
701 /// compression to the entries the store-compression gate selects.
702 ///
703 /// When `AUBE_COMPRESS_STORE` is unset this is exactly
704 /// [`Store::import_bytes`] — same CAS key, same write path. When it
705 /// is set and `rel_path` + size match the gate, the entry is first
706 /// unwrapped if it is a napi `--compress` hybrid (so the CAS stores
707 /// the raw `.node`, not the wrapper) and then written into the CAS
708 /// as a transparently-compressed file in ONE pass via
709 /// [`decmpfs::compress_bytes`] — never a write-then-read-back. The
710 /// kernel decompresses on read, so the stored file keeps its logical
711 /// size and exact bytes; `cas_file_matches_len` and the BLAKE3 CAS
712 /// key are computed against that logical content, unchanged.
713 ///
714 /// Fail-soft: `compress_bytes` itself falls back to a plain atomic
715 /// write on an unsupported FS or any backend error, so a matched
716 /// entry always lands. The gate firing only changes how the bytes
717 /// are stored, never whether they are.
718 pub fn import_bytes_gated(
719 &self,
720 rel_path: &str,
721 content: &[u8],
722 executable: bool,
723 ) -> Result<StoredFile, Error> {
724 self.import_bytes_with_gate(rel_path, content, executable, store_compression_gate())
725 }
726
727 /// Gate-injectable core of [`Store::import_bytes_gated`]. `gate` of
728 /// `None` is the byte-identical pre-existing CAS path. Separated from
729 /// the public method so tests can drive the compressed path with an
730 /// explicit gate rather than racing the process-global env toggle.
731 pub(crate) fn import_bytes_with_gate(
732 &self,
733 rel_path: &str,
734 content: &[u8],
735 executable: bool,
736 gate: Option<&Gate>,
737 ) -> Result<StoredFile, Error> {
738 self.prepare_for_write()?;
739 let Some(gate) = gate else {
740 return self.import_bytes(content, executable);
741 };
742
743 // Unwrap a napi `--compress` hybrid to the raw addon before the
744 // gate's size check and the CAS hash — a non-hybrid `.node`
745 // returns `None`, so this is a no-op for ordinary addons and the
746 // bytes (and CAS key) are identical to the ungated path.
747 let unwrapped = decmpfs::addon::unwrap_if_hybrid(content);
748 let stored_bytes: &[u8] = unwrapped.as_deref().unwrap_or(content);
749
750 if !gate.matches(rel_path, stored_bytes.len() as u64) {
751 // Not a gated entry. Store the (possibly unwrapped) bytes
752 // through the normal CAS path so a hybrid still lands as its
753 // raw addon even when too small to compress.
754 return self.import_bytes(stored_bytes, executable);
755 }
756
757 let hash_t0 = std::time::Instant::now();
758 let hex_hash = blake3_hex(stored_bytes);
759 if aube_util::diag::enabled() {
760 aube_util::diag::event_lazy(
761 aube_util::diag::Category::Store,
762 "blake3_hash",
763 hash_t0.elapsed(),
764 || format!(r#"{{"size":{}}}"#, stored_bytes.len()),
765 );
766 }
767 let store_path = self.file_path_from_hex(&hex_hash);
768
769 // If a prior import already committed this content, reuse it —
770 // the file is already stored (compressed or not) and the kernel
771 // reads it back identically. Matches `import_bytes`'s CAS-dedupe.
772 if cas_file_matches_len(&store_path, stored_bytes.len() as u64) {
773 if aube_util::diag::enabled() {
774 aube_util::diag::instant_lazy(aube_util::diag::Category::Store, "cas_hit", || {
775 format!(r#"{{"size":{}}}"#, stored_bytes.len())
776 });
777 }
778 return self.finish_gated(hex_hash, store_path, executable, stored_bytes.len());
779 }
780
781 // Ensure the shard exists; `compress_bytes` writes the final
782 // path directly (its own sibling-temp + rename), so it relies on
783 // the parent dir being present just like the slow CAS path.
784 if let Some(parent) = store_path.parent()
785 && !parent.exists()
786 {
787 std::fs::create_dir_all(parent).map_err(|e| Error::Io(parent.to_path_buf(), e))?;
788 }
789
790 // One-pass compressed write. The gate is honored inside
791 // `compress_bytes` too, but we pass `Gate::any()` because we have
792 // already matched against `rel_path` (a CAS path no longer
793 // carries the package-relative name the glob expects).
794 match decmpfs::compress_bytes(&store_path, stored_bytes, &Gate::any()) {
795 Ok(_) => {}
796 Err(err) => {
797 // A genuine I/O failure from the one-pass writer. Fall
798 // back to the fully-guarded CAS path so the install is
799 // never left without the file.
800 warn!(
801 "decmpfs one-pass write failed for {} ({err}); \
802 falling back to the plain CAS path",
803 store_path.display()
804 );
805 return self.import_bytes(stored_bytes, executable);
806 }
807 }
808
809 // Guard against a torn/short write the same way `import_bytes`
810 // does. decmpfs verifies the kernel read-back equals the bytes
811 // for a compressed Outcome, but a fail-soft plain fallback inside
812 // `compress_bytes` could still race a concurrent writer.
813 if !cas_file_matches_len(&store_path, stored_bytes.len() as u64) {
814 wait_for_cas_file_len(&store_path, stored_bytes.len() as u64);
815 }
816 if !cas_file_matches_len(&store_path, stored_bytes.len() as u64) {
817 let _ = xx::file::remove_file(&store_path);
818 return self.import_bytes(stored_bytes, executable);
819 }
820
821 // New content just landed (compressed, or fail-soft plain) — mirror
822 // `import_bytes`'s `cas_miss` so gated installs classify identically.
823 if aube_util::diag::enabled() {
824 aube_util::diag::instant_lazy(aube_util::diag::Category::Store, "cas_miss", || {
825 format!(r#"{{"size":{}}}"#, stored_bytes.len())
826 });
827 }
828 self.finish_gated(hex_hash, store_path, executable, stored_bytes.len())
829 }
830
831 /// Write the sidecar `<store_path>-exec` marker that records a CAS entry
832 /// as executable. Shared by `import_bytes` and `finish_gated`.
833 fn write_exec_marker(&self, store_path: &Path) -> Result<(), Error> {
834 let exec_marker = PathBuf::from(format!("{}-exec", store_path.display()));
835 self.create_cas_file(&exec_marker, None)?;
836 Ok(())
837 }
838
839 /// Shared tail of `import_bytes_gated`: write the executable marker
840 /// (if any) and build the `StoredFile`. Mirrors the marker handling
841 /// in `import_bytes`.
842 fn finish_gated(
843 &self,
844 hex_hash: String,
845 store_path: PathBuf,
846 executable: bool,
847 len: usize,
848 ) -> Result<StoredFile, Error> {
849 if executable {
850 self.write_exec_marker(&store_path)?;
851 }
852 Ok(StoredFile {
853 hex_hash,
854 store_path,
855 executable,
856 size: Some(len as u64),
857 })
858 }
859}
860
861// Thin wrapper over posix_fallocate(3) which returns the error code
862// directly (does not set errno). Caller decides how to handle the
863// error. Existing call site uses `let _ = ...` to ignore EOPNOTSUPP /
864// ENOSYS / EINVAL on filesystems where pre-allocation is a no-op.
865//
866// `len` is `libc::off_t` because that's the type the underlying glibc
867// signature uses, and it varies per target: `i64` on 64-bit Linux and
868// on 32-bit Linux when the libc bindings opt into _FILE_OFFSET_BITS=64,
869// `i32` on 32-bit Linux otherwise (e.g. Debian/Ubuntu's armhf packaging
870// build env). Taking it directly keeps the call-site cast in one place.
871#[cfg(target_os = "linux")]
872fn posix_fallocate(file: &std::fs::File, len: libc::off_t) -> std::io::Result<()> {
873 use std::os::fd::AsRawFd;
874 if len <= 0 {
875 return Ok(());
876 }
877 // SAFETY: fd is owned by `file` for the duration of the call.
878 let r = unsafe { libc::posix_fallocate(file.as_raw_fd(), 0, len) };
879 if r == 0 {
880 Ok(())
881 } else {
882 Err(std::io::Error::from_raw_os_error(r))
883 }
884}
885
886// Unsupported means kernel/fs lacks O_TMPFILE, caller falls back.
887// Hard is a real I/O error that bubbles up.
888#[cfg(target_os = "linux")]
889enum OTmpfileFallback {
890 Unsupported,
891 Hard(Error),
892}
893
894// Size threshold below which we skip both `posix_fallocate` and
895// `posix_fadvise(DONTNEED)` on the CAS write path. Both are
896// fixed-cost-per-call best-effort advisory syscalls whose benefits
897// (avoid ext4 fragmentation, evict pages) don't apply to small
898// writes — the kernel won't fragment a single-block write, and tiny
899// pages don't meaningfully pressure the cache. samply on a cold
900// 1230-pkg install pinned the two at ~4.4% + ~4.8% of self time
901// before this gate; gating to ≥64KB skips them for >95% of npm
902// tarball entries while preserving the original behavior on the
903// large files (typescript.js, monaco-editor, etc.) where it pays.
904//
905// Overridable via `AUBE_CAS_SMALL_FILE_THRESHOLD` (bytes). Set to 0
906// to restore the always-on behavior; set to a very large number to
907// effectively disable both syscalls.
908#[cfg(target_os = "linux")]
909const CAS_SMALL_FILE_THRESHOLD_DEFAULT: usize = 64 * 1024;
910
911#[cfg(target_os = "linux")]
912fn cas_small_file_threshold() -> usize {
913 static THRESHOLD: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
914 *THRESHOLD.get_or_init(|| {
915 match aube_util::env::embedder_env("CAS_SMALL_FILE_THRESHOLD")
916 .as_deref()
917 .map(|s| s.to_string_lossy().into_owned())
918 {
919 None => CAS_SMALL_FILE_THRESHOLD_DEFAULT,
920 Some(raw) => raw.parse::<usize>().unwrap_or_else(|_| {
921 warn!(
922 "CAS_SMALL_FILE_THRESHOLD={raw:?} is not a non-negative integer; \
923 falling back to default {CAS_SMALL_FILE_THRESHOLD_DEFAULT}"
924 );
925 CAS_SMALL_FILE_THRESHOLD_DEFAULT
926 }),
927 }
928 })
929}
930
931// Open anonymous file in parent dir, write, linkat via /proc/self/fd.
932// Skips the tempfile unique-name probe and explicit fchmod. Falls
933// back via Unsupported on EOPNOTSUPP, ENOENT (no /proc), or EXDEV.
934// AUBE_DISABLE_O_TMPFILE forces the legacy path.
935#[cfg(target_os = "linux")]
936fn try_o_tmpfile_publish(path: &Path, bytes: &[u8]) -> Result<CasWriteOutcome, OTmpfileFallback> {
937 use std::ffi::CString;
938 use std::io::Write;
939 use std::os::fd::FromRawFd;
940 use std::os::unix::ffi::OsStrExt;
941 use std::os::unix::fs::PermissionsExt;
942
943 let parent = path.parent().ok_or(OTmpfileFallback::Hard(Error::Io(
944 path.to_path_buf(),
945 std::io::ErrorKind::NotFound.into(),
946 )))?;
947 let parent_c = CString::new(parent.as_os_str().as_bytes()).map_err(|_| {
948 OTmpfileFallback::Hard(Error::Io(
949 path.to_path_buf(),
950 std::io::Error::new(std::io::ErrorKind::InvalidInput, "parent path has nul"),
951 ))
952 })?;
953 // SAFETY: `parent_c` is valid for the duration of the call.
954 let raw_fd = unsafe {
955 libc::open(
956 parent_c.as_ptr(),
957 libc::O_TMPFILE | libc::O_RDWR | libc::O_CLOEXEC,
958 0o644 as libc::c_uint,
959 )
960 };
961 if raw_fd < 0 {
962 let err = std::io::Error::last_os_error();
963 return match err.raw_os_error() {
964 // Old kernels lack O_TMPFILE. Overlayfs/tmpfs return
965 // EOPNOTSUPP, EISDIR, or EINVAL on some kernels.
966 // ENOTSUP is the same value as EOPNOTSUPP on Linux.
967 Some(libc::EOPNOTSUPP) | Some(libc::EISDIR) | Some(libc::EINVAL) => {
968 Err(OTmpfileFallback::Unsupported)
969 }
970 _ => Err(OTmpfileFallback::Hard(Error::Io(path.to_path_buf(), err))),
971 };
972 }
973 // SAFETY: raw_fd is owned, OwnedFd closes on drop.
974 let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd(raw_fd) };
975 let mut file = std::fs::File::from(owned);
976 let small_threshold = cas_small_file_threshold();
977 let is_large = bytes.len() >= small_threshold;
978 // Best-effort fallocate so the kernel allocates contiguous extents
979 // up front. Skips ext4 fragmentation churn on the next write.
980 // EOPNOTSUPP and ENOSYS are fine, regular write_all handles them.
981 // Skipped below `small_threshold`: fragmentation only matters for
982 // multi-block writes, and most npm tarball entries are well under
983 // that. See `cas_small_file_threshold` for rationale.
984 if is_large {
985 let _ = posix_fallocate(&file, bytes.len() as libc::off_t);
986 }
987 file.write_all(bytes)
988 .map_err(|e| OTmpfileFallback::Hard(Error::Io(path.to_path_buf(), e)))?;
989 file.set_permissions(std::fs::Permissions::from_mode(0o644))
990 .map_err(|e| OTmpfileFallback::Hard(Error::Io(path.to_path_buf(), e)))?;
991 // No sync_data: contradicts the no-fsync CAS policy. Crash window
992 // between write and linkat is acceptable, lockfile + state hash
993 // recovers the missing entry on next install.
994
995 let proc_link = format!("/proc/self/fd/{}", std::os::fd::AsRawFd::as_raw_fd(&file));
996 let proc_c = CString::new(proc_link.as_bytes()).map_err(|_| {
997 OTmpfileFallback::Hard(Error::Io(
998 path.to_path_buf(),
999 std::io::Error::other("fd path has nul"),
1000 ))
1001 })?;
1002 let final_c = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
1003 OTmpfileFallback::Hard(Error::Io(
1004 path.to_path_buf(),
1005 std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has nul"),
1006 ))
1007 })?;
1008 // SAFETY: both CStrings live through the call. AT_SYMLINK_FOLLOW
1009 // resolves the /proc/self/fd magic-link to the anon inode.
1010 let r = unsafe {
1011 libc::linkat(
1012 libc::AT_FDCWD,
1013 proc_c.as_ptr(),
1014 libc::AT_FDCWD,
1015 final_c.as_ptr(),
1016 libc::AT_SYMLINK_FOLLOW,
1017 )
1018 };
1019 if r == 0 {
1020 // CAS bytes are read-once into reflinks/hardlinks. Drop them
1021 // from the page cache so the parallel linker pass over many
1022 // packages doesn't push the working set out. Per-file cost is
1023 // roughly fixed regardless of size, so small files paid a
1024 // disproportionate share — gate on `small_threshold` to match
1025 // the fallocate gate above.
1026 if is_large {
1027 use std::os::fd::AsRawFd;
1028 let fd = file.as_raw_fd();
1029 // SAFETY: fd is still owned by `file` here. POSIX_FADV_DONTNEED
1030 // is advisory, return value is ignored.
1031 unsafe {
1032 libc::posix_fadvise(fd, 0, 0, libc::POSIX_FADV_DONTNEED);
1033 }
1034 }
1035 return Ok(CasWriteOutcome::Created);
1036 }
1037 let err = std::io::Error::last_os_error();
1038 match err.raw_os_error() {
1039 Some(libc::EEXIST) => Ok(CasWriteOutcome::AlreadyExisted),
1040 // No /proc in this sandbox.
1041 Some(libc::ENOENT) => Err(OTmpfileFallback::Unsupported),
1042 // Kernel opens O_TMPFILE but rejects linkat from /proc/self/fd.
1043 // ENOTSUP is same value as EOPNOTSUPP on Linux.
1044 Some(libc::EOPNOTSUPP) | Some(libc::EXDEV) => Err(OTmpfileFallback::Unsupported),
1045 // Seccomp-filtered containers (gVisor, strict k8s pod-security
1046 // profiles) block linkat and return EPERM/EACCES. Fall through
1047 // to the tempfile path instead of aborting the install.
1048 Some(libc::EPERM) | Some(libc::EACCES) => Err(OTmpfileFallback::Unsupported),
1049 _ => Err(OTmpfileFallback::Hard(Error::Io(path.to_path_buf(), err))),
1050 }
1051}
1052
1053/// Map a nibble (0–15) to its lowercase hex ASCII byte. Used by
1054/// `ensure_shards_exist` to build the 256 two-character shard names
1055/// without pulling in `format!`/`hex` per call.
1056fn hex_digit(n: u8) -> u8 {
1057 match n {
1058 0..=9 => b'0' + n,
1059 10..=15 => b'a' + n - 10,
1060 _ => unreachable!(),
1061 }
1062}