1use std::error::Error;
2use std::ffi::OsString;
3use std::fmt;
4use std::io::{self, Read};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8#[cfg(windows)]
9use cap_fs_ext::MetadataExt as CapMetadataExt;
10use cap_std::fs::{Dir, DirEntry, File, Metadata, MetadataExt, OpenOptions};
11#[cfg(unix)]
12use cap_std::fs::{Permissions, PermissionsExt};
13use vsh_store::BlobStore;
14use vsh_types::{
15 BlobId, ContentVersion, DirectoryDigest, FileStamp, NodeKind, NodeState, PlatformFileId, VPath,
16};
17use vsh_vfs::{BaseSnapshot, CapturedContent, ContentLoadError, SnapshotBuilder, SnapshotError};
18
19pub(crate) const RUNTIME_DIRECTORY: &str = ".vsh-runtime";
20pub(crate) const TRANSACTIONS_DIRECTORY: &str = "transactions";
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub struct SnapshotLimits {
25 pub max_nodes: usize,
27 pub max_depth: usize,
29 pub max_total_file_bytes: u64,
31}
32
33impl Default for SnapshotLimits {
34 fn default() -> Self {
35 Self {
36 max_nodes: 250_000,
37 max_depth: 128,
38 max_total_file_bytes: 16 * 1024 * 1024 * 1024,
39 }
40 }
41}
42
43#[derive(Debug)]
45pub enum HostError {
46 Io {
48 operation: &'static str,
50 path: VPath,
52 source: io::Error,
54 },
55 InternalIo {
57 operation: &'static str,
59 path: PathBuf,
61 source: io::Error,
63 },
64 UnsupportedNode {
66 path: VPath,
68 },
69 NonUtf8Name {
71 parent: VPath,
73 name: OsString,
75 },
76 NonUtf8Symlink {
78 path: VPath,
80 },
81 MissingFileIdentity {
83 path: VPath,
85 },
86 Unstable {
88 path: VPath,
90 before: Box<FileStamp>,
92 after: Box<FileStamp>,
94 },
95 SnapshotLimit {
97 limit: &'static str,
99 observed: u64,
101 maximum: u64,
103 },
104 Snapshot(SnapshotError),
106}
107
108impl HostError {
109 pub(crate) fn io(operation: &'static str, path: &VPath, source: io::Error) -> Self {
110 Self::Io {
111 operation,
112 path: path.clone(),
113 source,
114 }
115 }
116}
117
118impl fmt::Display for HostError {
119 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
120 match self {
121 Self::Io {
122 operation,
123 path,
124 source,
125 } => write!(formatter, "{operation} at {path}: {source}"),
126 Self::InternalIo {
127 operation,
128 path,
129 source,
130 } => write!(formatter, "{operation} at {}: {source}", path.display()),
131 Self::UnsupportedNode { path } => {
132 write!(formatter, "unsupported host node at {path}")
133 }
134 Self::NonUtf8Name { parent, name } => {
135 write!(
136 formatter,
137 "non-UTF-8 entry {} below {parent}",
138 name.display()
139 )
140 }
141 Self::NonUtf8Symlink { path } => {
142 write!(formatter, "symlink target at {path} is not portable UTF-8")
143 }
144 Self::MissingFileIdentity { path } => {
145 write!(
146 formatter,
147 "host did not expose a stable file identity for {path}"
148 )
149 }
150 Self::Unstable {
151 path,
152 before,
153 after,
154 } => write!(
155 formatter,
156 "host node changed during capture at {path}: {before:?} -> {after:?}"
157 ),
158 Self::SnapshotLimit {
159 limit,
160 observed,
161 maximum,
162 } => write!(
163 formatter,
164 "snapshot {limit} limit exceeded: observed {observed}, maximum {maximum}"
165 ),
166 Self::Snapshot(source) => fmt::Display::fmt(source, formatter),
167 }
168 }
169}
170
171impl Error for HostError {
172 fn source(&self) -> Option<&(dyn Error + 'static)> {
173 match self {
174 Self::Io { source, .. } | Self::InternalIo { source, .. } => Some(source),
175 Self::Snapshot(source) => Some(source),
176 Self::UnsupportedNode { .. }
177 | Self::NonUtf8Name { .. }
178 | Self::NonUtf8Symlink { .. }
179 | Self::MissingFileIdentity { .. }
180 | Self::Unstable { .. }
181 | Self::SnapshotLimit { .. } => None,
182 }
183 }
184}
185
186pub(crate) fn relative_path(path: &VPath) -> &Path {
187 Path::new(path.as_str())
188}
189
190#[cfg(not(windows))]
191pub(crate) fn sync_dir(dir: &Dir) -> io::Result<()> {
192 let mut options = OpenOptions::new();
193 options.read(true);
194 dir.open_with(".", &options)?.into_std().sync_all()
195}
196
197#[cfg(windows)]
198pub(crate) fn sync_dir(_dir: &Dir) -> io::Result<()> {
199 Ok(())
200}
201
202pub(crate) fn sync_installed_file(file: &File) -> io::Result<()> {
203 #[cfg(not(windows))]
204 {
205 file.sync_all()
206 }
207 #[cfg(windows)]
208 {
209 let _ = file;
213 Ok(())
214 }
215}
216
217pub(crate) fn stamp_at(root: &Dir, path: &VPath) -> Result<Option<FileStamp>, HostError> {
218 match root.symlink_metadata(relative_path(path)) {
219 Ok(metadata) => stamp_from_metadata(path, &metadata).map(Some),
220 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
221 Err(source) => Err(HostError::io("read metadata", path, source)),
222 }
223}
224
225pub(crate) fn stamp_file(file: &File, path: &VPath) -> Result<FileStamp, HostError> {
226 let metadata = file
227 .metadata()
228 .map_err(|source| HostError::io("read open-file metadata", path, source))?;
229 stamp_from_metadata(path, &metadata)
230}
231
232pub(crate) fn stamp_dir(dir: &Dir, path: &VPath) -> Result<FileStamp, HostError> {
233 let metadata = dir
234 .dir_metadata()
235 .map_err(|source| HostError::io("read open-directory metadata", path, source))?;
236 stamp_from_metadata(path, &metadata)
237}
238
239#[cfg(unix)]
240fn snapshot_entry_stamp(
241 _root: &Dir,
242 entry: &DirEntry,
243 path: &VPath,
244) -> Result<FileStamp, HostError> {
245 let metadata = entry
246 .metadata()
247 .map_err(|source| HostError::io("capture node metadata", path, source))?;
248 stamp_from_metadata(path, &metadata)
249}
250
251#[cfg(windows)]
252fn snapshot_entry_stamp(
253 root: &Dir,
254 _entry: &DirEntry,
255 path: &VPath,
256) -> Result<FileStamp, HostError> {
257 stamp_at(root, path)?.ok_or_else(|| {
258 HostError::io(
259 "capture node metadata",
260 path,
261 io::Error::new(io::ErrorKind::NotFound, "node disappeared"),
262 )
263 })
264}
265
266#[cfg(unix)]
267fn stamp_from_metadata(path: &VPath, metadata: &Metadata) -> Result<FileStamp, HostError> {
268 let kind = node_kind(path, metadata)?;
269 Ok(FileStamp {
270 kind,
271 size: if kind == NodeKind::Directory {
272 0
273 } else {
274 metadata.len()
275 },
276 mode: MetadataExt::mode(metadata) & 0o7777,
277 mtime_ns: i128::from(MetadataExt::mtime(metadata)) * 1_000_000_000
278 + i128::from(MetadataExt::mtime_nsec(metadata)),
279 ctime_ns: Some(
280 i128::from(MetadataExt::ctime(metadata)) * 1_000_000_000
281 + i128::from(MetadataExt::ctime_nsec(metadata)),
282 ),
283 file_id: PlatformFileId {
284 high: MetadataExt::dev(metadata),
285 low: MetadataExt::ino(metadata),
286 },
287 })
288}
289
290#[cfg(windows)]
291fn stamp_from_metadata(path: &VPath, metadata: &Metadata) -> Result<FileStamp, HostError> {
292 let kind = node_kind(path, metadata)?;
293 let high = <Metadata as CapMetadataExt>::dev(metadata);
294 let low = <Metadata as CapMetadataExt>::ino(metadata);
295 let readonly = metadata.permissions().readonly();
296 let mode = match (kind, readonly) {
297 (NodeKind::Directory, false) => 0o777,
298 (NodeKind::Directory, true) => 0o555,
299 (NodeKind::File, false) => 0o666,
300 (NodeKind::File, true) => 0o444,
301 (NodeKind::Symlink, _) => 0o777,
302 };
303 Ok(FileStamp {
304 kind,
305 size: if kind == NodeKind::Directory {
306 0
307 } else {
308 <Metadata as MetadataExt>::file_size(metadata)
309 },
310 mode,
311 mtime_ns: i128::from(<Metadata as MetadataExt>::last_write_time(metadata)) * 100,
312 ctime_ns: None,
313 file_id: PlatformFileId { high, low },
314 })
315}
316
317#[cfg(not(any(unix, windows)))]
318compile_error!("vsh-commit currently supports Unix and Windows hosts");
319
320fn node_kind(path: &VPath, metadata: &Metadata) -> Result<NodeKind, HostError> {
321 let file_type = metadata.file_type();
322 if file_type.is_file() {
323 Ok(NodeKind::File)
324 } else if file_type.is_dir() {
325 Ok(NodeKind::Directory)
326 } else if file_type.is_symlink() {
327 Ok(NodeKind::Symlink)
328 } else {
329 Err(HostError::UnsupportedNode { path: path.clone() })
330 }
331}
332
333pub(crate) fn stable_content(root: &Dir, path: &VPath) -> Result<CapturedContent, HostError> {
334 let before = stamp_at(root, path)?.ok_or_else(|| {
335 HostError::io(
336 "capture content",
337 path,
338 io::Error::new(io::ErrorKind::NotFound, "node disappeared"),
339 )
340 })?;
341 let bytes = match before.kind {
342 NodeKind::File => {
343 let mut file = root
344 .open(relative_path(path))
345 .map_err(|source| HostError::io("open file content", path, source))?;
346 let opened_before = stamp_file(&file, path)?;
347 if opened_before != before {
348 return Err(HostError::Unstable {
349 path: path.clone(),
350 before: Box::new(before),
351 after: Box::new(opened_before),
352 });
353 }
354 let mut bytes = Vec::new();
355 Read::by_ref(&mut file)
356 .take(before.size.saturating_add(1))
357 .read_to_end(&mut bytes)
358 .map_err(|source| HostError::io("read file content", path, source))?;
359 let opened_after = stamp_file(&file, path)?;
360 if opened_after != before {
361 return Err(HostError::Unstable {
362 path: path.clone(),
363 before: Box::new(before),
364 after: Box::new(opened_after),
365 });
366 }
367 bytes
368 }
369 NodeKind::Symlink => {
370 let target = root
371 .read_link_contents(relative_path(path))
372 .map_err(|source| HostError::io("read symlink target", path, source))?;
373 symlink_target_bytes(path, &target)?
374 }
375 NodeKind::Directory => {
376 return Err(HostError::io(
377 "capture directory content",
378 path,
379 io::Error::new(
380 io::ErrorKind::InvalidInput,
381 "directories have no byte content",
382 ),
383 ));
384 }
385 };
386 let after = stamp_at(root, path)?.ok_or_else(|| {
387 HostError::io(
388 "capture content",
389 path,
390 io::Error::new(io::ErrorKind::NotFound, "node disappeared"),
391 )
392 })?;
393 if after != before {
394 return Err(HostError::Unstable {
395 path: path.clone(),
396 before: Box::new(before),
397 after: Box::new(after),
398 });
399 }
400 if u64::try_from(bytes.len()).ok() != Some(before.size) {
401 return Err(HostError::io(
402 "capture stable content",
403 path,
404 io::Error::new(
405 io::ErrorKind::InvalidData,
406 "captured byte length does not match metadata",
407 ),
408 ));
409 }
410 Ok(CapturedContent {
411 bytes,
412 before,
413 after,
414 })
415}
416
417#[cfg(unix)]
418#[allow(clippy::unnecessary_wraps)]
419fn symlink_target_bytes(_path: &VPath, target: &Path) -> Result<Vec<u8>, HostError> {
420 use std::os::unix::ffi::OsStrExt;
421 Ok(target.as_os_str().as_bytes().to_vec())
422}
423
424#[cfg(windows)]
425fn symlink_target_bytes(path: &VPath, target: &Path) -> Result<Vec<u8>, HostError> {
426 target
427 .to_str()
428 .map(|value| value.as_bytes().to_vec())
429 .ok_or_else(|| HostError::NonUtf8Symlink { path: path.clone() })
430}
431
432pub(crate) fn state_matches(
433 root: &Dir,
434 path: &VPath,
435 expected: Option<NodeState>,
436) -> Result<(bool, Option<NodeState>), HostError> {
437 let Some(stamp) = stamp_at(root, path)? else {
438 return Ok((expected.is_none(), None));
439 };
440 let Some(expected) = expected else {
441 return Ok((false, Some(NodeState::from_stamp(stamp))));
442 };
443 let actual = match expected.content() {
444 Some(ContentVersion::Blob(_)) => {
445 if stamp.kind != expected.kind()
446 || stamp.size != expected.size()
447 || stamp.mode != expected.mode()
448 {
449 NodeState::from_stamp(stamp)
450 } else {
451 let capture = stable_content(root, path)?;
452 let actual_blob = BlobId::digest(&capture.bytes);
453 match stamp.kind {
454 NodeKind::File => NodeState::file(actual_blob, stamp.size, stamp.mode),
455 NodeKind::Symlink => NodeState::symlink(actual_blob, stamp.size, stamp.mode),
456 NodeKind::Directory => NodeState::from_stamp(stamp),
457 }
458 }
459 }
460 Some(_) => NodeState::from_stamp(stamp),
461 None => match stamp.kind {
462 NodeKind::Directory => NodeState::directory(stamp.mode),
463 NodeKind::File | NodeKind::Symlink => NodeState::from_stamp(stamp),
464 },
465 };
466 let matches = match expected.content() {
467 Some(ContentVersion::Stamp(expected_stamp)) => expected_stamp == stamp,
468 Some(ContentVersion::Blob(_)) | None => expected == actual,
469 Some(_) => false,
470 };
471 Ok((matches, Some(actual)))
472}
473
474pub(crate) fn relocated_state_matches(
475 root: &Dir,
476 path: &VPath,
477 expected: NodeState,
478) -> Result<bool, HostError> {
479 let Some(actual_stamp) = stamp_at(root, path)? else {
480 return Ok(false);
481 };
482 match expected.content() {
483 Some(ContentVersion::Stamp(expected_stamp)) => Ok(expected_stamp.kind == actual_stamp.kind
484 && expected_stamp.size == actual_stamp.size
485 && expected_stamp.mode == actual_stamp.mode
486 && expected_stamp.mtime_ns == actual_stamp.mtime_ns
487 && expected_stamp.file_id == actual_stamp.file_id),
488 Some(ContentVersion::Blob(_)) | None => {
489 state_matches(root, path, Some(expected)).map(|(matches, _)| matches)
490 }
491 Some(_) => Ok(false),
492 }
493}
494
495pub(crate) fn content_digest(root: &Dir, path: &VPath) -> Result<BlobId, HostError> {
496 stable_content(root, path).map(|capture| BlobId::digest(&capture.bytes))
497}
498
499pub(crate) fn directory_digest(
500 root: &Dir,
501 path: &VPath,
502 maximum_entries: usize,
503) -> Result<DirectoryDigest, HostError> {
504 let before = stamp_at(root, path)?.ok_or_else(|| {
505 HostError::io(
506 "read directory",
507 path,
508 io::Error::new(io::ErrorKind::NotFound, "directory disappeared"),
509 )
510 })?;
511 if before.kind != NodeKind::Directory {
512 return Err(HostError::io(
513 "read directory",
514 path,
515 io::Error::new(io::ErrorKind::NotADirectory, "node is not a directory"),
516 ));
517 }
518 let mut entries = Vec::new();
519 let iterator = root
520 .read_dir(relative_path(path))
521 .map_err(|source| HostError::io("enumerate directory", path, source))?;
522 for entry in iterator {
523 let entry = entry.map_err(|source| HostError::io("enumerate directory", path, source))?;
524 let name = entry.file_name();
525 let name = name.to_str().ok_or_else(|| HostError::NonUtf8Name {
526 parent: path.clone(),
527 name: name.clone(),
528 })?;
529 if path.is_root() && name == RUNTIME_DIRECTORY {
530 continue;
531 }
532 if entries.len() >= maximum_entries {
533 return Err(HostError::SnapshotLimit {
534 limit: "directory-entries",
535 observed: u64::try_from(entries.len())
536 .unwrap_or(u64::MAX)
537 .saturating_add(1),
538 maximum: u64::try_from(maximum_entries).unwrap_or(u64::MAX),
539 });
540 }
541 let child = path.join(name).map_err(|source| {
542 HostError::io("normalize directory entry", path, io::Error::other(source))
543 })?;
544 let stamp = stamp_at(root, &child)?.ok_or_else(|| {
545 HostError::io(
546 "read directory entry metadata",
547 &child,
548 io::Error::new(io::ErrorKind::NotFound, "entry disappeared"),
549 )
550 })?;
551 entries.push((child, NodeState::from_stamp(stamp)));
552 }
553 entries.sort_unstable_by(|left, right| left.0.cmp(&right.0));
554 let after = stamp_at(root, path)?.ok_or_else(|| {
555 HostError::io(
556 "read directory",
557 path,
558 io::Error::new(io::ErrorKind::NotFound, "directory disappeared"),
559 )
560 })?;
561 if before != after {
562 return Err(HostError::Unstable {
563 path: path.clone(),
564 before: Box::new(before),
565 after: Box::new(after),
566 });
567 }
568 Ok(DirectoryDigest::digest_entries(
569 entries.iter().map(|(child, state)| (child, *state)),
570 ))
571}
572
573#[allow(clippy::too_many_lines)]
574pub(crate) fn capture_snapshot(
575 root: &Arc<Dir>,
576 store: BlobStore,
577 limits: SnapshotLimits,
578) -> Result<BaseSnapshot, HostError> {
579 let root_path = VPath::root();
580 let root_stamp = stamp_dir(root, &root_path)?;
581 let mut builder = SnapshotBuilder::with_root_stamp(store, root_stamp);
582 let mut pending = vec![(root_path, 0_usize)];
583 let mut node_count = 1_usize;
584 let mut total_bytes = 0_u64;
585
586 while let Some((parent, depth)) = pending.pop() {
587 let before = stamp_at(root, &parent)?.ok_or_else(|| {
588 HostError::io(
589 "capture directory",
590 &parent,
591 io::Error::new(io::ErrorKind::NotFound, "directory disappeared"),
592 )
593 })?;
594 let iterator = root
595 .read_dir(relative_path(&parent))
596 .map_err(|source| HostError::io("enumerate snapshot directory", &parent, source))?;
597 let mut children = Vec::new();
598 for entry in iterator {
599 let entry = entry
600 .map_err(|source| HostError::io("enumerate snapshot directory", &parent, source))?;
601 let raw_name = entry.file_name();
602 let name = raw_name.to_str().ok_or_else(|| HostError::NonUtf8Name {
603 parent: parent.clone(),
604 name: raw_name.clone(),
605 })?;
606 if parent.is_root() && name == RUNTIME_DIRECTORY {
607 continue;
608 }
609 let child = parent.join(name).map_err(|source| {
610 HostError::io("normalize snapshot path", &parent, io::Error::other(source))
611 })?;
612 let stamp = snapshot_entry_stamp(root, &entry, &child)?;
613 children.push((child, stamp));
614 }
615 children.sort_unstable_by(|left, right| left.0.cmp(&right.0));
616 let after = stamp_at(root, &parent)?.ok_or_else(|| {
617 HostError::io(
618 "capture directory",
619 &parent,
620 io::Error::new(io::ErrorKind::NotFound, "directory disappeared"),
621 )
622 })?;
623 if before != after {
624 return Err(HostError::Unstable {
625 path: parent,
626 before: Box::new(before),
627 after: Box::new(after),
628 });
629 }
630
631 for (child, stamp) in children {
632 node_count = node_count.saturating_add(1);
633 if node_count > limits.max_nodes {
634 return Err(HostError::SnapshotLimit {
635 limit: "node-count",
636 observed: node_count as u64,
637 maximum: limits.max_nodes as u64,
638 });
639 }
640 match stamp.kind {
641 NodeKind::Directory => {
642 let next_depth = depth.saturating_add(1);
643 if next_depth > limits.max_depth {
644 return Err(HostError::SnapshotLimit {
645 limit: "depth",
646 observed: next_depth as u64,
647 maximum: limits.max_depth as u64,
648 });
649 }
650 builder
651 .add_stamped_directory(child.clone(), stamp)
652 .map_err(HostError::Snapshot)?;
653 pending.push((child, next_depth));
654 }
655 NodeKind::File | NodeKind::Symlink => {
656 total_bytes = total_bytes.saturating_add(stamp.size);
657 if total_bytes > limits.max_total_file_bytes {
658 return Err(HostError::SnapshotLimit {
659 limit: "total-file-bytes",
660 observed: total_bytes,
661 maximum: limits.max_total_file_bytes,
662 });
663 }
664 let loader_root = Arc::clone(root);
665 let loader_path = child.clone();
666 builder
667 .add_lazy(child, stamp, move |expected| {
668 let captured = stable_content(&loader_root, &loader_path)
669 .map_err(|source| ContentLoadError::new(source.to_string()))?;
670 if captured.before != expected || captured.after != expected {
671 return Err(ContentLoadError::new(
672 "snapshot node changed before lazy capture",
673 ));
674 }
675 Ok(captured)
676 })
677 .map_err(HostError::Snapshot)?;
678 }
679 }
680 }
681 }
682 builder.build().map_err(HostError::Snapshot)
683}
684
685pub(crate) fn open_or_create_real_dir(parent: &Dir, name: &str) -> io::Result<Dir> {
686 match parent.create_dir(name) {
687 Ok(()) => {}
688 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
689 Err(source) => return Err(source),
690 }
691 open_real_dir(parent, name)
692}
693
694pub(crate) fn open_real_dir(parent: &Dir, name: &str) -> io::Result<Dir> {
695 let before = parent.symlink_metadata(name)?;
696 if !before.is_dir() || before.is_symlink() {
697 return Err(io::Error::new(
698 io::ErrorKind::InvalidData,
699 "internal VSH path is not a real directory",
700 ));
701 }
702 let directory = parent.open_dir(name)?;
703 let opened = directory.dir_metadata()?;
704 let after = parent.symlink_metadata(name)?;
705 if !after.is_dir() || after.is_symlink() || !metadata_identity_matches(&opened, &after) {
706 return Err(io::Error::new(
707 io::ErrorKind::InvalidData,
708 "internal VSH directory changed while it was being pinned",
709 ));
710 }
711 Ok(directory)
712}
713
714pub(crate) fn open_real_file(parent: &Dir, name: &str) -> io::Result<File> {
715 let mut options = OpenOptions::new();
716 options.read(true);
717 open_real_file_with(parent, name, &options)
718}
719
720fn open_real_file_with(parent: &Dir, name: &str, options: &OpenOptions) -> io::Result<File> {
721 let before = parent.symlink_metadata(name)?;
722 if !before.is_file() || before.is_symlink() {
723 return Err(io::Error::new(
724 io::ErrorKind::InvalidData,
725 "internal VSH path is not a real file",
726 ));
727 }
728 let file = parent.open_with(name, options)?;
729 let opened = file.metadata()?;
730 let after = parent.symlink_metadata(name)?;
731 if !after.is_file() || after.is_symlink() || !metadata_identity_matches(&opened, &after) {
732 return Err(io::Error::new(
733 io::ErrorKind::InvalidData,
734 "internal VSH file changed while it was being pinned",
735 ));
736 }
737 Ok(file)
738}
739
740#[cfg(unix)]
741fn metadata_identity_matches(left: &Metadata, right: &Metadata) -> bool {
742 MetadataExt::dev(left) == MetadataExt::dev(right)
743 && MetadataExt::ino(left) == MetadataExt::ino(right)
744}
745
746#[cfg(windows)]
747fn metadata_identity_matches(left: &Metadata, right: &Metadata) -> bool {
748 <Metadata as CapMetadataExt>::dev(left) == <Metadata as CapMetadataExt>::dev(right)
749 && <Metadata as CapMetadataExt>::ino(left) == <Metadata as CapMetadataExt>::ino(right)
750}
751
752pub(crate) fn create_new_file(dir: &Dir, name: &str) -> io::Result<File> {
753 let mut options = OpenOptions::new();
754 options.write(true).create_new(true);
755 dir.open_with(name, &options)
756}
757
758pub(crate) fn open_coordination_file(
759 dir: &Dir,
760 name: &'static str,
761) -> Result<std::fs::File, HostError> {
762 let path = VPath::parse(name).expect("internal coordination filename is a valid VPath");
763 let mut create = OpenOptions::new();
764 create.read(true).write(true).create_new(true);
765 let file = match dir.open_with(name, &create) {
766 Ok(file) => file,
767 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
768 let mut existing = OpenOptions::new();
769 existing.read(true).write(true);
770 open_real_file_with(dir, name, &existing).map_err(|source| HostError::InternalIo {
771 operation: "open workspace coordination file",
772 path: PathBuf::from(name),
773 source,
774 })?
775 }
776 Err(source) => {
777 return Err(HostError::InternalIo {
778 operation: "create workspace coordination file",
779 path: PathBuf::from(name),
780 source,
781 });
782 }
783 };
784 let opened = stamp_file(&file, &path)?;
785 let named = stamp_at(dir, &path)?.ok_or_else(|| HostError::Unstable {
786 path: path.clone(),
787 before: Box::new(opened),
788 after: Box::new(opened),
789 })?;
790 if opened.kind != NodeKind::File
791 || opened.file_id != named.file_id
792 || named.kind != NodeKind::File
793 {
794 return Err(HostError::Unstable {
795 path,
796 before: Box::new(named),
797 after: Box::new(opened),
798 });
799 }
800 file.sync_all().map_err(|source| HostError::InternalIo {
801 operation: "sync workspace coordination file",
802 path: PathBuf::from(name),
803 source,
804 })?;
805 Ok(file.into_std())
806}
807
808pub(crate) fn set_file_mode(file: &File, mode: u32) -> io::Result<()> {
809 #[cfg(unix)]
810 {
811 file.set_permissions(Permissions::from_mode(mode))
812 }
813 #[cfg(windows)]
814 {
815 let mut permissions = file.metadata()?.permissions();
816 permissions.set_readonly(mode & 0o200 == 0);
817 file.set_permissions(permissions)
818 }
819}
820
821pub(crate) fn set_dir_mode(dir: &Dir, mode: u32) -> io::Result<()> {
822 #[cfg(unix)]
823 {
824 dir.set_permissions(".", Permissions::from_mode(mode))
825 }
826 #[cfg(windows)]
827 {
828 let mut permissions = dir.dir_metadata()?.permissions();
829 permissions.set_readonly(mode & 0o200 == 0);
830 dir.set_permissions(".", permissions)
831 }
832}
833
834pub(crate) fn witness_matches(
835 root: &Dir,
836 path: &VPath,
837 kind: NodeKind,
838 file_id: PlatformFileId,
839) -> Result<bool, HostError> {
840 Ok(stamp_at(root, path)?.is_some_and(|stamp| stamp.kind == kind && stamp.file_id == file_id))
841}
842
843pub(crate) fn validate_symlink_target(path: &VPath, bytes: &[u8]) -> Result<PathBuf, HostError> {
844 let target =
845 std::str::from_utf8(bytes).map_err(|_| HostError::NonUtf8Symlink { path: path.clone() })?;
846 if target.is_empty() {
847 return Err(HostError::io(
848 "validate symlink target",
849 path,
850 io::Error::new(io::ErrorKind::InvalidInput, "symlink target is empty"),
851 ));
852 }
853 let portable = target.replace('\\', "/");
854 let parent = path.parent().unwrap_or_else(VPath::root);
855 parent.join(&portable).map_err(|source| {
856 HostError::io(
857 "validate symlink target",
858 path,
859 io::Error::new(io::ErrorKind::PermissionDenied, source),
860 )
861 })?;
862 Ok(PathBuf::from(portable))
863}
864
865pub(crate) fn create_staged_symlink(
866 stage: &Dir,
867 name: &str,
868 root: &Dir,
869 path: &VPath,
870 target: &Path,
871) -> Result<(), HostError> {
872 #[cfg(unix)]
873 {
874 let _ = root;
875 stage
876 .symlink_contents(target, name)
877 .map_err(|source| HostError::io("create symbolic link", path, source))
878 }
879 #[cfg(windows)]
880 {
881 let parent = path.parent().unwrap_or_else(VPath::root);
882 let resolved = parent.join(&target.to_string_lossy()).map_err(|source| {
883 HostError::io(
884 "resolve symbolic-link type",
885 path,
886 io::Error::new(io::ErrorKind::InvalidInput, source),
887 )
888 })?;
889 let target_is_dir = root
890 .symlink_metadata(relative_path(&resolved))
891 .is_ok_and(|metadata| metadata.is_dir());
892 let result = if target_is_dir {
893 stage.symlink_dir(target, name)
894 } else {
895 stage.symlink_file(target, name)
896 };
897 result.map_err(|source| HostError::io("create symbolic link", path, source))
898 }
899}