返回 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::path::Component;
8 use std::path::Path;
9 use std::path::PathBuf;
10
11 use chrono::{DateTime, Utc};
12 use serde::{Deserialize, Serialize};
13
14 pub const ARTIFACTS_DIR_NAME: &str = "artifacts";
15
16 #[cfg(test)]
17 static TEST_ARTIFACT_SESSIONS_ROOT: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
18
19 #[cfg(test)]
20 pub(crate) static TEST_ARTIFACT_SESSIONS_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
21
22 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23 #[serde(rename_all = "snake_case")]
24 pub enum ArtifactKind {
25 ToolOutput,
26 }
27
28 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29 pub struct ArtifactRecord {
30 pub id: String,
31 pub kind: ArtifactKind,
32 #[serde(default)]
33 pub session_id: String,
34 pub tool_call_id: String,
35 pub tool_name: String,
36 pub created_at: DateTime<Utc>,
37 pub byte_size: u64,
38 pub preview: String,
39 pub storage_path: PathBuf,
40 }
41
42 fn sanitize_id_component(input: &str) -> String {
43 input
44 .chars()
45 .map(|c| {
46 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
47 c
48 } else {
49 '_'
50 }
51 })
52 .collect()
53 }
54
55 pub(crate) fn is_valid_session_id(session_id: &str) -> bool {
56 !session_id.is_empty()
57 && session_id
58 .chars()
59 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
60 }
61
62 #[must_use]
63 pub fn artifact_id_for_tool_call(tool_call_id: &str) -> String {
64 format!("art_{}", sanitize_id_component(tool_call_id))
65 }
66
67 #[must_use]
68 pub fn session_artifact_relative_path(artifact_id: &str) -> PathBuf {
69 PathBuf::from(ARTIFACTS_DIR_NAME).join(format!("{artifact_id}.txt"))
70 }
71
72 fn session_artifact_relative_path_with_extension(
73 artifact_id: &str,
74 extension: &str,
75 ) -> io::Result<PathBuf> {
76 let artifact_id = sanitize_id_component(artifact_id);
77 let extension = extension.trim_start_matches('.').to_ascii_lowercase();
78 if artifact_id.is_empty()
79 || extension.is_empty()
80 || !extension
81 .chars()
82 .all(|character| character.is_ascii_alphanumeric())
83 {
84 return Err(io::Error::new(
85 io::ErrorKind::InvalidInput,
86 "artifact id and extension must contain safe ASCII characters",
87 ));
88 }
89 Ok(PathBuf::from(ARTIFACTS_DIR_NAME).join(format!("{artifact_id}.{extension}")))
90 }
91
92 /// The root every session artifact is written under. Readers that serve
93 /// artifacts by a recorded reference resolve through this same function, so
94 /// a configured Runtime `sessions_dir` can never point a read at a different
95 /// tree than the writer used.
96 pub(crate) fn artifact_sessions_root() -> Option<PathBuf> {
97 #[cfg(test)]
98 if let Some(root) = TEST_ARTIFACT_SESSIONS_ROOT
99 .lock()
100 .unwrap_or_else(|err| err.into_inner())
101 .clone()
102 {
103 return Some(root);
104 }
105 // Artifacts live beside saved sessions, so an unsealed test gets the same
106 // private directory `default_sessions_dir` hands it.
107 #[cfg(test)]
108 if let Some(root) = crate::test_support::unsealed_state_dir("sessions") {
109 return Some(root);
110 }
111
112 // Use the same state-root authority as saved sessions, including an explicit
113 // CODEWHALE_HOME and legacy read fallback.
114 codewhale_config::resolve_state_dir("sessions").ok()
115 }
116
117 #[cfg(test)]
118 pub(crate) fn set_test_artifact_sessions_root(root: Option<PathBuf>) -> Option<PathBuf> {
119 let mut guard = TEST_ARTIFACT_SESSIONS_ROOT
120 .lock()
121 .unwrap_or_else(|err| err.into_inner());
122 std::mem::replace(&mut *guard, root)
123 }
124
125 #[must_use]
126 pub fn session_artifact_absolute_path(session_id: &str, relative_path: &Path) -> Option<PathBuf> {
127 if !is_valid_session_id(session_id) {
128 return None;
129 }
130 if relative_path.is_absolute()
131 || relative_path
132 .components()
133 .any(|component| matches!(component, Component::ParentDir))
134 {
135 return None;
136 }
137 Some(
138 artifact_sessions_root()?
139 .join(session_id)
140 .join(relative_path),
141 )
142 }
143
144 pub fn write_session_artifact(
145 session_id: &str,
146 artifact_id: &str,
147 content: &str,
148 ) -> io::Result<(PathBuf, PathBuf)> {
149 let relative_path = session_artifact_relative_path(artifact_id);
150 let absolute_path =
151 session_artifact_absolute_path(session_id, &relative_path).ok_or_else(|| {
152 io::Error::new(
153 io::ErrorKind::InvalidInput,
154 "could not resolve session artifact path (missing home directory)",
155 )
156 })?;
157 open_session_relative(session_id, &relative_path, true)?.replace(content.as_bytes())?;
158 Ok((absolute_path, relative_path))
159 }
160
161 /// Publish immutable session-owned bytes without replacing an earlier handle.
162 /// A duplicate replay with identical bytes is idempotent; a different payload
163 /// for the same relative path fails closed.
164 pub fn write_session_relative_immutable(
165 session_id: &str,
166 relative_path: &Path,
167 content: &[u8],
168 ) -> io::Result<PathBuf> {
169 let absolute_path =
170 session_artifact_absolute_path(session_id, relative_path).ok_or_else(|| {
171 io::Error::new(io::ErrorKind::InvalidInput, "invalid session artifact path")
172 })?;
173 let destination = open_session_relative(session_id, relative_path, true)?;
174 match destination.publish(content) {
175 Ok(()) => {}
176 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
177 use std::io::Read;
178 let mut existing = Vec::new();
179 destination
180 .open_file()?
181 .take(content.len() as u64 + 1)
182 .read_to_end(&mut existing)?;
183 if existing != content {
184 return Err(io::Error::new(
185 io::ErrorKind::AlreadyExists,
186 "immutable artifact handle already contains different bytes",
187 ));
188 }
189 }
190 Err(err) => return Err(err),
191 }
192 Ok(absolute_path)
193 }
194
195 /// The same confined session directory for mutable sidecars and immutable
196 /// artifacts. The saved-session owner remains responsible for session state.
197 pub(crate) fn open_session_relative(
198 session_id: &str,
199 relative_path: &Path,
200 create: bool,
201 ) -> io::Result<crate::fleet::files::WorkspaceFile> {
202 session_artifact_absolute_path(session_id, relative_path).ok_or_else(|| {
203 io::Error::new(io::ErrorKind::InvalidInput, "invalid session artifact path")
204 })?;
205 let root = artifact_sessions_root()
206 .ok_or_else(|| io::Error::other("session artifact root unavailable"))?;
207 if create {
208 std::fs::create_dir_all(&root)?;
209 }
210 crate::fleet::files::WorkspaceFile::open(
211 &root,
212 &PathBuf::from(session_id).join(relative_path),
213 create,
214 )
215 }
216
217 pub fn write_session_artifact_immutable(
218 session_id: &str,
219 artifact_id: &str,
220 content: &[u8],
221 ) -> io::Result<(PathBuf, PathBuf)> {
222 let relative_path = session_artifact_relative_path(artifact_id);
223 let absolute_path = write_session_relative_immutable(session_id, &relative_path, content)?;
224 Ok((absolute_path, relative_path))
225 }
226
227 /// Write arbitrary fetched bytes into a session artifact with a validated
228 /// extension. Media fetches use this after magic-byte validation.
229 pub fn write_session_artifact_bytes(
230 session_id: &str,
231 artifact_id: &str,
232 extension: &str,
233 content: &[u8],
234 ) -> io::Result<(PathBuf, PathBuf)> {
235 let relative_path = session_artifact_relative_path_with_extension(artifact_id, extension)?;
236 let absolute_path =
237 session_artifact_absolute_path(session_id, &relative_path).ok_or_else(|| {
238 io::Error::new(
239 io::ErrorKind::InvalidInput,
240 "could not resolve session artifact path (missing home directory)",
241 )
242 })?;
243 open_session_relative(session_id, &relative_path, true)?.replace(content)?;
244 Ok((absolute_path, relative_path))
245 }
246
247 fn preview_text(content: &str, max_chars: usize) -> String {
248 let mut preview: String = content.chars().take(max_chars).collect();
249 if content.chars().count() > max_chars {
250 preview.push_str("...");
251 }
252 preview
253 }
254
255 pub fn record_tool_output_artifact(
256 session_id: &str,
257 tool_call_id: &str,
258 tool_name: &str,
259 storage_path: impl Into<PathBuf>,
260 content: &str,
261 ) -> ArtifactRecord {
262 let storage_path = storage_path.into();
263 let byte_size = std::fs::metadata(&storage_path)
264 .map(|metadata| metadata.len())
265 .unwrap_or_else(|_| content.len() as u64);
266 record_tool_output_artifact_with_size(
267 session_id,
268 tool_call_id,
269 tool_name,
270 storage_path,
271 byte_size,
272 &preview_text(content, 200),
273 )
274 }
275
276 pub fn record_tool_output_artifact_with_size(
277 session_id: &str,
278 tool_call_id: &str,
279 tool_name: &str,
280 storage_path: impl Into<PathBuf>,
281 byte_size: u64,
282 preview: &str,
283 ) -> ArtifactRecord {
284 ArtifactRecord {
285 id: artifact_id_for_tool_call(tool_call_id),
286 kind: ArtifactKind::ToolOutput,
287 session_id: session_id.to_string(),
288 tool_call_id: tool_call_id.to_string(),
289 tool_name: tool_name.to_string(),
290 created_at: Utc::now(),
291 byte_size,
292 preview: preview_text(preview, 200),
293 storage_path: storage_path.into(),
294 }
295 }
296
297 #[must_use]
298 pub fn format_artifact_relative_path(path: &Path) -> String {
299 path.display().to_string().replace('\\', "/")
300 }
301
302 #[must_use]
303 pub fn format_byte_size(bytes: u64) -> String {
304 const KIB: u64 = 1024;
305 const MIB: u64 = KIB * 1024;
306 if bytes >= MIB {
307 format!("{} MB", bytes.div_ceil(MIB))
308 } else if bytes >= KIB {
309 format!("{} KB", bytes.div_ceil(KIB))
310 } else {
311 format!("{bytes} B")
312 }
313 }
314
315 #[cfg(test)]
316 mod tests {
317 use super::*;
318
319 struct TestArtifactSessionsRoot {
320 prior: Option<PathBuf>,
321 }
322
323 impl Drop for TestArtifactSessionsRoot {
324 fn drop(&mut self) {
325 set_test_artifact_sessions_root(self.prior.take());
326 }
327 }
328
329 fn set_test_sessions_root(root: PathBuf) -> TestArtifactSessionsRoot {
330 TestArtifactSessionsRoot {
331 prior: set_test_artifact_sessions_root(Some(root)),
332 }
333 }
334
335 #[test]
336 fn session_artifact_absolute_path_uses_test_sessions_root() {
337 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
338 .lock()
339 .unwrap_or_else(|err| err.into_inner());
340 let tmp = tempfile::tempdir().unwrap();
341 let _root = set_test_sessions_root(tmp.path().join("sessions"));
342
343 let path = session_artifact_absolute_path(
344 "session-123",
345 &PathBuf::from("artifacts").join("art_call-big.txt"),
346 )
347 .expect("path");
348
349 assert_eq!(
350 path,
351 tmp.path()
352 .join("sessions")
353 .join("session-123")
354 .join("artifacts")
355 .join("art_call-big.txt")
356 );
357 }
358
359 #[test]
360 fn binary_session_artifact_uses_validated_extension_and_exact_bytes() {
361 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
362 .lock()
363 .unwrap_or_else(|err| err.into_inner());
364 let tmp = tempfile::tempdir().unwrap();
365 let _root = set_test_sessions_root(tmp.path().join("sessions"));
366 let bytes = b"\x89PNG\r\n\x1a\nfixture";
367
368 let (absolute, relative) =
369 write_session_artifact_bytes("session-123", "web/media", ".PNG", bytes)
370 .expect("write binary artifact");
371
372 assert_eq!(relative, PathBuf::from("artifacts/web_media.png"));
373 assert_eq!(std::fs::read(absolute).unwrap(), bytes);
374 assert!(write_session_artifact_bytes("session-123", "bad", "../png", bytes).is_err());
375 }
376
377 #[test]
378 fn adaptive_evidence_publication_is_immutable_and_replay_safe() {
379 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
380 .lock()
381 .unwrap_or_else(|err| err.into_inner());
382 let tmp = tempfile::tempdir().unwrap();
383 let _root = set_test_sessions_root(tmp.path().join("sessions"));
384 let relative = PathBuf::from("artifacts/art_call.txt");
385 let bytes = b"first exact payload";
386
387 let first = write_session_relative_immutable("session-a", &relative, bytes).unwrap();
388 let replay = write_session_relative_immutable("session-a", &relative, bytes).unwrap();
389 assert_eq!(first, replay);
390 let err = write_session_relative_immutable("session-a", &relative, b"different")
391 .expect_err("handle aliasing must fail");
392 assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
393 assert_eq!(std::fs::read(first).unwrap(), bytes);
394 }
395
396 #[cfg(unix)]
397 #[test]
398 fn immutable_session_artifact_rejects_symlinked_parent() {
399 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
400 .lock()
401 .unwrap_or_else(|err| err.into_inner());
402 let tmp = tempfile::tempdir().unwrap();
403 let sessions = tmp.path().join("sessions");
404 let _root = set_test_sessions_root(sessions.clone());
405 std::fs::create_dir_all(&sessions).unwrap();
406 let outside = tempfile::tempdir().unwrap();
407 std::os::unix::fs::symlink(outside.path(), sessions.join("session-a")).unwrap();
408 assert!(
409 write_session_relative_immutable(
410 "session-a",
411 Path::new("artifacts/handoff.json"),
412 b"private"
413 )
414 .is_err()
415 );
416 assert!(!outside.path().join("artifacts").exists());
417 }
418
419 #[test]
420 fn adaptive_evidence_failed_publication_creates_no_handle() {
421 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
422 .lock()
423 .unwrap_or_else(|err| err.into_inner());
424 let tmp = tempfile::tempdir().unwrap();
425 let sessions = tmp.path().join("sessions");
426 let _root = set_test_sessions_root(sessions.clone());
427 std::fs::create_dir_all(sessions.join("session-a")).unwrap();
428 std::fs::write(sessions.join("session-a/artifacts"), b"block directory").unwrap();
429 let relative = PathBuf::from("artifacts/art_failed.txt");
430
431 assert!(write_session_relative_immutable("session-a", &relative, b"payload").is_err());
432 assert!(!sessions.join("session-a/artifacts/art_failed.txt").exists());
433 }
434 #[cfg(unix)]
435 fn check_mutable_parent_links(binary: bool) {
436 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
437 .lock()
438 .unwrap_or_else(|err| err.into_inner());
439 for linked_session in [true, false] {
440 let temp = tempfile::tempdir().unwrap();
441 let sessions = temp.path().join("sessions");
442 let _root = set_test_sessions_root(sessions.clone());
443 std::fs::create_dir_all(&sessions).unwrap();
444 let outside = temp.path().join("outside");
445 let outside_artifacts = if linked_session {
446 outside.join("artifacts")
447 } else {
448 outside.clone()
449 };
450 std::fs::create_dir_all(&outside_artifacts).unwrap();
451 let extension = if binary { "bin" } else { "txt" };
452 let canary = outside_artifacts.join(format!("mutable.{extension}"));
453 std::fs::write(&canary, b"old-exact-artifact-canary").unwrap();
454 if linked_session {
455 std::os::unix::fs::symlink(&outside, sessions.join("session-a")).unwrap();
456 } else {
457 std::fs::create_dir(sessions.join("session-a")).unwrap();
458 std::os::unix::fs::symlink(&outside, sessions.join("session-a/artifacts")).unwrap();
459 }
460 let result = if binary {
461 write_session_artifact_bytes("session-a", "mutable", extension, b"new-byte-payload")
462 } else {
463 write_session_artifact("session-a", "mutable", "new-text-payload")
464 };
465 assert_eq!(
466 std::fs::read(&canary).unwrap(),
467 b"old-exact-artifact-canary",
468 "mutable artifact overwrote a linked outside destination"
469 );
470 assert!(result.is_err(), "linked mutable artifact parent accepted");
471 }
472 }
473
474 #[cfg(unix)]
475 #[test]
476 fn mutable_text_artifact_refuses_linked_session_and_artifacts_parents() {
477 check_mutable_parent_links(false);
478 }
479
480 #[cfg(unix)]
481 #[test]
482 fn mutable_byte_artifact_refuses_linked_session_and_artifacts_parents() {
483 check_mutable_parent_links(true);
484 }
485
486 #[cfg(unix)]
487 #[test]
488 fn mutable_artifacts_replace_exact_bytes_without_following_leaf_links() {
489 use std::os::unix::fs::PermissionsExt;
490 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
491 .lock()
492 .unwrap_or_else(|err| err.into_inner());
493 let temp = tempfile::tempdir().unwrap();
494 let sessions = temp.path().join("sessions");
495 let _root = set_test_sessions_root(sessions.clone());
496 let outside = temp.path().join("outside.txt");
497 std::fs::write(&outside, b"outside-leaf-canary").unwrap();
498 for binary in [false, true] {
499 let extension = if binary { "bin" } else { "txt" };
500 let relative = PathBuf::from(format!("artifacts/mutable.{extension}"));
501 let directory = sessions.join("session-a/artifacts");
502 std::fs::create_dir_all(&directory).unwrap();
503 let path = sessions.join("session-a").join(&relative);
504 std::os::unix::fs::symlink(&outside, &path).unwrap();
505 let (absolute, recorded) = if binary {
506 write_session_artifact_bytes(
507 "session-a",
508 "mutable",
509 extension,
510 b"\0exact-byte-payload",
511 )
512 } else {
513 write_session_artifact("session-a", "mutable", "exact-text-payload")
514 }
515 .unwrap();
516 assert_eq!(absolute, path);
517 assert_eq!(recorded, relative);
518 let expected: &[u8] = if binary {
519 b"\0exact-byte-payload"
520 } else {
521 b"exact-text-payload"
522 };
523 assert_eq!(std::fs::read(&absolute).unwrap(), expected);
524 assert!(
525 !std::fs::symlink_metadata(&absolute)
526 .unwrap()
527 .file_type()
528 .is_symlink()
529 );
530 assert_eq!(
531 std::fs::metadata(&absolute).unwrap().permissions().mode() & 0o777,
532 0o600
533 );
534 assert_eq!(std::fs::read(&outside).unwrap(), b"outside-leaf-canary");
535 }
536 }
537 }
538
538 lines RUST