Skip to main content

assay_core/
attachments.rs

1//! Host-side AttachmentWriter implementations for protocol adapter payload preservation.
2
3use std::collections::BTreeSet;
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7
8use assay_adapter_api::{
9    AdapterError, AdapterErrorKind, AdapterResult, AttachmentWriter, RawPayloadRef,
10};
11use sha2::{Digest, Sha256};
12use tempfile::NamedTempFile;
13
14/// Host-enforced policy for preserving adapter raw payloads.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct AttachmentWritePolicy {
17    /// Hard size ceiling applied before persistence.
18    pub max_payload_bytes: u64,
19    /// Explicit allowlist of canonical media types accepted by the host.
20    pub allowed_media_types: BTreeSet<String>,
21}
22
23impl AttachmentWritePolicy {
24    /// Create a new attachment policy.
25    #[must_use]
26    pub fn new<I, S>(max_payload_bytes: u64, allowed_media_types: I) -> Self
27    where
28        I: IntoIterator<Item = S>,
29        S: Into<String>,
30    {
31        Self::try_new(max_payload_bytes, allowed_media_types)
32            .expect("attachment policy allowlist entries must be valid media types")
33    }
34
35    /// Create a new attachment policy with explicit validation errors.
36    pub fn try_new<I, S>(max_payload_bytes: u64, allowed_media_types: I) -> AdapterResult<Self>
37    where
38        I: IntoIterator<Item = S>,
39        S: Into<String>,
40    {
41        let allowed_media_types = allowed_media_types
42            .into_iter()
43            .map(|media_type| canonicalize_media_type(&media_type.into()))
44            .collect::<AdapterResult<BTreeSet<_>>>()?;
45
46        Ok(Self {
47            max_payload_bytes,
48            allowed_media_types,
49        })
50    }
51
52    fn validate(&self, payload: &[u8], media_type: &str) -> AdapterResult<String> {
53        if payload.len() as u64 > self.max_payload_bytes {
54            return Err(AdapterError::new(
55                AdapterErrorKind::Measurement,
56                format!(
57                    "payload exceeds attachment policy max_payload_bytes ({})",
58                    self.max_payload_bytes
59                ),
60            ));
61        }
62
63        let canonical_media_type = canonicalize_media_type(media_type)?;
64        if !self.allowed_media_types.contains(&canonical_media_type) {
65            return Err(AdapterError::new(
66                AdapterErrorKind::Measurement,
67                format!("unsupported attachment media type: {canonical_media_type}"),
68            ));
69        }
70
71        Ok(canonical_media_type)
72    }
73}
74
75/// Filesystem-backed host AttachmentWriter with explicit policy enforcement.
76#[derive(Debug, Clone)]
77pub struct FilesystemAttachmentWriter {
78    root: PathBuf,
79    policy: AttachmentWritePolicy,
80}
81
82impl FilesystemAttachmentWriter {
83    /// Create a new filesystem-backed attachment writer.
84    #[must_use]
85    pub fn new(root: impl Into<PathBuf>, policy: AttachmentWritePolicy) -> Self {
86        Self {
87            root: root.into(),
88            policy,
89        }
90    }
91
92    /// Return the root directory used for persisted payloads.
93    #[must_use]
94    pub fn root(&self) -> &Path {
95        &self.root
96    }
97
98    /// Resolve the stored payload path for a digest.
99    #[must_use]
100    fn stored_path(&self, sha256: &str) -> PathBuf {
101        let shard = &sha256[..2];
102        self.root.join(shard).join(sha256)
103    }
104}
105
106impl AttachmentWriter for FilesystemAttachmentWriter {
107    fn write_raw_payload(&self, payload: &[u8], media_type: &str) -> AdapterResult<RawPayloadRef> {
108        let canonical_media_type = self.policy.validate(payload, media_type)?;
109        let sha256 = sha256_hex(payload);
110        let target = self.stored_path(&sha256);
111
112        if target.exists() {
113            return Ok(RawPayloadRef {
114                sha256,
115                size_bytes: payload.len() as u64,
116                media_type: canonical_media_type,
117            });
118        }
119
120        let parent = target.parent().ok_or_else(|| {
121            AdapterError::new(
122                AdapterErrorKind::Infrastructure,
123                "attachment target path has no parent directory",
124            )
125        })?;
126
127        fs::create_dir_all(parent).map_err(|err| {
128            AdapterError::new(
129                AdapterErrorKind::Infrastructure,
130                format!("failed to prepare attachment directory: {err}"),
131            )
132        })?;
133
134        let mut temp = NamedTempFile::new_in(parent).map_err(|err| {
135            AdapterError::new(
136                AdapterErrorKind::Infrastructure,
137                format!("failed to allocate attachment temp file: {err}"),
138            )
139        })?;
140
141        temp.write_all(payload).map_err(|err| {
142            AdapterError::new(
143                AdapterErrorKind::Infrastructure,
144                format!("failed to write attachment payload: {err}"),
145            )
146        })?;
147        temp.flush().map_err(|err| {
148            AdapterError::new(
149                AdapterErrorKind::Infrastructure,
150                format!("failed to flush attachment payload: {err}"),
151            )
152        })?;
153
154        if let Err(err) = temp.persist(&target) {
155            if !target.exists() {
156                return Err(AdapterError::new(
157                    AdapterErrorKind::Infrastructure,
158                    format!("failed to persist attachment payload: {}", err.error),
159                ));
160            }
161        }
162
163        Ok(RawPayloadRef {
164            sha256,
165            size_bytes: payload.len() as u64,
166            media_type: canonical_media_type,
167        })
168    }
169}
170
171fn canonicalize_media_type(media_type: &str) -> AdapterResult<String> {
172    let canonical = media_type
173        .split(';')
174        .next()
175        .unwrap_or_default()
176        .trim()
177        .to_ascii_lowercase();
178
179    if canonical.is_empty() {
180        return Err(AdapterError::new(
181            AdapterErrorKind::Measurement,
182            "attachment media type must not be empty",
183        ));
184    }
185
186    let valid = canonical
187        .bytes()
188        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'+' | b'-'));
189    let Some((type_, subtype)) = canonical.split_once('/') else {
190        return Err(AdapterError::new(
191            AdapterErrorKind::Measurement,
192            "attachment media type is invalid",
193        ));
194    };
195    if !valid || type_.is_empty() || subtype.is_empty() || subtype.contains('/') {
196        return Err(AdapterError::new(
197            AdapterErrorKind::Measurement,
198            "attachment media type is invalid",
199        ));
200    }
201
202    Ok(canonical)
203}
204
205fn sha256_hex(payload: &[u8]) -> String {
206    let mut hasher = Sha256::new();
207    hasher.update(payload);
208    hex::encode(hasher.finalize())
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn policy() -> AttachmentWritePolicy {
216        AttachmentWritePolicy::new(1024, ["application/json", "application/octet-stream"])
217    }
218
219    #[test]
220    fn attachment_writer_persists_payload_and_returns_digest_ref() {
221        let dir = tempfile::tempdir().unwrap();
222        let writer = FilesystemAttachmentWriter::new(dir.path(), policy());
223        let payload = br#"{"hello":"world"}"#;
224
225        let raw_ref = writer
226            .write_raw_payload(payload, "Application/JSON; charset=utf-8")
227            .unwrap();
228
229        assert_eq!(raw_ref.size_bytes, payload.len() as u64);
230        assert_eq!(raw_ref.media_type, "application/json");
231        assert_eq!(raw_ref.sha256, sha256_hex(payload));
232        assert_eq!(
233            fs::read(writer.stored_path(&raw_ref.sha256)).unwrap(),
234            payload
235        );
236    }
237
238    #[test]
239    fn attachment_writer_rejects_oversize_payload_as_measurement() {
240        let dir = tempfile::tempdir().unwrap();
241        let writer = FilesystemAttachmentWriter::new(
242            dir.path(),
243            AttachmentWritePolicy::new(4, ["application/json"]),
244        );
245        let payload = br#"{"super":"secret-token"}"#;
246
247        let err = writer
248            .write_raw_payload(payload, "application/json")
249            .unwrap_err();
250
251        assert_eq!(err.kind, AdapterErrorKind::Measurement);
252        assert!(!err.message.contains("secret-token"));
253    }
254
255    #[test]
256    fn attachment_writer_rejects_invalid_media_type_as_measurement() {
257        let dir = tempfile::tempdir().unwrap();
258        let writer = FilesystemAttachmentWriter::new(dir.path(), policy());
259
260        let err = writer
261            .write_raw_payload(br#"{"ok":true}"#, "not a media type")
262            .unwrap_err();
263
264        assert_eq!(err.kind, AdapterErrorKind::Measurement);
265    }
266
267    #[test]
268    fn attachment_policy_canonicalizes_allowlist_entries() {
269        let dir = tempfile::tempdir().unwrap();
270        let policy = AttachmentWritePolicy::try_new(
271            1024,
272            [
273                "Application/JSON; charset=utf-8",
274                "application/octet-stream",
275            ],
276        )
277        .unwrap();
278        let writer = FilesystemAttachmentWriter::new(dir.path(), policy);
279
280        let raw_ref = writer
281            .write_raw_payload(br#"{"ok":true}"#, "application/json")
282            .unwrap();
283
284        assert_eq!(raw_ref.media_type, "application/json");
285    }
286
287    #[test]
288    fn attachment_writer_rejects_structurally_invalid_media_type_as_measurement() {
289        let dir = tempfile::tempdir().unwrap();
290        let writer = FilesystemAttachmentWriter::new(dir.path(), policy());
291
292        let err = writer
293            .write_raw_payload(br#"{"ok":true}"#, "application/json/extra")
294            .unwrap_err();
295
296        assert_eq!(err.kind, AdapterErrorKind::Measurement);
297    }
298
299    #[test]
300    fn attachment_writer_rejects_disallowed_media_type_as_measurement() {
301        let dir = tempfile::tempdir().unwrap();
302        let writer = FilesystemAttachmentWriter::new(dir.path(), policy());
303
304        let err = writer
305            .write_raw_payload(b"opaque-bytes", "text/plain")
306            .unwrap_err();
307
308        assert_eq!(err.kind, AdapterErrorKind::Measurement);
309        assert_eq!(err.message, "unsupported attachment media type: text/plain");
310    }
311
312    #[test]
313    fn attachment_writer_surfaces_storage_failure_as_infrastructure() {
314        let dir = tempfile::tempdir().unwrap();
315        let root_file = dir.path().join("not-a-directory");
316        fs::write(&root_file, b"occupied").unwrap();
317        let writer = FilesystemAttachmentWriter::new(root_file, policy());
318
319        let err = writer
320            .write_raw_payload(br#"{"ok":true}"#, "application/json")
321            .unwrap_err();
322
323        assert_eq!(err.kind, AdapterErrorKind::Infrastructure);
324        assert!(!err.message.contains("{\"ok\":true}"));
325    }
326
327    #[test]
328    fn attachment_writer_reuses_existing_digest_path() {
329        let dir = tempfile::tempdir().unwrap();
330        let writer = FilesystemAttachmentWriter::new(dir.path(), policy());
331        let payload = br#"{"hello":"world"}"#;
332
333        let first = writer
334            .write_raw_payload(payload, "application/json")
335            .unwrap();
336        let second = writer
337            .write_raw_payload(payload, "application/json")
338            .unwrap();
339
340        assert_eq!(first, second);
341        assert_eq!(
342            fs::read(writer.stored_path(&first.sha256)).unwrap(),
343            payload
344        );
345    }
346}