use std::io;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StateFinding {
pub host: usize,
pub port: u16,
pub latency_ms: u128,
pub status: String,
#[serde(default = "default_scan")]
pub scan: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub service: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub banner: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScanState {
pub proto: String,
pub targets: Vec<String>,
pub ports: Vec<u16>,
pub job_count: usize,
pub done: Vec<bool>,
pub findings: Vec<StateFinding>,
}
impl ScanState {
pub fn new(proto: &str, targets: Vec<String>, ports: Vec<u16>, job_count: usize) -> Self {
Self {
proto: proto.to_string(),
targets,
ports,
job_count,
done: vec![false; job_count],
findings: Vec::new(),
}
}
pub fn load(path: &Path) -> io::Result<ScanState> {
let text = std::fs::read_to_string(path)?;
serde_json::from_str(&text).map_err(io::Error::other)
}
pub fn save(&self, path: &Path) -> io::Result<()> {
let json = serde_json::to_string(self).map_err(io::Error::other)?;
let tmp = tmp_path(path);
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)
}
pub fn is_compatible(
&self,
proto: &str,
targets: &[String],
ports: &[u16],
job_count: usize,
) -> bool {
self.proto == proto
&& self.targets == targets
&& self.ports == ports
&& self.job_count == job_count
&& self.done.len() == job_count
}
pub fn remaining(&self) -> usize {
self.done.iter().filter(|d| !**d).count()
}
pub fn is_pending(&self, idx: usize) -> bool {
!self.done.get(idx).copied().unwrap_or(true)
}
pub fn complete(&mut self, idx: usize, finding: Option<StateFinding>) {
if let Some(slot) = self.done.get_mut(idx) {
*slot = true;
}
if let Some(finding) = finding {
self.findings.push(finding);
}
}
}
fn default_scan() -> String {
"connect".to_string()
}
fn tmp_path(path: &Path) -> std::path::PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(".tmp");
path.with_file_name(name)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> ScanState {
let mut s = ScanState::new(
"tcp",
vec!["127.0.0.1".into(), "127.0.0.2".into()],
vec![80, 443],
4,
);
s.complete(
0,
Some(StateFinding {
host: 0,
port: 80,
latency_ms: 5,
status: "open".into(),
scan: "connect".into(),
service: Some("http".into()),
banner: None,
}),
);
s.complete(1, None);
s
}
#[test]
fn round_trips_through_json() {
let s = sample();
let json = serde_json::to_string(&s).unwrap();
let back: ScanState = serde_json::from_str(&json).unwrap();
assert_eq!(s, back);
}
#[test]
fn tracks_done_and_remaining() {
let s = sample();
assert_eq!(s.remaining(), 2);
assert!(!s.is_pending(0));
assert!(!s.is_pending(1));
assert!(s.is_pending(2));
assert!(s.is_pending(3));
}
#[test]
fn an_ack_checkpoint_is_not_resumable_as_a_connect_scan() {
let ack = ScanState::new("tcp-ack", vec!["10.0.0.1".into()], vec![80], 1);
let targets = vec!["10.0.0.1".to_string()];
assert!(ack.is_compatible("tcp-ack", &targets, &[80], 1));
assert!(!ack.is_compatible("tcp", &targets, &[80], 1));
}
#[test]
fn ack_findings_round_trip_their_state_and_mode() {
let mut s = ScanState::new("tcp-ack", vec!["10.0.0.1".into()], vec![80], 1);
s.complete(
0,
Some(StateFinding {
host: 0,
port: 80,
latency_ms: 3,
status: "unfiltered".into(),
scan: "ack".into(),
service: None,
banner: None,
}),
);
let back: ScanState = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back.findings[0].status, "unfiltered");
assert_eq!(back.findings[0].scan, "ack");
}
#[test]
fn a_state_file_without_the_scan_field_reads_back_as_connect() {
let json = r#"{"proto":"tcp","targets":["10.0.0.1"],"ports":[80],"job_count":1,
"done":[true],"findings":[{"host":0,"port":80,"latency_ms":2,"status":"open"}]}"#;
let state: ScanState = serde_json::from_str(json).expect("legacy state should load");
assert_eq!(state.findings[0].scan, "connect");
}
#[test]
fn compatibility_requires_matching_shape() {
let s = sample();
let targets = vec!["127.0.0.1".to_string(), "127.0.0.2".to_string()];
assert!(s.is_compatible("tcp", &targets, &[80, 443], 4));
assert!(!s.is_compatible("udp", &targets, &[80, 443], 4));
assert!(!s.is_compatible("tcp", &targets, &[80], 4));
assert!(!s.is_compatible("tcp", &["127.0.0.1".to_string()], &[80, 443], 4));
assert!(!s.is_compatible("tcp", &targets, &[80, 443], 9));
}
#[test]
fn save_then_load_preserves_state() {
let dir = std::env::temp_dir();
let path = dir.join(format!("asphyxia-state-{}.json", std::process::id()));
let s = sample();
s.save(&path).expect("save");
let back = ScanState::load(&path).expect("load");
assert_eq!(s, back);
assert!(!tmp_path(&path).exists());
let _ = std::fs::remove_file(&path);
}
#[test]
fn findings_accumulate_across_completes() {
let mut s = ScanState::new("tcp", vec!["10.0.0.1".into()], vec![22], 1);
assert!(s.findings.is_empty());
s.complete(
0,
Some(StateFinding {
host: 0,
port: 22,
latency_ms: 1,
status: "open".into(),
scan: "connect".into(),
service: None,
banner: None,
}),
);
assert_eq!(s.findings.len(), 1);
assert_eq!(s.remaining(), 0);
}
}