Skip to main content

ferridriver_script/bindings/
artifacts.rs

1//! `ArtifactsJs`: wrapper that gives scripts a dedicated write-scoped
2//! directory for outputs like screenshots, PDFs, traces, downloaded bodies.
3//!
4//! Separate from `fs` (which is rooted at `script_root` and is primarily
5//! for reading fixtures + importing modules). `artifacts` is rooted at
6//! `artifacts_root` and is primarily for writing — keeps outputs out of
7//! your source tree.
8//!
9//! Same sandbox rules as `fs`: absolute paths, `..`, and symlink escapes
10//! are rejected. Names are resolved relative to the artifacts root.
11
12use std::sync::Arc;
13
14use rquickjs::JsLifetime;
15use rquickjs::class::Trace;
16
17use crate::error::ScriptError;
18use crate::fs::PathSandbox;
19
20#[derive(JsLifetime, Trace)]
21#[rquickjs::class(rename = "Artifacts")]
22pub struct ArtifactsJs {
23  #[qjs(skip_trace)]
24  sandbox: Arc<PathSandbox>,
25}
26
27impl ArtifactsJs {
28  #[must_use]
29  pub fn new(sandbox: Arc<PathSandbox>) -> Self {
30    Self { sandbox }
31  }
32
33  fn io_err(op: &'static str, msg: String) -> rquickjs::Error {
34    rquickjs::Error::new_from_js_message("artifacts", op, msg)
35  }
36
37  fn sandbox_err(err: &ScriptError) -> rquickjs::Error {
38    rquickjs::Error::new_from_js_message("artifacts", "sandbox", err.message.clone())
39  }
40}
41
42#[rquickjs::methods]
43impl ArtifactsJs {
44  /// Absolute path to the artifacts root (read-only).
45  #[qjs(get, rename = "root")]
46  pub fn root(&self) -> String {
47    self.sandbox.root().to_string_lossy().into_owned()
48  }
49
50  /// Write UTF-8 text to `name`. Creates parent directories as needed.
51  #[qjs(rename = "write")]
52  pub async fn write(&self, name: String, contents: String) -> rquickjs::Result<()> {
53    let sb = self.sandbox.clone();
54    let resolved = sb.resolve_write(&name).map_err(|e| Self::sandbox_err(&e))?;
55    tokio::fs::write(&resolved, contents)
56      .await
57      .map_err(|e| Self::io_err("write", e.to_string()))
58  }
59
60  /// Write raw bytes to `name`. Creates parent directories as needed.
61  /// Use this for screenshots, PDFs, downloads, or any binary payload.
62  #[qjs(rename = "writeBytes")]
63  pub async fn write_bytes(&self, name: String, bytes: Vec<u8>) -> rquickjs::Result<()> {
64    let sb = self.sandbox.clone();
65    let resolved = sb.resolve_write(&name).map_err(|e| Self::sandbox_err(&e))?;
66    tokio::fs::write(&resolved, bytes)
67      .await
68      .map_err(|e| Self::io_err("writeBytes", e.to_string()))
69  }
70
71  /// Read `name` as UTF-8 text.
72  #[qjs(rename = "read")]
73  pub async fn read(&self, name: String) -> rquickjs::Result<String> {
74    let sb = self.sandbox.clone();
75    let resolved = sb.resolve_read(&name).map_err(|e| Self::sandbox_err(&e))?;
76    tokio::fs::read_to_string(&resolved)
77      .await
78      .map_err(|e| Self::io_err("read", e.to_string()))
79  }
80
81  /// Read `name` as raw bytes (Uint8Array in JS).
82  #[qjs(rename = "readBytes")]
83  pub async fn read_bytes(&self, name: String) -> rquickjs::Result<Vec<u8>> {
84    let sb = self.sandbox.clone();
85    let resolved = sb.resolve_read(&name).map_err(|e| Self::sandbox_err(&e))?;
86    tokio::fs::read(&resolved)
87      .await
88      .map_err(|e| Self::io_err("readBytes", e.to_string()))
89  }
90
91  /// List entries at the artifacts root (or a subdirectory).
92  #[qjs(rename = "list")]
93  pub async fn list(&self) -> rquickjs::Result<Vec<String>> {
94    let root = self.sandbox.root().to_path_buf();
95    let mut entries = tokio::fs::read_dir(&root)
96      .await
97      .map_err(|e| Self::io_err("list", e.to_string()))?;
98    let mut names = Vec::new();
99    while let Some(entry) = entries
100      .next_entry()
101      .await
102      .map_err(|e| Self::io_err("list", e.to_string()))?
103    {
104      names.push(entry.file_name().to_string_lossy().into_owned());
105    }
106    Ok(names)
107  }
108
109  /// List entries in a subdirectory of the artifacts root.
110  #[qjs(rename = "readdir")]
111  pub async fn readdir(&self, subpath: String) -> rquickjs::Result<Vec<String>> {
112    let sb = self.sandbox.clone();
113    let resolved = sb.resolve_read(&subpath).map_err(|e| Self::sandbox_err(&e))?;
114    let mut entries = tokio::fs::read_dir(&resolved)
115      .await
116      .map_err(|e| Self::io_err("readdir", e.to_string()))?;
117    let mut names = Vec::new();
118    while let Some(entry) = entries
119      .next_entry()
120      .await
121      .map_err(|e| Self::io_err("readdir", e.to_string()))?
122    {
123      names.push(entry.file_name().to_string_lossy().into_owned());
124    }
125    Ok(names)
126  }
127
128  /// True if `name` exists inside the artifacts root.
129  ///
130  /// Paths that would escape the root are treated as non-existent (no
131  /// exception thrown) so probing code can discover presence without having
132  /// to try/catch sandbox errors.
133  #[qjs(rename = "exists")]
134  pub async fn exists(&self, name: String) -> rquickjs::Result<bool> {
135    match self.sandbox.resolve_read(&name) {
136      Ok(resolved) => Ok(tokio::fs::try_exists(&resolved).await.unwrap_or(false)),
137      Err(_) => Ok(false),
138    }
139  }
140
141  /// Remove a file at `name`. Returns `false` if the file did not exist.
142  #[qjs(rename = "remove")]
143  pub async fn remove(&self, name: String) -> rquickjs::Result<bool> {
144    let sb = self.sandbox.clone();
145    let Ok(resolved) = sb.resolve_read(&name) else {
146      return Ok(false);
147    };
148    match tokio::fs::remove_file(&resolved).await {
149      Ok(()) => Ok(true),
150      Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
151      Err(e) => Err(Self::io_err("remove", e.to_string())),
152    }
153  }
154}