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