Skip to main content

piw/bundle/
reader.rs

1//! Reading run bundles from disk: manifest discovery, full bundle loads,
2//! and artifact resolution. Malformed bundles are skipped, matching the
3//! TypeScript store's `listRunBundles` behavior.
4
5use 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            // The session directory has a fixed conventional name and the
31            // writer only records it in the manifest at the next state
32            // snapshot; a reader waiting for `paths.session` would miss the
33            // start of a live conversation, so probe the convention too.
34            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
72/// True when a manifest-relative path stays inside the bundle directory:
73/// relative, and made of plain name components only (no `..`, no roots).
74fn 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
83/// Resolve a bundle file to its canonical path, requiring the target to stay
84/// inside the bundle after following symlinks. The lexical check above
85/// rejects `..` and absolute components, but a plain-looking name can still
86/// be a symlink pointing outside the bundle.
87pub 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
93/// Read a bundle document, refusing targets that resolve outside the bundle.
94pub 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
102/// Read and validate the manifest, returning both the raw JSON document
103/// (views must carry the manifest verbatim, including fields this build
104/// does not know) and the typed projection.
105pub fn read_manifest_value(dir: &Path) -> Result<(Value, Manifest)> {
106    let path = dir.join("manifest.json");
107    // The manifest itself gets the same symlink containment as the documents
108    // it names: a manifest.json pointing outside the bundle must not be read.
109    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    // Manifest paths are attacker-adjacent input (bundles can be copied
122    // around); a path escaping the bundle directory must never be read.
123    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
146/// Parse NDJSON, skipping blank lines and a trailing partial line (a writer
147/// may be mid-append when we read).
148pub 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
192/// List all readable bundles in a runs directory, newest first (by
193/// `startedAt`, then run id, matching the TypeScript store).
194pub 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
214/// Compact placeholder for an artifact reference, matching the TypeScript
215/// viewer: `«artifact 12.3KB artifacts/sha256/…»`.
216pub 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
225/// Resolve `$artifact` references in a value by inlining artifact file
226/// contents (up to `max_bytes` per artifact) from the bundle directory.
227/// References that escape the bundle directory or exceed the limit are
228/// replaced by placeholders.
229pub 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                // The declared size is a cheap first filter; the read below
234                // re-checks the actual file size, which a malformed bundle
235                // can understate.
236                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                // The unwrapped object is user data that merely looks like a
248                // sentinel: decode its children, but do not re-test the
249                // object itself (mirrors the TypeScript decodeValueWith).
250                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
282/// Read an artifact file, refusing paths that escape the bundle directory
283/// and files whose actual size exceeds `max_bytes` (the size declared by the
284/// reference is untrusted).
285pub 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
293/// Read a remotely requested artifact only when both its declared path and
294/// canonical target stay under the artifact directory named by the manifest.
295pub 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
316/// Replace `$artifact` references with compact placeholders for previews,
317/// mirroring the TypeScript viewer's `withArtifactPlaceholders`.
318pub 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                // See resolve_artifacts: children only, no sentinel re-test.
326                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}