1use std::fs::{self, File};
2use std::os::unix::fs::{MetadataExt, PermissionsExt};
3use std::path::{Component, Path, PathBuf};
4use std::sync::Arc;
5
6use ahri_tre_security::{REDACTED_VALUE, redact_restricted_path};
7use rustix::fs::{FlockOperation, Mode, OFlags, flock, openat};
8use thiserror::Error;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ScratchAttemptId(Box<str>);
13
14impl ScratchAttemptId {
15 pub fn new(value: &str) -> Result<Self, ScratchError> {
16 if value.len() != 32
17 || !value
18 .bytes()
19 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
20 {
21 return Err(ScratchError::InvalidAttemptId);
22 }
23 Ok(Self(value.into()))
24 }
25
26 fn as_str(&self) -> &str {
27 &self.0
28 }
29}
30
31pub struct TrustedScratch {
34 pub(super) root: PathBuf,
35 pub(super) root_directory: Arc<File>,
36}
37
38impl std::fmt::Debug for TrustedScratch {
39 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 formatter
41 .debug_struct("TrustedScratch")
42 .field("root", &REDACTED_VALUE)
43 .finish()
44 }
45}
46
47impl TrustedScratch {
48 pub fn open<'a>(
49 root: &Path,
50 filesystem_lake_locations: impl IntoIterator<Item = &'a Path>,
51 ) -> Result<Self, ScratchError> {
52 validate_absolute_normalized(root)?;
53 if root == Path::new("/") || protected_roots().iter().any(|path| overlaps(root, path)) {
54 return Err(ScratchError::ProtectedRoot);
55 }
56 if filesystem_lake_locations
57 .into_iter()
58 .any(|lake| overlaps(root, lake))
59 {
60 return Err(ScratchError::LakeOverlap);
61 }
62
63 let slash = File::open("/").map_err(|_| ScratchError::Unavailable)?;
64 let mut root_directory = slash;
65 for component in root.components() {
66 let Component::Normal(name) = component else {
67 continue;
68 };
69 let descriptor = openat(
70 &root_directory,
71 Path::new(name),
72 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
73 Mode::empty(),
74 )
75 .map_err(|_| ScratchError::Unavailable)?;
76 root_directory = File::from(descriptor);
77 }
78 let metadata = root_directory
79 .metadata()
80 .map_err(|_| ScratchError::Unavailable)?;
81 if !metadata.is_dir()
82 || metadata.uid() != rustix::process::geteuid().as_raw()
83 || metadata.permissions().mode() & 0o777 != 0o700
84 {
85 return Err(ScratchError::UnsafeRoot);
86 }
87 Ok(Self {
88 root: root.to_path_buf(),
89 root_directory: Arc::new(root_directory),
90 })
91 }
92
93 pub fn create_session_attempt(
96 &self,
97 identifier: ScratchAttemptId,
98 ) -> Result<ScratchAttempt, ScratchError> {
99 let mut attempt = self.create_attempt(identifier)?;
100 let directory = File::from(
101 openat(
102 &*self.root_directory,
103 attempt
104 .path
105 .file_name()
106 .ok_or(ScratchError::UnsafeAttempt)?,
107 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
108 Mode::empty(),
109 )
110 .map_err(|_| ScratchError::Unavailable)?,
111 );
112 flock(&directory, FlockOperation::NonBlockingLockExclusive)
114 .map_err(|_| ScratchError::Unavailable)?;
115 let _marker = File::from(
116 openat(
117 &directory,
118 ".session-lease",
119 OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
120 Mode::from_bits_retain(0o600),
121 )
122 .map_err(|_| ScratchError::Unavailable)?,
123 );
124 attempt._session_lease = Some(directory);
125 Ok(attempt)
126 }
127
128 pub fn reap_abandoned_sessions(&self) -> Result<(), ScratchError> {
132 let mut cleanup_failed = false;
133 for entry in fs::read_dir(&self.root).map_err(|_| ScratchError::Unavailable)? {
134 let entry = entry.map_err(|_| ScratchError::Unavailable)?;
135 let name = entry.file_name();
136 if name
137 .to_str()
138 .is_none_or(|name| ScratchAttemptId::new(name).is_err())
139 {
140 continue;
141 }
142 let Ok(directory) = openat(
143 &*self.root_directory,
144 name.as_os_str(),
145 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
146 Mode::empty(),
147 ) else {
148 continue;
149 };
150 let directory = File::from(directory);
151 let metadata = directory
152 .metadata()
153 .map_err(|_| ScratchError::Unavailable)?;
154 if metadata.uid() != rustix::process::geteuid().as_raw()
155 || metadata.permissions().mode() & 0o777 != 0o700
156 {
157 continue;
158 }
159 let Ok(lease) = openat(
160 &directory,
161 ".session-lease",
162 OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOFOLLOW,
163 Mode::empty(),
164 ) else {
165 continue;
166 };
167 let lease = File::from(lease);
168 let metadata = lease.metadata().map_err(|_| ScratchError::Unavailable)?;
169 if !metadata.is_file()
170 || metadata.nlink() != 1
171 || metadata.uid() != rustix::process::geteuid().as_raw()
172 || metadata.permissions().mode() & 0o777 != 0o600
173 {
174 continue;
175 }
176 if flock(&directory, FlockOperation::NonBlockingLockExclusive).is_err() {
177 continue;
178 }
179 cleanup_failed |= cleanup_session_tree(&entry.path()).is_err();
180 }
181 if cleanup_failed {
182 Err(ScratchError::CleanupFailed)
183 } else {
184 Ok(())
185 }
186 }
187
188 pub fn create_attempt(
189 &self,
190 identifier: ScratchAttemptId,
191 ) -> Result<ScratchAttempt, ScratchError> {
192 rustix::fs::mkdirat(
193 &*self.root_directory,
194 identifier.as_str(),
195 Mode::from_bits_retain(0o700),
196 )
197 .map_err(|error| {
198 if error == rustix::io::Errno::EXIST {
199 ScratchError::AttemptExists
200 } else {
201 ScratchError::Unavailable
202 }
203 })?;
204 let path = self.root.join(identifier.as_str());
205 let metadata = fs::symlink_metadata(&path).map_err(|_| ScratchError::Unavailable)?;
206 if !metadata.is_dir()
207 || metadata.file_type().is_symlink()
208 || metadata.uid() != rustix::process::geteuid().as_raw()
209 || metadata.permissions().mode() & 0o777 != 0o700
210 {
211 return Err(ScratchError::UnsafeAttempt);
212 }
213 Ok(ScratchAttempt {
214 root: self.root.clone(),
215 path,
216 _root_directory: Arc::clone(&self.root_directory),
217 _session_lease: None,
218 })
219 }
220}
221
222pub struct ScratchAttempt {
224 root: PathBuf,
225 path: PathBuf,
226 _root_directory: Arc<File>,
227 _session_lease: Option<File>,
228}
229
230impl std::fmt::Debug for ScratchAttempt {
231 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 formatter
233 .debug_struct("ScratchAttempt")
234 .field(
235 "path",
236 &redact_restricted_path(self.path.display().to_string()),
237 )
238 .finish()
239 }
240}
241
242impl ScratchAttempt {
243 fn configured_root(&self) -> TrustedScratch {
244 TrustedScratch {
245 root: self.root.clone(),
246 root_directory: Arc::clone(&self._root_directory),
247 }
248 }
249
250 pub fn create_dataset_attempt(
252 &self,
253 authority: &mut dyn ahri_tre_core::DatasetOutputAuthority,
254 ) -> Result<Self, crate::LakeError> {
255 authority.ensure_current()?;
256 use sha2::Digest;
257 let identity = crate::file_staging::hex_digest(sha2::Sha256::digest(
258 self.root.as_os_str().as_encoded_bytes(),
259 ));
260 authority.authorize_scratch_root(&identity)?;
261 let identifier = ScratchAttemptId::new(&authority.attempt_id().simple().to_string())?;
262 self.configured_root()
263 .create_attempt(identifier)
264 .map_err(Into::into)
265 }
266
267 pub fn cleanup_dataset_attempt(
270 &self,
271 authority: &mut dyn ahri_tre_core::DatasetOutputAuthority,
272 ) -> Result<(), crate::LakeError> {
273 authority.ensure_current()?;
274 use sha2::Digest;
275 let identity = crate::file_staging::hex_digest(sha2::Sha256::digest(
276 self.root.as_os_str().as_encoded_bytes(),
277 ));
278 authority.authorize_scratch_root(&identity)?;
279 let attempt = Self {
280 root: self.root.clone(),
281 path: self.root.join(authority.attempt_id().simple().to_string()),
282 _root_directory: Arc::clone(&self._root_directory),
283 _session_lease: None,
284 };
285 attempt.cleanup().map_err(Into::into)
286 }
287
288 pub fn create_child(&self, identifier: ScratchAttemptId) -> Result<Self, ScratchError> {
290 TrustedScratch::open(self.path(), std::iter::empty::<&Path>())?.create_attempt(identifier)
291 }
292
293 pub fn path(&self) -> &Path {
295 &self.path
296 }
297
298 pub fn cleanup(&self) -> Result<(), ScratchError> {
301 if self.path.parent() != Some(self.root.as_path()) {
302 return Err(ScratchError::UnsafeAttempt);
303 }
304 match fs::symlink_metadata(&self.path) {
305 Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
306 if self._session_lease.is_some() {
307 cleanup_session_tree(&self.path)
308 } else {
309 fs::remove_dir_all(&self.path).map_err(|_| ScratchError::CleanupFailed)
310 }
311 }
312 Ok(_) => Err(ScratchError::UnsafeAttempt),
313 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
314 Err(_) => Err(ScratchError::CleanupFailed),
315 }
316 }
317}
318
319fn cleanup_session_tree(path: &Path) -> Result<(), ScratchError> {
322 for entry in fs::read_dir(path).map_err(|_| ScratchError::CleanupFailed)? {
323 let entry = entry.map_err(|_| ScratchError::CleanupFailed)?;
324 if entry.file_name() == ".session-lease" {
325 continue;
326 }
327 if entry
328 .file_type()
329 .map_err(|_| ScratchError::CleanupFailed)?
330 .is_dir()
331 {
332 fs::remove_dir_all(entry.path()).map_err(|_| ScratchError::CleanupFailed)?;
333 } else {
334 fs::remove_file(entry.path()).map_err(|_| ScratchError::CleanupFailed)?;
335 }
336 }
337 fs::remove_file(path.join(".session-lease")).map_err(|_| ScratchError::CleanupFailed)?;
338 if fs::remove_dir(path).is_err() {
339 use std::os::unix::fs::OpenOptionsExt;
341 let _ = fs::OpenOptions::new()
342 .write(true)
343 .create_new(true)
344 .mode(0o600)
345 .open(path.join(".session-lease"));
346 return Err(ScratchError::CleanupFailed);
347 }
348 Ok(())
349}
350
351impl Drop for ScratchAttempt {
352 fn drop(&mut self) {
353 let _ = self.cleanup();
354 }
355}
356
357fn validate_absolute_normalized(path: &Path) -> Result<(), ScratchError> {
358 if !path.is_absolute()
359 || path
360 .components()
361 .any(|component| !matches!(component, Component::RootDir | Component::Normal(_)))
362 {
363 return Err(ScratchError::InvalidRoot);
364 }
365 Ok(())
366}
367
368fn protected_roots() -> [PathBuf; 5] {
369 [
370 "/etc/ahri-tre",
371 "/run/secrets",
372 "/var/lib/ahri-tre/secrets",
373 "/run/ahri-tre",
374 "/etc/ahri-tre/config.toml",
375 ]
376 .map(PathBuf::from)
377}
378
379fn overlaps(left: &Path, right: &Path) -> bool {
380 left.starts_with(right) || right.starts_with(left)
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
384pub enum ScratchError {
385 #[error("trusted scratch root must be an absolute normalized path")]
386 InvalidRoot,
387 #[error("trusted scratch root overlaps a protected path")]
388 ProtectedRoot,
389 #[error("trusted scratch root overlaps a filesystem Lake")]
390 LakeOverlap,
391 #[error("trusted scratch root is unavailable without following links")]
392 Unavailable,
393 #[error("trusted scratch root has unsafe ownership, type, or permissions")]
394 UnsafeRoot,
395 #[error("scratch attempt identifier is not canonical")]
396 InvalidAttemptId,
397 #[error("scratch attempt already exists")]
398 AttemptExists,
399 #[error("scratch attempt has unsafe ownership, type, or permissions")]
400 UnsafeAttempt,
401 #[error("scratch attempt cleanup failed")]
402 CleanupFailed,
403}