1use 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14pub struct BundlePath(String);
15
16impl BundlePath {
17 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 #[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 fn wire_schema() -> WireSchema {
77 WireSchema::opaque("BundlePath", WireSchema::String)
78 }
79}
80
81#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
83pub struct Sha256Digest(pub(crate) [u8; 32]);
84
85impl Sha256Digest {
86 #[must_use]
88 pub fn of(bytes: &[u8]) -> Self {
89 Self(Sha256::digest(bytes).into())
90 }
91
92 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 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 #[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 fn wire_schema() -> WireSchema {
157 WireSchema::opaque("Sha256Digest", WireSchema::String)
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[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#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
206#[error("digest must be 64 lowercase hexadecimal characters, got '{0}'")]
207pub struct DigestError(String);
208
209#[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}