exacl 0.12.0

Manipulate file system access control lists (ACL) on macOS, Linux, and FreeBSD
Documentation
//! API Tests for exacl module.

use ctor::ctor;
use exacl::{getfacl, setfacl, AclEntry, AclOption, Perm};
use log::debug;
use std::io;

#[ctor]
fn init() {
    env_logger::init();
}

#[test]
fn test_getfacl_file() -> io::Result<()> {
    let file = tempfile::NamedTempFile::new()?;
    let entries = getfacl(&file, None)?;

    #[cfg(target_os = "macos")]
    assert_eq!(entries.len(), 0);

    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
    assert_eq!(entries.len(), 3);

    debug!("test_getfacl_file: {}", exacl::to_string(&entries)?);

    // Test default ACL on macOS (should fail).
    #[cfg(target_os = "macos")]
    {
        let result = getfacl(&file, AclOption::DEFAULT_ACL);
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("macOS does not support default ACL"));
    }

    // Test default ACL (should be error; files don't have default ACL).
    #[cfg(target_os = "linux")]
    {
        let result = getfacl(&file, AclOption::DEFAULT_ACL);
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("Permission denied"));
    }

    // Test default ACL (should be error; files don't have default ACL).
    #[cfg(target_os = "freebsd")]
    {
        let result = getfacl(&file, AclOption::DEFAULT_ACL);
        // If file is using NFSv4 ACL, the error message will be
        // "Default ACL not supported", otherwise the error message will be
        // "Invalid argument".
        let errmsg = result.unwrap_err().to_string();
        assert!(
            errmsg.contains("Default ACL not supported") || errmsg.contains("Invalid argument")
        );
    }

    Ok(())
}

#[test]
fn test_setfacl_file() -> io::Result<()> {
    let file = tempfile::NamedTempFile::new()?;
    let mut entries = getfacl(&file, None)?;

    entries.push(AclEntry::allow_user("500", Perm::READ, None));
    setfacl(&[file], &entries, None)?;

    Ok(())
}

/// Get the type of filesystem from `df -Th` command output.
#[cfg(target_os = "linux")]
fn get_filesystem(path: &std::path::PathBuf) -> String {
    let df = std::process::Command::new("df")
        .arg("-Th")
        .arg(path)
        .stdout(std::process::Stdio::piped())
        .spawn()
        .expect("df is a valid unix command");
    let sed = std::process::Command::new("sed")
        .arg("1d")
        .stdin(df.stdout.unwrap())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .expect("sed is a valid unix command");
    let tr = std::process::Command::new("tr")
        .arg("-s")
        .arg(" ")
        .stdin(sed.stdout.unwrap())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .expect("tr is a valid unix command");
    let cut = std::process::Command::new("cut")
        .arg("-d")
        .arg(" ")
        .arg("-f2")
        .stdin(tr.stdout.unwrap())
        .output()
        .expect("cut is a valid unix command");
    String::from_utf8(cut.stdout)
        .expect("FS should be valid utf8")
        .trim_end()
        .to_string()
}

