1use 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
27const HANDLE_BUF_BYTES: u64 = 8 * 1024;
30
31pub(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 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
76pub trait Closable: Send + Sync {
79 fn close(&mut self);
80}
81
82pub struct ChargedLineReader {
84 reader: Option<BufReader<File>>,
85 buf: Vec<u8>,
86 bom_handled: bool,
87 pub(super) bytes_read: u64,
90 pub(super) bytes_scanned: u64,
91 charge: ByteCharge,
92 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 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 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
202pub 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
216pub struct ChargedByteReader {
218 file: Option<File>,
219 chunk_size: usize,
220 charge: ByteCharge,
221 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 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
272pub struct WalkEntry {
276 pub name: String,
277 pub guest_path: String,
278 pub kind: &'static str,
279 pub size: u64,
280}
281
282const MAX_OPEN_DIRS: usize = 32;
296
297const MAX_DEFERRED: usize = 16_384;
304
305struct Level {
306 iter: Option<ReadDir>,
309 path: PathBuf,
315 prefix: String,
318 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
346pub struct ContainedWalk {
360 base: Arc<Dir>,
361 mounts: Vec<(PathBuf, Arc<Dir>)>,
364 base_prefix: String,
366 stack: Vec<Level>,
367 open_dirs: usize,
370 deferred: usize,
372 recursive: bool,
373}
374
375impl ContainedWalk {
376 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 fn visit(&mut self, entry: DirEntry) -> Option<WalkEntry> {
418 let raw_name = entry.file_name();
419 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 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 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 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 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 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
527pub 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
551pub struct ChargedFileWriter {
556 open: Option<OpenWrite>,
558 temp: TempFile,
563 final_path: ContentPath,
564 charge: ByteCharge,
565}
566
567pub struct TempFile {
571 path: ContentPath,
572 identity: FileIdentity,
573}
574
575impl TempFile {
576 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
589struct OpenWrite {
593 writer: BufWriter<File>,
594 disk_charge: QuotaCharge,
595 held: Option<OpenFileGuard>,
598}
599
600#[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 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 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
688fn 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
695fn 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
710fn 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
735fn 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
747fn 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 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 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 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 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 #[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 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 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}