use crate::executor::ExecutorError;
const MAX_USERNAME_LEN: usize = 32;
const MAX_DNS_NAME_LEN: usize = 253;
const MAX_DNS_LABEL_LEN: usize = 63;
pub(crate) const MAX_PORT: u32 = 65_535;
const MAX_LVM_NAME_LEN: usize = 127;
const MAX_LVM_SIZE_LEN: usize = 32;
const MAX_JOURNAL_TIME_LEN: usize = 64;
const MAX_JOURNAL_GREP_LEN: usize = 256;
const MAX_SYSCTL_KEY_LEN: usize = 128;
const MAX_SYSCTL_VALUE_LEN: usize = 200;
const MAX_MEMORY_LIMIT_LEN: usize = 24;
const MAX_TASKS_MAX_DIGITS: usize = 12;
const MAX_FSTAB_FIELD_LEN: usize = 256;
const MAX_SUDOERS_NAME_LEN: usize = 64;
const MAX_ABSOLUTE_PATH_LEN: usize = 255;
const LOG_ROOT: &str = "/var/log/";
const MAX_SYSLOG_HOST_LEN: usize = 255;
const MAX_APT_PACKAGE_LEN: usize = 128;
const MAX_APT_PIN_EXPR_LEN: usize = 200;
const MAX_SUDO_COMMANDS_LEN: usize = 1024;
const MAX_EMAIL_LEN: usize = 254;
const MIN_EMAIL_LEN: usize = 3;
pub fn validated_username(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.len() > MAX_USERNAME_LEN {
return Err(ExecutorError::InvalidParam(param));
}
if s.starts_with('-') || s.starts_with('.') || s.contains("..") {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_group(s: &str, param: &'static str) -> Result<String, ExecutorError> {
validated_username(s, param)
}
const CRITICAL_ACCOUNTS: &[&str] = &["root", "daemon", "bin", "sys", "sudo", "adm"];
const CRITICAL_GROUPS: &[&str] = &[
"root", "sudo", "wheel", "adm", "sys", "daemon", "bin", "staff", "shadow", "disk",
];
pub fn validated_username_not_critical(
s: &str,
param: &'static str,
) -> Result<String, ExecutorError> {
let s = validated_username(s, param)?;
if CRITICAL_ACCOUNTS.contains(&s.as_str()) {
return Err(ExecutorError::InvalidParam(param));
}
Ok(s)
}
pub fn validated_group_not_critical(s: &str, param: &'static str) -> Result<String, ExecutorError> {
let s = validated_group(s, param)?;
if CRITICAL_GROUPS.contains(&s.as_str()) {
return Err(ExecutorError::InvalidParam(param));
}
Ok(s)
}
pub fn validated_unit_name(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.starts_with('-') {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '@' | '.' | '_' | ':' | '-'))
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub(crate) const ROOT_SHELL_UNITS: &[&str] =
&["debug-shell", "emergency", "rescue", "runlevel1", "single"];
pub fn validated_activatable_unit(s: &str, param: &'static str) -> Result<String, ExecutorError> {
let unit = validated_unit_name(s, param)?;
let bare = bare_unit_name(&unit);
if ROOT_SHELL_UNITS.contains(&bare.as_str()) || POWER_STATE_UNITS.contains(&bare.as_str()) {
return Err(ExecutorError::InvalidParam(param));
}
Ok(unit)
}
pub(crate) const POWER_STATE_UNITS: &[&str] = &[
"reboot",
"soft-reboot",
"poweroff",
"halt",
"kexec",
"ctrl-alt-del",
"exit",
"runlevel0",
"runlevel6",
"shutdown",
"umount",
"sleep",
"suspend",
"hibernate",
"hybrid-sleep",
"suspend-then-hibernate",
"systemd-reboot",
"systemd-soft-reboot",
"systemd-poweroff",
"systemd-halt",
"systemd-kexec",
"systemd-exit",
"systemd-suspend",
"systemd-hibernate",
"systemd-hybrid-sleep",
"systemd-suspend-then-hibernate",
];
pub fn validated_stoppable_unit(s: &str, param: &'static str) -> Result<String, ExecutorError> {
let unit = validated_unit_name(s, param)?;
if is_protected_unit(&bare_unit_name(&unit)) {
return Err(ExecutorError::InvalidParam(param));
}
Ok(unit)
}
const UNIT_TYPE_SUFFIXES: &[&str] = &[
".service", ".target", ".socket", ".mount", ".path", ".slice", ".scope",
];
fn bare_unit_name(unit: &str) -> String {
let lower = unit.to_ascii_lowercase();
let stripped = UNIT_TYPE_SUFFIXES
.iter()
.find_map(|suffix| lower.strip_suffix(suffix))
.unwrap_or(&lower);
match stripped.split_once('@') {
Some((template, _)) => template.to_string(),
None => stripped.to_string(),
}
}
pub(crate) const RESOURCE_LIMIT_PROTECTED_UNITS: &[&str] = &[
"sysknife-daemon",
"auditd",
"systemd-journald",
"rsyslog",
"dbus",
"dbus-broker",
"polkit",
"polkitd",
"systemd-logind",
"ssh",
"sshd",
];
const RESOURCE_LIMIT_PROTECTED_PREFIXES: &[&str] = &["systemd-journald", "auditd", "audit-rules"];
pub fn validated_resource_limit_unit(
s: &str,
param: &'static str,
) -> Result<String, ExecutorError> {
let unit = validated_unit_name(s, param)?;
let lower = unit.to_ascii_lowercase();
if lower.ends_with(".slice") || lower.ends_with(".scope") {
return Err(ExecutorError::InvalidParam(param));
}
if is_protected_unit(&bare_unit_name(&unit)) {
return Err(ExecutorError::InvalidParam(param));
}
Ok(unit)
}
fn is_protected_unit(bare: &str) -> bool {
RESOURCE_LIMIT_PROTECTED_UNITS.contains(&bare)
|| RESOURCE_LIMIT_PROTECTED_PREFIXES
.iter()
.any(|family| bare.starts_with(family))
}
pub fn validated_hostname(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.len() > MAX_DNS_NAME_LEN || s.starts_with('-') {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
{
return Err(ExecutorError::InvalidParam(param));
}
for label in s.split('.') {
if label.is_empty() || label.len() > MAX_DNS_LABEL_LEN {
return Err(ExecutorError::InvalidParam(param));
}
}
Ok(s.to_string())
}
pub fn validated_timezone(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.starts_with('-') {
return Err(ExecutorError::InvalidParam(param));
}
if s.contains("..") {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-'))
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_locale(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.starts_with('-') {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_ppa_name(s: &str, param: &'static str) -> Result<String, ExecutorError> {
let Some((user, ppa)) = s.split_once('/') else {
return Err(ExecutorError::InvalidParam(param));
};
if user.is_empty() || ppa.is_empty() {
return Err(ExecutorError::InvalidParam(param));
}
let is_valid_component = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-');
if !user.chars().all(is_valid_component) || !ppa.chars().all(is_valid_component) {
return Err(ExecutorError::InvalidParam(param));
}
if s.len() > SAFE_ARG_MAX_BYTES {
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
const APPARMOR_PROFILE_NAME_MAX: usize = 128;
pub fn validated_apparmor_profile(s: &str, param: &'static str) -> Result<String, ExecutorError> {
const PREFIX: &str = "/etc/apparmor.d/";
if s.is_empty() {
return Err(ExecutorError::InvalidParam(param));
}
if s.starts_with('/') {
if !s.starts_with(PREFIX) {
return Err(ExecutorError::InvalidParam(param));
}
if s.contains("..") {
return Err(ExecutorError::InvalidParam(param));
}
let suffix = &s[PREFIX.len()..];
if suffix.is_empty() {
return Err(ExecutorError::InvalidParam(param));
}
let ok = suffix
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/'));
if !ok {
return Err(ExecutorError::InvalidParam(param));
}
} else {
if s.contains('/') {
return Err(ExecutorError::InvalidParam(param));
}
if s.starts_with('.') || s.starts_with('-') {
return Err(ExecutorError::InvalidParam(param));
}
if s.len() > APPARMOR_PROFILE_NAME_MAX {
return Err(ExecutorError::InvalidParam(param));
}
let ok = s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
if !ok {
return Err(ExecutorError::InvalidParam(param));
}
}
Ok(s.to_string())
}
const UFW_APP_NAME_MAX: usize = 64;
pub fn validated_port_or_service(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() {
return Err(ExecutorError::InvalidParam(param));
}
if let Some(slash_pos) = s.find('/') {
let port_part = &s[..slash_pos];
let proto_part = &s[slash_pos + 1..];
if proto_part != "tcp" && proto_part != "udp" {
return Err(ExecutorError::InvalidParam(param));
}
if port_part.is_empty() || !port_part.chars().all(|c| c.is_ascii_digit()) {
return Err(ExecutorError::InvalidParam(param));
}
let port: u32 = port_part
.parse()
.map_err(|_| ExecutorError::InvalidParam(param))?;
if port == 0 || port > MAX_PORT {
return Err(ExecutorError::InvalidParam(param));
}
return Ok(s.to_string());
}
if s.chars().all(|c| c.is_ascii_digit()) {
let port: u32 = s.parse().map_err(|_| ExecutorError::InvalidParam(param))?;
if port == 0 || port > MAX_PORT {
return Err(ExecutorError::InvalidParam(param));
}
return Ok(s.to_string());
}
if !s.starts_with(|c: char| c.is_ascii_alphabetic()) {
return Err(ExecutorError::InvalidParam(param));
}
if s.len() > UFW_APP_NAME_MAX || s.ends_with(' ') {
return Err(ExecutorError::InvalidParam(param));
}
let ok = s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ' '));
if !ok {
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub const SAFE_ARG_MAX_BYTES: usize = 254;
pub fn validated_safe_arg(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.len() > SAFE_ARG_MAX_BYTES {
return Err(ExecutorError::InvalidParam(param));
}
if s.starts_with('-') {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '/' | '+' | '@' | '-'))
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub const LOCAL_IMAGE_TRANSPORTS: &[&str] = &[
"oci",
"oci-archive",
"dir",
"docker-archive",
"docker-daemon",
"containers-storage",
"ostree",
"sif",
"tarball",
"file",
];
pub const UNVERIFIED_IMAGE_TRANSPORTS: &[&str] =
&["ostree-unverified-registry", "ostree-unverified-image"];
pub fn validated_rebase_ref(s: &str, param: &'static str) -> Result<String, ExecutorError> {
let target = validated_safe_arg(s, param)?;
let mut previous = String::new();
for part in target.split(':') {
let lower = part.to_ascii_lowercase();
let local_path = part.starts_with('/') && !(previous == "docker" && part.starts_with("//"));
if LOCAL_IMAGE_TRANSPORTS.contains(&lower.as_str())
|| UNVERIFIED_IMAGE_TRANSPORTS.contains(&lower.as_str())
|| part.starts_with('.')
|| local_path
{
return Err(ExecutorError::InvalidParam(param));
}
previous = lower;
}
if target.contains("..") || target.contains("@/") {
return Err(ExecutorError::InvalidParam(param));
}
Ok(target)
}
pub fn validated_lvm_name(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.len() > MAX_LVM_NAME_LEN {
return Err(ExecutorError::InvalidParam(param));
}
let mut chars = s.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_alphanumeric() || first == '_') {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '_' | '.' | '-'))
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_lvm_size(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.len() > MAX_LVM_SIZE_LEN {
return Err(ExecutorError::InvalidParam(param));
}
let body = s.strip_prefix('+').unwrap_or(s);
let (digits, suffix) = match body.chars().last() {
Some(c) if c.is_ascii_alphabetic() => (&body[..body.len() - 1], Some(c)),
_ => (body, None),
};
if let Some(c) = suffix {
if !matches!(c, 'k' | 'K' | 'm' | 'M' | 'g' | 'G' | 't' | 'T' | 'p' | 'P') {
return Err(ExecutorError::InvalidParam(param));
}
}
if digits.is_empty()
|| digits.matches('.').count() > 1
|| !digits.chars().all(|c| c.is_ascii_digit() || c == '.')
|| digits.starts_with('.')
|| digits.ends_with('.')
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
const JOURNAL_PRIORITY_NAMES: &[&str] = &[
"emerg", "alert", "crit", "err", "warning", "notice", "info", "debug",
];
pub fn validated_journal_priority(s: &str, param: &'static str) -> Result<String, ExecutorError> {
let is_level = |lvl: &str| -> bool {
matches!(lvl, "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7")
|| JOURNAL_PRIORITY_NAMES.contains(&lvl)
};
let ok = match s.split_once("..") {
Some((lo, hi)) => is_level(lo) && is_level(hi),
None => is_level(s),
};
if ok {
Ok(s.to_string())
} else {
Err(ExecutorError::InvalidParam(param))
}
}
pub fn validated_journal_time(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.len() > MAX_JOURNAL_TIME_LEN {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | ':' | '-' | '+' | '.' | ','))
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_journal_grep(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.len() > MAX_JOURNAL_GREP_LEN {
return Err(ExecutorError::InvalidParam(param));
}
if s.chars().any(|c| c.is_control()) {
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_sysctl_key(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.len() > MAX_SYSCTL_KEY_LEN {
return Err(ExecutorError::InvalidParam(param));
}
let first = s.chars().next().unwrap();
if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_sysctl_value(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.len() > MAX_SYSCTL_VALUE_LEN {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '_' | '/' | ':' | ',' | '-'))
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_memory_limit(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s == "infinity" {
return Ok(s.to_string());
}
if s.len() > MAX_MEMORY_LIMIT_LEN {
return Err(ExecutorError::InvalidParam(param));
}
let digits = match s.chars().last() {
Some('K' | 'M' | 'G' | 'T') => &s[..s.len() - 1],
_ => s,
};
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_cpu_quota(s: &str, param: &'static str) -> Result<String, ExecutorError> {
let digits = s
.strip_suffix('%')
.ok_or(ExecutorError::InvalidParam(param))?;
if digits.is_empty() || digits.len() > 7 || !digits.chars().all(|c| c.is_ascii_digit()) {
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_tasks_max(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s == "infinity" {
return Ok(s.to_string());
}
if s.is_empty() || s.len() > MAX_TASKS_MAX_DIGITS || !s.chars().all(|c| c.is_ascii_digit()) {
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
const FSTYPE_ALLOW: &[&str] = &[
"ext4", "ext3", "ext2", "xfs", "btrfs", "vfat", "exfat", "ntfs3", "nfs", "nfs4", "cifs",
];
const CRITICAL_MOUNTPOINTS: &[&str] = &[
"/",
"/boot",
"/boot/efi",
"/etc",
"/usr",
"/bin",
"/sbin",
"/lib",
"/lib64",
"/var",
"/home",
"/root",
"/proc",
"/sys",
"/dev",
"/run",
];
pub fn validated_mount_device(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.len() > MAX_FSTAB_FIELD_LEN || s.starts_with('-') || s.contains("..") {
return Err(ExecutorError::InvalidParam(param));
}
let dev_like = s.starts_with("/dev/")
&& s[5..]
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '.' | '-'));
let uuid = s
.strip_prefix("UUID=")
.is_some_and(|u| u.len() >= 8 && u.chars().all(|c| c.is_ascii_hexdigit() || c == '-'));
let label = s.strip_prefix("LABEL=").is_some_and(|l| {
!l.is_empty()
&& l.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | ':' | '-'))
});
let cifs = s.starts_with("//")
&& s.matches('/').count() >= 3
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'));
let nfs = {
match s.split_once(":/") {
Some((host, _)) => {
!host.is_empty()
&& !host.starts_with('/')
&& s.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, ':' | '/' | '.' | '_' | '-')
})
}
None => false,
}
};
if dev_like || uuid || label || cifs || nfs {
Ok(s.to_string())
} else {
Err(ExecutorError::InvalidParam(param))
}
}
fn validated_absolute_path(
s: &str,
param: &'static str,
max_len: usize,
) -> Result<String, ExecutorError> {
if !s.starts_with('/') || s.len() > max_len || s.contains("..") {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '.' | '-'))
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_home_dir(s: &str, param: &'static str) -> Result<String, ExecutorError> {
validated_absolute_path(s, param, MAX_FSTAB_FIELD_LEN)
}
pub fn validated_mount_point(s: &str, param: &'static str) -> Result<String, ExecutorError> {
let s = validated_absolute_path(s, param, MAX_FSTAB_FIELD_LEN)?;
if CRITICAL_MOUNTPOINTS.contains(&s.as_str()) {
return Err(ExecutorError::InvalidParam(param));
}
Ok(s)
}
pub fn validated_fstype(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if FSTYPE_ALLOW.contains(&s) {
Ok(s.to_string())
} else {
Err(ExecutorError::InvalidParam(param))
}
}
const MOUNT_OPTIONS_DENY: &[&str] = &["suid", "dev"];
pub fn hardened_mount_options(requested: &str) -> String {
let mut out: Vec<&str> = requested.split(',').filter(|o| !o.is_empty()).collect();
for required in ["nosuid", "nodev"] {
if !out.iter().any(|o| o.eq_ignore_ascii_case(required)) {
out.push(required);
}
}
out.join(",")
}
pub fn validated_mount_options(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.len() > MAX_FSTAB_FIELD_LEN {
return Err(ExecutorError::InvalidParam(param));
}
if !s.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '=' | ',' | '.' | '_' | ':' | '@' | '/' | '+' | '-')
}) {
return Err(ExecutorError::InvalidParam(param));
}
if s.split(',').any(|opt| {
MOUNT_OPTIONS_DENY
.iter()
.any(|deny| opt.eq_ignore_ascii_case(deny))
}) {
return Err(ExecutorError::InvalidParam(param));
}
Ok(hardened_mount_options(s))
}
pub fn validated_swap_path(s: &str, param: &'static str) -> Result<String, ExecutorError> {
validated_absolute_path(s, param, MAX_FSTAB_FIELD_LEN)
}
pub fn validated_sudoers_name(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.len() > MAX_SUDOERS_NAME_LEN {
return Err(ExecutorError::InvalidParam(param));
}
let first = s.chars().next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-'))
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_apt_pin_name(s: &str, param: &'static str) -> Result<String, ExecutorError> {
validated_sudoers_name(s, param)
}
pub fn validated_log_path(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if !s.starts_with(LOG_ROOT) || s.len() > MAX_ABSOLUTE_PATH_LEN || s.contains("..") {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '*' | '-'))
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
const LOG_EVIDENCE_DIRS: &[&str] = &["/var/log", "/var/log/audit", "/var/log/journal"];
fn log_glob_sweeps_evidence(path: &str) -> bool {
let parts: Vec<&str> = path
.split('/')
.filter(|p| !p.is_empty() && *p != ".")
.collect();
let Some((last, parent)) = parts.split_last() else {
return false;
};
if !last.chars().all(|c| c == '*') {
return false;
}
LOG_EVIDENCE_DIRS.contains(&format!("/{}", parent.join("/")).as_str())
|| parent.iter().any(|p| p.contains('*'))
}
pub fn validated_log_rotation(path: &str, rotate: u32) -> Result<String, ExecutorError> {
let path = validated_log_path(path, "path")?;
if log_glob_sweeps_evidence(&path) {
return Err(ExecutorError::InvalidParam("path"));
}
if path.contains('*') && rotate == 0 {
return Err(ExecutorError::InvalidParam("rotate"));
}
Ok(path)
}
pub fn validated_syslog_host(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.len() > MAX_SYSLOG_HOST_LEN || s.contains("..") || s.starts_with('-') {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | ':' | '_' | '-'))
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_install_package(s: &str, param: &'static str) -> Result<String, ExecutorError> {
let first_ok = s.chars().next().is_some_and(|c| c.is_ascii_alphanumeric());
let charset_ok = s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '_' | ':' | '=' | '~' | '-'));
if s.is_empty()
|| s.len() > MAX_APT_PACKAGE_LEN
|| !first_ok
|| !charset_ok
|| s.contains('/')
|| s.ends_with(".deb")
|| s.ends_with(".rpm")
|| s.ends_with('-')
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_unmodified_package(s: &str, param: &'static str) -> Result<String, ExecutorError> {
let package = validated_install_package(s, param)?;
if package.ends_with('+') {
return Err(ExecutorError::InvalidParam(param));
}
Ok(package)
}
pub fn validated_apt_show_package(s: &str, param: &'static str) -> Result<String, ExecutorError> {
let package = validated_safe_arg(s, param)?;
if package.ends_with('-') {
return Err(ExecutorError::InvalidParam(param));
}
Ok(package)
}
pub fn validated_apt_package(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty()
|| s.starts_with('-')
|| s.len() > MAX_APT_PACKAGE_LEN
|| !s.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '*' | '?' | '_' | ':' | '-')
})
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_apt_pin_expr(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty()
|| s.len() > MAX_APT_PIN_EXPR_LEN
|| !s.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, ' ' | '=' | '.' | ',' | ':' | '/' | '*' | '_' | '+' | '-')
})
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub(crate) const SHELL_EQUIVALENT_COMMANDS: &[&str] = &[
"sh",
"bash",
"rbash",
"dash",
"ash",
"zsh",
"ksh",
"ksh93",
"mksh",
"csh",
"tcsh",
"fish",
"busybox",
"pwsh",
"powershell",
"su",
"sudo",
"sudoedit",
"doas",
"runuser",
"pkexec",
"setpriv",
"chroot",
"unshare",
"nsenter",
"capsh",
"python",
"python2",
"python3",
"perl",
"perl5",
"ruby",
"irb",
"lua",
"luac",
"node",
"nodejs",
"php",
"tclsh",
"wish",
"expect",
"gdb",
"julia",
"guile",
"rscript",
"awk",
"gawk",
"mawk",
"nawk",
"sed",
"ed",
"vi",
"vim",
"vimdiff",
"view",
"ex",
"nvim",
"emacs",
"nano",
"less",
"more",
"most",
"man",
"pg",
"info",
"env",
"nice",
"ionice",
"taskset",
"stdbuf",
"setarch",
"timeout",
"watch",
"flock",
"xargs",
"find",
"script",
"scriptreplay",
"socat",
"nc",
"ncat",
"netcat",
"tar",
"cpio",
"zip",
"unzip",
"rsync",
"scp",
"sftp",
"ftp",
"ssh",
"tmux",
"screen",
"byobu",
"make",
"cmake",
"strace",
"ltrace",
"tcpdump",
"docker",
"podman",
"git",
"crontab",
"at",
"systemctl",
"journalctl",
"apt",
"apt-get",
"dpkg",
"rpm",
"yum",
"dnf",
"zypper",
"pip",
"pip3",
"gem",
"npm",
"mount",
];
pub(crate) const SHELL_EQUIVALENT_SUFFIXES: &[&str] = &[".basic", ".tiny"];
fn unversioned_command_name(base: &str) -> &str {
let base = SHELL_EQUIVALENT_SUFFIXES
.iter()
.find_map(|suffix| base.strip_suffix(suffix))
.unwrap_or(base);
base.trim_end_matches(|c: char| c.is_ascii_digit() || c == '.')
}
pub fn shell_equivalent_sudo_command(commands: &str) -> Option<String> {
if commands == "ALL" {
return None;
}
commands
.split(',')
.filter(|c| !c.is_empty())
.find(|c| {
let base = c.rsplit('/').next().unwrap_or(c).to_ascii_lowercase();
SHELL_EQUIVALENT_COMMANDS.contains(&base.as_str())
|| SHELL_EQUIVALENT_COMMANDS.contains(&unversioned_command_name(&base))
})
.map(str::to_string)
}
pub fn validated_sudo_commands(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s == "ALL" {
return Ok(s.to_string());
}
if s.is_empty() || s.len() > MAX_SUDO_COMMANDS_LEN {
return Err(ExecutorError::InvalidParam(param));
}
let mut saw_command = false;
for c in s.split(',').filter(|c| !c.is_empty()) {
saw_command = true;
if !c.starts_with('/')
|| c.contains("..")
|| c.contains('*')
|| !c
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-'))
{
return Err(ExecutorError::InvalidParam(param));
}
}
if !saw_command {
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_pro_service(s: &str, param: &'static str) -> Result<String, ExecutorError> {
const SERVICES: &[&str] = &[
"esm-apps",
"esm-infra",
"livepatch",
"usg",
"fips",
"fips-updates",
"fips-preview",
"cis",
"ros",
"ros-updates",
"cc-eal",
"realtime-kernel",
"landscape",
"anbox-cloud",
];
if SERVICES.contains(&s) {
Ok(s.to_string())
} else {
Err(ExecutorError::InvalidParam(param))
}
}
pub fn validated_audit_path(s: &str, param: &'static str) -> Result<String, ExecutorError> {
validated_absolute_path(s, param, MAX_ABSOLUTE_PATH_LEN)
}
pub fn validated_audit_perms(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty() || s.len() > 4 || !s.chars().all(|c| matches!(c, 'r' | 'w' | 'x' | 'a')) {
return Err(ExecutorError::InvalidParam(param));
}
let mut seen = [false; 4];
for c in s.chars() {
let i = match c {
'r' => 0,
'w' => 1,
'x' => 2,
_ => 3,
};
if seen[i] {
return Err(ExecutorError::InvalidParam(param));
}
seen[i] = true;
}
Ok(s.to_string())
}
pub fn validated_domain(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.is_empty()
|| s.len() > MAX_DNS_NAME_LEN
|| s.contains("..")
|| s.starts_with('-')
|| s.starts_with('.')
|| s.ends_with('.')
{
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
pub fn validated_email(s: &str, param: &'static str) -> Result<String, ExecutorError> {
if s.len() < MIN_EMAIL_LEN || s.len() > MAX_EMAIL_LEN {
return Err(ExecutorError::InvalidParam(param));
}
let parts: Vec<&str> = s.split('@').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() || !parts[1].contains('.') {
return Err(ExecutorError::InvalidParam(param));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '@' | '.' | '_' | '+' | '-'))
{
return Err(ExecutorError::InvalidParam(param));
}
Ok(s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn activatable_unit_rejects_root_shell_units() {
for evil in [
"debug-shell.service",
"debug-shell",
"DEBUG-SHELL.SERVICE",
"emergency.service",
"emergency",
"rescue.service",
"rescue.target",
] {
assert!(
validated_activatable_unit(evil, "unit").is_err(),
"{evil:?} must not be activatable"
);
}
}
#[test]
fn activatable_unit_accepts_ordinary_services() {
for ok in [
"nginx.service",
"sshd.service",
"podman.socket",
"my-app@1.service",
] {
assert!(
validated_activatable_unit(ok, "unit").is_ok(),
"{ok:?} is an ordinary unit and must be activatable"
);
}
assert!(validated_activatable_unit("-x.service", "unit").is_err());
}
#[test]
fn resource_limit_unit_rejects_the_enforcement_and_evidence_path() {
for protected in [
"sysknife-daemon.service",
"sysknife-daemon",
"SYSKNIFE-DAEMON.SERVICE",
"auditd.service",
"systemd-journald.service",
"systemd-journald.socket",
"rsyslog.service",
"polkit.service",
"dbus.service",
"dbus-broker.service",
"systemd-logind.service",
"sshd.service",
"ssh.service",
"systemd-journald-audit.socket",
"systemd-journald-dev-log.socket",
"systemd-journald-varlink@7.socket",
"audit-rules.service",
"sshd@1.service",
"sysknife-daemon.socket",
"dbus.socket",
] {
assert!(
validated_resource_limit_unit(protected, "unit").is_err(),
"{protected:?} resource limits must not be settable at Dev tier"
);
}
}
#[test]
fn resource_limit_unit_rejects_every_slice_and_scope() {
for container in [
"system.slice",
"user.slice",
"machine.slice",
"user-1000.slice",
"init.scope",
"session-3.scope",
"SYSTEM.SLICE",
] {
assert!(
validated_resource_limit_unit(container, "unit").is_err(),
"{container:?} is a cgroup container, not one service"
);
}
}
#[test]
fn resource_limit_unit_accepts_ordinary_services() {
for ok in [
"nginx.service",
"postgresql.service",
"my-app@1.service",
"podman.socket",
"sysknife-nightly-backup.service",
] {
assert!(
validated_resource_limit_unit(ok, "unit").is_ok(),
"{ok:?} is an ordinary unit and must stay cappable"
);
}
assert!(validated_resource_limit_unit("-x.service", "unit").is_err());
assert!(validated_resource_limit_unit("", "unit").is_err());
}
#[test]
fn install_package_rejects_local_file_paths() {
for evil in [
"/tmp/evil.deb",
"./evil.deb",
"../evil.rpm",
"/home/attacker/x.rpm",
"evil.deb", "evil.rpm",
"nginx/bookworm", "-oAPT::foo=bar", "pkg;rm -rf /", ] {
assert!(
validated_install_package(evil, "package").is_err(),
"{evil:?} must be rejected as an install target"
);
}
}
#[test]
fn install_package_accepts_real_names() {
for ok in [
"nginx",
"python3-pip",
"lib32-glibc",
"gcc-c++",
"nginx=1.24.0-1",
"nginx:amd64",
"container-selinux",
"2:openssl", ] {
assert!(
validated_install_package(ok, "package").is_ok(),
"{ok:?} is a legitimate package spec and must be accepted"
);
}
}
#[test]
fn install_package_refuses_a_trailing_minus() {
for evil in [
"linux-image-generic-",
"linux-generic-hwe-24.04-",
"nginx-",
"a-",
"nginx=1.0-",
"nginx:amd64-",
] {
assert!(
validated_install_package(evil, "package").is_err(),
"{evil:?} would make apt-get install REMOVE a package and must be refused"
);
}
}
#[test]
fn apt_package_operands_refuse_the_task_selector() {
for evil in [
"ubuntu-desktop^",
"task-xfce^",
"nginx^",
"nginx^amd64",
"a^b",
"nginx=1.0^",
] {
assert!(
validated_install_package(evil, "package").is_err(),
"{evil:?} installs a whole task and must be refused for install"
);
assert!(
validated_unmodified_package(evil, "package").is_err(),
"{evil:?} must be refused for remove, purge, hold and unhold"
);
}
}
#[test]
fn install_package_keeps_names_that_end_in_plus() {
for ok in [
"g++",
"libstdc++6",
"gcc-c++",
"libc++-dev",
"clang++",
"nginx+",
] {
assert!(
validated_install_package(ok, "package").is_ok(),
"{ok:?} must stay installable"
);
}
}
#[test]
fn unmodified_package_refuses_either_trailing_modifier() {
for evil in [
"g++",
"clang++",
"nginx+",
"linux-image-generic+",
"nginx=1.0+",
"nginx-",
"linux-image-generic-",
"nginx=1.0-",
] {
assert!(
validated_unmodified_package(evil, "package").is_err(),
"{evil:?} would let apt-get remove, purge, hold or unhold do the opposite \
operation or act on a different name, and must be refused"
);
}
}
#[test]
fn unmodified_package_keeps_every_install_rule() {
for evil in [
"",
"-nginx",
"-oAPT::Update::Pre-Invoke::=/bin/sh",
"/tmp/x.deb",
"./x.deb",
"x.deb",
"x.rpm",
".nginx",
"nginx/noble",
"ng*",
"ng inx",
"nginx\n",
] {
assert!(
validated_unmodified_package(evil, "package").is_err(),
"{evil:?} must be refused"
);
}
for ok in [
"nginx",
"python3-pip",
"libstdc++6",
"nginx=1.24.0-1",
"nginx:amd64",
"g++:amd64",
] {
assert!(
validated_unmodified_package(ok, "package").is_ok(),
"{ok:?} must stay accepted; `g++:amd64` is how a `+` package is removed"
);
}
}
#[test]
fn apt_package_length_limit_is_128_for_both_validators() {
let longest = "a".repeat(128);
let too_long = "a".repeat(129);
assert!(validated_install_package(&longest, "package").is_ok());
assert!(validated_unmodified_package(&longest, "package").is_ok());
assert!(validated_install_package(&too_long, "package").is_err());
assert!(validated_unmodified_package(&too_long, "package").is_err());
}
#[test]
fn show_package_refuses_a_trailing_minus_and_keeps_the_release_selector() {
assert!(validated_apt_show_package("nginx-", "package").is_err());
assert!(validated_apt_show_package("linux-image-generic-", "package").is_err());
for ok in ["nginx", "nginx/noble", "nginx:amd64", "g++", "libstdc++6"] {
assert!(
validated_apt_show_package(ok, "package").is_ok(),
"{ok:?} must stay readable"
);
}
assert!(validated_apt_show_package("-nginx", "package").is_err());
assert!(validated_apt_show_package("", "package").is_err());
}
#[test]
fn rebase_ref_refuses_local_sources_and_keeps_remote_refs() {
for ok in [
"fedora/41/x86_64/silverblue",
"fedora:fedora/41/x86_64/silverblue",
"ostree-image-signed:docker://quay.io/fedora/fedora-silverblue:41",
"ostree-remote-registry:fedora:quay.io/fedora/fedora-silverblue:41",
] {
assert!(
validated_rebase_ref(ok, "target_ref").is_ok(),
"{ok:?} is a remote target and must stay valid"
);
}
for bad in [
"/tmp/repo",
"./repo",
"../repo",
"fedora:../../tmp/x",
"x:/tmp/repo",
"file:///tmp/repo",
"ostree-unverified-image:oci:/tmp/img",
"ostree-unverified-image:OCI:/tmp/img",
"ostree-unverified-image:dir:/tmp/img",
"ostree-unverified-image:containers-storage:localhost/x",
"ostree-unverified-image:ostree:x@/tmp/repo",
"ostree-unverified-image:docker:/tmp/x",
"ostree-unverified-registry:quay.io/fedora/fedora-silverblue:41",
"Ostree-Unverified-Registry:quay.io/x:1",
"ostree-unverified-image:docker://quay.io/x:1",
"-b",
"--experimental",
"",
] {
assert!(
validated_rebase_ref(bad, "target_ref").is_err(),
"{bad:?} must be refused"
);
}
}
#[test]
fn apt_package_parity_corpus_agrees_with_the_daemon_validators() {
let corpus = include_str!("../../tests/data/apt-package-parity.txt");
let mut seen: std::collections::BTreeSet<(String, String)> = Default::default();
let mut checked = 0;
for (index, line) in corpus.lines().enumerate() {
let number = index + 1;
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.splitn(3, ' ');
let (Some(verb), Some(verdict), Some(operand)) =
(parts.next(), parts.next(), parts.next())
else {
panic!("corpus line {number} is not `<verb> <ok|refuse> <operand>`: {line:?}");
};
let result = match verb {
"install" => validated_install_package(operand, "package"),
"remove" | "purge" | "hold" | "unhold" => {
validated_unmodified_package(operand, "package")
}
other => panic!("corpus line {number}: unknown verb {other:?}"),
};
match verdict {
"ok" => assert!(
result.is_ok(),
"corpus line {number}: `{verb} {operand}` must be accepted"
),
"refuse" => assert!(
result.is_err(),
"corpus line {number}: `{verb} {operand}` must be refused"
),
other => panic!("corpus line {number}: unknown verdict {other:?}"),
}
seen.insert((verb.to_string(), verdict.to_string()));
checked += 1;
}
assert!(
checked >= 60,
"only {checked} corpus lines were checked; the corpus was truncated or not parsed"
);
for verb in ["install", "remove", "purge", "hold", "unhold"] {
for verdict in ["ok", "refuse"] {
assert!(
seen.contains(&(verb.to_string(), verdict.to_string())),
"the corpus has no `{verb} {verdict}` line, so that half is unchecked"
);
}
}
}
#[test]
fn audit_path_and_perms() {
assert!(validated_audit_path("/etc/passwd", "p").is_ok());
assert!(validated_audit_path("relative", "p").is_err());
assert!(validated_audit_path("/etc/../shadow", "p").is_err());
assert!(validated_audit_path("/var/log/*.log", "p").is_err()); assert!(validated_audit_perms("wa", "p").is_ok());
assert!(validated_audit_perms("rwxa", "p").is_ok());
assert!(validated_audit_perms("ww", "p").is_err()); assert!(validated_audit_perms("z", "p").is_err());
assert!(validated_audit_perms("", "p").is_err());
}
#[test]
fn domain_and_email() {
assert!(validated_domain("example.com", "d").is_ok());
assert!(validated_domain("a.b.example.co.uk", "d").is_ok());
assert!(validated_domain("-bad.com", "d").is_err());
assert!(validated_domain("a..b.com", "d").is_err());
assert!(validated_domain("ex ample.com", "d").is_err());
assert!(validated_email("ops@example.com", "e").is_ok());
assert!(validated_email("no-at-sign", "e").is_err());
assert!(validated_email("a@b", "e").is_err()); assert!(validated_email("a@b@c.com", "e").is_err());
}
#[test]
fn pro_service_allowlist() {
assert!(validated_pro_service("esm-apps", "service").is_ok());
assert!(validated_pro_service("livepatch", "service").is_ok());
assert!(validated_pro_service("realtime-kernel", "service").is_ok());
assert!(validated_pro_service("bogus", "service").is_err());
assert!(validated_pro_service("--help", "service").is_err());
assert!(validated_pro_service("esm-apps; rm -rf /", "service").is_err());
assert!(validated_pro_service("", "service").is_err());
}
#[test]
fn username_accepts_valid() {
assert_eq!(
validated_username("alice", "username").unwrap(),
"alice".to_string()
);
assert_eq!(
validated_username("bob_99", "username").unwrap(),
"bob_99".to_string()
);
assert_eq!(
validated_username("user.name", "username").unwrap(),
"user.name".to_string()
);
assert_eq!(
validated_username("a-b", "username").unwrap(),
"a-b".to_string()
);
}
#[test]
fn username_rejects_empty() {
assert!(validated_username("", "username").is_err());
}
#[test]
fn username_rejects_starts_with_dash() {
assert!(validated_username("-alice", "username").is_err());
}
#[test]
fn username_rejects_traversal_forms() {
assert!(validated_username("..", "username").is_err());
assert!(validated_username(".", "username").is_err());
assert!(validated_username(".hidden", "username").is_err());
assert!(validated_username("a..b", "username").is_err());
}
#[test]
fn username_rejects_too_long() {
let long = "a".repeat(33);
assert!(validated_username(&long, "username").is_err());
}
#[test]
fn username_accepts_max_length() {
let max = "a".repeat(32);
assert!(validated_username(&max, "username").is_ok());
}
#[test]
fn username_rejects_spaces() {
assert!(validated_username("al ice", "username").is_err());
}
#[test]
fn username_rejects_slashes() {
assert!(validated_username("al/ice", "username").is_err());
}
#[test]
fn username_rejects_null_bytes() {
assert!(validated_username("al\0ice", "username").is_err());
}
#[test]
fn group_delegates_to_username_rules() {
assert!(validated_group("wheel", "group").is_ok());
assert!(validated_group("-bad", "group").is_err());
assert!(validated_group("", "group").is_err());
}
#[test]
fn username_not_critical_rejects_denylisted_accounts() {
for name in CRITICAL_ACCOUNTS {
assert!(
validated_username_not_critical(name, "username").is_err(),
"{name} must be rejected as a critical account"
);
}
}
#[test]
fn username_not_critical_allows_normal_user() {
assert_eq!(
validated_username_not_critical("alice", "username").unwrap(),
"alice".to_string()
);
}
#[test]
fn username_not_critical_still_enforces_charset() {
assert!(validated_username_not_critical("-alice", "username").is_err());
assert!(validated_username_not_critical("", "username").is_err());
}
#[test]
fn group_not_critical_rejects_denylisted_groups() {
for name in CRITICAL_GROUPS {
assert!(
validated_group_not_critical(name, "group").is_err(),
"{name} must be rejected as a critical group"
);
}
}
#[test]
fn group_not_critical_allows_normal_group() {
assert_eq!(
validated_group_not_critical("developers", "group").unwrap(),
"developers".to_string()
);
}
#[test]
fn unit_name_accepts_valid() {
assert!(validated_unit_name("sshd.service", "unit").is_ok());
assert!(validated_unit_name("NetworkManager.service", "unit").is_ok());
assert!(validated_unit_name("user@1000.service", "unit").is_ok());
assert!(validated_unit_name("dbus-org.freedesktop.resolve1.service", "unit").is_ok());
assert!(validated_unit_name("system-getty.slice:0", "unit").is_ok());
}
#[test]
fn unit_name_rejects_empty() {
assert!(validated_unit_name("", "unit").is_err());
}
#[test]
fn unit_name_rejects_leading_dash() {
assert!(validated_unit_name("--version", "unit").is_err());
assert!(validated_unit_name("-.mount", "unit").is_err());
}
#[test]
fn unit_name_rejects_slashes() {
assert!(validated_unit_name("foo/bar.service", "unit").is_err());
}
#[test]
fn unit_name_rejects_spaces() {
assert!(validated_unit_name("foo bar.service", "unit").is_err());
}
#[test]
fn unit_name_rejects_null_bytes() {
assert!(validated_unit_name("foo\0.service", "unit").is_err());
}
#[test]
fn hostname_accepts_valid() {
assert!(validated_hostname("sysknife-lab", "hostname").is_ok());
assert!(validated_hostname("my.host.example", "hostname").is_ok());
assert!(validated_hostname("a", "hostname").is_ok());
}
#[test]
fn hostname_rejects_empty() {
assert!(validated_hostname("", "hostname").is_err());
}
#[test]
fn hostname_rejects_too_long() {
let long = format!(
"{}.{}",
"a".repeat(63),
"b".repeat(253 - 63 - 1 + 1) );
assert!(validated_hostname(&long, "hostname").is_err());
}
#[test]
fn hostname_accepts_max_length() {
let hostname = format!("{}.{}.{}", "a".repeat(63), "b".repeat(63), "c".repeat(63));
assert!(validated_hostname(&hostname, "hostname").is_ok());
}
#[test]
fn hostname_rejects_label_too_long() {
let long_label = "a".repeat(64);
assert!(validated_hostname(&long_label, "hostname").is_err());
}
#[test]
fn hostname_rejects_empty_label() {
assert!(validated_hostname("foo..bar", "hostname").is_err());
assert!(validated_hostname(".foo", "hostname").is_err());
assert!(validated_hostname("foo.", "hostname").is_err());
}
#[test]
fn hostname_rejects_spaces() {
assert!(validated_hostname("my host", "hostname").is_err());
}
#[test]
fn hostname_rejects_underscores() {
assert!(validated_hostname("my_host", "hostname").is_err());
}
#[test]
fn hostname_rejects_leading_dash() {
assert!(validated_hostname("-host", "hostname").is_err());
}
#[test]
fn timezone_accepts_valid() {
assert!(validated_timezone("America/Mexico_City", "timezone").is_ok());
assert!(validated_timezone("UTC", "timezone").is_ok());
assert!(validated_timezone("Etc/GMT+5", "timezone").is_ok());
assert!(validated_timezone("US/Eastern", "timezone").is_ok());
}
#[test]
fn timezone_rejects_empty() {
assert!(validated_timezone("", "timezone").is_err());
}
#[test]
fn timezone_rejects_dot_dot() {
assert!(validated_timezone("America/../etc/passwd", "timezone").is_err());
assert!(validated_timezone("..", "timezone").is_err());
}
#[test]
fn timezone_rejects_spaces() {
assert!(validated_timezone("US/ Eastern", "timezone").is_err());
}
#[test]
fn timezone_rejects_leading_dash() {
assert!(validated_timezone("-America/Mexico_City", "timezone").is_err());
}
#[test]
fn timezone_rejects_null_bytes() {
assert!(validated_timezone("UTC\0", "timezone").is_err());
}
#[test]
fn locale_accepts_valid() {
assert!(validated_locale("en_US.UTF-8", "locale").is_ok());
assert!(validated_locale("C", "locale").is_ok());
assert!(validated_locale("POSIX", "locale").is_ok());
}
#[test]
fn locale_rejects_empty() {
assert!(validated_locale("", "locale").is_err());
}
#[test]
fn locale_rejects_spaces() {
assert!(validated_locale("en US.UTF-8", "locale").is_err());
}
#[test]
fn locale_rejects_slashes() {
assert!(validated_locale("en/US", "locale").is_err());
}
#[test]
fn locale_rejects_leading_dash() {
assert!(validated_locale("-en_US.UTF-8", "locale").is_err());
}
#[test]
fn locale_rejects_null_bytes() {
assert!(validated_locale("en\0US", "locale").is_err());
}
#[test]
fn safe_arg_accepts_valid() {
assert!(validated_safe_arg("org.mozilla.firefox", "app_id").is_ok());
assert!(validated_safe_arg("flathub", "remote").is_ok());
assert!(validated_safe_arg("my-container", "name").is_ok());
assert!(validated_safe_arg("registry.example.com/image:tag", "image").is_ok());
}
#[test]
fn safe_arg_rejects_empty() {
assert!(validated_safe_arg("", "name").is_err());
}
#[test]
fn safe_arg_rejects_null_bytes() {
assert!(validated_safe_arg("hello\0world", "name").is_err());
}
#[test]
fn safe_arg_rejects_starts_with_dash() {
assert!(validated_safe_arg("-evil", "name").is_err());
assert!(validated_safe_arg("--rm", "name").is_err());
}
#[test]
fn safe_arg_accepts_dash_not_at_start() {
assert!(validated_safe_arg("my-container", "name").is_ok());
}
#[test]
fn safe_arg_rejects_unicode_and_non_ascii() {
assert!(validated_safe_arg("café", "name").is_err());
assert!(validated_safe_arg("über", "name").is_err());
}
#[test]
fn safe_arg_rejects_every_shell_metacharacter() {
for meta in [
"a b", "a\tb", "a\nb", "a\rb", "a\0b", "a;b", "a&b", "a|b", "a$b", "a`b`", "a$(b)", "a>b", "a<b", "a\\b", "a\"b", "a'b", "a*b", "a?b", "a[b]", "a{b}", "a~b", "a!b", "a#b", "a%b", "a^b", "a=b", "a,b", "a(b)", ] {
assert!(
validated_safe_arg(meta, "arg").is_err(),
"should reject metacharacter sequence {meta:?}"
);
}
}
#[test]
fn safe_arg_rejects_oversized_input() {
let over = "a".repeat(SAFE_ARG_MAX_BYTES + 1);
assert!(validated_safe_arg(&over, "name").is_err());
let max = "a".repeat(SAFE_ARG_MAX_BYTES);
assert!(validated_safe_arg(&max, "name").is_ok());
}
#[test]
fn ppa_name_accepts_valid() {
assert!(validated_ppa_name("deadsnakes/ppa", "name").is_ok());
assert!(validated_ppa_name("user123/my-ppa", "name").is_ok());
assert!(validated_ppa_name("team.name/repo_name", "name").is_ok());
}
#[test]
fn ppa_name_rejects_no_slash() {
assert!(validated_ppa_name("nodeownerppa", "name").is_err());
}
#[test]
fn ppa_name_rejects_empty_user() {
assert!(validated_ppa_name("/ppa", "name").is_err());
}
#[test]
fn ppa_name_rejects_empty_ppa() {
assert!(validated_ppa_name("user/", "name").is_err());
}
#[test]
fn ppa_name_rejects_multiple_slashes() {
assert!(validated_ppa_name("a/b/c", "name").is_err());
}
#[test]
fn ppa_name_rejects_shell_metacharacters() {
assert!(validated_ppa_name("user/ppa;evil", "name").is_err());
assert!(validated_ppa_name("user$(cmd)/ppa", "name").is_err());
}
#[test]
fn apparmor_profile_accepts_absolute_path() {
assert!(
validated_apparmor_profile("/etc/apparmor.d/usr.bin.firefox", "profile_path").is_ok()
);
assert!(
validated_apparmor_profile("/etc/apparmor.d/abstractions/base", "profile_path").is_ok()
);
}
#[test]
fn apparmor_profile_accepts_profile_name() {
assert!(validated_apparmor_profile("usr.bin.firefox", "profile_path").is_ok());
}
#[test]
fn apparmor_profile_rejects_traversal_relative() {
assert!(validated_apparmor_profile("../../../tmp/evil", "profile_path").is_err());
}
#[test]
fn apparmor_profile_rejects_wrong_prefix() {
assert!(validated_apparmor_profile("/etc/passwd", "profile_path").is_err());
}
#[test]
fn apparmor_profile_rejects_traversal_in_path() {
assert!(
validated_apparmor_profile("/etc/apparmor.d/../../etc/passwd", "profile_path").is_err()
);
}
#[test]
fn apparmor_profile_rejects_relative_with_slash() {
assert!(validated_apparmor_profile("evil/profile", "profile_path").is_err());
}
#[test]
fn apparmor_profile_rejects_shell_metachars() {
assert!(validated_apparmor_profile("; rm -rf /", "profile_path").is_err());
}
#[test]
fn apparmor_profile_rejects_empty() {
assert!(validated_apparmor_profile("", "profile_path").is_err());
}
#[test]
fn apparmor_profile_rejects_too_long() {
let long = "a".repeat(APPARMOR_PROFILE_NAME_MAX + 1);
assert!(validated_apparmor_profile(&long, "profile_path").is_err());
}
#[test]
fn port_or_service_accepts_bare_ports() {
assert!(validated_port_or_service("22", "port_or_service").is_ok());
assert!(validated_port_or_service("1", "port_or_service").is_ok());
assert!(validated_port_or_service("65535", "port_or_service").is_ok());
}
#[test]
fn port_or_service_accepts_port_protocol() {
assert!(validated_port_or_service("22/tcp", "port_or_service").is_ok());
assert!(validated_port_or_service("53/udp", "port_or_service").is_ok());
assert!(validated_port_or_service("8080/tcp", "port_or_service").is_ok());
}
#[test]
fn port_or_service_accepts_app_profile_names() {
assert!(validated_port_or_service("OpenSSH", "port_or_service").is_ok());
assert!(validated_port_or_service("Apache", "port_or_service").is_ok());
assert!(validated_port_or_service("Nginx-Full", "port_or_service").is_ok());
}
#[test]
fn port_or_service_accepts_app_profile_names_with_internal_spaces() {
assert_eq!(
validated_port_or_service("Nginx Full", "port_or_service").unwrap(),
"Nginx Full".to_string()
);
assert!(validated_port_or_service("Apache Full", "port_or_service").is_ok());
}
#[test]
fn port_or_service_rejects_out_of_range_ports() {
assert!(validated_port_or_service("0", "port_or_service").is_err());
assert!(validated_port_or_service("65536", "port_or_service").is_err());
assert!(validated_port_or_service("99999", "port_or_service").is_err());
}
#[test]
fn port_or_service_rejects_bad_protocol_forms() {
assert!(validated_port_or_service("22/sctp", "port_or_service").is_err());
assert!(validated_port_or_service("22/tcp/extra", "port_or_service").is_err());
assert!(validated_port_or_service("22/", "port_or_service").is_err());
}
#[test]
fn port_or_service_rejects_port_without_slash() {
assert!(validated_port_or_service("22tcp", "port_or_service").is_err());
}
#[test]
fn port_or_service_rejects_empty() {
assert!(validated_port_or_service("", "port_or_service").is_err());
}
#[test]
fn port_or_service_rejects_too_long_app_name() {
let long = "A".repeat(UFW_APP_NAME_MAX + 1);
assert!(validated_port_or_service(&long, "port_or_service").is_err());
}
#[test]
fn port_or_service_rejects_shell_metachars() {
assert!(validated_port_or_service("; rm -rf /", "port_or_service").is_err());
}
#[test]
fn port_or_service_rejects_trailing_space_in_app_name() {
assert!(validated_port_or_service("Nginx Full ", "port_or_service").is_err());
}
#[test]
fn port_or_service_rejects_metachar_with_space_in_app_name() {
assert!(validated_port_or_service("hello; rm -rf /", "port_or_service").is_err());
}
#[test]
fn port_or_service_rejects_digit_leading_non_port() {
assert!(validated_port_or_service("2hello", "port_or_service").is_err());
}
#[test]
fn validators_return_invalid_param_with_correct_field_name() {
let err = validated_username("", "username").unwrap_err();
assert!(matches!(err, ExecutorError::InvalidParam("username")));
let err = validated_group("-bad", "group").unwrap_err();
assert!(matches!(err, ExecutorError::InvalidParam("group")));
let err = validated_unit_name("foo/bar", "unit").unwrap_err();
assert!(matches!(err, ExecutorError::InvalidParam("unit")));
let err = validated_hostname("", "hostname").unwrap_err();
assert!(matches!(err, ExecutorError::InvalidParam("hostname")));
let err = validated_timezone("..", "timezone").unwrap_err();
assert!(matches!(err, ExecutorError::InvalidParam("timezone")));
let err = validated_locale("", "locale").unwrap_err();
assert!(matches!(err, ExecutorError::InvalidParam("locale")));
let err = validated_safe_arg("-x", "name").unwrap_err();
assert!(matches!(err, ExecutorError::InvalidParam("name")));
}
#[test]
fn lvm_name_accepts_valid_and_rejects_injection() {
assert!(validated_lvm_name("ubuntu-vg", "vg").is_ok());
assert!(validated_lvm_name("root_lv.0", "lv").is_ok());
assert!(validated_lvm_name("data+cache", "lv").is_ok());
assert!(validated_lvm_name("-rf", "lv").is_err());
assert!(validated_lvm_name(".", "lv").is_err());
assert!(validated_lvm_name("..", "lv").is_err());
assert!(validated_lvm_name("vg/lv", "lv").is_err());
assert!(validated_lvm_name("", "lv").is_err());
}
#[test]
fn lvm_size_accepts_absolute_relative_decimal() {
assert!(validated_lvm_size("20G", "size").is_ok());
assert!(validated_lvm_size("+10G", "size").is_ok());
assert!(validated_lvm_size("512M", "size").is_ok());
assert!(validated_lvm_size("1.5T", "size").is_ok());
assert!(validated_lvm_size("4096", "size").is_ok()); }
#[test]
fn lvm_size_rejects_shrink_and_junk() {
assert!(validated_lvm_size("-10G", "size").is_err()); assert!(validated_lvm_size("10X", "size").is_err()); assert!(validated_lvm_size("G", "size").is_err()); assert!(validated_lvm_size("1.2.3G", "size").is_err()); assert!(validated_lvm_size("50%FREE", "size").is_err()); }
#[test]
fn journal_priority_accepts_levels_and_ranges() {
assert!(validated_journal_priority("err", "priority").is_ok());
assert!(validated_journal_priority("3", "priority").is_ok());
assert!(validated_journal_priority("0..3", "priority").is_ok());
assert!(validated_journal_priority("err..info", "priority").is_ok());
assert!(validated_journal_priority("8", "priority").is_err());
assert!(validated_journal_priority("fatal", "priority").is_err());
assert!(validated_journal_priority("err;info", "priority").is_err());
}
#[test]
fn journal_time_allows_forms_and_rejects_control() {
assert!(validated_journal_time("2026-07-22 10:00:00", "since").is_ok());
assert!(validated_journal_time("yesterday", "since").is_ok());
assert!(validated_journal_time("-1h", "since").is_ok());
assert!(validated_journal_time("2 days ago", "since").is_ok());
assert!(validated_journal_time("a\nb", "since").is_err());
assert!(validated_journal_time("", "since").is_err());
}
#[test]
fn journal_grep_rejects_control_chars() {
assert!(validated_journal_grep("connection timed out", "grep").is_ok());
assert!(validated_journal_grep("err.*fatal", "grep").is_ok());
assert!(validated_journal_grep("bad\nline", "grep").is_err());
assert!(validated_journal_grep("", "grep").is_err());
}
#[test]
fn sysctl_key_accepts_dotted_and_rejects_injection() {
assert!(validated_sysctl_key("net.ipv4.ip_forward", "key").is_ok());
assert!(validated_sysctl_key("vm.swappiness", "key").is_ok());
assert!(validated_sysctl_key("kernel.kptr_restrict", "key").is_ok());
assert!(validated_sysctl_key("-net.ipv4.ip_forward", "key").is_err()); assert!(validated_sysctl_key("net/ipv4/ip_forward", "key").is_err()); assert!(validated_sysctl_key("Net.Ipv4", "key").is_err()); assert!(validated_sysctl_key("", "key").is_err());
}
#[test]
fn sysctl_value_accepts_numbers_and_lists() {
assert!(validated_sysctl_value("1", "value").is_ok());
assert!(validated_sysctl_value("4096 87380 6291456", "value").is_ok()); assert!(validated_sysctl_value("kernel.core", "value").is_ok());
assert!(validated_sysctl_value("bad\nvalue", "value").is_err());
assert!(validated_sysctl_value("v$(id)", "value").is_err()); assert!(validated_sysctl_value("", "value").is_err());
}
#[test]
fn memory_limit_accepts_bytes_suffix_infinity() {
assert!(validated_memory_limit("infinity", "m").is_ok());
assert!(validated_memory_limit("500M", "m").is_ok());
assert!(validated_memory_limit("2G", "m").is_ok());
assert!(validated_memory_limit("1048576", "m").is_ok()); assert!(validated_memory_limit("500m", "m").is_err()); assert!(validated_memory_limit("500MB", "m").is_err()); assert!(validated_memory_limit("-5M", "m").is_err());
assert!(validated_memory_limit("", "m").is_err());
}
#[test]
fn cpu_quota_requires_percent() {
assert!(validated_cpu_quota("50%", "q").is_ok());
assert!(validated_cpu_quota("200%", "q").is_ok()); assert!(validated_cpu_quota("50", "q").is_err()); assert!(validated_cpu_quota("%", "q").is_err());
assert!(validated_cpu_quota("5.5%", "q").is_err());
}
#[test]
fn tasks_max_positive_int_or_infinity() {
assert!(validated_tasks_max("4096", "t").is_ok());
assert!(validated_tasks_max("infinity", "t").is_ok());
assert!(validated_tasks_max("40.5", "t").is_err());
assert!(validated_tasks_max("-1", "t").is_err());
assert!(validated_tasks_max("", "t").is_err());
}
#[test]
fn mount_device_accepts_forms_and_rejects_junk() {
assert!(validated_mount_device("/dev/sdb1", "d").is_ok());
assert!(validated_mount_device("UUID=1234abcd-5678", "d").is_ok());
assert!(validated_mount_device("LABEL=data", "d").is_ok());
assert!(validated_mount_device("//nas/share", "d").is_ok());
assert!(validated_mount_device("nas.local:/export", "d").is_ok());
assert!(validated_mount_device("-rf", "d").is_err()); assert!(validated_mount_device("/dev/../etc", "d").is_err()); assert!(validated_mount_device("$(id)", "d").is_err());
}
#[test]
fn mount_point_rejects_critical_and_traversal() {
assert!(validated_mount_point("/mnt/data", "m").is_ok());
assert!(validated_mount_point("/srv/backups", "m").is_ok());
assert!(validated_mount_point("/", "m").is_err()); assert!(validated_mount_point("/boot", "m").is_err()); assert!(validated_mount_point("/etc", "m").is_err()); assert!(validated_mount_point("/mnt/../etc", "m").is_err()); assert!(validated_mount_point("relative", "m").is_err()); }
#[test]
fn fstype_allowlist() {
assert!(validated_fstype("ext4", "f").is_ok());
assert!(validated_fstype("xfs", "f").is_ok());
assert!(validated_fstype("nfs", "f").is_ok());
assert!(validated_fstype("proc", "f").is_err());
assert!(validated_fstype("", "f").is_err());
}
#[test]
fn mount_options_and_swap_path() {
for bad in [
"suid",
"dev",
"ro,suid",
"suid,noatime",
"nodev,dev",
"DEV",
"SUID",
] {
assert!(
validated_mount_options(bad, "o").is_err(),
"mount options {bad:?} were accepted; suid and dev are the two that escalate"
);
}
assert_eq!(
hardened_mount_options(""),
"nosuid,nodev",
"an empty option list must still be hardened"
);
assert_eq!(
hardened_mount_options("ro,noatime"),
"ro,noatime,nosuid,nodev"
);
assert_eq!(hardened_mount_options("nosuid,nodev,ro"), "nosuid,nodev,ro");
assert!(validated_mount_options("exec,ro", "o").is_ok());
assert!(validated_mount_options("noatime,ro", "o").is_ok());
assert!(validated_mount_options("", "o").is_ok()); assert!(validated_mount_options("bad opt", "o").is_err()); assert!(validated_swap_path("/swapfile", "p").is_ok());
assert!(validated_swap_path("/var/swap/sk.swap", "p").is_ok());
assert!(validated_swap_path("swapfile", "p").is_err()); assert!(validated_swap_path("/../etc/x", "p").is_err()); }
#[test]
fn sudoers_name_no_dots_or_tildes() {
assert!(validated_sudoers_name("deploy-restart", "n").is_ok());
assert!(validated_sudoers_name("ci_01", "n").is_ok());
assert!(validated_sudoers_name("bad.name", "n").is_err()); assert!(validated_sudoers_name("bad~", "n").is_err()); assert!(validated_sudoers_name("-lead", "n").is_err());
assert!(validated_sudoers_name("", "n").is_err());
}
#[test]
fn sudo_commands_all_or_abs_paths() {
assert!(validated_sudo_commands("ALL", "c").is_ok());
assert!(validated_sudo_commands("/usr/bin/systemctl", "c").is_ok());
assert!(validated_sudo_commands("/usr/bin/systemctl,/usr/sbin/service", "c").is_ok());
assert!(validated_sudo_commands("systemctl", "c").is_err()); assert!(validated_sudo_commands("/usr/bin/*", "c").is_err()); assert!(validated_sudo_commands("/usr/bin/../bin/sh", "c").is_err()); assert!(validated_sudo_commands("/bin/sh; rm -rf /", "c").is_err()); }
#[test]
fn apt_pin_name_package_expr() {
assert!(validated_apt_pin_name("hold-nginx", "n").is_ok());
assert!(validated_apt_pin_name("bad.name", "n").is_err()); assert!(validated_apt_package("nginx", "p").is_ok());
assert!(validated_apt_package("libc6*", "p").is_ok()); assert!(validated_apt_package("bad pkg", "p").is_err()); assert!(validated_apt_pin_expr("release a=noble-security", "e").is_ok());
assert!(validated_apt_pin_expr("version 1.24.*", "e").is_ok());
assert!(validated_apt_pin_expr("origin\nrepo", "e").is_err()); assert!(validated_apt_pin_expr("", "e").is_err());
}
#[test]
fn log_path_and_syslog_host() {
assert!(validated_log_path("/var/log/nginx/*.log", "p").is_ok());
assert!(validated_log_path("/var/log/app.log", "p").is_ok());
assert!(validated_log_path("relative.log", "p").is_err());
assert!(validated_log_path("/var/log/../etc/x", "p").is_err());
assert!(validated_syslog_host("logs.example.com", "h").is_ok());
assert!(validated_syslog_host("10.0.0.5", "h").is_ok());
assert!(validated_syslog_host("fe80::1", "h").is_ok());
assert!(validated_syslog_host("-bad", "h").is_err());
assert!(validated_syslog_host("bad host", "h").is_err());
}
#[test]
fn log_path_is_confined_to_the_log_root() {
for outside in [
"/etc/shadow",
"/etc/*",
"/boot/*",
"/root/.ssh/authorized_keys",
"/usr/lib/sysknife/*",
"/*",
"/",
"/var/lib/*",
"/var/logs/x", "/var/log", "//var/log/x", ] {
assert!(
validated_log_path(outside, "p").is_err(),
"{outside} must not be accepted as a rotation target"
);
}
for inside in [
"/var/log/nginx/*.log",
"/var/log/syslog",
"/var/log/myapp/current.log",
] {
assert!(
validated_log_path(inside, "p").is_ok(),
"{inside} is a legitimate log glob"
);
}
}
}