1use std::fs;
9use std::path::{Path, PathBuf};
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15use crate::tools::shell::redact_secrets;
16use crate::utils::datetime;
17
18pub const DEFAULT_MAX_ARTIFACT_BYTES: usize = 64 * 1024;
20
21pub const DEFAULT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
23
24const ARTIFACT_SCHEMA_VERSION: u32 = 1;
25const TRUNCATION_MARKER: &str = "...[truncated]";
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ArtifactKind {
31 ToolEvidence,
33}
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ArtifactRetentionState {
39 Active,
41 Expired,
43 Corrupt,
45}
46
47#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
49pub struct ArtifactMetadata {
50 pub schema_version: u32,
52 pub identity: String,
54 pub kind: ArtifactKind,
56 pub handle: String,
58 pub content_hash: String,
60 pub original_byte_count: usize,
62 pub bounded_byte_count: usize,
64 pub truncated: bool,
66 pub redacted: bool,
68 pub created_at: String,
70 pub created_at_unix: u64,
72 pub expires_at: Option<String>,
74 pub expires_at_unix: Option<u64>,
76 pub retention: ArtifactRetentionState,
78}
79
80#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct ArtifactWrite {
83 pub metadata: ArtifactMetadata,
85 pub bounded_lines: Vec<String>,
87}
88
89#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct ArtifactRecovery {
92 pub metadata: ArtifactMetadata,
94 pub content: Option<String>,
96 pub diagnostic: Option<ArtifactDiagnostic>,
98}
99
100#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
102pub struct ArtifactDiagnostic {
103 pub code: String,
105 pub message: String,
107}
108
109#[derive(Clone, Debug)]
111pub struct ArtifactStore {
112 root: PathBuf,
113 max_bytes: usize,
114 retention: Option<Duration>,
115}
116
117impl ArtifactStore {
118 pub fn new(root: impl Into<PathBuf>) -> Self {
120 Self::with_limits(root, DEFAULT_MAX_ARTIFACT_BYTES, Some(DEFAULT_RETENTION))
121 }
122
123 pub fn with_limits(root: impl Into<PathBuf>, max_bytes: usize, retention: Option<Duration>) -> Self {
128 Self { root: root.into(), max_bytes, retention }
129 }
130
131 pub fn create_tool_evidence(&self, identity: &str, lines: &[String]) -> std::io::Result<ArtifactWrite> {
133 self.create(identity, ArtifactKind::ToolEvidence, &lines.join("\n"))
134 }
135
136 pub fn create(&self, identity: &str, kind: ArtifactKind, original: &str) -> std::io::Result<ArtifactWrite> {
138 let redacted = redact_artifact_content(original);
139 let (bounded, capped) = cap_bytes(&redacted, self.max_bytes);
140 let content_hash = sha256_hex(bounded.as_bytes());
141 let handle = format!(
142 "artifact_v{ARTIFACT_SCHEMA_VERSION}_{}",
143 stable_handle_hash(identity, &content_hash)
144 );
145 let created_at_unix = unix_now();
146 let expires_at_unix = self
147 .retention
148 .map(|duration| created_at_unix.saturating_add(duration.as_secs()));
149 let expires_at = expires_at_unix.map(datetime::from_unix_seconds);
150 let metadata = ArtifactMetadata {
151 schema_version: ARTIFACT_SCHEMA_VERSION,
152 identity: redact_secrets(identity),
153 kind,
154 handle: handle.clone(),
155 content_hash,
156 original_byte_count: original.len(),
157 bounded_byte_count: bounded.len(),
158 truncated: capped,
159 redacted: redacted != original,
160 created_at: datetime::from_unix_seconds(created_at_unix),
161 created_at_unix,
162 expires_at,
163 expires_at_unix,
164 retention: ArtifactRetentionState::Active,
165 };
166
167 fs::create_dir_all(&self.root)?;
168 write_atomic(&self.body_path(&handle), bounded.as_bytes())?;
169 let metadata_json = serde_json::to_vec(&metadata)
170 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
171 write_atomic(&self.metadata_path(&handle), &metadata_json)?;
172
173 Ok(ArtifactWrite { metadata, bounded_lines: split_lines(&bounded) })
174 }
175
176 pub fn metadata(&self, handle: &str) -> std::io::Result<ArtifactMetadata> {
178 let path = self.metadata_path(handle);
179 let bytes = fs::read(path)?;
180 serde_json::from_slice(&bytes).map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))
181 }
182
183 pub fn recover(&self, handle: &str) -> std::io::Result<ArtifactRecovery> {
185 let mut metadata = self.metadata(handle)?;
186 if Self::is_expired(&metadata) {
187 metadata.retention = ArtifactRetentionState::Expired;
188 return Ok(ArtifactRecovery {
189 metadata,
190 content: None,
191 diagnostic: Some(ArtifactDiagnostic {
192 code: "artifact_expired".to_string(),
193 message: format!("artifact `{handle}` is outside its retention period"),
194 }),
195 });
196 }
197
198 let body = match fs::read(self.body_path(handle)) {
199 Ok(body) => body,
200 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
201 metadata.retention = ArtifactRetentionState::Corrupt;
202 return Ok(ArtifactRecovery {
203 metadata,
204 content: None,
205 diagnostic: Some(ArtifactDiagnostic {
206 code: "artifact_missing".to_string(),
207 message: format!("artifact `{handle}` body is unavailable"),
208 }),
209 });
210 }
211 Err(error) => return Err(error),
212 };
213 let hash = sha256_hex(&body);
214 if hash != metadata.content_hash || body.len() != metadata.bounded_byte_count {
215 metadata.retention = ArtifactRetentionState::Corrupt;
216 return Ok(ArtifactRecovery {
217 metadata,
218 content: None,
219 diagnostic: Some(ArtifactDiagnostic {
220 code: "artifact_corrupt".to_string(),
221 message: format!("artifact `{handle}` failed integrity verification"),
222 }),
223 });
224 }
225
226 let content = String::from_utf8(body)
227 .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "artifact body is not valid UTF-8"))?;
228 Ok(ArtifactRecovery { metadata, content: Some(content), diagnostic: None })
229 }
230
231 pub fn body_path_for_test(&self, handle: &str) -> PathBuf {
233 self.body_path(handle)
234 }
235
236 fn is_expired(metadata: &ArtifactMetadata) -> bool {
237 metadata
238 .expires_at_unix
239 .is_some_and(|expires_at| unix_now() >= expires_at)
240 }
241
242 fn body_path(&self, handle: &str) -> PathBuf {
243 self.root.join(format!("{handle}.body"))
244 }
245
246 fn metadata_path(&self, handle: &str) -> PathBuf {
247 self.root.join(format!("{handle}.json"))
248 }
249}
250
251pub fn bounded_redacted_lines(lines: &[String], max_bytes: usize) -> Vec<String> {
254 let original = lines.join("\n");
255 let redacted = redact_artifact_content(&original);
256 let (bounded, _) = cap_bytes(&redacted, max_bytes);
257 split_lines(&bounded)
258}
259
260fn redact_artifact_content(value: &str) -> String {
261 match serde_json::from_str::<serde_json::Value>(value) {
262 Ok(json) => redact_json_value(json).to_string(),
263 Err(_) => {
264 let redacted = redact_secrets(value);
265 let token_re = regex_lite::Regex::new(r"(?i)(token|auth_token)\s*[:=]\s*\S{4,}")
266 .expect("valid artifact redaction regex");
267 token_re.replace_all(&redacted, "$1=[REDACTED]").to_string()
268 }
269 }
270}
271
272fn redact_json_value(value: serde_json::Value) -> serde_json::Value {
273 match value {
274 serde_json::Value::Array(values) => {
275 serde_json::Value::Array(values.into_iter().map(redact_json_value).collect())
276 }
277 serde_json::Value::Object(entries) => serde_json::Value::Object(
278 entries
279 .into_iter()
280 .map(|(key, value)| {
281 let value = if is_sensitive_key(&key) {
282 serde_json::Value::String("[REDACTED]".to_string())
283 } else {
284 redact_json_value(value)
285 };
286 (key, value)
287 })
288 .collect(),
289 ),
290 serde_json::Value::String(text) => serde_json::Value::String(redact_secrets(&text)),
291 value => value,
292 }
293}
294
295fn is_sensitive_key(key: &str) -> bool {
296 matches!(
297 key.to_ascii_lowercase().as_str(),
298 "password" | "passwd" | "api_key" | "apikey" | "access_token" | "secret" | "token"
299 )
300}
301
302fn cap_bytes(value: &str, max_bytes: usize) -> (String, bool) {
303 if value.len() <= max_bytes {
304 return (value.to_string(), false);
305 }
306 if max_bytes <= TRUNCATION_MARKER.len() {
307 let mut end = max_bytes;
308 while end > 0 && !value.is_char_boundary(end) {
309 end -= 1;
310 }
311 return (value[..end].to_string(), true);
312 }
313 let mut end = max_bytes - TRUNCATION_MARKER.len();
314 while end > 0 && !value.is_char_boundary(end) {
315 end -= 1;
316 }
317 let mut bounded = value[..end].to_string();
318 bounded.push_str(TRUNCATION_MARKER);
319 (bounded, true)
320}
321
322fn split_lines(value: &str) -> Vec<String> {
323 if value.is_empty() { Vec::new() } else { value.split('\n').map(str::to_string).collect() }
324}
325
326fn stable_handle_hash(identity: &str, content_hash: &str) -> String {
327 let mut hasher = Sha256::new();
328 hasher.update(identity.as_bytes());
329 hasher.update([0]);
330 hasher.update(content_hash.as_bytes());
331 hex_digest(hasher.finalize())
332}
333
334fn sha256_hex(bytes: &[u8]) -> String {
335 hex_digest(Sha256::digest(bytes))
336}
337
338fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
339 bytes.as_ref().iter().map(|byte| format!("{byte:02x}")).collect()
340}
341
342fn unix_now() -> u64 {
343 SystemTime::now()
344 .duration_since(UNIX_EPOCH)
345 .unwrap_or_default()
346 .as_secs()
347}
348
349fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
350 let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
351 fs::write(&temporary, bytes)?;
352 fs::rename(temporary, path)
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn creates_bounded_redacted_artifact_with_stable_metadata() {
361 let root = tempfile::tempdir().expect("temp dir");
362 let store = ArtifactStore::with_limits(root.path(), 32, None);
363 let write = store
364 .create_tool_evidence(
365 "tool:call-1",
366 &[
367 "api_key=sk-supersecretvalue".to_string(),
368 "abcdefghijklmnopqrstuvwxyz".to_string(),
369 ],
370 )
371 .expect("artifact");
372
373 assert!(write.metadata.handle.starts_with("artifact_v1_"));
374 assert_eq!(write.metadata.bounded_byte_count, 32);
375 assert!(write.metadata.truncated);
376 assert!(!write.bounded_lines.join("\n").contains("supersecretvalue"));
377 let recovered = store.recover(&write.metadata.handle).expect("recover");
378 assert_eq!(recovered.diagnostic, None);
379 let expected = write.bounded_lines.join("\n");
380 assert_eq!(recovered.content.as_deref(), Some(expected.as_str()));
381 }
382
383 #[test]
384 fn missing_and_corrupt_bodies_keep_metadata_and_return_diagnostics() {
385 let root = tempfile::tempdir().expect("temp dir");
386 let store = ArtifactStore::new(root.path());
387 let write = store
388 .create("identity", ArtifactKind::ToolEvidence, "safe")
389 .expect("artifact");
390 fs::remove_file(store.body_path_for_test(&write.metadata.handle)).expect("remove body");
391 let recovery = store.recover(&write.metadata.handle).expect("diagnostic");
392 assert_eq!(recovery.metadata.handle, write.metadata.handle);
393 assert_eq!(
394 recovery.diagnostic.as_ref().map(|d| d.code.as_str()),
395 Some("artifact_missing")
396 );
397 }
398
399 #[test]
400 fn json_secrets_are_redacted_before_the_size_cap() {
401 let root = tempfile::tempdir().expect("temp dir");
402 let store = ArtifactStore::with_limits(root.path(), 256, None);
403 let write = store
404 .create(
405 "identity",
406 ArtifactKind::ToolEvidence,
407 r#"{"token":"do-not-store","message":"ok"}"#,
408 )
409 .expect("artifact");
410 let body = write.bounded_lines.join("\n");
411 assert!(!body.contains("do-not-store"));
412 assert!(body.contains("[REDACTED]"));
413 }
414
415 #[test]
416 fn expiry_is_explicit_and_metadata_survives_recovery_failure() {
417 let root = tempfile::tempdir().expect("temp dir");
418 let store = ArtifactStore::with_limits(root.path(), 128, Some(Duration::ZERO));
419 let write = store
420 .create("identity", ArtifactKind::ToolEvidence, "safe")
421 .expect("artifact");
422 let recovery = store.recover(&write.metadata.handle).expect("expired diagnostic");
423 assert_eq!(recovery.metadata.handle, write.metadata.handle);
424 assert_eq!(recovery.metadata.retention, ArtifactRetentionState::Expired);
425 assert_eq!(
426 recovery.diagnostic.as_ref().map(|d| d.code.as_str()),
427 Some("artifact_expired")
428 );
429 }
430}