Skip to main content

phoxal_bundle/
path.rs

1//! Canonical bundle-relative paths.
2
3use std::fmt;
4use std::path::{Path, PathBuf};
5
6use phoxal_runtime_contract::wire_schema::{DescribeWire, WireSchema};
7use serde::{Deserialize, Serialize};
8
9/// A normalized bundle-relative path: forward slashes only, no leading slash,
10/// no empty, `.`, or `..` component.
11#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub struct BundlePath(String);
13
14impl BundlePath {
15    /// Validate a forward-slash relative path.
16    pub fn new(value: impl Into<String>) -> Result<Self, BundlePathError> {
17        let value = value.into();
18        if value.is_empty() {
19            return Err(BundlePathError::Empty);
20        }
21        if value.starts_with('/') {
22            return Err(BundlePathError::Absolute(value));
23        }
24        if value.contains('\\') {
25            return Err(BundlePathError::NotNormalized(value));
26        }
27        if value
28            .split('/')
29            .any(|component| component.is_empty() || component == "." || component == "..")
30        {
31            return Err(BundlePathError::NotNormalized(value));
32        }
33        Ok(Self(value))
34    }
35
36    /// The normalized path string stored in JSON.
37    #[must_use]
38    pub fn as_str(&self) -> &str {
39        &self.0
40    }
41
42    pub(crate) fn filesystem_path(&self, root: &Path) -> PathBuf {
43        root.join(self.0.split('/').collect::<PathBuf>())
44    }
45}
46
47impl fmt::Display for BundlePath {
48    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49        formatter.write_str(self.as_str())
50    }
51}
52
53impl Serialize for BundlePath {
54    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
55        serializer.serialize_str(self.as_str())
56    }
57}
58
59impl<'de> Deserialize<'de> for BundlePath {
60    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
61        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
62    }
63}
64
65impl DescribeWire for BundlePath {
66    // Invariant: this states what the `Serialize` above writes - the normalized
67    // forward-slash path as one string.
68    fn wire_schema() -> WireSchema {
69        WireSchema::opaque("BundlePath", WireSchema::String)
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    /// `BundlePath` has a hand-written serializer whose output shape the Rust
78    /// declaration does not predict, so the declared shape is checked against a
79    /// real serialized value.
80    #[test]
81    fn the_declared_shape_is_the_shape_its_serializer_writes() {
82        let path = BundlePath::new("bin/brain").expect("a canonical bundle path");
83        let json = serde_json::to_value(&path).expect("a bundle path serializes");
84        assert_eq!(BundlePath::wire_schema().conforms(&json), Ok(()));
85        assert_eq!(
86            BundlePath::wire_schema(),
87            WireSchema::opaque("BundlePath", WireSchema::String)
88        );
89    }
90
91    /// Every way of naming something outside the bundle is refused, which is
92    /// what makes an asset read unable to escape `assets/`.
93    #[test]
94    fn a_path_that_could_leave_the_bundle_is_refused() {
95        for rejected in [
96            "",
97            "/etc/passwd",
98            "assets/../../etc",
99            "assets/./x",
100            "assets\\x",
101        ] {
102            assert!(BundlePath::new(rejected).is_err(), "{rejected}");
103        }
104        assert_eq!(
105            BundlePath::new("assets/robot/meshes/base.stl")
106                .expect("a normalized relative path")
107                .as_str(),
108            "assets/robot/meshes/base.stl"
109        );
110    }
111}
112
113/// Why a bundle-relative path was rejected.
114#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
115pub enum BundlePathError {
116    #[error("bundle path is empty")]
117    Empty,
118    #[error("bundle path is absolute: '{0}'")]
119    Absolute(String),
120    #[error("bundle path is not normalized: '{0}'")]
121    NotNormalized(String),
122}