1use anyhow::{bail, Context, Result};
10#[cfg(not(windows))]
11use rustix::fs::{fsync, mkdirat, openat, renameat, unlinkat, AtFlags, Mode, OFlags};
12#[cfg(windows)]
13use std::ffi::OsString;
14#[cfg(not(windows))]
15use std::fs::File;
16#[cfg(windows)]
17use std::fs::OpenOptions;
18#[cfg(windows)]
19use std::io::{ErrorKind, Write};
20#[cfg(not(windows))]
21use std::io::{Read, Write};
22#[cfg(not(windows))]
23use std::os::fd::OwnedFd;
24#[cfg(windows)]
25use std::os::windows::ffi::OsStrExt;
26#[cfg(windows)]
27use std::os::windows::fs::MetadataExt;
28#[cfg(not(windows))]
29use std::path::{Component, Path};
30#[cfg(windows)]
31use std::path::{Component, Path, PathBuf};
32
33#[cfg(not(windows))]
35pub struct WorkspaceRoot {
36 fd: OwnedFd,
37}
38
39#[derive(Debug)]
42#[cfg(not(windows))]
43pub struct TargetDir {
44 fd: OwnedFd,
45 name: String,
46}
47
48#[cfg(not(windows))]
49impl WorkspaceRoot {
50 pub fn open(root: &Path) -> Result<Self> {
53 let fd = rustix::fs::open(root, OFlags::RDONLY | OFlags::DIRECTORY, Mode::empty())
54 .with_context(|| format!("opening workspace root {}", root.display()))?;
55 Ok(Self { fd })
56 }
57
58 pub fn target_dir(&self, relative: &str, create: bool) -> Result<TargetDir> {
62 let mut components = Vec::new();
63 for component in Path::new(relative).components() {
64 match component {
65 Component::Normal(part) => components.push(
66 part.to_str()
67 .context("promotion path is not valid UTF-8")?
68 .to_string(),
69 ),
70 Component::CurDir => {}
71 _ => bail!("promotion path escapes the workspace: {relative}"),
72 }
73 }
74 let name = components
75 .pop()
76 .context("promotion path has no file name")?;
77 let mut current = self
78 .fd
79 .try_clone()
80 .context("cloning workspace root descriptor")?;
81 let dir_flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW;
82 for part in &components {
83 if create {
84 match mkdirat(¤t, part.as_str(), Mode::from_raw_mode(0o777)) {
85 Ok(()) | Err(rustix::io::Errno::EXIST) => {}
86 Err(error) => {
87 return Err(error).with_context(|| format!("creating directory {part}"))
88 }
89 }
90 }
91 current = openat(¤t, part.as_str(), dir_flags, Mode::empty())
92 .with_context(|| format!("descending into {part} under {relative}"))?;
93 }
94 Ok(TargetDir { fd: current, name })
95 }
96
97 pub fn read_if_present(&self, relative: &str) -> Result<Option<Vec<u8>>> {
100 match self.target_dir(relative, false) {
101 Ok(target) => target.read_optional(),
102 Err(error)
103 if error.downcast_ref::<rustix::io::Errno>() == Some(&rustix::io::Errno::NOENT) =>
104 {
105 Ok(None)
106 }
107 Err(error) => Err(error),
108 }
109 }
110}
111
112#[cfg(not(windows))]
113impl TargetDir {
114 pub fn read_optional(&self) -> Result<Option<Vec<u8>>> {
116 let flags = OFlags::RDONLY | OFlags::NOFOLLOW;
117 match openat(&self.fd, self.name.as_str(), flags, Mode::empty()) {
118 Ok(fd) => {
119 let mut bytes = Vec::new();
120 File::from(fd)
121 .read_to_end(&mut bytes)
122 .with_context(|| format!("reading {}", self.name))?;
123 Ok(Some(bytes))
124 }
125 Err(rustix::io::Errno::NOENT) => Ok(None),
126 Err(rustix::io::Errno::LOOP) | Err(rustix::io::Errno::MLINK) => {
127 bail!("promotion target is a symlink: {}", self.name)
128 }
129 Err(error) => Err(error).with_context(|| format!("opening {}", self.name)),
130 }
131 }
132
133 pub fn write(&self, bytes: &[u8]) -> Result<()> {
136 let staged = format!(".perspt-promote-{}", uuid::Uuid::new_v4());
137 let flags = OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW;
138 let staged_fd = openat(&self.fd, staged.as_str(), flags, Mode::from_raw_mode(0o666))
139 .with_context(|| format!("staging {}", self.name))?;
140 let mut file = File::from(staged_fd);
141 let written = file
142 .write_all(bytes)
143 .and_then(|()| file.sync_all())
144 .with_context(|| format!("writing staged {}", self.name));
145 if let Err(error) = written {
146 let _ = unlinkat(&self.fd, staged.as_str(), AtFlags::empty());
147 return Err(error);
148 }
149 renameat(&self.fd, staged.as_str(), &self.fd, self.name.as_str())
150 .with_context(|| format!("promoting {}", self.name))?;
151 fsync(&self.fd).with_context(|| format!("syncing parent of {}", self.name))?;
152 Ok(())
153 }
154
155 pub fn remove(&self) -> Result<()> {
157 match unlinkat(&self.fd, self.name.as_str(), AtFlags::empty()) {
158 Ok(()) | Err(rustix::io::Errno::NOENT) => Ok(()),
159 Err(error) => Err(error).with_context(|| format!("removing {}", self.name)),
160 }
161 }
162
163 pub fn apply(&self, bytes: Option<&[u8]>) -> Result<()> {
165 match bytes {
166 Some(content) => self.write(content),
167 None => self.remove(),
168 }
169 }
170}
171
172#[cfg(all(test, not(windows)))]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn write_and_read_through_descriptor() {
178 let dir = tempfile::tempdir().unwrap();
179 let root = WorkspaceRoot::open(dir.path()).unwrap();
180 let target = root.target_dir("nested/dir/file.txt", true).unwrap();
181 target.write(b"content").unwrap();
182 assert_eq!(target.read_optional().unwrap().unwrap(), b"content");
183 assert_eq!(
184 std::fs::read(dir.path().join("nested/dir/file.txt")).unwrap(),
185 b"content"
186 );
187 target.remove().unwrap();
188 assert!(target.read_optional().unwrap().is_none());
189 }
190
191 #[test]
192 fn symlink_ancestor_is_refused_at_open_time() {
193 let dir = tempfile::tempdir().unwrap();
194 let outside = tempfile::tempdir().unwrap();
195 std::os::unix::fs::symlink(outside.path(), dir.path().join("dir")).unwrap();
196 let root = WorkspaceRoot::open(dir.path()).unwrap();
197 let error = root.target_dir("dir/file.txt", false).unwrap_err();
198 assert!(error.to_string().contains("descending into dir"));
199 }
200
201 #[test]
202 fn symlink_target_file_is_refused() {
203 let dir = tempfile::tempdir().unwrap();
204 std::fs::write(dir.path().join("real.txt"), b"real").unwrap();
205 std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link.txt"))
206 .unwrap();
207 let root = WorkspaceRoot::open(dir.path()).unwrap();
208 let target = root.target_dir("link.txt", false).unwrap();
209 assert!(target.read_optional().is_err());
210 }
211
212 #[test]
213 fn parent_escapes_are_rejected_structurally() {
214 let dir = tempfile::tempdir().unwrap();
215 let root = WorkspaceRoot::open(dir.path()).unwrap();
216 assert!(root.target_dir("../outside.txt", false).is_err());
217 assert!(root.target_dir("/etc/passwd", false).is_err());
218 }
219}
220
221#[cfg(windows)]
228pub struct WorkspaceRoot {
229 root: PathBuf,
230}
231
232#[cfg(windows)]
233#[derive(Debug)]
234pub struct TargetDir {
235 parent: PathBuf,
236 name: OsString,
237}
238
239#[cfg(windows)]
240impl WorkspaceRoot {
241 pub fn open(root: &Path) -> Result<Self> {
242 let root = root
243 .canonicalize()
244 .with_context(|| format!("opening workspace root {}", root.display()))?;
245 anyhow::ensure!(root.is_dir(), "workspace root is not a directory");
246 Ok(Self { root })
247 }
248
249 pub fn target_dir(&self, relative: &str, create: bool) -> Result<TargetDir> {
250 let mut components = Vec::new();
251 for component in Path::new(relative).components() {
252 match component {
253 Component::Normal(part) => components.push(part.to_os_string()),
254 Component::CurDir => {}
255 _ => bail!("promotion path escapes the workspace: {relative}"),
256 }
257 }
258 let name = components
259 .pop()
260 .context("promotion path has no file name")?;
261 let mut parent = self.root.clone();
262 for part in components {
263 parent.push(part);
264 match std::fs::symlink_metadata(&parent) {
265 Ok(metadata) => {
266 ensure_plain(&parent, &metadata)?;
267 anyhow::ensure!(
268 metadata.is_dir(),
269 "promotion ancestor is not a directory: {}",
270 parent.display()
271 );
272 }
273 Err(error) if error.kind() == ErrorKind::NotFound && create => {
274 std::fs::create_dir(&parent)
275 .with_context(|| format!("creating directory {}", parent.display()))?;
276 let metadata = std::fs::symlink_metadata(&parent)?;
277 ensure_plain(&parent, &metadata)?;
278 }
279 Err(error) => {
280 return Err(error)
281 .with_context(|| format!("descending into {}", parent.display()))
282 }
283 }
284 }
285 Ok(TargetDir { parent, name })
286 }
287
288 pub fn read_if_present(&self, relative: &str) -> Result<Option<Vec<u8>>> {
289 match self.target_dir(relative, false) {
290 Ok(target) => target.read_optional(),
291 Err(error) if io_not_found(&error) => Ok(None),
292 Err(error) => Err(error),
293 }
294 }
295}
296
297#[cfg(windows)]
298impl TargetDir {
299 fn path(&self) -> PathBuf {
300 self.parent.join(&self.name)
301 }
302
303 pub fn read_optional(&self) -> Result<Option<Vec<u8>>> {
304 let path = self.path();
305 match std::fs::symlink_metadata(&path) {
306 Ok(metadata) => ensure_plain(&path, &metadata)?,
307 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
308 Err(error) => return Err(error).with_context(|| format!("opening {}", path.display())),
309 }
310 std::fs::read(&path)
311 .map(Some)
312 .with_context(|| format!("reading {}", path.display()))
313 }
314
315 pub fn write(&self, bytes: &[u8]) -> Result<()> {
316 let target = self.path();
317 let existed = match std::fs::symlink_metadata(&target) {
318 Ok(metadata) => {
319 ensure_plain(&target, &metadata)?;
320 true
321 }
322 Err(error) if error.kind() == ErrorKind::NotFound => false,
323 Err(error) => {
324 return Err(error).with_context(|| format!("opening {}", target.display()))
325 }
326 };
327 let staged = self
328 .parent
329 .join(format!(".perspt-promote-{}", uuid::Uuid::new_v4()));
330 let mut file = OpenOptions::new()
331 .write(true)
332 .create_new(true)
333 .open(&staged)
334 .with_context(|| format!("staging {}", target.display()))?;
335 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
336 let _ = std::fs::remove_file(&staged);
337 return Err(error).with_context(|| format!("writing staged {}", target.display()));
338 }
339 drop(file);
340 if let Err(error) = replace_file(&staged, &target, existed) {
341 let _ = std::fs::remove_file(&staged);
342 return Err(error).with_context(|| format!("promoting {}", target.display()));
343 }
344 Ok(())
345 }
346
347 pub fn remove(&self) -> Result<()> {
348 let target = self.path();
349 match std::fs::symlink_metadata(&target) {
350 Ok(metadata) => ensure_plain(&target, &metadata)?,
351 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
352 Err(error) => {
353 return Err(error).with_context(|| format!("opening {}", target.display()))
354 }
355 }
356 std::fs::remove_file(&target).with_context(|| format!("removing {}", target.display()))
357 }
358
359 pub fn apply(&self, bytes: Option<&[u8]>) -> Result<()> {
360 match bytes {
361 Some(content) => self.write(content),
362 None => self.remove(),
363 }
364 }
365}
366
367#[cfg(windows)]
368fn ensure_plain(path: &Path, metadata: &std::fs::Metadata) -> Result<()> {
369 const REPARSE_POINT: u32 = 0x400;
370 anyhow::ensure!(
371 metadata.file_attributes() & REPARSE_POINT == 0,
372 "promotion path contains a reparse point: {}",
373 path.display()
374 );
375 Ok(())
376}
377
378#[cfg(windows)]
379fn io_not_found(error: &anyhow::Error) -> bool {
380 error
381 .chain()
382 .find_map(|cause| cause.downcast_ref::<std::io::Error>())
383 .is_some_and(|cause| cause.kind() == ErrorKind::NotFound)
384}
385
386#[cfg(windows)]
387fn replace_file(staged: &Path, target: &Path, existed: bool) -> std::io::Result<()> {
388 use windows_sys::Win32::Storage::FileSystem::{
389 MoveFileExW, ReplaceFileW, MOVEFILE_WRITE_THROUGH, REPLACEFILE_WRITE_THROUGH,
390 };
391
392 let staged = staged
393 .as_os_str()
394 .encode_wide()
395 .chain(Some(0))
396 .collect::<Vec<_>>();
397 let target = target
398 .as_os_str()
399 .encode_wide()
400 .chain(Some(0))
401 .collect::<Vec<_>>();
402 let success = unsafe {
403 if existed {
404 ReplaceFileW(
405 target.as_ptr(),
406 staged.as_ptr(),
407 std::ptr::null(),
408 REPLACEFILE_WRITE_THROUGH,
409 std::ptr::null(),
410 std::ptr::null(),
411 )
412 } else {
413 MoveFileExW(staged.as_ptr(), target.as_ptr(), MOVEFILE_WRITE_THROUGH)
414 }
415 };
416 if success == 0 {
417 Err(std::io::Error::last_os_error())
418 } else {
419 Ok(())
420 }
421}
422
423#[cfg(all(test, windows))]
424mod windows_tests {
425 use super::*;
426
427 #[test]
428 fn native_write_replace_read_and_remove() {
429 let dir = tempfile::tempdir().unwrap();
430 let root = WorkspaceRoot::open(dir.path()).unwrap();
431 let target = root.target_dir("nested/file.txt", true).unwrap();
432 target.write(b"first").unwrap();
433 target.write(b"second").unwrap();
434 assert_eq!(target.read_optional().unwrap().unwrap(), b"second");
435 target.remove().unwrap();
436 assert!(target.read_optional().unwrap().is_none());
437 }
438
439 #[test]
440 fn native_parent_escapes_are_rejected() {
441 let dir = tempfile::tempdir().unwrap();
442 let root = WorkspaceRoot::open(dir.path()).unwrap();
443 assert!(root.target_dir("../outside.txt", false).is_err());
444 assert!(root.target_dir(r"C:\outside.txt", false).is_err());
445 }
446}