gatehouse_runtime_protocol/lifecycle_v2/
plan_storage.rs1use 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 if unsafe { libc::close(file.into_raw_fd()) } != 0 {
107 return Err("record close failed");
108 }
109 Ok(())
110}
111impl PlanRecord {
112 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}