#[test]
#[cfg(target_os = "linux")]
fn test_too_many_entries() -> io::Result<()> {
    use std::collections::HashMap;
    const UNTESTED: u32 = 65535;

    let path = std::env::temp_dir();
    let fs = get_filesystem(&path);
    debug!("Running on filesystem: {{{}}} TMPDIR={:?}", fs, path);

    let supported_fs = HashMap::from([
        ("brtfs", UNTESTED),
        // FIXME: xfs is not tested. -wwf
        // https://elixir.bootlin.com/linux/latest/source/fs/xfs/libxfs/xfs_format.h#L1809
        ("xfs", 5461), // max ext attr size = 64KB
        ("tmpfs", 8191),
        ("ext2", 507),
        ("ext3", 507),
        ("ext4", 507),
        ("gpfs", UNTESTED),
        ("nss", UNTESTED),
    ]);
    assert!(
        supported_fs.contains_key(fs.as_str()),
        "Not a supported filesystem: {fs}"
    );
    let max_entries = supported_fs[fs.as_str()];
    if max_entries == UNTESTED {
        debug!("Filesystem {} is not tested!", fs);
    }

    let mut entries = vec![
        AclEntry::allow_user("", Perm::READ, None),
        AclEntry::allow_group("", Perm::READ, None),
        AclEntry::allow_other(Perm::empty(), None),
        AclEntry::allow_mask(Perm::READ, None),
    ];
    let max_entries = max_entries.saturating_sub(u32::try_from(entries.len()).unwrap());

    let offset = 500;
    for i in 0..max_entries {
        entries.push(AclEntry::allow_user(
            &(offset + i as usize).to_string(),
            Perm::READ,
            None,
        ));
    }

    let files = [tempfile::NamedTempFile::new_in(path)?];
    debug!("Call setfacl with {} entries...", entries.len());
    setfacl(&files, &entries, None)?;
    debug!("{} entries were added and it is okay", entries.len());

    // Add last entry.
    entries.push(AclEntry::allow_user(
        (u32::MAX - 1).to_string().as_str(),
        Perm::READ,
        None,
    ));

    // Last entry is one too many.
    let err = setfacl(&files, &entries, None).unwrap_err();
    debug!("Got error as expected: {}", err);
    assert!(
        err.to_string().contains("No space left on device")
            || err.to_string().contains("Argument list too long")
    );

    Ok(())
}

#[test]
fn test_reader_writer() -> io::Result<()> {
    let input = r#"
    u:aaa:rwx#comment
    g:bbb:rwx
    u:ccc:rx
    "#;

    let entries = exacl::from_str(input)?;
    let actual = exacl::to_string(&entries)?;

    let expected = r#"allow::user:aaa:read,write,execute
allow::group:bbb:read,write,execute
allow::user:ccc:read,execute
"#;
    assert_eq!(expected, actual);

    Ok(())
}

#[test]
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn test_exclusive_acloptions() {
    let path = "/tmp";

    let err1 = getfacl(path, AclOption::ACCESS_ACL | AclOption::DEFAULT_ACL).unwrap_err();
    assert_eq!(
        err1.to_string(),
        "ACCESS_ACL and DEFAULT_ACL are mutually exclusive options"
    );

    let err2 = setfacl(&[path], &[], AclOption::ACCESS_ACL | AclOption::DEFAULT_ACL).unwrap_err();
    assert_eq!(
        err2.to_string(),
        "ACCESS_ACL and DEFAULT_ACL are mutually exclusive options"
    );
}

#[test]
#[cfg(target_os = "macos")]
fn test_exclusive_acloptions() {
    let path = "/tmp";

    let err1 = getfacl(path, AclOption::ACCESS_ACL | AclOption::DEFAULT_ACL).unwrap_err();
    assert_eq!(
        err1.to_string(),
        "File \"/tmp\": macOS does not support default ACL"
    );

    let err2 = setfacl(&[path], &[], AclOption::ACCESS_ACL | AclOption::DEFAULT_ACL).unwrap_err();
    assert_eq!(
        err2.to_string(),
        "File \"/tmp\": macOS does not support default ACL"
    );
}

#[test]
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn test_from_mode() {
    let acl_7777 = exacl::to_string(&exacl::from_mode(0o7777)).unwrap();
    assert_eq!(acl_7777, "allow::user::read,write,execute\nallow::group::read,write,execute\nallow::other::read,write,execute\n");

    let acl_000 = exacl::to_string(&exacl::from_mode(0o000)).unwrap();
    assert_eq!(acl_000, "allow::user::\nallow::group::\nallow::other::\n");

    let acl_123 = exacl::to_string(&exacl::from_mode(0o123)).unwrap();
    assert_eq!(
        acl_123,
        "allow::user::execute\nallow::group::write\nallow::other::write,execute\n"
    );

    let acl_12345 = exacl::to_string(&exacl::from_mode(0o12345)).unwrap();
    assert_eq!(
        acl_12345,
        "allow::user::write,execute\nallow::group::read\nallow::other::read,execute\n"
    );
}