Skip to main content

gatehouse_runtime_protocol/lifecycle_v2/
plan_storage.rs

1//! Create-new evidence storage. Errors retain files; never retry or repair them.
2use super::{plan_record::PlanRecord, protected_context::ProtectedContext};
3use std::{
4    fs::{self, File, OpenOptions},
5    io::{Read, Write},
6    os::{
7        fd::{AsRawFd, IntoRawFd},
8        unix::fs::{MetadataExt, OpenOptionsExt},
9    },
10    path::{Path, PathBuf},
11};
12type Result<T> = std::result::Result<T, &'static str>;
13const LIMIT: u64 = 16 * 1024 * 1024;
14fn io<T>(r: std::io::Result<T>) -> Result<T> {
15    r.map_err(|_| "plan evidence filesystem failure")
16}
17fn identity(m: &fs::Metadata) -> (u64, u64, u32, u32, u32, u64, i64, i64, i64, i64) {
18    (
19        m.dev(),
20        m.ino(),
21        m.mode(),
22        m.uid(),
23        m.gid(),
24        m.nlink(),
25        m.ctime(),
26        m.ctime_nsec(),
27        m.mtime(),
28        m.mtime_nsec(),
29    )
30}
31struct Parent {
32    path: PathBuf,
33    file: File,
34    dev: u64,
35    ino: u64,
36}
37impl Parent {
38    fn open(path: &Path) -> Result<(Self, PathBuf)> {
39        let parent = path.parent().ok_or("record parent required")?;
40        let name = path.file_name().ok_or("record name required")?;
41        if !path.is_absolute()
42            || path.to_str().is_none_or(|s| {
43                s.len() > 4096
44                    || s.chars().any(char::is_control)
45                    || s.split('/')
46                        .skip(1)
47                        .any(|p| p.is_empty() || p == "." || p == "..")
48            })
49        {
50            return Err("canonical record path required");
51        }
52        check_parent(parent)?;
53        let file = io(OpenOptions::new()
54            .read(true)
55            .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
56            .open(parent))?;
57        let m = io(file.metadata())?;
58        let held = Self {
59            path: parent.into(),
60            dev: m.dev(),
61            ino: m.ino(),
62            file,
63        };
64        held.check()?;
65        let anchored = PathBuf::from(format!("/proc/self/fd/{}", held.file.as_raw_fd())).join(name);
66        Ok((held, anchored))
67    }
68    fn check(&self) -> Result<()> {
69        check_parent(&self.path)?;
70        let m = io(fs::symlink_metadata(&self.path))?;
71        let held = io(self.file.metadata())?;
72        if (m.dev(), m.ino()) != (self.dev, self.ino) || identity(&m) != identity(&held) {
73            return Err("record parent replaced");
74        }
75        Ok(())
76    }
77}
78fn check_parent(path: &Path) -> Result<()> {
79    if io(fs::canonicalize(path))? != path {
80        return Err("record parent symlink");
81    }
82    for p in path.ancestors() {
83        let m = io(fs::symlink_metadata(p))?;
84        if !m.is_dir()
85            || m.mode() & 0o022 != 0
86            || ![0, unsafe { libc::geteuid() }].contains(&m.uid())
87        {
88            return Err("unprotected record parent");
89        }
90    }
91    Ok(())
92}
93fn valid(m: &fs::Metadata) -> Result<()> {
94    if !m.is_file()
95        || m.mode() & 0o7777 != 0o600
96        || m.uid() != unsafe { libc::geteuid() }
97        || m.nlink() != 1
98        || m.len() > LIMIT
99    {
100        return Err("unprotected record file");
101    }
102    Ok(())
103}
104fn close(file: File) -> Result<()> {
105    // Do not retry close after EINTR: Linux already releases the descriptor.
106    if unsafe { libc::close(file.into_raw_fd()) } != 0 {
107        return Err("record close failed");
108    }
109    Ok(())
110}
111impl PlanRecord {
112    /// Not a journal or lease. A failed call can leave a complete or partial file.
113    pub fn create_new(&self, path: &Path, context: &ProtectedContext) -> Result<()> {
114        self.create_checked(path, context, || Ok(()))
115    }
116    pub(super) fn create_checked(
117        &self,
118        path: &Path,
119        context: &ProtectedContext,
120        after_write: impl FnOnce() -> Result<()>,
121    ) -> Result<()> {
122        let bytes = self.encode()?;
123        Self::decode(&bytes, context)?;
124        write_new(path, &bytes, |_| context.revalidate())?;
125        after_write()?;
126        if Self::open(path, context)?.encode()? != bytes {
127            return Err("created record differs from intended evidence");
128        }
129        Ok(())
130    }
131    pub fn open(path: &Path, context: &ProtectedContext) -> Result<Self> {
132        let (parent, anchored) = Parent::open(path)?;
133        let file = io(OpenOptions::new()
134            .read(true)
135            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
136            .open(&anchored))?;
137        let before = io(file.metadata())?;
138        valid(&before)?;
139        let mut bytes = Vec::new();
140        io((&file).take(LIMIT + 1).read_to_end(&mut bytes))?;
141        if bytes.len() as u64 > LIMIT {
142            return Err("record too large");
143        }
144        let record = Self::decode(&bytes, context)?;
145        parent.check()?;
146        if identity(&before) != identity(&io(file.metadata())?)
147            || identity(&before) != identity(&io(fs::symlink_metadata(&anchored))?)
148        {
149            return Err("record changed during read");
150        }
151        close(file)?;
152        context.revalidate()?;
153        Ok(record)
154    }
155}
156trait StorageIo {
157    fn write(&mut self, file: &mut File, bytes: &[u8]) -> std::io::Result<()> {
158        file.write_all(bytes)
159    }
160    fn sync(&mut self, file: &File, _parent: bool) -> std::io::Result<()> {
161        file.sync_all()
162    }
163    fn close(&mut self, file: File) -> Result<()> {
164        close(file)
165    }
166}
167struct SystemIo;
168impl StorageIo for SystemIo {}
169fn write_new(path: &Path, bytes: &[u8], boundary: impl FnMut(&str) -> Result<()>) -> Result<()> {
170    write_with_io(path, bytes, boundary, &mut SystemIo)
171}
172fn write_with_io(
173    path: &Path,
174    bytes: &[u8],
175    mut boundary: impl FnMut(&str) -> Result<()>,
176    ops: &mut impl StorageIo,
177) -> Result<()> {
178    let (parent, anchored) = Parent::open(path)?;
179    boundary("before-create")?;
180    let mut file = io(OpenOptions::new()
181        .write(true)
182        .create_new(true)
183        .mode(0o600)
184        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
185        .open(&anchored))?;
186    let initial = io(file.metadata())?;
187    valid(&initial)?;
188    boundary("created")?;
189    let middle = bytes.len() / 2;
190    io(ops.write(&mut file, &bytes[..middle]))?;
191    boundary("partial-write")?;
192    io(ops.write(&mut file, &bytes[middle..]))?;
193    boundary("before-file-sync")?;
194    io(ops.sync(&file, false))?;
195    boundary("before-parent-sync")?;
196    parent.check()?;
197    let final_meta = io(file.metadata())?;
198    valid(&final_meta)?;
199    if (initial.dev(), initial.ino()) != (final_meta.dev(), final_meta.ino())
200        || identity(&final_meta) != identity(&io(fs::symlink_metadata(&anchored))?)
201        || final_meta.len() != bytes.len() as u64
202    {
203        return Err("record replaced during write");
204    }
205    io(ops.sync(&parent.file, true))?;
206    boundary("before-close")?;
207    ops.close(file)?;
208    parent.check()?;
209    boundary("complete")?;
210    Ok(())
211}
212#[cfg(test)]
213mod tests {
214    use super::*;
215    fn root() -> tempfile::TempDir {
216        use std::os::unix::fs::PermissionsExt;
217        let dir = tempfile::Builder::new()
218            .prefix("p103-")
219            .tempdir_in(std::env::var_os("HOME").unwrap())
220            .unwrap();
221        fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
222        dir
223    }
224    #[test]
225    fn interruption_retains_evidence_and_never_overwrites() {
226        for stage in [
227            "before-create",
228            "created",
229            "partial-write",
230            "before-file-sync",
231            "before-parent-sync",
232            "before-close",
233            "complete",
234        ] {
235            let root = root();
236            let path = root.path().join("record");
237            assert!(write_new(&path, b"evidence", |s| if s == stage {
238                Err("injected interruption")
239            } else {
240                Ok(())
241            })
242            .is_err());
243            if stage == "before-create" {
244                assert!(!path.exists());
245            } else {
246                let prior = fs::read(&path).unwrap();
247                assert!(write_new(&path, b"replacement", |_| Ok(())).is_err());
248                assert_eq!(fs::read(&path).unwrap(), prior);
249            }
250        }
251    }
252    #[test]
253    fn concurrent_create_has_one_winner_and_symlink_refuses() {
254        let root = root();
255        let path = root.path().join("record");
256        std::thread::scope(|s| {
257            let a = s.spawn(|| write_new(&path, b"one", |_| Ok(())));
258            let b = s.spawn(|| write_new(&path, b"two", |_| Ok(())));
259            assert_ne!(a.join().unwrap().is_ok(), b.join().unwrap().is_ok());
260        });
261        let link = root.path().join("link");
262        std::os::unix::fs::symlink(&path, &link).unwrap();
263        assert!(write_new(&link, b"bad", |_| Ok(())).is_err());
264    }
265    struct Fault(&'static str);
266    impl StorageIo for Fault {
267        fn write(&mut self, file: &mut File, bytes: &[u8]) -> std::io::Result<()> {
268            if self.0 == "short-write" {
269                file.write_all(&bytes[..1])?;
270                return Err(std::io::Error::from_raw_os_error(libc::ENOSPC));
271            }
272            file.write_all(bytes)
273        }
274        fn sync(&mut self, file: &File, parent: bool) -> std::io::Result<()> {
275            if self.0 == if parent { "parent-sync" } else { "file-sync" } {
276                return Err(std::io::Error::from_raw_os_error(libc::EIO));
277            }
278            file.sync_all()
279        }
280        fn close(&mut self, file: File) -> Result<()> {
281            close(file)?;
282            if self.0 == "close" {
283                Err("injected close error after descriptor release")
284            } else {
285                Ok(())
286            }
287        }
288    }
289    #[test]
290    fn io_failures_return_failure_and_retain_without_retry() {
291        for stage in ["short-write", "file-sync", "parent-sync", "close"] {
292            let root = root();
293            let path = root.path().join("record");
294            assert!(write_with_io(&path, b"evidence", |_| Ok(()), &mut Fault(stage)).is_err());
295            let retained = fs::read(&path).unwrap();
296            assert_eq!(
297                retained,
298                if stage == "short-write" {
299                    b"e".as_slice()
300                } else {
301                    b"evidence".as_slice()
302                }
303            );
304            assert!(write_new(&path, b"retry", |_| Ok(())).is_err());
305            assert_eq!(fs::read(&path).unwrap(), retained);
306        }
307    }
308    #[test]
309    fn replacement_during_write_is_rejected() {
310        let root = root();
311        let path = root.path().join("record");
312        assert!(write_new(&path, b"evidence", |stage| {
313            if stage == "before-parent-sync" {
314                fs::rename(&path, root.path().join("old")).unwrap();
315                fs::write(&path, b"replacement").unwrap();
316            }
317            Ok(())
318        })
319        .is_err());
320    }
321    #[test]
322    fn parent_replacement_during_write_is_rejected() {
323        use std::os::unix::fs::PermissionsExt;
324        let root = root();
325        let parent = root.path().join("parent");
326        fs::create_dir(&parent).unwrap();
327        fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).unwrap();
328        let path = parent.join("record");
329        assert!(write_new(&path, b"evidence", |stage| {
330            if stage == "before-parent-sync" {
331                fs::rename(&parent, root.path().join("old-parent")).unwrap();
332                fs::create_dir(&parent).unwrap();
333                fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).unwrap();
334            }
335            Ok(())
336        })
337        .is_err());
338        assert!(!path.exists());
339        assert_eq!(
340            fs::read(root.path().join("old-parent/record")).unwrap(),
341            b"evidence"
342        );
343    }
344}