aion_server/worker/workspace_root.rs
1//! Server-side `{workspace_root}` expansion for declared action bodies.
2//!
3//! A declared body runs with its environment cleared to `PATH` only, so it
4//! cannot expand `~` or read a variable to learn where session workspaces
5//! live. The workflow's start inputs cannot carry the location either — the
6//! operator's start contract is deliberately minimal, and the workspace root
7//! is a property of the SERVER, not of any one run. The server therefore
8//! states it: a declared command may carry the literal placeholder
9//! [`WORKSPACE_ROOT_PLACEHOLDER`], and the dispatch path replaces every
10//! occurrence with the server's own workspace root before the command is
11//! parsed.
12//!
13//! The root is the aion home's `clones/` directory
14//! ([`crate::config::aion_home`] → `<home>/clones`) — the same location the
15//! crate worker's provision handlers established (#175): durable history
16//! records workspace paths, so they must live somewhere that survives a host
17//! reboot, never the OS temp dir. There is no separate configurable value;
18//! the home is already the operator's one answer to "where does this
19//! server's state live" (#113), and resolving a second answer here is how
20//! two components come to disagree about one path.
21//!
22//! Resolution happens ONCE, at server-state construction, and every consumer
23//! — the declared-body dispatcher, the startup banner — reads that one
24//! value. A resolution failure is carried, not raised: boot proceeds, and
25//! the failure surfaces as a terminal dispatch refusal when (and only when)
26//! a placeholder-bearing body is dispatched. Bodies that do not use the
27//! placeholder are untouched by resolution failure.
28//!
29//! Expansion happens on the raw command STRING, before
30//! [`aion_worker::shell::ShellAction`] parses it into an argv. A root whose
31//! text would change that parse — whitespace splits a word, `$` opens a
32//! parameter reference, a quote opens a quoted region, NUL cannot cross
33//! `execve` — is refused rather than spliced, because a silent reshape of
34//! the declared command is exactly what the template layer exists to
35//! prevent. The root is server-controlled, so the refusal is theoretical;
36//! it is checked because "theoretical" is not "impossible".
37//!
38//! The COMMAND is held to the same standard: every placeholder occurrence
39//! must stand alone as one whole, unquoted argv word of the command as the
40//! template parses it. An occurrence inside a quoted region or glued to
41//! adjacent text would make the spliced root a FRAGMENT of some larger
42//! word, so what executes would not be the path the server resolved — the
43//! dispatch is refused by name instead ([`WorkspaceRootError::PlaceholderMisplaced`]).
44
45use std::path::{Path, PathBuf};
46
47use thiserror::Error;
48
49/// The literal placeholder a declared command carries where the server's
50/// workspace root belongs.
51///
52/// Braces are literal text to both the AWL checker and the worker SDK's
53/// command template (`$name`/`${name}` are the only parameter forms), so a
54/// command carrying this placeholder checks clean and — if it ever reached
55/// the executor unexpanded — would fail loudly on a nonexistent
56/// `{workspace_root}` path rather than silently running somewhere else.
57pub const WORKSPACE_ROOT_PLACEHOLDER: &str = "{workspace_root}";
58
59/// The directory under the aion home where session workspaces live.
60const CLONES_DIRECTORY: &str = "clones";
61
62/// Why the workspace root could not be resolved or spliced into a declared
63/// command.
64///
65/// Every variant is terminal at dispatch time: the root is a property of the
66/// server's configuration and filesystem, so retrying the dispatch cannot
67/// change it.
68#[derive(Debug, Clone, Error, PartialEq, Eq)]
69pub enum WorkspaceRootError {
70 /// The aion home itself could not be resolved, so there is no root to
71 /// derive.
72 #[error("the aion home cannot be resolved, so there is no workspace root: {reason}")]
73 Unresolvable {
74 /// The home resolution's own diagnosis.
75 reason: String,
76 },
77 /// The resolved root is not an absolute path.
78 ///
79 /// A relative root resolves against the server's current directory, so
80 /// the same recorded history would name a different location after a
81 /// restart from elsewhere.
82 #[error(
83 "the workspace root `{path}` is not an absolute path; a relative root names a \
84 different location after a restart from a different directory"
85 )]
86 NotAbsolute {
87 /// The offending path, rendered for the refusal.
88 path: String,
89 },
90 /// The resolved root is not valid UTF-8, so it has no faithful spelling
91 /// inside a declared command string.
92 #[error(
93 "the workspace root `{path}` is not valid UTF-8, so it cannot be spliced into a \
94 declared command"
95 )]
96 NotUnicode {
97 /// The offending path, rendered lossily for the refusal.
98 path: String,
99 },
100 /// The resolved root contains a character that would change how the
101 /// declared command parses after splicing.
102 #[error(
103 "the workspace root `{path}` contains {character}, which would change the parsed \
104 shape of the declared command it is spliced into"
105 )]
106 ShapeChanging {
107 /// The offending path, rendered for the refusal.
108 path: String,
109 /// Which shape-changing character was found, named for the refusal.
110 character: &'static str,
111 },
112 /// A placeholder occurrence in the declared command is not a whole,
113 /// unquoted argv word, so the spliced root would become a fragment of
114 /// some larger word instead of the path the server resolved.
115 #[error(
116 "the declared command carries {{workspace_root}} {placement}; every occurrence \
117 must stand alone as one whole, unquoted argv word, because the root is spliced \
118 into the command string before it is parsed"
119 )]
120 PlaceholderMisplaced {
121 /// Where the offending occurrence sits, named for the refusal.
122 placement: &'static str,
123 },
124 /// The root directory does not exist and could not be created.
125 #[error("the workspace root directory `{path}` could not be created: {error}")]
126 CreationFailed {
127 /// The directory that could not be created.
128 path: String,
129 /// The io error's own diagnosis.
130 error: String,
131 },
132}
133
134/// A declared command with its workspace-root placeholder expanded.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ExpandedCommand {
137 /// The command with every placeholder occurrence replaced by the root.
138 pub command: String,
139 /// The resolved root that was spliced in, for the dispatch log.
140 pub workspace_root: String,
141}
142
143/// The server's one workspace root, resolved once and read everywhere.
144///
145/// Carries the RESULT of resolution rather than requiring it: a server whose
146/// home cannot resolve must still boot (nothing else needs the root), so the
147/// failure is held here and surfaces as a terminal refusal at the first
148/// dispatch of a placeholder-bearing body.
149#[derive(Debug, Clone)]
150pub struct WorkspaceRoot {
151 resolution: Result<PathBuf, WorkspaceRootError>,
152}
153
154impl WorkspaceRoot {
155 /// Resolve the workspace root from the server's aion home:
156 /// [`crate::config::aion_home`] → `<home>/clones`.
157 ///
158 /// This is the ONE derivation of the value. Everything downstream — the
159 /// declared-body dispatcher, the startup banner, any composition point
160 /// reading the banner — consumes the resolved value rather than
161 /// re-deriving it, so two components can never disagree about where
162 /// workspaces live.
163 #[must_use]
164 pub fn resolve() -> Self {
165 let resolution = crate::config::aion_home()
166 .map(|home| root_under(&home.path))
167 .map_err(|error| WorkspaceRootError::Unresolvable {
168 reason: error.to_string(),
169 });
170 Self { resolution }
171 }
172
173 /// Build a root from an already-decided resolution.
174 ///
175 /// For embedders and tests that need a known root (or a known failure)
176 /// without touching the process environment the production
177 /// [`WorkspaceRoot::resolve`] reads. Single derivation is a convention
178 /// the production path upholds, not an enforced invariant: nothing stops
179 /// a caller constructing a second, disagreeing root here.
180 #[must_use]
181 pub const fn from_resolution(resolution: Result<PathBuf, WorkspaceRootError>) -> Self {
182 Self { resolution }
183 }
184
185 /// The one-line rendering of this root for the startup banner: the
186 /// resolved path's display form, or `unresolvable: <reason>` when
187 /// resolution failed — never a fabricated value.
188 #[must_use]
189 pub fn banner_value(&self) -> String {
190 match self.resolved() {
191 Ok(path) => path.display().to_string(),
192 Err(error) => format!("unresolvable: {error}"),
193 }
194 }
195
196 /// The resolved root, or why there is none.
197 ///
198 /// Read-only: reporting the value (the startup banner) must not create
199 /// the directory. Creation happens at expansion time, where a failure
200 /// has a dispatch to refuse.
201 ///
202 /// # Errors
203 ///
204 /// Returns the held [`WorkspaceRootError`] when resolution failed.
205 pub fn resolved(&self) -> Result<&Path, &WorkspaceRootError> {
206 match &self.resolution {
207 Ok(path) => Ok(path.as_path()),
208 Err(error) => Err(error),
209 }
210 }
211
212 /// Expand every [`WORKSPACE_ROOT_PLACEHOLDER`] occurrence in `command`
213 /// with the resolved root.
214 ///
215 /// A command without the placeholder is untouched — `Ok(None)`, no
216 /// resolution requirement, no filesystem side effect — so an unresolved
217 /// root never affects a body that does not use it. A command WITH the
218 /// placeholder requires the full chain: every occurrence standing alone
219 /// as one whole, unquoted argv word, then a resolved, absolute, UTF-8
220 /// root with no shape-changing characters, and an existing directory
221 /// (created here, idempotently and owner-only `0700`, if missing — an
222 /// already-existing root keeps whatever permissions the operator gave
223 /// it; they are the operator's own).
224 ///
225 /// # Errors
226 ///
227 /// Returns [`WorkspaceRootError`] when a placeholder occurrence is not a
228 /// whole unquoted argv word, when the root is unresolved, not absolute,
229 /// not valid UTF-8, contains a character that would change the command's
230 /// parsed shape, or does not exist and cannot be created.
231 pub fn expand(&self, command: &str) -> Result<Option<ExpandedCommand>, WorkspaceRootError> {
232 if !command.contains(WORKSPACE_ROOT_PLACEHOLDER) {
233 return Ok(None);
234 }
235 if let Some(placement) = misplaced_placeholder(command) {
236 return Err(WorkspaceRootError::PlaceholderMisplaced { placement });
237 }
238 let root = self.resolution.as_ref().map_err(Clone::clone)?;
239 if !root.is_absolute() {
240 return Err(WorkspaceRootError::NotAbsolute {
241 path: root.to_string_lossy().into_owned(),
242 });
243 }
244 let root_text = root
245 .to_str()
246 .ok_or_else(|| WorkspaceRootError::NotUnicode {
247 path: root.to_string_lossy().into_owned(),
248 })?;
249 if let Some(character) = shape_changing_character(root_text) {
250 return Err(WorkspaceRootError::ShapeChanging {
251 path: root_text.to_owned(),
252 character,
253 });
254 }
255 create_root_directory(root).map_err(|error| WorkspaceRootError::CreationFailed {
256 path: root_text.to_owned(),
257 error: error.to_string(),
258 })?;
259 Ok(Some(ExpandedCommand {
260 command: command.replace(WORKSPACE_ROOT_PLACEHOLDER, root_text),
261 workspace_root: root_text.to_owned(),
262 }))
263 }
264}
265
266/// Derive the workspace root from the aion home: `<home>/clones`.
267///
268/// The ONE derivation rule, factored pure so a test can pin it without
269/// resolving a real home. The location is the crate worker's established one
270/// (#175): durable history records workspace paths, so they live under the
271/// home, never the OS temp dir.
272fn root_under(home: &Path) -> PathBuf {
273 home.join(CLONES_DIRECTORY)
274}
275
276/// Create the root directory (and any missing ancestors), owner-only.
277///
278/// New directories are minted `0700`: the root holds session workspaces —
279/// operator repositories and agent working trees — so nothing else on the
280/// host gets a default read into them. An ALREADY-existing root is left
281/// exactly as found; its permissions are the operator's own configuration,
282/// not this function's to correct.
283fn create_root_directory(root: &Path) -> std::io::Result<()> {
284 let mut builder = std::fs::DirBuilder::new();
285 builder.recursive(true);
286 // The crate's portability gate is target-cfg (see `[target.'cfg(unix)']`
287 // in Cargo.toml); the mode is a unix concept, so it is gated the same way.
288 #[cfg(unix)]
289 {
290 use std::os::unix::fs::DirBuilderExt as _;
291 builder.mode(0o700);
292 }
293 builder.create(root)
294}
295
296/// How a stretch of the raw command string is quoted, by the SAME rules the
297/// worker SDK's command template parses with
298/// (`aion_worker::shell::template::CommandTemplate::parse`): single quotes
299/// are literal until the closing single quote, double quotes likewise, and
300/// each region is entered only from unquoted text.
301#[derive(Clone, Copy, PartialEq, Eq)]
302enum QuoteContext {
303 /// Plain command text: whitespace here splits words.
304 Unquoted,
305 /// Inside a `'…'` region.
306 Single,
307 /// Inside a `"…"` region.
308 Double,
309}
310
311/// The template-rule quoting context at byte `position` of `command`.
312///
313/// An unterminated quote leaves the tail of the command inside the region,
314/// which is also how the template treats it (it refuses the parse) — so a
315/// placeholder after an unclosed quote reads as quoted here and is refused.
316fn quote_context_at(command: &str, position: usize) -> QuoteContext {
317 let mut context = QuoteContext::Unquoted;
318 for (index, character) in command.char_indices() {
319 if index >= position {
320 break;
321 }
322 context = match (context, character) {
323 (QuoteContext::Unquoted, '\'') => QuoteContext::Single,
324 (QuoteContext::Unquoted, '"') => QuoteContext::Double,
325 (QuoteContext::Single, '\'') | (QuoteContext::Double, '"') => QuoteContext::Unquoted,
326 (current, _) => current,
327 };
328 }
329 context
330}
331
332/// Where the first misplaced placeholder occurrence sits, named for the
333/// refusal — or `None` when EVERY occurrence stands alone as one whole,
334/// unquoted argv word of the command as the template parses it.
335///
336/// Walked with the template's own quoting rules rather than a regex guess:
337/// a word boundary is unquoted whitespace (or an end of the command), and a
338/// neighbouring quote character glues the occurrence into a larger word just
339/// as any other adjacent text does.
340fn misplaced_placeholder(command: &str) -> Option<&'static str> {
341 for (start, _) in command.match_indices(WORKSPACE_ROOT_PLACEHOLDER) {
342 let end = start + WORKSPACE_ROOT_PLACEHOLDER.len();
343 // The placeholder contains no quote characters, so the context of its
344 // first byte is the context of the whole occurrence.
345 match quote_context_at(command, start) {
346 QuoteContext::Single => return Some("inside single quotes"),
347 QuoteContext::Double => return Some("inside double quotes"),
348 QuoteContext::Unquoted => {}
349 }
350 // An unquoted position's immediate neighbours are unquoted too (a
351 // quoted region can only end at its own closing quote, which is never
352 // whitespace), so plain whitespace checks are the template's word
353 // boundaries here.
354 let starts_a_word = command[..start]
355 .chars()
356 .next_back()
357 .is_none_or(char::is_whitespace);
358 let ends_a_word = command[end..]
359 .chars()
360 .next()
361 .is_none_or(char::is_whitespace);
362 if !starts_a_word || !ends_a_word {
363 return Some("glued to adjacent text");
364 }
365 }
366 None
367}
368
369/// The first character in `root` that would change a declared command's
370/// parsed shape, named for the refusal — or `None` when the root splices
371/// cleanly.
372///
373/// The set mirrors what the worker SDK's command template gives meaning to:
374/// whitespace splits words, `$` opens a parameter reference, `'` and `"`
375/// open quoted regions, and NUL cannot cross `execve` at all.
376fn shape_changing_character(root: &str) -> Option<&'static str> {
377 for character in root.chars() {
378 if character.is_whitespace() {
379 return Some("whitespace");
380 }
381 match character {
382 '$' => return Some("`$`"),
383 '\'' => return Some("a single quote"),
384 '"' => return Some("a double quote"),
385 '\0' => return Some("a NUL byte"),
386 _ => {}
387 }
388 }
389 None
390}
391
392#[cfg(test)]
393mod tests {
394 use std::path::PathBuf;
395
396 use std::path::Path;
397
398 use super::{
399 ExpandedCommand, WORKSPACE_ROOT_PLACEHOLDER, WorkspaceRoot, WorkspaceRootError, root_under,
400 shape_changing_character,
401 };
402
403 /// What a test returns. Every fallible step is carried rather than
404 /// unwrapped, because the workspace denies panicking accessors in test
405 /// code as firmly as in library code.
406 type TestResult = Result<(), Box<dyn std::error::Error>>;
407
408 fn unresolved() -> WorkspaceRoot {
409 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
410 reason: "AION_HOME must not be empty".to_owned(),
411 }))
412 }
413
414 #[test]
415 fn expansion_replaces_every_occurrence() -> TestResult {
416 let scratch = tempfile::tempdir()?;
417 let root = scratch.path().join("clones");
418 let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
419 let root_text = root.to_string_lossy().into_owned();
420 let expanded = workspace
421 .expand("sh -c 'x' -- {workspace_root} $run_id {workspace_root}")?
422 .ok_or("a placeholder-bearing command must expand")?;
423 assert_eq!(
424 expanded,
425 ExpandedCommand {
426 command: format!("sh -c 'x' -- {root_text} $run_id {root_text}"),
427 workspace_root: root_text,
428 }
429 );
430 Ok(())
431 }
432
433 #[test]
434 fn a_command_without_the_placeholder_is_not_expanded() -> TestResult {
435 let scratch = tempfile::tempdir()?;
436 let resolved = WorkspaceRoot::from_resolution(Ok(scratch.path().join("clones")));
437 assert_eq!(resolved.expand("echo $greeting")?, None);
438 // Resolution failure must not touch a body that does not use the
439 // placeholder — the refusal is scoped to bodies that need the root.
440 assert_eq!(unresolved().expand("echo $greeting")?, None);
441 Ok(())
442 }
443
444 #[test]
445 fn the_root_is_derived_as_the_homes_clones_directory() {
446 // Pins the derivation rule AND the directory name: renaming
447 // `CLONES_DIRECTORY` (or changing the rule) must break here, because
448 // durable history already records paths under this exact location.
449 assert_eq!(
450 root_under(Path::new("/x")),
451 std::path::PathBuf::from("/x/clones")
452 );
453 assert_eq!(
454 root_under(Path::new("/Users/operator/.aion")),
455 std::path::PathBuf::from("/Users/operator/.aion/clones")
456 );
457 }
458
459 #[test]
460 fn the_banner_value_reports_the_path_or_the_failure() -> TestResult {
461 let scratch = tempfile::tempdir()?;
462 let root = scratch.path().join("clones");
463 let resolved = WorkspaceRoot::from_resolution(Ok(root.clone()));
464 assert_eq!(resolved.banner_value(), root.display().to_string());
465 let failed = unresolved().banner_value();
466 assert!(
467 failed.starts_with("unresolvable: "),
468 "an unresolved root must be reported as exactly that: {failed}"
469 );
470 assert!(
471 failed.contains("AION_HOME must not be empty"),
472 "the banner must carry the resolution failure's own reason: {failed}"
473 );
474 Ok(())
475 }
476
477 #[test]
478 fn a_placeholder_that_is_a_whole_bare_word_is_accepted() -> TestResult {
479 let scratch = tempfile::tempdir()?;
480 let workspace = WorkspaceRoot::from_resolution(Ok(scratch.path().join("clones")));
481 assert!(
482 workspace
483 .expand("sh -c 'x' -- {workspace_root} $run_id")?
484 .is_some(),
485 "a bare-word placeholder must expand"
486 );
487 Ok(())
488 }
489
490 #[test]
491 fn a_misplaced_placeholder_is_refused_naming_the_placement() -> TestResult {
492 let scratch = tempfile::tempdir()?;
493 let root = scratch.path().join("clones");
494 for (command, placement) in [
495 // Inside a single-quoted region the template takes it literally,
496 // so the spliced root would hide inside one quoted word.
497 ("sh -c '{workspace_root}'", "inside single quotes"),
498 // Likewise inside double quotes.
499 ("echo \"{workspace_root}\"", "inside double quotes"),
500 // Glued to preceding text.
501 ("echo x{workspace_root}", "glued to adjacent text"),
502 // Glued to following text.
503 ("echo {workspace_root}/sub", "glued to adjacent text"),
504 // Glued on both sides at once.
505 ("echo x{workspace_root}/sub", "glued to adjacent text"),
506 // Glued to a quoted region: adjacency, not quoting, is the defect.
507 ("echo ''{workspace_root}", "glued to adjacent text"),
508 // A `$` prefix would make the template read `${workspace_root}` as
509 // a parameter reference, not the placeholder at all.
510 ("echo ${workspace_root}", "glued to adjacent text"),
511 // ONE misplaced occurrence refuses even when another is bare.
512 (
513 "echo {workspace_root} x{workspace_root}",
514 "glued to adjacent text",
515 ),
516 ] {
517 let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
518 let Err(error) = workspace.expand(command) else {
519 return Err(format!("command {command:?} must be refused").into());
520 };
521 assert_eq!(
522 error,
523 WorkspaceRootError::PlaceholderMisplaced { placement },
524 "command {command:?} must be refused as {placement}"
525 );
526 }
527 assert!(
528 !root.exists(),
529 "a refused command must not create the root directory"
530 );
531 Ok(())
532 }
533
534 #[test]
535 fn the_shipped_provision_commands_pass_the_placement_guard() -> TestResult {
536 // The standing guard for the two live documents: their `run` bodies
537 // carry the placeholder as `-- {workspace_root}`, a whole bare word,
538 // and must keep expanding. Derived from the documents themselves so
539 // an edit to either body is held here, not at the first dispatch.
540 let scratch = tempfile::tempdir()?;
541 let workspace = WorkspaceRoot::from_resolution(Ok(scratch.path().join("clones")));
542 for (name, source) in [
543 (
544 "assistant.awl",
545 include_str!("../../../../examples/assistant/awl/assistant.awl"),
546 ),
547 (
548 "assistant_spike.awl",
549 include_str!("../../../../examples/assistant/awl/assistant_spike.awl"),
550 ),
551 ] {
552 let compiled = aion_awl::compile(source, Path::new("."))
553 .map_err(|error| format!("{name} must compile: {error}"))?;
554 let command = compiled
555 .contract
556 .workers
557 .iter()
558 .flat_map(|worker| &worker.actions)
559 .find_map(|action| match &action.body {
560 Some(aion_package::ActionBodyContract::Run { command })
561 if action.name == "assistant_provision" =>
562 {
563 Some(command.clone())
564 }
565 _ => None,
566 })
567 .ok_or_else(|| format!("{name} must declare a bodied assistant_provision"))?;
568 assert!(
569 workspace.expand(&command)?.is_some(),
570 "{name}'s provision body must pass the placement guard and expand"
571 );
572 }
573 Ok(())
574 }
575
576 #[tokio::test]
577 async fn every_accepted_printable_ascii_root_survives_the_real_parser() -> TestResult {
578 // The refusal set is tied to the REAL parser, not to an enumeration:
579 // for every printable ASCII character, either `expand` refuses the
580 // root outright, or the expanded probe command — the placeholder as a
581 // bare argv word — executes through the worker SDK's own
582 // `ShellAction` and observes EXACTLY the root as its argument.
583 let scratch = tempfile::tempdir()?;
584 let mut executed = 0usize;
585 for code in 0x20u8..=0x7Eu8 {
586 let character = char::from(code);
587 let root = scratch.path().join(format!("with{character}char"));
588 let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
589 let probe = format!("printf %s {WORKSPACE_ROOT_PLACEHOLDER}");
590 match workspace.expand(&probe) {
591 Err(_) => {}
592 Ok(expanded) => {
593 let expanded =
594 expanded.ok_or("a placeholder-bearing probe must expand or refuse")?;
595 let action = aion_worker::shell::ShellAction::new(&expanded.command).map_err(
596 |error| format!("accepted root {root:?} failed to parse: {error}"),
597 )?;
598 let (context, _cancellation) = aion_worker::ActivityContext::new(
599 aion_core::ActivityId::from_sequence_position(1),
600 1,
601 );
602 let outcome = action
603 .run(&std::collections::BTreeMap::new(), &context)
604 .await
605 .map_err(|error| {
606 format!("accepted root {root:?} failed to execute: {error}")
607 })?;
608 assert_eq!(
609 outcome.stdout, expanded.workspace_root,
610 "the command must observe exactly the accepted root {root:?}"
611 );
612 executed += 1;
613 }
614 }
615 }
616 assert!(
617 executed > 0,
618 "at least one printable root must be accepted, or this test refused everything \
619 and proved nothing"
620 );
621 Ok(())
622 }
623
624 #[test]
625 fn an_unresolved_root_refuses_a_placeholder_bearing_command_by_name() -> TestResult {
626 let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
627 let Err(error) = unresolved().expand(&command) else {
628 return Err("an unresolved root must refuse expansion".into());
629 };
630 assert_eq!(
631 error,
632 WorkspaceRootError::Unresolvable {
633 reason: "AION_HOME must not be empty".to_owned(),
634 }
635 );
636 assert!(
637 error.to_string().contains("AION_HOME must not be empty"),
638 "the refusal must carry the resolution failure's own reason: {error}"
639 );
640 Ok(())
641 }
642
643 #[test]
644 fn a_relative_root_is_refused() -> TestResult {
645 let workspace = WorkspaceRoot::from_resolution(Ok(PathBuf::from("relative/clones")));
646 let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
647 let Err(error) = workspace.expand(&command) else {
648 return Err("a relative root must be refused".into());
649 };
650 assert_eq!(
651 error,
652 WorkspaceRootError::NotAbsolute {
653 path: "relative/clones".to_owned(),
654 }
655 );
656 Ok(())
657 }
658
659 #[test]
660 fn a_shape_changing_root_is_refused_naming_the_character() -> TestResult {
661 for (fragment, character) in [
662 ("with space", "whitespace"),
663 ("with\ttab", "whitespace"),
664 ("with\nnewline", "whitespace"),
665 ("with$dollar", "`$`"),
666 ("with'single", "a single quote"),
667 ("with\"double", "a double quote"),
668 ("with\0nul", "a NUL byte"),
669 ] {
670 let root = PathBuf::from(format!("/absolute/{fragment}"));
671 let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
672 let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
673 let Err(error) = workspace.expand(&command) else {
674 return Err(format!("root {root:?} must be refused as shape-changing").into());
675 };
676 assert_eq!(
677 error,
678 WorkspaceRootError::ShapeChanging {
679 path: root.to_string_lossy().into_owned(),
680 character,
681 },
682 "root {root:?} must be refused naming {character}"
683 );
684 }
685 Ok(())
686 }
687
688 #[test]
689 fn a_clean_root_has_no_shape_changing_character() {
690 assert_eq!(
691 shape_changing_character("/Users/operator/.aion/clones"),
692 None
693 );
694 }
695
696 #[test]
697 fn the_directory_is_created_when_missing() -> TestResult {
698 let scratch = tempfile::tempdir()?;
699 let root = scratch.path().join("nested").join("clones");
700 assert!(!root.exists(), "the root must start absent");
701 let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
702 let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
703 let first = workspace.expand(&command)?;
704 assert!(first.is_some(), "expansion must succeed");
705 assert!(root.is_dir(), "expansion must create the missing root");
706 // A created root is owner-only: it holds session workspaces, so
707 // nothing else on the host gets a default read into it.
708 #[cfg(unix)]
709 {
710 use std::os::unix::fs::PermissionsExt as _;
711 let mode = std::fs::metadata(&root)?.permissions().mode() & 0o777;
712 assert_eq!(
713 mode, 0o700,
714 "a created root must be mode 0700, got {mode:o}"
715 );
716 }
717 // Idempotent: a second expansion over the now-existing directory
718 // succeeds identically.
719 assert_eq!(workspace.expand(&command)?, first);
720 Ok(())
721 }
722
723 #[test]
724 fn a_root_that_cannot_be_created_is_refused_naming_the_io_error() -> TestResult {
725 let scratch = tempfile::tempdir()?;
726 // A ROOT beneath a regular file cannot be created by any retry.
727 let file = scratch.path().join("occupied");
728 std::fs::write(&file, b"not a directory")?;
729 let root = file.join("clones");
730 let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
731 let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
732 let Err(error) = workspace.expand(&command) else {
733 return Err("creation beneath a regular file must fail".into());
734 };
735 let WorkspaceRootError::CreationFailed { path, error: io } = &error else {
736 return Err(format!("expected CreationFailed, got: {error}").into());
737 };
738 assert_eq!(path, &root.to_string_lossy().into_owned());
739 assert!(!io.is_empty(), "the io error's own words must be carried");
740 Ok(())
741 }
742
743 #[test]
744 fn resolved_reports_without_creating() -> TestResult {
745 let scratch = tempfile::tempdir()?;
746 let root = scratch.path().join("clones");
747 let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
748 assert_eq!(workspace.resolved(), Ok(root.as_path()));
749 assert!(
750 !root.exists(),
751 "reporting the root must not create the directory"
752 );
753 let failed = unresolved();
754 let Err(error) = failed.resolved() else {
755 return Err("an unresolved root must report its failure".into());
756 };
757 assert!(matches!(error, WorkspaceRootError::Unresolvable { .. }));
758 Ok(())
759 }
760}