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 serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10/// A normalized, traversal-proof bundle-relative path.
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 starts_with_directory(&self, directory: &str) -> bool {
43        self.0
44            .strip_prefix(directory)
45            .is_some_and(|rest| rest.starts_with('/') && rest.len() > 1)
46    }
47
48    pub(crate) fn filesystem_path(&self, root: &Path) -> PathBuf {
49        root.join(self.0.split('/').collect::<PathBuf>())
50    }
51}
52
53impl fmt::Display for BundlePath {
54    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55        formatter.write_str(self.as_str())
56    }
57}
58
59impl Serialize for BundlePath {
60    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
61        serializer.serialize_str(self.as_str())
62    }
63}
64
65impl<'de> Deserialize<'de> for BundlePath {
66    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
67        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
68    }
69}
70
71/// A SHA-256 digest rendered as exactly 64 lowercase hexadecimal characters.
72#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
73pub struct Sha256Digest(pub(crate) [u8; 32]);
74
75impl Sha256Digest {
76    /// Hash one byte sequence.
77    #[must_use]
78    pub fn of(bytes: &[u8]) -> Self {
79        Self(Sha256::digest(bytes).into())
80    }
81
82    /// Stream one reader into the digest without buffering the complete file.
83    pub fn from_reader(mut reader: impl Read) -> std::io::Result<Self> {
84        let mut hasher = Sha256::new();
85        let mut buffer = [0_u8; 64 * 1024];
86        loop {
87            let read = reader.read(&mut buffer)?;
88            if read == 0 {
89                break;
90            }
91            hasher.update(&buffer[..read]);
92        }
93        Ok(Self(hasher.finalize().into()))
94    }
95
96    /// Parse the canonical JSON representation.
97    pub fn parse(value: &str) -> Result<Self, DigestError> {
98        if value.len() != 64
99            || !value
100                .bytes()
101                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
102        {
103            return Err(DigestError(value.to_string()));
104        }
105        let mut bytes = [0; 32];
106        for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
107            bytes[index] = (hex(pair[0])? << 4) | hex(pair[1])?;
108        }
109        Ok(Self(bytes))
110    }
111
112    /// Render the canonical lowercase hexadecimal representation.
113    #[must_use]
114    pub fn as_hex(self) -> String {
115        let mut output = String::with_capacity(64);
116        for byte in self.0 {
117            output.push(hex_digit(byte >> 4));
118            output.push(hex_digit(byte & 0x0f));
119        }
120        output
121    }
122}
123
124impl fmt::Display for Sha256Digest {
125    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126        formatter.write_str(&self.as_hex())
127    }
128}
129
130impl Serialize for Sha256Digest {
131    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
132        serializer.serialize_str(&self.as_hex())
133    }
134}
135
136impl<'de> Deserialize<'de> for Sha256Digest {
137    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
138        Self::parse(&String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
139    }
140}
141
142fn hex(value: u8) -> Result<u8, DigestError> {
143    match value {
144        b'0'..=b'9' => Ok(value - b'0'),
145        b'a'..=b'f' => Ok(value - b'a' + 10),
146        _ => Err(DigestError(String::from("non-hex digest"))),
147    }
148}
149
150const fn hex_digit(value: u8) -> char {
151    match value {
152        0..=9 => (b'0' + value) as char,
153        _ => (b'a' + value - 10) as char,
154    }
155}
156
157/// A digest that was not the canonical lowercase SHA-256 spelling.
158#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
159#[error("digest must be 64 lowercase hexadecimal characters, got '{0}'")]
160pub struct DigestError(String);
161
162/// Why a bundle-relative path was rejected.
163#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
164pub enum BundlePathError {
165    #[error("bundle path is empty")]
166    Empty,
167    #[error("bundle path is absolute: '{0}'")]
168    Absolute(String),
169    #[error("bundle path is not normalized: '{0}'")]
170    NotNormalized(String),
171}