Skip to main content

waggle_core/
target.rs

1//! Targets and their metadata: [`CanonicalUrl`], the mint-time
2//! [`TargetMeta`] snapshot (invariant I-3: the system never scrapes
3//! targets), and [`MediaRef`] — bytes by reference with integrity
4//! (design doc `02`, rev 2.3).
5
6use std::collections::BTreeMap;
7
8use core::fmt;
9use serde::{de, Deserialize, Deserializer, Serialize};
10use thiserror::Error;
11
12/// Bodies at or under this many bytes may inline in a manifest; above it
13/// they become a [`MediaRef`] into the content-addressed store. Chosen at
14/// the range where `SQLite`'s small-blob reads beat the filesystem (02).
15pub const INLINE_THRESHOLD_BYTES: usize = 64 * 1024;
16
17/// Total manifest size cap — mint rejects manifests that exceed it (02).
18pub const MANIFEST_SIZE_CAP_BYTES: usize = 256 * 1024;
19
20/// Why a target URI was rejected.
21#[derive(Debug, Error, PartialEq, Eq)]
22pub enum TargetError {
23    /// Empty or longer than 2048 bytes.
24    #[error("target uri length {0} outside 1..=2048")]
25    Length(usize),
26    /// Contains whitespace or control characters.
27    #[error("target uri contains whitespace or control characters")]
28    Charset,
29}
30
31/// The permanent identity of an artifact — a file path, workspace URI, or
32/// URL. Never mutated by sharing; tokens point at it (02 §1).
33#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
34#[serde(transparent)]
35pub struct CanonicalUrl(String);
36
37impl CanonicalUrl {
38    /// Validate a target URI: non-empty, ≤2048 bytes, no whitespace or
39    /// control characters. Scheme semantics belong to hosts, not the core.
40    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    /// The URI as given.
51    #[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/// Mint-time snapshot of what the target *is* — supplied by the minter,
71/// never scraped (I-3). This is what unfurls render and what agents read
72/// before resolving.
73#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74pub struct TargetMeta {
75    /// Short human/agent-readable title.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub title: Option<String>,
78    /// One-paragraph description of the artifact.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub description: Option<String>,
81    /// Optional preview image for unfurl cards.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub image_url: Option<CanonicalUrl>,
84    /// Public labels — mint-time only; mutable labels live on the manifest.
85    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
86    pub labels: BTreeMap<String, String>,
87}
88
89/// Bytes by reference: where they live, what they are, and the integrity
90/// hash a resolver verifies after fetching (rev 2.3). Bytes never ride the
91/// log or a tool response — delivery is out-of-band.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct MediaRef {
94    /// Where the bytes live (content-addressed store or external URL).
95    pub uri: CanonicalUrl,
96    /// MIME type of the referenced bytes.
97    pub content_type: String,
98    /// Size in bytes.
99    pub size: u64,
100    /// SHA-256 of the bytes, hex-encoded — verify what you fetched.
101    pub sha256: Sha256Hex,
102}
103
104/// A lowercase-hex SHA-256 digest (64 chars), validated on construction.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106#[serde(transparent)]
107pub struct Sha256Hex(String);
108
109/// Why a digest string was rejected.
110#[derive(Debug, Error, PartialEq, Eq)]
111#[error("sha256 must be 64 lowercase hex characters")]
112pub struct Sha256Error;
113
114impl Sha256Hex {
115    /// Validate 64 lowercase hex characters.
116    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    /// The digest as lowercase hex.
129    #[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}