Skip to main content

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                crate::assistant::EMBEDDED_ASSISTANT_DOCUMENT,
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::WorkflowId::new_v4(),
600                        aion_core::RunId::new_v4(),
601                        aion_core::ActivityId::from_sequence_position(1),
602                        1,
603                    );
604                    let outcome = action
605                        .run(&std::collections::BTreeMap::new(), &context)
606                        .await
607                        .map_err(|error| {
608                            format!("accepted root {root:?} failed to execute: {error}")
609                        })?;
610                    assert_eq!(
611                        outcome.stdout, expanded.workspace_root,
612                        "the command must observe exactly the accepted root {root:?}"
613                    );
614                    executed += 1;
615                }
616            }
617        }
618        assert!(
619            executed > 0,
620            "at least one printable root must be accepted, or this test refused everything \
621             and proved nothing"
622        );
623        Ok(())
624    }
625
626    #[test]
627    fn an_unresolved_root_refuses_a_placeholder_bearing_command_by_name() -> TestResult {
628        let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
629        let Err(error) = unresolved().expand(&command) else {
630            return Err("an unresolved root must refuse expansion".into());
631        };
632        assert_eq!(
633            error,
634            WorkspaceRootError::Unresolvable {
635                reason: "AION_HOME must not be empty".to_owned(),
636            }
637        );
638        assert!(
639            error.to_string().contains("AION_HOME must not be empty"),
640            "the refusal must carry the resolution failure's own reason: {error}"
641        );
642        Ok(())
643    }
644
645    #[test]
646    fn a_relative_root_is_refused() -> TestResult {
647        let workspace = WorkspaceRoot::from_resolution(Ok(PathBuf::from("relative/clones")));
648        let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
649        let Err(error) = workspace.expand(&command) else {
650            return Err("a relative root must be refused".into());
651        };
652        assert_eq!(
653            error,
654            WorkspaceRootError::NotAbsolute {
655                path: "relative/clones".to_owned(),
656            }
657        );
658        Ok(())
659    }
660
661    #[test]
662    fn a_shape_changing_root_is_refused_naming_the_character() -> TestResult {
663        for (fragment, character) in [
664            ("with space", "whitespace"),
665            ("with\ttab", "whitespace"),
666            ("with\nnewline", "whitespace"),
667            ("with$dollar", "`$`"),
668            ("with'single", "a single quote"),
669            ("with\"double", "a double quote"),
670            ("with\0nul", "a NUL byte"),
671        ] {
672            let root = PathBuf::from(format!("/absolute/{fragment}"));
673            let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
674            let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
675            let Err(error) = workspace.expand(&command) else {
676                return Err(format!("root {root:?} must be refused as shape-changing").into());
677            };
678            assert_eq!(
679                error,
680                WorkspaceRootError::ShapeChanging {
681                    path: root.to_string_lossy().into_owned(),
682                    character,
683                },
684                "root {root:?} must be refused naming {character}"
685            );
686        }
687        Ok(())
688    }
689
690    #[test]
691    fn a_clean_root_has_no_shape_changing_character() {
692        assert_eq!(
693            shape_changing_character("/Users/operator/.aion/clones"),
694            None
695        );
696    }
697
698    #[test]
699    fn the_directory_is_created_when_missing() -> TestResult {
700        let scratch = tempfile::tempdir()?;
701        let root = scratch.path().join("nested").join("clones");
702        assert!(!root.exists(), "the root must start absent");
703        let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
704        let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
705        let first = workspace.expand(&command)?;
706        assert!(first.is_some(), "expansion must succeed");
707        assert!(root.is_dir(), "expansion must create the missing root");
708        // A created root is owner-only: it holds session workspaces, so
709        // nothing else on the host gets a default read into it.
710        #[cfg(unix)]
711        {
712            use std::os::unix::fs::PermissionsExt as _;
713            let mode = std::fs::metadata(&root)?.permissions().mode() & 0o777;
714            assert_eq!(
715                mode, 0o700,
716                "a created root must be mode 0700, got {mode:o}"
717            );
718        }
719        // Idempotent: a second expansion over the now-existing directory
720        // succeeds identically.
721        assert_eq!(workspace.expand(&command)?, first);
722        Ok(())
723    }
724
725    #[test]
726    fn a_root_that_cannot_be_created_is_refused_naming_the_io_error() -> TestResult {
727        let scratch = tempfile::tempdir()?;
728        // A ROOT beneath a regular file cannot be created by any retry.
729        let file = scratch.path().join("occupied");
730        std::fs::write(&file, b"not a directory")?;
731        let root = file.join("clones");
732        let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
733        let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
734        let Err(error) = workspace.expand(&command) else {
735            return Err("creation beneath a regular file must fail".into());
736        };
737        let WorkspaceRootError::CreationFailed { path, error: io } = &error else {
738            return Err(format!("expected CreationFailed, got: {error}").into());
739        };
740        assert_eq!(path, &root.to_string_lossy().into_owned());
741        assert!(!io.is_empty(), "the io error's own words must be carried");
742        Ok(())
743    }
744
745    #[test]
746    fn resolved_reports_without_creating() -> TestResult {
747        let scratch = tempfile::tempdir()?;
748        let root = scratch.path().join("clones");
749        let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
750        assert_eq!(workspace.resolved(), Ok(root.as_path()));
751        assert!(
752            !root.exists(),
753            "reporting the root must not create the directory"
754        );
755        let failed = unresolved();
756        let Err(error) = failed.resolved() else {
757            return Err("an unresolved root must report its failure".into());
758        };
759        assert!(matches!(error, WorkspaceRootError::Unresolvable { .. }));
760        Ok(())
761    }
762}