Skip to main content

thndrs_lib/core/
artifacts.rs

1//! Bounded, redacted local artifacts for recoverable tool evidence.
2//!
3//! The artifact store is an application boundary: it owns filesystem effects,
4//! redaction, retention, and recovery diagnostics. Handles and metadata are
5//! safe to place in provider-neutral tool and context contracts; artifact
6//! bodies are never stored in those contracts.
7
8use 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
18/// Default maximum number of bytes retained for one artifact body.
19pub const DEFAULT_MAX_ARTIFACT_BYTES: usize = 64 * 1024;
20
21/// Default artifact retention period.
22pub 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/// Coarse classification of an artifact body.
28#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ArtifactKind {
31    /// Bounded output from an application-owned tool execution.
32    ToolEvidence,
33}
34
35/// Retention state recorded with artifact metadata.
36#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ArtifactRetentionState {
39    /// Body is present and within its retention period.
40    Active,
41    /// Body is no longer eligible for recovery.
42    Expired,
43    /// Metadata exists but the body is missing or failed integrity checks.
44    Corrupt,
45}
46
47/// Durable metadata for one bounded redacted artifact.
48#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
49pub struct ArtifactMetadata {
50    /// Schema version for this metadata file.
51    pub schema_version: u32,
52    /// Application-defined evidence identity.
53    pub identity: String,
54    /// Artifact classification.
55    pub kind: ArtifactKind,
56    /// Stable opaque handle used by tool results and context items.
57    pub handle: String,
58    /// SHA-256 hash of the bounded redacted body.
59    pub content_hash: String,
60    /// Bytes supplied before redaction and bounding.
61    pub original_byte_count: usize,
62    /// Bytes persisted after redaction and bounding.
63    pub bounded_byte_count: usize,
64    /// Whether the byte cap changed the body.
65    pub truncated: bool,
66    /// Whether redaction changed one or more source bytes.
67    pub redacted: bool,
68    /// ISO-8601 UTC creation time.
69    pub created_at: String,
70    /// Unix creation time used for deterministic expiry checks.
71    pub created_at_unix: u64,
72    /// ISO-8601 UTC expiry time, when retention is enabled.
73    pub expires_at: Option<String>,
74    /// Unix expiry time used for recovery checks.
75    pub expires_at_unix: Option<u64>,
76    /// Current retention state as last evaluated by the store.
77    pub retention: ArtifactRetentionState,
78}
79
80/// A successful artifact write, including the safe projection used by the UI.
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct ArtifactWrite {
83    /// Persisted metadata.
84    pub metadata: ArtifactMetadata,
85    /// Bounded redacted lines suitable for display or compatibility records.
86    pub bounded_lines: Vec<String>,
87}
88
89/// A recovery result that keeps metadata usable when the body is unavailable.
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct ArtifactRecovery {
92    /// Metadata recovered from the artifact sidecar.
93    pub metadata: ArtifactMetadata,
94    /// Bounded redacted body, when it is present and valid.
95    pub content: Option<String>,
96    /// Explicit diagnostic for missing, expired, or corrupt evidence.
97    pub diagnostic: Option<ArtifactDiagnostic>,
98}
99
100/// Safe diagnostic returned when an artifact cannot be recovered.
101#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
102pub struct ArtifactDiagnostic {
103    /// Stable machine-readable diagnostic code.
104    pub code: String,
105    /// Secret-free human-readable explanation.
106    pub message: String,
107}
108
109/// Application-owned bounded artifact store.
110#[derive(Clone, Debug)]
111pub struct ArtifactStore {
112    root: PathBuf,
113    max_bytes: usize,
114    retention: Option<Duration>,
115}
116
117impl ArtifactStore {
118    /// Create a store using the default body cap and retention period.
119    pub fn new(root: impl Into<PathBuf>) -> Self {
120        Self::with_limits(root, DEFAULT_MAX_ARTIFACT_BYTES, Some(DEFAULT_RETENTION))
121    }
122
123    /// Create a store with explicit body and retention limits.
124    ///
125    /// `retention = None` is an explicit application choice for no expiry; it
126    /// is not used by thndrs' default construction.
127    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    /// Persist one tool result after redaction and byte bounding.
132    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    /// Persist one body after redaction and byte bounding.
137    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    /// Read artifact metadata without attempting body recovery.
177    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    /// Recover a bounded redacted body and preserve metadata on failure.
184    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    /// Return the artifact body path for controlled corruption/retention tests.
232    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
251/// Redact and cap tool lines before placing them in a compatibility/session
252/// projection. This function never returns the original body by default.
253pub 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}