From 5850fd912da4db4e9c1e7d914e9c2a2eede16cf1 Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Mon, 25 May 2026 13:42:07 -0700 Subject: [PATCH 1/2] Make `sandlock run` a drop-in for `docker run` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sandlock run` now accepts Docker's command-line shape: the first positional is the image and the rest is the command, so `docker run ... IMAGE CMD` can usually be swapped for `sandlock run ... IMAGE CMD` unchanged — the container runs confined by Landlock + seccomp instead of namespaces, with no root and no daemon. CLI changes (sandlock-cli): - First positional = image (Docker mode). A `--` terminator selects native host-command mode (`sandlock run [flags] -- CMD`), preserving full access to the security flags. `--image` stays as an explicit alternative. - Add Docker flags: -v/--volume (HOST:CONTAINER[:ro|:rw]) -> per-sandbox mount + fs access, -w/--workdir -> cwd, -p/--publish -> net-bind + port-remap, -u/--user -> uid, -e/--env (+ --env-file) -> env, --hostname -> sandbox name, --entrypoint, --cpus -> cpu count, --network none|host, -t/--tty, --rm. --detach/--privileged/ --cap-add/--cap-drop/--pull are accepted for compatibility but ignored. - Repurpose colliding short flags to Docker meanings (-t/-e/-p/-w); the native equivalents remain as long options (--timeout, --exec-shell, --profile, --fs-write). The COW storage dir moves from --workdir to --fs-workdir. Image config (sandlock-core): - Add image::inspect_config to read ENTRYPOINT, CMD, WorkingDir, Env and User from the image and apply them like `docker run` (image defaults sit below CLI overrides). The image rootfs stays read-only (rootless sandboxing has no privileged overlay mount); use -v for writable paths or opt into --fs-isolation. Tests: docker-mode arg-splitting, volume/publish/user parsing, image config precedence, and Docker-gated integration tests (skip when no Docker daemon). README documents the drop-in usage and the flag changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 73 ++++- crates/sandlock-cli/src/main.rs | 430 +++++++++++++++++++++++--- crates/sandlock-cli/tests/cli_test.rs | 75 ++++- crates/sandlock-core/src/image.rs | 93 +++++- crates/sandlock-core/src/sandbox.rs | 8 +- 5 files changed, 616 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index b5254fdf..c23e07e3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ and **seccomp user notification** (resource limits, IP enforcement, /proc virtualization). No root, no cgroups, no containers. ``` -sandlock run -w /tmp -r /usr -r /lib -m 512M -- python3 untrusted.py +sandlock run --fs-write /tmp -r /usr -r /lib -m 512M -- python3 untrusted.py ``` ## Why Sandlock? @@ -92,13 +92,13 @@ cargo install --path crates/sandlock-cli ```bash # Basic confinement -sandlock run -r /usr -r /lib -w /tmp -- ls /tmp +sandlock run -r /usr -r /lib --fs-write /tmp -- ls /tmp # Interactive shell -sandlock run -i -r /usr -r /lib -r /lib64 -r /bin -r /etc -w /tmp -- /bin/sh +sandlock run -i -r /usr -r /lib -r /lib64 -r /bin -r /etc --fs-write /tmp -- /bin/sh # Resource limits + timeout -sandlock run -m 512M -P 20 -t 30 -- ./compute.sh +sandlock run -m 512M -P 20 --timeout 30 -- ./compute.sh # Outbound allowlist — restrict to one host on one port sandlock run --net-allow api.openai.com:443 -r /usr -r /lib -r /etc -- python3 agent.py @@ -144,7 +144,7 @@ sandlock run --net-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py # Clean environment sandlock run --clean-env --env CC=gcc \ - -r /usr -r /lib -w /tmp -- make + -r /usr -r /lib --fs-write /tmp -- make # Deterministic execution (frozen time + seeded randomness) sandlock run --time-start "2000-01-01T00:00:00Z" --random-seed 42 -- ./build.sh @@ -166,22 +166,69 @@ sandlock kill web.local sandlock run --chroot ./rootfs --fs-mount /work:/tmp/sandbox/work -- /bin/sh # COW filesystem (writes captured, committed on success) -sandlock run --workdir /opt/project -r /usr -r /lib -- python3 task.py +sandlock run --fs-workdir /opt/project -r /usr -r /lib -- python3 task.py # Dry-run (show what files would change, then discard) -sandlock run --dry-run --workdir . -w . -r /usr -r /lib -r /bin -r /etc -- make build +sandlock run --dry-run --fs-workdir . --fs-write . -r /usr -r /lib -r /bin -r /etc -- make build # Use a saved profile -sandlock run -p build -- make -j4 +sandlock run --profile build -- make -j4 # No-supervisor mode (Landlock + deny-only seccomp, no supervisor process) -sandlock run --no-supervisor -r /usr -r /lib -r /lib64 -r /bin -w /tmp -- ./script.sh +sandlock run --no-supervisor -r /usr -r /lib -r /lib64 -r /bin --fs-write /tmp -- ./script.sh # Nested sandboxing: confine sandlock's own supervisor -sandlock run --no-supervisor -r /proc -r /usr -r /lib -r /lib64 -r /bin -r /etc -w /tmp -- \ - sandlock run -r /usr -w /tmp -- untrusted-command +sandlock run --no-supervisor -r /proc -r /usr -r /lib -r /lib64 -r /bin -r /etc --fs-write /tmp -- \ + sandlock run -r /usr --fs-write /tmp -- untrusted-command ``` +### Docker-compatible CLI + +`sandlock run` is a drop-in for `docker run`: the first positional is the image, +the rest is the command, and the common Docker flags are honoured. In most cases +you can swap `docker run` for `sandlock run` unchanged — the container runs +confined by Landlock + seccomp instead of namespaces, with **no root and no +daemon**. + +```bash +# Just replace `docker` with `sandlock` +sandlock run alpine echo hello +sandlock run -it ubuntu bash +sandlock run -e FOO=bar -w /app -v "$PWD:/app" python:3.12-slim python app.py +sandlock run --entrypoint /bin/sh alpine -c 'echo hi' +sandlock run -p 8080 --network host nginx # publish/allow a port +``` + +The image's baked-in `ENTRYPOINT`, `CMD`, `WorkingDir`, `Env`, and `User` are +applied just like Docker. Images are pulled/extracted via the local Docker +storage and cached under `~/.cache/sandlock/images`. + +> Writable layer: unlike Docker, the image rootfs is mounted **read-only** by +> default (rootless sandboxing has no privileged overlay mount). Use `-v` to +> bind writable host paths into the container, or — where privileges allow — +> `--fs-isolation overlayfs --fs-workdir DIR` for a writable, discarded upper +> layer. + +Supported Docker flags: `-i/--interactive`, `-t/--tty`, `-e/--env`, +`--env-file`, `-v/--volume` (`HOST:CONTAINER[:ro|:rw]`), `-w/--workdir`, +`-p/--publish`, `-u/--user`, `-m/--memory`, `--cpus`, `--name`, `--hostname`, +`--entrypoint`, `--network none|host`, `--rm`. `--detach/-d`, `--privileged`, +`--cap-add/--cap-drop`, and `--pull` are accepted for compatibility but do not +apply to the sandbox model. + +To sandbox a **host** command (no image), put it after `--`; this is the native +mode and keeps full access to the security flags below: + +```bash +sandlock run --fs-write /tmp -r /usr -r /lib -- ./script.sh +``` + +> Note: because the first positional is the image, the colliding short flags now +> follow Docker (`-t` = tty, `-e` = env, `-p` = publish, `-w` = workdir). The +> sandlock-native equivalents remain available as long options (`--timeout`, +> `--exec-shell`, `--profile`, `--fs-write`), and the COW storage directory moved +> from `--workdir` to `--fs-workdir`. + ### Python API ```python @@ -425,8 +472,8 @@ extra_deny = [] ```bash sandlock profile list sandlock profile show build -sandlock run -p build # uses [program].exec + args -sandlock run -p build -- make test # trailing command overrides [program] +sandlock run --profile build # uses [program].exec + args +sandlock run --profile build -- make test # trailing command overrides [program] ``` ## How It Works diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 597e997d..16278d21 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -18,7 +18,7 @@ struct Cli { #[derive(Subcommand)] enum Command { /// Run a command in a sandbox - Run(RunArgs), + Run(Box), /// Check kernel feature support Check, /// List all running sandboxes @@ -37,9 +37,14 @@ enum Command { /// Arguments for the `run` subcommand. /// +/// `sandlock run` is a drop-in for `docker run`: the first positional is the +/// image, the rest is the command, and the common Docker flags below are +/// honoured. To sandbox a *host* command (no image) put it after `--`, e.g. +/// `sandlock run --fs-write /tmp -- ./script.sh`. +/// /// Sandbox-level flags come from `SandboxBuilder` via `#[clap(flatten)]`. -/// CLI-only flags (profile, timeout, image, etc.) and non-clap-friendly -/// sandbox fields (max_memory, fs_mount, env, gpu, cpu-cores) remain here. +/// CLI-only flags and non-clap-friendly sandbox fields (max_memory, fs_mount, +/// env, gpu, cpu-cores) remain here. #[derive(clap::Args)] struct RunArgs { // ── Sandbox flags (flattened from SandboxBuilder) ─────────────────────── @@ -47,7 +52,8 @@ struct RunArgs { sandbox_builder: SandboxBuilder, // ── Sandbox-builder fields that need special parsing (not in SandboxBuilder's clap derive) ── - #[arg(short = 'm', long = "max-memory")] + /// Memory limit, e.g. 512M, 1G (Docker: -m/--memory) + #[arg(short = 'm', long = "memory", visible_alias = "max-memory", value_name = "SIZE")] max_memory: Option, #[arg(long = "max-disk")] @@ -63,7 +69,8 @@ struct RunArgs { #[arg(long = "fs-mount", value_name = "VIRTUAL:HOST")] fs_mount: Vec, - #[arg(long = "env", value_name = "KEY=VALUE")] + /// Set an environment variable (Docker: -e/--env) + #[arg(short = 'e', long = "env", value_name = "KEY=VALUE")] env_vars: Vec, /// CPU cores to pin the sandbox to (e.g. --cpu-cores 0,2,3) @@ -74,11 +81,69 @@ struct RunArgs { #[arg(long = "gpu", value_delimiter = ',')] gpu_devices: Vec, + // ── Docker-compatible flags ─────────────────────────────────────────────── + /// Bind mount a volume: HOST:CONTAINER[:ro|:rw] (Docker: -v/--volume) + #[arg(short = 'v', long = "volume", value_name = "HOST:CONTAINER")] + volume: Vec, + + /// Working directory inside the container (Docker: -w/--workdir) + #[arg(short = 'w', long = "workdir", value_name = "DIR")] + docker_workdir: Option, + + /// Publish/allow a port: [HOST:]CONTAINER (Docker: -p/--publish) + #[arg(short = 'p', long = "publish", value_name = "PORT")] + publish: Vec, + + /// Username or UID to run as (Docker: -u/--user) + #[arg(short = 'u', long = "user", value_name = "UID")] + user: Option, + + /// Read environment variables from a file (Docker: --env-file) + #[arg(long = "env-file", value_name = "PATH")] + env_file: Vec, + + /// Container hostname; also used as the sandbox name (Docker: --hostname) + #[arg(long = "hostname", value_name = "NAME")] + hostname: Option, + + /// Override the image ENTRYPOINT (Docker: --entrypoint) + #[arg(long = "entrypoint", value_name = "CMD")] + entrypoint: Option, + + /// Number of CPUs (Docker: --cpus) + #[arg(long = "cpus", value_name = "N")] + cpus: Option, + + /// Network mode: `none` (default deny) or `host` (allow all egress) (Docker: --network) + #[arg(long = "network", visible_alias = "net", value_name = "MODE")] + network: Option, + + /// Allocate a pseudo-TTY; implies --interactive (Docker: -t/--tty) + #[arg(short = 't', long = "tty")] + tty: bool, + + /// Accepted for Docker compatibility; sandboxes are always ephemeral (Docker: --rm) + #[arg(long = "rm")] + rm: bool, + + /// Accepted for Docker compatibility but ignored: --detach/-d, --privileged, + /// --cap-add, --cap-drop, --pull are not supported by the sandbox model. + #[arg(short = 'd', long = "detach")] + detach: bool, + #[arg(long = "privileged")] + privileged: bool, + #[arg(long = "cap-add", value_name = "CAP")] + cap_add: Vec, + #[arg(long = "cap-drop", value_name = "CAP")] + cap_drop: Vec, + #[arg(long = "pull", value_name = "POLICY")] + pull: Option, + // ── CLI-only flags ─────────────────────────────────────────────────────── - #[arg(short = 't', long)] + #[arg(long = "timeout")] timeout: Option, - #[arg(short = 'p', long, conflicts_with = "profile_file")] + #[arg(long = "profile", conflicts_with = "profile_file")] profile: Option, /// Load a profile directly from a file path (TOML format) @@ -92,13 +157,14 @@ struct RunArgs { #[arg(long)] name: Option, - #[arg(short = 'e', long = "exec-shell", value_name = "CMD")] + #[arg(long = "exec-shell", value_name = "CMD")] exec_shell: Option, #[arg(short = 'i', long)] interactive: bool, - /// Use a local Docker image as chroot rootfs + /// Use a local Docker/OCI image as the rootfs. Usually given as the first + /// positional argument (Docker style); this flag is an explicit alternative. #[arg(long)] image: Option, @@ -110,10 +176,97 @@ struct RunArgs { #[arg(long)] no_supervisor: bool, - #[arg(last = true)] + /// `IMAGE [COMMAND] [ARG...]` (Docker style), or — after `--` — a host + /// command to sandbox with no image. + #[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "IMAGE | COMMAND")] + image_and_cmd: Vec, +} + +/// Resolved run target: which image to use (if any) and the command to run. +struct RunTarget { + image: Option, cmd: Vec, } +/// Did the invocation use the `--` options terminator? When present, the +/// trailing positionals are a host command (native mode) rather than a Docker +/// `IMAGE [CMD...]` sequence. +fn had_options_terminator() -> bool { + std::env::args().any(|a| a == "--") +} + +/// Decide whether we are running a Docker image or a host command, and split +/// the trailing positionals accordingly. +/// +/// * `--image IMG` → image mode; positionals are the command. +/// * `... -- CMD` → native mode; positionals are the host command, no image. +/// * `IMG [CMD...]` → Docker mode; first positional is the image. +fn resolve_run_target(args: &RunArgs) -> RunTarget { + split_run_target(args.image.as_deref(), &args.image_and_cmd, had_options_terminator()) +} + +/// Pure core of [`resolve_run_target`], split out for testing. +fn split_run_target(image_flag: Option<&str>, positionals: &[String], had_terminator: bool) -> RunTarget { + if let Some(img) = image_flag { + return RunTarget { image: Some(img.to_string()), cmd: positionals.to_vec() }; + } + if had_terminator { + return RunTarget { image: None, cmd: positionals.to_vec() }; + } + match positionals.split_first() { + Some((first, rest)) => RunTarget { image: Some(first.clone()), cmd: rest.to_vec() }, + None => RunTarget { image: None, cmd: Vec::new() }, + } +} + +/// A parsed Docker `-v/--volume` spec. +struct Volume { + host: PathBuf, + container: PathBuf, + read_only: bool, +} + +/// Parse a Docker volume spec `HOST:CONTAINER[:ro|:rw]`. Relative host paths are +/// resolved against the current directory (as `docker` does for bind mounts). +fn parse_volume(spec: &str) -> Result { + let parts: Vec<&str> = spec.split(':').collect(); + let (host, container, mode) = match parts.as_slice() { + [h, c] => (*h, *c, "rw"), + [h, c, m] => (*h, *c, *m), + _ => return Err(anyhow!( + "-v/--volume requires HOST:CONTAINER[:ro|:rw], got: {}", spec + )), + }; + let read_only = match mode { + "ro" => true, + "rw" => false, + other => return Err(anyhow!("volume mode must be ro or rw, got: {}", other)), + }; + let host = if host.starts_with('/') { + PathBuf::from(host) + } else { + std::env::current_dir()?.join(host) + }; + Ok(Volume { host, container: PathBuf::from(container), read_only }) +} + +/// Parse a Docker `-p/--publish` spec `[IP:][HOST:]CONTAINER[/proto]`, returning +/// the container port (the port the process binds inside the sandbox). +fn parse_publish(spec: &str) -> Result { + let without_proto = spec.split('/').next().unwrap_or(spec); + let container_port = without_proto.rsplit(':').next().unwrap_or(without_proto); + container_port + .parse::() + .map_err(|_| anyhow!("invalid --publish port in {:?}", spec)) +} + +/// Parse a Docker `-u/--user` value (`UID` or `UID:GID`, or a `name`), returning +/// the numeric UID if it is numeric. Names cannot be resolved without the +/// image's /etc/passwd, so they yield `None`. +fn parse_user(user: &str) -> Option { + user.split(':').next().unwrap_or(user).parse::().ok() +} + #[derive(Subcommand)] enum ProfileAction { /// List available profiles @@ -136,7 +289,7 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Command::Run(args) => run_command(args).await?, + Command::Run(args) => run_command(*args).await?, Command::List => { match network_registry::list() { @@ -247,7 +400,28 @@ async fn main() -> Result<()> { async fn run_command(args: RunArgs) -> Result<()> { let pb = &args.sandbox_builder; + // Warn on Docker flags we accept but cannot honour. + if args.detach { + return Err(anyhow!("--detach/-d is not supported: sandlock runs in the foreground")); + } + for (set, flag) in [ + (args.privileged, "--privileged"), + (!args.cap_add.is_empty(), "--cap-add"), + (!args.cap_drop.is_empty(), "--cap-drop"), + (args.pull.is_some(), "--pull"), + ] { + if set { + eprintln!("sandlock: warning: {} is ignored (no Docker compatibility for it)", flag); + } + } + + // Resolve Docker-style `IMAGE [CMD...]` vs native `-- CMD` up front. + let target = resolve_run_target(&args); + if args.no_supervisor { + if target.image.is_some() { + return Err(anyhow!("--no-supervisor is incompatible with running an image")); + } validate_no_supervisor(&args)?; // Load profile once (if any) and split into policy base + program spec. @@ -284,8 +458,8 @@ async fn run_command(args: RunArgs) -> Result<()> { // Derive the effective command: profile's [program] section supplies the // default; a trailing positional command on the CLI overrides it. - let effective_cmd: Vec = if !args.cmd.is_empty() || args.exec_shell.is_some() { - args.cmd.clone() + let effective_cmd: Vec = if !target.cmd.is_empty() || args.exec_shell.is_some() { + target.cmd.clone() } else if let Some(spec) = ns_profile_spec { if let Some(exec) = spec.exec { let exec_str = exec.into_os_string().into_string() @@ -307,11 +481,22 @@ async fn run_command(args: RunArgs) -> Result<()> { if !pb.extra_allow_syscalls.is_empty() { builder = builder.extra_allow_syscalls(pb.extra_allow_syscalls.clone()); } if !pb.extra_deny_syscalls.is_empty() { builder = builder.extra_deny_syscalls(pb.extra_deny_syscalls.clone()); } if pb.clean_env { builder = builder.clean_env(true); } + for path in &args.env_file { + let content = std::fs::read_to_string(path) + .map_err(|e| anyhow!("failed to read --env-file {}: {}", path.display(), e))?; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { continue; } + if let Some((k, v)) = line.split_once('=') { + builder = builder.env_var(k.trim(), v); + } + } + } for spec in &args.env_vars { if let Some((k, v)) = spec.split_once('=') { builder = builder.env_var(k, v); - } else { - return Err(anyhow!("--env requires KEY=VALUE, got: {}", spec)); + } else if let Ok(v) = std::env::var(spec) { + builder = builder.env_var(spec, v); } } @@ -476,33 +661,126 @@ async fn run_command(args: RunArgs) -> Result<()> { } if !args.cpu_cores.is_empty() { builder = builder.cpu_cores(args.cpu_cores.clone()); } if !args.gpu_devices.is_empty() { builder = builder.gpu_devices(args.gpu_devices.clone()); } + // ── Image: extract rootfs, then layer in the image's baked-in config ────── + // (env, working dir, user) the way `docker run` does. The image's defaults + // sit at the lowest precedence; the CLI flags below override them. + let image_config = if let Some(ref img) = target.image { + let rootfs = sandlock_core::image::extract(img, None)?; + builder = builder.chroot(rootfs).fs_read("/"); + Some(sandlock_core::image::inspect_config(img)?) + } else { + None + }; + let mut effective_cwd: Option = None; + let mut effective_uid: Option = None; + if let Some(ref cfg) = image_config { + for kv in &cfg.env { + if let Some((k, v)) = kv.split_once('=') { builder = builder.env_var(k, v); } + } + effective_cwd = cfg.workdir.clone(); + effective_uid = cfg.user.as_deref().and_then(parse_user); + } + + // ── Environment: --env-file, then -e/--env (later wins, like Docker) ────── + for path in &args.env_file { + let content = std::fs::read_to_string(path) + .map_err(|e| anyhow!("failed to read --env-file {}: {}", path.display(), e))?; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { continue; } + if let Some((k, v)) = line.split_once('=') { + builder = builder.env_var(k.trim(), v); + } + } + } for spec in &args.env_vars { if let Some((k, v)) = spec.split_once('=') { builder = builder.env_var(k, v); - } else { - return Err(anyhow!("--env requires KEY=VALUE, got: {}", spec)); + } else if let Ok(v) = std::env::var(spec) { + // Docker: `-e VAR` (no `=value`) forwards the host's value. + builder = builder.env_var(spec, v); } } - let sandbox_name = args.name.clone().unwrap_or_else(|| network_registry::next_name()); - - // Handle --image: extract rootfs, set chroot, get default cmd - let image_cmd: Option>; - if let Some(ref img) = args.image { - let rootfs = sandlock_core::image::extract(img, None)?; - builder = builder.chroot(rootfs).fs_read("/"); - if args.cmd.is_empty() { - image_cmd = Some(sandlock_core::image::inspect_cmd(img)?); + // ── Docker -v/--volume → per-sandbox mount + filesystem access ──────────── + for spec in &args.volume { + let vol = parse_volume(spec)?; + builder = builder.fs_mount(&vol.container, &vol.host); + builder = if vol.read_only { + builder.fs_read(&vol.container) } else { - image_cmd = None; + builder.fs_write(&vol.container) + }; + } + + // ── Docker -w/--workdir and -u/--user (override image defaults) ─────────── + if let Some(ref wd) = args.docker_workdir { effective_cwd = Some(wd.clone()); } + if let Some(ref u) = args.user { + effective_uid = Some(parse_user(u) + .ok_or_else(|| anyhow!("--user must be a numeric UID or UID:GID, got: {}", u))?); + } + if let Some(ref wd) = effective_cwd { builder = builder.cwd(wd); } + if let Some(uid) = effective_uid { builder = builder.uid(uid); } + + // ── Docker -p/--publish → allow binding + port virtualization ───────────── + if !args.publish.is_empty() { + for spec in &args.publish { + builder = builder.net_bind_port(parse_publish(spec)?); } - } else { - image_cmd = None; + builder = builder.port_remap(true); } + // ── Docker --network (none = default deny, host = allow all egress) ─────── + if let Some(ref mode) = args.network { + match mode.as_str() { + "none" => {} + "host" => { builder = builder.net_allow(":*").net_allow("udp://*:*"); } + other => return Err(anyhow!("unsupported --network mode: {} (use none or host)", other)), + } + } + + // ── Docker --cpus → CPU count ───────────────────────────────────────────── + if let Some(n) = args.cpus { + if n <= 0.0 { return Err(anyhow!("--cpus must be positive, got: {}", n)); } + builder = builder.max_cpu(n.ceil() as u8); + } + + // Sandbox name: --name, else --hostname, else auto-generated. + let sandbox_name = args.name.clone() + .or_else(|| args.hostname.clone()) + .unwrap_or_else(network_registry::next_name); + + // ── Resolve the command ─────────────────────────────────────────────────── + // Precedence: --exec-shell > CLI command > image ENTRYPOINT/CMD > profile. + // --entrypoint overrides the image ENTRYPOINT for both forms. + let cli_cmd: Option> = if !target.cmd.is_empty() { + Some(match &args.entrypoint { + Some(ep) => { + let mut v = vec![ep.clone()]; + v.extend(target.cmd.clone()); + v + } + None => target.cmd.clone(), + }) + } else { + None + }; + let image_cmd: Option> = match (&image_config, cli_cmd.is_some()) { + (Some(cfg), false) => Some(match &args.entrypoint { + // Override ENTRYPOINT but keep the image's default CMD as arguments. + Some(ep) => { + let mut v = vec![ep.clone()]; + if let Some(ref c) = cfg.cmd { v.extend(c.clone()); } + v + } + None => cfg.default_command(), + }), + _ => None, + }; + // Derive the effective command: profile's [program] section supplies a - // default; a trailing positional command on the CLI overrides it. - let profile_cmd: Option> = if args.cmd.is_empty() && args.exec_shell.is_none() && image_cmd.is_none() { + // default; a CLI command, an image, or --exec-shell overrides it. + let profile_cmd: Option> = if cli_cmd.is_none() && image_cmd.is_none() && args.exec_shell.is_none() { if let Some(spec) = profile_program_spec { if let Some(exec) = spec.exec { let exec_str = exec.into_os_string().into_string() @@ -520,17 +798,17 @@ async fn run_command(args: RunArgs) -> Result<()> { None }; - if args.exec_shell.is_none() && args.cmd.is_empty() && image_cmd.is_none() && profile_cmd.is_none() { - return Err(anyhow!("no command specified (no trailing command and no [program].exec in profile)")); + if args.exec_shell.is_none() && cli_cmd.is_none() && image_cmd.is_none() && profile_cmd.is_none() { + return Err(anyhow!("no command specified (no image, no trailing command, and no [program].exec in profile)")); } let policy = builder.build()?; let cmd_strs: Vec<&str> = if let Some(ref shell_cmd) = args.exec_shell { vec!["/bin/sh", "-c", shell_cmd.as_str()] + } else if let Some(ref cc) = cli_cmd { + cc.iter().map(|s| s.as_str()).collect() } else if let Some(ref ic) = image_cmd { ic.iter().map(|s| s.as_str()).collect() - } else if !args.cmd.is_empty() { - args.cmd.iter().map(|s| s.as_str()).collect() } else if let Some(ref pc) = profile_cmd { pc.iter().map(|s| s.as_str()).collect() } else { @@ -679,6 +957,14 @@ fn validate_no_supervisor(args: &RunArgs) -> Result<()> { if args.status_fd.is_some() { bad.push("--status-fd"); } if !pb.fs_denied.is_empty() { bad.push("--fs-deny"); } if !args.fs_mount.is_empty() { bad.push("--fs-mount"); } + // Docker-compatible flags that map onto supervisor-only features. + if !args.volume.is_empty() { bad.push("--volume"); } + if !args.publish.is_empty() { bad.push("--publish"); } + if args.docker_workdir.is_some() { bad.push("--workdir"); } + if args.user.is_some() { bad.push("--user"); } + if args.hostname.is_some() { bad.push("--hostname"); } + if args.network.is_some() { bad.push("--network"); } + if args.cpus.is_some() { bad.push("--cpus"); } if !bad.is_empty() { return Err(anyhow!( @@ -837,3 +1123,77 @@ fn parse_time_start(s: &str) -> Result { .map_err(|e| anyhow!("invalid --time-start '{}': {}", s, e))?; Ok(ts.into()) } + +#[cfg(test)] +mod docker_compat_tests { + use super::*; + + fn s(v: &[&str]) -> Vec { + v.iter().map(|x| x.to_string()).collect() + } + + #[test] + fn docker_mode_first_positional_is_image() { + // `sandlock run ubuntu bash` → image=ubuntu, cmd=[bash] + let t = split_run_target(None, &s(&["ubuntu", "bash"]), false); + assert_eq!(t.image.as_deref(), Some("ubuntu")); + assert_eq!(t.cmd, s(&["bash"])); + } + + #[test] + fn docker_mode_image_only_uses_default_cmd() { + let t = split_run_target(None, &s(&["python:3.12"]), false); + assert_eq!(t.image.as_deref(), Some("python:3.12")); + assert!(t.cmd.is_empty()); + } + + #[test] + fn terminator_selects_native_host_command() { + // `sandlock run --fs-write /tmp -- echo hi` → no image, cmd=[echo, hi] + let t = split_run_target(None, &s(&["echo", "hi"]), true); + assert_eq!(t.image, None); + assert_eq!(t.cmd, s(&["echo", "hi"])); + } + + #[test] + fn explicit_image_flag_takes_all_positionals_as_cmd() { + let t = split_run_target(Some("alpine"), &s(&["sh", "-c", "true"]), true); + assert_eq!(t.image.as_deref(), Some("alpine")); + assert_eq!(t.cmd, s(&["sh", "-c", "true"])); + } + + #[test] + fn parse_volume_forms() { + let cwd = std::env::current_dir().unwrap(); + let v = parse_volume("/data:/app").unwrap(); + assert_eq!(v.host, std::path::PathBuf::from("/data")); + assert_eq!(v.container, std::path::PathBuf::from("/app")); + assert!(!v.read_only); + + let v = parse_volume("/data:/app:ro").unwrap(); + assert!(v.read_only); + + // relative host path is resolved against cwd + let v = parse_volume("src:/app").unwrap(); + assert_eq!(v.host, cwd.join("src")); + + assert!(parse_volume("/app").is_err()); + assert!(parse_volume("/h:/c:bogus").is_err()); + } + + #[test] + fn parse_publish_extracts_container_port() { + assert_eq!(parse_publish("8080").unwrap(), 8080); + assert_eq!(parse_publish("8080:80").unwrap(), 80); + assert_eq!(parse_publish("127.0.0.1:8080:80").unwrap(), 80); + assert_eq!(parse_publish("80/tcp").unwrap(), 80); + assert!(parse_publish("notaport").is_err()); + } + + #[test] + fn parse_user_numeric_only() { + assert_eq!(parse_user("1000"), Some(1000)); + assert_eq!(parse_user("1000:1000"), Some(1000)); + assert_eq!(parse_user("node"), None); + } +} diff --git a/crates/sandlock-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index cb1058d6..685dec63 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -176,7 +176,7 @@ fn test_no_supervisor_rejects_incompatible_flags() { #[test] fn test_no_supervisor_writable_path() { let output = sandlock_bin() - .args(["run", "--no-supervisor", "-r", "/usr", "-r", "/lib", "-r", "/lib64", "-r", "/bin", "-w", "/tmp", "--", + .args(["run", "--no-supervisor", "-r", "/usr", "-r", "/lib", "-r", "/lib64", "-r", "/bin", "--fs-write", "/tmp", "--", "sh", "-c", "echo no-supervisor-write > /tmp/sandlock-no-supervisor-test && cat /tmp/sandlock-no-supervisor-test"]) .output() .expect("failed to run"); @@ -193,7 +193,7 @@ fn test_no_supervisor_nested_sandbox() { let output = sandlock_bin() .args(["run", "--no-supervisor", "-r", "/usr", "-r", "/lib", "-r", "/lib64", "-r", "/bin", "-r", "/etc", - "-r", "/proc", "-r", "/dev", "-w", "/tmp", + "-r", "/proc", "-r", "/dev", "--fs-write", "/tmp", "-r", sandlock_dir, "--", sandlock_path, "run", "-r", "/usr", "-r", "/lib", "-r", "/lib64", "-r", "/bin", "-r", "/etc", @@ -214,3 +214,74 @@ fn test_no_supervisor_exit_code() { assert_eq!(output.status.code(), Some(42)); } + +// ── Docker CLI compatibility ───────────────────────────────────────────────── + +/// True when the local Docker daemon can be reached and `alpine:latest` is +/// available. Docker-mode tests skip (pass as no-ops) otherwise so CI without a +/// Docker daemon stays green. +fn docker_alpine_available() -> bool { + Command::new("docker") + .args(["image", "inspect", "alpine:latest"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +#[test] +fn docker_help_lists_compat_flags() { + let output = sandlock_bin().args(["run", "--help"]).output().expect("run --help"); + let stdout = String::from_utf8_lossy(&output.stdout); + for flag in ["--volume", "--publish", "--workdir", "--user", "--network", "--tty", "--entrypoint"] { + assert!(stdout.contains(flag), "run --help should mention {}", flag); + } +} + +#[test] +fn docker_run_image_positional() { + if !docker_alpine_available() { eprintln!("skipping: alpine not available"); return; } + let output = sandlock_bin() + .args(["run", "alpine", "echo", "hello-docker"]) + .output() + .expect("failed to run image"); + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).contains("hello-docker")); +} + +#[test] +fn docker_run_env_and_workdir() { + if !docker_alpine_available() { eprintln!("skipping: alpine not available"); return; } + let output = sandlock_bin() + .args(["run", "-e", "FOO=bar", "-w", "/tmp", "alpine", "sh", "-c", "echo $FOO@$(pwd)"]) + .output() + .expect("failed to run"); + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "bar@/tmp"); +} + +#[test] +fn docker_run_applies_image_default_path() { + if !docker_alpine_available() { eprintln!("skipping: alpine not available"); return; } + // The image's baked-in PATH should be applied even without --env. + let output = sandlock_bin() + .args(["run", "alpine", "sh", "-c", "echo $PATH"]) + .output() + .expect("failed to run"); + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).contains("/usr/bin")); +} + +#[test] +fn docker_run_volume_readonly() { + if !docker_alpine_available() { eprintln!("skipping: alpine not available"); return; } + let dir = std::env::temp_dir().join("sandlock-vol-it"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("f.txt"), "mounted").unwrap(); + let output = sandlock_bin() + .args(["run", "-v", &format!("{}:/mnt:ro", dir.display()), "alpine", "cat", "/mnt/f.txt"]) + .output() + .expect("failed to run"); + let _ = std::fs::remove_dir_all(&dir); + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).contains("mounted")); +} diff --git a/crates/sandlock-core/src/image.rs b/crates/sandlock-core/src/image.rs index 0fc62095..eb778d2a 100644 --- a/crates/sandlock-core/src/image.rs +++ b/crates/sandlock-core/src/image.rs @@ -128,31 +128,78 @@ fn extract_container(container_id: &str, rootfs: &Path) -> Result<(), SandlockEr /// /// Returns the combined entrypoint and cmd, or `["/bin/sh"]` if none configured. pub fn inspect_cmd(image: &str) -> Result, SandlockError> { + Ok(inspect_config(image)?.default_command()) +} + +/// The subset of an image's `Config` that sandlock applies when running it, +/// mirroring how `docker run` derives defaults from the image. +#[derive(Debug, Default, Clone)] +pub struct ImageConfig { + /// `Config.Entrypoint` — prepended to the command. + pub entrypoint: Option>, + /// `Config.Cmd` — the default command (overridden by a CLI command). + pub cmd: Option>, + /// `Config.WorkingDir` — the default working directory. + pub workdir: Option, + /// `Config.Env` — baked-in environment as `KEY=VALUE` pairs. + pub env: Vec, + /// `Config.User` — default user (name or uid[:gid]). + pub user: Option, +} + +impl ImageConfig { + /// Combined ENTRYPOINT + CMD, falling back to `/bin/sh` when neither is set. + pub fn default_command(&self) -> Vec { + match (&self.entrypoint, &self.cmd) { + (Some(ep), Some(c)) => [ep.clone(), c.clone()].concat(), + (Some(ep), None) => ep.clone(), + (None, Some(c)) => c.clone(), + (None, None) => vec!["/bin/sh".into()], + } + } +} + +/// Inspect a local Docker image's `Config`, returning the fields sandlock maps +/// onto its sandbox (entrypoint, cmd, working dir, env, user). +/// +/// On any inspection failure this returns a default config so callers can fall +/// back to running `/bin/sh`. +pub fn inspect_config(image: &str) -> Result { + // One field per line keeps parsing simple and avoids delimiter clashes with + // values that may themselves contain `|`. let output = Command::new("docker") .args([ "inspect", "--format", - "{{json .Config.Entrypoint}}|{{json .Config.Cmd}}", + "{{json .Config.Entrypoint}}\n{{json .Config.Cmd}}\n\ + {{json .Config.WorkingDir}}\n{{json .Config.Env}}\n{{json .Config.User}}", image, ]) .output() .map_err(|_| SandboxRuntimeError::Child("docker inspect failed".into()))?; if !output.status.success() { - return Ok(vec!["/bin/sh".into()]); + return Ok(ImageConfig::default()); } - let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let parts: Vec<&str> = raw.splitn(2, '|').collect(); + let raw = String::from_utf8_lossy(&output.stdout); + let mut lines = raw.lines(); - let entrypoint = parts.first().and_then(|s| parse_json_string_array(s)); - let cmd = parts.get(1).and_then(|s| parse_json_string_array(s)); + let entrypoint = lines.next().and_then(parse_json_string_array); + let cmd = lines.next().and_then(parse_json_string_array); + let workdir = lines.next().and_then(parse_json_string).filter(|s| !s.is_empty()); + let env = lines.next().and_then(parse_json_string_array).unwrap_or_default(); + let user = lines.next().and_then(parse_json_string).filter(|s| !s.is_empty()); - match (entrypoint, cmd) { - (Some(ep), Some(c)) => Ok([ep, c].concat()), - (Some(ep), None) => Ok(ep), - (None, Some(c)) => Ok(c), - (None, None) => Ok(vec!["/bin/sh".into()]), + Ok(ImageConfig { entrypoint, cmd, workdir, env, user }) +} + +/// Parse a JSON string literal like `"abc"` (or `null`) into its value. +fn parse_json_string(s: &str) -> Option { + let s = s.trim(); + if s == "null" || s.len() < 2 || !s.starts_with('"') || !s.ends_with('"') { + return None; } + Some(s[1..s.len() - 1].replace("\\\"", "\"").replace("\\\\", "\\")) } /// Parse a JSON string array like `["a","b"]` or return None for `null`. @@ -231,4 +278,28 @@ mod tests { Some(vec!["/bin/sh".into()]) ); } + + #[test] + fn test_parse_json_string() { + assert_eq!(parse_json_string(r#""/app""#), Some("/app".into())); + assert_eq!(parse_json_string("null"), None); + assert_eq!(parse_json_string(r#""""#), Some(String::new())); + assert_eq!(parse_json_string(r#""a\"b""#), Some("a\"b".into())); + } + + #[test] + fn test_default_command_precedence() { + let cfg = ImageConfig { + entrypoint: Some(vec!["/entry".into()]), + cmd: Some(vec!["arg".into()]), + ..Default::default() + }; + assert_eq!(cfg.default_command(), vec!["/entry".to_string(), "arg".into()]); + + let cmd_only = ImageConfig { cmd: Some(vec!["bash".into()]), ..Default::default() }; + assert_eq!(cmd_only.default_command(), vec!["bash".to_string()]); + + let empty = ImageConfig::default(); + assert_eq!(empty.default_command(), vec!["/bin/sh".to_string()]); + } } diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index cfbee29c..160f6766 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -1765,7 +1765,9 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(short = 'r', long = "fs-read", value_name = "PATH"))] pub fs_readable: Vec, - #[cfg_attr(feature = "cli", arg(short = 'w', long = "fs-write", value_name = "PATH"))] + // NOTE: `-w` is intentionally not a short alias here; the CLI reserves `-w` + // for Docker-compatible `--workdir`. Use the long `--fs-write` form. + #[cfg_attr(feature = "cli", arg(long = "fs-write", value_name = "PATH"))] pub fs_writable: Vec, #[cfg_attr(feature = "cli", arg(long = "fs-deny", value_name = "PATH"))] @@ -1843,7 +1845,9 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", clap(skip))] pub fs_isolation: Option, - #[cfg_attr(feature = "cli", arg(long = "workdir"))] + // `--workdir` is reserved for Docker-compatible working-directory (mapped to + // `cwd`); the COW/overlay storage directory uses `--fs-workdir`. + #[cfg_attr(feature = "cli", arg(long = "fs-workdir"))] pub workdir: Option, #[cfg_attr(feature = "cli", arg(long = "cwd"))] From fdc934c8e67f3b66c2fba91c744a27db3640bf76 Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Mon, 25 May 2026 20:38:22 -0700 Subject: [PATCH 2/2] Address PR review: robust -- handling, image precedence, JSON parsing - image.rs: parse `docker inspect` output with serde_json so all JSON escapes are decoded correctly; fall back to a default ImageConfig when `docker` cannot be executed (matching the docstring). - main.rs: detect native (host-command) mode by a *leading* `--` terminator only, so `sandlock run IMAGE -- args` keeps IMAGE as the image instead of misclassifying it as a host command. - main.rs: layer image defaults below the profile (cwd/uid/env) so a profile's settings are no longer clobbered by image defaults; CLI flags still win. - main.rs: reject non-finite/out-of-range --cpus values before casting. - main.rs: warn instead of silently dropping `-e VAR` when VAR is unset in the host environment (both supervisor and no-supervisor paths). - main.rs: report the storage-dir flag as --fs-workdir in the --no-supervisor incompatibility message. - sandbox.rs: give --fs-write the uppercase short flag -W. Co-Authored-By: Claude Opus 4.7 --- README.md | 4 +- crates/sandlock-cli/src/main.rs | 82 +++++++++++++++++++++++------ crates/sandlock-core/src/image.rs | 44 ++++++---------- crates/sandlock-core/src/sandbox.rs | 6 +-- 4 files changed, 87 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index c23e07e3..543a08b9 100644 --- a/README.md +++ b/README.md @@ -226,8 +226,8 @@ sandlock run --fs-write /tmp -r /usr -r /lib -- ./script.sh > Note: because the first positional is the image, the colliding short flags now > follow Docker (`-t` = tty, `-e` = env, `-p` = publish, `-w` = workdir). The > sandlock-native equivalents remain available as long options (`--timeout`, -> `--exec-shell`, `--profile`, `--fs-write`), and the COW storage directory moved -> from `--workdir` to `--fs-workdir`. +> `--exec-shell`, `--profile`), `--fs-write` keeps a short flag as the uppercase +> `-W`, and the COW storage directory moved from `--workdir` to `--fs-workdir`. ### Python API diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 16278d21..b519505e 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -188,11 +188,20 @@ struct RunTarget { cmd: Vec, } -/// Did the invocation use the `--` options terminator? When present, the -/// trailing positionals are a host command (native mode) rather than a Docker -/// `IMAGE [CMD...]` sequence. -fn had_options_terminator() -> bool { - std::env::args().any(|a| a == "--") +/// Did the invocation use a *leading* `--` options terminator — one that came +/// before any positional argument? That selects native (host-command) mode. +/// +/// clap strips this leading `--` and leaves the trailing positionals as exactly +/// the argv tokens that followed it, so we detect it by checking whether the +/// positionals equal the argv tail after the first `--`. A `--` that appears +/// *after* the image (Docker's `IMAGE -- args`) is instead retained by clap +/// inside the positionals, so it does not match here and stays in Docker mode. +fn leading_terminator(positionals: &[String]) -> bool { + let argv: Vec = std::env::args().collect(); + match argv.iter().position(|a| a == "--") { + Some(t) => argv[t + 1..] == *positionals, + None => false, + } } /// Decide whether we are running a Docker image or a host command, and split @@ -202,15 +211,18 @@ fn had_options_terminator() -> bool { /// * `... -- CMD` → native mode; positionals are the host command, no image. /// * `IMG [CMD...]` → Docker mode; first positional is the image. fn resolve_run_target(args: &RunArgs) -> RunTarget { - split_run_target(args.image.as_deref(), &args.image_and_cmd, had_options_terminator()) + split_run_target(args.image.as_deref(), &args.image_and_cmd, leading_terminator(&args.image_and_cmd)) } /// Pure core of [`resolve_run_target`], split out for testing. -fn split_run_target(image_flag: Option<&str>, positionals: &[String], had_terminator: bool) -> RunTarget { +/// +/// `native_mode` is true when a leading `--` terminator selected host-command +/// mode (see [`leading_terminator`]). +fn split_run_target(image_flag: Option<&str>, positionals: &[String], native_mode: bool) -> RunTarget { if let Some(img) = image_flag { return RunTarget { image: Some(img.to_string()), cmd: positionals.to_vec() }; } - if had_terminator { + if native_mode { return RunTarget { image: None, cmd: positionals.to_vec() }; } match positionals.split_first() { @@ -497,6 +509,8 @@ async fn run_command(args: RunArgs) -> Result<()> { builder = builder.env_var(k, v); } else if let Ok(v) = std::env::var(spec) { builder = builder.env_var(spec, v); + } else { + eprintln!("sandlock: warning: -e {}: not set in host environment, not forwarded", spec); } } @@ -528,6 +542,19 @@ async fn run_command(args: RunArgs) -> Result<()> { (None, None) }; + // Capture the profile's program identity before `base` is consumed below. + // Image defaults sit *below* the profile in precedence, so we only let the + // image fill cwd/uid/env that the profile did not already set. + let profile_cwd: Option = base_from_profile + .as_ref() + .and_then(|b| b.cwd.as_ref()) + .map(|p| p.to_string_lossy().into_owned()); + let profile_uid: Option = base_from_profile.as_ref().and_then(|b| b.uid); + let profile_env_keys: std::collections::HashSet = base_from_profile + .as_ref() + .map(|b| b.env.iter().map(|(k, _)| k.clone()).collect()) + .unwrap_or_default(); + // Start from profile or default let mut builder = if let Some(base) = base_from_profile { // Rebuild builder from loaded profile as base @@ -671,14 +698,19 @@ async fn run_command(args: RunArgs) -> Result<()> { } else { None }; - let mut effective_cwd: Option = None; - let mut effective_uid: Option = None; + // Precedence (low → high): image defaults < profile < CLI flags. The profile + // values were already layered into the builder above, so the image only + // supplies cwd/uid/env that the profile left unset; CLI flags override both. + let mut effective_cwd: Option = profile_cwd; + let mut effective_uid: Option = profile_uid; if let Some(ref cfg) = image_config { for kv in &cfg.env { - if let Some((k, v)) = kv.split_once('=') { builder = builder.env_var(k, v); } + if let Some((k, v)) = kv.split_once('=') { + if !profile_env_keys.contains(k) { builder = builder.env_var(k, v); } + } } - effective_cwd = cfg.workdir.clone(); - effective_uid = cfg.user.as_deref().and_then(parse_user); + if effective_cwd.is_none() { effective_cwd = cfg.workdir.clone(); } + if effective_uid.is_none() { effective_uid = cfg.user.as_deref().and_then(parse_user); } } // ── Environment: --env-file, then -e/--env (later wins, like Docker) ────── @@ -699,6 +731,10 @@ async fn run_command(args: RunArgs) -> Result<()> { } else if let Ok(v) = std::env::var(spec) { // Docker: `-e VAR` (no `=value`) forwards the host's value. builder = builder.env_var(spec, v); + } else { + // Docker leaves an unset `-e VAR` out of the child's environment; + // warn so it is not a silent no-op. + eprintln!("sandlock: warning: -e {}: not set in host environment, not forwarded", spec); } } @@ -741,7 +777,13 @@ async fn run_command(args: RunArgs) -> Result<()> { // ── Docker --cpus → CPU count ───────────────────────────────────────────── if let Some(n) = args.cpus { - if n <= 0.0 { return Err(anyhow!("--cpus must be positive, got: {}", n)); } + if !n.is_finite() || n <= 0.0 { + return Err(anyhow!("--cpus must be a positive, finite number, got: {}", n)); + } + // max_cpu takes a u8; reject values whose ceiling would not fit. + if n > 255.0 { + return Err(anyhow!("--cpus must be at most 255, got: {}", n)); + } builder = builder.max_cpu(n.ceil() as u8); } @@ -945,7 +987,7 @@ fn validate_no_supervisor(args: &RunArgs) -> Result<()> { if pb.chroot.is_some() { bad.push("--chroot"); } if args.image.is_some() { bad.push("--image"); } if pb.uid.is_some() { bad.push("--uid"); } - if pb.workdir.is_some() { bad.push("--workdir"); } + if pb.workdir.is_some() { bad.push("--fs-workdir"); } if pb.cwd.is_some() { bad.push("--cwd"); } if args.fs_isolation.is_some() { bad.push("--fs-isolation"); } if pb.fs_storage.is_some() { bad.push("--fs-storage"); } @@ -1155,6 +1197,16 @@ mod docker_compat_tests { assert_eq!(t.cmd, s(&["echo", "hi"])); } + #[test] + fn internal_terminator_after_image_stays_docker_mode() { + // `sandlock run alpine -- echo hi`: clap retains the `--` in the + // positionals (it follows the image), so leading_terminator() is false + // and alpine is still treated as the image, not a host command. + let t = split_run_target(None, &s(&["alpine", "--", "echo", "hi"]), false); + assert_eq!(t.image.as_deref(), Some("alpine")); + assert_eq!(t.cmd, s(&["--", "echo", "hi"])); + } + #[test] fn explicit_image_flag_takes_all_positionals_as_cmd() { let t = split_run_target(Some("alpine"), &s(&["sh", "-c", "true"]), true); diff --git a/crates/sandlock-core/src/image.rs b/crates/sandlock-core/src/image.rs index eb778d2a..1b0b3d75 100644 --- a/crates/sandlock-core/src/image.rs +++ b/crates/sandlock-core/src/image.rs @@ -162,12 +162,12 @@ impl ImageConfig { /// Inspect a local Docker image's `Config`, returning the fields sandlock maps /// onto its sandbox (entrypoint, cmd, working dir, env, user). /// -/// On any inspection failure this returns a default config so callers can fall -/// back to running `/bin/sh`. +/// Any inspection failure — `docker` not being executable, or a non-zero exit — +/// yields a default config so callers can fall back to running `/bin/sh`. pub fn inspect_config(image: &str) -> Result { // One field per line keeps parsing simple and avoids delimiter clashes with // values that may themselves contain `|`. - let output = Command::new("docker") + let output = match Command::new("docker") .args([ "inspect", "--format", "{{json .Config.Entrypoint}}\n{{json .Config.Cmd}}\n\ @@ -175,7 +175,11 @@ pub fn inspect_config(image: &str) -> Result { image, ]) .output() - .map_err(|_| SandboxRuntimeError::Child("docker inspect failed".into()))?; + { + Ok(output) => output, + // `docker` not found / not executable: treat as "no config available". + Err(_) => return Ok(ImageConfig::default()), + }; if !output.status.success() { return Ok(ImageConfig::default()); @@ -194,35 +198,17 @@ pub fn inspect_config(image: &str) -> Result { } /// Parse a JSON string literal like `"abc"` (or `null`) into its value. +/// +/// Uses `serde_json` so all JSON escapes (`\n`, `\t`, `\uXXXX`, …) are decoded +/// correctly; `null` and malformed input both yield `None`. fn parse_json_string(s: &str) -> Option { - let s = s.trim(); - if s == "null" || s.len() < 2 || !s.starts_with('"') || !s.ends_with('"') { - return None; - } - Some(s[1..s.len() - 1].replace("\\\"", "\"").replace("\\\\", "\\")) + serde_json::from_str::>(s.trim()).ok().flatten() } -/// Parse a JSON string array like `["a","b"]` or return None for `null`. +/// Parse a JSON string array like `["a","b"]`, or return `None` for `null` or +/// malformed input. An empty array `[]` parses to `Some(vec![])`. fn parse_json_string_array(s: &str) -> Option> { - let s = s.trim(); - if s == "null" || s.is_empty() { - return None; - } - if !s.starts_with('[') || !s.ends_with(']') { - return None; - } - let inner = &s[1..s.len() - 1]; - if inner.trim().is_empty() { - return Some(Vec::new()); - } - let mut result = Vec::new(); - for item in inner.split(',') { - let item = item.trim(); - if item.starts_with('"') && item.ends_with('"') && item.len() >= 2 { - result.push(item[1..item.len() - 1].replace("\\\"", "\"").replace("\\\\", "\\")); - } - } - if result.is_empty() { None } else { Some(result) } + serde_json::from_str::>>(s.trim()).ok().flatten() } // ============================================================ diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 160f6766..5a186e40 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -1765,9 +1765,9 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(short = 'r', long = "fs-read", value_name = "PATH"))] pub fs_readable: Vec, - // NOTE: `-w` is intentionally not a short alias here; the CLI reserves `-w` - // for Docker-compatible `--workdir`. Use the long `--fs-write` form. - #[cfg_attr(feature = "cli", arg(long = "fs-write", value_name = "PATH"))] + // NOTE: `-w` (lowercase) is reserved for Docker-compatible `--workdir`, so + // `--fs-write` takes the uppercase `-W` short flag instead. + #[cfg_attr(feature = "cli", arg(short = 'W', long = "fs-write", value_name = "PATH"))] pub fs_writable: Vec, #[cfg_attr(feature = "cli", arg(long = "fs-deny", value_name = "PATH"))]