Gunnar

nordisk/heimdall

crates/heimdall-wasm/src/names.rs

// 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());
        }
    }
}