Skip to main content

onelf_format/
cache_layout.rs

1//! Shared layout and safety rules for the on-disk extraction cache.
2//!
3//! The runtime and the packer CLI both read and write this cache. They used
4//! to answer "where is it, and is it safe to use" independently, and drifted:
5//! one required an absolute env-derived path and a `0700` uid-owned
6//! directory, the other happily fell back to a shared `/tmp`. Both now go
7//! through here.
8//!
9//! The current user id is a parameter rather than a call, so this crate stays
10//! dependency-free; each caller already has a way to ask the OS.
11//!
12//! Layout under the resolved root:
13//!
14//! ```text
15//! pkg/<id>              extracted package tree
16//! pkg/.<id>.ready       completion marker, written only after a full extract
17//! pkg/.<id>.tmp         extraction scratch, renamed to pkg/<id> on success
18//! cas/<aa>/<hash>       content blob, hardlinked into package trees
19//! meta/<id>             last-used timestamp
20//! lock/<id>             in-use lock, held shared for an instance's lifetime
21//! lock/<id>.extract     extraction mutex
22//! ```
23
24use std::path::{Path, PathBuf};
25
26use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
27
28/// True if `path` is a real directory (not a symlink) owned by `uid` with no
29/// group or other permission bits. Uses `symlink_metadata`, so a planted
30/// symlink is rejected rather than followed.
31pub fn is_safe_owned_dir(path: &Path, uid: u32) -> bool {
32    let Ok(md) = std::fs::symlink_metadata(path) else {
33        return false;
34    };
35    md.is_dir() && md.uid() == uid && (md.mode() & 0o077) == 0
36}
37
38/// Ensure `dir` is a `0700` directory owned by `uid`, creating it atomically
39/// at that mode when absent. Returns false if it exists as a symlink or as
40/// another user's directory, which are never chmod'd through; a real
41/// directory we own but with loose permissions is tightened in place.
42pub fn ensure_safe_dir(dir: &Path, uid: u32) -> bool {
43    if let Some(parent) = dir.parent() {
44        let _ = std::fs::create_dir_all(parent);
45    }
46    match std::fs::DirBuilder::new().mode(0o700).create(dir) {
47        Ok(()) => return true,
48        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
49        Err(_) => return false,
50    }
51    if let Ok(md) = std::fs::symlink_metadata(dir)
52        && md.is_dir()
53        && md.uid() == uid
54        && md.mode() & 0o077 != 0
55    {
56        let _ = std::fs::set_permissions(dir, PermissionsExt::from_mode(0o700));
57    }
58    is_safe_owned_dir(dir, uid)
59}
60
61/// Resolve the cache root, or `None` when no safe location can be
62/// established. Callers MUST treat `None` as a refusal rather than
63/// substituting a guess: a shared world-writable fallback is what this
64/// function exists to prevent.
65///
66/// Prefers `$XDG_CACHE_HOME`, then `~/.cache`, both of which are already
67/// user-private. Only an *absolute* env-derived path is trusted, because an
68/// empty or relative one would resolve against the current directory, which
69/// the caller does not control. `fallback` supplies the last resort (the
70/// runtime passes its `0700` per-uid directory; a caller with none passes
71/// `None`).
72pub fn resolve_root(uid: u32, fallback: Option<PathBuf>) -> Option<PathBuf> {
73    let abs = |v: std::ffi::OsString| -> Option<PathBuf> {
74        let p = PathBuf::from(v);
75        p.is_absolute().then_some(p)
76    };
77    let base = std::env::var_os("XDG_CACHE_HOME")
78        .and_then(abs)
79        .or_else(|| {
80            std::env::var_os("HOME")
81                .and_then(abs)
82                .map(|h| h.join(".cache"))
83        })
84        .or(fallback)?;
85    let onelf = base.join("onelf");
86    ensure_safe_dir(&onelf, uid).then_some(onelf)
87}
88
89/// Extracted tree for `package_id`.
90pub fn pkg_dir(root: &Path, package_id: &str) -> PathBuf {
91    root.join("pkg").join(package_id)
92}
93
94/// Completion marker for `package_id`. Its presence is what distinguishes a
95/// fully extracted tree from one an interrupted run left behind.
96pub fn ready_marker(root: &Path, package_id: &str) -> PathBuf {
97    root.join("pkg").join(format!(".{package_id}.ready"))
98}
99
100/// In-use lock for `package_id`, held shared for an instance's lifetime.
101pub fn lock_path(root: &Path, package_id: &str) -> PathBuf {
102    root.join("lock").join(package_id)
103}
104
105/// Extraction mutex for `package_id`, distinct from the in-use lock so a
106/// second runner waits only for extraction, not for the first instance's
107/// whole lifetime.
108pub fn extract_lock_path(root: &Path, package_id: &str) -> PathBuf {
109    root.join("lock").join(format!("{package_id}.extract"))
110}
111
112/// Last-used timestamp for `package_id`.
113pub fn meta_path(root: &Path, package_id: &str) -> PathBuf {
114    root.join("meta").join(package_id)
115}
116
117/// Store-wide lock separating extraction from collection.
118///
119/// Extraction holds it *shared* while it populates the content store, because
120/// a blob is briefly reachable only from the store itself: it is renamed into
121/// place before being hardlinked into the package tree, and in that window its
122/// link count does not yet reflect the reference that is about to exist.
123/// Collection holds it *exclusive*, which proves no extraction is inside that
124/// window and therefore that link counts can be trusted.
125pub fn cas_lock_path(root: &Path) -> PathBuf {
126    root.join("lock").join("cas")
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    /// Distinguishes scratch paths between concurrently running tests, which
134    /// share a process and therefore a pid.
135    fn unique(tag: &str) -> std::path::PathBuf {
136        use std::sync::atomic::{AtomicU64, Ordering};
137        static SEQ: AtomicU64 = AtomicU64::new(0);
138        std::env::temp_dir().join(format!(
139            "onelf-{tag}-{}-{}",
140            std::process::id(),
141            SEQ.fetch_add(1, Ordering::Relaxed)
142        ))
143    }
144
145    /// The uid that owns what this process creates, read back from a scratch
146    /// file. The crate has no dependencies, so there is no `getuid` to call.
147    fn uid() -> u32 {
148        let p = unique("uidprobe");
149        std::fs::write(&p, b"").unwrap();
150        let u = std::fs::symlink_metadata(&p).unwrap().uid();
151        let _ = std::fs::remove_file(&p);
152        u
153    }
154
155    #[test]
156    fn safe_dir_rules() {
157        let me = uid();
158        let dir = unique("layout");
159        let _ = std::fs::remove_dir_all(&dir);
160
161        assert!(ensure_safe_dir(&dir, me), "fresh dir is created 0700");
162        assert!(is_safe_owned_dir(&dir, me));
163
164        std::fs::set_permissions(&dir, PermissionsExt::from_mode(0o755)).unwrap();
165        assert!(!is_safe_owned_dir(&dir, me), "0755 is not owner-private");
166        assert!(ensure_safe_dir(&dir, me), "our own dir is tightened back");
167        assert!(is_safe_owned_dir(&dir, me));
168
169        assert!(
170            !is_safe_owned_dir(&dir, me.wrapping_add(1)),
171            "another uid's directory is refused, never chmod'd"
172        );
173
174        let _ = std::fs::remove_dir_all(&dir);
175    }
176
177    #[test]
178    fn missing_and_non_directory_are_unsafe() {
179        let me = uid();
180        assert!(!is_safe_owned_dir(Path::new("/nonexistent/onelf/xyz"), me));
181
182        let f = unique("layout-file");
183        std::fs::write(&f, b"x").unwrap();
184        assert!(!is_safe_owned_dir(&f, me), "a regular file is not a dir");
185        let _ = std::fs::remove_file(&f);
186    }
187
188    /// A relative `XDG_CACHE_HOME` must be ignored in favour of the
189    /// fallback, so the cache never lands under the current directory.
190    ///
191    /// This is the only test in the crate that touches the environment, and
192    /// it restores both variables before returning.
193    #[test]
194    fn relative_env_roots_are_not_trusted() {
195        let me = uid();
196        let fallback = unique("fb");
197        let _ = std::fs::remove_dir_all(&fallback);
198        std::fs::create_dir_all(&fallback).unwrap();
199
200        let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
201        let prev_home = std::env::var_os("HOME");
202        // SAFETY: no other test in this binary reads the environment.
203        unsafe {
204            std::env::set_var("XDG_CACHE_HOME", "relative/path");
205            std::env::remove_var("HOME");
206        }
207
208        let root = resolve_root(me, Some(fallback.clone()));
209
210        unsafe {
211            match prev_xdg {
212                Some(v) => std::env::set_var("XDG_CACHE_HOME", v),
213                None => std::env::remove_var("XDG_CACHE_HOME"),
214            }
215            if let Some(v) = prev_home {
216                std::env::set_var("HOME", v);
217            }
218        }
219
220        assert_eq!(root, Some(fallback.join("onelf")));
221        let _ = std::fs::remove_dir_all(&fallback);
222    }
223
224    #[test]
225    fn layout_paths_are_stable() {
226        let root = Path::new("/c/onelf");
227        assert_eq!(pkg_dir(root, "ab"), Path::new("/c/onelf/pkg/ab"));
228        assert_eq!(
229            ready_marker(root, "ab"),
230            Path::new("/c/onelf/pkg/.ab.ready")
231        );
232        assert_eq!(lock_path(root, "ab"), Path::new("/c/onelf/lock/ab"));
233        assert_eq!(
234            extract_lock_path(root, "ab"),
235            Path::new("/c/onelf/lock/ab.extract")
236        );
237        assert_eq!(meta_path(root, "ab"), Path::new("/c/onelf/meta/ab"));
238    }
239}