Skip to main content

phoxal_bundle/
path.rs

1//! Canonical bundle-relative paths and digest values.
2
3use std::fmt;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6
7use phoxal_runtime_contract::wire_schema::{DescribeWire, WireSchema};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11/// A normalized bundle-relative path: forward slashes only, no leading slash,
12/// no empty, `.`, or `..` component.
13#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14pub struct BundlePath(String);
15
16impl BundlePath {
17    /// Validate a forward-slash relative path.
18    pub fn new(value: impl Into<String>) -> Result<Self, BundlePathError> {
19        let value = value.into();
20        if value.is_empty() {
21            return Err(BundlePathError::Empty);
22        }
23        if value.starts_with('/') {
24            return Err(BundlePathError::Absolute(value));
25        }
26        if value.contains('\\') {
27            return Err(BundlePathError::NotNormalized(value));
28        }
29        if value
30            .split('/')
31            .any(|component| component.is_empty() || component == "." || component == "..")
32        {
33            return Err(BundlePathError::NotNormalized(value));
34        }
35        Ok(Self(value))
36    }
37
38    /// The normalized path string stored in JSON.
39    #[must_use]
40    pub fn as_str(&self) -> &str {
41        &self.0
42    }
43
44    pub(crate) fn starts_with_directory(&self, directory: &str) -> bool {
45        self.0
46            .strip_prefix(directory)
47            .is_some_and(|rest| rest.starts_with('/') && rest.len() > 1)
48    }
49
50    pub(crate) fn filesystem_path(&self, root: &Path) -> PathBuf {
51        root.join(self.0.split('/').collect::<PathBuf>())
52    }
53}
54
55impl fmt::Display for BundlePath {
56    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57        formatter.write_str(self.as_str())
58    }
59}
60
61impl Serialize for BundlePath {
62    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
63        serializer.serialize_str(self.as_str())
64    }
65}
66
67impl<'de> Deserialize<'de> for BundlePath {
68    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
69        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
70    }
71}
72
73impl DescribeWire for BundlePath {
74    // Invariant: this states what the `Serialize` above writes - the normalized
75    // forward-slash path as one string.
76    fn wire_schema() -> WireSchema {
77        WireSchema::opaque("BundlePath", WireSchema::String)
78    }
79}
80
81/// A SHA-256 digest rendered as exactly 64 lowercase hexadecimal characters.
82#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
83pub struct Sha256Digest(pub(crate) [u8; 32]);
84
85impl Sha256Digest {
86    /// Hash one byte sequence.
87    #[must_use]
88    pub fn of(bytes: &[u8]) -> Self {
89        Self(Sha256::digest(bytes).into())
90    }
91
92    /// Stream one reader into the digest without buffering the complete file.
93    pub fn from_reader(mut reader: impl Read) -> std::io::Result<Self> {
94        let mut hasher = Sha256::new();
95        let mut buffer = [0_u8; 64 * 1024];
96        loop {
97            let read = reader.read(&mut buffer)?;
98            if read == 0 {
99                break;
100            }
101            hasher.update(&buffer[..read]);
102        }
103        Ok(Self(hasher.finalize().into()))
104    }
105
106    /// Parse the canonical JSON representation.
107    pub fn parse(value: &str) -> Result<Self, DigestError> {
108        if value.len() != 64
109            || !value
110                .bytes()
111                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
112        {
113            return Err(DigestError(value.to_string()));
114        }
115        let mut bytes = [0; 32];
116        for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
117            bytes[index] = (hex(pair[0])? << 4) | hex(pair[1])?;
118        }
119        Ok(Self(bytes))
120    }
121
122    /// Render the canonical lowercase hexadecimal representation.
123    #[must_use]
124    pub fn as_hex(self) -> String {
125        let mut output = String::with_capacity(64);
126        for byte in self.0 {
127            output.push(hex_digit(byte >> 4));
128            output.push(hex_digit(byte & 0x0f));
129        }
130        output
131    }
132}
133
134impl fmt::Display for Sha256Digest {
135    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136        formatter.write_str(&self.as_hex())
137    }
138}
139
140impl Serialize for Sha256Digest {
141    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
142        serializer.serialize_str(&self.as_hex())
143    }
144}
145
146impl<'de> Deserialize<'de> for Sha256Digest {
147    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
148        Self::parse(&String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
149    }
150}
151
152impl DescribeWire for Sha256Digest {
153    // Invariant: this states what the `Serialize` above writes - the canonical
154    // lowercase hexadecimal rendering as one string, never the 32 raw bytes the
155    // type holds.
156    fn wire_schema() -> WireSchema {
157        WireSchema::opaque("Sha256Digest", WireSchema::String)
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    /// Both of these have hand-written serializers whose output shape the Rust
166    /// declaration does not predict - a digest is 32 bytes in memory and 64
167    /// characters on the wire - so the declared shape is checked against a real
168    /// serialized value.
169    #[test]
170    fn each_declared_shape_is_the_shape_its_serializer_writes() {
171        let path = BundlePath::new("bin/brain").expect("a canonical bundle path");
172        let path_json = serde_json::to_value(&path).expect("a bundle path serializes");
173        assert_eq!(BundlePath::wire_schema().conforms(&path_json), Ok(()));
174        assert_eq!(
175            BundlePath::wire_schema(),
176            WireSchema::opaque("BundlePath", WireSchema::String)
177        );
178
179        let digest = Sha256Digest::of(b"payload");
180        let digest_json = serde_json::to_value(digest).expect("a digest serializes");
181        assert_eq!(Sha256Digest::wire_schema().conforms(&digest_json), Ok(()));
182        assert_eq!(
183            Sha256Digest::wire_schema(),
184            WireSchema::opaque("Sha256Digest", WireSchema::String)
185        );
186    }
187}
188
189fn hex(value: u8) -> Result<u8, DigestError> {
190    match value {
191        b'0'..=b'9' => Ok(value - b'0'),
192        b'a'..=b'f' => Ok(value - b'a' + 10),
193        _ => Err(DigestError(String::from("non-hex digest"))),
194    }
195}
196
197const fn hex_digit(value: u8) -> char {
198    match value {
199        0..=9 => (b'0' + value) as char,
200        _ => (b'a' + value - 10) as char,
201    }
202}
203
204/// A digest that was not the canonical lowercase SHA-256 spelling.
205#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
206#[error("digest must be 64 lowercase hexadecimal characters, got '{0}'")]
207pub struct DigestError(String);
208
209/// Why a bundle-relative path was rejected.
210#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
211pub enum BundlePathError {
212    #[error("bundle path is empty")]
213    Empty,
214    #[error("bundle path is absolute: '{0}'")]
215    Absolute(String),
216    #[error("bundle path is not normalized: '{0}'")]
217    NotNormalized(String),
218}