Skip to main content

running_process_platform_internal/platform/
fs.rs

1//! Runtime-artifact identity, permissions, secure-open, replacement, and directory primitives.
2//!
3//! Callers name the *role* a directory plays for their product -- ephemeral
4//! runtime artifacts, persistent state, per-run scratch. Which location on this
5//! host plays that role, and how two accounts are kept apart there, is decided
6//! here. Callers still own their own layout beneath it: leaf names, extensions,
7//! and subdirectories are product conventions, not host mechanics.
8
9/// A descriptor the caller already owns and has asked us to write to.
10///
11/// Deliberately opaque. Callers hold host-specific things -- a `RawFd` on
12/// Unix, a `RawHandle` on Windows -- and there is no honest neutral spelling
13/// for *what they hold*, so the conversion into this type is host-specific
14/// and stays at the caller's edge. What is not host-specific is everything
15/// after: writing all of a buffer to it, retrying the partial writes and the
16/// interruptions that every host has in its own dialect.
17///
18/// This borrows. It does not close the descriptor, and it does not extend its
19/// lifetime: the caller who opened it still decides when it goes away, and
20/// using this after that is the same mistake as using the raw value would be.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct RawDescriptor(usize);
23
24impl RawDescriptor {
25    /// Wrap a host descriptor value. Host trees call this; callers do not.
26    pub(crate) fn from_value(value: usize) -> Self {
27        Self(value)
28    }
29
30    /// The underlying host value, for the host tree that will use it.
31    pub(crate) fn value(self) -> usize {
32        self.0
33    }
34}
35
36pub use crate::fs_write_all_to_descriptor as write_all_to_descriptor;
37
38#[cfg(feature = "fs")]
39pub use crate::{
40    fs_create_private_file as create_private_file, fs_decode_path_bytes as decode_path_bytes,
41    fs_encode_path_bytes as encode_path_bytes, fs_file_identity as file_identity,
42    fs_is_lock_conflict as is_lock_conflict, fs_open_lock_file as open_lock_file,
43    fs_path_identity as path_identity, fs_replace_file as replace_file,
44    fs_sync_directory as sync_directory, fs_try_lock_exclusive as try_lock_exclusive,
45    fs_unlock as unlock, fs_user_config_dir as user_config_dir, fs_user_data_dir as user_data_dir,
46    fs_user_run_data_root as user_run_data_root, fs_user_runtime_dir as user_runtime_dir,
47    fs_user_state_dir as user_state_dir, FsFileIdentity as FileIdentity,
48};
49
50#[cfg(all(test, feature = "fs"))]
51mod tests {
52    use super::*;
53
54    const PRODUCT: &str = "rp-fs-facade-test";
55
56    /// Every role resolves to an absolute directory that names the product.
57    ///
58    /// Asserted as a property rather than against one host's spelling: the
59    /// point of the facade is that callers cannot tell which host answered.
60    #[test]
61    fn every_role_is_an_absolute_product_scoped_directory() {
62        for directory in [
63            user_runtime_dir(PRODUCT),
64            user_state_dir(PRODUCT),
65            user_run_data_root(PRODUCT),
66        ] {
67            assert!(
68                directory.is_absolute(),
69                "{} must be absolute",
70                directory.display()
71            );
72            assert!(
73                directory.to_string_lossy().contains(PRODUCT),
74                "{} must be scoped to the product",
75                directory.display()
76            );
77        }
78    }
79
80    /// Two products never share a directory in any role.
81    #[test]
82    fn distinct_products_do_not_collide() {
83        let other = "rp-fs-facade-other";
84        assert_ne!(user_runtime_dir(PRODUCT), user_runtime_dir(other));
85        assert_ne!(user_state_dir(PRODUCT), user_state_dir(other));
86        assert_ne!(user_run_data_root(PRODUCT), user_run_data_root(other));
87    }
88
89    /// A file is the same file as itself, by whichever pair this host uses.
90    #[test]
91    fn a_file_has_one_identity_through_both_a_handle_and_its_path() {
92        let dir = std::env::temp_dir().join(format!("rp-fs-identity-{}", std::process::id()));
93        std::fs::create_dir_all(&dir).expect("create dir");
94        let path = dir.join("subject");
95        let file = std::fs::OpenOptions::new()
96            .read(true)
97            .write(true)
98            .create(true)
99            .truncate(true)
100            .open(&path)
101            .expect("create subject");
102
103        let by_handle = file_identity(&file).expect("identity by handle");
104        let by_path = path_identity(&path).expect("identity by path");
105        assert_eq!(by_handle, by_path);
106
107        drop(file);
108        let _ = std::fs::remove_dir_all(&dir);
109    }
110
111    /// Two distinct files never share an identity, which is the property a
112    /// caller relies on to notice its file was replaced underneath it.
113    #[test]
114    fn distinct_files_have_distinct_identities() {
115        let dir = std::env::temp_dir().join(format!("rp-fs-identity2-{}", std::process::id()));
116        std::fs::create_dir_all(&dir).expect("create dir");
117        let (first, second) = (dir.join("first"), dir.join("second"));
118        std::fs::write(&first, b"a").expect("write first");
119        std::fs::write(&second, b"b").expect("write second");
120
121        let a = path_identity(&first).expect("identity a");
122        let b = path_identity(&second).expect("identity b");
123        if a.is_some() {
124            assert_ne!(a, b);
125        }
126
127        let _ = std::fs::remove_dir_all(&dir);
128    }
129
130    /// An exclusive lock excludes a second holder, and releasing readmits one.
131    ///
132    /// Both handles are opened through the facade, so this exercises the open
133    /// and the lock together -- on Windows the two interact, because a
134    /// restrictive share mode would fail the second open before it could ask
135    /// for the lock.
136    #[test]
137    fn an_exclusive_lock_excludes_a_second_holder_until_released() {
138        let dir = std::env::temp_dir().join(format!("rp-fs-lock-{}", std::process::id()));
139        std::fs::create_dir_all(&dir).expect("create dir");
140        let path = dir.join("guard.lock");
141
142        let first = open_lock_file(&path).expect("open first");
143        let second = open_lock_file(&path).expect("open second");
144
145        try_lock_exclusive(&first).expect("first acquires");
146        let conflict = try_lock_exclusive(&second).expect_err("second must be refused");
147        assert!(
148            is_lock_conflict(&conflict),
149            "refusal must classify as a conflict, got {conflict:?}"
150        );
151
152        unlock(&first).expect("release first");
153        try_lock_exclusive(&second).expect("second acquires after release");
154        unlock(&second).expect("release second");
155
156        drop((first, second));
157        let _ = std::fs::remove_dir_all(&dir);
158    }
159
160    /// A genuine failure is not reported as a conflict, so a caller does not
161    /// retry forever on something waiting cannot fix.
162    #[test]
163    fn an_unrelated_error_is_not_a_lock_conflict() {
164        let missing = std::env::temp_dir().join("rp-fs-lock-no-such-file");
165        let _ = std::fs::remove_file(&missing);
166        let error = std::fs::File::open(&missing).expect_err("must not exist");
167        assert!(!is_lock_conflict(&error));
168    }
169
170    /// The pair round-trips, which is the only contract a wire encoding owes
171    /// its decoder: the far end must reconstruct exactly the path that was
172    /// named, not an equivalent one.
173    #[test]
174    fn a_path_survives_encoding_and_decoding_unchanged() {
175        for original in [
176            std::path::PathBuf::from("relative/leaf.log"),
177            std::env::temp_dir()
178                .join("rp path with spaces")
179                .join("t.log"),
180            std::env::current_exe().expect("current image"),
181        ] {
182            let decoded =
183                decode_path_bytes(&encode_path_bytes(&original)).expect("decode what we encoded");
184            assert_eq!(decoded, original);
185        }
186    }
187
188    /// An empty path is a path, and must not become an error or a surprise.
189    #[test]
190    fn an_empty_path_round_trips_as_empty() {
191        let empty = std::path::PathBuf::new();
192        assert!(encode_path_bytes(&empty).is_empty());
193        assert_eq!(
194            decode_path_bytes(&encode_path_bytes(&empty)).expect("decode empty"),
195            empty
196        );
197    }
198
199    /// Replacing works whether or not the target already exists.
200    ///
201    /// Both cases matter: a bare rename onto an existing file fails on
202    /// Windows, and the no-target case is the one a first write takes.
203    #[test]
204    fn a_file_is_replaced_whether_or_not_the_target_exists() {
205        let dir = std::env::temp_dir().join(format!("rp-fs-replace-{}", std::process::id()));
206        std::fs::create_dir_all(&dir).expect("create dir");
207        let target = dir.join("manifest");
208
209        let first = dir.join("first.tmp");
210        std::fs::write(&first, b"first").expect("write first");
211        replace_file(&first, &target).expect("replace absent target");
212        assert_eq!(std::fs::read(&target).expect("read"), b"first");
213
214        let second = dir.join("second.tmp");
215        std::fs::write(&second, b"second").expect("write second");
216        replace_file(&second, &target).expect("replace existing target");
217        assert_eq!(std::fs::read(&target).expect("read"), b"second");
218
219        // The replaced-from paths are consumed by the move, not left behind.
220        assert!(!first.exists());
221        assert!(!second.exists());
222
223        sync_directory(&dir).expect("sync the directory that records it");
224        let _ = std::fs::remove_dir_all(&dir);
225    }
226
227    /// Shared data and machine-local state are different roles, and a host
228    /// that distinguishes them must not collapse the two.
229    #[test]
230    fn shared_data_is_its_own_role() {
231        let data = user_data_dir(PRODUCT);
232        assert!(data.is_absolute());
233        assert!(data.to_string_lossy().contains(PRODUCT));
234    }
235
236    /// A private file is created, and refuses to open over an existing one.
237    ///
238    /// The refusal is the security-relevant half: opening over a file someone
239    /// else made would inherit their permissions, so it must fail rather than
240    /// succeed with weaker protection than the caller asked for.
241    #[test]
242    fn a_private_file_is_created_once_and_refuses_to_reopen() {
243        let dir = std::env::temp_dir().join(format!("rp-fs-private-{}", std::process::id()));
244        std::fs::create_dir_all(&dir).expect("create dir");
245        let path = dir.join("artifact.json");
246        let _ = std::fs::remove_file(&path);
247
248        {
249            let mut file = create_private_file(&path).expect("create private file");
250            use std::io::Write as _;
251            file.write_all(b"payload").expect("write");
252        }
253        assert_eq!(std::fs::read(&path).expect("read back"), b"payload");
254
255        let second = create_private_file(&path).expect_err("must not open over an existing file");
256        assert_eq!(second.kind(), std::io::ErrorKind::AlreadyExists);
257
258        let _ = std::fs::remove_dir_all(&dir);
259    }
260
261    /// The roles are stable: asking twice gives the same answer, so a path
262    /// derived at startup still names the same directory later.
263    #[test]
264    fn roles_are_stable_across_calls() {
265        assert_eq!(user_runtime_dir(PRODUCT), user_runtime_dir(PRODUCT));
266        assert_eq!(user_state_dir(PRODUCT), user_state_dir(PRODUCT));
267        assert_eq!(user_run_data_root(PRODUCT), user_run_data_root(PRODUCT));
268    }
269}