1use super::durable::{EffectId, RunId};
3use super::filesystem::{
4 DirectoryEntry, DirectoryHandle, DirectoryReader, FileReader, FileWriter, Filesystem,
5 FilesystemError, FilesystemInfo, MutationBudget, OpenWrite, ReadItem, ReadOutput, ReadPosition,
6 ReaderHandle, ReaderOptions, TempFile, WriterHandle, bounded_read,
7};
8use super::fs::{self, ContainError, ContentPath, FileIdentity, LinkPath, MAX_REMOVE_ENTRIES};
9use super::fuel::WorkBudget;
10use super::vfs::Placement;
11use super::{DiskQuota, Holder, QuotaCharge, QuotaExceeded, Vfs};
12use crate::stdlib::shared::{contain_trap, quota_refusal, write_target_trap};
13use cap_std::fs::{Dir, DirEntry, ReadDir};
14use std::collections::VecDeque;
15use std::ffi::{OsStr, OsString};
16use std::fs::File;
17use std::io::{BufWriter, Read, Seek, SeekFrom, Write};
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21pub struct DefaultFilesystem {
22 vfs: Vfs,
23 run_id: RunId,
24}
25impl DefaultFilesystem {
26 pub fn new(vfs: Vfs, run_id: RunId) -> Self {
27 Self { vfs, run_id }
28 }
29}
30#[async_trait::async_trait]
31impl Filesystem for DefaultFilesystem {
32 fn info(&self) -> FilesystemInfo {
33 FilesystemInfo::from_vfs(&self.vfs)
34 }
35 fn cwd(&self) -> &str {
36 self.vfs.cwd()
37 }
38 fn placement(&self, path: &str) -> Result<Placement, FilesystemError> {
39 Ok(fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?
40 .placement()
41 .clone())
42 }
43 async fn metadata(
44 &self,
45 _effect_id: &EffectId,
46 _work: Option<Arc<WorkBudget>>,
47 path: &str,
48 follow: bool,
49 ) -> Result<cap_std::fs::Metadata, FilesystemError> {
50 if follow {
51 Ok(fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?.metadata()?)
52 } else {
53 Ok(fs::resolve_link(&self.vfs, self.vfs.cwd(), path)?.symlink_metadata()?)
54 }
55 }
56 async fn read(
57 &self,
58 _effect_id: &EffectId,
59 _work: Option<Arc<WorkBudget>>,
60 path: &str,
61 offset: u64,
62 maximum_bytes: u64,
63 ) -> Result<Vec<u8>, FilesystemError> {
64 let file = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?.open()?;
65 bounded_read(file, offset, maximum_bytes)
66 }
67 async fn write(
68 &self,
69 _effect_id: &EffectId,
70 _work: Option<Arc<WorkBudget>>,
71 path: &str,
72 bytes: Vec<u8>,
73 ) -> Result<(), FilesystemError> {
74 let path = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?;
75 path.writable()?;
76 if path.is_root() {
77 return Err(FilesystemError::Unsupported(
78 "cannot write to the VFS root or a mount point",
79 ));
80 }
81 let mut quota = QuotaCharge::new(path.placement().quota().cloned(), path.regular_file());
82 quota.reserve(bytes.len() as u64)?;
83 let temp = path.temp_sibling();
84 let mut file = temp.create_new()?;
85 let result = (|| {
86 file.write_all(&bytes)?;
87 file.sync_all()?;
88 drop(file);
89 temp.rename_to(&path)?;
90 Ok(())
91 })();
92 if result.is_err() {
93 let _ = temp.remove_file();
94 } else {
95 quota.commit();
96 }
97 result
98 }
99 async fn append(
100 &self,
101 _effect_id: &EffectId,
102 _work: Option<Arc<WorkBudget>>,
103 path: &str,
104 bytes: Vec<u8>,
105 ) -> Result<(), FilesystemError> {
106 let path = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?;
107 path.writable()?;
108 if path.is_root() {
109 return Err(FilesystemError::Unsupported(
110 "cannot write to the VFS root or a mount point",
111 ));
112 }
113 let count = bytes.len() as u64;
114 let before = path.file_len();
115 let mut quota = QuotaCharge::new(path.placement().quota().cloned(), None);
116 quota.reserve(count)?;
117 let result = path
118 .append()
119 .map_err(FilesystemError::from)
120 .and_then(|mut file| file.write_all(&bytes).map_err(Into::into));
121 if result.is_err() {
122 quota
123 .unreserve(count.saturating_sub(path.file_len().saturating_sub(before).min(count)));
124 quota.keep();
125 } else {
126 quota.commit();
127 }
128 result
129 }
130 async fn mkdir(
131 &self,
132 _effect_id: &EffectId,
133 _work: Option<Arc<WorkBudget>>,
134 path: &str,
135 recursive: bool,
136 ) -> Result<(), FilesystemError> {
137 let path = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?;
138 if recursive {
139 path.create_dir_all()?;
140 } else {
141 path.create_dir()?;
142 }
143 Ok(())
144 }
145 async fn remove(
146 &self,
147 _effect_id: &EffectId,
148 _work: Option<Arc<WorkBudget>>,
149 path: &str,
150 recursive: bool,
151 budget: &mut dyn MutationBudget,
152 ) -> Result<(), FilesystemError> {
153 default_remove(&self.vfs, path, recursive, budget).map_err(FilesystemError::Execution)
154 }
155 async fn move_entry(
156 &self,
157 _effect_id: &EffectId,
158 _work: Option<Arc<WorkBudget>>,
159 from: &str,
160 to: &str,
161 budget: &mut dyn MutationBudget,
162 ) -> Result<(), FilesystemError> {
163 default_move(&self.vfs, from, to, budget).map_err(FilesystemError::Execution)
164 }
165 async fn copy(
166 &self,
167 _effect_id: &EffectId,
168 _work: Option<Arc<WorkBudget>>,
169 from: &str,
170 to: &str,
171 recursive: bool,
172 budget: &mut dyn MutationBudget,
173 ) -> Result<(), FilesystemError> {
174 default_copy(&self.vfs, from, to, recursive, budget).map_err(FilesystemError::Execution)
175 }
176 async fn next_reader(
177 &self,
178 _: &EffectId,
179 work: Option<Arc<WorkBudget>>,
180 reader: &mut FileReader,
181 limits: &super::limits::TenantLimits,
182 ) -> Result<ReadOutput, FilesystemError> {
183 let native = reader.local.as_mut().ok_or(FilesystemError::Unsupported(
184 "default reader is not available in this process",
185 ))?;
186 let Some(file) = &mut native.file else {
187 return empty_read(limits);
188 };
189 read_item(
190 file,
191 &reader.options,
192 &mut native.position,
193 work.as_deref(),
194 limits,
195 )
196 }
197 async fn close_reader(
198 &self,
199 _: &EffectId,
200 _: Option<Arc<WorkBudget>>,
201 reader: &mut FileReader,
202 ) -> Result<(), FilesystemError> {
203 let native = reader.local.as_mut().ok_or(FilesystemError::Unsupported(
204 "default reader is not available in this process",
205 ))?;
206 native.file = None;
207 native.held = None;
208 native.position.closed = true;
209 Ok(())
210 }
211 async fn next_directory(
212 &self,
213 _: &EffectId,
214 _: Option<Arc<WorkBudget>>,
215 directory: &mut DirectoryReader,
216 _: &super::limits::TenantLimits,
217 ) -> Result<Option<DirectoryEntry>, FilesystemError> {
218 let native = directory
219 .local
220 .as_mut()
221 .ok_or(FilesystemError::Unsupported(
222 "default listing is not available in this process",
223 ))?;
224 Ok(native.entries.as_mut().and_then(Iterator::next))
225 }
226 async fn close_directory(
227 &self,
228 _: &EffectId,
229 _: Option<Arc<WorkBudget>>,
230 directory: &mut DirectoryReader,
231 _: &super::limits::TenantLimits,
232 ) -> Result<(), FilesystemError> {
233 let native = directory
234 .local
235 .as_mut()
236 .ok_or(FilesystemError::Unsupported(
237 "default listing is not available in this process",
238 ))?;
239 native.entries = None;
240 Ok(())
241 }
242 async fn write_chunk(
243 &self,
244 _: &EffectId,
245 _: Option<Arc<WorkBudget>>,
246 writer: &mut FileWriter,
247 bytes: Vec<u8>,
248 ) -> Result<(), FilesystemError> {
249 let native = writer.local.as_mut().ok_or(FilesystemError::Unsupported(
250 "default writer is not available in this process",
251 ))?;
252 native.write_bytes(&bytes).map_err(Into::into)
253 }
254 async fn close_writer(
255 &self,
256 _: &EffectId,
257 _: Option<Arc<WorkBudget>>,
258 writer: &mut FileWriter,
259 ) -> Result<(), FilesystemError> {
260 let native = writer.local.as_mut().ok_or(FilesystemError::Unsupported(
261 "default writer is not available in this process",
262 ))?;
263 let result = native.close().map_err(Into::into);
264 writer.closed = native.open.is_none();
265 result
266 }
267 async fn open_reader(
268 &self,
269 _effect_id: &EffectId,
270 _work: Option<Arc<WorkBudget>>,
271 path: &str,
272 options: ReaderOptions,
273 ) -> Result<FileReader, FilesystemError> {
274 let path = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?;
275 let file = path.open()?;
276 let held = path.placement().quota().and_then(|quota| {
277 let metadata = file.metadata().ok()?;
278 Some(quota.hold(fs::FileIdentity::of(&metadata).ok()?, super::Holder::Reader))
279 });
280 Ok(FileReader {
281 run_id: self.run_id.clone(),
282 id: _effect_id.clone(),
283 options,
284 local: Some(ReaderHandle {
285 file: Some(file.into_std()),
286 position: ReadPosition::default(),
287 held,
288 }),
289 })
290 }
291 async fn open_directory(
292 &self,
293 _effect_id: &EffectId,
294 _work: Option<Arc<WorkBudget>>,
295 path: &str,
296 recursive: bool,
297 _: &super::limits::TenantLimits,
298 ) -> Result<DirectoryReader, FilesystemError> {
299 let resolved = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?;
300 if !resolved.metadata()?.is_dir() {
301 return Err(std::io::Error::from(std::io::ErrorKind::NotADirectory).into());
302 }
303 let base = std::sync::Arc::new(resolved.open_dir()?);
304 let mounts = if recursive {
305 resolved.mounts_below()
306 } else {
307 Vec::new()
308 };
309 let normalized = fs::guest_normalize(self.vfs.cwd(), path).map_err(ContainError::from)?;
310 let prefix = if normalized == "/" {
311 String::new()
312 } else {
313 format!("{}/", normalized.trim_start_matches('/'))
314 };
315 let mut walk = ContainedWalk::new(base, prefix, recursive, mounts)?;
316 Ok(DirectoryReader {
317 run_id: self.run_id.clone(),
318 id: _effect_id.clone(),
319 local: Some(DirectoryHandle {
320 entries: Some(Box::new(std::iter::from_fn(move || walk.next_entry()))),
321 }),
322 })
323 }
324 async fn open_writer(
325 &self,
326 _effect_id: &EffectId,
327 _work: Option<Arc<WorkBudget>>,
328 path: &str,
329 ) -> Result<FileWriter, FilesystemError> {
330 let path = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?;
331 path.writable()?;
332 if path.is_root() {
333 return Err(FilesystemError::Unsupported(
334 "cannot write to the VFS root or a mount point",
335 ));
336 }
337 let temp_path = path.temp_sibling();
338 let file = temp_path.create_new()?;
339 let temp = TempFile::created(temp_path, &file)?;
340 let quota = path.placement().quota().cloned();
341 Ok(FileWriter {
342 run_id: self.run_id.clone(),
343 id: _effect_id.clone(),
344 closed: false,
345 local: Some(WriterHandle::new(file.into_std(), temp, path, quota)),
346 })
347 }
348}
349
350pub fn read_item(
354 file: &mut (impl Read + Seek),
355 options: &ReaderOptions,
356 position: &mut ReadPosition,
357 work: Option<&WorkBudget>,
358 limits: &super::limits::TenantLimits,
359) -> Result<ReadOutput, FilesystemError> {
360 if position.closed {
361 return empty_read(limits);
362 }
363 file.seek(SeekFrom::Start(position.offset))?;
364 let mut next = position.clone();
365 let result = match options {
366 ReaderOptions::Bytes { chunk_size } => {
367 read_chunk(file, *chunk_size, &mut next, work, limits)
368 }
369 ReaderOptions::Lines { maximum_bytes } => {
370 read_line(file, *maximum_bytes, &mut next, work, limits)
371 }
372 }?;
373 *position = next;
374 Ok(result)
375}
376fn empty_read(limits: &super::limits::TenantLimits) -> Result<ReadOutput, FilesystemError> {
377 Ok(ReadOutput {
378 item: None,
379 _charge: Some(memory(limits, 0)?),
380 })
381}
382fn memory(
383 limits: &super::limits::TenantLimits,
384 bytes: u64,
385) -> Result<super::limits::HostBytes, FilesystemError> {
386 super::limits::HostBytes::new(limits, bytes)
387 .map_err(|e| FilesystemError::Execution(wasmtime::Error::new(e)))
388}
389fn read_cost(
390 work: Option<&WorkBudget>,
391 rate: super::fuel::Rate,
392 bytes: usize,
393) -> Result<(), FilesystemError> {
394 if let Some(work) = work {
395 work.before_effect();
397 work.charge(rate, bytes as u64)?;
398 }
399 Ok(())
400}
401fn read_chunk(
402 file: &mut impl Read,
403 size: usize,
404 position: &mut ReadPosition,
405 work: Option<&WorkBudget>,
406 limits: &super::limits::TenantLimits,
407) -> Result<ReadOutput, FilesystemError> {
408 if size == 0 {
409 return Err(FilesystemError::Rejected(
410 "chunk size must be positive".into(),
411 ));
412 }
413 if let Some(work) = work {
414 work.charge(super::fuel::COPY, size as u64)?;
415 }
416 let charge = memory(limits, size as u64)?;
417 let mut bytes = Vec::new();
418 bytes
419 .try_reserve_exact(size)
420 .map_err(|e| FilesystemError::Backend(Box::new(e)))?;
421 bytes.resize(size, 0);
422 let mut filled = 0;
423 while filled < size {
424 let count = file.read(&mut bytes[filled..])?;
425 read_cost(work, super::fuel::IO, count)?;
426 if count == 0 {
427 break;
428 }
429 filled += count;
430 }
431 position.offset = position
432 .offset
433 .checked_add(filled as u64)
434 .ok_or_else(|| FilesystemError::Rejected("reader offset overflow".into()))?;
435 bytes.truncate(filled);
436 Ok(ReadOutput {
437 item: (filled > 0).then_some(ReadItem::Bytes(bytes)),
438 _charge: Some(charge),
439 })
440}
441fn read_line(
442 file: &mut impl Read,
443 maximum: u64,
444 position: &mut ReadPosition,
445 work: Option<&WorkBudget>,
446 limits: &super::limits::TenantLimits,
447) -> Result<ReadOutput, FilesystemError> {
448 use std::io::BufRead;
449 let mut charge = memory(limits, 8192)?;
450 let mut reader = std::io::BufReader::new(file);
451 let mut bytes = Vec::new();
452 loop {
453 let needs_read = reader.buffer().is_empty();
454 let available = reader.fill_buf()?;
455 if needs_read {
456 read_cost(work, super::fuel::IO, available.len())?;
457 }
458 if available.is_empty() {
459 break;
460 }
461 let newline = available.iter().position(|&b| b == b'\n');
462 let count = newline.map_or(available.len(), |i| i + 1);
463 read_cost(work, super::fuel::SCAN, count)?;
464 let length = bytes.len().checked_add(count).ok_or_else(|| {
465 FilesystemError::Execution(super::host::range_error(
466 "fs.lines: line length exceeds host capacity",
467 ))
468 })?;
469 if length as u64 > maximum {
470 return Err(FilesystemError::Execution(super::host::range_error(
471 format!(
472 "fs.lines: line exceeds maxReadSize of {maximum} bytes; read smaller chunks with fs.bytes"
473 ),
474 )));
475 }
476 if length > bytes.capacity() {
477 let capacity = length
478 .max(bytes.capacity().saturating_mul(2))
479 .min(usize::try_from(maximum).unwrap_or(usize::MAX));
480 charge
481 .grow(limits, (capacity - bytes.capacity()) as u64)
482 .map_err(|e| FilesystemError::Execution(wasmtime::Error::new(e)))?;
483 bytes
484 .try_reserve_exact(capacity - bytes.len())
485 .map_err(|e| FilesystemError::Backend(Box::new(e)))?;
486 }
487 bytes.extend_from_slice(&available[..count]);
488 reader.consume(count);
489 if newline.is_some() {
490 break;
491 }
492 }
493 if bytes.is_empty() {
494 return empty_read(limits);
495 }
496 position.offset = position
497 .offset
498 .checked_add(bytes.len() as u64)
499 .ok_or_else(|| FilesystemError::Rejected("reader offset overflow".into()))?;
500 if bytes.last() == Some(&b'\n') {
501 bytes.pop();
502 if bytes.last() == Some(&b'\r') {
503 bytes.pop();
504 }
505 }
506 let capacity = bytes
507 .len()
508 .checked_mul(3)
509 .ok_or_else(|| FilesystemError::Rejected("line capacity overflow".into()))?;
510 let output_charge = memory(limits, capacity as u64)?;
511 let mut text = String::new();
512 text.try_reserve_exact(capacity)
513 .map_err(|e| FilesystemError::Backend(Box::new(e)))?;
514 for part in bytes.utf8_chunks() {
515 text.push_str(part.valid());
516 if !part.invalid().is_empty() {
517 text.push('\u{fffd}');
518 }
519 }
520 if !position.bom_handled {
521 position.bom_handled = true;
522 if text.starts_with('\u{feff}') {
523 text.drain(..'\u{feff}'.len_utf8());
524 }
525 }
526 Ok(ReadOutput {
527 item: Some(ReadItem::Line(text)),
528 _charge: Some(output_charge),
529 })
530}
531
532const MAX_OPEN_DIRS: usize = 32;
546
547const MAX_DEFERRED: usize = 16_384;
554
555struct Level {
556 iter: Option<ReadDir>,
559 path: PathBuf,
565 prefix: String,
568 deferred: VecDeque<OsString>,
578}
579
580impl Level {
581 fn new(path: PathBuf, iter: ReadDir) -> Self {
582 let mut prefix = String::new();
583 for component in &path {
584 prefix.push_str(&component.to_string_lossy());
585 prefix.push('/');
586 }
587 Self {
588 iter: Some(iter),
589 path,
590 prefix,
591 deferred: VecDeque::new(),
592 }
593 }
594}
595
596struct ContainedWalk {
610 base: Arc<Dir>,
611 mounts: Vec<(PathBuf, Arc<Dir>)>,
614 base_prefix: String,
616 stack: Vec<Level>,
617 open_dirs: usize,
620 deferred: usize,
622 recursive: bool,
623}
624
625impl ContainedWalk {
626 pub fn new(
629 base: Arc<Dir>,
630 base_prefix: String,
631 recursive: bool,
632 mounts: Vec<(PathBuf, Arc<Dir>)>,
633 ) -> std::io::Result<Self> {
634 let iter = base.entries()?;
635 Ok(Self {
636 base,
637 mounts,
638 base_prefix,
639 stack: vec![Level::new(PathBuf::new(), iter)],
640 open_dirs: 1,
641 deferred: 0,
642 recursive,
643 })
644 }
645
646 fn next_entry(&mut self) -> Option<DirectoryEntry> {
647 loop {
648 let level = self.stack.last_mut()?;
649 match level.iter.as_mut().map(Iterator::next) {
650 Some(Some(Ok(entry))) => {
651 if let Some(found) = self.visit(entry) {
652 return Some(found);
653 }
654 }
655 Some(Some(Err(_))) => {}
656 Some(None) => {
657 level.iter = None;
658 self.open_dirs -= 1;
659 }
660 None => self.resume_deferred(),
661 }
662 }
663 }
664
665 fn visit(&mut self, entry: DirEntry) -> Option<DirectoryEntry> {
668 let raw_name = entry.file_name();
669 let ft = entry
672 .file_type()
673 .ok()
674 .or_else(|| entry.metadata().ok().map(|m| m.file_type()))?;
675 let size = if ft.is_file() {
676 entry.metadata().map_or(0, |m| m.len())
677 } else {
678 0
679 };
680 let level = self.stack.last()?;
681 let name = raw_name.to_string_lossy().into_owned();
682 let guest_path = format!("/{}{}{}", self.base_prefix, level.prefix, name);
683 if self.recursive && ft.is_dir() && !ft.is_symlink() {
684 self.descend(&raw_name, &entry);
685 }
686 Some(DirectoryEntry {
687 name,
688 path: guest_path,
689 kind: kind_of(&ft),
690 bytes: size,
691 })
692 }
693
694 fn descend(&mut self, name: &OsStr, entry: &DirEntry) {
700 if self.open_dirs < MAX_OPEN_DIRS
701 && let Some(child) = self.stack.last().map(|level| level.path.join(name))
702 && let Ok(dir) = self.open_child(&child, entry)
703 && let Ok(iter) = dir.entries()
704 {
705 self.stack.push(Level::new(child, iter));
706 self.open_dirs += 1;
707 return;
708 }
709 if self.deferred < MAX_DEFERRED
710 && let Some(level) = self.stack.last_mut()
711 {
712 level.deferred.push_back(name.to_os_string());
713 self.deferred += 1;
714 }
715 }
716
717 fn resume_deferred(&mut self) {
722 let Some(level) = self.stack.last_mut() else {
723 return;
724 };
725 let Some(name) = level.deferred.pop_front() else {
726 self.stack.pop();
727 return;
728 };
729 self.deferred -= 1;
730 let path = level.path.join(name);
731 if let Ok(dir) = self.open_path(&path)
734 && let Ok(iter) = dir.entries()
735 {
736 self.stack.push(Level::new(path, iter));
737 self.open_dirs += 1;
738 }
739 }
740
741 fn open_child(&self, path: &Path, entry: &DirEntry) -> std::io::Result<Dir> {
744 match self.mounts.iter().find(|(rel, _)| rel == path) {
745 Some((_, volume)) => volume.try_clone(),
746 None => entry.open_dir(),
747 }
748 }
749
750 fn open_path(&self, path: &Path) -> std::io::Result<Dir> {
753 for (rel, volume) in &self.mounts {
754 if let Ok(rest) = path.strip_prefix(rel) {
755 if rest.as_os_str().is_empty() {
756 return volume.try_clone();
757 }
758 return volume.open_dir(rest);
759 }
760 }
761 self.base.open_dir(path)
762 }
763}
764
765pub fn kind_of(ft: &cap_std::fs::FileType) -> &'static str {
766 if ft.is_symlink() {
767 "symlink"
768 } else if ft.is_dir() {
769 "directory"
770 } else if ft.is_file() {
771 "file"
772 } else {
773 "other"
774 }
775}
776
777impl TempFile {
778 pub fn created(path: ContentPath, file: &cap_std::fs::File) -> std::io::Result<Self> {
780 Ok(Self {
781 path,
782 identity: FileIdentity::of(&file.metadata()?)?,
783 })
784 }
785
786 fn is_intact(&self) -> Result<bool, ContainError> {
787 self.path.refers_to(self.identity)
788 }
789}
790
791#[derive(Debug)]
793enum WriteError {
794 Io(std::io::Error),
795 Contain(ContainError),
796 Full(QuotaExceeded),
797}
798
799impl std::fmt::Display for WriteError {
800 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
801 match self {
802 WriteError::Io(e) => e.fmt(f),
803 WriteError::Contain(e) => e.fmt(f),
804 WriteError::Full(e) => e.fmt(f),
805 }
806 }
807}
808
809impl WriterHandle {
810 fn new(
811 file: File,
812 temp: TempFile,
813 final_path: ContentPath,
814 quota: Option<Arc<DiskQuota>>,
815 ) -> Self {
816 let held = quota
817 .as_ref()
818 .map(|quota| quota.hold(temp.identity, Holder::Writer));
819 Self {
820 open: Some(OpenWrite {
821 writer: BufWriter::new(file),
822 disk_charge: QuotaCharge::new(quota, None),
823 held,
824 }),
825 temp,
826 final_path,
827 }
828 }
829
830 fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), WriteError> {
831 let open = self.reserve(bytes.len() as u64)?;
832 open.writer.write_all(bytes).map_err(WriteError::Io)
833 }
834
835 fn reserve(&mut self, bytes: u64) -> Result<&mut OpenWrite, WriteError> {
836 let open = self
837 .open
838 .as_mut()
839 .ok_or_else(|| WriteError::Io(std::io::Error::other("writer already closed")))?;
840 open.disk_charge.reserve(bytes).map_err(WriteError::Full)?;
841 Ok(open)
842 }
843
844 fn close(&mut self) -> Result<(), WriteError> {
847 let Some(open) = self.open.take() else {
848 return Ok(());
849 };
850 finalize(open, &self.temp, &self.final_path)
851 }
852}
853
854impl Drop for WriterHandle {
855 fn drop(&mut self) {
856 if let Some(open) = self.open.take() {
857 let unlinked = open
860 .writer
861 .into_inner()
862 .ok()
863 .is_some_and(|file| is_unlinked(&file));
864 discard_temp(&self.temp, open.disk_charge, unlinked);
865 drop(open.held);
866 }
867 }
868}
869
870fn is_unlinked(file: &File) -> bool {
873 cap_std::fs::Metadata::from_file(file)
874 .is_ok_and(|meta| crate::runtime::fs::link_count(&meta) == Some(0))
875}
876
877fn discard_temp(temp: &TempFile, disk_charge: QuotaCharge, unlinked: bool) {
884 let removed = temp.is_intact().unwrap_or(false) && temp.path.remove_file().is_ok();
885 if removed || unlinked {
886 disk_charge.release_when_closed(temp.identity);
887 } else {
888 disk_charge.keep();
889 }
890}
891
892fn finalize(open: OpenWrite, temp: &TempFile, final_path: &ContentPath) -> Result<(), WriteError> {
894 let OpenWrite {
895 writer,
896 mut disk_charge,
897 held,
898 } = open;
899 let committed = match flush_and_sync(writer) {
900 Ok(unlinked) => {
901 let renamed = rename_into_place(temp, final_path, &mut disk_charge);
902 match renamed {
903 Ok(()) => disk_charge.commit(),
904 Err(_) => discard_temp(temp, disk_charge, unlinked),
905 }
906 renamed
907 }
908 Err(err) => {
909 discard_temp(temp, disk_charge, false);
910 Err(err)
911 }
912 };
913 drop(held);
914 committed
915}
916
917fn flush_and_sync(writer: BufWriter<File>) -> Result<bool, WriteError> {
921 let mut file = writer
922 .into_inner()
923 .map_err(|e| WriteError::Io(e.into_error()))?;
924 file.flush().map_err(WriteError::Io)?;
925 file.sync_all().map_err(WriteError::Io)?;
926 Ok(is_unlinked(&file))
927}
928
929fn rename_into_place(
931 temp: &TempFile,
932 final_path: &ContentPath,
933 disk_charge: &mut QuotaCharge,
934) -> Result<(), WriteError> {
935 if !temp.is_intact().map_err(WriteError::Contain)? {
936 return Err(WriteError::Io(std::io::Error::other(
937 "the writer's temporary file was moved or replaced",
938 )));
939 }
940 disk_charge
941 .cover(final_path.regular_file())
942 .map_err(WriteError::Full)?;
943 temp.path.rename_to(final_path).map_err(WriteError::Contain)
944}
945
946impl From<WriteError> for FilesystemError {
947 fn from(error: WriteError) -> Self {
948 match error {
949 WriteError::Io(e) => Self::Io(e),
950 WriteError::Contain(e) => Self::Containment(e),
951 WriteError::Full(e) => Self::Quota(e),
952 }
953 }
954}
955
956fn default_remove(
957 vfs: &Vfs,
958 path: &str,
959 recursive: bool,
960 budget: &mut dyn MutationBudget,
961) -> wasmtime::Result<()> {
962 let resolved =
963 fs::resolve_link(vfs, vfs.cwd(), path).map_err(|e| contain_trap("fs.remove", path, &e))?;
964 let context = MutationContext {
965 op: "fs.remove",
966 path,
967 quota: resolved.placement().quota().cloned(),
968 changed: false,
969 };
970 meter_mutation(budget, context, |step| {
971 fs::removal::remove(&resolved, recursive, step)
972 })
973}
974fn default_move(
975 vfs: &Vfs,
976 from: &str,
977 to: &str,
978 budget: &mut dyn MutationBudget,
979) -> wasmtime::Result<()> {
980 let from_resolved =
981 fs::resolve_link(vfs, vfs.cwd(), from).map_err(|e| contain_trap("fs.move", from, &e))?;
982 let to_resolved =
983 fs::resolve_link(vfs, vfs.cwd(), to).map_err(|e| contain_trap("fs.move", to, &e))?;
984 let pair = format!("{from} -> {to}");
985 if from_resolved.is_root() || to_resolved.is_root() {
986 wasmtime::bail!(
987 "fs.move {pair}: the VFS root and mount points cannot be moved or replaced"
988 );
989 }
990 if from_resolved
991 .same_entry(&to_resolved)
992 .map_err(|e| contain_trap("fs.move", &pair, &e))?
993 {
994 return Ok(());
995 }
996 if !from_resolved
997 .placement()
998 .same_volume(to_resolved.placement())
999 {
1000 let mut work = CopyWork::default();
1001 let result = move_across(budget, &from_resolved, &to_resolved, &pair, &mut work);
1002 work.settle(budget)?;
1003 return result;
1004 }
1005 let moved = from_resolved.regular_file().map(|(file, _)| file);
1008 let replaced = to_resolved
1009 .regular_file()
1010 .filter(|(file, _)| Some(*file) != moved);
1011 let context = MutationContext {
1012 op: "fs.move",
1013 path: &pair,
1014 quota: None,
1015 changed: false,
1016 };
1017 meter_mutation(budget, context, |step| {
1018 crate::runtime::fs::removal::rename(&from_resolved, &to_resolved, step)
1019 })?;
1020 if let (Some(quota), Some((file, bytes))) = (to_resolved.placement().quota(), replaced) {
1021 quota.release_file(file, bytes);
1022 }
1023 Ok(())
1024}
1025fn default_copy(
1026 vfs: &Vfs,
1027 from: &str,
1028 to: &str,
1029 recursive: bool,
1030 budget: &mut dyn MutationBudget,
1031) -> wasmtime::Result<()> {
1032 let from_resolved =
1033 fs::resolve_link(vfs, vfs.cwd(), from).map_err(|e| contain_trap("fs.copy", from, &e))?;
1034 let pair = format!("{from} -> {to}");
1035 let mount = from_resolved
1036 .contains_mount_point()
1037 .map_err(|e| contain_trap("fs.copy", &pair, &e))?;
1038 if let Some(mount) = mount {
1039 wasmtime::bail!(
1040 "fs.copy {pair}: {from} contains the mount point {mount}; copy the \
1041 mount's contents separately"
1042 );
1043 }
1044 let to_volume =
1047 fs::resolve_content(vfs, vfs.cwd(), to).map_err(|e| contain_trap("fs.copy", to, &e))?;
1048 from_resolved
1049 .check_copy_destination(&to_volume)
1050 .map_err(|e| contain_trap("fs.copy", &pair, &e))?;
1051 if recursive
1055 && from_resolved
1056 .symlink_metadata()
1057 .is_ok_and(|meta| meta.is_dir())
1058 {
1059 to_volume
1060 .create_dir_all()
1061 .map_err(|e| write_target_trap("fs.copy", to, &e))?;
1062 }
1063 let to_resolved =
1064 fs::resolve_link(vfs, vfs.cwd(), to).map_err(|e| contain_trap("fs.copy", to, &e))?;
1065 let quota = to_resolved.placement().quota().cloned();
1066 let mut work = CopyWork::default();
1067 let result = copy_bounded(
1068 &from_resolved,
1069 &to_resolved,
1070 &CopyRun {
1071 recursive,
1072 links: LinkCopies::BestEffort,
1073 op: "fs.copy",
1074 pair: &pair,
1075 quota: quota.as_ref(),
1076 },
1077 &mut work,
1078 );
1079 work.settle(budget)?;
1080 result
1081}
1082fn copy_link(from: &LinkPath, to: &LinkPath) -> Result<(), ContainError> {
1087 to.symlink(&from.read_link_contents()?, from.link_kind()?)
1088}
1089
1090fn move_across(
1099 budget: &mut dyn MutationBudget,
1100 from: &LinkPath,
1101 to: &LinkPath,
1102 pair: &str,
1103 work: &mut CopyWork,
1104) -> wasmtime::Result<()> {
1105 let meta = from
1106 .symlink_metadata()
1107 .map_err(|e| contain_trap("fs.move", pair, &e))?;
1108 let is_dir = meta.is_dir();
1109 meter_mutation(
1114 budget,
1115 MutationContext {
1116 op: "fs.move",
1117 path: pair,
1118 quota: None,
1119 changed: false,
1120 },
1121 |step| {
1122 crate::runtime::fs::removal::validate(from, &mut *step)?;
1123 crate::runtime::fs::removal::validate(to, step)
1124 },
1125 )?;
1126 refuse_unfitting_destination(to, is_dir, pair)?;
1127 let to_quota = to.placement().quota().cloned();
1128 let staged = to.temp_sibling();
1129 let run = CopyRun {
1130 recursive: true,
1131 links: LinkCopies::Required,
1132 op: "fs.move",
1133 pair,
1134 quota: to_quota.as_ref(),
1135 };
1136 let copied = copy_bounded(from, &staged, &run, work).and_then(|()| {
1137 let replaced = to.regular_file();
1138 meter_mutation(
1139 budget,
1140 MutationContext {
1141 op: "fs.move",
1142 path: pair,
1143 quota: None,
1144 changed: true,
1145 },
1146 |step| crate::runtime::fs::removal::rename(&staged, to, step),
1147 )?;
1148 if let (Some(quota), Some((file, bytes))) = (to_quota.as_ref(), replaced) {
1149 quota.release_file(file, bytes);
1150 }
1151 Ok(())
1152 });
1153 if let Err(err) = copied {
1154 let _ = meter_mutation(
1157 budget,
1158 MutationContext {
1159 op: "fs.move",
1160 path: pair,
1161 quota: to_quota,
1162 changed: true,
1163 },
1164 |step| crate::runtime::fs::removal::remove(&staged, true, step),
1165 );
1166 return Err(err);
1167 }
1168 meter_mutation(
1169 budget,
1170 MutationContext {
1171 op: "fs.move",
1172 path: pair,
1173 quota: from.placement().quota().cloned(),
1174 changed: true,
1175 },
1176 |step| {
1177 crate::runtime::fs::removal::remove(from, true, step).map_err(|error| {
1178 if crate::runtime::host::ends_the_run(&error) { return error; }
1179 wasmtime::Error::msg(format!(
1180 "fs.move {pair}: moved to the destination but could not remove the source, so both may now exist: {error}"
1181 ))
1182 })
1183 },
1184 )
1185}
1186
1187fn refuse_unfitting_destination(
1191 to: &LinkPath,
1192 moving_dir: bool,
1193 pair: &str,
1194) -> wasmtime::Result<()> {
1195 let existing = match to.symlink_metadata() {
1196 Ok(existing) => existing,
1197 Err(ContainError::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
1198 return Ok(());
1199 }
1200 Err(err) => return Err(contain_trap("fs.move", pair, &err)),
1201 };
1202 let replacing_dir = existing.is_dir();
1203 if replacing_dir != moving_dir {
1204 let (what, onto) = if moving_dir {
1205 ("a directory", "something that is not a directory")
1206 } else {
1207 ("a file", "a directory")
1208 };
1209 wasmtime::bail!("fs.move {pair}: cannot move {what} onto {onto}");
1210 }
1211 if !replacing_dir {
1212 return Ok(());
1213 }
1214 let has_entries = to
1215 .entries()
1216 .map_err(|e| contain_trap("fs.move", pair, &e))?
1217 .next()
1218 .is_some();
1219 if has_entries {
1220 wasmtime::bail!("fs.move {pair}: the destination directory is not empty");
1221 }
1222 Ok(())
1223}
1224
1225struct MutationContext<'a> {
1226 op: &'static str,
1227 path: &'a str,
1228 quota: Option<Arc<DiskQuota>>,
1229 changed: bool,
1230}
1231
1232fn meter_mutation(
1233 budget: &mut dyn MutationBudget,
1234 context: MutationContext<'_>,
1235 run: impl FnOnce(
1236 &mut dyn FnMut(crate::runtime::fs::removal::Step) -> wasmtime::Result<()>,
1237 ) -> wasmtime::Result<()>,
1238) -> wasmtime::Result<()> {
1239 use crate::runtime::fs::removal::Step;
1240 budget.begin()?;
1241 let mut changed = context.changed;
1242 let result = run(&mut |step| {
1243 match step {
1244 Step::Inspect(units) | Step::Mutate(units) if changed => {
1245 budget.syscalls(units, true)?;
1246 }
1247 Step::Inspect(units) | Step::Mutate(units) => {
1248 budget.syscalls(units, false)?;
1249 }
1250 Step::Reserve(bytes) => budget.reserve(bytes)?,
1251 Step::Unlinked(file) => {
1252 changed = true;
1253 budget.effect();
1254 if let (Some(quota), Some((file, bytes))) = (&context.quota, file) {
1255 quota.release_file(file, bytes);
1256 }
1257 }
1258 }
1259 Ok(())
1260 });
1261 budget.end();
1262 result.map_err(|error| match error.downcast::<ContainError>() {
1263 Ok(error) => contain_trap(context.op, context.path, &error),
1264 Err(error) => error,
1265 })
1266}
1267
1268#[derive(Clone, Copy)]
1270struct CopyRun<'a> {
1271 recursive: bool,
1272 links: LinkCopies,
1273 op: &'a str,
1275 pair: &'a str,
1277 quota: Option<&'a Arc<DiskQuota>>,
1279}
1280
1281#[derive(Clone, Copy, PartialEq, Eq)]
1284enum LinkCopies {
1285 BestEffort,
1287 Required,
1289}
1290
1291const MAX_COPY_ENTRIES: u64 = MAX_REMOVE_ENTRIES as u64;
1293const MAX_COPY_DEPTH: usize = 64;
1294
1295#[derive(Default)]
1296struct CopyWork {
1297 entries: u64,
1298 bytes: u64,
1299}
1300
1301impl CopyWork {
1302 fn visit(&mut self, op: &str, pair: &str) -> wasmtime::Result<()> {
1303 if self.entries >= MAX_COPY_ENTRIES {
1304 return Err(crate::runtime::host::range_error(format!(
1305 "{op} {pair}: exceeds {MAX_COPY_ENTRIES} entries; copy smaller subtrees"
1306 )));
1307 }
1308 self.entries += 1;
1309 Ok(())
1310 }
1311
1312 fn settle(&self, budget: &mut dyn MutationBudget) -> wasmtime::Result<()> {
1313 budget.copied(self.entries, self.bytes)
1314 }
1315}
1316
1317struct CopyFrame {
1318 from: LinkPath,
1319 to: LinkPath,
1320 from_dir: Arc<cap_std::fs::Dir>,
1321 to_dir: Arc<cap_std::fs::Dir>,
1322 entries: cap_std::fs::ReadDir,
1323}
1324
1325fn copy_bounded(
1328 from: &LinkPath,
1329 to: &LinkPath,
1330 run: &CopyRun<'_>,
1331 work: &mut CopyWork,
1332) -> wasmtime::Result<()> {
1333 let mut frames = Vec::new();
1334 if let Some(frame) = copy_entry(from, to, run, work, 0)? {
1335 frames
1336 .try_reserve(1)
1337 .map_err(crate::runtime::host::fatal_host_error)?;
1338 frames.push(frame);
1339 }
1340 while let Some(frame) = frames.last_mut() {
1341 let Some(entry) = frame.entries.next() else {
1342 frames.pop();
1343 continue;
1344 };
1345 let entry = entry.map_err(|e| contain_trap(run.op, run.pair, &ContainError::from(e)))?;
1346 let name = entry.file_name();
1347 let from = frame.from.child(Arc::clone(&frame.from_dir), name.clone());
1348 let to = frame.to.child(Arc::clone(&frame.to_dir), name);
1349 if let Some(child) = copy_entry(&from, &to, run, work, frames.len())? {
1350 frames
1351 .try_reserve(1)
1352 .map_err(crate::runtime::host::fatal_host_error)?;
1353 frames.push(child);
1354 }
1355 }
1356 Ok(())
1357}
1358
1359fn copy_entry(
1360 from: &LinkPath,
1361 to: &LinkPath,
1362 run: &CopyRun<'_>,
1363 work: &mut CopyWork,
1364 depth: usize,
1365) -> wasmtime::Result<Option<CopyFrame>> {
1366 let CopyRun {
1367 recursive,
1368 links,
1369 op,
1370 pair,
1371 quota,
1372 } = *run;
1373 work.visit(op, pair)?;
1374 let meta = from
1375 .symlink_metadata()
1376 .map_err(|e| contain_trap(op, pair, &e))?;
1377 let ft = meta.file_type();
1378 if ft.is_symlink() {
1379 let result = copy_link(from, to);
1380 if depth == 0 || links == LinkCopies::Required {
1383 result.map_err(|e| contain_trap(op, pair, &e))?;
1384 }
1385 return Ok(None);
1386 }
1387 if ft.is_dir() {
1388 if !recursive {
1389 wasmtime::bail!("{op} {pair}: source is a directory (use {{ recursive: true }})");
1390 }
1391 if depth >= MAX_COPY_DEPTH {
1392 return Err(crate::runtime::host::range_error(format!(
1393 "{op} {pair}: exceeds {MAX_COPY_DEPTH} directory levels; copy smaller subtrees"
1394 )));
1395 }
1396 to.create_dir_all()
1397 .map_err(|e| contain_trap(op, pair, &e))?;
1398 let from_dir = Arc::new(from.open_dir().map_err(|e| contain_trap(op, pair, &e))?);
1399 let to_dir = Arc::new(to.open_dir().map_err(|e| contain_trap(op, pair, &e))?);
1400 let entries = from_dir
1401 .entries()
1402 .map_err(|e| contain_trap(op, pair, &ContainError::from(e)))?;
1403 return Ok(Some(CopyFrame {
1404 from: from.clone(),
1405 to: to.clone(),
1406 from_dir,
1407 to_dir,
1408 entries,
1409 }));
1410 }
1411 let mut disk_charge = QuotaCharge::in_place(quota.cloned(), to.copied_onto_file());
1414 disk_charge
1415 .reserve(meta.len())
1416 .map_err(|exceeded| quota_refusal(op, pair, exceeded))?;
1417 match from.copy_to_counted(to) {
1418 Ok(bytes) => {
1419 work.bytes = work.bytes.saturating_add(bytes);
1420 disk_charge.commit();
1421 }
1422 Err(e) => {
1423 disk_charge.settle_at(to.copied_onto_file().map_or(0, |(_, bytes)| bytes));
1424 return Err(contain_trap(op, pair, &e));
1425 }
1426 }
1427 Ok(None)
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432 #[tokio::test]
1433 async fn resource_serialization_keeps_identity_without_local_handles() {
1434 use super::*;
1435 use crate::runtime::limits::TenantLimits;
1436
1437 let root = tempfile::tempdir().unwrap();
1438 std::fs::write(root.path().join("input"), b"abc").unwrap();
1439 let data = crate::runtime::StoreData::with_vfs(
1440 "run".into(),
1441 Vfs::external(root.path().to_owned()).unwrap(),
1442 );
1443 let filesystem = data.filesystem;
1444 let limits = TenantLimits::new(1 << 20);
1445 let mut reader = filesystem
1446 .open_reader(
1447 &"open-reader".into(),
1448 None,
1449 "/input",
1450 ReaderOptions::Bytes { chunk_size: 3 },
1451 )
1452 .await
1453 .unwrap();
1454 let mut writer = filesystem
1455 .open_writer(&"open-writer".into(), None, "/output")
1456 .await
1457 .unwrap();
1458 let mut directory = filesystem
1459 .open_directory(&"open-directory".into(), None, "/", false, &limits)
1460 .await
1461 .unwrap();
1462
1463 let encoded_reader = serde_json::to_value(&reader).unwrap();
1465 let encoded_writer = serde_json::to_value(&writer).unwrap();
1466 let encoded_directory = serde_json::to_value(&directory).unwrap();
1467 for encoded in [&encoded_reader, &encoded_writer, &encoded_directory] {
1468 assert_eq!(encoded["run_id"], "run");
1469 assert!(encoded.get("local").is_none());
1470 }
1471 let restored_reader: FileReader = serde_json::from_value(encoded_reader).unwrap();
1472 let restored_writer: FileWriter = serde_json::from_value(encoded_writer).unwrap();
1473 let restored_directory: DirectoryReader =
1474 serde_json::from_value(encoded_directory).unwrap();
1475 assert!(restored_reader.local.is_none());
1476 assert!(restored_writer.local.is_none());
1477 assert!(restored_directory.local.is_none());
1478 assert_eq!(restored_reader.id, reader.id);
1479 assert_eq!(restored_writer.id, writer.id);
1480 assert_eq!(restored_directory.id, directory.id);
1481 drop((restored_reader, restored_writer, restored_directory));
1482
1483 let output = filesystem
1484 .next_reader(&"read".into(), None, &mut reader, &limits)
1485 .await
1486 .unwrap();
1487 assert!(matches!(output.item, Some(ReadItem::Bytes(bytes)) if bytes == b"abc"));
1488 assert!(
1489 filesystem
1490 .next_directory(&"next".into(), None, &mut directory, &limits)
1491 .await
1492 .unwrap()
1493 .is_some()
1494 );
1495 filesystem
1496 .write_chunk(&"write".into(), None, &mut writer, b"saved".to_vec())
1497 .await
1498 .unwrap();
1499 filesystem
1500 .close_writer(&"close".into(), None, &mut writer)
1501 .await
1502 .unwrap();
1503 assert_eq!(std::fs::read(root.path().join("output")).unwrap(), b"saved");
1504 }
1505
1506 #[test]
1507 fn logical_lines_bound_memory_and_restore_without_buffered_read_ahead() {
1508 use super::*;
1509 use crate::runtime::limits::TenantLimits;
1510 let limits = TenantLimits::new(1 << 20);
1511 let work = Arc::new(WorkBudget::new(u64::MAX));
1512 let mut file = std::io::Cursor::new(b"\xef\xbb\xbfhi\r\n\xff\n");
1513 let options = ReaderOptions::Lines { maximum_bytes: 32 };
1514 let mut position = ReadPosition::default();
1515 let output = read_item(
1516 &mut file,
1517 &options,
1518 &mut position,
1519 Some(work.as_ref()),
1520 &limits,
1521 )
1522 .unwrap();
1523 assert!(matches!(&output.item, Some(ReadItem::Line(line)) if line == "hi"));
1524 assert_eq!(position.offset, 7);
1525 assert!(limits.host_attached_bytes() > 0);
1526 drop(output);
1527 let output = read_item(
1528 &mut file,
1529 &options,
1530 &mut position,
1531 Some(work.as_ref()),
1532 &limits,
1533 )
1534 .unwrap();
1535 assert!(matches!(&output.item, Some(ReadItem::Line(line)) if line == "\u{fffd}"));
1536 drop(output);
1537 assert!(
1538 read_item(
1539 &mut file,
1540 &options,
1541 &mut position,
1542 Some(work.as_ref()),
1543 &limits
1544 )
1545 .unwrap()
1546 .item
1547 .is_none()
1548 );
1549 assert_eq!(limits.host_attached_bytes(), 0);
1550 }
1551
1552 #[test]
1553 fn line_limit_and_memory_refusal_leave_progress_retryable() {
1554 use super::*;
1555 use crate::runtime::limits::TenantLimits;
1556 let limits = TenantLimits::new(1 << 20);
1557 let mut position = ReadPosition::default();
1558 let mut file = std::io::Cursor::new(b"abcde");
1559 let options = ReaderOptions::Lines { maximum_bytes: 4 };
1560 for _ in 0..2 {
1561 let error = read_item(&mut file, &options, &mut position, None, &limits)
1562 .err()
1563 .unwrap();
1564 assert!(error.to_string().contains("maxReadSize"));
1565 assert_eq!(position.offset, 0);
1566 assert_eq!(limits.host_attached_bytes(), 0);
1567 }
1568 let limits = TenantLimits::new(8192 + 4);
1569 let mut file = std::io::Cursor::new(b"abcd");
1570 let error = read_item(&mut file, &options, &mut position, None, &limits)
1571 .err()
1572 .unwrap();
1573 assert!(
1574 matches!(error, FilesystemError::Execution(ref e) if e.is::<crate::runtime::limits::MemoryCapExceeded>())
1575 );
1576 assert_eq!(position.offset, 0);
1577 assert_eq!(limits.host_attached_bytes(), 0);
1578 }
1579
1580 #[test]
1581 fn read_failures_refund_buffers_and_charge_consumed_io_without_advancing_progress() {
1582 use super::*;
1583 use crate::runtime::limits::TenantLimits;
1584 struct FailsAfterOne(std::io::Cursor<[u8; 2]>);
1585 impl Read for FailsAfterOne {
1586 fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
1587 if self.0.position() > 0 {
1588 return Err(std::io::Error::other("injected read failure"));
1589 }
1590 self.0.read(&mut bytes[..1])
1591 }
1592 }
1593 impl Seek for FailsAfterOne {
1594 fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
1595 self.0.seek(position)
1596 }
1597 }
1598 let limits = TenantLimits::new(1 << 20);
1599 let work = Arc::new(crate::runtime::fuel::WorkBudget::new(u64::MAX));
1600 let mut file = FailsAfterOne(std::io::Cursor::new(*b"ab"));
1601 let mut position = ReadPosition::default();
1602 assert!(
1603 read_item(
1604 &mut file,
1605 &ReaderOptions::Bytes { chunk_size: 2 },
1606 &mut position,
1607 Some(work.as_ref()),
1608 &limits
1609 )
1610 .is_err()
1611 );
1612 assert_eq!(position.offset, 0);
1613 assert_eq!(limits.host_attached_bytes(), 0);
1614 assert!(work.spent() >= crate::runtime::fuel::IO.cost(1));
1615 }
1616
1617 #[test]
1618 fn unknown_read_cost_settles_after_read_even_when_fuel_is_spent() {
1619 use super::*;
1620 let limits = crate::runtime::limits::TenantLimits::new(1 << 20);
1621 let work = Arc::new(crate::runtime::fuel::WorkBudget::new(0));
1622 let mut file = std::io::Cursor::new(b"line\n");
1623 let mut position = ReadPosition::default();
1624 assert!(
1625 read_item(
1626 &mut file,
1627 &ReaderOptions::Lines { maximum_bytes: 32 },
1628 &mut position,
1629 Some(work.as_ref()),
1630 &limits
1631 )
1632 .unwrap()
1633 .item
1634 .is_some()
1635 );
1636 assert_eq!(position.offset, 5);
1637 assert!(work.spent() > 0);
1638 }
1639
1640 use super::*;
1641 use crate::runtime::fs::resolve_link;
1642 #[test]
1643 fn copy_entry_and_depth_limits_preserve_completed_work() {
1644 let root = tempfile::tempdir().unwrap();
1645 std::fs::create_dir(root.path().join("source")).unwrap();
1646 std::fs::write(root.path().join("source/file"), "content").unwrap();
1647 let vfs = Vfs::external(root.path().to_path_buf()).unwrap();
1648 let from = resolve_link(&vfs, "/", "/source").unwrap();
1649 let to = resolve_link(&vfs, "/", "/target").unwrap();
1650 let run = CopyRun {
1651 recursive: true,
1652 links: LinkCopies::Required,
1653 op: "fs.copy",
1654 pair: "source -> target",
1655 quota: None,
1656 };
1657 let mut work = CopyWork {
1658 entries: MAX_COPY_ENTRIES - 1,
1659 bytes: 0,
1660 };
1661 let error = copy_bounded(&from, &to, &run, &mut work).unwrap_err();
1662 assert!(error.to_string().contains("entries"));
1663 assert!(root.path().join("target").is_dir());
1664 assert!(!root.path().join("target/file").exists());
1665 assert_eq!(
1666 std::fs::read(root.path().join("source/file")).unwrap(),
1667 b"content"
1668 );
1669 let to = resolve_link(&vfs, "/", "/too-deep").unwrap();
1670 let error = copy_entry(&from, &to, &run, &mut CopyWork::default(), MAX_COPY_DEPTH)
1671 .err()
1672 .unwrap();
1673 assert!(error.to_string().contains("directory levels"));
1674 assert!(!root.path().join("too-deep").exists());
1675 }
1676
1677 fn build_chain(root: &std::path::Path, branch: &str, depth: usize) -> Vec<String> {
1680 let mut path = root.join(branch);
1681 let mut rel = branch.to_string();
1682 std::fs::create_dir_all(&path).unwrap();
1683 std::fs::write(path.join("f.txt"), "x").unwrap();
1684 let mut expected = vec![rel.clone(), format!("{rel}/f.txt")];
1685 for level in 1..=depth {
1686 path = path.join(format!("l{level}"));
1687 rel = format!("{rel}/l{level}");
1688 std::fs::create_dir(&path).unwrap();
1689 std::fs::write(path.join("f.txt"), "x").unwrap();
1690 expected.push(rel.clone());
1691 expected.push(format!("{rel}/f.txt"));
1692 }
1693 expected
1694 }
1695
1696 fn walk_over(root: &std::path::Path, listed: &str, recursive: bool) -> ContainedWalk {
1697 let vfs = crate::runtime::Vfs::external(root.to_path_buf()).unwrap();
1698 let resolved = crate::runtime::fs::resolve_content(&vfs, "/", listed).unwrap();
1699 let base = Arc::new(resolved.open_dir().unwrap());
1700 let base_prefix = if listed == "/" {
1701 String::new()
1702 } else {
1703 format!("{}/", listed.trim_start_matches('/'))
1704 };
1705 ContainedWalk::new(base, base_prefix, recursive, Vec::new()).unwrap()
1706 }
1707
1708 fn walk_all(root: &std::path::Path, listed: &str) -> Vec<DirectoryEntry> {
1709 let mut walk = walk_over(root, listed, true);
1710 std::iter::from_fn(|| walk.next_entry()).collect()
1711 }
1712
1713 fn assert_subtrees_contiguous(entries: &[DirectoryEntry]) {
1716 for (i, dir) in entries.iter().enumerate() {
1717 if dir.kind != "directory" {
1718 continue;
1719 }
1720 let prefix = format!("{}/", dir.path);
1721 let inside: Vec<usize> = entries
1722 .iter()
1723 .enumerate()
1724 .filter(|(_, e)| e.path.starts_with(&prefix))
1725 .map(|(j, _)| j)
1726 .collect();
1727 let (Some(&first), Some(&last)) = (inside.first(), inside.last()) else {
1728 continue;
1729 };
1730 assert!(
1731 first > i,
1732 "{} yielded before its parent",
1733 entries[first].path
1734 );
1735 assert_eq!(
1736 last - first + 1,
1737 inside.len(),
1738 "entries from outside {} are interleaved into its subtree",
1739 dir.path,
1740 );
1741 }
1742 }
1743
1744 #[test]
1745 fn deep_tree_yields_every_entry_exactly_once() {
1746 let dir = tempfile::tempdir().unwrap();
1747 let mut expected = build_chain(dir.path(), "a", MAX_OPEN_DIRS + 5);
1748 let mut got: Vec<String> = walk_all(dir.path(), "/")
1749 .into_iter()
1750 .map(|e| e.path.trim_start_matches('/').to_string())
1751 .collect();
1752 got.sort();
1753 expected.sort();
1754 assert_eq!(
1755 got, expected,
1756 "deep walk must yield every entry exactly once"
1757 );
1758 }
1759
1760 #[test]
1764 fn a_wide_level_past_the_open_dir_cap_walks_completely() {
1765 let dir = tempfile::tempdir().unwrap();
1766 let mut expected = build_chain(dir.path(), "a", MAX_OPEN_DIRS + 2);
1767 let deep = expected
1768 .iter()
1769 .filter(|p| !p.ends_with("f.txt"))
1770 .max_by_key(|p| p.matches('/').count())
1771 .expect("the deepest directory")
1772 .clone();
1773 for i in 0..64 {
1774 let child = format!("{deep}/w{i}");
1775 std::fs::create_dir(dir.path().join(&child)).unwrap();
1776 std::fs::write(dir.path().join(&child).join("f.txt"), "x").unwrap();
1777 expected.push(child.clone());
1778 expected.push(format!("{child}/f.txt"));
1779 }
1780
1781 let mut got: Vec<String> = walk_all(dir.path(), "/")
1782 .into_iter()
1783 .map(|e| e.path.trim_start_matches('/').to_string())
1784 .collect();
1785 got.sort();
1786 expected.sort();
1787 assert_eq!(
1788 got, expected,
1789 "every postponed subtree must still be walked"
1790 );
1791 }
1792
1793 #[test]
1794 fn deep_tree_under_a_nested_base_keeps_the_base_prefix() {
1795 let dir = tempfile::tempdir().unwrap();
1796 let expected: Vec<String> = build_chain(dir.path(), "a", MAX_OPEN_DIRS + 5)
1797 .into_iter()
1798 .filter(|p| p != "a")
1799 .map(|p| format!("/{p}"))
1800 .collect();
1801 let mut got: Vec<String> = walk_all(dir.path(), "/a")
1802 .into_iter()
1803 .map(|e| e.path)
1804 .collect();
1805 let mut expected = expected;
1806 got.sort();
1807 expected.sort();
1808 assert_eq!(
1809 got, expected,
1810 "guest paths keep the listed directory's prefix"
1811 );
1812 }
1813
1814 #[test]
1815 fn deep_subtrees_stay_contiguous_past_the_open_dir_cap() {
1816 let dir = tempfile::tempdir().unwrap();
1817 build_chain(dir.path(), "a", MAX_OPEN_DIRS + 5);
1818 build_chain(dir.path(), "b", MAX_OPEN_DIRS + 5);
1819 assert_subtrees_contiguous(&walk_all(dir.path(), "/"));
1820 }
1821
1822 #[test]
1823 fn open_directory_handles_stay_within_the_cap() {
1824 let dir = tempfile::tempdir().unwrap();
1825 build_chain(dir.path(), "a", MAX_OPEN_DIRS + 5);
1826 let mut walk = walk_over(dir.path(), "/", true);
1827 let mut deepest = 0;
1828 while walk.next_entry().is_some() {
1829 deepest = deepest.max(walk.open_dirs);
1830 assert!(
1831 walk.open_dirs <= MAX_OPEN_DIRS,
1832 "walk held {} directory handles",
1833 walk.open_dirs,
1834 );
1835 }
1836 assert_eq!(deepest, MAX_OPEN_DIRS, "a chain past the cap must reach it");
1837 }
1838
1839 #[cfg(unix)]
1840 #[test]
1841 fn a_subdirectory_that_would_not_open_is_retried_from_the_base() {
1842 use std::os::unix::fs::PermissionsExt;
1843
1844 let dir = tempfile::tempdir().unwrap();
1845 let blocked = dir.path().join("w");
1846 std::fs::create_dir(&blocked).unwrap();
1847 std::fs::write(blocked.join("inside.txt"), "x").unwrap();
1848 std::fs::write(dir.path().join("sibling.txt"), "x").unwrap();
1849 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
1850 if std::fs::read_dir(&blocked).is_ok() {
1851 eprintln!("skipping: this user can read a 0o000 directory");
1852 return;
1853 }
1854
1855 let mut walk = walk_over(dir.path(), "/", true);
1856 let mut paths = Vec::new();
1857 while let Some(entry) = walk.next_entry() {
1858 paths.push(entry.path);
1859 if entry.name == "w" {
1862 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
1863 }
1864 }
1865 assert!(
1866 paths.iter().any(|p| p == "/w/inside.txt"),
1867 "a subdirectory that failed to open must be retried, got {paths:?}",
1868 );
1869 }
1870
1871 #[cfg(unix)]
1872 #[test]
1873 fn a_deferred_directory_with_a_non_utf8_name_still_walks() {
1874 use std::os::unix::ffi::OsStrExt;
1875
1876 let dir = tempfile::tempdir().unwrap();
1877 build_chain(dir.path(), "a", MAX_OPEN_DIRS + 3);
1878 let mut deepest = dir.path().join("a");
1879 for level in 1..=(MAX_OPEN_DIRS + 3) {
1880 deepest = deepest.join(format!("l{level}"));
1881 }
1882 let odd = deepest.join(std::ffi::OsStr::from_bytes(b"bad\xffname"));
1883 if std::fs::create_dir(&odd).is_err() {
1886 eprintln!("skipping: this filesystem rejects non-UTF-8 names");
1887 return;
1888 }
1889 std::fs::write(odd.join("in.txt"), "x").unwrap();
1890
1891 let entries = walk_all(dir.path(), "/");
1892 assert_eq!(
1893 entries.iter().filter(|e| e.name == "in.txt").count(),
1894 1,
1895 "a deferred directory whose name is not UTF-8 must still be reopened",
1896 );
1897 }
1898}