pub mod checkpoint;
pub mod engine;
pub mod input;
pub mod lock;
pub mod output;
pub mod plan;
pub mod state;
use crate::error::CliError;
use crate::store::{dir_metadata_ok, euid, shown, unsafe_dir_error};
use rustix::fs::{Mode, OFlags};
use std::fs::File;
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};
pub const MAX_PROMPTS: usize = 10_000;
pub const CHUNK_SIZE: usize = 20;
pub const MAX_STATE_BYTES: u64 = 128 * 1024 * 1024;
pub const MAX_BATCH_SIZE: u64 = (1 << 53) - 1;
pub const PROGRAM: &str = crate::store::PROGRAM;
pub const STATE_SUFFIX: &str = ".bulk-scan.json";
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ItemStatus {
Pending,
Submitting,
Submitted,
Complete,
Failed,
Ambiguous,
}
impl ItemStatus {
pub fn name(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Submitting => "submitting",
Self::Submitted => "submitted",
Self::Complete => "complete",
Self::Failed => "failed",
Self::Ambiguous => "ambiguous",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AttemptOutcome {
InFlight,
Accepted,
UnusableReceipt,
Rejected,
NotSent,
Unknown,
}
pub fn is_scan_id(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() == 36
&& bytes.iter().enumerate().all(|(index, byte)| match index {
8 | 13 | 18 | 23 => *byte == b'-',
_ => byte.is_ascii_hexdigit(),
})
}
pub fn ranges(indices: &[u64]) -> String {
let mut sorted = indices.to_vec();
sorted.sort_unstable();
sorted.dedup();
let mut parts = Vec::new();
let mut iter = sorted.into_iter().peekable();
while let Some(start) = iter.next() {
let mut end = start;
while iter.peek() == Some(&(end + 1)) {
end += 1;
iter.next();
}
parts.push(if start == end {
start.to_string()
} else {
format!("{start}-{end}")
});
}
parts.join(", ")
}
pub fn random_hex() -> String {
uuid::Uuid::new_v4().as_bytes()[..8]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
pub fn new_job_id(now: std::time::SystemTime) -> String {
let stamp = crate::debug::iso_timestamp(now);
let compact: String = stamp
.get(..19)
.unwrap_or_default()
.chars()
.filter(|c| c.is_ascii_digit() || *c == 'T')
.collect();
format!("{compact}Z-{}", random_hex())
}
pub fn is_job_id(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() == 33
&& bytes.iter().enumerate().all(|(index, byte)| match index {
0..=7 | 9..=14 => byte.is_ascii_digit(),
8 => *byte == b'T',
15 => *byte == b'Z',
16 => *byte == b'-',
_ => matches!(byte, b'0'..=b'9' | b'a'..=b'f'),
})
}
pub fn now_stamp() -> String {
crate::debug::iso_timestamp(std::time::SystemTime::now())
}
pub fn is_stamp(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() == 24
&& bytes.iter().enumerate().all(|(index, byte)| match index {
4 | 7 => *byte == b'-',
10 => *byte == b'T',
13 | 16 => *byte == b':',
19 => *byte == b'.',
23 => *byte == b'Z',
_ => byte.is_ascii_digit(),
})
}
pub fn state_dir_path(
xdg_state_home: Option<&std::ffi::OsStr>,
home: Option<&Path>,
) -> Result<PathBuf, CliError> {
if let Some(state) = xdg_state_home.filter(|value| !value.is_empty()) {
let state = Path::new(state);
if !state.is_absolute() {
return Err(CliError::operational(
"XDG_STATE_HOME must be an absolute path",
));
}
return Ok(crate::store::normalize(&state.join("airs-cli/bulk-scans")));
}
match home.filter(|home| home.is_absolute()) {
Some(home) => Ok(crate::store::normalize(
&home.join(".local/state/airs-cli/bulk-scans"),
)),
None => Err(CliError::operational(
"Cannot store the bulk-scan state: HOME is not an absolute path; set XDG_STATE_HOME",
)),
}
}
#[derive(Debug)]
pub struct StateDir {
path: PathBuf,
fd: File,
}
impl StateDir {
pub fn ensure(dir: &Path) -> Result<Self, CliError> {
let cannot = || {
CliError::operational(format!(
"Cannot create {}; check the permissions of its parent directory",
shown(dir)
))
};
match std::fs::symlink_metadata(dir) {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.map_err(|_| cannot())?;
}
Err(_) => return Err(cannot()),
}
Self::open(dir)
}
pub fn open(dir: &Path) -> Result<Self, CliError> {
let refuse = |path: &Path| unsafe_dir_error(path);
match std::fs::symlink_metadata(dir) {
Ok(metadata) if !metadata.file_type().is_dir() => return Err(refuse(dir)),
Ok(_) => {}
Err(_) => {
return Err(CliError::operational(format!(
"Cannot create {}; check the permissions of its parent directory",
shown(dir)
)));
}
}
let path = std::fs::canonicalize(dir).map_err(|_| refuse(dir))?;
let fd = rustix::fs::open(
&path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(|_| refuse(&path))?;
let fd = File::from(fd);
let metadata = fd.metadata().map_err(|_| refuse(&path))?;
if !dir_metadata_ok(
metadata.file_type().is_dir(),
metadata.uid(),
metadata.permissions().mode(),
euid(),
) {
return Err(refuse(&path));
}
Ok(Self { path, fd })
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn fd(&self) -> &File {
&self.fd
}
pub fn sync(&self) -> bool {
checkpoint::injected(checkpoint::Step::DirSync).is_ok() && self.fd.sync_all().is_ok()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Identity {
pub dev: u64,
pub ino: u64,
}
impl Identity {
#[allow(clippy::unnecessary_cast)]
pub fn of_stat(stat: &rustix::fs::Stat) -> Self {
Self {
dev: stat.st_dev as u64,
ino: stat.st_ino as u64,
}
}
pub fn of_file(file: &File) -> std::io::Result<Self> {
let metadata = file.metadata()?;
Ok(Self {
dev: metadata.dev(),
ino: metadata.ino(),
})
}
}
#[allow(clippy::unnecessary_cast)]
pub fn private_regular(stat: &rustix::fs::Stat) -> bool {
rustix::fs::FileType::from_raw_mode(stat.st_mode) == rustix::fs::FileType::RegularFile
&& stat.st_uid == euid()
&& (stat.st_mode as u32) & 0o077 == 0
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, UNIX_EPOCH};
#[test]
fn ranges_are_ascending_runs() {
assert_eq!(ranges(&(0..20).collect::<Vec<_>>()), "0-19");
assert_eq!(ranges(&[24, 18, 20, 21, 22, 23]), "18, 20-24");
assert_eq!(ranges(&[5]), "5");
assert_eq!(ranges(&[1, 3, 5]), "1, 3, 5");
assert_eq!(ranges(&[]), "");
}
#[test]
fn job_ids_have_the_documented_shape() {
let id = new_job_id(UNIX_EPOCH + Duration::from_secs(1_792_000_000));
assert!(id.starts_with("20261014T174640Z-"), "{id}");
assert!(is_job_id(&id), "{id}");
assert_ne!(
id,
new_job_id(UNIX_EPOCH + Duration::from_secs(1_792_000_000))
);
for bad in [
"",
"20261014T174640Z-0123456789abcdeF",
"20261014T174640-0123456789abcdef0",
"2026101xT174640Z-0123456789abcdef",
"20261014T174640Z-0123456789abcde",
] {
assert!(!is_job_id(bad), "{bad}");
}
}
#[test]
fn scan_ids_are_uuid_shaped() {
assert!(is_scan_id("00000000-0000-4000-8000-0000000000a1"));
assert!(is_scan_id("ABCDEF00-0000-4000-8000-0000000000A1"));
for bad in [
"not-a-uuid",
"",
"00000000-0000-4000-8000-0000000000a",
"00000000_0000-4000-8000-0000000000a1",
] {
assert!(!is_scan_id(bad), "{bad}");
}
}
#[test]
fn the_state_directory_follows_xdg_then_home() {
let home = Path::new("/home/u");
assert_eq!(
state_dir_path(Some("/s//x/".as_ref()), Some(home)).unwrap(),
Path::new("/s/x/airs-cli/bulk-scans")
);
assert_eq!(
state_dir_path(None, Some(home)).unwrap(),
Path::new("/home/u/.local/state/airs-cli/bulk-scans")
);
assert_eq!(
state_dir_path(Some("".as_ref()), Some(home)).unwrap(),
Path::new("/home/u/.local/state/airs-cli/bulk-scans")
);
assert_eq!(
state_dir_path(Some("rel".as_ref()), Some(home))
.unwrap_err()
.message(),
"XDG_STATE_HOME must be an absolute path"
);
assert_eq!(
state_dir_path(None, Some(Path::new("rel")))
.unwrap_err()
.message(),
"Cannot store the bulk-scan state: HOME is not an absolute path; set XDG_STATE_HOME"
);
assert!(state_dir_path(None, None).is_err());
}
#[test]
fn stamps_have_the_state_shape() {
assert!(is_stamp(&now_stamp()));
assert!(!is_stamp("2026-10-17T12:00:00Z"));
}
}