aion_toolchain/workspace.rs
1//! Per-submission isolated build workspace.
2//!
3//! A network-facing authoring submission must never write to, or build inside,
4//! the operator-provisioned project template: two concurrent submissions would
5//! race on the same entry-module source file, the same `build/` directory, and
6//! the same `.aion` output, so an author could receive another author's
7//! artifact (or a half-overwritten one). The configured
8//! `[authoring].project_root` is therefore a **read-only template** at request
9//! time; every submission gets its own throwaway working copy.
10//!
11//! [`Workspace::stage`] creates a fresh temporary directory as a **sibling** of
12//! the template — under the template's own parent directory — and recursively
13//! copies the template's *contents* directly into it, so the working-copy root
14//! sits at exactly the **same directory depth** as the template. Same-depth
15//! sibling placement is load-bearing: a Gleam project's `aion_flow` path
16//! dependency (and every `source = "local"` entry in `manifest.toml`) is
17//! recorded **relative** to the project root, so a relative path such as
18//! `../../gleam/aion_flow` resolves identically from a same-depth sibling as
19//! from the template itself. Copying into an arbitrary location (the system
20//! temp dir, say) — or nesting the working copy one directory deeper than the
21//! template — would break those relative path dependencies; rewriting them
22//! would mean parsing and re-emitting two TOML formats faithfully on every
23//! request. Same-depth sibling placement preserves them untouched.
24//!
25//! The temporary directory is owned by the [`Workspace`] and removed when it is
26//! dropped — on the success path and on every error path alike (the build
27//! artifacts, including the captured submission source, never outlive the
28//! request). The template is never mutated.
29
30use std::path::Path;
31
32use crate::error::ToolchainError;
33
34/// An isolated, throwaway working copy of an authoring project template.
35///
36/// Created by [`Workspace::stage`]; the working copy lives under a temporary
37/// directory that is removed when the `Workspace` is dropped. The submitted
38/// source is written into, and the build runs entirely within,
39/// [`Workspace::root`] — never the template.
40pub struct Workspace {
41 /// The owned temporary directory; its removal on drop is the cleanup. The
42 /// temp directory **is** the working-copy project root — the template's
43 /// contents are copied directly into it so the root sits at the same
44 /// directory depth as the template (a same-depth sibling), and relative
45 /// path dependencies resolve identically.
46 temp: tempfile::TempDir,
47}
48
49impl Workspace {
50 /// Stages a fresh, isolated working copy of `template_root`.
51 ///
52 /// Creates a temporary directory as a sibling of `template_root` (under its
53 /// parent) and recursively copies the template's contents directly into it,
54 /// so the working-copy root sits at the same directory depth as the
55 /// template. Same-depth sibling placement preserves the template's relative
56 /// path dependencies (`aion_flow` and any other `source = "local"` Gleam
57 /// dependency): a path such as `../../gleam/aion_flow` resolves identically
58 /// from the working copy as from the template root.
59 ///
60 /// The template is read only — it is never written to or built in. The
61 /// returned [`Workspace`] owns the temporary directory and removes it on
62 /// drop.
63 ///
64 /// # Errors
65 ///
66 /// Returns [`ToolchainError::InvalidProject`] when `template_root` has no
67 /// parent directory (a filesystem root cannot host a sibling), and
68 /// [`ToolchainError::Io`] when the sibling temporary directory cannot be
69 /// created (for example the template's parent directory is not writable) or
70 /// the recursive copy fails.
71 pub fn stage(template_root: &Path) -> Result<Self, ToolchainError> {
72 let parent = template_root
73 .parent()
74 .filter(|parent| !parent.as_os_str().is_empty())
75 .ok_or_else(|| ToolchainError::InvalidProject {
76 message: format!(
77 "authoring project root `{}` has no parent directory to host an isolated build workspace; the template must be provisioned inside a writable parent directory",
78 template_root.display()
79 ),
80 })?;
81
82 let temp = tempfile::Builder::new()
83 .prefix("aion-authoring-submission-")
84 .tempdir_in(parent)
85 .map_err(|source| ToolchainError::Io {
86 path: parent.to_path_buf(),
87 source,
88 })?;
89
90 // Copy the template's contents directly into the temp directory so the
91 // working-copy root IS the temp directory — a same-depth sibling of the
92 // template, never a directory deeper. This keeps relative path
93 // dependencies (`../../gleam/aion_flow`) resolving identically.
94 copy_tree(template_root, temp.path())?;
95
96 Ok(Self { temp })
97 }
98
99 /// The isolated working-copy project root: where the submitted source is
100 /// written and the build runs. It is the temporary directory itself — a
101 /// same-depth sibling of the template.
102 #[must_use]
103 pub fn root(&self) -> &Path {
104 self.temp.path()
105 }
106}
107
108impl std::fmt::Debug for Workspace {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 f.debug_struct("Workspace")
111 .field("root", &self.temp.path())
112 .finish()
113 }
114}
115
116/// Recursively copies the directory tree at `from` into `to`, creating `to` and
117/// every intermediate directory.
118///
119/// The template is operator-provisioned local content, not untrusted input, so
120/// the copy mirrors files and directories faithfully. Every failure is a
121/// path-carrying [`ToolchainError::Io`] — nothing is skipped silently.
122fn copy_tree(from: &Path, to: &Path) -> Result<(), ToolchainError> {
123 std::fs::create_dir_all(to).map_err(|source| ToolchainError::Io {
124 path: to.to_path_buf(),
125 source,
126 })?;
127
128 let entries = std::fs::read_dir(from).map_err(|source| ToolchainError::Io {
129 path: from.to_path_buf(),
130 source,
131 })?;
132
133 for entry in entries {
134 let entry = entry.map_err(|source| ToolchainError::Io {
135 path: from.to_path_buf(),
136 source,
137 })?;
138 let file_type = entry.file_type().map_err(|source| ToolchainError::Io {
139 path: entry.path(),
140 source,
141 })?;
142 let source_path = entry.path();
143 let target_path = to.join(entry.file_name());
144
145 if file_type.is_dir() {
146 copy_tree(&source_path, &target_path)?;
147 } else {
148 std::fs::copy(&source_path, &target_path).map_err(|source| ToolchainError::Io {
149 path: source_path.clone(),
150 source,
151 })?;
152 }
153 }
154
155 Ok(())
156}
157
158#[cfg(test)]
159mod tests {
160 use std::path::Path;
161
162 use super::Workspace;
163 use crate::error::ToolchainError;
164
165 /// Builds a minimal template tree (gleam.toml, workflow.toml, nested src/,
166 /// schemas/) under a fresh temp dir and returns the temp dir plus the
167 /// template root inside it. The template root has a real parent so a
168 /// sibling can be staged.
169 fn template() -> Result<(tempfile::TempDir, std::path::PathBuf), Box<dyn std::error::Error>> {
170 let parent = tempfile::Builder::new()
171 .prefix("aion-toolchain-workspace-template-")
172 .tempdir()?;
173 let root = parent.path().join("project");
174 std::fs::create_dir_all(root.join("src/nested"))?;
175 std::fs::create_dir_all(root.join("schemas"))?;
176 std::fs::write(root.join("gleam.toml"), b"name = \"demo\"\n")?;
177 std::fs::write(root.join("workflow.toml"), b"[[workflow]]\n")?;
178 std::fs::write(root.join("src/demo.gleam"), b"pub fn run() { Nil }\n")?;
179 std::fs::write(root.join("src/nested/helper.gleam"), b"pub const x = 1\n")?;
180 std::fs::write(root.join("schemas/input.json"), b"{}\n")?;
181 Ok((parent, root))
182 }
183
184 #[test]
185 fn stage_copies_the_full_tree_into_an_isolated_root() -> Result<(), Box<dyn std::error::Error>>
186 {
187 let (_parent, template_root) = template()?;
188 let workspace = Workspace::stage(&template_root)?;
189
190 let root = workspace.root();
191 assert_ne!(
192 root, template_root,
193 "the workspace root is not the template"
194 );
195 assert!(root.join("gleam.toml").is_file());
196 assert!(root.join("workflow.toml").is_file());
197 assert!(root.join("src/demo.gleam").is_file());
198 assert!(
199 root.join("src/nested/helper.gleam").is_file(),
200 "nested src modules are copied"
201 );
202 assert!(root.join("schemas/input.json").is_file());
203 assert_eq!(
204 std::fs::read(root.join("src/demo.gleam"))?,
205 std::fs::read(template_root.join("src/demo.gleam"))?,
206 "copied bytes match the template"
207 );
208 Ok(())
209 }
210
211 #[test]
212 fn stage_places_the_workspace_as_a_same_depth_sibling_of_the_template()
213 -> Result<(), Box<dyn std::error::Error>> {
214 let (_parent, template_root) = template()?;
215 let template_parent = template_root.parent().ok_or("template has a parent")?;
216 let workspace = Workspace::stage(&template_root)?;
217
218 // The working-copy root IS the temp dir — a direct child of the
219 // template's parent, at the SAME directory depth as the template. A
220 // relative path dependency such as `../../gleam/aion_flow` therefore
221 // resolves identically from the working copy as from the template.
222 assert_eq!(
223 workspace.root().parent(),
224 Some(template_parent),
225 "the workspace root is a same-depth sibling of the template under the same parent"
226 );
227 assert_eq!(
228 workspace.root().components().count(),
229 template_root.components().count(),
230 "the workspace root sits at the same directory depth as the template"
231 );
232 Ok(())
233 }
234
235 #[test]
236 fn dropping_the_workspace_removes_the_temp_dir_and_leaves_the_template()
237 -> Result<(), Box<dyn std::error::Error>> {
238 let (_parent, template_root) = template()?;
239 let workspace = Workspace::stage(&template_root)?;
240 // The working-copy root IS the temp dir, so removing it on drop removes
241 // the root itself.
242 let staged_root = workspace.root().to_path_buf();
243 assert!(staged_root.join("gleam.toml").is_file());
244
245 // Mutate the working copy to prove the template is untouched.
246 std::fs::write(staged_root.join("src/demo.gleam"), b"// overwritten\n")?;
247
248 drop(workspace);
249
250 assert!(
251 !staged_root.exists(),
252 "the workspace temp dir (the working-copy root) is removed on drop"
253 );
254 assert!(
255 template_root.join("gleam.toml").is_file(),
256 "the template is left intact"
257 );
258 assert_eq!(
259 std::fs::read(template_root.join("src/demo.gleam"))?,
260 b"pub fn run() { Nil }\n",
261 "the template source is never mutated by a submission"
262 );
263 Ok(())
264 }
265
266 #[test]
267 fn two_submissions_stage_into_distinct_isolated_roots() -> Result<(), Box<dyn std::error::Error>>
268 {
269 let (_parent, template_root) = template()?;
270 let first = Workspace::stage(&template_root)?;
271 let second = Workspace::stage(&template_root)?;
272
273 assert_ne!(
274 first.root(),
275 second.root(),
276 "concurrent submissions never share a working-copy root"
277 );
278
279 std::fs::write(first.root().join("src/demo.gleam"), b"// first\n")?;
280 std::fs::write(second.root().join("src/demo.gleam"), b"// second\n")?;
281 assert_eq!(
282 std::fs::read(first.root().join("src/demo.gleam"))?,
283 b"// first\n",
284 "the first workspace is unaffected by writes to the second"
285 );
286 Ok(())
287 }
288
289 #[test]
290 fn stage_rejects_a_template_without_a_parent_directory() {
291 // The filesystem root has no parent to host a sibling.
292 let result = Workspace::stage(Path::new("/"));
293 assert!(
294 matches!(result, Err(ToolchainError::InvalidProject { .. })),
295 "a parentless template root is a typed InvalidProject, never a panic"
296 );
297 }
298}