// SPDX-License-Identifier: AGPL-3.0-only
//! The rule for an `emit-file` name. A name is ONE plain file name, never a
//! path: the caller writes it into a fresh output directory, and nothing a
//! guest says may reach outside it.
use std::fmt;
/// Longest accepted name, in bytes (the common file-system limit).
pub const MAX_NAME_BYTES: usize = 255;
/// Why a name was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NameRefusal {
Empty,
TooLong(usize),
LeadingDot,
Forbidden(char),
}
impl fmt::Display for NameRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NameRefusal::Empty => write!(f, "the name is empty"),
NameRefusal::TooLong(n) => write!(f, "the name is {n} bytes, more than {MAX_NAME_BYTES}"),
NameRefusal::LeadingDot => write!(f, "the name starts with '.' (hidden file, '.' or '..')"),
NameRefusal::Forbidden(c) => write!(f, "the name holds {c:?}; only ASCII letters, digits, '.', '_', '-' and '+' are allowed"),
}
}
}
/// Accept `name` only if it is one plain ASCII file name.
///
/// Stricter than `heimdall_core::validate_output_name` (every name accepted
/// here is accepted there too): ASCII letters, digits, `.`, `_`, `-`, `+`
/// only, 1..=255 bytes, and no leading `.` — so no `.`/`..`, no hidden file,
/// no separator, no control byte, no look-alike Unicode.
pub fn check_file_name(name: &str) -> Result<(), NameRefusal> {
if name.is_empty() {
return Err(NameRefusal::Empty);
}
if name.len() > MAX_NAME_BYTES {
return Err(NameRefusal::TooLong(name.len()));
}
if let Some(c) = name
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+')))
{
return Err(NameRefusal::Forbidden(c));
}
if name.starts_with('.') {
return Err(NameRefusal::LeadingDot);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::path::{Component, Path};
#[test]
fn plain_names_are_accepted() {
for n in [
"report.txt",
"a",
"A-b_c+d.e",
"00000031",
"x.tar.gz",
"a..b",
] {
assert_eq!(check_file_name(n), Ok(()), "{n}");
}
}
#[test]
fn traversal_and_separators_are_refused() {
for n in ["..", ".", "../x", "a/b", "/x", "a\\b", "x/..", ".git"] {
assert!(check_file_name(n).is_err(), "{n} accepted");
}
}
#[test]
fn empty_and_overlong_names_are_refused() {
assert_eq!(check_file_name(""), Err(NameRefusal::Empty));
assert_eq!(check_file_name(&"a".repeat(MAX_NAME_BYTES)), Ok(()));
assert_eq!(
check_file_name(&"a".repeat(MAX_NAME_BYTES + 1)),
Err(NameRefusal::TooLong(MAX_NAME_BYTES + 1))
);
}
#[test]
fn control_bytes_and_non_ascii_are_refused_and_named() {
assert_eq!(check_file_name("a\0b"), Err(NameRefusal::Forbidden('\0')));
assert_eq!(check_file_name("a\nb"), Err(NameRefusal::Forbidden('\n')));
assert_eq!(check_file_name("å"), Err(NameRefusal::Forbidden('å')));
assert_eq!(check_file_name("a b"), Err(NameRefusal::Forbidden(' ')));
}
#[test]
fn a_refusal_reads_as_a_sentence() {
assert!(NameRefusal::LeadingDot.to_string().contains("'.'"));
assert!(NameRefusal::Forbidden('/').to_string().contains("'/'"));
}
proptest! {
/// Whatever is accepted is exactly one normal path component: it can
/// never name a parent, the root, or another directory.
#[test]
fn an_accepted_name_is_one_normal_path_component(name in "\\PC{0,300}") {
if check_file_name(&name).is_ok() {
let parts: Vec<_> = Path::new(&name).components().collect();
prop_assert_eq!(parts.len(), 1);
prop_assert!(matches!(parts[0], Component::Normal(_)));
prop_assert!(!name.starts_with('.'));
prop_assert!(name.len() <= MAX_NAME_BYTES);
prop_assert!(name.is_ascii());
}
}
#[test]
fn names_from_the_allowed_alphabet_are_accepted(name in "[A-Za-z0-9_+-][A-Za-z0-9._+-]{0,254}") {
prop_assert_eq!(check_file_name(&name), Ok(()));
}
#[test]
fn every_name_accepted_here_is_accepted_by_heimdall_core(name in "\\PC{0,300}") {
if check_file_name(&name).is_ok() {
prop_assert!(heimdall_core::validate_output_name(&name).is_ok());
}
}
#[test]
fn any_name_with_a_separator_is_refused(a in "[a-z]{0,10}", b in "[a-z]{0,10}", sep in "[/\\\\]") {
let name = format!("{a}{sep}{b}");
prop_assert!(check_file_name(&name).is_err());
}
}
}