1use std::collections::BTreeMap;
7
8use core::fmt;
9use serde::{de, Deserialize, Deserializer, Serialize};
10use thiserror::Error;
11
12pub const INLINE_THRESHOLD_BYTES: usize = 64 * 1024;
16
17pub const MANIFEST_SIZE_CAP_BYTES: usize = 256 * 1024;
19
20#[derive(Debug, Error, PartialEq, Eq)]
22pub enum TargetError {
23 #[error("target uri length {0} outside 1..=2048")]
25 Length(usize),
26 #[error("target uri contains whitespace or control characters")]
28 Charset,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
34#[serde(transparent)]
35pub struct CanonicalUrl(String);
36
37impl CanonicalUrl {
38 pub fn new(raw: &str) -> Result<Self, TargetError> {
41 if raw.is_empty() || raw.len() > 2048 {
42 return Err(TargetError::Length(raw.len()));
43 }
44 if raw.chars().any(|c| c.is_whitespace() || c.is_control()) {
45 return Err(TargetError::Charset);
46 }
47 Ok(Self(raw.to_owned()))
48 }
49
50 #[must_use]
52 pub fn as_str(&self) -> &str {
53 &self.0
54 }
55}
56
57impl fmt::Display for CanonicalUrl {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.write_str(&self.0)
60 }
61}
62
63impl<'de> Deserialize<'de> for CanonicalUrl {
64 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
65 let s = String::deserialize(d)?;
66 Self::new(&s).map_err(de::Error::custom)
67 }
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74pub struct TargetMeta {
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub title: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub description: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub image_url: Option<CanonicalUrl>,
84 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
86 pub labels: BTreeMap<String, String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct MediaRef {
94 pub uri: CanonicalUrl,
96 pub content_type: String,
98 pub size: u64,
100 pub sha256: Sha256Hex,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106#[serde(transparent)]
107pub struct Sha256Hex(String);
108
109#[derive(Debug, Error, PartialEq, Eq)]
111#[error("sha256 must be 64 lowercase hex characters")]
112pub struct Sha256Error;
113
114impl Sha256Hex {
115 pub fn new(raw: &str) -> Result<Self, Sha256Error> {
117 let ok = raw.len() == 64
118 && raw
119 .bytes()
120 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
121 if ok {
122 Ok(Self(raw.to_owned()))
123 } else {
124 Err(Sha256Error)
125 }
126 }
127
128 #[must_use]
130 pub fn as_str(&self) -> &str {
131 &self.0
132 }
133}
134
135impl<'de> Deserialize<'de> for Sha256Hex {
136 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
137 let s = String::deserialize(d)?;
138 Self::new(&s).map_err(de::Error::custom)
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn canonical_url_rules() {
148 assert!(CanonicalUrl::new("ws://analysis/report.md").is_ok());
149 assert!(CanonicalUrl::new("/tmp/analysis.md").is_ok());
150 assert_eq!(CanonicalUrl::new(""), Err(TargetError::Length(0)));
151 assert_eq!(CanonicalUrl::new("has space"), Err(TargetError::Charset));
152 assert_eq!(CanonicalUrl::new("tab\there"), Err(TargetError::Charset));
153 let long = "x".repeat(2049);
154 assert!(matches!(
155 CanonicalUrl::new(&long),
156 Err(TargetError::Length(2049))
157 ));
158 }
159
160 #[test]
161 fn sha256_hex_validation() {
162 let ok = "a".repeat(64);
163 assert!(Sha256Hex::new(&ok).is_ok());
164 assert_eq!(Sha256Hex::new("short"), Err(Sha256Error));
165 let upper = "A".repeat(64);
166 assert_eq!(
167 Sha256Hex::new(&upper),
168 Err(Sha256Error),
169 "uppercase rejected — one canonical form"
170 );
171 let nonhex = "g".repeat(64);
172 assert_eq!(Sha256Hex::new(&nonhex), Err(Sha256Error));
173 }
174
175 #[test]
176 fn media_ref_serde_roundtrip() {
177 let m = MediaRef {
178 uri: CanonicalUrl::new("blob://ab/abcd").unwrap(),
179 content_type: "image/png".to_owned(),
180 size: 1024,
181 sha256: Sha256Hex::new(&"0".repeat(64)).unwrap(),
182 };
183 let json = serde_json::to_string(&m).unwrap();
184 let back: MediaRef = serde_json::from_str(&json).unwrap();
185 assert_eq!(back, m);
186 }
187
188 #[test]
189 fn target_meta_omits_empty_fields() {
190 let json = serde_json::to_string(&TargetMeta::default()).unwrap();
191 assert_eq!(
192 json, "{}",
193 "empty snapshot serializes empty — manifests stay small"
194 );
195 }
196}