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