Skip to main content

submilli_engine/stdlib/fs/
handles.rs

1//! Externref payloads for `submilli:fs` open resources.
2//!
3//! Each payload owns its OS handle plus a clone of `TenantLimits.host_attached_bytes`
4//! (via [`ByteCharge`]); it charges the in-process buffer footprint at creation and
5//! refunds it when wasmtime GC reclaims the externref.
6//!
7//! Readers `close()` eagerly: the OS handle is dropped (freeing the fd immediately) and
8//! the bytes refunded; `Drop` is the safety net for a reader that was never closed.
9//! A [`ChargedFileWriter`]'s explicit `close()` performs the atomic temp→final rename;
10//! its `Drop` only flushes and removes the temp file, because a GC-timed `rename()` would
11//! be non-deterministic.
12
13use std::collections::VecDeque;
14use std::ffi::{OsStr, OsString};
15use std::fs::File;
16use std::io::{BufRead, BufReader, BufWriter, Read, Write};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use std::sync::atomic::{AtomicU64, Ordering};
20
21use cap_std::fs::{Dir, DirEntry, ReadDir};
22
23use crate::runtime::fs::{ContainError, ContentPath, FileIdentity};
24use crate::runtime::limits::{MemoryCapExceeded, TenantLimits};
25use crate::runtime::{DiskQuota, Holder, OpenFileGuard, QuotaCharge, QuotaExceeded};
26
27/// Footprint charged per reader/writer, matching the default `BufReader`/`BufWriter`
28/// capacity. Byte readers add their `chunk_size` working buffer on top.
29const HANDLE_BUF_BYTES: u64 = 8 * 1024;
30
31/// Bytes charged against the store's host-attached counter, refunded on `release`/`drop`.
32pub(super) struct ByteCharge {
33    bytes: u64,
34    counter: Arc<AtomicU64>,
35}
36
37impl ByteCharge {
38    pub(super) fn new(limits: &TenantLimits, bytes: u64) -> Result<Self, MemoryCapExceeded> {
39        limits.charge_host_bytes(bytes)?;
40        Ok(Self {
41            bytes,
42            counter: limits.host_attached_counter(),
43        })
44    }
45
46    pub(super) fn grow(
47        &mut self,
48        limits: &TenantLimits,
49        bytes: u64,
50    ) -> Result<(), MemoryCapExceeded> {
51        limits.charge_host_bytes(bytes)?;
52        self.bytes = self.bytes.saturating_add(bytes);
53        Ok(())
54    }
55
56    /// Refunds the charged bytes once; subsequent calls are no-ops.
57    fn release(&mut self) {
58        let n = std::mem::take(&mut self.bytes);
59        if n == 0 {
60            return;
61        }
62        let _ = self
63            .counter
64            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
65                Some(current.saturating_sub(n))
66            });
67    }
68}
69
70impl Drop for ByteCharge {
71    fn drop(&mut self) {
72        self.release();
73    }
74}
75
76/// An iterator handle with an idempotent eager close — what the shared
77/// `for…of` close path calls on any of the three reader kinds.
78pub trait Closable: Send + Sync {
79    fn close(&mut self);
80}
81
82/// Line iterator over a file. `close()` and `Drop` both free the fd and refund bytes.
83pub struct ChargedLineReader {
84    reader: Option<BufReader<File>>,
85    buf: Vec<u8>,
86    bom_handled: bool,
87    /// OS-read bytes (including buffered read-ahead) and scanned line bytes,
88    /// retained for settlement on errors too.
89    pub(super) bytes_read: u64,
90    pub(super) bytes_scanned: u64,
91    charge: ByteCharge,
92    /// Keeps the file's bytes counted against the size limit while it is open,
93    /// even after its name is removed.
94    held: Option<OpenFileGuard>,
95}
96
97impl ChargedLineReader {
98    pub fn new(
99        reader: BufReader<File>,
100        limits: &TenantLimits,
101        held: Option<OpenFileGuard>,
102    ) -> Result<Self, MemoryCapExceeded> {
103        Ok(Self {
104            reader: Some(reader),
105            buf: Vec::new(),
106            bom_handled: false,
107            bytes_read: 0,
108            bytes_scanned: 0,
109            charge: ByteCharge::new(limits, HANDLE_BUF_BYTES)?,
110            held,
111        })
112    }
113
114    /// Read one bounded line, accounting for the retained buffer and returned
115    /// text before allocation. The byte limit includes BOM and line delimiters.
116    pub fn read_next(
117        &mut self,
118        limits: &TenantLimits,
119        max_bytes: u64,
120    ) -> wasmtime::Result<Option<ChargedLine>> {
121        let Some(reader) = self.reader.as_mut() else {
122            return Ok(None);
123        };
124        self.buf.clear();
125        loop {
126            let needs_read = reader.buffer().is_empty();
127            let available = reader.fill_buf()?;
128            if needs_read {
129                self.bytes_read = self.bytes_read.saturating_add(available.len() as u64);
130            }
131            if available.is_empty() {
132                break;
133            }
134            let newline = available.iter().position(|&byte| byte == b'\n');
135            let count = newline.map_or(available.len(), |index| index + 1);
136            self.bytes_scanned = self.bytes_scanned.saturating_add(count as u64);
137            let length = self.buf.len().checked_add(count).ok_or_else(|| {
138                crate::runtime::host::range_error("fs.lines: line length exceeds host capacity")
139            })?;
140            if length as u64 > max_bytes {
141                return Err(crate::runtime::host::range_error(format!(
142                    "fs.lines: line exceeds maxReadSize of {max_bytes} bytes; read smaller chunks with fs.bytes"
143                )));
144            }
145            if length > self.buf.capacity() {
146                let maximum = usize::try_from(max_bytes).unwrap_or(usize::MAX);
147                let capacity = length
148                    .max(self.buf.capacity().saturating_mul(2))
149                    .min(maximum);
150                self.charge
151                    .grow(limits, (capacity - self.buf.capacity()) as u64)?;
152                self.buf
153                    .try_reserve_exact(capacity - self.buf.len())
154                    .map_err(crate::runtime::host::fatal_host_error)?;
155            }
156            self.buf.extend_from_slice(&available[..count]);
157            reader.consume(count);
158            if newline.is_some() {
159                break;
160            }
161        }
162        if self.buf.is_empty() {
163            return Ok(None);
164        }
165        if self.buf.last() == Some(&b'\n') {
166            self.buf.pop();
167            if self.buf.last() == Some(&b'\r') {
168                self.buf.pop();
169            }
170        }
171        // Every invalid UTF-8 byte can become a three-byte replacement character.
172        let text_charge = ByteCharge::new(limits, (self.buf.len() as u64).saturating_mul(3))?;
173        let mut text = String::new();
174        text.try_reserve_exact(self.buf.len().saturating_mul(3))
175            .map_err(crate::runtime::host::fatal_host_error)?;
176        for part in self.buf.utf8_chunks() {
177            text.push_str(part.valid());
178            if !part.invalid().is_empty() {
179                text.push('\u{FFFD}');
180            }
181        }
182        if !self.bom_handled {
183            self.bom_handled = true;
184            if text.starts_with('\u{FEFF}') {
185                text.drain(..'\u{FEFF}'.len_utf8());
186            }
187        }
188        Ok(Some(ChargedLine {
189            text,
190            _charge: text_charge,
191        }))
192    }
193
194    pub fn close(&mut self) {
195        self.reader = None;
196        self.buf = Vec::new();
197        self.charge.release();
198        self.held = None;
199    }
200}
201
202/// Keeps returned native text charged until the caller finishes marshalling it.
203pub struct ChargedLine {
204    pub text: String,
205    _charge: ByteCharge,
206}
207
208impl Closable for Option<ChargedLineReader> {
209    fn close(&mut self) {
210        if let Some(mut reader) = self.take() {
211            reader.close();
212        }
213    }
214}
215
216/// Fixed-size chunk iterator over a file.
217pub struct ChargedByteReader {
218    file: Option<File>,
219    chunk_size: usize,
220    charge: ByteCharge,
221    /// Keeps the file's bytes counted against the size limit while it is open,
222    /// even after its name is removed.
223    held: Option<OpenFileGuard>,
224}
225
226impl ChargedByteReader {
227    pub fn new(
228        file: File,
229        chunk_size: usize,
230        limits: &TenantLimits,
231        held: Option<OpenFileGuard>,
232    ) -> Result<Self, MemoryCapExceeded> {
233        let bytes = HANDLE_BUF_BYTES.saturating_add(chunk_size as u64);
234        Ok(Self {
235            file: Some(file),
236            chunk_size,
237            charge: ByteCharge::new(limits, bytes)?,
238            held,
239        })
240    }
241
242    /// Next chunk of up to `chunk_size` bytes; the final chunk may be short.
243    /// `Ok(None)` at EOF or once closed.
244    pub fn read_next(&mut self) -> std::io::Result<Option<Vec<u8>>> {
245        let Some(file) = self.file.as_mut() else {
246            return Ok(None);
247        };
248        let mut buf = vec![0u8; self.chunk_size];
249        let mut filled = 0;
250        while filled < buf.len() {
251            let n = file.read(&mut buf[filled..])?;
252            if n == 0 {
253                break;
254            }
255            filled += n;
256        }
257        if filled == 0 {
258            Ok(None)
259        } else {
260            buf.truncate(filled);
261            Ok(Some(buf))
262        }
263    }
264
265    pub fn close(&mut self) {
266        self.file = None;
267        self.charge.release();
268        self.held = None;
269    }
270}
271
272/// One directory entry as the guest sees it. Guest-relative path included, because the
273/// walk knows it — deriving it afterwards by stripping a host prefix is what leaked host
274/// paths into responses.
275pub struct WalkEntry {
276    pub name: String,
277    pub guest_path: String,
278    pub kind: &'static str,
279    pub size: u64,
280}
281
282/// Concurrently open directory handles per walk. A deep tree times the number of list
283/// handles a guest holds open consumes process-wide descriptors that the tenant limiter
284/// does not account for, since it charges a byte budget only. Subdirectories found past
285/// the cap are postponed and reopened from the base handle once a handle frees up.
286///
287/// Higher than `walkdir`'s `max_open` default of 10: `walkdir` pays only extra syscalls
288/// when it hits the cap (it closes the *shallowest* level and reopens it by path,
289/// preserving depth-first order), whereas postponing costs ordering as well — a postponed
290/// subtree lands after the rest of its parent's entries. Real checkouts (nested
291/// `node_modules`, deep monorepos) routinely pass 10 levels and rarely pass 32.
292///
293/// The `list` doc in `declaration.rs` states this depth as the guest-facing ordering
294/// contract — change both together.
295const MAX_OPEN_DIRS: usize = 32;
296
297/// Postponed subdirectory names a single walk will hold at once. Only levels past
298/// [`MAX_OPEN_DIRS`] postpone at all, so reaching this needs a directory 32 deep with a
299/// fan-out in the thousands; past it a subdirectory is skipped rather than remembered,
300/// which is the same best-effort outcome an unreadable one already gets. Without a
301/// ceiling the queue grows with the width of a directory the guest did not have to pay
302/// for in the byte cap.
303const MAX_DEFERRED: usize = 16_384;
304
305struct Level {
306    /// `None` once the directory is fully read: the handle is released at that point,
307    /// while the frame stays on the stack until [`Level::deferred`] drains.
308    iter: Option<ReadDir>,
309    /// Path of this level's directory relative to the listed directory — empty at the top.
310    /// Base-relative rather than guest-absolute so a postponed level can be reopened
311    /// against the base handle, and a `PathBuf` rather than a `String` so a name the
312    /// platform allows but UTF-8 does not still reopens — the lossy [`Level::prefix`]
313    /// would have replaced it with U+FFFD and lost the subtree.
314    path: PathBuf,
315    /// Lossy rendering of [`Level::path`] with guest `/` separators: `""` at the top,
316    /// `"sub/"` one down. Guest paths are built from this.
317    prefix: String,
318    /// Names of subdirectories of this level whose descent was postponed — because the
319    /// open-handle cap was reached, or because opening them from this level's handle
320    /// failed (most likely descriptor exhaustion, which is exactly what the cap manages).
321    /// Drained in order once this level is fully read, each rejoined to [`Level::path`]
322    /// and reopened from the base handle.
323    ///
324    /// Bare names, not paths: at the depth where postponement starts, the level's own
325    /// path is 32 components long, and storing it again per postponed child would make
326    /// the queue tens of times larger than the entry names it stands for.
327    deferred: VecDeque<OsString>,
328}
329
330impl Level {
331    fn new(path: PathBuf, iter: ReadDir) -> Self {
332        let mut prefix = String::new();
333        for component in &path {
334            prefix.push_str(&component.to_string_lossy());
335            prefix.push('/');
336        }
337        Self {
338            iter: Some(iter),
339            path,
340            prefix,
341            deferred: VecDeque::new(),
342        }
343    }
344}
345
346/// Directory walker (flat or recursive), rooted at a contained handle.
347///
348/// Lazy, pre-order, and non-following — matching `walkdir`'s default, so a checkout's
349/// internal links surface as entries rather than being descended and double-walked.
350/// Per-entry errors skip and continue rather than trapping the whole iteration: a
351/// recursive walk over a real checkout is the flagship use case, and one unreadable
352/// subtree should not end it.
353///
354/// Ordering: every directory is yielded before its descendants, and every subtree is
355/// yielded as one contiguous run — nothing outside a directory's subtree is interleaved
356/// into it. Strictly depth-first down to [`MAX_OPEN_DIRS`] levels of nesting; past that,
357/// a subdirectory's own contents follow the rest of its parent's entries instead of
358/// coming immediately after it, because the descent has to wait for a directory handle.
359pub struct ContainedWalk {
360    base: Arc<Dir>,
361    /// Mount points below the base, relative to it, with the volume handle a
362    /// descent opens in place of the empty directory the root holds there.
363    mounts: Vec<(PathBuf, Arc<Dir>)>,
364    /// Guest-relative path of the listed directory, `""` for the root, else `"tree/"`.
365    base_prefix: String,
366    stack: Vec<Level>,
367    /// Levels on `stack` still holding a `ReadDir`; the frames that have released theirs
368    /// stay on the stack while their postponed subdirectories drain.
369    open_dirs: usize,
370    /// Postponed names held across every level, against [`MAX_DEFERRED`].
371    deferred: usize,
372    recursive: bool,
373}
374
375impl ContainedWalk {
376    /// `base_prefix` is the guest-relative path of the listed directory, `""` for the root.
377    /// `mounts` are the mount points below it; see [`ContentPath::mounts_below`].
378    pub fn new(
379        base: Arc<Dir>,
380        base_prefix: String,
381        recursive: bool,
382        mounts: Vec<(PathBuf, Arc<Dir>)>,
383    ) -> std::io::Result<Self> {
384        let iter = base.entries()?;
385        Ok(Self {
386            base,
387            mounts,
388            base_prefix,
389            stack: vec![Level::new(PathBuf::new(), iter)],
390            open_dirs: 1,
391            deferred: 0,
392            recursive,
393        })
394    }
395
396    fn next_entry(&mut self) -> Option<WalkEntry> {
397        loop {
398            let level = self.stack.last_mut()?;
399            match level.iter.as_mut().map(Iterator::next) {
400                Some(Some(Ok(entry))) => {
401                    if let Some(found) = self.visit(entry) {
402                        return Some(found);
403                    }
404                }
405                Some(Some(Err(_))) => {}
406                Some(None) => {
407                    level.iter = None;
408                    self.open_dirs -= 1;
409                }
410                None => self.resume_deferred(),
411            }
412        }
413    }
414
415    /// Yields `entry`, descending into it first when it is a directory a recursive walk
416    /// should enter. `None` for an entry whose type cannot be determined at all.
417    fn visit(&mut self, entry: DirEntry) -> Option<WalkEntry> {
418        let raw_name = entry.file_name();
419        // `file_type` can fail where the directory entry carries no type; the entry's
420        // own metadata is the fallback before giving up on it.
421        let ft = entry
422            .file_type()
423            .ok()
424            .or_else(|| entry.metadata().ok().map(|m| m.file_type()))?;
425        let size = if ft.is_file() {
426            entry.metadata().map_or(0, |m| m.len())
427        } else {
428            0
429        };
430        let level = self.stack.last()?;
431        let name = raw_name.to_string_lossy().into_owned();
432        let guest_path = format!("/{}{}{}", self.base_prefix, level.prefix, name);
433        if self.recursive && ft.is_dir() && !ft.is_symlink() {
434            self.descend(&raw_name, &entry);
435        }
436        Some(WalkEntry {
437            name,
438            guest_path,
439            kind: kind_of(&ft),
440            size,
441        })
442    }
443
444    /// Opens the subdirectory named `name` as the new innermost level, or postpones it on
445    /// the current level — when there is no handle to spare, and equally when the open
446    /// fails, since a failure here is most likely descriptor exhaustion and dropping the
447    /// subtree over that would silently truncate the walk. Past [`MAX_DEFERRED`] it is
448    /// dropped after all, the walk having run out of room to remember it.
449    fn descend(&mut self, name: &OsStr, entry: &DirEntry) {
450        if self.open_dirs < MAX_OPEN_DIRS
451            && let Some(child) = self.stack.last().map(|level| level.path.join(name))
452            && let Ok(dir) = self.open_child(&child, entry)
453            && let Ok(iter) = dir.entries()
454        {
455            self.stack.push(Level::new(child, iter));
456            self.open_dirs += 1;
457            return;
458        }
459        if self.deferred < MAX_DEFERRED
460            && let Some(level) = self.stack.last_mut()
461        {
462            level.deferred.push_back(name.to_os_string());
463            self.deferred += 1;
464        }
465    }
466
467    /// The innermost level is fully read and has released its handle: descend into the
468    /// next subdirectory it postponed, or pop it once none are left. A postponed directory
469    /// that still won't open is skipped — same best-effort contract as an unreadable one,
470    /// and one retry keeps the walk finite.
471    fn resume_deferred(&mut self) {
472        let Some(level) = self.stack.last_mut() else {
473            return;
474        };
475        let Some(name) = level.deferred.pop_front() else {
476            self.stack.pop();
477            return;
478        };
479        self.deferred -= 1;
480        let path = level.path.join(name);
481        // The level draining its deferrals released its own handle first, so `open_dirs`
482        // is always below the cap here.
483        if let Ok(dir) = self.open_path(&path)
484            && let Ok(iter) = dir.entries()
485        {
486            self.stack.push(Level::new(path, iter));
487            self.open_dirs += 1;
488        }
489    }
490
491    /// The directory `entry` names at `path`, or the mounted volume when `path`
492    /// is a mount point.
493    fn open_child(&self, path: &Path, entry: &DirEntry) -> std::io::Result<Dir> {
494        match self.mounts.iter().find(|(rel, _)| rel == path) {
495            Some((_, volume)) => volume.try_clone(),
496            None => entry.open_dir(),
497        }
498    }
499
500    /// The directory at `path` below the base, through the volume mounted over
501    /// it if any.
502    fn open_path(&self, path: &Path) -> std::io::Result<Dir> {
503        for (rel, volume) in &self.mounts {
504            if let Ok(rest) = path.strip_prefix(rel) {
505                if rest.as_os_str().is_empty() {
506                    return volume.try_clone();
507                }
508                return volume.open_dir(rest);
509            }
510        }
511        self.base.open_dir(path)
512    }
513}
514
515pub fn kind_of(ft: &cap_std::fs::FileType) -> &'static str {
516    if ft.is_symlink() {
517        "symlink"
518    } else if ft.is_dir() {
519        "directory"
520    } else if ft.is_file() {
521        "file"
522    } else {
523        "other"
524    }
525}
526
527/// Directory walker (flat or recursive).
528pub struct ChargedDirIter {
529    walk: Option<ContainedWalk>,
530    charge: ByteCharge,
531}
532
533impl ChargedDirIter {
534    pub fn new(walk: ContainedWalk, limits: &TenantLimits) -> Result<Self, MemoryCapExceeded> {
535        Ok(Self {
536            walk: Some(walk),
537            charge: ByteCharge::new(limits, HANDLE_BUF_BYTES)?,
538        })
539    }
540
541    pub fn next_entry(&mut self) -> Option<WalkEntry> {
542        self.walk.as_mut().and_then(ContainedWalk::next_entry)
543    }
544
545    pub fn close(&mut self) {
546        self.walk = None;
547        self.charge.release();
548    }
549}
550
551/// Buffered writer to a temp sibling file. Explicit [`close`](Self::close) fsyncs and
552/// atomically renames it onto `final_path`; `Drop` (the safety net for an un-`close`d
553/// writer) discards the temp file without renaming, removing it only while its name
554/// still refers to the writer's file.
555pub struct ChargedFileWriter {
556    /// `None` once the writer is closed.
557    open: Option<OpenWrite>,
558    /// Both sides stay contained paths for the whole lifetime of the writer. The commit
559    /// happens through the same handle the open resolved against, so a link swapped over
560    /// the parent between `writer()` and `close()` cannot redirect it — and the drop path
561    /// cannot unlink something under the server's working directory.
562    temp: TempFile,
563    final_path: ContentPath,
564    charge: ByteCharge,
565}
566
567/// The temp file a writer streams into. The program can reach it by name while the
568/// writer is open, so before committing or discarding it, the writer checks that the
569/// name still refers to the file it created.
570pub struct TempFile {
571    path: ContentPath,
572    identity: FileIdentity,
573}
574
575impl TempFile {
576    /// The temp file just created at `path` and opened as `file`.
577    pub fn created(path: ContentPath, file: &cap_std::fs::File) -> std::io::Result<Self> {
578        Ok(Self {
579            path,
580            identity: FileIdentity::of(&file.metadata()?)?,
581        })
582    }
583
584    fn is_intact(&self) -> Result<bool, ContainError> {
585        self.path.refers_to(self.identity)
586    }
587}
588
589/// An open writer's buffer and its claim on the VFS's size limit. Program code runs
590/// between writes, so the claim replaces nothing up front: every byte written counts
591/// until `close` covers the file it replaces.
592struct OpenWrite {
593    writer: BufWriter<File>,
594    disk_charge: QuotaCharge,
595    /// Registers the temp file as the writer's own, so a removal of it by name
596    /// leaves its bytes for the writer to settle.
597    held: Option<OpenFileGuard>,
598}
599
600/// Why a write through a [`ChargedFileWriter`] failed.
601#[derive(Debug)]
602pub enum WriteError {
603    Io(std::io::Error),
604    Contain(ContainError),
605    Full(QuotaExceeded),
606}
607
608impl std::fmt::Display for WriteError {
609    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610        match self {
611            WriteError::Io(e) => e.fmt(f),
612            WriteError::Contain(e) => e.fmt(f),
613            WriteError::Full(e) => e.fmt(f),
614        }
615    }
616}
617
618impl ChargedFileWriter {
619    pub fn new(
620        file: File,
621        temp: TempFile,
622        final_path: ContentPath,
623        limits: &TenantLimits,
624        quota: Option<Arc<DiskQuota>>,
625    ) -> Result<Self, MemoryCapExceeded> {
626        let held = quota
627            .as_ref()
628            .map(|quota| quota.hold(temp.identity, Holder::Writer));
629        Ok(Self {
630            open: Some(OpenWrite {
631                writer: BufWriter::new(file),
632                disk_charge: QuotaCharge::new(quota, None),
633                held,
634            }),
635            temp,
636            final_path,
637            charge: ByteCharge::new(limits, HANDLE_BUF_BYTES)?,
638        })
639    }
640
641    pub fn write_line(&mut self, line: &str) -> Result<(), WriteError> {
642        let open = self.reserve(line.len() as u64 + 1)?;
643        writeln!(open.writer, "{line}").map_err(WriteError::Io)
644    }
645
646    pub fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), WriteError> {
647        let open = self.reserve(bytes.len() as u64)?;
648        open.writer.write_all(bytes).map_err(WriteError::Io)
649    }
650
651    fn reserve(&mut self, bytes: u64) -> Result<&mut OpenWrite, WriteError> {
652        let open = self
653            .open
654            .as_mut()
655            .ok_or_else(|| WriteError::Io(std::io::Error::other("writer already closed")))?;
656        open.disk_charge.reserve(bytes).map_err(WriteError::Full)?;
657        Ok(open)
658    }
659
660    /// Flush, fsync, and atomically rename temp→final. Idempotent: a second call after a
661    /// successful close is a no-op.
662    pub fn close(&mut self) -> Result<(), WriteError> {
663        let Some(open) = self.open.take() else {
664            return Ok(());
665        };
666        let result = finalize(open, &self.temp, &self.final_path);
667        self.charge.release();
668        result
669    }
670}
671
672impl Drop for ChargedFileWriter {
673    fn drop(&mut self) {
674        if let Some(open) = self.open.take() {
675            // Discard the temp file rather than rename it: a GC-timed rename would be
676            // non-deterministic.
677            let unlinked = open
678                .writer
679                .into_inner()
680                .ok()
681                .is_some_and(|file| is_unlinked(&file));
682            discard_temp(&self.temp, open.disk_charge, unlinked);
683            drop(open.held);
684        }
685    }
686}
687
688/// Whether `file` has no name left, so its data goes once every handle on it
689/// closes.
690fn is_unlinked(file: &File) -> bool {
691    cap_std::fs::Metadata::from_file(file)
692        .is_ok_and(|meta| crate::runtime::fs::link_count(&meta) == Some(0))
693}
694
695/// Remove an uncommitted temp file and settle its disk charge. The program can reach
696/// the temp file by name while the writer is open. If the name no longer refers to
697/// the writer's file, it was moved or replaced: nothing is removed, and its bytes
698/// stay counted while they may still be on disk under another name. Once the file
699/// has no name at all, whether removed here or by the program, its bytes go when
700/// the last handle on it closes.
701fn discard_temp(temp: &TempFile, disk_charge: QuotaCharge, unlinked: bool) {
702    let removed = temp.is_intact().unwrap_or(false) && temp.path.remove_file().is_ok();
703    if removed || unlinked {
704        disk_charge.release_when_closed(temp.identity);
705    } else {
706        disk_charge.keep();
707    }
708}
709
710/// Commit the temp file over the final one. Any failure discards the temp file.
711fn finalize(open: OpenWrite, temp: &TempFile, final_path: &ContentPath) -> Result<(), WriteError> {
712    let OpenWrite {
713        writer,
714        mut disk_charge,
715        held,
716    } = open;
717    let committed = match flush_and_sync(writer) {
718        Ok(unlinked) => {
719            let renamed = rename_into_place(temp, final_path, &mut disk_charge);
720            match renamed {
721                Ok(()) => disk_charge.commit(),
722                Err(_) => discard_temp(temp, disk_charge, unlinked),
723            }
724            renamed
725        }
726        Err(err) => {
727            discard_temp(temp, disk_charge, false);
728            Err(err)
729        }
730    };
731    drop(held);
732    committed
733}
734
735/// Flush and sync the writer's file and close it, reporting whether the file has no
736/// name left. Closing before the rename lets Windows handles release; unix `rename`
737/// over an open file works either way.
738fn flush_and_sync(writer: BufWriter<File>) -> Result<bool, WriteError> {
739    let mut file = writer
740        .into_inner()
741        .map_err(|e| WriteError::Io(e.into_error()))?;
742    file.flush().map_err(WriteError::Io)?;
743    file.sync_all().map_err(WriteError::Io)?;
744    Ok(is_unlinked(&file))
745}
746
747/// Rename the temp file over the final one, first covering what it replaces.
748fn rename_into_place(
749    temp: &TempFile,
750    final_path: &ContentPath,
751    disk_charge: &mut QuotaCharge,
752) -> Result<(), WriteError> {
753    if !temp.is_intact().map_err(WriteError::Contain)? {
754        return Err(WriteError::Io(std::io::Error::other(
755            "the writer's temporary file was moved or replaced",
756        )));
757    }
758    disk_charge
759        .cover(final_path.regular_file())
760        .map_err(WriteError::Full)?;
761    temp.path.rename_to(final_path).map_err(WriteError::Contain)
762}
763
764impl Closable for ChargedLineReader {
765    fn close(&mut self) {
766        ChargedLineReader::close(self);
767    }
768}
769
770impl Closable for ChargedByteReader {
771    fn close(&mut self) {
772        ChargedByteReader::close(self);
773    }
774}
775
776impl Closable for ChargedDirIter {
777    fn close(&mut self) {
778        ChargedDirIter::close(self);
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    fn temp_file() -> File {
787        tempfile::tempfile().expect("anonymous temp file")
788    }
789
790    fn line_reader(contents: &[u8], limits: &TenantLimits) -> ChargedLineReader {
791        use std::io::{Seek, SeekFrom};
792        let mut file = temp_file();
793        file.write_all(contents).unwrap();
794        file.seek(SeekFrom::Start(0)).unwrap();
795        ChargedLineReader::new(BufReader::new(file), limits, None).unwrap()
796    }
797
798    #[test]
799    fn line_limit_includes_delimiters_and_bounds_retained_memory() {
800        let limits = TenantLimits::new(1024 * 1024);
801        let mut reader = line_reader(b"abc\nx", &limits);
802        let line = reader.read_next(&limits, 4).unwrap().unwrap();
803        assert_eq!(line.text, "abc");
804        assert_eq!(reader.bytes_read, 5);
805        assert!(limits.host_attached_bytes() >= HANDLE_BUF_BYTES + 4 + 9);
806        drop(line);
807        assert_eq!(reader.read_next(&limits, 4).unwrap().unwrap().text, "x");
808        reader.close();
809        assert_eq!(limits.host_attached_bytes(), 0);
810
811        let mut too_long = line_reader(b"abcde", &limits);
812        let error = too_long.read_next(&limits, 4).err().unwrap();
813        assert!(error.to_string().contains("maxReadSize"));
814        assert_eq!(too_long.buf.capacity(), 0);
815        assert_eq!(too_long.bytes_read, 5);
816    }
817
818    #[test]
819    fn line_buffer_admission_uses_the_current_tenant_memory() {
820        let limits = TenantLimits::new(HANDLE_BUF_BYTES + 4);
821        let mut reader = line_reader(b"abcd", &limits);
822        // The input fits, but its returned String must also be admitted.
823        let error = reader.read_next(&limits, 4).err().unwrap();
824        assert!(error.is::<MemoryCapExceeded>());
825        assert_eq!(reader.bytes_read, 4);
826        reader.close();
827        assert_eq!(limits.host_attached_bytes(), 0);
828    }
829
830    #[test]
831    fn line_decoding_preserves_bom_crlf_and_lossy_utf8_behavior() {
832        let limits = TenantLimits::new(1024 * 1024);
833        let mut reader = line_reader(b"\xef\xbb\xbfhi\r\n\xff\n", &limits);
834        assert_eq!(reader.read_next(&limits, 32).unwrap().unwrap().text, "hi");
835        assert_eq!(
836            reader.read_next(&limits, 32).unwrap().unwrap().text,
837            "\u{fffd}"
838        );
839        assert!(reader.read_next(&limits, 32).unwrap().is_none());
840    }
841
842    #[test]
843    fn drop_releases_bytes_back_to_limits() {
844        let limits = TenantLimits::new(10 * 1024 * 1024);
845        let reader = ChargedLineReader::new(BufReader::new(temp_file()), &limits, None).unwrap();
846        assert!(limits.host_attached_bytes() > 0, "new should charge bytes");
847        drop(reader);
848        assert_eq!(
849            limits.host_attached_bytes(),
850            0,
851            "Drop must refund the charged bytes",
852        );
853    }
854
855    #[test]
856    fn drop_releases_one_of_many_proportionally() {
857        let limits = TenantLimits::new(10 * 1024 * 1024);
858        let r1 = ChargedLineReader::new(BufReader::new(temp_file()), &limits, None).unwrap();
859        let r2 = ChargedByteReader::new(temp_file(), 4096, &limits, None).unwrap();
860        let total = limits.host_attached_bytes();
861        assert_eq!(total, HANDLE_BUF_BYTES + (HANDLE_BUF_BYTES + 4096));
862        drop(r1);
863        assert_eq!(limits.host_attached_bytes(), HANDLE_BUF_BYTES + 4096);
864        drop(r2);
865        assert_eq!(limits.host_attached_bytes(), 0);
866    }
867
868    #[test]
869    fn close_refunds_eagerly_and_is_idempotent() {
870        let limits = TenantLimits::new(10 * 1024 * 1024);
871        let mut reader =
872            ChargedLineReader::new(BufReader::new(temp_file()), &limits, None).unwrap();
873        assert!(limits.host_attached_bytes() > 0);
874        reader.close();
875        assert_eq!(limits.host_attached_bytes(), 0, "close refunds immediately");
876        reader.close();
877        drop(reader);
878        assert_eq!(limits.host_attached_bytes(), 0, "no double refund");
879    }
880
881    /// A writer over a real VFS, since both of its paths are now contained.
882    fn writer_over(
883        vfs: &crate::runtime::Vfs,
884        limits: &TenantLimits,
885    ) -> (ChargedFileWriter, ContentPath, ContentPath) {
886        use crate::runtime::fs::resolve_content;
887        let final_path = resolve_content(vfs, "/", "/out.txt").unwrap();
888        let temp_path = final_path.temp_sibling();
889        let file = temp_path.create().unwrap();
890        let temp = TempFile::created(temp_path.clone(), &file).unwrap();
891        let w = ChargedFileWriter::new(file.into_std(), temp, final_path.clone(), limits, None)
892            .unwrap();
893        (w, temp_path, final_path)
894    }
895
896    fn temp_leftovers(root: &std::path::Path) -> Vec<std::ffi::OsString> {
897        std::fs::read_dir(root)
898            .unwrap()
899            .map(|e| e.unwrap().file_name())
900            .filter(|n| n.to_string_lossy().starts_with("out.txt."))
901            .collect()
902    }
903
904    #[test]
905    fn unclosed_writer_drop_removes_temp_and_skips_rename() {
906        let limits = TenantLimits::new(10 * 1024 * 1024);
907        let dir = tempfile::tempdir().unwrap();
908        let vfs = crate::runtime::Vfs::external(dir.path().to_path_buf()).unwrap();
909        let (mut w, _temp, _final) = writer_over(&vfs, &limits);
910        w.write_line("hello").unwrap();
911        assert!(limits.host_attached_bytes() > 0);
912        drop(w);
913        assert!(
914            temp_leftovers(dir.path()).is_empty(),
915            "temp file removed on drop"
916        );
917        assert!(
918            !dir.path().join("out.txt").exists(),
919            "no rename without explicit close"
920        );
921        assert_eq!(limits.host_attached_bytes(), 0);
922    }
923
924    /// Builds `root/<branch>` as a chain `l1/l2/…/l{depth}` with an `f.txt` in every
925    /// directory. Returns every path the walk should yield, relative to `root`.
926    fn build_chain(root: &std::path::Path, branch: &str, depth: usize) -> Vec<String> {
927        let mut path = root.join(branch);
928        let mut rel = branch.to_string();
929        std::fs::create_dir_all(&path).unwrap();
930        std::fs::write(path.join("f.txt"), "x").unwrap();
931        let mut expected = vec![rel.clone(), format!("{rel}/f.txt")];
932        for level in 1..=depth {
933            path = path.join(format!("l{level}"));
934            rel = format!("{rel}/l{level}");
935            std::fs::create_dir(&path).unwrap();
936            std::fs::write(path.join("f.txt"), "x").unwrap();
937            expected.push(rel.clone());
938            expected.push(format!("{rel}/f.txt"));
939        }
940        expected
941    }
942
943    fn walk_over(root: &std::path::Path, listed: &str, recursive: bool) -> ContainedWalk {
944        let vfs = crate::runtime::Vfs::external(root.to_path_buf()).unwrap();
945        let resolved = crate::runtime::fs::resolve_content(&vfs, "/", listed).unwrap();
946        let base = Arc::new(resolved.open_dir().unwrap());
947        let base_prefix = if listed == "/" {
948            String::new()
949        } else {
950            format!("{}/", listed.trim_start_matches('/'))
951        };
952        ContainedWalk::new(base, base_prefix, recursive, Vec::new()).unwrap()
953    }
954
955    fn walk_all(root: &std::path::Path, listed: &str) -> Vec<WalkEntry> {
956        let mut walk = walk_over(root, listed, true);
957        std::iter::from_fn(|| walk.next_entry()).collect()
958    }
959
960    /// Every subtree is one consecutive run: for each directory, its descendants are
961    /// yielded after it with nothing from outside the subtree interleaved.
962    fn assert_subtrees_contiguous(entries: &[WalkEntry]) {
963        for (i, dir) in entries.iter().enumerate() {
964            if dir.kind != "directory" {
965                continue;
966            }
967            let prefix = format!("{}/", dir.guest_path);
968            let inside: Vec<usize> = entries
969                .iter()
970                .enumerate()
971                .filter(|(_, e)| e.guest_path.starts_with(&prefix))
972                .map(|(j, _)| j)
973                .collect();
974            let (Some(&first), Some(&last)) = (inside.first(), inside.last()) else {
975                continue;
976            };
977            assert!(
978                first > i,
979                "{} yielded before its parent",
980                entries[first].guest_path
981            );
982            assert_eq!(
983                last - first + 1,
984                inside.len(),
985                "entries from outside {} are interleaved into its subtree",
986                dir.guest_path,
987            );
988        }
989    }
990
991    #[test]
992    fn deep_tree_yields_every_entry_exactly_once() {
993        let dir = tempfile::tempdir().unwrap();
994        let mut expected = build_chain(dir.path(), "a", MAX_OPEN_DIRS + 5);
995        let mut got: Vec<String> = walk_all(dir.path(), "/")
996            .into_iter()
997            .map(|e| e.guest_path.trim_start_matches('/').to_string())
998            .collect();
999        got.sort();
1000        expected.sort();
1001        assert_eq!(
1002            got, expected,
1003            "deep walk must yield every entry exactly once"
1004        );
1005    }
1006
1007    /// Past the open-handle cap the queue holds bare names, rejoined to the level's
1008    /// own path when the descent resumes. A wide level down there is where a mistake
1009    /// in that rejoin shows up — either as a doubled prefix or as a lost subtree.
1010    #[test]
1011    fn a_wide_level_past_the_open_dir_cap_walks_completely() {
1012        let dir = tempfile::tempdir().unwrap();
1013        let mut expected = build_chain(dir.path(), "a", MAX_OPEN_DIRS + 2);
1014        let deep = expected
1015            .iter()
1016            .filter(|p| !p.ends_with("f.txt"))
1017            .max_by_key(|p| p.matches('/').count())
1018            .expect("the deepest directory")
1019            .clone();
1020        for i in 0..64 {
1021            let child = format!("{deep}/w{i}");
1022            std::fs::create_dir(dir.path().join(&child)).unwrap();
1023            std::fs::write(dir.path().join(&child).join("f.txt"), "x").unwrap();
1024            expected.push(child.clone());
1025            expected.push(format!("{child}/f.txt"));
1026        }
1027
1028        let mut got: Vec<String> = walk_all(dir.path(), "/")
1029            .into_iter()
1030            .map(|e| e.guest_path.trim_start_matches('/').to_string())
1031            .collect();
1032        got.sort();
1033        expected.sort();
1034        assert_eq!(
1035            got, expected,
1036            "every postponed subtree must still be walked"
1037        );
1038    }
1039
1040    #[test]
1041    fn deep_tree_under_a_nested_base_keeps_the_base_prefix() {
1042        let dir = tempfile::tempdir().unwrap();
1043        let expected: Vec<String> = build_chain(dir.path(), "a", MAX_OPEN_DIRS + 5)
1044            .into_iter()
1045            .filter(|p| p != "a")
1046            .map(|p| format!("/{p}"))
1047            .collect();
1048        let mut got: Vec<String> = walk_all(dir.path(), "/a")
1049            .into_iter()
1050            .map(|e| e.guest_path)
1051            .collect();
1052        let mut expected = expected;
1053        got.sort();
1054        expected.sort();
1055        assert_eq!(
1056            got, expected,
1057            "guest paths keep the listed directory's prefix"
1058        );
1059    }
1060
1061    #[test]
1062    fn deep_subtrees_stay_contiguous_past_the_open_dir_cap() {
1063        let dir = tempfile::tempdir().unwrap();
1064        build_chain(dir.path(), "a", MAX_OPEN_DIRS + 5);
1065        build_chain(dir.path(), "b", MAX_OPEN_DIRS + 5);
1066        assert_subtrees_contiguous(&walk_all(dir.path(), "/"));
1067    }
1068
1069    #[test]
1070    fn open_directory_handles_stay_within_the_cap() {
1071        let dir = tempfile::tempdir().unwrap();
1072        build_chain(dir.path(), "a", MAX_OPEN_DIRS + 5);
1073        let mut walk = walk_over(dir.path(), "/", true);
1074        let mut deepest = 0;
1075        while walk.next_entry().is_some() {
1076            deepest = deepest.max(walk.open_dirs);
1077            assert!(
1078                walk.open_dirs <= MAX_OPEN_DIRS,
1079                "walk held {} directory handles",
1080                walk.open_dirs,
1081            );
1082        }
1083        assert_eq!(deepest, MAX_OPEN_DIRS, "a chain past the cap must reach it");
1084    }
1085
1086    #[cfg(unix)]
1087    #[test]
1088    fn a_subdirectory_that_would_not_open_is_retried_from_the_base() {
1089        use std::os::unix::fs::PermissionsExt;
1090
1091        let dir = tempfile::tempdir().unwrap();
1092        let blocked = dir.path().join("w");
1093        std::fs::create_dir(&blocked).unwrap();
1094        std::fs::write(blocked.join("inside.txt"), "x").unwrap();
1095        std::fs::write(dir.path().join("sibling.txt"), "x").unwrap();
1096        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
1097        if std::fs::read_dir(&blocked).is_ok() {
1098            eprintln!("skipping: this user can read a 0o000 directory");
1099            return;
1100        }
1101
1102        let mut walk = walk_over(dir.path(), "/", true);
1103        let mut paths = Vec::new();
1104        while let Some(entry) = walk.next_entry() {
1105            paths.push(entry.guest_path);
1106            // The descent into `/w` has already failed by the time it is yielded; the walk
1107            // must retry it when it drains, not drop the subtree.
1108            if entry.name == "w" {
1109                std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
1110            }
1111        }
1112        assert!(
1113            paths.iter().any(|p| p == "/w/inside.txt"),
1114            "a subdirectory that failed to open must be retried, got {paths:?}",
1115        );
1116    }
1117
1118    #[cfg(unix)]
1119    #[test]
1120    fn a_deferred_directory_with_a_non_utf8_name_still_walks() {
1121        use std::os::unix::ffi::OsStrExt;
1122
1123        let dir = tempfile::tempdir().unwrap();
1124        build_chain(dir.path(), "a", MAX_OPEN_DIRS + 3);
1125        let mut deepest = dir.path().join("a");
1126        for level in 1..=(MAX_OPEN_DIRS + 3) {
1127            deepest = deepest.join(format!("l{level}"));
1128        }
1129        let odd = deepest.join(std::ffi::OsStr::from_bytes(b"bad\xffname"));
1130        // APFS (and any other filesystem enforcing UTF-8 names) rejects this outright,
1131        // which makes the case unreachable there rather than untested.
1132        if std::fs::create_dir(&odd).is_err() {
1133            eprintln!("skipping: this filesystem rejects non-UTF-8 names");
1134            return;
1135        }
1136        std::fs::write(odd.join("in.txt"), "x").unwrap();
1137
1138        let entries = walk_all(dir.path(), "/");
1139        assert_eq!(
1140            entries.iter().filter(|e| e.name == "in.txt").count(),
1141            1,
1142            "a deferred directory whose name is not UTF-8 must still be reopened",
1143        );
1144    }
1145
1146    #[test]
1147    fn writer_close_renames_and_refunds() {
1148        let limits = TenantLimits::new(10 * 1024 * 1024);
1149        let dir = tempfile::tempdir().unwrap();
1150        let vfs = crate::runtime::Vfs::external(dir.path().to_path_buf()).unwrap();
1151        let final_path = dir.path().join("out.txt");
1152        let (mut w, _temp, _final) = writer_over(&vfs, &limits);
1153        w.write_line("hello").unwrap();
1154        w.close().unwrap();
1155        assert!(final_path.exists());
1156        assert!(temp_leftovers(dir.path()).is_empty());
1157        assert_eq!(limits.host_attached_bytes(), 0);
1158        assert_eq!(std::fs::read_to_string(&final_path).unwrap(), "hello\n");
1159    }
1160}