Skip to main content

perspt_agent/
promote.rs

1//! Workspace promotion with platform-specific hardening.
2//!
3//! Unix checks and acts through a held directory descriptor, walking relative
4//! paths with `O_NOFOLLOW | O_DIRECTORY`; an ancestor swap cannot redirect a
5//! write. Native Windows supports the explicitly reduced-isolation release
6//! path with reparse-point checks and write-through replacement, but does not
7//! claim the same descriptor-relative race guarantee (Gate L).
8
9use 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/// A workspace root held open as a directory descriptor.
34#[cfg(not(windows))]
35pub struct WorkspaceRoot {
36    fd: OwnedFd,
37}
38
39/// The parent directory of a promotion target, held open, plus the final
40/// file name. All reads and writes for the target go through this pair.
41#[derive(Debug)]
42#[cfg(not(windows))]
43pub struct TargetDir {
44    fd: OwnedFd,
45    name: String,
46}
47
48#[cfg(not(windows))]
49impl WorkspaceRoot {
50    /// Open the workspace root. The root itself may be reached through a
51    /// symlink (the user chose it); everything below it may not.
52    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    /// Walk to the parent directory of `relative`, refusing symlinks at
59    /// every component. With `create`, missing intermediate directories are
60    /// created through the descriptor.
61    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(&current, 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(&current, 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    /// Read `relative` through descriptors; `None` when the file or any
98    /// ancestor directory is absent. Symlinks anywhere still error.
99    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    /// Read the target through the descriptor; `None` when absent.
115    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    /// Write the target atomically: staged sibling created `O_EXCL` through
134    /// the descriptor, fsynced, renamed over the target, directory fsynced.
135    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    /// Remove the target through the descriptor; absent targets are fine.
156    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    /// Apply a recorded state: bytes present means write, absent means remove.
164    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/// Native Windows promotion used by the explicit reduced-isolation mode.
222///
223/// Windows paths are checked for reparse points component by component and
224/// replacement uses the native write-through APIs. Unlike the descriptor-held
225/// Unix implementation, these checks cannot close every ancestor swap race;
226/// callers must not describe this mode as governed OS isolation.
227#[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}