use crate::error::CliError;
use std::ffi::OsStr;
use std::fs::File;
use std::io::Read;
use std::path::Path;
pub const CONFIG_LIMIT: u64 = 1_048_576;
pub const PROMPTS_LIMIT: u64 = 16_777_216;
pub fn shown(path: &OsStr) -> String {
path.to_string_lossy().into_owned()
}
pub const SCRIPT_LIMIT: u64 = 4 * 1_048_576;
pub const UPLOAD_LIMIT: u64 = 7 * 1_048_576;
fn unreadable(path: &OsStr, limit: u64) -> CliError {
CliError::operational(format!(
"Cannot read {} (missing, not a regular file, unreadable or larger than {} MiB)",
shown(path),
limit / 1_048_576
))
}
pub fn read_bytes(path: &OsStr, limit: u64) -> Result<Vec<u8>, CliError> {
let fail = || unreadable(path, limit);
let target = Path::new(path);
let metadata = std::fs::metadata(target).map_err(|_| fail())?;
if !metadata.is_file() || metadata.len() > limit {
return Err(fail());
}
let file = File::open(target).map_err(|_| fail())?;
let opened = file.metadata().map_err(|_| fail())?;
if !opened.is_file() || opened.len() > limit {
return Err(fail());
}
let mut bytes = Vec::new();
file.take(limit + 1)
.read_to_end(&mut bytes)
.map_err(|_| fail())?;
if bytes.len() as u64 > limit {
return Err(fail());
}
Ok(bytes)
}
pub fn read_text(path: &OsStr, limit: u64) -> Result<String, CliError> {
String::from_utf8(read_bytes(path, limit)?)
.map_err(|_| CliError::operational(format!("{} is not valid UTF-8", shown(path))))
}
pub fn read_config(path: &OsStr) -> Result<serde_json::Map<String, serde_json::Value>, CliError> {
let text = read_text(path, CONFIG_LIMIT)?;
let value: serde_json::Value = serde_json::from_str(&text)
.map_err(|_| CliError::operational(format!("{} is not valid JSON", shown(path))))?;
match value {
serde_json::Value::Object(object) => Ok(object),
_ => Err(CliError::operational(format!(
"{} must contain a JSON object",
shown(path)
))),
}
}
pub fn write_new(path: &OsStr, bytes: &[u8]) -> Result<(), CliError> {
use rustix::fs::{Mode, OFlags};
use std::io::Write;
let target = Path::new(path);
let flags = OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC;
let cannot_write = || {
CliError::operational(format!(
"Cannot write {}; check that its directory exists and is writable",
shown(path)
))
};
let fd = match rustix::fs::open(target, flags, Mode::from_raw_mode(0o644)) {
Ok(fd) => fd,
Err(rustix::io::Errno::EXIST) => {
return Err(CliError::operational(format!(
"{} already exists; choose another --output-file",
shown(path)
)));
}
Err(_) => return Err(cannot_write()),
};
let mut file = File::from(fd);
if file.write_all(bytes).and_then(|()| file.flush()).is_err() {
drop(file);
let _ = std::fs::remove_file(target);
return Err(cannot_write());
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Published {
Durable,
DirectoryNotSynced,
}
pub fn already_exists(path: &Path, flag: &str) -> CliError {
CliError::operational(format!(
"{} already exists; choose another {flag}",
shown(path.as_os_str())
))
}
pub fn refuse_existing(path: &Path, flag: &str) -> Result<(), CliError> {
match std::fs::symlink_metadata(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
_ => Err(already_exists(path, flag)),
}
}
pub fn publish_new(path: &Path, bytes: &[u8], flag: &str) -> Result<Published, CliError> {
use rustix::fs::{Mode, OFlags};
use std::io::Write;
let cannot_write = || {
CliError::operational(format!(
"Cannot write {}; check that its directory exists and is writable",
shown(path.as_os_str())
))
};
let dir = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => std::path::PathBuf::from("."),
};
let temp = dir.join(format!(
".airs-{}.tmp",
&uuid::Uuid::new_v4().simple().to_string()[..16]
));
let flags = OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC;
let fd =
rustix::fs::open(&temp, flags, Mode::from_raw_mode(0o600)).map_err(|_| cannot_write())?;
let written = rustix::fs::fchmod(&fd, Mode::from_raw_mode(0o600))
.map_err(std::io::Error::from)
.and_then(|()| {
let mut file = File::from(fd);
file.write_all(bytes)?;
file.flush()?;
file.sync_all()
});
if written.is_err() {
let _ = std::fs::remove_file(&temp);
return Err(cannot_write());
}
let linked = rustix::fs::link(&temp, path);
let _ = std::fs::remove_file(&temp);
match linked {
Ok(()) => {}
Err(rustix::io::Errno::EXIST) => return Err(already_exists(path, flag)),
Err(_) => return Err(cannot_write()),
}
if crate::store::atomic::sync_dir(&dir) {
Ok(Published::Durable)
} else {
Ok(Published::DirectoryNotSynced)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::path::PathBuf;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let dir = std::env::temp_dir()
.join(format!("airs-files-test-{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn temp(name: &str, bytes: &[u8]) -> (TempDir, OsString) {
let dir = TempDir::new();
let path = dir.path().join(name);
std::fs::write(&path, bytes).unwrap();
(dir, path.into_os_string())
}
fn message(result: Result<impl std::fmt::Debug, CliError>) -> String {
match result {
Err(CliError::Operational(failure)) => failure.message,
other => panic!("expected an operational error, got {other:?}"),
}
}
#[test]
fn config_files_are_bounded_utf8_json_objects() {
let (_dir, path) = temp("ok.json", br#"{"topic_name": "t", "n": 1.0}"#);
let object = read_config(&path).unwrap();
assert_eq!(object["topic_name"], "t");
let shown = path.to_string_lossy().into_owned();
for (bytes, expected) in [
(&b"[1]"[..], format!("{shown} must contain a JSON object")),
(b"\"x\"", format!("{shown} must contain a JSON object")),
(b"{", format!("{shown} is not valid JSON")),
(b"{} {}", format!("{shown} is not valid JSON")),
(br#"{"a": "\ud800"}"#, format!("{shown} is not valid JSON")),
(br#"{"a": 1e999}"#, format!("{shown} is not valid JSON")),
(b"{\"a\": \"\xff\"}", format!("{shown} is not valid UTF-8")),
] {
std::fs::write(&path, bytes).unwrap();
assert_eq!(message(read_config(&path)), expected, "{bytes:?}");
}
}
#[test]
fn new_files_are_created_exclusively() {
let dir = TempDir::new();
let path = dir.path().join("out.csv");
write_new(path.as_os_str(), b"a,b\n").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"a,b\n");
let shown = path.to_string_lossy().into_owned();
assert_eq!(
message(write_new(path.as_os_str(), b"x")),
format!("{shown} already exists; choose another --output-file")
);
assert_eq!(std::fs::read(&path).unwrap(), b"a,b\n", "untouched");
let link = dir.path().join("link.csv");
std::os::unix::fs::symlink(dir.path().join("dangling"), &link).unwrap();
assert!(
message(write_new(link.as_os_str(), b"x"))
.ends_with("already exists; choose another --output-file")
);
assert!(
!dir.path().join("dangling").exists(),
"the link is not followed"
);
let missing = dir.path().join("nodir").join("x.csv");
assert_eq!(
message(write_new(missing.as_os_str(), b"x")),
format!(
"Cannot write {}; check that its directory exists and is writable",
missing.to_string_lossy()
)
);
}
#[test]
fn published_files_are_private_exclusive_and_leave_no_temporary() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new();
let path = dir.path().join("t.csv");
assert_eq!(
publish_new(&path, b"a,b\n", "--output-file").unwrap(),
Published::Durable
);
assert_eq!(std::fs::read(&path).unwrap(), b"a,b\n");
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let shown = path.to_string_lossy().into_owned();
assert_eq!(
message(publish_new(&path, b"x", "--output-file")),
format!("{shown} already exists; choose another --output-file")
);
assert_eq!(std::fs::read(&path).unwrap(), b"a,b\n", "untouched");
let link = dir.path().join("link.csv");
std::os::unix::fs::symlink(dir.path().join("dangling"), &link).unwrap();
assert!(
message(refuse_existing(&link, "--output-dir"))
.ends_with("choose another --output-dir")
);
assert!(message(publish_new(&link, b"x", "--output-file")).contains("already exists"));
assert!(
!dir.path().join("dangling").exists(),
"the link is not followed"
);
assert!(refuse_existing(&dir.path().join("new.csv"), "--output-file").is_ok());
let missing = dir.path().join("nodir").join("x.csv");
assert!(message(publish_new(&missing, b"x", "--output-file")).starts_with("Cannot write "));
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|entry| entry.file_name().to_string_lossy().starts_with(".airs-"))
.collect();
assert!(leftovers.is_empty(), "no temporary is left");
}
#[test]
fn missing_directories_fifos_and_large_files_are_unreadable() {
let dir = TempDir::new();
let missing = dir.path().join("missing.json").into_os_string();
let expected = |path: &OsStr| {
format!(
"Cannot read {} (missing, not a regular file, unreadable or larger than 1 MiB)",
path.to_string_lossy()
)
};
assert_eq!(message(read_config(&missing)), expected(&missing));
let directory = dir.path().as_os_str().to_owned();
assert_eq!(message(read_config(&directory)), expected(&directory));
let big = dir.path().join("big.json");
std::fs::write(&big, vec![b' '; CONFIG_LIMIT as usize + 1]).unwrap();
assert_eq!(
message(read_config(big.as_os_str())),
expected(big.as_os_str())
);
let exact = dir.path().join("exact.json");
let mut bytes = vec![b' '; CONFIG_LIMIT as usize - 2];
bytes.splice(0..0, *b"{}");
std::fs::write(&exact, &bytes).unwrap();
assert!(read_config(exact.as_os_str()).unwrap().is_empty());
assert!(
read_text(big.as_os_str(), PROMPTS_LIMIT).is_ok(),
"a 1 MiB + 1 file is within the prompts limit"
);
}
}