Skip to main content

ahri_tre_lake/
scratch.rs

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/// Opaque identifier for one trusted staging attempt.
11#[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
31/// Validated trusted-service scratch root. It owns an open descriptor so the
32/// validated root remains the authority for attempt creation.
33pub 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    /// Session-local DuckDB spill is distinct from durable Dataset attempt scratch.
94    /// A retained OS lease lets a restarted runtime reap only stopped Sessions.
95    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        // Lock the directory before publishing the Session marker.
113        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    /// Reaps abandoned Session spill only. Dataset attempt directories have no
129    /// Session marker and must use shared durable attempt recovery, even when no
130    /// public operation exists. A live lease always prevents deletion.
131    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
222/// One exclusively-created private attempt directory.
223pub 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    /// Creates output-owned scratch using the durable private attempt identity.
251    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    /// Reconciles only the recorded attempt under this retained configured root.
268    /// Executor exclusion and metadata outcome must have been claimed first.
269    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    /// Creates an exclusive child attempt under this retained scratch capability.
289    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    /// Restricted trusted-adapter path; callers must not serialize or project it.
294    pub fn path(&self) -> &Path {
295        &self.path
296    }
297
298    /// Deletes the complete attempt tree without following child symlinks.
299    /// Missing attempts are already clean and therefore succeed.
300    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
319// Keep the retry marker until every spill entry is removed. Partial cleanup
320// must not turn an abandoned Session tree into unidentifiable Dataset scratch.
321fn 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        // No spill remains, but retain retry eligibility if directory removal fails.
340        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}