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/// Whether a handle another process holds open keeps a file from being removed
39/// on this host; `false` where a name unlinks while descriptors stay open.
40pub use crate::fs_open_handles_block_removal as open_handles_block_removal;
41
42#[cfg(feature = "fs")]
43pub use crate::{
44    fs_create_private_file as create_private_file, fs_decode_path_bytes as decode_path_bytes,
45    fs_encode_path_bytes as encode_path_bytes, fs_file_identity as file_identity,
46    fs_is_link_handle as is_link_handle, fs_is_lock_conflict as is_lock_conflict,
47    fs_open_lock_file as open_lock_file, fs_open_read_no_follow as open_read_no_follow,
48    fs_path_identity as path_identity, fs_replace_file as replace_file,
49    fs_state_home_from_environment as state_home_from_environment,
50    fs_sync_directory as sync_directory, fs_try_lock_exclusive as try_lock_exclusive,
51    fs_unlock as unlock, fs_user_config_dir as user_config_dir, fs_user_data_dir as user_data_dir,
52    fs_user_run_data_root as user_run_data_root, fs_user_runtime_dir as user_runtime_dir,
53    fs_user_state_dir as user_state_dir,
54    fs_user_state_dir_from_environment as user_state_dir_from_environment,
55    FsFileIdentity as FileIdentity,
56};
57
58#[cfg(all(test, feature = "fs"))]
59mod tests {
60    use super::*;
61
62    const PRODUCT: &str = "rp-fs-facade-test";
63
64    /// Every role resolves to an absolute directory that names the product.
65    ///
66    /// Asserted as a property rather than against one host's spelling: the
67    /// point of the facade is that callers cannot tell which host answered.
68    #[test]
69    fn every_role_is_an_absolute_product_scoped_directory() {
70        for directory in [
71            user_runtime_dir(PRODUCT),
72            user_state_dir(PRODUCT),
73            user_run_data_root(PRODUCT),
74        ] {
75            assert!(
76                directory.is_absolute(),
77                "{} must be absolute",
78                directory.display()
79            );
80            assert!(
81                directory.to_string_lossy().contains(PRODUCT),
82                "{} must be scoped to the product",
83                directory.display()
84            );
85        }
86    }
87
88    /// Two products never share a directory in any role.
89    #[test]
90    fn distinct_products_do_not_collide() {
91        let other = "rp-fs-facade-other";
92        assert_ne!(user_runtime_dir(PRODUCT), user_runtime_dir(other));
93        assert_ne!(user_state_dir(PRODUCT), user_state_dir(other));
94        assert_ne!(user_run_data_root(PRODUCT), user_run_data_root(other));
95    }
96
97    /// A file is the same file as itself, by whichever pair this host uses.
98    #[test]
99    fn a_file_has_one_identity_through_both_a_handle_and_its_path() {
100        let dir = std::env::temp_dir().join(format!("rp-fs-identity-{}", std::process::id()));
101        std::fs::create_dir_all(&dir).expect("create dir");
102        let path = dir.join("subject");
103        let file = std::fs::OpenOptions::new()
104            .read(true)
105            .write(true)
106            .create(true)
107            .truncate(true)
108            .open(&path)
109            .expect("create subject");
110
111        let by_handle = file_identity(&file).expect("identity by handle");
112        let by_path = path_identity(&path).expect("identity by path");
113        assert_eq!(by_handle, by_path);
114
115        drop(file);
116        let _ = std::fs::remove_dir_all(&dir);
117    }
118
119    /// Two distinct files never share an identity, which is the property a
120    /// caller relies on to notice its file was replaced underneath it.
121    #[test]
122    fn distinct_files_have_distinct_identities() {
123        let dir = std::env::temp_dir().join(format!("rp-fs-identity2-{}", std::process::id()));
124        std::fs::create_dir_all(&dir).expect("create dir");
125        let (first, second) = (dir.join("first"), dir.join("second"));
126        std::fs::write(&first, b"a").expect("write first");
127        std::fs::write(&second, b"b").expect("write second");
128
129        let a = path_identity(&first).expect("identity a");
130        let b = path_identity(&second).expect("identity b");
131        if a.is_some() {
132            assert_ne!(a, b);
133        }
134
135        let _ = std::fs::remove_dir_all(&dir);
136    }
137
138    /// An exclusive lock excludes a second holder, and releasing readmits one.
139    ///
140    /// Both handles are opened through the facade, so this exercises the open
141    /// and the lock together -- on Windows the two interact, because a
142    /// restrictive share mode would fail the second open before it could ask
143    /// for the lock.
144    #[test]
145    fn an_exclusive_lock_excludes_a_second_holder_until_released() {
146        let dir = std::env::temp_dir().join(format!("rp-fs-lock-{}", std::process::id()));
147        std::fs::create_dir_all(&dir).expect("create dir");
148        let path = dir.join("guard.lock");
149
150        let first = open_lock_file(&path).expect("open first");
151        let second = open_lock_file(&path).expect("open second");
152
153        try_lock_exclusive(&first).expect("first acquires");
154        let conflict = try_lock_exclusive(&second).expect_err("second must be refused");
155        assert!(
156            is_lock_conflict(&conflict),
157            "refusal must classify as a conflict, got {conflict:?}"
158        );
159
160        unlock(&first).expect("release first");
161        try_lock_exclusive(&second).expect("second acquires after release");
162        unlock(&second).expect("release second");
163
164        drop((first, second));
165        let _ = std::fs::remove_dir_all(&dir);
166    }
167
168    /// A genuine failure is not reported as a conflict, so a caller does not
169    /// retry forever on something waiting cannot fix.
170    #[test]
171    fn an_unrelated_error_is_not_a_lock_conflict() {
172        let missing = std::env::temp_dir().join("rp-fs-lock-no-such-file");
173        let _ = std::fs::remove_file(&missing);
174        let error = std::fs::File::open(&missing).expect_err("must not exist");
175        assert!(!is_lock_conflict(&error));
176    }
177
178    /// The pair round-trips, which is the only contract a wire encoding owes
179    /// its decoder: the far end must reconstruct exactly the path that was
180    /// named, not an equivalent one.
181    #[test]
182    fn a_path_survives_encoding_and_decoding_unchanged() {
183        for original in [
184            std::path::PathBuf::from("relative/leaf.log"),
185            std::env::temp_dir()
186                .join("rp path with spaces")
187                .join("t.log"),
188            std::env::current_exe().expect("current image"),
189        ] {
190            let decoded =
191                decode_path_bytes(&encode_path_bytes(&original)).expect("decode what we encoded");
192            assert_eq!(decoded, original);
193        }
194    }
195
196    /// An empty path is a path, and must not become an error or a surprise.
197    #[test]
198    fn an_empty_path_round_trips_as_empty() {
199        let empty = std::path::PathBuf::new();
200        assert!(encode_path_bytes(&empty).is_empty());
201        assert_eq!(
202            decode_path_bytes(&encode_path_bytes(&empty)).expect("decode empty"),
203            empty
204        );
205    }
206
207    /// Replacing works whether or not the target already exists.
208    ///
209    /// Both cases matter: a bare rename onto an existing file fails on
210    /// Windows, and the no-target case is the one a first write takes.
211    #[test]
212    fn a_file_is_replaced_whether_or_not_the_target_exists() {
213        let dir = std::env::temp_dir().join(format!("rp-fs-replace-{}", std::process::id()));
214        std::fs::create_dir_all(&dir).expect("create dir");
215        let target = dir.join("manifest");
216
217        let first = dir.join("first.tmp");
218        std::fs::write(&first, b"first").expect("write first");
219        replace_file(&first, &target).expect("replace absent target");
220        assert_eq!(std::fs::read(&target).expect("read"), b"first");
221
222        let second = dir.join("second.tmp");
223        std::fs::write(&second, b"second").expect("write second");
224        replace_file(&second, &target).expect("replace existing target");
225        assert_eq!(std::fs::read(&target).expect("read"), b"second");
226
227        // The replaced-from paths are consumed by the move, not left behind.
228        assert!(!first.exists());
229        assert!(!second.exists());
230
231        sync_directory(&dir).expect("sync the directory that records it");
232        let _ = std::fs::remove_dir_all(&dir);
233    }
234
235    /// Shared data and machine-local state are different roles, and a host
236    /// that distinguishes them must not collapse the two.
237    #[test]
238    fn shared_data_is_its_own_role() {
239        let data = user_data_dir(PRODUCT);
240        assert!(data.is_absolute());
241        assert!(data.to_string_lossy().contains(PRODUCT));
242    }
243
244    /// A private file is created, and refuses to open over an existing one.
245    ///
246    /// The refusal is the security-relevant half: opening over a file someone
247    /// else made would inherit their permissions, so it must fail rather than
248    /// succeed with weaker protection than the caller asked for.
249    #[test]
250    fn a_private_file_is_created_once_and_refuses_to_reopen() {
251        let dir = std::env::temp_dir().join(format!("rp-fs-private-{}", std::process::id()));
252        std::fs::create_dir_all(&dir).expect("create dir");
253        let path = dir.join("artifact.json");
254        let _ = std::fs::remove_file(&path);
255
256        {
257            let mut file = create_private_file(&path).expect("create private file");
258            use std::io::Write as _;
259            file.write_all(b"payload").expect("write");
260        }
261        assert_eq!(std::fs::read(&path).expect("read back"), b"payload");
262
263        let second = create_private_file(&path).expect_err("must not open over an existing file");
264        assert_eq!(second.kind(), std::io::ErrorKind::AlreadyExists);
265
266        let _ = std::fs::remove_dir_all(&dir);
267    }
268
269    /// A no-follow open of an ordinary file reads it and does not report a
270    /// link. The link half is host-specific and pinned in each host's tests.
271    #[test]
272    fn a_no_follow_open_reads_an_ordinary_file() {
273        let dir = std::env::temp_dir().join(format!("rp-fs-nofollow-{}", std::process::id()));
274        std::fs::create_dir_all(&dir).expect("create dir");
275        let path = dir.join("artifact.json");
276        std::fs::write(&path, b"payload").expect("write");
277
278        let mut file = open_read_no_follow(&path).expect("open ordinary file");
279        let metadata = file.metadata().expect("metadata");
280        assert!(metadata.is_file());
281        assert!(!is_link_handle(&metadata));
282        let mut body = Vec::new();
283        std::io::Read::read_to_end(&mut file, &mut body).expect("read");
284        assert_eq!(body, b"payload");
285        drop(file);
286
287        let _ = std::fs::remove_dir_all(&dir);
288    }
289
290    /// The roles are stable: asking twice gives the same answer, so a path
291    /// derived at startup still names the same directory later.
292    #[test]
293    fn roles_are_stable_across_calls() {
294        assert_eq!(user_runtime_dir(PRODUCT), user_runtime_dir(PRODUCT));
295        assert_eq!(user_state_dir(PRODUCT), user_state_dir(PRODUCT));
296        assert_eq!(user_run_data_root(PRODUCT), user_run_data_root(PRODUCT));
297    }
298}