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
22pub trait Filesystem {
24 type Error;
25
26 async fn read_all(&self, path: &str) -> Result<Vec<u8>, Self::Error>;
28
29 async fn read_range(&self, path: &str, offset: u64, len: usize)
31 -> Result<Vec<u8>, Self::Error>;
32
33 async fn read_dir(&self, path: &str) -> Result<Vec<String>, Self::Error>;
35
36 async fn entry_type(&self, path: &str) -> Result<Option<FilesystemEntryType>, Self::Error>;
40
41 async fn read_link(&self, path: &str) -> Result<String, Self::Error>;
45
46 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(¤t_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(¤t_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}