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#[derive(Clone)]
23pub struct DataDirectory {
24 path: Arc<PathBuf>,
25 directory: Arc<Dir>,
26}
27
28impl DataDirectory {
29 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 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 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 #[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#[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}