1use 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#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct AttachmentWritePolicy {
17 pub max_payload_bytes: u64,
19 pub allowed_media_types: BTreeSet<String>,
21}
22
23impl AttachmentWritePolicy {
24 #[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 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#[derive(Debug, Clone)]
77pub struct FilesystemAttachmentWriter {
78 root: PathBuf,
79 policy: AttachmentWritePolicy,
80}
81
82impl FilesystemAttachmentWriter {
83 #[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 #[must_use]
94 pub fn root(&self) -> &Path {
95 &self.root
96 }
97
98 #[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}