返回 CodeWhale
fs_confined.rs
根目录 / crates / tui / src / fs_confined.rs
1 //! Shared regular-file I/O, replacing project_context's private no-follow open.
2 //! Parent containment uses the existing Fleet `WorkspaceFile` primitive.
3 //!
4 //! The caller chooses the root; links below it are refused. The root itself
5 //! may resolve through a user-selected link. Writes are not transactions:
6 //! callers needing atomic replacement must use `WorkspaceFile::replace`.
7
8 use std::fs::{self, File};
9 use std::io::{self, Read, Write};
10 use std::path::Path;
11
12 use crate::fleet::files::WorkspaceFile;
13
14 fn target(root: &Path, path: &Path, create: bool) -> io::Result<WorkspaceFile> {
15 let relative = path.strip_prefix(root).map_err(|_| {
16 io::Error::new(
17 io::ErrorKind::InvalidInput,
18 "File must stay within its root",
19 )
20 })?;
21 WorkspaceFile::open(root, relative, create)
22 }
23
24 pub(crate) fn open_read(root: &Path, path: &Path) -> io::Result<File> {
25 open_regular(root, path, false)
26 }
27
28 /// [`open_read`] for a file another process may hold open for writing, such as
29 /// a running worker's log. Windows denies those writers to [`open_read`] (it
30 /// is the protected reader), so this takes the sharing reader; the links and
31 /// regular-file policy is the same.
32 pub(crate) fn open_read_shared(root: &Path, path: &Path) -> io::Result<File> {
33 open_regular(root, path, true)
34 }
35
36 fn open_regular(root: &Path, path: &Path, shared: bool) -> io::Result<File> {
37 let target = target(root, path, false)?;
38 let metadata = fs::symlink_metadata(path)?;
39 if metadata.file_type().is_symlink() {
40 return Err(io::Error::new(
41 io::ErrorKind::InvalidInput,
42 "Refusing symlinked file",
43 ));
44 }
45 if !metadata.is_file() {
46 return Err(io::Error::new(
47 io::ErrorKind::InvalidInput,
48 "Path is not a regular file",
49 ));
50 }
51 // The pinned parent and no-follow open enforce the same policy even if
52 // the path changes after the metadata check. The handle is checked too.
53 if shared {
54 target.open_file_shared()
55 } else {
56 target.open_file()
57 }
58 }
59
60 pub(crate) fn read_to_string(root: &Path, path: &Path) -> io::Result<String> {
61 let mut contents = String::new();
62 open_read(root, path)?.read_to_string(&mut contents)?;
63 Ok(contents)
64 }
65
66 /// Operator-owned global instructions may intentionally share a linked file.
67 /// This is not a workspace reader: it resolves links without a containment root.
68 pub(crate) fn open_user_read(path: &Path) -> io::Result<File> {
69 let resolved = fs::canonicalize(path)?;
70 let mut options = fs::OpenOptions::new();
71 options.read(true);
72 #[cfg(unix)]
73 {
74 use std::os::unix::fs::OpenOptionsExt;
75 options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
76 }
77 #[cfg(windows)]
78 {
79 use std::os::windows::fs::OpenOptionsExt;
80 options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT
81 }
82 let file = options.open(resolved)?;
83 let metadata = file.metadata()?;
84 if !metadata.is_file() || crate::plugins::metadata_is_link_or_reparse(&metadata) {
85 return Err(io::Error::new(
86 io::ErrorKind::InvalidInput,
87 "Path is not a regular file",
88 ));
89 }
90 Ok(file)
91 }
92
93 pub(crate) fn open_append(root: &Path, path: &Path) -> io::Result<File> {
94 target(root, path, true)?.open_write(true)
95 }
96
97 pub(crate) fn write(root: &Path, path: &Path, contents: &[u8]) -> io::Result<()> {
98 let mut file = target(root, path, true)?.open_write(false)?;
99 // Validate the opened file before truncating it.
100 file.set_len(0)?;
101 file.write_all(contents)
102 }
103
104 #[cfg(test)]
105 mod tests {
106 use super::*;
107
108 #[test]
109 fn confined_files_support_read_append_and_truncate() {
110 let root = tempfile::tempdir().unwrap();
111 let path = root.path().join("notes/file.md");
112 open_append(root.path(), &path)
113 .unwrap()
114 .write_all(b"first")
115 .unwrap();
116 open_append(root.path(), &path)
117 .unwrap()
118 .write_all(b" second")
119 .unwrap();
120 assert_eq!(read_to_string(root.path(), &path).unwrap(), "first second");
121 write(root.path(), &path, b"next").unwrap();
122 assert_eq!(read_to_string(root.path(), &path).unwrap(), "next");
123 }
124
125 /// A running worker holds its log open for writing while the host reads
126 /// it (Windows denied that to the protected reader).
127 #[test]
128 fn a_shared_read_sees_a_file_a_writer_still_holds_open() {
129 let root = tempfile::tempdir().unwrap();
130 let path = root.path().join("logs/worker.log");
131 let mut writer = open_append(root.path(), &path).unwrap();
132 writer.write_all(b"started").unwrap();
133 writer.flush().unwrap();
134 let mut text = String::new();
135 open_read_shared(root.path(), &path)
136 .unwrap()
137 .read_to_string(&mut text)
138 .unwrap();
139 assert_eq!(text, "started");
140 writer.write_all(b" and running").unwrap();
141 drop(writer);
142 assert_eq!(
143 read_to_string(root.path(), &path).unwrap(),
144 "started and running"
145 );
146 assert!(open_read_shared(root.path(), root.path()).is_err());
147 assert!(open_read_shared(root.path(), &root.path().join("../worker.log")).is_err());
148 }
149
150 #[test]
151 fn confined_files_refuse_paths_outside_the_root() {
152 let root = tempfile::tempdir().unwrap();
153 let outside = tempfile::tempdir().unwrap();
154 for path in [
155 outside.path().join("file.md"),
156 root.path().join("../file.md"),
157 ] {
158 assert!(open_read(root.path(), &path).is_err());
159 assert!(open_append(root.path(), &path).is_err());
160 assert!(write(root.path(), &path, b"next").is_err());
161 }
162 }
163
164 #[cfg(unix)]
165 #[test]
166 fn confined_files_validate_handles_before_truncation() {
167 let root = tempfile::tempdir().unwrap();
168 let outside = tempfile::tempdir().unwrap();
169 let original = outside.path().join("original.md");
170 fs::write(&original, "original").unwrap();
171 let path = root.path().join("linked.md");
172 fs::hard_link(&original, &path).unwrap();
173 assert!(write(root.path(), &path, b"next").is_err());
174 assert_eq!(fs::read_to_string(original).unwrap(), "original");
175 assert!(open_read(root.path(), root.path()).is_err());
176 }
177
178 #[cfg(unix)]
179 #[test]
180 fn confined_files_refuse_links_even_within_the_root() {
181 use std::os::unix::fs::symlink;
182 let root = tempfile::tempdir().unwrap();
183 let directory = root.path().join("real");
184 fs::create_dir(&directory).unwrap();
185 let original = directory.join("file.md");
186 fs::write(&original, "original").unwrap();
187 symlink(&directory, root.path().join("linked-dir")).unwrap();
188 symlink(&original, root.path().join("linked.md")).unwrap();
189 for path in [
190 root.path().join("linked-dir/file.md"),
191 root.path().join("linked.md"),
192 ] {
193 assert!(open_read(root.path(), &path).is_err());
194 assert!(open_read_shared(root.path(), &path).is_err());
195 assert!(open_append(root.path(), &path).is_err());
196 assert!(write(root.path(), &path, b"next").is_err());
197 assert_eq!(fs::read_to_string(&original).unwrap(), "original");
198 }
199 }
200
201 #[cfg(unix)]
202 #[test]
203 fn confined_writes_do_not_require_read_permission() {
204 use std::os::unix::fs::PermissionsExt;
205 let root = tempfile::tempdir().unwrap();
206 let path = root.path().join("notes.md");
207 fs::write(&path, "first").unwrap();
208 fs::set_permissions(&path, fs::Permissions::from_mode(0o200)).unwrap();
209 let mut file = open_append(root.path(), &path).unwrap();
210 assert!(file.read(&mut [0]).is_err());
211 file.write_all(b" second").unwrap();
212 drop(file);
213 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
214 assert_eq!(fs::read_to_string(&path).unwrap(), "first second");
215 fs::set_permissions(&path, fs::Permissions::from_mode(0o200)).unwrap();
216 write(root.path(), &path, b"next").unwrap();
217 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
218 assert_eq!(fs::read_to_string(&path).unwrap(), "next");
219 }
220 }
221
221 lines RUST