返回 CodeWhale
artifacts.rs
根目录 / crates / tui / src / artifacts.rs
1 //! Session-scoped artifact metadata.
2 //!
3 //! Large tool outputs are written under the owning session directory and saved
4 //! sessions keep a durable metadata index for resume/listing flows.
5
6 use std::io;
7 use std::io::Write;
8 use std::path::Component;
9 use std::path::Path;
10 use std::path::PathBuf;
11
12 use chrono::{DateTime, Utc};
13 use serde::{Deserialize, Serialize};
14
15 pub const ARTIFACTS_DIR_NAME: &str = "artifacts";
16
17 #[cfg(test)]
18 static TEST_ARTIFACT_SESSIONS_ROOT: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
19
20 #[cfg(test)]
21 pub(crate) static TEST_ARTIFACT_SESSIONS_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
22
23 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24 #[serde(rename_all = "snake_case")]
25 pub enum ArtifactKind {
26 ToolOutput,
27 }
28
29 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30 pub struct ArtifactRecord {
31 pub id: String,
32 pub kind: ArtifactKind,
33 #[serde(default)]
34 pub session_id: String,
35 pub tool_call_id: String,
36 pub tool_name: String,
37 pub created_at: DateTime<Utc>,
38 pub byte_size: u64,
39 pub preview: String,
40 pub storage_path: PathBuf,
41 }
42
43 fn sanitize_id_component(input: &str) -> String {
44 input
45 .chars()
46 .map(|c| {
47 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
48 c
49 } else {
50 '_'
51 }
52 })
53 .collect()
54 }
55
56 fn is_valid_session_id(session_id: &str) -> bool {
57 !session_id.is_empty()
58 && session_id
59 .chars()
60 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
61 }
62
63 #[must_use]
64 pub fn artifact_id_for_tool_call(tool_call_id: &str) -> String {
65 format!("art_{}", sanitize_id_component(tool_call_id))
66 }
67
68 #[must_use]
69 pub fn session_artifact_relative_path(artifact_id: &str) -> PathBuf {
70 PathBuf::from(ARTIFACTS_DIR_NAME).join(format!("{artifact_id}.txt"))
71 }
72
73 fn session_artifact_relative_path_with_extension(
74 artifact_id: &str,
75 extension: &str,
76 ) -> io::Result<PathBuf> {
77 let artifact_id = sanitize_id_component(artifact_id);
78 let extension = extension.trim_start_matches('.').to_ascii_lowercase();
79 if artifact_id.is_empty()
80 || extension.is_empty()
81 || !extension
82 .chars()
83 .all(|character| character.is_ascii_alphanumeric())
84 {
85 return Err(io::Error::new(
86 io::ErrorKind::InvalidInput,
87 "artifact id and extension must contain safe ASCII characters",
88 ));
89 }
90 Ok(PathBuf::from(ARTIFACTS_DIR_NAME).join(format!("{artifact_id}.{extension}")))
91 }
92
93 fn artifact_sessions_root() -> Option<PathBuf> {
94 #[cfg(test)]
95 if let Some(root) = TEST_ARTIFACT_SESSIONS_ROOT
96 .lock()
97 .unwrap_or_else(|err| err.into_inner())
98 .clone()
99 {
100 return Some(root);
101 }
102
103 // Honor explicit HOME/USERPROFILE isolation before consulting the host
104 // known-folder API. On Windows, `crate::config::effective_home_dir()` can ignore subprocess
105 // environment redirection and leak artifacts into the runner profile.
106 let home = crate::config::effective_home_dir()?;
107 let primary = home.join(".codewhale").join("sessions");
108 let legacy = home.join(".deepseek").join("sessions");
109 if primary.exists() || !legacy.exists() {
110 return Some(primary);
111 }
112 Some(legacy)
113 }
114
115 #[cfg(test)]
116 pub(crate) fn set_test_artifact_sessions_root(root: Option<PathBuf>) -> Option<PathBuf> {
117 let mut guard = TEST_ARTIFACT_SESSIONS_ROOT
118 .lock()
119 .unwrap_or_else(|err| err.into_inner());
120 std::mem::replace(&mut *guard, root)
121 }
122
123 #[must_use]
124 pub fn session_artifact_absolute_path(session_id: &str, relative_path: &Path) -> Option<PathBuf> {
125 if !is_valid_session_id(session_id) {
126 return None;
127 }
128 if relative_path.is_absolute()
129 || relative_path
130 .components()
131 .any(|component| matches!(component, Component::ParentDir))
132 {
133 return None;
134 }
135 Some(
136 artifact_sessions_root()?
137 .join(session_id)
138 .join(relative_path),
139 )
140 }
141
142 pub fn write_session_artifact(
143 session_id: &str,
144 artifact_id: &str,
145 content: &str,
146 ) -> io::Result<(PathBuf, PathBuf)> {
147 let relative_path = session_artifact_relative_path(artifact_id);
148 let absolute_path =
149 session_artifact_absolute_path(session_id, &relative_path).ok_or_else(|| {
150 io::Error::new(
151 io::ErrorKind::InvalidInput,
152 "could not resolve session artifact path (missing home directory)",
153 )
154 })?;
155 if let Some(parent) = absolute_path.parent() {
156 std::fs::create_dir_all(parent)?;
157 }
158 crate::utils::write_atomic(&absolute_path, content.as_bytes())?;
159 Ok((absolute_path, relative_path))
160 }
161
162 /// Publish immutable session-owned bytes without replacing an earlier handle.
163 /// A duplicate replay with identical bytes is idempotent; a different payload
164 /// for the same relative path fails closed.
165 pub fn write_session_relative_immutable(
166 session_id: &str,
167 relative_path: &Path,
168 content: &[u8],
169 ) -> io::Result<PathBuf> {
170 let absolute_path =
171 session_artifact_absolute_path(session_id, relative_path).ok_or_else(|| {
172 io::Error::new(io::ErrorKind::InvalidInput, "invalid session artifact path")
173 })?;
174 if let Some(parent) = absolute_path.parent() {
175 std::fs::create_dir_all(parent)?;
176 }
177 if absolute_path.exists() {
178 return if std::fs::read(&absolute_path)? == content {
179 Ok(absolute_path)
180 } else {
181 Err(io::Error::new(
182 io::ErrorKind::AlreadyExists,
183 "immutable artifact handle already contains different bytes",
184 ))
185 };
186 }
187 let file_name = absolute_path
188 .file_name()
189 .and_then(|name| name.to_str())
190 .unwrap_or("artifact");
191 let temp_path = absolute_path.with_file_name(format!(
192 ".{file_name}.{}.{}.tmp",
193 std::process::id(),
194 uuid::Uuid::new_v4()
195 ));
196 let publish = (|| -> io::Result<()> {
197 let mut file = std::fs::OpenOptions::new()
198 .write(true)
199 .create_new(true)
200 .open(&temp_path)?;
201 file.write_all(content)?;
202 file.sync_all()?;
203 match std::fs::hard_link(&temp_path, &absolute_path) {
204 Ok(()) => Ok(()),
205 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
206 if std::fs::read(&absolute_path)? == content {
207 Ok(())
208 } else {
209 Err(io::Error::new(
210 io::ErrorKind::AlreadyExists,
211 "immutable artifact handle raced with different bytes",
212 ))
213 }
214 }
215 Err(err) => Err(err),
216 }
217 })();
218 let _ = std::fs::remove_file(&temp_path);
219 publish?;
220 Ok(absolute_path)
221 }
222
223 pub fn write_session_artifact_immutable(
224 session_id: &str,
225 artifact_id: &str,
226 content: &[u8],
227 ) -> io::Result<(PathBuf, PathBuf)> {
228 let relative_path = session_artifact_relative_path(artifact_id);
229 let absolute_path = write_session_relative_immutable(session_id, &relative_path, content)?;
230 Ok((absolute_path, relative_path))
231 }
232
233 /// Write arbitrary fetched bytes into a session artifact with a validated
234 /// extension. Media fetches use this after magic-byte validation.
235 pub fn write_session_artifact_bytes(
236 session_id: &str,
237 artifact_id: &str,
238 extension: &str,
239 content: &[u8],
240 ) -> io::Result<(PathBuf, PathBuf)> {
241 let relative_path = session_artifact_relative_path_with_extension(artifact_id, extension)?;
242 let absolute_path =
243 session_artifact_absolute_path(session_id, &relative_path).ok_or_else(|| {
244 io::Error::new(
245 io::ErrorKind::InvalidInput,
246 "could not resolve session artifact path (missing home directory)",
247 )
248 })?;
249 if let Some(parent) = absolute_path.parent() {
250 std::fs::create_dir_all(parent)?;
251 }
252 crate::utils::write_atomic(&absolute_path, content)?;
253 Ok((absolute_path, relative_path))
254 }
255
256 fn preview_text(content: &str, max_chars: usize) -> String {
257 let mut preview: String = content.chars().take(max_chars).collect();
258 if content.chars().count() > max_chars {
259 preview.push_str("...");
260 }
261 preview
262 }
263
264 pub fn record_tool_output_artifact(
265 session_id: &str,
266 tool_call_id: &str,
267 tool_name: &str,
268 storage_path: impl Into<PathBuf>,
269 content: &str,
270 ) -> ArtifactRecord {
271 let storage_path = storage_path.into();
272 let byte_size = std::fs::metadata(&storage_path)
273 .map(|metadata| metadata.len())
274 .unwrap_or_else(|_| content.len() as u64);
275 record_tool_output_artifact_with_size(
276 session_id,
277 tool_call_id,
278 tool_name,
279 storage_path,
280 byte_size,
281 &preview_text(content, 200),
282 )
283 }
284
285 pub fn record_tool_output_artifact_with_size(
286 session_id: &str,
287 tool_call_id: &str,
288 tool_name: &str,
289 storage_path: impl Into<PathBuf>,
290 byte_size: u64,
291 preview: &str,
292 ) -> ArtifactRecord {
293 ArtifactRecord {
294 id: artifact_id_for_tool_call(tool_call_id),
295 kind: ArtifactKind::ToolOutput,
296 session_id: session_id.to_string(),
297 tool_call_id: tool_call_id.to_string(),
298 tool_name: tool_name.to_string(),
299 created_at: Utc::now(),
300 byte_size,
301 preview: preview_text(preview, 200),
302 storage_path: storage_path.into(),
303 }
304 }
305
306 #[must_use]
307 pub fn format_artifact_relative_path(path: &Path) -> String {
308 path.display().to_string().replace('\\', "/")
309 }
310
311 #[must_use]
312 pub fn format_byte_size(bytes: u64) -> String {
313 const KIB: u64 = 1024;
314 const MIB: u64 = KIB * 1024;
315 if bytes >= MIB {
316 format!("{} MB", bytes.div_ceil(MIB))
317 } else if bytes >= KIB {
318 format!("{} KB", bytes.div_ceil(KIB))
319 } else {
320 format!("{bytes} B")
321 }
322 }
323
324 #[cfg(test)]
325 mod tests {
326 use super::*;
327
328 struct TestArtifactSessionsRoot {
329 prior: Option<PathBuf>,
330 }
331
332 impl Drop for TestArtifactSessionsRoot {
333 fn drop(&mut self) {
334 set_test_artifact_sessions_root(self.prior.take());
335 }
336 }
337
338 fn set_test_sessions_root(root: PathBuf) -> TestArtifactSessionsRoot {
339 TestArtifactSessionsRoot {
340 prior: set_test_artifact_sessions_root(Some(root)),
341 }
342 }
343
344 #[test]
345 fn session_artifact_absolute_path_uses_test_sessions_root() {
346 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
347 .lock()
348 .unwrap_or_else(|err| err.into_inner());
349 let tmp = tempfile::tempdir().unwrap();
350 let _root = set_test_sessions_root(tmp.path().join("sessions"));
351
352 let path = session_artifact_absolute_path(
353 "session-123",
354 &PathBuf::from("artifacts").join("art_call-big.txt"),
355 )
356 .expect("path");
357
358 assert_eq!(
359 path,
360 tmp.path()
361 .join("sessions")
362 .join("session-123")
363 .join("artifacts")
364 .join("art_call-big.txt")
365 );
366 }
367
368 #[test]
369 fn binary_session_artifact_uses_validated_extension_and_exact_bytes() {
370 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
371 .lock()
372 .unwrap_or_else(|err| err.into_inner());
373 let tmp = tempfile::tempdir().unwrap();
374 let _root = set_test_sessions_root(tmp.path().join("sessions"));
375 let bytes = b"\x89PNG\r\n\x1a\nfixture";
376
377 let (absolute, relative) =
378 write_session_artifact_bytes("session-123", "web/media", ".PNG", bytes)
379 .expect("write binary artifact");
380
381 assert_eq!(relative, PathBuf::from("artifacts/web_media.png"));
382 assert_eq!(std::fs::read(absolute).unwrap(), bytes);
383 assert!(write_session_artifact_bytes("session-123", "bad", "../png", bytes).is_err());
384 }
385
386 #[test]
387 fn adaptive_evidence_publication_is_immutable_and_replay_safe() {
388 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
389 .lock()
390 .unwrap_or_else(|err| err.into_inner());
391 let tmp = tempfile::tempdir().unwrap();
392 let _root = set_test_sessions_root(tmp.path().join("sessions"));
393 let relative = PathBuf::from("artifacts/art_call.txt");
394 let bytes = b"first exact payload";
395
396 let first = write_session_relative_immutable("session-a", &relative, bytes).unwrap();
397 let replay = write_session_relative_immutable("session-a", &relative, bytes).unwrap();
398 assert_eq!(first, replay);
399 let err = write_session_relative_immutable("session-a", &relative, b"different")
400 .expect_err("handle aliasing must fail");
401 assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
402 assert_eq!(std::fs::read(first).unwrap(), bytes);
403 }
404
405 #[test]
406 fn adaptive_evidence_failed_publication_creates_no_handle() {
407 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
408 .lock()
409 .unwrap_or_else(|err| err.into_inner());
410 let tmp = tempfile::tempdir().unwrap();
411 let sessions = tmp.path().join("sessions");
412 let _root = set_test_sessions_root(sessions.clone());
413 std::fs::create_dir_all(sessions.join("session-a")).unwrap();
414 std::fs::write(sessions.join("session-a/artifacts"), b"block directory").unwrap();
415 let relative = PathBuf::from("artifacts/art_failed.txt");
416
417 assert!(write_session_relative_immutable("session-a", &relative, b"payload").is_err());
418 assert!(!sessions.join("session-a/artifacts/art_failed.txt").exists());
419 }
420 }
421
421 lines RUST