use crate::signals::{SIGNAL_NAMES, canonical_signal_name};
use osarg::{Arg, Parser, standard};
use std::ffi::OsString;
use std::fmt;
use std::io::{self, Write};
const HELP_TEXT: &str = concat!(
"usage: tino [OPTIONS] [--] CMD [ARGS...]\n\n",
"options:\n",
" -s, --subreaper Enable PR_SET_CHILD_SUBREAPER\n",
" -p SIG Set PR_SET_PDEATHSIG (e.g. TERM, SIGTERM)\n",
" -v Increase log verbosity (repeatable)\n",
" -w, --warn-on-reap Warn when reaping secondary child processes\n",
" -g, --pgroup-kill Forward signals to the child's process group\n",
" -e, --remap-exit CODE Remap child exit code to success (repeatable)\n",
" -t, --grace-ms MS Grace period before SIGKILL (default: 500)\n",
" --write-restrict Restrict child filesystem writes\n",
" --write-allow PATH Allow writes beneath PATH (repeatable)\n",
" --write-preset PRESET Add writable preset: tmp, runtime\n",
" --write-warn-only Warn and continue when write restriction fails\n",
" --write-no-dev Do not automatically allow /dev writes\n",
" --bind-tcp-allow PORT Allow binding only on local TCP ports\n",
" --connect-tcp-allow PORT Allow outbound TCP only to remote ports\n",
" --scope-signals Restrict signal delivery to the same Landlock domain\n",
" --scope-abstract-unix Restrict abstract UNIX socket connects to the same Landlock domain\n",
" --exec-allow PATH Allow executing files beneath PATH\n",
" --device-ioctl-allow PATH Allow device ioctl operations beneath PATH\n",
" --expand-env Expand ${VAR} and ${VAR:-default} in child args\n",
" --explain Explain the effective configuration and exit\n",
" -l, --license Print license text and exit\n",
" -h, --help Show help\n",
" -V, --version Show version\n",
);
const VERSION_TEXT: &str = concat!(env!("CARGO_PKG_NAME"), " ", env!("CARGO_PKG_VERSION"), "\n");
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WritePreset {
Tmp,
Runtime,
}
impl WritePreset {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Tmp => "tmp",
Self::Runtime => "runtime",
}
}
fn parse(raw: &str) -> Result<Self, CliParseError> {
match raw {
"tmp" => Ok(Self::Tmp),
"runtime" => Ok(Self::Runtime),
_ => Err(CliParseError::message(format!(
"invalid value for --write-preset: {raw} (expected tmp or runtime)"
))),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CliParseErrorKind {
Message,
Help,
Version,
}
#[derive(Debug)]
pub struct CliParseError {
kind: CliParseErrorKind,
message: String,
}
impl CliParseError {
fn message(message: String) -> Self {
std::hint::cold_path();
Self {
kind: CliParseErrorKind::Message,
message,
}
}
fn help() -> Self {
Self {
kind: CliParseErrorKind::Help,
message: HELP_TEXT.to_string(),
}
}
fn version() -> Self {
Self {
kind: CliParseErrorKind::Version,
message: VERSION_TEXT.to_string(),
}
}
#[must_use]
pub const fn kind(&self) -> CliParseErrorKind {
self.kind
}
fn print_and_exit(&self) -> ! {
match self.kind {
CliParseErrorKind::Help | CliParseErrorKind::Version => {
print!("{}", self.message);
let _ = io::stdout().flush();
std::process::exit(0);
}
CliParseErrorKind::Message => {
std::hint::cold_path();
eprintln!("error: {}", self.message);
eprintln!();
eprint!("{HELP_TEXT}");
let _ = io::stderr().flush();
std::process::exit(2);
}
}
}
}
impl fmt::Display for CliParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for CliParseError {}
#[derive(Debug)]
pub struct Cli {
pub subreaper: bool,
pub pdeath: Option<String>,
pub verbosity: u8,
pub warn_on_reap: bool,
pub pgroup_kill: bool,
pub remap_exit: Vec<u8>,
pub grace_ms: u64,
pub write_restrict: bool,
pub write_allow: Vec<String>,
pub write_preset: Vec<WritePreset>,
pub write_warn_only: bool,
pub write_no_dev: bool,
pub bind_tcp_allow: Vec<u16>,
pub connect_tcp_allow: Vec<u16>,
pub scope_signals: bool,
pub scope_abstract_unix: bool,
pub exec_allow: Vec<String>,
pub device_ioctl_allow: Vec<String>,
pub expand_env: bool,
pub explain: bool,
pub license: bool,
pub cmd: Vec<String>,
}
impl Cli {
#[must_use]
pub fn parse() -> Self {
match Self::try_parse_from(std::env::args_os()) {
Ok(cli) => cli,
Err(err) => err.print_and_exit(),
}
}
#[must_use]
pub fn parse_from<I, T>(args: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
match Self::try_parse_from(args) {
Ok(cli) => cli,
Err(err) => err.print_and_exit(),
}
}
pub fn try_parse_from<I, T>(args: I) -> Result<Self, CliParseError>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let argv = args.into_iter().map(Into::into).collect::<Vec<_>>();
let mut parser = Parser::new(argv.into_iter().skip(1));
let mut cli = Self::default();
while let Some(arg) = parser
.next()
.map_err(|err| CliParseError::message(err.to_string()))?
{
if let Some(flag) = standard::classify(arg) {
return Err(match flag {
standard::Flag::Help => CliParseError::help(),
standard::Flag::Version => CliParseError::version(),
});
}
match arg {
Arg::Short('s') | Arg::Long("subreaper") => cli.subreaper = true,
Arg::Short('p') => {
let raw = parser
.value()
.map_err(|err| CliParseError::message(err.to_string()))?;
cli.pdeath = Some(parse_signal(raw.to_str().map_err(from_osarg_error)?)?);
}
Arg::Short('v') => cli.verbosity = cli.verbosity.saturating_add(1),
Arg::Short('w') | Arg::Long("warn-on-reap") => cli.warn_on_reap = true,
Arg::Short('g') | Arg::Long("pgroup-kill") => cli.pgroup_kill = true,
Arg::Short('e') | Arg::Long("remap-exit") => {
*cli.remap_exit.push_mut(0) = parser
.parse::<u8>()
.map_err(|err| CliParseError::message(err.to_string()))?;
}
Arg::Short('t') | Arg::Long("grace-ms") => {
cli.grace_ms = parser
.parse::<u64>()
.map_err(|err| CliParseError::message(err.to_string()))?;
}
Arg::Long("write-restrict") => cli.write_restrict = true,
Arg::Long("write-allow") => {
*cli.write_allow.push_mut(String::new()) =
parse_string_value(&mut parser, "--write-allow")?;
}
Arg::Long("write-preset") => {
let preset = parse_string_value(&mut parser, "--write-preset")?;
*cli.write_preset.push_mut(WritePreset::Tmp) = WritePreset::parse(&preset)?;
}
Arg::Long("write-warn-only") => cli.write_warn_only = true,
Arg::Long("write-no-dev") => cli.write_no_dev = true,
Arg::Long("bind-tcp-allow") => {
*cli.bind_tcp_allow.push_mut(0) =
parse_u16_value(&mut parser, "--bind-tcp-allow")?;
}
Arg::Long("connect-tcp-allow") => {
*cli.connect_tcp_allow.push_mut(0) =
parse_u16_value(&mut parser, "--connect-tcp-allow")?;
}
Arg::Long("scope-signals") => cli.scope_signals = true,
Arg::Long("scope-abstract-unix") => cli.scope_abstract_unix = true,
Arg::Long("exec-allow") => {
*cli.exec_allow.push_mut(String::new()) =
parse_string_value(&mut parser, "--exec-allow")?;
}
Arg::Long("device-ioctl-allow") => {
*cli.device_ioctl_allow.push_mut(String::new()) =
parse_string_value(&mut parser, "--device-ioctl-allow")?;
}
Arg::Long("expand-env") => cli.expand_env = true,
Arg::Long("explain") => cli.explain = true,
Arg::Short('l') | Arg::Long("license") => cli.license = true,
Arg::Value(value) => {
*cli.cmd.push_mut(String::new()) =
value.to_str().map_err(from_osarg_error)?.to_owned();
cli.cmd.extend(
parser
.remaining_vec()
.into_iter()
.map(os_string_into_string)
.collect::<Result<Vec<_>, _>>()?,
);
break;
}
other => return Err(CliParseError::message(other.unexpected().to_string())),
}
}
Ok(cli)
}
pub(crate) fn resolved_verbosity(&self) -> u8 {
self.verbosity.min(3)
}
}
impl Default for Cli {
fn default() -> Self {
Self {
subreaper: false,
pdeath: None,
verbosity: 0,
warn_on_reap: false,
pgroup_kill: false,
remap_exit: Vec::new(),
grace_ms: 500,
write_restrict: false,
write_allow: Vec::new(),
write_preset: Vec::new(),
write_warn_only: false,
write_no_dev: false,
bind_tcp_allow: Vec::new(),
connect_tcp_allow: Vec::new(),
scope_signals: false,
scope_abstract_unix: false,
exec_allow: Vec::new(),
device_ioctl_allow: Vec::new(),
expand_env: false,
explain: false,
license: false,
cmd: Vec::new(),
}
}
}
fn parse_signal(raw: &str) -> Result<String, CliParseError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(CliParseError::message("signal name cannot be empty".into()));
}
if let Some(name) = canonical_signal_name(trimmed) {
Ok(format!("SIG{}", name))
} else {
Err(CliParseError::message(format!(
"invalid signal '{raw}'; supported values: {}",
SIGNAL_NAMES.join(", ")
)))
}
}
fn parse_string_value<I>(parser: &mut Parser<I>, option: &str) -> Result<String, CliParseError>
where
I: Iterator<Item = OsString>,
{
parser
.value()
.map_err(|err| CliParseError::message(format!("{option}: {err}")))?
.to_str()
.map(str::to_owned)
.map_err(from_osarg_error)
}
fn parse_u16_value<I>(parser: &mut Parser<I>, option: &str) -> Result<u16, CliParseError>
where
I: Iterator<Item = OsString>,
{
parser
.parse::<u16>()
.map_err(|err| CliParseError::message(format!("{option}: {err}")))
}
fn os_string_into_string(value: OsString) -> Result<String, CliParseError> {
value.into_string().map_err(|value| {
CliParseError::message(format!(
"argument is not valid UTF-8: {}",
value.to_string_lossy()
))
})
}
fn from_osarg_error(err: osarg::Error) -> CliParseError {
CliParseError::message(err.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_family = "unix")]
use std::os::unix::ffi::OsStringExt;
type FlagCase<'a> = (&'a [&'a str], fn(&Cli) -> bool);
fn parse_ok<I, T>(args: I) -> Cli
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
Cli::try_parse_from(args).expect("parse cli")
}
#[test]
fn parse_signal_accepts_known_variants() {
assert_eq!(parse_signal("TERM").unwrap(), "SIGTERM");
assert_eq!(parse_signal("sigterm").unwrap(), "SIGTERM");
assert_eq!(parse_signal("SIGUSR1").unwrap(), "SIGUSR1");
}
#[test]
fn parse_signal_rejects_unknown_values() {
assert!(parse_signal("NOPE").is_err());
assert!(parse_signal("").is_err());
}
#[test]
fn parse_counts_grouped_verbosity_and_collects_command_tail() {
let cli =
Cli::try_parse_from(["tino", "-vv", "--", "/bin/echo", "--value"]).expect("parse cli");
assert_eq!(cli.verbosity, 2);
assert_eq!(cli.cmd, vec!["/bin/echo", "--value"]);
}
#[test]
fn parse_help_and_version_as_control_flow() {
let help = Cli::try_parse_from(["tino", "--help"]).unwrap_err();
assert_eq!(help.kind(), CliParseErrorKind::Help);
let version = Cli::try_parse_from(["tino", "-V"]).unwrap_err();
assert_eq!(version.kind(), CliParseErrorKind::Version);
}
#[test]
fn parse_supports_short_and_long_flag_spellings() {
let cases: &[FlagCase<'_>] = &[
(&["tino", "-s"], |cli| cli.subreaper),
(&["tino", "--subreaper"], |cli| cli.subreaper),
(&["tino", "-w"], |cli| cli.warn_on_reap),
(&["tino", "--warn-on-reap"], |cli| cli.warn_on_reap),
(&["tino", "-g"], |cli| cli.pgroup_kill),
(&["tino", "--pgroup-kill"], |cli| cli.pgroup_kill),
(&["tino", "--write-restrict"], |cli| cli.write_restrict),
(&["tino", "--write-warn-only"], |cli| cli.write_warn_only),
(&["tino", "--write-no-dev"], |cli| cli.write_no_dev),
(&["tino", "--scope-signals"], |cli| cli.scope_signals),
(&["tino", "--scope-abstract-unix"], |cli| {
cli.scope_abstract_unix
}),
(&["tino", "--expand-env"], |cli| cli.expand_env),
(&["tino", "--explain"], |cli| cli.explain),
(&["tino", "-l"], |cli| cli.license),
(&["tino", "--license"], |cli| cli.license),
];
for (args, predicate) in cases {
let cli = parse_ok(*args);
assert!(predicate(&cli), "flag did not parse as expected: {args:?}");
}
}
#[test]
fn parse_supports_value_option_spellings() {
let cases: &[FlagCase<'_>] = &[
(&["tino", "-p", "TERM"], |cli| {
cli.pdeath.as_deref() == Some("SIGTERM")
}),
(&["tino", "-pTERM"], |cli| {
cli.pdeath.as_deref() == Some("SIGTERM")
}),
(&["tino", "-e", "3"], |cli| cli.remap_exit == vec![3]),
(&["tino", "--remap-exit", "3"], |cli| {
cli.remap_exit == vec![3]
}),
(&["tino", "--remap-exit=3"], |cli| cli.remap_exit == vec![3]),
(&["tino", "-t", "250"], |cli| cli.grace_ms == 250),
(&["tino", "-t250"], |cli| cli.grace_ms == 250),
(&["tino", "--grace-ms", "250"], |cli| cli.grace_ms == 250),
(&["tino", "--grace-ms=250"], |cli| cli.grace_ms == 250),
(&["tino", "--write-allow", "/tmp"], |cli| {
cli.write_allow == vec!["/tmp"]
}),
(&["tino", "--write-allow=/tmp"], |cli| {
cli.write_allow == vec!["/tmp"]
}),
(&["tino", "--write-preset", "tmp"], |cli| {
cli.write_preset == vec![WritePreset::Tmp]
}),
(&["tino", "--write-preset=runtime"], |cli| {
cli.write_preset == vec![WritePreset::Runtime]
}),
(&["tino", "--bind-tcp-allow", "80"], |cli| {
cli.bind_tcp_allow == vec![80]
}),
(&["tino", "--bind-tcp-allow=80"], |cli| {
cli.bind_tcp_allow == vec![80]
}),
(&["tino", "--connect-tcp-allow", "443"], |cli| {
cli.connect_tcp_allow == vec![443]
}),
(&["tino", "--connect-tcp-allow=443"], |cli| {
cli.connect_tcp_allow == vec![443]
}),
(&["tino", "--exec-allow", "/bin/sh"], |cli| {
cli.exec_allow == vec!["/bin/sh"]
}),
(&["tino", "--exec-allow=/bin/sh"], |cli| {
cli.exec_allow == vec!["/bin/sh"]
}),
(&["tino", "--device-ioctl-allow", "/dev/null"], |cli| {
cli.device_ioctl_allow == vec!["/dev/null"]
}),
(&["tino", "--device-ioctl-allow=/dev/null"], |cli| {
cli.device_ioctl_allow == vec!["/dev/null"]
}),
];
for (args, predicate) in cases {
let cli = parse_ok(*args);
assert!(
predicate(&cli),
"value option did not parse as expected: {args:?}"
);
}
}
#[test]
fn parse_collects_repeatable_values_in_order() {
let cli = parse_ok([
"tino",
"-e",
"3",
"--remap-exit=7",
"--write-allow=/tmp",
"--write-allow",
"/run",
"--bind-tcp-allow=80",
"--bind-tcp-allow",
"443",
"--connect-tcp-allow=8080",
"--connect-tcp-allow",
"8443",
"--exec-allow=/bin/sh",
"--exec-allow",
"/usr/bin/env",
"--device-ioctl-allow=/dev/null",
"--device-ioctl-allow",
"/dev/pts",
]);
assert_eq!(cli.remap_exit, vec![3, 7]);
assert_eq!(cli.write_allow, vec!["/tmp", "/run"]);
assert_eq!(cli.bind_tcp_allow, vec![80, 443]);
assert_eq!(cli.connect_tcp_allow, vec![8080, 8443]);
assert_eq!(cli.exec_allow, vec!["/bin/sh", "/usr/bin/env"]);
assert_eq!(cli.device_ioctl_allow, vec!["/dev/null", "/dev/pts"]);
}
#[test]
fn parse_accumulates_repeated_verbosity_flags() {
let cases: &[(&[&str], u8)] = &[(&["tino", "-vvv"], 3), (&["tino", "-v", "-v"], 2)];
for (args, expected) in cases {
let cli = parse_ok(*args);
assert_eq!(
cli.verbosity, *expected,
"unexpected verbosity for {args:?}"
);
}
}
#[test]
fn parse_supports_short_long_attached_and_repeatable_options() {
let cli = parse_ok([
"tino",
"-svgw",
"-pTERM",
"-e",
"3",
"--remap-exit=7",
"-t250",
"--write-restrict",
"--write-allow",
"/tmp",
"--write-preset=runtime",
"--write-warn-only",
"--write-no-dev",
"--bind-tcp-allow",
"80",
"--bind-tcp-allow=443",
"--connect-tcp-allow",
"8080",
"--scope-signals",
"--scope-abstract-unix",
"--exec-allow",
"/bin/sh",
"--device-ioctl-allow",
"/dev/null",
"--expand-env",
"--explain",
"--license",
"--",
"/bin/echo",
"hello",
]);
assert!(cli.subreaper);
assert_eq!(cli.pdeath.as_deref(), Some("SIGTERM"));
assert_eq!(cli.verbosity, 1);
assert!(cli.warn_on_reap);
assert!(cli.pgroup_kill);
assert_eq!(cli.remap_exit, vec![3, 7]);
assert_eq!(cli.grace_ms, 250);
assert!(cli.write_restrict);
assert_eq!(cli.write_allow, vec!["/tmp"]);
assert_eq!(cli.write_preset, vec![WritePreset::Runtime]);
assert!(cli.write_warn_only);
assert!(cli.write_no_dev);
assert_eq!(cli.bind_tcp_allow, vec![80, 443]);
assert_eq!(cli.connect_tcp_allow, vec![8080]);
assert!(cli.scope_signals);
assert!(cli.scope_abstract_unix);
assert_eq!(cli.exec_allow, vec!["/bin/sh"]);
assert_eq!(cli.device_ioctl_allow, vec!["/dev/null"]);
assert!(cli.expand_env);
assert!(cli.explain);
assert!(cli.license);
assert_eq!(cli.cmd, vec!["/bin/echo", "hello"]);
}
#[test]
fn parse_first_positional_collects_remaining_command() {
let cli = parse_ok(["tino", "--expand-env", "/bin/echo", "--literal-flag"]);
assert!(cli.expand_env);
assert_eq!(cli.cmd, vec!["/bin/echo", "--literal-flag"]);
}
#[test]
fn parse_rejects_unknown_argument() {
let err = Cli::try_parse_from(["tino", "--nope"]).unwrap_err();
assert_eq!(err.kind(), CliParseErrorKind::Message);
assert!(err.to_string().contains("unexpected argument"));
}
#[test]
fn parse_rejects_missing_option_value() {
let cases = [
["tino", "-p"],
["tino", "-e"],
["tino", "-t"],
["tino", "--grace-ms"],
["tino", "--write-allow"],
["tino", "--write-preset"],
["tino", "--bind-tcp-allow"],
["tino", "--connect-tcp-allow"],
["tino", "--exec-allow"],
["tino", "--device-ioctl-allow"],
];
for args in cases {
let err = Cli::try_parse_from(args).unwrap_err();
assert_eq!(err.kind(), CliParseErrorKind::Message);
assert!(
err.to_string().contains("missing value"),
"unexpected missing-value error for {args:?}: {err}"
);
}
}
#[test]
fn parse_rejects_invalid_numeric_value() {
let cases = [
["tino", "-e", "256"],
["tino", "--bind-tcp-allow", "70000"],
["tino", "--connect-tcp-allow", "70000"],
["tino", "--grace-ms", "abc"],
];
for args in cases {
let err = Cli::try_parse_from(args).unwrap_err();
assert_eq!(err.kind(), CliParseErrorKind::Message);
assert!(
err.to_string().contains("invalid value")
|| err.to_string().contains("invalid digit")
|| err.to_string().contains("number too large"),
"unexpected numeric parse error for {args:?}: {err}"
);
}
}
#[test]
fn parse_rejects_invalid_write_preset() {
let err = Cli::try_parse_from(["tino", "--write-preset", "logs"]).unwrap_err();
assert_eq!(err.kind(), CliParseErrorKind::Message);
assert!(err.to_string().contains("invalid value for --write-preset"));
}
#[cfg(target_family = "unix")]
#[test]
fn parse_rejects_non_utf8_command_arguments() {
let err = Cli::try_parse_from([
OsString::from("tino"),
OsString::from("--"),
OsString::from_vec(vec![0xff, 0xfe]),
])
.unwrap_err();
assert_eq!(err.kind(), CliParseErrorKind::Message);
assert!(err.to_string().contains("not valid UTF-8"));
}
}