Skip to main content

gobblytes_core/
lib.rs

1#![no_std]
2#![allow(async_fn_in_trait)]
3
4extern crate alloc;
5
6use alloc::{
7    collections::{BTreeMap, VecDeque},
8    format,
9    string::{String, ToString},
10    vec::Vec,
11};
12use core::fmt;
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum FilesystemEntryType {
16    File,
17    Directory,
18    Symlink,
19    Other,
20}
21
22/// Minimal VFS-like access to a filesystem tree.
23pub trait Filesystem {
24    type Error;
25
26    /// Read the full contents of a file at `path` (absolute or relative to root).
27    async fn read_all(&self, path: &str) -> Result<Vec<u8>, Self::Error>;
28
29    /// Read a range of bytes from a file at `path`.
30    async fn read_range(&self, path: &str, offset: u64, len: usize)
31    -> Result<Vec<u8>, Self::Error>;
32
33    /// List entries (file/dir names) in a directory at `path`.
34    async fn read_dir(&self, path: &str) -> Result<Vec<String>, Self::Error>;
35
36    /// Return entry type for a path without following symlinks.
37    ///
38    /// Returns `Ok(None)` when the path does not exist.
39    async fn entry_type(&self, path: &str) -> Result<Option<FilesystemEntryType>, Self::Error>;
40
41    /// Read symlink target bytes as UTF-8 text.
42    ///
43    /// Implementations should return an error when `path` is not a symlink.
44    async fn read_link(&self, path: &str) -> Result<String, Self::Error>;
45
46    /// Check if a path exists.
47    async fn exists(&self, path: &str) -> Result<bool, Self::Error>;
48}
49
50const MAX_SYMLINK_HOPS: usize = 32;
51
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct OstreeError {
54    message: String,
55}
56
57impl OstreeError {
58    fn new(message: impl Into<String>) -> Self {
59        Self {
60            message: message.into(),
61        }
62    }
63}
64
65impl fmt::Display for OstreeError {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        write!(f, "{}", self.message)
68    }
69}
70
71#[derive(Clone)]
72pub struct OstreeFs<P> {
73    inner: P,
74    deployment_root: String,
75}
76
77pub fn normalize_ostree_deployment_path(path: &str) -> Result<String, OstreeError> {
78    let trimmed = path.trim();
79    if trimmed.is_empty() {
80        return Err(OstreeError::new("ostree path is empty"));
81    }
82
83    let mut components = Vec::new();
84    for component in trimmed.split('/') {
85        match component {
86            "" | "." => {}
87            ".." => {
88                return Err(OstreeError::new(format!(
89                    "ostree path must not contain '..': {trimmed}"
90                )));
91            }
92            _ => components.push(component.to_string()),
93        }
94    }
95
96    if components.is_empty() {
97        return Err(OstreeError::new("ostree path resolves to root or empty"));
98    }
99
100    Ok(components.join("/"))
101}
102
103fn split_non_parent_components(path: &str) -> Result<Vec<String>, OstreeError> {
104    let trimmed = path.trim();
105    if trimmed.is_empty() {
106        return Err(OstreeError::new("path is empty"));
107    }
108
109    let mut components = Vec::new();
110    for component in trimmed.split('/') {
111        match component {
112            "" | "." => {}
113            ".." => {
114                return Err(OstreeError::new(format!(
115                    "path must not contain '..': {trimmed}"
116                )));
117            }
118            _ => components.push(component.to_string()),
119        }
120    }
121    Ok(components)
122}
123
124fn apply_path_target(base: &mut Vec<String>, target: &str) -> Result<(), OstreeError> {
125    let trimmed = target.trim();
126    if trimmed.is_empty() {
127        return Err(OstreeError::new("symlink target is empty"));
128    }
129
130    if trimmed.starts_with('/') {
131        base.clear();
132    }
133
134    for component in trimmed.split('/') {
135        match component {
136            "" | "." => {}
137            ".." => {
138                base.pop();
139            }
140            _ => base.push(component.to_string()),
141        }
142    }
143
144    Ok(())
145}
146
147impl<P> OstreeFs<P> {
148    pub fn new(inner: P, deployment_path: &str) -> Result<Self, OstreeError> {
149        let deployment_root = normalize_ostree_deployment_path(deployment_path)?;
150        Ok(Self {
151            inner,
152            deployment_root,
153        })
154    }
155
156    fn map_path(&self, path: &str) -> String {
157        let suffix = path.trim().trim_start_matches('/');
158        if suffix.is_empty() {
159            format!("/{}", self.deployment_root)
160        } else {
161            format!("/{}/{}", self.deployment_root, suffix)
162        }
163    }
164}
165
166impl<P> OstreeFs<P>
167where
168    P: Filesystem,
169    P::Error: fmt::Display,
170{
171    pub async fn resolve_deployment_path(
172        inner: &P,
173        deployment_path: &str,
174    ) -> Result<String, OstreeError> {
175        let normalized = normalize_ostree_deployment_path(deployment_path)?;
176        let normalized_abs = format!("/{normalized}");
177        let mut remaining = split_non_parent_components(&normalized_abs)?
178            .into_iter()
179            .collect::<VecDeque<_>>();
180        let mut resolved = Vec::new();
181        let mut symlink_hops = 0usize;
182
183        while let Some(component) = remaining.pop_front() {
184            resolved.push(component);
185            let current_path = format!("/{}", resolved.join("/"));
186            let entry_type = inner
187                .entry_type(&current_path)
188                .await
189                .map_err(|err| OstreeError::new(format!("read entry type {current_path}: {err}")))?
190                .ok_or_else(|| OstreeError::new(format!("missing path {current_path}")))?;
191
192            if entry_type != FilesystemEntryType::Symlink {
193                continue;
194            }
195
196            symlink_hops += 1;
197            if symlink_hops > MAX_SYMLINK_HOPS {
198                return Err(OstreeError::new(format!(
199                    "symlink resolution exceeded {MAX_SYMLINK_HOPS} hops for {deployment_path}"
200                )));
201            }
202
203            let link_target = inner.read_link(&current_path).await.map_err(|err| {
204                OstreeError::new(format!("read symlink target {current_path}: {err}"))
205            })?;
206            resolved.pop();
207            apply_path_target(&mut resolved, &link_target)?;
208
209            let mut rewritten = resolved.into_iter().collect::<VecDeque<_>>();
210            rewritten.extend(remaining.into_iter());
211            remaining = rewritten;
212            resolved = Vec::new();
213        }
214
215        let resolved_path = if resolved.is_empty() {
216            "/".to_string()
217        } else {
218            format!("/{}", resolved.join("/"))
219        };
220        let resolved_type = inner
221            .entry_type(&resolved_path)
222            .await
223            .map_err(|err| OstreeError::new(format!("read entry type {resolved_path}: {err}")))?
224            .ok_or_else(|| {
225                OstreeError::new(format!(
226                    "resolved ostree path does not exist: {resolved_path}"
227                ))
228            })?;
229        if resolved_type != FilesystemEntryType::Directory {
230            return Err(OstreeError::new(format!(
231                "resolved ostree path is not a directory: {resolved_path}"
232            )));
233        }
234        normalize_ostree_deployment_path(&resolved_path)
235    }
236}
237
238impl<P> Filesystem for OstreeFs<P>
239where
240    P: Filesystem,
241{
242    type Error = P::Error;
243
244    async fn read_all(&self, path: &str) -> Result<Vec<u8>, Self::Error> {
245        let mapped = self.map_path(path);
246        self.inner.read_all(&mapped).await
247    }
248
249    async fn read_range(
250        &self,
251        path: &str,
252        offset: u64,
253        len: usize,
254    ) -> Result<Vec<u8>, Self::Error> {
255        let mapped = self.map_path(path);
256        self.inner.read_range(&mapped, offset, len).await
257    }
258
259    async fn read_dir(&self, path: &str) -> Result<Vec<String>, Self::Error> {
260        let mapped = self.map_path(path);
261        self.inner.read_dir(&mapped).await
262    }
263
264    async fn entry_type(&self, path: &str) -> Result<Option<FilesystemEntryType>, Self::Error> {
265        let mapped = self.map_path(path);
266        self.inner.entry_type(&mapped).await
267    }
268
269    async fn read_link(&self, path: &str) -> Result<String, Self::Error> {
270        let mapped = self.map_path(path);
271        self.inner.read_link(&mapped).await
272    }
273
274    async fn exists(&self, path: &str) -> Result<bool, Self::Error> {
275        let mapped = self.map_path(path);
276        self.inner.exists(&mapped).await
277    }
278}
279
280#[derive(Clone, Debug, Default)]
281pub struct MockFilesystem {
282    entry_types: BTreeMap<String, FilesystemEntryType>,
283    directories: BTreeMap<String, Vec<String>>,
284    files: BTreeMap<String, Vec<u8>>,
285    symlinks: BTreeMap<String, String>,
286}
287
288impl MockFilesystem {
289    pub fn add_dir(&mut self, path: &str, entries: &[&str]) {
290        self.entry_types
291            .insert(path.to_string(), FilesystemEntryType::Directory);
292        self.directories.insert(
293            path.to_string(),
294            entries.iter().map(|entry| (*entry).to_string()).collect(),
295        );
296    }
297
298    pub fn add_file(&mut self, path: &str, data: &[u8]) {
299        self.entry_types
300            .insert(path.to_string(), FilesystemEntryType::File);
301        self.files.insert(path.to_string(), data.to_vec());
302    }
303
304    pub fn add_symlink(&mut self, path: &str) {
305        self.add_symlink_target(path, ".");
306    }
307
308    pub fn add_symlink_target(&mut self, path: &str, target: &str) {
309        self.entry_types
310            .insert(path.to_string(), FilesystemEntryType::Symlink);
311        self.symlinks.insert(path.to_string(), target.to_string());
312    }
313}
314
315#[derive(Clone, Debug, Eq, PartialEq)]
316pub enum MockFilesystemError {
317    MissingPath(String),
318    MissingDirectory(String),
319    NotASymlink(String),
320    OffsetOverflow(String),
321}
322
323impl fmt::Display for MockFilesystemError {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        match self {
326            Self::MissingPath(path) => write!(f, "missing path {path}"),
327            Self::MissingDirectory(path) => write!(f, "missing directory {path}"),
328            Self::NotASymlink(path) => write!(f, "path is not a symlink: {path}"),
329            Self::OffsetOverflow(path) => write!(f, "range offset exceeds usize for {path}"),
330        }
331    }
332}
333
334impl Filesystem for MockFilesystem {
335    type Error = MockFilesystemError;
336
337    async fn read_all(&self, path: &str) -> Result<Vec<u8>, Self::Error> {
338        self.files
339            .get(path)
340            .cloned()
341            .ok_or_else(|| MockFilesystemError::MissingPath(path.to_string()))
342    }
343
344    async fn read_range(
345        &self,
346        path: &str,
347        offset: u64,
348        len: usize,
349    ) -> Result<Vec<u8>, Self::Error> {
350        let data = self
351            .files
352            .get(path)
353            .ok_or_else(|| MockFilesystemError::MissingPath(path.to_string()))?;
354        let offset = usize::try_from(offset)
355            .map_err(|_| MockFilesystemError::OffsetOverflow(path.to_string()))?;
356        if offset >= data.len() {
357            return Ok(Vec::new());
358        }
359        let end = (offset.saturating_add(len)).min(data.len());
360        Ok(data[offset..end].to_vec())
361    }
362
363    async fn read_dir(&self, path: &str) -> Result<Vec<String>, Self::Error> {
364        self.directories
365            .get(path)
366            .cloned()
367            .ok_or_else(|| MockFilesystemError::MissingDirectory(path.to_string()))
368    }
369
370    async fn entry_type(&self, path: &str) -> Result<Option<FilesystemEntryType>, Self::Error> {
371        Ok(self.entry_types.get(path).copied())
372    }
373
374    async fn read_link(&self, path: &str) -> Result<String, Self::Error> {
375        self.symlinks
376            .get(path)
377            .cloned()
378            .ok_or_else(|| MockFilesystemError::NotASymlink(path.to_string()))
379    }
380
381    async fn exists(&self, path: &str) -> Result<bool, Self::Error> {
382        Ok(self.entry_types.contains_key(path) || self.directories.contains_key(path))
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use alloc::vec;
390    use futures::executor::block_on;
391
392    #[test]
393    fn normalize_ostree_path_removes_root_prefix_and_dots() {
394        let path = normalize_ostree_deployment_path(" /ostree//boot.1/./fedora/123/0/ ").unwrap();
395        assert_eq!(path, "ostree/boot.1/fedora/123/0");
396    }
397
398    #[test]
399    fn normalize_ostree_path_rejects_parent_components() {
400        let err = normalize_ostree_deployment_path("/ostree/../etc").unwrap_err();
401        assert!(err.to_string().contains("must not contain '..'"));
402    }
403
404    #[test]
405    fn apply_path_target_handles_relative_parent_segments() {
406        let mut base = vec![
407            "ostree".to_string(),
408            "boot.1.1".to_string(),
409            "live-pocket-fedora".to_string(),
410            "bootcsum".to_string(),
411        ];
412        apply_path_target(
413            &mut base,
414            "../../../deploy/live-pocket-fedora/deploy/deadbeef.0",
415        )
416        .unwrap();
417        assert_eq!(
418            base,
419            vec![
420                "ostree".to_string(),
421                "deploy".to_string(),
422                "live-pocket-fedora".to_string(),
423                "deploy".to_string(),
424                "deadbeef.0".to_string()
425            ]
426        );
427    }
428
429    #[test]
430    fn apply_path_target_replaces_base_on_absolute_targets() {
431        let mut base = vec!["ostree".to_string(), "boot.1".to_string()];
432        apply_path_target(&mut base, "/ostree/deploy/live-pocket-fedora").unwrap();
433        assert_eq!(
434            base,
435            vec![
436                "ostree".to_string(),
437                "deploy".to_string(),
438                "live-pocket-fedora".to_string()
439            ]
440        );
441    }
442
443    #[test]
444    fn ostree_decorator_maps_paths_into_deployment_root() {
445        let rootfs =
446            OstreeFs::new(MockFilesystem::default(), "/ostree/boot.1/fedora/abc123/0").unwrap();
447        assert_eq!(
448            rootfs.map_path("/lib/modules"),
449            "/ostree/boot.1/fedora/abc123/0/lib/modules"
450        );
451        assert_eq!(
452            rootfs.map_path("usr/lib/modules"),
453            "/ostree/boot.1/fedora/abc123/0/usr/lib/modules"
454        );
455        assert_eq!(rootfs.map_path("/"), "/ostree/boot.1/fedora/abc123/0");
456    }
457
458    #[test]
459    fn resolve_deployment_path_follows_relative_symlink() {
460        let mut fs = MockFilesystem::default();
461        fs.add_dir("/ostree", &["boot.1", "deploy"]);
462        fs.add_dir("/ostree/boot.1", &["fedora"]);
463        fs.add_dir("/ostree/boot.1/fedora", &["abc"]);
464        fs.add_dir("/ostree/boot.1/fedora/abc", &["0"]);
465        fs.add_symlink_target(
466            "/ostree/boot.1/fedora/abc/0",
467            "../../../deploy/fedora/deploy/deadbeef.0",
468        );
469        fs.add_dir("/ostree/deploy", &["fedora"]);
470        fs.add_dir("/ostree/deploy/fedora", &["deploy"]);
471        fs.add_dir("/ostree/deploy/fedora/deploy", &["deadbeef.0"]);
472        fs.add_dir("/ostree/deploy/fedora/deploy/deadbeef.0", &[]);
473
474        let resolved = block_on(OstreeFs::resolve_deployment_path(
475            &fs,
476            "/ostree/boot.1/fedora/abc/0",
477        ))
478        .unwrap();
479        assert_eq!(resolved, "ostree/deploy/fedora/deploy/deadbeef.0");
480    }
481}