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()
}
fn unreadable(path: &OsStr, limit: u64) -> CliError {
let size = if limit == PROMPTS_LIMIT {
"16 MiB"
} else {
"1 MiB"
};
CliError::operational(format!(
"Cannot read {} (missing, not a regular file, unreadable or larger than {size})",
shown(path)
))
}
pub fn read_text(path: &OsStr, limit: u64) -> Result<String, 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());
}
String::from_utf8(bytes)
.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(())
}
#[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 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"
);
}
}