aion_worker/shell/workspace.rs
1//! Resolving a declared command's working directory against a host's
2//! workspace root.
3//!
4//! A `command` declaration states its `cwd` verbatim, `{workspace_root}`
5//! placeholder and all, because where workspaces live is a property of the box
6//! that runs the command and not of the document that declares it. Two hosts
7//! answer that question — the server, from its aion home, and `aion worker
8//! awl`, from the directory the document was pointed at — and both then have
9//! the SAME job: splice the root in, and refuse a root that cannot be spliced.
10//!
11//! This is that job, once. The two hosts differ in how they find the root and
12//! in nothing else; a second copy of the rules would be two executors
13//! disagreeing about the world a process runs in, which is exactly the defect
14//! the one-render rule exists to prevent.
15
16use std::path::{Path, PathBuf};
17
18use thiserror::Error;
19
20/// The placeholder a declared `cwd` may carry, defined by the LANGUAGE crate.
21///
22/// Restated here as a private constant rather than imported: `aion-worker`
23/// deliberately does not depend on `aion-awl` (the dependency is test-only and
24/// one-directional), and the bytes are a format constant the estate agrees on.
25/// `crates/aion-awl/src/workspace_root.rs` is where it is defined; a
26/// differential test in that crate pins the two spellings equal.
27const WORKSPACE_ROOT_PLACEHOLDER: &str = "{workspace_root}";
28
29/// Why a declared working directory could not be resolved against a root.
30///
31/// Every variant is TERMINAL: the root is a property of this box's
32/// configuration and filesystem, so a second attempt reads the same answer.
33#[derive(Clone, Debug, PartialEq, Eq, Error)]
34pub enum WorkingDirectoryError {
35 /// The root is not an absolute path.
36 ///
37 /// Refused because a relative root names a different place depending on
38 /// where the host process was launched from, which is not something a
39 /// document's author can see or predict.
40 #[error(
41 "the workspace root `{path}` is not absolute, so the directory a declared command \
42 runs in would depend on where this process was started"
43 )]
44 NotAbsolute {
45 /// The offending root, rendered lossily for the refusal.
46 path: String,
47 },
48 /// The root is not valid UTF-8, so it has no faithful spelling to splice
49 /// into the declared path.
50 #[error(
51 "the workspace root `{path}` is not valid UTF-8, so it cannot be spliced into a \
52 declared working directory"
53 )]
54 NotUnicode {
55 /// The offending root, rendered lossily for the refusal.
56 path: String,
57 },
58 /// The root contains a NUL byte, so no process can be launched in it.
59 #[error(
60 "the workspace root `{path}` contains a NUL byte, which cannot cross `execve`, so no \
61 process can be launched in it"
62 )]
63 NotSpawnable {
64 /// The offending root, rendered for the refusal.
65 path: String,
66 },
67 /// The root directory does not exist and could not be created.
68 #[error("the workspace root directory `{path}` could not be created: {error}")]
69 CreationFailed {
70 /// The directory that could not be created.
71 path: String,
72 /// The io error's own diagnosis.
73 error: String,
74 },
75}
76
77/// Whether `declared` asks where the workspace is.
78///
79/// A caller that must FIND its root before it can splice one — the server,
80/// whose root resolution may itself have failed — asks this first, so a
81/// command that never wanted the answer is not refused for it.
82#[must_use]
83pub fn needs_workspace_root(declared: &str) -> bool {
84 declared.contains(WORKSPACE_ROOT_PLACEHOLDER)
85}
86
87/// Resolve `declared` — a command's `cwd`, verbatim — against `root`.
88///
89/// A declared path with no placeholder is returned untouched and `root` is
90/// never consulted, so a command that does not ask where the workspace is
91/// cannot be refused for an answer it never wanted.
92///
93/// A path that DOES carry the placeholder gets the root validated and created
94/// before the splice, because a working directory that does not exist is a
95/// spawn failure on the first dispatch rather than a refusal at the desk.
96///
97/// # Errors
98///
99/// Returns [`WorkingDirectoryError`] when the root is relative, is not valid
100/// UTF-8, carries a NUL byte, or names a directory that cannot be created.
101pub fn resolve_working_directory(
102 declared: &str,
103 root: &Path,
104) -> Result<PathBuf, WorkingDirectoryError> {
105 if !needs_workspace_root(declared) {
106 return Ok(PathBuf::from(declared));
107 }
108 if !root.is_absolute() {
109 return Err(WorkingDirectoryError::NotAbsolute {
110 path: root.to_string_lossy().into_owned(),
111 });
112 }
113 let text = root
114 .to_str()
115 .ok_or_else(|| WorkingDirectoryError::NotUnicode {
116 path: root.to_string_lossy().into_owned(),
117 })?;
118 if text.contains('\0') {
119 return Err(WorkingDirectoryError::NotSpawnable {
120 path: text.to_owned(),
121 });
122 }
123 create_root_directory(root).map_err(|error| WorkingDirectoryError::CreationFailed {
124 path: text.to_owned(),
125 error: error.to_string(),
126 })?;
127 Ok(PathBuf::from(
128 declared.replace(WORKSPACE_ROOT_PLACEHOLDER, text),
129 ))
130}
131
132/// Create the root directory if it is missing, owner-only.
133///
134/// Idempotent: an existing directory is not an error. The mode matters because
135/// a workspace holds whatever a declared command puts there, and this crate's
136/// portability gate is target-cfg, so the unix concept is gated the same way.
137fn create_root_directory(root: &Path) -> std::io::Result<()> {
138 let mut builder = std::fs::DirBuilder::new();
139 builder.recursive(true);
140 #[cfg(unix)]
141 {
142 use std::os::unix::fs::DirBuilderExt as _;
143 builder.mode(0o700);
144 }
145 builder.create(root)
146}
147
148#[cfg(test)]
149mod tests {
150 use super::{WorkingDirectoryError, resolve_working_directory};
151 use std::path::{Path, PathBuf};
152
153 /// What a test returns. Every fallible step is carried rather than
154 /// unwrapped, because the workspace denies panicking accessors in test
155 /// code as firmly as in library code.
156 type TestResult = Result<(), Box<dyn std::error::Error>>;
157
158 #[test]
159 fn a_path_without_the_placeholder_never_consults_the_root() -> TestResult {
160 // A relative root would be refused if it were read at all, so this
161 // passing proves the root was not read.
162 assert_eq!(
163 resolve_working_directory("/srv/app", Path::new("relative"))?,
164 PathBuf::from("/srv/app")
165 );
166 Ok(())
167 }
168
169 #[test]
170 fn the_placeholder_splices_the_root_and_creates_it() -> TestResult {
171 let scratch = tempfile::tempdir()?;
172 let root = scratch.path().join("clones");
173 let resolved = resolve_working_directory("{workspace_root}/repo", &root)?;
174 assert_eq!(resolved, root.join("repo"));
175 assert!(root.is_dir(), "the root must be created before the splice");
176 Ok(())
177 }
178
179 #[test]
180 fn a_relative_root_refuses_rather_than_naming_a_place_nobody_chose() {
181 assert_eq!(
182 resolve_working_directory("{workspace_root}", Path::new("clones")),
183 Err(WorkingDirectoryError::NotAbsolute {
184 path: "clones".to_owned(),
185 })
186 );
187 }
188
189 #[test]
190 fn an_uncreatable_root_refuses_by_name() -> TestResult {
191 let scratch = tempfile::tempdir()?;
192 let file = scratch.path().join("occupied");
193 std::fs::write(&file, b"not a directory")?;
194 let Err(error) = resolve_working_directory("{workspace_root}", &file.join("clones")) else {
195 return Err("a root beneath a regular file cannot be created".into());
196 };
197 assert!(
198 matches!(error, WorkingDirectoryError::CreationFailed { .. }),
199 "{error}"
200 );
201 Ok(())
202 }
203}