1use anyhow::{Context, Result, bail};
2use oxdock_fs::{
3 GuardedPath, LazyGuardedTempDir, PathResolver, WorkspaceFs, discover_workspace_root,
4 env as oxdock_env, init_temp_gc,
5};
6#[cfg(windows)]
7use oxdock_process::CommandBuilder;
8use oxdock_process::{DefaultProcessManager, SharedInput};
9use std::env;
10use std::io::{self, IsTerminal, Read};
11use std::sync::{Arc, Mutex};
12
13pub use oxdock_core::{
14 Engine, EngineOutput, ExecState, FuncKind, FuncMeta, FuncParam, HostModule, HostRegistration,
15 NativeFn, OxDockFn, OxDockType, PureFn, StepCtx, TypeDescriptor, Value, parse_script,
16 parse_script_with_modules, run_steps, run_steps_with_context, run_steps_with_context_result,
17 run_steps_with_manager_with_modules,
18};
19use oxdock_core::{ExecIo, run_steps_with_lazy_snapshot_and_modules};
20pub use oxdock_parser::{Guard, Step, StepKind};
21pub use oxdock_process::shell_program;
22use std::collections::BTreeMap;
23
24mod endpoints;
25pub use endpoints::{EndpointFlags, build_registry};
26use oxdock_net_plugin::EndpointRegistry;
27
28#[cfg(feature = "ssh")]
35fn cli_host_modules() -> Vec<HostModule<DefaultProcessManager>> {
36 cli_host_modules_with(&Arc::new(EndpointRegistry::new(false)))
37}
38
39#[cfg(not(feature = "ssh"))]
41fn cli_host_modules() -> Vec<HostModule<DefaultProcessManager>> {
42 cli_host_modules_with(&Arc::new(EndpointRegistry::new(false)))
43}
44
45#[cfg(feature = "ssh")]
49fn cli_host_modules_with(
50 registry: &Arc<EndpointRegistry>,
51) -> Vec<HostModule<DefaultProcessManager>> {
52 vec![
53 oxdock_net_plugin::module_with_endpoints(Arc::clone(registry)),
54 oxdock_ssh_plugin::module_with_endpoints(Arc::clone(registry)),
55 ]
56}
57
58#[cfg(not(feature = "ssh"))]
60fn cli_host_modules_with(
61 registry: &Arc<EndpointRegistry>,
62) -> Vec<HostModule<DefaultProcessManager>> {
63 vec![oxdock_net_plugin::module_with_endpoints(Arc::clone(
64 registry,
65 ))]
66}
67
68#[cfg(feature = "ssh")]
70fn cli_host_types() -> Vec<&'static TypeDescriptor> {
71 vec![
72 oxdock_net_plugin::NetListenerTag::descriptor(),
73 oxdock_ssh_plugin::SshServerTag::descriptor(),
74 oxdock_ssh_plugin::SshSessionTag::descriptor(),
75 ]
76}
77
78#[cfg(not(feature = "ssh"))]
80fn cli_host_types() -> Vec<&'static TypeDescriptor> {
81 vec![oxdock_net_plugin::NetListenerTag::descriptor()]
82}
83
84fn parse_cli_script(script: &str) -> Result<Vec<Step>> {
88 let modules = cli_host_modules();
89 if modules.is_empty() {
90 parse_script(script)
91 } else {
92 let mut engine = Engine::new();
93 for module in modules {
94 engine.register_module(module);
95 }
96 parse_script_with_modules(script, engine.module_table())
97 }
98}
99
100pub fn run() -> Result<()> {
101 init_temp_gc();
102 let workspace_root = discover_workspace_root().context("guard workspace root")?;
103
104 let mut args = std::env::args().skip(1);
105 let opts = match Options::parse(&mut args, &workspace_root) {
109 Ok(opts) => opts,
110 Err(err) if err.to_string() == usage() => {
111 print!("{err}");
112 return Ok(());
113 }
114 Err(err) => return Err(err),
115 };
116 execute(opts, workspace_root)
117}
118
119#[derive(Debug, Clone)]
120pub enum ScriptSource {
121 Path(GuardedPath),
122 Stdin,
123}
124
125#[derive(Debug, Clone)]
126pub struct Options {
127 pub script: ScriptSource,
128 pub shell: bool,
129 pub endpoints: EndpointFlags,
130}
131
132impl Options {
133 pub fn parse(
134 args: &mut impl Iterator<Item = String>,
135 workspace_root: &GuardedPath,
136 ) -> Result<Self> {
137 use lexopt::Arg::{Long, Short, Value};
138
139 let mut script: Option<ScriptSource> = None;
140 let mut shell = false;
141 let mut endpoints = EndpointFlags::default();
142 let mut set_script = |source: ScriptSource, origin: &str| -> Result<()> {
143 if script.is_some() {
144 bail!("script given multiple times ({origin})");
145 }
146 script = Some(source);
147 Ok(())
148 };
149 let mut parser = lexopt::Parser::from_args(args.by_ref());
154 while let Some(arg) = parser.next()? {
155 match arg {
156 Long("script") => {
157 let path = value_string(
158 parser
159 .value()
160 .map_err(|_| anyhow::anyhow!("--script requires a path"))?,
161 )?;
162 if path.is_empty() {
163 bail!("--script requires a path");
164 }
165 if path == "-" {
166 set_script(ScriptSource::Stdin, "--script -")?;
167 } else {
168 set_script(
169 ScriptSource::Path(
170 workspace_root
171 .join(&path)
172 .with_context(|| format!("guard script path {path}"))?,
173 ),
174 "--script",
175 )?;
176 }
177 }
178 Long("shell") => {
179 shell = true;
180 }
181 Long("listen") => {
182 let raw = value_string(
183 parser
184 .value()
185 .map_err(|_| anyhow::anyhow!("--listen requires an address"))?,
186 )?;
187 endpoints.listens.push(endpoints::parse_listen_arg(&raw)?);
188 }
189 Short('p') => {
190 let raw = value_string(
191 parser
192 .value()
193 .map_err(|_| anyhow::anyhow!("-p requires outer:inner"))?,
194 )?;
195 endpoints
196 .publishes
197 .push(endpoints::parse_publish_arg(&raw)?);
198 }
199 Long("offline") => {
200 endpoints.offline = true;
201 }
202 Long("help") | Short('h') => {
203 bail!("{}", usage());
204 }
205 Value(value) => {
206 let text = value_string(value)?;
207 if text.is_empty() {
208 continue;
209 }
210 if text == "-" {
211 set_script(ScriptSource::Stdin, "positional `-`")?;
212 } else {
213 set_script(
214 ScriptSource::Path(
215 workspace_root
216 .join(&text)
217 .with_context(|| format!("guard script path {text}"))?,
218 ),
219 "positional argument",
220 )?;
221 }
222 }
223 Long(other) => bail!("unexpected flag: --{other}"),
224 Short(other) => bail!("unexpected flag: -{other}"),
225 }
226 }
227
228 let script = script.unwrap_or(ScriptSource::Stdin);
229
230 Ok(Self {
231 script,
232 shell,
233 endpoints,
234 })
235 }
236}
237
238fn value_string(value: std::ffi::OsString) -> Result<String> {
241 value
242 .into_string()
243 .map_err(|_| anyhow::anyhow!("argument must be UTF-8"))
244}
245
246pub fn usage() -> String {
248 let version = env!("CARGO_PKG_VERSION");
249 let description = env!("CARGO_PKG_DESCRIPTION");
250 indoc::formatdoc! {"
251 oxdock {version} — {description}
252 Usage: oxdock [OPTIONS] [SCRIPT]
253 SCRIPT script file path (same as `--script <file>`); `-` reads stdin
254 --script <file|-> script file under the workspace root, or `-` for stdin
255 --shell run the script, then drop into an interactive shell (requires a TTY)
256 --listen <addr> expose a logical service port ([host:]port, repeatable)
257 -p <[host:]outer:inner> map outer port to an inner service port or name (repeatable; outer 0 is ephemeral)
258 --offline open no sockets (conflicts with --listen/-p)
259 --help, -h print this help and exit
260 With no script given, reads the script from stdin (must be piped unless `--shell`).
261 Scripts declare logical endpoints (a port like 2251); the flags above map them to interfaces.
262 "}
263}
264
265pub fn execute(opts: Options, workspace_root: GuardedPath) -> Result<()> {
266 init_temp_gc();
267 execute_with_shell_runner(opts, workspace_root, run_shell, true)
268}
269
270pub struct ExecutionResult {
278 pub snapshot: Arc<LazyGuardedTempDir>,
282 pub final_cwd: GuardedPath,
285 pub bindings: BTreeMap<String, Value>,
289}
290
291impl ExecutionResult {
292 pub fn has_snapshot(&self) -> bool {
294 self.snapshot.is_materialized()
295 }
296
297 pub fn snapshot_path(&self) -> Option<&GuardedPath> {
299 self.snapshot.get()
300 }
301}
302
303pub fn execute_with_result(opts: Options, workspace_root: GuardedPath) -> Result<ExecutionResult> {
304 if opts.shell {
305 bail!("execute_with_result does not support --shell");
306 }
307
308 let script = read_script(&opts.script, &workspace_root)?;
311
312 let mut final_cwd = workspace_root.clone();
313 let snapshot = Arc::new(LazyGuardedTempDir::new());
314 if !script.trim().is_empty() {
315 let endpoints = build_registry(&opts.endpoints)?;
318 let steps = parse_cli_script(&script)?;
319 let output = run_steps_with_lazy_snapshot_and_modules(
320 &workspace_root,
321 &steps,
322 ExecIo::new(),
323 cli_host_modules_with(&endpoints),
324 cli_host_types(),
325 )?;
326 final_cwd = output.final_cwd;
327 return Ok(ExecutionResult {
328 snapshot: output.snapshot,
329 final_cwd,
330 bindings: output.bindings,
331 });
332 }
333
334 Ok(ExecutionResult {
335 snapshot,
336 final_cwd,
337 bindings: BTreeMap::new(),
338 })
339}
340
341fn report_ephemeral_publishes(flags: &EndpointFlags, registry: &Arc<EndpointRegistry>) {
345 for (outer, inner) in &flags.publishes {
346 if outer.port() != 0 {
347 continue;
348 }
349 match registry.bound_addr(inner) {
350 Some(addr) => eprintln!("oxdock: published {addr} -> {inner}"),
351 None => eprintln!("oxdock: published <unbound> -> {inner}"),
352 }
353 }
354}
355
356fn read_script(source: &ScriptSource, workspace_root: &GuardedPath) -> Result<String> {
358 match source {
359 ScriptSource::Path(path) => {
360 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
361 resolver
362 .read_to_string(path)
363 .with_context(|| format!("failed to read script at {}", path.display()))
364 }
365 ScriptSource::Stdin => {
366 let mut buf = String::new();
367 io::stdin()
368 .lock()
369 .read_to_string(&mut buf)
370 .context("failed to read script from stdin")?;
371 Ok(buf)
372 }
373 }
374}
375
376fn execute_with_shell_runner<F>(
377 opts: Options,
378 workspace_root: GuardedPath,
379 shell_runner: F,
380 require_tty: bool,
381) -> Result<()>
382where
383 F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
384{
385 #[cfg(windows)]
386 maybe_reexec_shell_to_temp(&opts)?;
387
388 let script = match &opts.script {
392 ScriptSource::Path(path) => {
393 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
396 resolver
397 .read_to_string(path)
398 .with_context(|| format!("failed to read script at {}", path.display()))?
399 }
400 ScriptSource::Stdin => {
401 let stdin = io::stdin();
402 if stdin.is_terminal() {
403 if opts.shell {
408 String::new()
409 } else {
410 bail!(
411 "no stdin detected; pass --script <file> or pipe a script into stdin (use --script - if explicit)"
412 );
413 }
414 } else {
415 let mut buf = String::new();
416 stdin
417 .lock()
418 .read_to_string(&mut buf)
419 .context("failed to read script from stdin")?;
420 buf
421 }
422 }
423 };
424
425 let mut final_cwd = workspace_root.clone();
432 let mut snapshot = Arc::new(LazyGuardedTempDir::new());
433 let mut fs: Option<Box<dyn WorkspaceFs>> = None;
434 if !script.trim().is_empty() {
435 let endpoints = build_registry(&opts.endpoints)?;
438 report_ephemeral_publishes(&opts.endpoints, &endpoints);
439 let steps = parse_cli_script(&script)?;
440 let mut stdin_handle: Option<SharedInput> = None;
445 if let ScriptSource::Path(_) = opts.script {
446 let stdin = io::stdin();
447 if !stdin.is_terminal() {
448 stdin_handle = Some(Arc::new(Mutex::new(stdin)));
454 }
455 }
456
457 let mut io_cfg = ExecIo::new();
458 io_cfg.set_stdin(stdin_handle);
459 let output = run_steps_with_lazy_snapshot_and_modules(
460 &workspace_root,
461 &steps,
462 io_cfg,
463 cli_host_modules_with(&endpoints),
464 cli_host_types(),
465 )?;
466 final_cwd = output.final_cwd;
467 snapshot = output.snapshot;
468 fs = Some(output.fs);
469 }
470
471 if opts.shell {
473 if require_tty && !has_controlling_tty() {
474 bail!("--shell requires a tty (no controlling tty available)");
475 }
476 match fs.as_ref() {
481 Some(fs) => {
482 if fs.is_snapshot_pending() {
483 snapshot
484 .materialize()
485 .context("failed to create shell temp dir")?;
486 }
487 final_cwd = fs.concretize_cwd(&final_cwd);
488 }
489 None => {
490 snapshot
491 .materialize()
492 .context("failed to create shell temp dir")?;
493 final_cwd = snapshot
494 .get()
495 .cloned()
496 .expect("shell snapshot materialized above");
497 }
498 }
499 return shell_runner(&final_cwd, &workspace_root);
500 }
501
502 Ok(())
503}
504
505#[cfg(test)]
506fn execute_for_test<F>(opts: Options, workspace_root: GuardedPath, shell_runner: F) -> Result<()>
507where
508 F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
509{
510 execute_with_shell_runner(opts, workspace_root, shell_runner, false)
511}
512
513fn has_controlling_tty() -> bool {
514 #[cfg(unix)]
518 {
519 io::stdin().is_terminal() || io::stderr().is_terminal()
520 }
521
522 #[cfg(windows)]
523 {
524 io::stdin().is_terminal() || io::stderr().is_terminal()
525 }
526
527 #[cfg(not(any(unix, windows)))]
528 {
529 false
530 }
531}
532
533#[cfg(windows)]
534fn maybe_reexec_shell_to_temp(opts: &Options) -> Result<()> {
535 if !opts.shell {
538 return Ok(());
539 }
540 if std::env::var(oxdock_env::SHELL_REEXEC).ok().as_deref() == Some("1") {
541 return Ok(());
542 }
543
544 let self_path = std::env::current_exe().context("determine current executable")?;
545 let base_temp =
546 GuardedPath::new_root(std::env::temp_dir().as_path()).context("guard system temp dir")?;
547 let ts = std::time::SystemTime::now()
548 .duration_since(std::time::UNIX_EPOCH)
549 .unwrap_or_default()
550 .as_millis();
551 let temp_file = base_temp
552 .join(&format!("oxdock-shell-{ts}-{}.exe", std::process::id()))
553 .context("construct temp shell path")?;
554
555 let temp_root_guard = temp_file
559 .parent()
560 .ok_or_else(|| anyhow::anyhow!("temp path unexpectedly missing parent"))?;
561 let resolver_temp = PathResolver::new(temp_root_guard.as_path(), temp_root_guard.as_path())?;
562 let dest = temp_file;
563 #[allow(clippy::disallowed_types)]
564 let source = oxdock_fs::UnguardedPath::external(self_path);
565 resolver_temp
566 .copy_file_from_unguarded(&source, &dest)
567 .with_context(|| format!("failed to copy shell runner to {}", dest.display()))?;
568
569 let mut cmd = CommandBuilder::new(dest.as_path());
570 cmd.args(std::env::args_os().skip(1));
571 cmd.env(oxdock_env::SHELL_REEXEC, "1");
572 cmd.spawn()
573 .with_context(|| format!("failed to spawn shell from {}", dest.display()))?;
574
575 std::process::exit(0);
577}
578
579pub fn run_script(workspace_root: &GuardedPath, steps: &[Step]) -> Result<()> {
580 run_steps_with_context(workspace_root, workspace_root, steps)
581}
582
583fn shell_banner(cwd: &GuardedPath, workspace_root: &GuardedPath) -> String {
584 #[cfg(windows)]
585 let cwd_disp = oxdock_fs::command_path(cwd).as_ref().display().to_string();
586 #[cfg(windows)]
587 let workspace_disp = oxdock_fs::command_path(workspace_root)
588 .as_ref()
589 .display()
590 .to_string();
591
592 #[cfg(not(windows))]
593 let cwd_disp = cwd.display().to_string();
594 #[cfg(not(windows))]
595 let workspace_disp = workspace_root.display().to_string();
596
597 let pkg = env::var(oxdock_env::CARGO_PKG_NAME)
598 .unwrap_or_else(|_| oxdock_env::FALLBACK_APP_NAME.to_string());
599 indoc::formatdoc! {"
600 {pkg} shell workspace
601 cwd: {cwd_disp}
602 source: workspace root at {workspace_disp}
603 lifetime: temporary directory created for this shell session; it disappears when you exit
604 creation: temp workspace starts empty unless your script copies files into it
605
606 WARNING: This shell still runs on your host filesystem and is **not** isolated!
607 "}
608}
609
610fn run_shell(cwd: &GuardedPath, workspace_root: &GuardedPath) -> Result<()> {
611 oxdock_process::spawn_interactive_shell(cwd, workspace_root, &shell_banner(cwd, workspace_root))
612}
613
614#[cfg(test)]
617mod tests {
618 use super::*;
619 use indoc::indoc;
620 use oxdock_fs::PathResolver;
621 use std::cell::{Cell, RefCell};
622
623 #[cfg_attr(
624 miri,
625 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
626 )]
627 #[test]
628 fn shell_runner_receives_final_workdir() -> Result<()> {
629 let workspace = GuardedPath::tempdir()?;
630 let workspace_root = workspace.as_guarded_path().clone();
631 let script_path = workspace_root.join("script.ox")?;
632 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
633 let script = indoc! {"
634 WRITE temp.txt 123
635 WORKDIR sub
636 "};
637 resolver.write_file(&script_path, script.as_bytes())?;
638
639 let opts = Options {
640 script: ScriptSource::Path(script_path),
641 shell: true,
642 endpoints: EndpointFlags::default(),
643 };
644
645 let observed = Cell::new(false);
646 execute_for_test(opts, workspace_root.clone(), |cwd, _| {
647 assert!(
648 cwd.as_path().ends_with("sub"),
649 "final cwd should end in WORKDIR target, got {}",
650 cwd.display()
651 );
652
653 let temp_root = GuardedPath::new_root(cwd.root())
654 .context("construct guard for temp workspace root")?;
655 let sub_dir = temp_root.join("sub")?;
656 assert_eq!(
657 cwd.as_path(),
658 sub_dir.as_path(),
659 "shell runner cwd should match guarded sub dir"
660 );
661 let temp_file = temp_root.join("temp.txt")?;
662 let temp_resolver = PathResolver::new(temp_root.as_path(), temp_root.as_path())?;
663 let contents = temp_resolver.read_to_string(&temp_file)?;
664 assert!(
665 contents.contains("123"),
666 "expected WRITE command to materialize temp file"
667 );
668 observed.set(true);
669 Ok(())
670 })?;
671
672 assert!(
673 observed.into_inner(),
674 "shell runner closure should have been invoked"
675 );
676 Ok(())
677 }
678
679 #[cfg_attr(
680 miri,
681 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
682 )]
683 #[test]
684 fn options_parse_requires_script_path_value() {
685 let workspace = GuardedPath::tempdir().expect("tempdir");
686 let mut args = vec!["--script".to_string()].into_iter();
687 let err = Options::parse(&mut args, workspace.as_guarded_path())
688 .expect_err("expected missing path error");
689 assert!(err.to_string().contains("--script requires a path"));
690 }
691
692 #[cfg_attr(
693 miri,
694 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
695 )]
696 #[test]
697 fn options_parse_script_path_and_shell() {
698 let workspace = GuardedPath::tempdir().expect("tempdir");
699 let workspace_root = workspace.as_guarded_path().clone();
700 let script_path = workspace_root.join("script.txt").expect("script path");
701 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
702 .expect("resolver");
703 resolver
704 .write_file(&script_path, b"WRITE out.txt hi")
705 .expect("write script");
706 let mut args = vec![
707 "--script".to_string(),
708 "script.txt".to_string(),
709 "--shell".to_string(),
710 ]
711 .into_iter();
712 let opts = Options::parse(&mut args, &workspace_root).expect("parse");
713 assert!(opts.shell);
714 match opts.script {
715 ScriptSource::Path(path) => assert_eq!(path, script_path),
716 ScriptSource::Stdin => panic!("expected path script"),
717 }
718 }
719
720 #[cfg_attr(
721 miri,
722 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
723 )]
724 #[test]
725 fn options_parse_positional_script_path() {
726 let workspace = GuardedPath::tempdir().expect("tempdir");
727 let workspace_root = workspace.as_guarded_path().clone();
728 let mut args = vec!["script.txt".to_string()].into_iter();
729 let opts = Options::parse(&mut args, &workspace_root).expect("parse");
730 assert!(!opts.shell);
731 match opts.script {
732 ScriptSource::Path(path) => assert_eq!(
733 path,
734 workspace_root.join("script.txt").expect("script path")
735 ),
736 ScriptSource::Stdin => panic!("expected path script"),
737 }
738 }
739
740 #[cfg_attr(
741 miri,
742 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
743 )]
744 #[test]
745 fn options_parse_positional_dash_reads_stdin() {
746 let workspace = GuardedPath::tempdir().expect("tempdir");
747 let mut args = vec!["-".to_string()].into_iter();
748 let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
749 assert!(matches!(opts.script, ScriptSource::Stdin));
750 }
751
752 #[cfg_attr(
753 miri,
754 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
755 )]
756 #[test]
757 fn options_parse_rejects_duplicate_script_sources() {
758 let workspace = GuardedPath::tempdir().expect("tempdir");
759 let workspace_root = workspace.as_guarded_path().clone();
760 let mut args = vec![
761 "a.ox".to_string(),
762 "--script".to_string(),
763 "b.ox".to_string(),
764 ]
765 .into_iter();
766 let err = Options::parse(&mut args, &workspace_root)
767 .expect_err("expected duplicate script error");
768 assert!(err.to_string().contains("multiple times"), "{err:?}");
769
770 let mut args = vec!["a.ox".to_string(), "b.ox".to_string()].into_iter();
771 let err = Options::parse(&mut args, &workspace_root)
772 .expect_err("expected duplicate script error");
773 assert!(err.to_string().contains("multiple times"), "{err:?}");
774 }
775
776 #[cfg_attr(
777 miri,
778 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
779 )]
780 #[test]
781 fn options_parse_rejects_unknown_flags() {
782 let workspace = GuardedPath::tempdir().expect("tempdir");
783 let mut args = vec!["--frobnicate".to_string()].into_iter();
784 let err = Options::parse(&mut args, workspace.as_guarded_path())
785 .expect_err("expected unknown flag error");
786 assert!(err.to_string().contains("unexpected flag"), "{err:?}");
787 }
788
789 #[test]
790 fn usage_describes_positional_script_and_help() {
791 let text = usage();
792 assert!(text.contains("Usage: oxdock"), "{text}");
793 assert!(text.contains("SCRIPT"), "{text}");
794 assert!(text.contains("--script"), "{text}");
795 assert!(text.contains("--help"), "{text}");
796 assert!(text.contains("--listen"), "{text}");
797 assert!(text.contains("-p <[host:]outer:inner>"), "{text}");
798 assert!(text.contains("--offline"), "{text}");
799 assert!(text.contains(env!("CARGO_PKG_DESCRIPTION")), "{text}");
801 }
802
803 #[cfg_attr(
804 miri,
805 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
806 )]
807 #[test]
808 fn options_parse_endpoint_flags() {
809 let workspace = GuardedPath::tempdir().expect("tempdir");
812 let mut args = vec![
813 "--listen".to_string(),
814 "0.0.0.0:2251".to_string(),
815 "-p".to_string(),
816 "2222:demo-proxy".to_string(),
817 "-p".to_string(),
818 "0:2252".to_string(),
819 "-".to_string(),
820 ]
821 .into_iter();
822 let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
823 assert_eq!(opts.endpoints.listens.len(), 1);
824 assert_eq!(opts.endpoints.publishes.len(), 2);
825 assert!(!opts.endpoints.offline);
826 assert!(matches!(opts.script, ScriptSource::Stdin));
827 }
828
829 #[cfg_attr(
830 miri,
831 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
832 )]
833 #[test]
834 fn options_parse_offline_flag() {
835 let workspace = GuardedPath::tempdir().expect("tempdir");
836 let mut args = vec!["--offline".to_string(), "-".to_string()].into_iter();
837 let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
838 assert!(opts.endpoints.offline);
839 }
840
841 #[cfg_attr(
842 miri,
843 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
844 )]
845 #[test]
846 fn options_parse_rejects_bad_endpoint_flags() {
847 let workspace = GuardedPath::tempdir().expect("tempdir");
848 for args in [
849 vec!["--listen"],
850 vec!["--listen", "0.0.0.0:0"],
851 vec!["-p"],
852 vec!["-p", "2222"],
853 vec!["-p", "2222:0"],
854 ] {
855 let mut args = args.into_iter().map(str::to_string);
856 Options::parse(&mut args, workspace.as_guarded_path())
857 .expect_err("bad endpoint flag must fail");
858 }
859 }
860
861 #[cfg_attr(
862 miri,
863 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
864 )]
865 #[test]
866 fn options_parse_empty_values_hold_token_boundaries() {
867 let workspace = GuardedPath::tempdir().expect("tempdir");
871 let mut args = vec![
872 "--script".to_string(),
873 "".to_string(),
874 "--shell".to_string(),
875 ]
876 .into_iter();
877 let err = Options::parse(&mut args, workspace.as_guarded_path())
878 .expect_err("empty script path must fail");
879 assert!(
880 err.to_string().contains("--script requires a path"),
881 "{err:?}"
882 );
883 let mut args = vec!["".to_string(), "-".to_string()].into_iter();
884 let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
885 assert!(matches!(opts.script, ScriptSource::Stdin));
886 }
887
888 #[cfg_attr(
889 miri,
890 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
891 )]
892 #[test]
893 fn options_parse_accepts_equals_and_attached_forms() {
894 let workspace = GuardedPath::tempdir().expect("tempdir");
897 let workspace_root = workspace.as_guarded_path().clone();
898 let mut args = vec![
899 "--listen=0.0.0.0:2251".to_string(),
900 "-p2222:demo-proxy".to_string(),
901 "--".to_string(),
902 "script.ox".to_string(),
903 ]
904 .into_iter();
905 let opts = Options::parse(&mut args, &workspace_root).expect("parse");
906 assert_eq!(opts.endpoints.listens.len(), 1);
907 assert_eq!(opts.endpoints.publishes.len(), 1);
908 match opts.script {
909 ScriptSource::Path(path) => {
910 assert_eq!(path, workspace_root.join("script.ox").expect("script path"))
911 }
912 ScriptSource::Stdin => panic!("expected path script after --"),
913 }
914 }
915
916 #[cfg_attr(
917 miri,
918 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
919 )]
920 #[test]
921 fn options_parse_help_returns_usage_error_without_exiting() {
922 let workspace = GuardedPath::tempdir().expect("tempdir");
925 for flag in ["--help", "-h"] {
926 let mut args = vec![flag.to_string()].into_iter();
927 let err = Options::parse(&mut args, workspace.as_guarded_path())
928 .expect_err("help flag must not parse as options");
929 assert_eq!(err.to_string(), usage());
930 }
931 }
932
933 #[cfg_attr(
934 miri,
935 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
936 )]
937 #[test]
938 fn execute_with_result_runs_script() {
939 let workspace = GuardedPath::tempdir().expect("tempdir");
940 let workspace_root = workspace.as_guarded_path().clone();
941 let script_path = workspace_root.join("script.txt").expect("script path");
942 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
943 .expect("resolver");
944 resolver
945 .write_file(&script_path, b"WRITE out.txt hi")
946 .expect("write script");
947 let opts = Options {
948 script: ScriptSource::Path(script_path),
949 shell: false,
950 endpoints: EndpointFlags::default(),
951 };
952 let result = execute_with_result(opts, workspace_root).expect("execute");
953 let snapshot = result
954 .snapshot_path()
955 .expect("default WRITE materializes the snapshot");
956 assert_eq!(snapshot, &result.final_cwd);
957 let temp_resolver = PathResolver::new(snapshot.root(), snapshot.root()).expect("resolver");
958 let out = snapshot.join("out.txt").expect("out path");
959 let contents = temp_resolver.read_to_string(&out).expect("read out");
960 assert_eq!(contents.trim(), "hi");
961 }
962
963 #[cfg_attr(
964 miri,
965 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
966 )]
967 #[test]
968 fn execute_with_result_local_only_creates_no_snapshot() {
969 let workspace = GuardedPath::tempdir().expect("tempdir");
970 let workspace_root = workspace.as_guarded_path().clone();
971 let script_path = workspace_root.join("script.txt").expect("script path");
972 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
973 .expect("resolver");
974 resolver
975 .write_file(&script_path, b"WORKSPACE LOCAL\nWRITE out.txt hi")
976 .expect("write script");
977 let opts = Options {
978 script: ScriptSource::Path(script_path),
979 shell: false,
980 endpoints: EndpointFlags::default(),
981 };
982 let result = execute_with_result(opts, workspace_root.clone()).expect("execute");
983 assert!(
984 !result.has_snapshot(),
985 "WORKSPACE LOCAL-only script must not create a snapshot tempdir"
986 );
987 assert!(result.snapshot_path().is_none());
988 let out = workspace_root.join("out.txt").expect("out path");
990 let contents = resolver.read_to_string(&out).expect("read out");
991 assert_eq!(contents.trim(), "hi");
992 assert_eq!(result.final_cwd.root(), workspace_root.as_path());
994 }
995
996 #[cfg_attr(
997 miri,
998 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
999 )]
1000 #[test]
1001 fn execute_with_result_empty_script_creates_no_snapshot() {
1002 let workspace = GuardedPath::tempdir().expect("tempdir");
1003 let workspace_root = workspace.as_guarded_path().clone();
1004 let script_path = workspace_root.join("empty.txt").expect("script path");
1005 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
1006 .expect("resolver");
1007 resolver
1008 .write_file(&script_path, b"")
1009 .expect("write script");
1010 let opts = Options {
1011 script: ScriptSource::Path(script_path),
1012 shell: false,
1013 endpoints: EndpointFlags::default(),
1014 };
1015 let result = execute_with_result(opts, workspace_root.clone()).expect("execute");
1016 assert!(
1017 !result.has_snapshot(),
1018 "empty script must not create a snapshot tempdir"
1019 );
1020 assert_eq!(result.final_cwd, workspace_root);
1021 }
1022
1023 #[cfg_attr(
1024 miri,
1025 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
1026 )]
1027 #[test]
1028 fn execute_for_test_invokes_shell_runner() -> Result<()> {
1029 let workspace = GuardedPath::tempdir()?;
1030 let workspace_root = workspace.as_guarded_path().clone();
1031 let script_path = workspace_root.join("empty.txt")?;
1032 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
1033 resolver.write_file(&script_path, b"")?;
1034 let opts = Options {
1035 script: ScriptSource::Path(script_path),
1036 shell: true,
1037 endpoints: EndpointFlags::default(),
1038 };
1039 let called = RefCell::new(None::<(String, String)>);
1040 execute_for_test(opts, workspace_root.clone(), |cwd, workspace| {
1041 called.replace(Some((cwd.display(), workspace.display())));
1042 assert!(
1045 cwd.exists(),
1046 "shell cwd must exist on disk, got {}",
1047 cwd.display()
1048 );
1049 Ok(())
1050 })?;
1051 let seen = called.borrow().clone().expect("shell runner called");
1052 assert_eq!(seen.1, workspace_root.display());
1053 Ok(())
1054 }
1055
1056 #[cfg(feature = "ssh")]
1060 #[cfg_attr(
1061 miri,
1062 ignore = "loopback TCP plus threads plus a Tokio runtime; also GuardedPath::tempdir"
1063 )]
1064 #[test]
1065 fn ssh_feature_serves_and_closes() -> Result<()> {
1066 let workspace = GuardedPath::tempdir()?;
1067 let workspace_root = workspace.as_guarded_path().clone();
1068 let script_path = workspace_root.join("ssh-serve.ox")?;
1069 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
1070 let script = indoc! {"
1071 IMPORT [STD, SSH]
1072 LET $m: MAP = SSH_SERVE(\"23301\", {username: \"test\", password: \"test123\"})
1073 SSH_CLOSE($m.server)
1074 "};
1075 resolver.write_file(&script_path, script.as_bytes())?;
1076 let opts = Options {
1077 script: ScriptSource::Path(script_path),
1078 shell: false,
1079 endpoints: EndpointFlags::default(),
1080 };
1081 execute_with_result(opts, workspace_root)?;
1082 Ok(())
1083 }
1084
1085 #[cfg_attr(
1089 miri,
1090 ignore = "loopback TCP plus GuardedPath::tempdir; blocked under Miri isolation"
1091 )]
1092 #[test]
1093 fn net_module_listens_and_closes() -> Result<()> {
1094 let workspace = GuardedPath::tempdir()?;
1095 let workspace_root = workspace.as_guarded_path().clone();
1096 let script_path = workspace_root.join("net-listen.ox")?;
1097 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
1098 let script = indoc! {"
1099 IMPORT [STD, NET]
1100 LET $l: MAP = NET_LISTEN(\"23501\", {})
1101 NET_CLOSE($l.listener)
1102 "};
1103 resolver.write_file(&script_path, script.as_bytes())?;
1104 let opts = Options {
1105 script: ScriptSource::Path(script_path),
1106 shell: false,
1107 endpoints: EndpointFlags::default(),
1108 };
1109 execute_with_result(opts, workspace_root)?;
1110 Ok(())
1111 }
1112
1113 #[cfg(not(feature = "ssh"))]
1116 #[cfg_attr(
1117 miri,
1118 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
1119 )]
1120 #[test]
1121 fn ssh_scripts_rejected_without_feature() -> Result<()> {
1122 let workspace = GuardedPath::tempdir()?;
1123 let workspace_root = workspace.as_guarded_path().clone();
1124 let script_path = workspace_root.join("ssh-serve.ox")?;
1125 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
1126 let script = indoc! {"
1127 IMPORT [STD, SSH]
1128 LET $m: MAP = SSH_SERVE(\"23301\", {username: \"test\", password: \"test123\"})
1129 SSH_CLOSE($m.server)
1130 "};
1131 resolver.write_file(&script_path, script.as_bytes())?;
1132 let opts = Options {
1133 script: ScriptSource::Path(script_path),
1134 shell: false,
1135 endpoints: EndpointFlags::default(),
1136 };
1137 let err = match execute_with_result(opts, workspace_root) {
1138 Ok(_) => panic!("SSH names must be unknown without the feature"),
1139 Err(err) => err,
1140 };
1141 assert!(err.to_string().contains("SSH"), "{err}");
1142 Ok(())
1143 }
1144}
1145
1146#[cfg(all(test, windows))]
1147mod windows_shell_tests {
1148 use super::*;
1149
1150 #[test]
1151 fn command_path_strips_verbatim_prefix() -> Result<()> {
1152 let temp = GuardedPath::tempdir()?;
1153 let converted = oxdock_fs::command_path(temp.as_guarded_path());
1154 let as_str = converted.as_ref().display().to_string();
1155 assert!(
1156 !as_str.starts_with(r"\\?\"),
1157 "expected non-verbatim path, got {as_str}"
1158 );
1159 Ok(())
1160 }
1161}