Skip to main content

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}