1use crate::bundle::types::{
6 as_artifact_ref, DefinitionSnapshot, Manifest, RunState, SessionBinding, SessionCapture,
7 SessionEntryRecord, SessionEventRecord, TraceEvent, RUN_BUNDLE_SCHEMA,
8};
9use anyhow::{Context, Result};
10use serde_json::Value;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone)]
14pub struct BundlePaths {
15 pub dir: PathBuf,
16 pub workflow: PathBuf,
17 pub state: PathBuf,
18 pub trace: PathBuf,
19 pub session: Option<PathBuf>,
20 pub artifacts: Option<PathBuf>,
21}
22
23impl BundlePaths {
24 pub fn from_manifest(dir: &Path, manifest: &Manifest) -> Self {
25 Self {
26 dir: dir.to_path_buf(),
27 workflow: dir.join(&manifest.paths.workflow),
28 state: dir.join(&manifest.paths.state),
29 trace: dir.join(&manifest.paths.trace),
30 session: Some(match manifest.paths.session.as_ref() {
35 Some(p) => dir.join(p),
36 None => dir.join("session"),
37 }),
38 artifacts: manifest.paths.artifacts.as_ref().map(|p| dir.join(p)),
39 }
40 }
41
42 pub fn session_binding(&self) -> Option<PathBuf> {
43 self.session.as_ref().map(|dir| dir.join("binding.json"))
44 }
45
46 pub fn session_entries(&self) -> Option<PathBuf> {
47 self.session.as_ref().map(|dir| dir.join("entries.ndjson"))
48 }
49
50 pub fn session_events(&self) -> Option<PathBuf> {
51 self.session.as_ref().map(|dir| dir.join("events.ndjson"))
52 }
53
54 pub fn session_capture(&self) -> Option<PathBuf> {
55 self.session.as_ref().map(|dir| dir.join("capture.json"))
56 }
57}
58
59#[derive(Debug, Clone)]
60pub struct LoadedBundle {
61 pub manifest: Manifest,
62 pub paths: BundlePaths,
63 pub state: RunState,
64 pub snapshot: Option<DefinitionSnapshot>,
65 pub trace: Vec<TraceEvent>,
66 pub session_binding: Option<SessionBinding>,
67 pub session_entries: Vec<SessionEntryRecord>,
68 pub session_events: Vec<SessionEventRecord>,
69 pub session_capture: Option<SessionCapture>,
70}
71
72fn is_contained(relative: &str) -> bool {
75 let path = Path::new(relative);
76 !relative.is_empty()
77 && path.is_relative()
78 && path
79 .components()
80 .all(|component| matches!(component, std::path::Component::Normal(_)))
81}
82
83pub fn contained_path(bundle_dir: &Path, path: &Path) -> Option<PathBuf> {
88 let canonical = path.canonicalize().ok()?;
89 let base = bundle_dir.canonicalize().ok()?;
90 canonical.starts_with(&base).then_some(canonical)
91}
92
93pub fn read_contained(bundle_dir: &Path, path: &Path) -> Option<String> {
95 std::fs::read_to_string(contained_path(bundle_dir, path)?).ok()
96}
97
98pub fn read_manifest(dir: &Path) -> Result<Manifest> {
99 read_manifest_value(dir).map(|(_, manifest)| manifest)
100}
101
102pub fn read_manifest_value(dir: &Path) -> Result<(Value, Manifest)> {
106 let path = dir.join("manifest.json");
107 let raw = read_contained(dir, &path)
110 .with_context(|| format!("reading {} inside the bundle", path.display()))?;
111 let raw: Value =
112 serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?;
113 let manifest: Manifest = serde_json::from_value(raw.clone())
114 .with_context(|| format!("parsing {}", path.display()))?;
115 anyhow::ensure!(
116 manifest.schema == RUN_BUNDLE_SCHEMA,
117 "unsupported bundle schema {:?} in {}",
118 manifest.schema,
119 path.display()
120 );
121 let entries = [
124 Some(&manifest.paths.workflow),
125 Some(&manifest.paths.state),
126 Some(&manifest.paths.trace),
127 manifest.paths.session.as_ref(),
128 manifest.paths.artifacts.as_ref(),
129 ];
130 for entry in entries.into_iter().flatten() {
131 anyhow::ensure!(
132 is_contained(entry),
133 "manifest path {entry:?} escapes the bundle in {}",
134 path.display()
135 );
136 }
137 Ok((raw, manifest))
138}
139
140fn read_json<T: serde::de::DeserializeOwned>(bundle_dir: &Path, path: &Path) -> Result<T> {
141 let raw = read_contained(bundle_dir, path)
142 .with_context(|| format!("reading {} inside the bundle", path.display()))?;
143 serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))
144}
145
146pub fn parse_ndjson<T: serde::de::DeserializeOwned>(raw: &str) -> Vec<T> {
149 raw.lines()
150 .filter(|line| !line.trim().is_empty())
151 .filter_map(|line| serde_json::from_str(line).ok())
152 .collect()
153}
154
155pub fn read_bundle(dir: &Path) -> Result<LoadedBundle> {
156 let manifest = read_manifest(dir)?;
157 let paths = BundlePaths::from_manifest(dir, &manifest);
158 let state: RunState = read_json(dir, &paths.state)?;
159 let snapshot: Option<DefinitionSnapshot> = read_json(dir, &paths.workflow).ok();
160 let trace: Vec<TraceEvent> = read_contained(dir, &paths.trace)
161 .map(|raw| parse_ndjson(&raw))
162 .unwrap_or_default();
163 let session_binding = paths
164 .session_binding()
165 .and_then(|path| read_json(dir, &path).ok());
166 let session_entries = paths
167 .session_entries()
168 .and_then(|path| read_contained(dir, &path))
169 .map(|raw| parse_ndjson(&raw))
170 .unwrap_or_default();
171 let session_events = paths
172 .session_events()
173 .and_then(|path| read_contained(dir, &path))
174 .map(|raw| parse_ndjson(&raw))
175 .unwrap_or_default();
176 let session_capture = paths
177 .session_capture()
178 .and_then(|path| read_json(dir, &path).ok());
179 Ok(LoadedBundle {
180 manifest,
181 paths,
182 state,
183 snapshot,
184 trace,
185 session_binding,
186 session_entries,
187 session_events,
188 session_capture,
189 })
190}
191
192pub fn list_bundles(runs_dir: &Path) -> Vec<(PathBuf, Manifest)> {
195 let Ok(entries) = std::fs::read_dir(runs_dir) else {
196 return Vec::new();
197 };
198 let mut bundles: Vec<(PathBuf, Manifest)> = entries
199 .filter_map(|entry| entry.ok())
200 .filter(|entry| entry.file_type().map(|t| t.is_dir()).unwrap_or(false))
201 .filter_map(|entry| {
202 let dir = entry.path();
203 read_manifest(&dir).ok().map(|manifest| (dir, manifest))
204 })
205 .collect();
206 bundles.sort_by(|a, b| {
207 b.1.started_at
208 .cmp(&a.1.started_at)
209 .then_with(|| b.1.run_id.cmp(&a.1.run_id))
210 });
211 bundles
212}
213
214pub fn artifact_placeholder(path: &str, bytes: u64) -> String {
217 let size = if bytes < 1024 {
218 format!("{bytes}B")
219 } else {
220 format!("{:.1}KB", bytes as f64 / 1024.0)
221 };
222 format!("«artifact {size} {path}»")
223}
224
225pub fn resolve_artifacts(value: &Value, bundle_dir: &Path, max_bytes: u64) -> Value {
230 match value {
231 Value::Object(_) => {
232 if let Some(reference) = as_artifact_ref(value) {
233 let text = if reference.bytes <= max_bytes {
237 read_artifact_checked(bundle_dir, &reference.path, max_bytes)
238 } else {
239 None
240 };
241 return match text {
242 Some(text) => Value::String(text),
243 None => Value::String(artifact_placeholder(&reference.path, reference.bytes)),
244 };
245 }
246 if let Some(inner) = crate::bundle::types::as_escaped(value) {
247 return match inner.as_object() {
251 Some(object) => Value::Object(
252 object
253 .iter()
254 .map(|(key, value)| {
255 (key.clone(), resolve_artifacts(value, bundle_dir, max_bytes))
256 })
257 .collect(),
258 ),
259 None => inner.clone(),
260 };
261 }
262 let object = value.as_object().unwrap();
263 Value::Object(
264 object
265 .iter()
266 .map(|(key, value)| {
267 (key.clone(), resolve_artifacts(value, bundle_dir, max_bytes))
268 })
269 .collect(),
270 )
271 }
272 Value::Array(items) => Value::Array(
273 items
274 .iter()
275 .map(|item| resolve_artifacts(item, bundle_dir, max_bytes))
276 .collect(),
277 ),
278 other => other.clone(),
279 }
280}
281
282pub fn read_artifact_checked(bundle_dir: &Path, relative: &str, max_bytes: u64) -> Option<String> {
286 let canonical = contained_path(bundle_dir, &bundle_dir.join(relative))?;
287 if std::fs::metadata(&canonical).ok()?.len() > max_bytes {
288 return None;
289 }
290 std::fs::read_to_string(canonical).ok()
291}
292
293pub fn read_declared_artifact_checked(
296 bundle_dir: &Path,
297 artifact_dir: &str,
298 relative: &str,
299 max_bytes: u64,
300) -> Option<String> {
301 let artifact_dir = Path::new(artifact_dir);
302 let relative = Path::new(relative);
303 if artifact_dir.as_os_str().is_empty() || !relative.starts_with(artifact_dir) {
304 return None;
305 }
306 let canonical_root = contained_path(bundle_dir, &bundle_dir.join(artifact_dir))?;
307 let canonical_file = contained_path(bundle_dir, &bundle_dir.join(relative))?;
308 if !canonical_file.starts_with(&canonical_root)
309 || std::fs::metadata(&canonical_file).ok()?.len() > max_bytes
310 {
311 return None;
312 }
313 std::fs::read_to_string(canonical_file).ok()
314}
315
316pub fn with_artifact_placeholders(value: &Value) -> Value {
319 match value {
320 Value::Object(_) => {
321 if let Some(reference) = as_artifact_ref(value) {
322 return Value::String(artifact_placeholder(&reference.path, reference.bytes));
323 }
324 if let Some(inner) = crate::bundle::types::as_escaped(value) {
325 return match inner.as_object() {
327 Some(object) => Value::Object(
328 object
329 .iter()
330 .map(|(key, value)| (key.clone(), with_artifact_placeholders(value)))
331 .collect(),
332 ),
333 None => inner.clone(),
334 };
335 }
336 let object = value.as_object().unwrap();
337 Value::Object(
338 object
339 .iter()
340 .map(|(key, value)| (key.clone(), with_artifact_placeholders(value)))
341 .collect(),
342 )
343 }
344 Value::Array(items) => Value::Array(items.iter().map(with_artifact_placeholders).collect()),
345 other => other.clone(),
346 }
347}