use anyhow::{Context, Result, bail};
use oxdock_fs::{
GuardedPath, LazyGuardedTempDir, PathResolver, WorkspaceFs, discover_workspace_root,
init_temp_gc,
};
#[cfg(windows)]
use oxdock_process::CommandBuilder;
use oxdock_process::{DefaultProcessManager, SharedInput};
use std::env;
use std::io::{self, IsTerminal, Read};
use std::sync::{Arc, Mutex};
pub use oxdock_core::{
Engine, EngineOutput, ExecState, FuncKind, FuncMeta, FuncParam, HostModule, HostRegistration,
NativeFn, OxDockFn, OxDockType, PureFn, StepCtx, TypeDescriptor, Value, parse_script,
parse_script_with_modules, run_steps, run_steps_with_context, run_steps_with_context_result,
run_steps_with_manager_with_modules,
};
use oxdock_core::{ExecIo, run_steps_with_lazy_snapshot_and_modules};
pub use oxdock_parser::{Guard, Step, StepKind};
pub use oxdock_process::shell_program;
use std::collections::BTreeMap;
mod endpoints;
pub use endpoints::{EndpointFlags, build_registry};
use oxdock_net_plugin::EndpointRegistry;
#[cfg(feature = "ssh")]
fn cli_host_modules() -> Vec<HostModule<DefaultProcessManager>> {
cli_host_modules_with(&Arc::new(EndpointRegistry::new(false)))
}
#[cfg(not(feature = "ssh"))]
fn cli_host_modules() -> Vec<HostModule<DefaultProcessManager>> {
cli_host_modules_with(&Arc::new(EndpointRegistry::new(false)))
}
#[cfg(feature = "ssh")]
fn cli_host_modules_with(
registry: &Arc<EndpointRegistry>,
) -> Vec<HostModule<DefaultProcessManager>> {
vec![
oxdock_net_plugin::module_with_endpoints(Arc::clone(registry)),
oxdock_ssh_plugin::module_with_endpoints(Arc::clone(registry)),
]
}
#[cfg(not(feature = "ssh"))]
fn cli_host_modules_with(
registry: &Arc<EndpointRegistry>,
) -> Vec<HostModule<DefaultProcessManager>> {
vec![oxdock_net_plugin::module_with_endpoints(Arc::clone(
registry,
))]
}
#[cfg(feature = "ssh")]
fn cli_host_types() -> Vec<&'static TypeDescriptor> {
vec![
oxdock_net_plugin::NetListenerTag::descriptor(),
oxdock_ssh_plugin::SshServerTag::descriptor(),
oxdock_ssh_plugin::SshSessionTag::descriptor(),
]
}
#[cfg(not(feature = "ssh"))]
fn cli_host_types() -> Vec<&'static TypeDescriptor> {
vec![oxdock_net_plugin::NetListenerTag::descriptor()]
}
fn parse_cli_script(script: &str) -> Result<Vec<Step>> {
let modules = cli_host_modules();
if modules.is_empty() {
parse_script(script)
} else {
let mut engine = Engine::new();
for module in modules {
engine.register_module(module);
}
parse_script_with_modules(script, engine.module_table())
}
}
pub fn run() -> Result<()> {
init_temp_gc();
let workspace_root = discover_workspace_root().context("guard workspace root")?;
let mut args = std::env::args().skip(1);
let opts = match Options::parse(&mut args, &workspace_root) {
Ok(opts) => opts,
Err(err) if err.to_string() == usage() => {
print!("{err}");
return Ok(());
}
Err(err) => return Err(err),
};
execute(opts, workspace_root)
}
#[derive(Debug, Clone)]
pub enum ScriptSource {
Path(GuardedPath),
Stdin,
}
#[derive(Debug, Clone)]
pub struct Options {
pub script: ScriptSource,
pub shell: bool,
pub endpoints: EndpointFlags,
}
impl Options {
pub fn parse(
args: &mut impl Iterator<Item = String>,
workspace_root: &GuardedPath,
) -> Result<Self> {
use lexopt::Arg::{Long, Short, Value};
let mut script: Option<ScriptSource> = None;
let mut shell = false;
let mut endpoints = EndpointFlags::default();
let mut set_script = |source: ScriptSource, origin: &str| -> Result<()> {
if script.is_some() {
bail!("script given multiple times ({origin})");
}
script = Some(source);
Ok(())
};
let mut parser = lexopt::Parser::from_args(args.by_ref());
while let Some(arg) = parser.next()? {
match arg {
Long("script") => {
let path = value_string(
parser
.value()
.map_err(|_| anyhow::anyhow!("--script requires a path"))?,
)?;
if path.is_empty() {
bail!("--script requires a path");
}
if path == "-" {
set_script(ScriptSource::Stdin, "--script -")?;
} else {
set_script(
ScriptSource::Path(
workspace_root
.join(&path)
.with_context(|| format!("guard script path {path}"))?,
),
"--script",
)?;
}
}
Long("shell") => {
shell = true;
}
Long("listen") => {
let raw = value_string(
parser
.value()
.map_err(|_| anyhow::anyhow!("--listen requires an address"))?,
)?;
endpoints.listens.push(endpoints::parse_listen_arg(&raw)?);
}
Short('p') => {
let raw = value_string(
parser
.value()
.map_err(|_| anyhow::anyhow!("-p requires outer:inner"))?,
)?;
endpoints
.publishes
.push(endpoints::parse_publish_arg(&raw)?);
}
Long("offline") => {
endpoints.offline = true;
}
Long("help") | Short('h') => {
bail!("{}", usage());
}
Value(value) => {
let text = value_string(value)?;
if text.is_empty() {
continue;
}
if text == "-" {
set_script(ScriptSource::Stdin, "positional `-`")?;
} else {
set_script(
ScriptSource::Path(
workspace_root
.join(&text)
.with_context(|| format!("guard script path {text}"))?,
),
"positional argument",
)?;
}
}
Long(other) => bail!("unexpected flag: --{other}"),
Short(other) => bail!("unexpected flag: -{other}"),
}
}
let script = script.unwrap_or(ScriptSource::Stdin);
Ok(Self {
script,
shell,
endpoints,
})
}
}
fn value_string(value: std::ffi::OsString) -> Result<String> {
value
.into_string()
.map_err(|_| anyhow::anyhow!("argument must be UTF-8"))
}
pub fn usage() -> String {
let version = env!("CARGO_PKG_VERSION");
let description = env!("CARGO_PKG_DESCRIPTION");
indoc::formatdoc! {"
oxdock {version} — {description}
Usage: oxdock [OPTIONS] [SCRIPT]
SCRIPT script file path (same as `--script <file>`); `-` reads stdin
--script <file|-> script file under the workspace root, or `-` for stdin
--shell run the script, then drop into an interactive shell (requires a TTY)
--listen <addr> expose a logical service port ([host:]port, repeatable)
-p <[host:]outer:inner> map outer port to an inner service port or name (repeatable; outer 0 is ephemeral)
--offline open no sockets (conflicts with --listen/-p)
--help, -h print this help and exit
With no script given, reads the script from stdin (must be piped unless `--shell`).
Scripts declare logical endpoints (a port like 2251); the flags above map them to interfaces.
"}
}
pub fn execute(opts: Options, workspace_root: GuardedPath) -> Result<()> {
init_temp_gc();
execute_with_shell_runner(opts, workspace_root, run_shell, true)
}
pub struct ExecutionResult {
pub snapshot: Arc<LazyGuardedTempDir>,
pub final_cwd: GuardedPath,
pub bindings: BTreeMap<String, Value>,
}
impl ExecutionResult {
pub fn has_snapshot(&self) -> bool {
self.snapshot.is_materialized()
}
pub fn snapshot_path(&self) -> Option<&GuardedPath> {
self.snapshot.get()
}
}
pub fn execute_with_result(opts: Options, workspace_root: GuardedPath) -> Result<ExecutionResult> {
if opts.shell {
bail!("execute_with_result does not support --shell");
}
let script = read_script(&opts.script, &workspace_root)?;
let mut final_cwd = workspace_root.clone();
let snapshot = Arc::new(LazyGuardedTempDir::new());
if !script.trim().is_empty() {
let endpoints = build_registry(&opts.endpoints)?;
let steps = parse_cli_script(&script)?;
let output = run_steps_with_lazy_snapshot_and_modules(
&workspace_root,
&steps,
ExecIo::new(),
cli_host_modules_with(&endpoints),
cli_host_types(),
)?;
final_cwd = output.final_cwd;
return Ok(ExecutionResult {
snapshot: output.snapshot,
final_cwd,
bindings: output.bindings,
});
}
Ok(ExecutionResult {
snapshot,
final_cwd,
bindings: BTreeMap::new(),
})
}
fn report_ephemeral_publishes(flags: &EndpointFlags, registry: &Arc<EndpointRegistry>) {
for (outer, inner) in &flags.publishes {
if outer.port() != 0 {
continue;
}
match registry.bound_addr(inner) {
Some(addr) => eprintln!("oxdock: published {addr} -> {inner}"),
None => eprintln!("oxdock: published <unbound> -> {inner}"),
}
}
}
fn read_script(source: &ScriptSource, workspace_root: &GuardedPath) -> Result<String> {
match source {
ScriptSource::Path(path) => {
let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
resolver
.read_to_string(path)
.with_context(|| format!("failed to read script at {}", path.display()))
}
ScriptSource::Stdin => {
let mut buf = String::new();
io::stdin()
.lock()
.read_to_string(&mut buf)
.context("failed to read script from stdin")?;
Ok(buf)
}
}
}
fn execute_with_shell_runner<F>(
opts: Options,
workspace_root: GuardedPath,
shell_runner: F,
require_tty: bool,
) -> Result<()>
where
F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
{
#[cfg(windows)]
maybe_reexec_shell_to_temp(&opts)?;
let script = match &opts.script {
ScriptSource::Path(path) => {
let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
resolver
.read_to_string(path)
.with_context(|| format!("failed to read script at {}", path.display()))?
}
ScriptSource::Stdin => {
let stdin = io::stdin();
if stdin.is_terminal() {
if opts.shell {
String::new()
} else {
bail!(
"no stdin detected; pass --script <file> or pipe a script into stdin (use --script - if explicit)"
);
}
} else {
let mut buf = String::new();
stdin
.lock()
.read_to_string(&mut buf)
.context("failed to read script from stdin")?;
buf
}
}
};
let mut final_cwd = workspace_root.clone();
let mut snapshot = Arc::new(LazyGuardedTempDir::new());
let mut fs: Option<Box<dyn WorkspaceFs>> = None;
if !script.trim().is_empty() {
let endpoints = build_registry(&opts.endpoints)?;
report_ephemeral_publishes(&opts.endpoints, &endpoints);
let steps = parse_cli_script(&script)?;
let mut stdin_handle: Option<SharedInput> = None;
if let ScriptSource::Path(_) = opts.script {
let stdin = io::stdin();
if !stdin.is_terminal() {
stdin_handle = Some(Arc::new(Mutex::new(stdin)));
}
}
let mut io_cfg = ExecIo::new();
io_cfg.set_stdin(stdin_handle);
let output = run_steps_with_lazy_snapshot_and_modules(
&workspace_root,
&steps,
io_cfg,
cli_host_modules_with(&endpoints),
cli_host_types(),
)?;
final_cwd = output.final_cwd;
snapshot = output.snapshot;
fs = Some(output.fs);
}
if opts.shell {
if require_tty && !has_controlling_tty() {
bail!("--shell requires a tty (no controlling tty available)");
}
match fs.as_ref() {
Some(fs) => {
if fs.is_snapshot_pending() {
snapshot
.materialize()
.context("failed to create shell temp dir")?;
}
final_cwd = fs.concretize_cwd(&final_cwd);
}
None => {
snapshot
.materialize()
.context("failed to create shell temp dir")?;
final_cwd = snapshot
.get()
.cloned()
.expect("shell snapshot materialized above");
}
}
return shell_runner(&final_cwd, &workspace_root);
}
Ok(())
}
#[cfg(test)]
fn execute_for_test<F>(opts: Options, workspace_root: GuardedPath, shell_runner: F) -> Result<()>
where
F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
{
execute_with_shell_runner(opts, workspace_root, shell_runner, false)
}
fn has_controlling_tty() -> bool {
#[cfg(unix)]
{
io::stdin().is_terminal() || io::stderr().is_terminal()
}
#[cfg(windows)]
{
io::stdin().is_terminal() || io::stderr().is_terminal()
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
#[cfg(windows)]
fn maybe_reexec_shell_to_temp(opts: &Options) -> Result<()> {
if !opts.shell {
return Ok(());
}
if std::env::var("OXDOCK_SHELL_REEXEC").ok().as_deref() == Some("1") {
return Ok(());
}
let self_path = std::env::current_exe().context("determine current executable")?;
let base_temp =
GuardedPath::new_root(std::env::temp_dir().as_path()).context("guard system temp dir")?;
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let temp_file = base_temp
.join(&format!("oxdock-shell-{ts}-{}.exe", std::process::id()))
.context("construct temp shell path")?;
let temp_root_guard = temp_file
.parent()
.ok_or_else(|| anyhow::anyhow!("temp path unexpectedly missing parent"))?;
let resolver_temp = PathResolver::new(temp_root_guard.as_path(), temp_root_guard.as_path())?;
let dest = temp_file;
#[allow(clippy::disallowed_types)]
let source = oxdock_fs::UnguardedPath::external(self_path);
resolver_temp
.copy_file_from_unguarded(&source, &dest)
.with_context(|| format!("failed to copy shell runner to {}", dest.display()))?;
let mut cmd = CommandBuilder::new(dest.as_path());
cmd.args(std::env::args_os().skip(1));
cmd.env("OXDOCK_SHELL_REEXEC", "1");
cmd.spawn()
.with_context(|| format!("failed to spawn shell from {}", dest.display()))?;
std::process::exit(0);
}
pub fn run_script(workspace_root: &GuardedPath, steps: &[Step]) -> Result<()> {
run_steps_with_context(workspace_root, workspace_root, steps)
}
fn shell_banner(cwd: &GuardedPath, workspace_root: &GuardedPath) -> String {
#[cfg(windows)]
let cwd_disp = oxdock_fs::command_path(cwd).as_ref().display().to_string();
#[cfg(windows)]
let workspace_disp = oxdock_fs::command_path(workspace_root)
.as_ref()
.display()
.to_string();
#[cfg(not(windows))]
let cwd_disp = cwd.display().to_string();
#[cfg(not(windows))]
let workspace_disp = workspace_root.display().to_string();
let pkg = env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "oxdock".to_string());
indoc::formatdoc! {"
{pkg} shell workspace
cwd: {cwd_disp}
source: workspace root at {workspace_disp}
lifetime: temporary directory created for this shell session; it disappears when you exit
creation: temp workspace starts empty unless your script copies files into it
WARNING: This shell still runs on your host filesystem and is **not** isolated!
"}
}
fn run_shell(cwd: &GuardedPath, workspace_root: &GuardedPath) -> Result<()> {
oxdock_process::spawn_interactive_shell(cwd, workspace_root, &shell_banner(cwd, workspace_root))
}
#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;
use oxdock_fs::PathResolver;
use std::cell::{Cell, RefCell};
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn shell_runner_receives_final_workdir() -> Result<()> {
let workspace = GuardedPath::tempdir()?;
let workspace_root = workspace.as_guarded_path().clone();
let script_path = workspace_root.join("script.ox")?;
let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
let script = indoc! {"
WRITE temp.txt 123
WORKDIR sub
"};
resolver.write_file(&script_path, script.as_bytes())?;
let opts = Options {
script: ScriptSource::Path(script_path),
shell: true,
endpoints: EndpointFlags::default(),
};
let observed = Cell::new(false);
execute_for_test(opts, workspace_root.clone(), |cwd, _| {
assert!(
cwd.as_path().ends_with("sub"),
"final cwd should end in WORKDIR target, got {}",
cwd.display()
);
let temp_root = GuardedPath::new_root(cwd.root())
.context("construct guard for temp workspace root")?;
let sub_dir = temp_root.join("sub")?;
assert_eq!(
cwd.as_path(),
sub_dir.as_path(),
"shell runner cwd should match guarded sub dir"
);
let temp_file = temp_root.join("temp.txt")?;
let temp_resolver = PathResolver::new(temp_root.as_path(), temp_root.as_path())?;
let contents = temp_resolver.read_to_string(&temp_file)?;
assert!(
contents.contains("123"),
"expected WRITE command to materialize temp file"
);
observed.set(true);
Ok(())
})?;
assert!(
observed.into_inner(),
"shell runner closure should have been invoked"
);
Ok(())
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn options_parse_requires_script_path_value() {
let workspace = GuardedPath::tempdir().expect("tempdir");
let mut args = vec!["--script".to_string()].into_iter();
let err = Options::parse(&mut args, workspace.as_guarded_path())
.expect_err("expected missing path error");
assert!(err.to_string().contains("--script requires a path"));
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn options_parse_script_path_and_shell() {
let workspace = GuardedPath::tempdir().expect("tempdir");
let workspace_root = workspace.as_guarded_path().clone();
let script_path = workspace_root.join("script.txt").expect("script path");
let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
.expect("resolver");
resolver
.write_file(&script_path, b"WRITE out.txt hi")
.expect("write script");
let mut args = vec![
"--script".to_string(),
"script.txt".to_string(),
"--shell".to_string(),
]
.into_iter();
let opts = Options::parse(&mut args, &workspace_root).expect("parse");
assert!(opts.shell);
match opts.script {
ScriptSource::Path(path) => assert_eq!(path, script_path),
ScriptSource::Stdin => panic!("expected path script"),
}
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn options_parse_positional_script_path() {
let workspace = GuardedPath::tempdir().expect("tempdir");
let workspace_root = workspace.as_guarded_path().clone();
let mut args = vec!["script.txt".to_string()].into_iter();
let opts = Options::parse(&mut args, &workspace_root).expect("parse");
assert!(!opts.shell);
match opts.script {
ScriptSource::Path(path) => assert_eq!(
path,
workspace_root.join("script.txt").expect("script path")
),
ScriptSource::Stdin => panic!("expected path script"),
}
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn options_parse_positional_dash_reads_stdin() {
let workspace = GuardedPath::tempdir().expect("tempdir");
let mut args = vec!["-".to_string()].into_iter();
let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
assert!(matches!(opts.script, ScriptSource::Stdin));
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn options_parse_rejects_duplicate_script_sources() {
let workspace = GuardedPath::tempdir().expect("tempdir");
let workspace_root = workspace.as_guarded_path().clone();
let mut args = vec![
"a.ox".to_string(),
"--script".to_string(),
"b.ox".to_string(),
]
.into_iter();
let err = Options::parse(&mut args, &workspace_root)
.expect_err("expected duplicate script error");
assert!(err.to_string().contains("multiple times"), "{err:?}");
let mut args = vec!["a.ox".to_string(), "b.ox".to_string()].into_iter();
let err = Options::parse(&mut args, &workspace_root)
.expect_err("expected duplicate script error");
assert!(err.to_string().contains("multiple times"), "{err:?}");
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn options_parse_rejects_unknown_flags() {
let workspace = GuardedPath::tempdir().expect("tempdir");
let mut args = vec!["--frobnicate".to_string()].into_iter();
let err = Options::parse(&mut args, workspace.as_guarded_path())
.expect_err("expected unknown flag error");
assert!(err.to_string().contains("unexpected flag"), "{err:?}");
}
#[test]
fn usage_describes_positional_script_and_help() {
let text = usage();
assert!(text.contains("Usage: oxdock"), "{text}");
assert!(text.contains("SCRIPT"), "{text}");
assert!(text.contains("--script"), "{text}");
assert!(text.contains("--help"), "{text}");
assert!(text.contains("--listen"), "{text}");
assert!(text.contains("-p <[host:]outer:inner>"), "{text}");
assert!(text.contains("--offline"), "{text}");
assert!(text.contains(env!("CARGO_PKG_DESCRIPTION")), "{text}");
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn options_parse_endpoint_flags() {
let workspace = GuardedPath::tempdir().expect("tempdir");
let mut args = vec![
"--listen".to_string(),
"0.0.0.0:2251".to_string(),
"-p".to_string(),
"2222:demo-proxy".to_string(),
"-p".to_string(),
"0:2252".to_string(),
"-".to_string(),
]
.into_iter();
let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
assert_eq!(opts.endpoints.listens.len(), 1);
assert_eq!(opts.endpoints.publishes.len(), 2);
assert!(!opts.endpoints.offline);
assert!(matches!(opts.script, ScriptSource::Stdin));
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn options_parse_offline_flag() {
let workspace = GuardedPath::tempdir().expect("tempdir");
let mut args = vec!["--offline".to_string(), "-".to_string()].into_iter();
let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
assert!(opts.endpoints.offline);
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn options_parse_rejects_bad_endpoint_flags() {
let workspace = GuardedPath::tempdir().expect("tempdir");
for args in [
vec!["--listen"],
vec!["--listen", "0.0.0.0:0"],
vec!["-p"],
vec!["-p", "2222"],
vec!["-p", "2222:0"],
] {
let mut args = args.into_iter().map(str::to_string);
Options::parse(&mut args, workspace.as_guarded_path())
.expect_err("bad endpoint flag must fail");
}
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn options_parse_empty_values_hold_token_boundaries() {
let workspace = GuardedPath::tempdir().expect("tempdir");
let mut args = vec![
"--script".to_string(),
"".to_string(),
"--shell".to_string(),
]
.into_iter();
let err = Options::parse(&mut args, workspace.as_guarded_path())
.expect_err("empty script path must fail");
assert!(
err.to_string().contains("--script requires a path"),
"{err:?}"
);
let mut args = vec!["".to_string(), "-".to_string()].into_iter();
let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
assert!(matches!(opts.script, ScriptSource::Stdin));
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn options_parse_accepts_equals_and_attached_forms() {
let workspace = GuardedPath::tempdir().expect("tempdir");
let workspace_root = workspace.as_guarded_path().clone();
let mut args = vec![
"--listen=0.0.0.0:2251".to_string(),
"-p2222:demo-proxy".to_string(),
"--".to_string(),
"script.ox".to_string(),
]
.into_iter();
let opts = Options::parse(&mut args, &workspace_root).expect("parse");
assert_eq!(opts.endpoints.listens.len(), 1);
assert_eq!(opts.endpoints.publishes.len(), 1);
match opts.script {
ScriptSource::Path(path) => {
assert_eq!(path, workspace_root.join("script.ox").expect("script path"))
}
ScriptSource::Stdin => panic!("expected path script after --"),
}
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn options_parse_help_returns_usage_error_without_exiting() {
let workspace = GuardedPath::tempdir().expect("tempdir");
for flag in ["--help", "-h"] {
let mut args = vec![flag.to_string()].into_iter();
let err = Options::parse(&mut args, workspace.as_guarded_path())
.expect_err("help flag must not parse as options");
assert_eq!(err.to_string(), usage());
}
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn execute_with_result_runs_script() {
let workspace = GuardedPath::tempdir().expect("tempdir");
let workspace_root = workspace.as_guarded_path().clone();
let script_path = workspace_root.join("script.txt").expect("script path");
let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
.expect("resolver");
resolver
.write_file(&script_path, b"WRITE out.txt hi")
.expect("write script");
let opts = Options {
script: ScriptSource::Path(script_path),
shell: false,
endpoints: EndpointFlags::default(),
};
let result = execute_with_result(opts, workspace_root).expect("execute");
let snapshot = result
.snapshot_path()
.expect("default WRITE materializes the snapshot");
assert_eq!(snapshot, &result.final_cwd);
let temp_resolver = PathResolver::new(snapshot.root(), snapshot.root()).expect("resolver");
let out = snapshot.join("out.txt").expect("out path");
let contents = temp_resolver.read_to_string(&out).expect("read out");
assert_eq!(contents.trim(), "hi");
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn execute_with_result_local_only_creates_no_snapshot() {
let workspace = GuardedPath::tempdir().expect("tempdir");
let workspace_root = workspace.as_guarded_path().clone();
let script_path = workspace_root.join("script.txt").expect("script path");
let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
.expect("resolver");
resolver
.write_file(&script_path, b"WORKSPACE LOCAL\nWRITE out.txt hi")
.expect("write script");
let opts = Options {
script: ScriptSource::Path(script_path),
shell: false,
endpoints: EndpointFlags::default(),
};
let result = execute_with_result(opts, workspace_root.clone()).expect("execute");
assert!(
!result.has_snapshot(),
"WORKSPACE LOCAL-only script must not create a snapshot tempdir"
);
assert!(result.snapshot_path().is_none());
let out = workspace_root.join("out.txt").expect("out path");
let contents = resolver.read_to_string(&out).expect("read out");
assert_eq!(contents.trim(), "hi");
assert_eq!(result.final_cwd.root(), workspace_root.as_path());
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn execute_with_result_empty_script_creates_no_snapshot() {
let workspace = GuardedPath::tempdir().expect("tempdir");
let workspace_root = workspace.as_guarded_path().clone();
let script_path = workspace_root.join("empty.txt").expect("script path");
let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
.expect("resolver");
resolver
.write_file(&script_path, b"")
.expect("write script");
let opts = Options {
script: ScriptSource::Path(script_path),
shell: false,
endpoints: EndpointFlags::default(),
};
let result = execute_with_result(opts, workspace_root.clone()).expect("execute");
assert!(
!result.has_snapshot(),
"empty script must not create a snapshot tempdir"
);
assert_eq!(result.final_cwd, workspace_root);
}
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn execute_for_test_invokes_shell_runner() -> Result<()> {
let workspace = GuardedPath::tempdir()?;
let workspace_root = workspace.as_guarded_path().clone();
let script_path = workspace_root.join("empty.txt")?;
let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
resolver.write_file(&script_path, b"")?;
let opts = Options {
script: ScriptSource::Path(script_path),
shell: true,
endpoints: EndpointFlags::default(),
};
let called = RefCell::new(None::<(String, String)>);
execute_for_test(opts, workspace_root.clone(), |cwd, workspace| {
called.replace(Some((cwd.display(), workspace.display())));
assert!(
cwd.exists(),
"shell cwd must exist on disk, got {}",
cwd.display()
);
Ok(())
})?;
let seen = called.borrow().clone().expect("shell runner called");
assert_eq!(seen.1, workspace_root.display());
Ok(())
}
#[cfg(feature = "ssh")]
#[cfg_attr(
miri,
ignore = "loopback TCP plus threads plus a Tokio runtime; also GuardedPath::tempdir"
)]
#[test]
fn ssh_feature_serves_and_closes() -> Result<()> {
let workspace = GuardedPath::tempdir()?;
let workspace_root = workspace.as_guarded_path().clone();
let script_path = workspace_root.join("ssh-serve.ox")?;
let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
let script = indoc! {"
IMPORT [STD, SSH]
LET $m: MAP = SSH_SERVE(\"23301\", {username: \"test\", password: \"test123\"})
SSH_CLOSE($m.server)
"};
resolver.write_file(&script_path, script.as_bytes())?;
let opts = Options {
script: ScriptSource::Path(script_path),
shell: false,
endpoints: EndpointFlags::default(),
};
execute_with_result(opts, workspace_root)?;
Ok(())
}
#[cfg_attr(
miri,
ignore = "loopback TCP plus GuardedPath::tempdir; blocked under Miri isolation"
)]
#[test]
fn net_module_listens_and_closes() -> Result<()> {
let workspace = GuardedPath::tempdir()?;
let workspace_root = workspace.as_guarded_path().clone();
let script_path = workspace_root.join("net-listen.ox")?;
let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
let script = indoc! {"
IMPORT [STD, NET]
LET $l: MAP = NET_LISTEN(\"23501\", {})
NET_CLOSE($l.listener)
"};
resolver.write_file(&script_path, script.as_bytes())?;
let opts = Options {
script: ScriptSource::Path(script_path),
shell: false,
endpoints: EndpointFlags::default(),
};
execute_with_result(opts, workspace_root)?;
Ok(())
}
#[cfg(not(feature = "ssh"))]
#[cfg_attr(
miri,
ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
)]
#[test]
fn ssh_scripts_rejected_without_feature() -> Result<()> {
let workspace = GuardedPath::tempdir()?;
let workspace_root = workspace.as_guarded_path().clone();
let script_path = workspace_root.join("ssh-serve.ox")?;
let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
let script = indoc! {"
IMPORT [STD, SSH]
LET $m: MAP = SSH_SERVE(\"23301\", {username: \"test\", password: \"test123\"})
SSH_CLOSE($m.server)
"};
resolver.write_file(&script_path, script.as_bytes())?;
let opts = Options {
script: ScriptSource::Path(script_path),
shell: false,
endpoints: EndpointFlags::default(),
};
let err = match execute_with_result(opts, workspace_root) {
Ok(_) => panic!("SSH names must be unknown without the feature"),
Err(err) => err,
};
assert!(err.to_string().contains("SSH"), "{err}");
Ok(())
}
}
#[cfg(all(test, windows))]
mod windows_shell_tests {
use super::*;
#[test]
fn command_path_strips_verbatim_prefix() -> Result<()> {
let temp = GuardedPath::tempdir()?;
let converted = oxdock_fs::command_path(temp.as_guarded_path());
let as_str = converted.as_ref().display().to_string();
assert!(
!as_str.starts_with(r"\\?\"),
"expected non-verbatim path, got {as_str}"
);
Ok(())
}
}