Skip to main content

vsh_commit/
host.rs

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/// Bounds for eager host metadata traversal; file and link bytes remain lazy.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub struct SnapshotLimits {
25    /// Maximum nodes including the virtual root.
26    pub max_nodes: usize,
27    /// Maximum directory nesting below the root.
28    pub max_depth: usize,
29    /// Maximum sum of file and symlink byte sizes represented by metadata.
30    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/// Capability-scoped host filesystem observation failure.
44#[derive(Debug)]
45pub enum HostError {
46    /// A user-visible workspace operation failed.
47    Io {
48        /// Stable operation label.
49        operation: &'static str,
50        /// Exact virtual path.
51        path: VPath,
52        /// Underlying host error.
53        source: io::Error,
54    },
55    /// An internal runtime-directory operation failed.
56    InternalIo {
57        /// Stable operation label.
58        operation: &'static str,
59        /// Capability-relative internal path.
60        path: PathBuf,
61        /// Underlying host error.
62        source: io::Error,
63    },
64    /// The host entry is neither a regular file, directory, nor symbolic link.
65    UnsupportedNode {
66        /// Rejected path.
67        path: VPath,
68    },
69    /// A host name cannot be represented by portable [`VPath`] UTF-8.
70    NonUtf8Name {
71        /// Directory containing the name.
72        parent: VPath,
73        /// Rejected host name.
74        name: OsString,
75    },
76    /// A Windows symbolic-link target cannot be represented portably.
77    NonUtf8Symlink {
78        /// Rejected link path.
79        path: VPath,
80    },
81    /// The platform did not expose a stable node identity.
82    MissingFileIdentity {
83        /// Affected path.
84        path: VPath,
85    },
86    /// Metadata changed around a supposedly stable read or enumeration.
87    Unstable {
88        /// Affected path.
89        path: VPath,
90        /// First metadata observation.
91        before: Box<FileStamp>,
92        /// Second metadata observation.
93        after: Box<FileStamp>,
94    },
95    /// Snapshot traversal exceeded a configured bound.
96    SnapshotLimit {
97        /// Stable limit name.
98        limit: &'static str,
99        /// Observed value.
100        observed: u64,
101        /// Configured maximum.
102        maximum: u64,
103    },
104    /// Immutable snapshot construction failed.
105    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        // The staged inode was already flushed through its writable handle. A
210        // read-only Windows handle cannot call FlushFileBuffers, and Windows
211        // exposes no directory-entry fsync equivalent for the new hard link.
212        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}