1use std::fmt;
4use std::path::{Path, PathBuf};
5
6use phoxal_runtime_contract::wire_schema::{DescribeWire, WireSchema};
7use serde::{Deserialize, Serialize};
8
9#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub struct BundlePath(String);
13
14impl BundlePath {
15 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 #[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 fn wire_schema() -> WireSchema {
69 WireSchema::opaque("BundlePath", WireSchema::String)
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 #[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 #[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#[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}