Skip to main content

vsh_store/
directory.rs

1use std::error::Error;
2use std::fmt;
3use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8#[cfg(windows)]
9use cap_fs_ext::MetadataExt as CapMetadataExt;
10use cap_std::ambient_authority;
11#[cfg(unix)]
12use cap_std::fs::MetadataExt;
13use cap_std::fs::{Dir, Metadata, OpenOptions};
14
15const RUNTIME_DIRECTORY: &str = ".vsh-runtime";
16const DATA_DIRECTORY: &str = "data";
17
18/// A pinned capability for VSH's durable data directory.
19///
20/// Runtime-owned files are opened relative to this handle, so replacing an
21/// ambient ancestor with a symlink cannot redirect later blob or state-store I/O.
22#[derive(Clone)]
23pub struct DataDirectory {
24    path: Arc<PathBuf>,
25    directory: Arc<Dir>,
26}
27
28impl DataDirectory {
29    /// Open a caller-selected, trusted data directory with ambient authority.
30    ///
31    /// The final component must be a real directory rather than a symbolic link.
32    /// Runtime callers should keep this directory disjoint from the untrusted
33    /// workspace; [`Self::open_workspace`] is the safe constructor for the default
34    /// workspace-local location.
35    ///
36    /// # Errors
37    ///
38    /// Returns an error if the directory cannot be created, pinned, or verified.
39    pub fn open_trusted(path: impl AsRef<Path>) -> Result<Self, DataDirectoryError> {
40        let requested = path.as_ref();
41        let path = std::path::absolute(requested).map_err(|source| {
42            DataDirectoryError::io("resolve trusted data directory", requested, source)
43        })?;
44        Dir::create_ambient_dir_all(&path, ambient_authority()).map_err(|source| {
45            DataDirectoryError::io("create trusted data directory", &path, source)
46        })?;
47        let before = fs::symlink_metadata(&path).map_err(|source| {
48            DataDirectoryError::io("inspect trusted data directory", &path, source)
49        })?;
50        if !before.is_dir() || before.file_type().is_symlink() {
51            return Err(DataDirectoryError::not_real(&path));
52        }
53        let directory = Dir::open_ambient_dir(&path, ambient_authority()).map_err(|source| {
54            DataDirectoryError::io("open trusted data directory", &path, source)
55        })?;
56        let opened = directory.dir_metadata().map_err(|source| {
57            DataDirectoryError::io("inspect opened data directory", &path, source)
58        })?;
59        let after = fs::symlink_metadata(&path).map_err(|source| {
60            DataDirectoryError::io("reinspect trusted data directory", &path, source)
61        })?;
62        if !after.is_dir()
63            || after.file_type().is_symlink()
64            || !ambient_directory_matches(&opened, &path)
65        {
66            return Err(DataDirectoryError::unstable(&path));
67        }
68        let canonical_path = fs::canonicalize(&path).map_err(|source| {
69            DataDirectoryError::io("canonicalize trusted data directory", &path, source)
70        })?;
71        let canonical = fs::symlink_metadata(&canonical_path).map_err(|source| {
72            DataDirectoryError::io(
73                "inspect canonical trusted data directory",
74                &canonical_path,
75                source,
76            )
77        })?;
78        let final_named = fs::symlink_metadata(&path).map_err(|source| {
79            DataDirectoryError::io("finalize trusted data directory", &path, source)
80        })?;
81        if !canonical.is_dir()
82            || canonical.file_type().is_symlink()
83            || !ambient_directory_matches(&opened, &canonical_path)
84            || !final_named.is_dir()
85            || final_named.file_type().is_symlink()
86            || !ambient_directory_matches(&opened, &path)
87        {
88            return Err(DataDirectoryError::unstable(&path));
89        }
90        sync_directory(&directory).map_err(|source| {
91            DataDirectoryError::io("sync trusted data directory", &path, source)
92        })?;
93        Ok(Self::new(canonical_path, directory))
94    }
95
96    /// Open the protected `.vsh-runtime/data` directory below a workspace handle.
97    ///
98    /// Every child is created and verified relative to a pinned workspace
99    /// capability. Pre-existing symlinks and directory-swap races therefore fail
100    /// closed without writing outside the workspace root.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if the workspace cannot be opened or either protected
105    /// component is not a stable real directory.
106    pub fn open_workspace(workspace_root: impl AsRef<Path>) -> Result<Self, DataDirectoryError> {
107        let requested = workspace_root.as_ref();
108        let workspace_root = std::path::absolute(requested).map_err(|source| {
109            DataDirectoryError::io("resolve workspace data capability", requested, source)
110        })?;
111        let root =
112            Dir::open_ambient_dir(&workspace_root, ambient_authority()).map_err(|source| {
113                DataDirectoryError::io("open workspace data capability", &workspace_root, source)
114            })?;
115        let runtime_path = workspace_root.join(RUNTIME_DIRECTORY);
116        let runtime = open_or_create_real_dir(&root, RUNTIME_DIRECTORY).map_err(|source| {
117            DataDirectoryError::io("open protected runtime directory", &runtime_path, source)
118        })?;
119        let data = Self::open_runtime_data(&runtime, &workspace_root)?;
120        sync_directory(&runtime).map_err(|source| {
121            DataDirectoryError::io("sync protected runtime directory", &runtime_path, source)
122        })?;
123        sync_directory(&root).map_err(|source| {
124            DataDirectoryError::io("sync workspace root", &workspace_root, source)
125        })?;
126        Ok(data)
127    }
128
129    /// Open `.vsh-runtime/data` relative to an already pinned runtime directory.
130    ///
131    /// This constructor lets the trusted committer and durable stores share one
132    /// runtime-directory identity instead of reopening an ambient path.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if `data` cannot be created, pinned, or synchronized.
137    pub fn open_runtime_data(
138        runtime: &Dir,
139        workspace_root: impl AsRef<Path>,
140    ) -> Result<Self, DataDirectoryError> {
141        let requested = workspace_root.as_ref();
142        let workspace_root = std::path::absolute(requested).map_err(|source| {
143            DataDirectoryError::io("resolve protected data directory", requested, source)
144        })?;
145        let data_path = workspace_root.join(RUNTIME_DIRECTORY).join(DATA_DIRECTORY);
146        let data = open_or_create_real_dir(runtime, DATA_DIRECTORY).map_err(|source| {
147            DataDirectoryError::io("open protected data directory", &data_path, source)
148        })?;
149        sync_directory(&data).map_err(|source| {
150            DataDirectoryError::io("sync protected data directory", &data_path, source)
151        })?;
152        sync_directory(runtime).map_err(|source| {
153            DataDirectoryError::io(
154                "sync protected data-directory parent",
155                &workspace_root.join(RUNTIME_DIRECTORY),
156                source,
157            )
158        })?;
159        Ok(Self::new(data_path, data))
160    }
161
162    fn new(path: PathBuf, directory: Dir) -> Self {
163        Self {
164            path: Arc::new(path),
165            directory: Arc::new(directory),
166        }
167    }
168
169    /// Return the ambient path used only for diagnostics and backup tooling.
170    #[must_use]
171    pub fn path(&self) -> &Path {
172        &self.path
173    }
174
175    pub(crate) fn directory(&self) -> &Dir {
176        &self.directory
177    }
178
179    pub(crate) fn open_real_child(&self, name: &str) -> io::Result<Self> {
180        let directory = open_or_create_real_dir(&self.directory, name)?;
181        Ok(Self::new(self.path.join(name), directory))
182    }
183}
184
185impl fmt::Debug for DataDirectory {
186    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
187        formatter
188            .debug_struct("DataDirectory")
189            .field("path", &self.path)
190            .finish_non_exhaustive()
191    }
192}
193
194impl PartialEq for DataDirectory {
195    fn eq(&self, other: &Self) -> bool {
196        self.path == other.path
197    }
198}
199
200impl Eq for DataDirectory {}
201
202pub(crate) fn open_or_create_real_dir(parent: &Dir, name: &str) -> io::Result<Dir> {
203    match parent.create_dir(name) {
204        Ok(()) => {}
205        Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
206        Err(source) => return Err(source),
207    }
208    let before = parent.symlink_metadata(name)?;
209    if !before.is_dir() || before.is_symlink() {
210        return Err(not_real_directory_error());
211    }
212    let child = parent.open_dir(name)?;
213    let opened = child.dir_metadata()?;
214    let after = parent.symlink_metadata(name)?;
215    if !after.is_dir() || after.is_symlink() || !metadata_identity_matches(&opened, &after) {
216        return Err(unstable_directory_error());
217    }
218    Ok(child)
219}
220
221pub(crate) fn open_real_file(
222    directory: &Dir,
223    name: &str,
224    options: &OpenOptions,
225) -> io::Result<fs::File> {
226    let file = directory.open_with(name, options)?;
227    let opened = file.metadata()?;
228    let named = directory.symlink_metadata(name)?;
229    if !opened.is_file()
230        || !named.is_file()
231        || named.is_symlink()
232        || !metadata_identity_matches(&opened, &named)
233    {
234        return Err(io::Error::new(
235            io::ErrorKind::InvalidData,
236            "internal VSH file is not a stable real file",
237        ));
238    }
239    Ok(file.into_std())
240}
241
242#[cfg(not(windows))]
243pub(crate) fn sync_directory(directory: &Dir) -> io::Result<()> {
244    let mut options = OpenOptions::new();
245    options.read(true);
246    directory.open_with(".", &options)?.into_std().sync_all()
247}
248
249#[cfg(windows)]
250pub(crate) fn sync_directory(_directory: &Dir) -> io::Result<()> {
251    Ok(())
252}
253
254fn not_real_directory_error() -> io::Error {
255    io::Error::new(
256        io::ErrorKind::InvalidData,
257        "internal VSH path is not a real directory",
258    )
259}
260
261fn unstable_directory_error() -> io::Error {
262    io::Error::new(
263        io::ErrorKind::InvalidData,
264        "internal VSH directory changed while it was being pinned",
265    )
266}
267
268#[cfg(unix)]
269fn metadata_identity_matches(left: &Metadata, right: &Metadata) -> bool {
270    MetadataExt::dev(left) == MetadataExt::dev(right)
271        && MetadataExt::ino(left) == MetadataExt::ino(right)
272}
273
274#[cfg(windows)]
275fn metadata_identity_matches(left: &Metadata, right: &Metadata) -> bool {
276    <Metadata as CapMetadataExt>::dev(left) == <Metadata as CapMetadataExt>::dev(right)
277        && <Metadata as CapMetadataExt>::ino(left) == <Metadata as CapMetadataExt>::ino(right)
278}
279
280fn ambient_directory_matches(opened: &Metadata, path: &Path) -> bool {
281    Dir::open_ambient_dir(path, ambient_authority())
282        .and_then(|directory| directory.dir_metadata())
283        .is_ok_and(|named| metadata_identity_matches(opened, &named))
284}
285
286#[cfg(not(any(unix, windows)))]
287compile_error!("vsh-store currently supports Unix and Windows hosts");
288
289/// Failure to create or pin a durable VSH data directory.
290#[derive(Debug)]
291pub struct DataDirectoryError {
292    operation: &'static str,
293    path: PathBuf,
294    source: io::Error,
295}
296
297impl DataDirectoryError {
298    fn io(operation: &'static str, path: &Path, source: io::Error) -> Self {
299        Self {
300            operation,
301            path: path.to_owned(),
302            source,
303        }
304    }
305
306    fn not_real(path: &Path) -> Self {
307        Self::io(
308            "verify trusted data directory",
309            path,
310            not_real_directory_error(),
311        )
312    }
313
314    fn unstable(path: &Path) -> Self {
315        Self::io(
316            "verify trusted data directory",
317            path,
318            unstable_directory_error(),
319        )
320    }
321}
322
323impl fmt::Display for DataDirectoryError {
324    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
325        write!(
326            formatter,
327            "{} at {}: {}",
328            self.operation,
329            self.path.display(),
330            self.source
331        )
332    }
333}
334
335impl Error for DataDirectoryError {
336    fn source(&self) -> Option<&(dyn Error + 'static)> {
337        Some(&self.source)
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use std::sync::atomic::{AtomicU64, Ordering};
345
346    static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
347
348    struct TestDirectory(PathBuf);
349
350    impl TestDirectory {
351        fn new(name: &str) -> Self {
352            let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
353            let path = std::env::temp_dir().join(format!(
354                "vsh-data-directory-test-{}-{sequence}-{name}",
355                std::process::id()
356            ));
357            fs::create_dir(&path).unwrap();
358            Self(path)
359        }
360
361        fn path(&self) -> &Path {
362            &self.0
363        }
364    }
365
366    impl Drop for TestDirectory {
367        fn drop(&mut self) {
368            let _ = fs::remove_dir_all(&self.0);
369        }
370    }
371
372    #[cfg(not(windows))]
373    #[test]
374    fn capability_directory_can_be_synchronized() {
375        let workspace = TestDirectory::new("sync-capability");
376        let directory = Dir::open_ambient_dir(workspace.path(), ambient_authority()).unwrap();
377
378        sync_directory(&directory).unwrap();
379    }
380
381    #[test]
382    fn workspace_data_directory_is_real_and_pinned() {
383        let workspace = TestDirectory::new("pinned-workspace");
384        let data = DataDirectory::open_workspace(workspace.path()).unwrap();
385
386        assert_eq!(data.path(), workspace.path().join(".vsh-runtime/data"));
387        assert_eq!(data, data.clone());
388        assert!(format!("{data:?}").contains("DataDirectory"));
389
390        #[cfg(unix)]
391        {
392            use std::os::unix::fs::symlink;
393
394            let outside = TestDirectory::new("pinned-outside");
395            let original = workspace.path().join(".vsh-runtime");
396            let relocated = workspace.path().join(".vsh-runtime-relocated");
397            fs::rename(&original, &relocated).unwrap();
398            symlink(outside.path(), &original).unwrap();
399
400            let child = data.open_real_child("still-pinned").unwrap();
401            assert!(relocated.join("data/still-pinned").is_dir());
402            assert!(!outside.path().join("data/still-pinned").exists());
403            drop(child);
404        }
405    }
406
407    #[cfg(unix)]
408    #[test]
409    fn workspace_runtime_symlink_is_rejected_before_external_write() {
410        use std::os::unix::fs::symlink;
411
412        let workspace = TestDirectory::new("symlink-workspace");
413        let outside = TestDirectory::new("symlink-outside");
414        symlink(outside.path(), workspace.path().join(".vsh-runtime")).unwrap();
415
416        let error = DataDirectory::open_workspace(workspace.path()).unwrap_err();
417
418        assert_eq!(
419            error
420                .source()
421                .unwrap()
422                .downcast_ref::<io::Error>()
423                .unwrap()
424                .kind(),
425            io::ErrorKind::InvalidData
426        );
427        assert!(!outside.path().join("data").exists());
428    }
429
430    #[cfg(unix)]
431    #[test]
432    fn trusted_data_directory_rejects_a_final_symlink() {
433        use std::os::unix::fs::symlink;
434
435        let parent = TestDirectory::new("trusted-parent");
436        let outside = TestDirectory::new("trusted-outside");
437        let link = parent.path().join("data-link");
438        symlink(outside.path(), &link).unwrap();
439
440        let error = DataDirectory::open_trusted(&link).unwrap_err();
441        assert!(error.to_string().contains("data-link"));
442        assert!(error.source().is_some());
443    }
444
445    #[test]
446    fn missing_workspace_and_non_directory_data_fail_with_typed_sources() {
447        let parent = TestDirectory::new("invalid-components");
448        let missing = parent.path().join("missing-workspace");
449        let missing_error = DataDirectory::open_workspace(&missing).unwrap_err();
450        assert!(missing_error.to_string().contains("workspace"));
451        assert!(missing_error.source().is_some());
452
453        let file = parent.path().join("data-file");
454        fs::write(&file, b"not a directory").unwrap();
455        let file_error = DataDirectory::open_trusted(&file).unwrap_err();
456        assert!(file_error.to_string().contains("data-file"));
457        assert!(file_error.source().is_some());
458    }
459
460    #[cfg(unix)]
461    #[test]
462    fn workspace_data_symlink_is_rejected_without_external_writes() {
463        use std::os::unix::fs::symlink;
464
465        let workspace = TestDirectory::new("data-symlink-workspace");
466        let outside = TestDirectory::new("data-symlink-outside");
467        fs::create_dir(workspace.path().join(".vsh-runtime")).unwrap();
468        symlink(outside.path(), workspace.path().join(".vsh-runtime/data")).unwrap();
469
470        let error = DataDirectory::open_workspace(workspace.path()).unwrap_err();
471
472        assert_eq!(
473            error
474                .source()
475                .unwrap()
476                .downcast_ref::<io::Error>()
477                .unwrap()
478                .kind(),
479            io::ErrorKind::InvalidData
480        );
481        assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
482    }
483}