Files
craft-libs/crates/atomic/tests/permissions.rs

141 lines
6.2 KiB
Rust

#![cfg(unix)]
use craft_atomic::{NewFilePermissions, WriteOptions, atomic_write, write_new};
use std::fs::{self, File, Permissions};
use std::io::{self, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
struct TempDir(PathBuf);
impl TempDir {
fn new() -> io::Result<Self> {
static N: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!("craft-permissions-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
fs::create_dir(&path)?;
Ok(Self(path))
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn mode(path: &Path) -> io::Result<u32> {
Ok(fs::metadata(path)?.permissions().mode() & 0o777)
}
/// Set umask only in a separate shell process; changing it in a parallel test process races.
fn child(dir: &Path, mask: &str, expected: &str, check_acl: bool) -> io::Result<()> {
let output = Command::new("sh")
.args(["-c", "umask \"$1\"; exec \"$2\" --exact permission_child --nocapture", "permissions", mask])
.arg(std::env::current_exe()?)
.env("CRAFT_PERMISSION_TEST_DIR", dir)
.env("CRAFT_PERMISSION_TEST_EXPECTED", expected)
.env("CRAFT_PERMISSION_TEST_ACL", if check_acl { "1" } else { "0" })
.output()?;
assert!(output.status.success(), "umask {mask}:\n{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"), "child must execute its test");
Ok(())
}
#[test]
fn normal_creation_matches_os_permissions() -> io::Result<()> {
for (mask, expected) in [("000", "666"), ("002", "664"), ("022", "644"), ("027", "640"), ("077", "600")] {
let dir = TempDir::new()?;
child(&dir.0, mask, expected, false)?;
}
Ok(())
}
#[test]
fn permission_child() -> io::Result<()> {
let Some(dir) = std::env::var_os("CRAFT_PERMISSION_TEST_DIR") else { return Ok(()) };
let dir = Path::new(&dir);
let expected = u32::from_str_radix(&std::env::var("CRAFT_PERMISSION_TEST_EXPECTED").map_err(io::Error::other)?, 8).map_err(io::Error::other)?;
let ordinary = dir.join("ordinary");
fs::write(&ordinary, b"ordinary")?;
assert_eq!(mode(&ordinary)?, expected);
let options = WriteOptions::default();
let payload = b"document bytes";
for (tag, streaming, exclusive) in
[("replace", false, false), ("replace-stream", true, false), ("exclusive", false, true), ("exclusive-stream", true, true)]
{
let path = dir.join(tag);
let fill = |f: &mut File| {
assert_eq!(f.metadata()?.permissions().mode() & 0o777, expected, "normal creation before writing");
f.write_all(payload)?;
assert_eq!(f.metadata()?.permissions().mode() & 0o777, expected, "normal creation during writing");
Ok(())
};
match (streaming, exclusive) {
(false, false) => options.atomic_write(&path, payload)?,
(false, true) => options.write_new(&path, payload)?,
(true, false) => options.atomic_write_with(&path, fill)?,
(true, true) => options.write_new_with(&path, fill)?,
}
assert_eq!(mode(&path)?, expected, "{tag}");
assert_eq!(fs::read(&path)?, payload);
if std::env::var("CRAFT_PERMISSION_TEST_ACL").as_deref() == Ok("1") {
let acl = |p: &Path| -> io::Result<Vec<u8>> {
let output = Command::new("getfacl").args(["-c", "-n"]).arg(p).output()?;
assert!(output.status.success());
Ok(output.stdout)
};
assert_eq!(acl(&path)?, acl(&ordinary)?, "same inherited ACL for {tag}");
}
if exclusive {
assert_eq!(options.write_new(&path, b"must not replace").map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists));
assert_eq!(fs::read(&path)?, payload);
} else {
fs::set_permissions(&path, Permissions::from_mode(0o660))?;
options.atomic_write(&path, b"replacement")?;
assert_eq!(mode(&path)?, 0o660, "preserve existing mode regardless of current umask");
}
}
for (tag, write) in [("default", atomic_write as fn(&Path, &[u8]) -> io::Result<()>), ("default-exclusive", write_new)] {
let p = dir.join(tag);
write(&p, b"document")?;
assert_eq!(mode(&p)?, expected);
}
let private = WriteOptions::default().with_new_file_permissions(NewFilePermissions::Private);
for exclusive in [false, true] {
let p = dir.join(format!("private-{exclusive}"));
let fill = |f: &mut File| {
assert_eq!(f.metadata()?.permissions().mode() & 0o077, 0, "private from creation");
f.write_all(b"private")
};
if exclusive {
private.write_new_with(&p, fill)?;
} else {
private.atomic_write_with(&p, fill)?;
}
assert_eq!(mode(&p)?, 0o600);
}
for exclusive in [false, true] {
let path = dir.join(format!("callback-error-{exclusive}"));
let fill = |f: &mut File| {
f.write_all(b"partial")?;
Err(io::Error::from(io::ErrorKind::StorageFull))
};
let result = if exclusive { options.write_new_with(&path, fill) } else { options.atomic_write_with(&path, fill) };
assert_eq!(result.map_err(|e| e.kind()), Err(io::ErrorKind::StorageFull));
assert!(!path.exists());
}
assert!(fs::read_dir(dir)?.all(|e| e.is_ok_and(|e| !e.file_name().to_string_lossy().ends_with(".craft_tmp"))));
Ok(())
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires setfacl/getfacl and a filesystem with POSIX ACL support"]
fn directory_default_acl_takes_precedence_over_restrictive_umask() -> io::Result<()> {
let dir = TempDir::new()?;
let output = Command::new("setfacl").args(["-m", "d:u::rwx,d:u:12345:r--,d:g::r-x,d:m::r-x,d:o::---"]).arg(&dir.0).output()?;
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
// With this default ACL, ordinary creation yields 0640 even under umask 077.
child(&dir.0, "077", "640", true)
}