use std::path::Path;
use seer_core::bulk::{BulkOperation, BulkResult, BulkResultData};
pub const MAX_BULK_FILE_SIZE: u64 = 1024 * 1024;
pub fn read_bulk_input<P: AsRef<Path>>(path: P) -> Result<String, String> {
let path = path.as_ref();
let metadata = std::fs::metadata(path)
.map_err(|e| format!("cannot stat input file {}: {}", path.display(), e))?;
if !metadata.is_file() {
return Err(format!(
"input path is not a regular file: {}",
path.display()
));
}
if metadata.len() > MAX_BULK_FILE_SIZE {
return Err(format!(
"input file exceeds {} byte limit: {} bytes",
MAX_BULK_FILE_SIZE,
metadata.len()
));
}
std::fs::read_to_string(path)
.map_err(|e| format!("failed to read input file {}: {}", path.display(), e))
}
pub fn format_interval(minutes: f64) -> String {
if minutes < 1.0 {
format!("{}s", (minutes * 60.0) as u64)
} else if minutes == 1.0 {
"1m".to_string()
} else {
format!("{}m", minutes)
}
}
pub fn bulk_results_to_csv(results: &[BulkResult], operation: &str) -> String {
let mut csv = String::new();
match operation {
"status" => {
csv.push_str("domain,success,http_status,http_status_text,title,ssl_issuer,ssl_valid_until,ssl_days_remaining,domain_expires,domain_days_remaining,registrar,dns_resolves,dns_a_records,dns_aaaa_records,dns_cname,dns_nameservers,duration_ms,error\n");
}
"lookup" | "whois" | "rdap" => {
csv.push_str("domain,success,registrar,created,expires,updated,duration_ms,error\n");
}
"dig" | "dns" => {
csv.push_str("domain,success,record_type,records,duration_ms,error\n");
}
"propagation" | "prop" => {
csv.push_str(
"domain,success,propagation_pct,servers_total,servers_responded,duration_ms,error\n",
);
}
"avail" => {
csv.push_str("domain,success,available,confidence,method,details,duration_ms,error\n");
}
"info" => {
csv.push_str("domain,success,source,registrar,registrant,organization,created,expires,updated,nameservers,status,dnssec,registrant_email,registrant_phone,registrant_address,registrant_country,admin_name,admin_organization,admin_email,admin_phone,tech_name,tech_organization,tech_email,tech_phone,whois_server,rdap_url,duration_ms,error\n");
}
_ => {
csv.push_str("domain,success,duration_ms,error\n");
}
}
for result in results {
let domain = escape_csv_field(&get_domain_from_operation(&result.operation));
let success = result.success;
let duration_ms = result.duration_ms;
let error = escape_csv_field(result.error.as_deref().unwrap_or(""));
match operation {
"status" => {
let (
http_status,
http_text,
title,
ssl_issuer,
ssl_valid_until,
ssl_days,
domain_expires,
domain_days,
registrar,
) = if let Some(BulkResultData::Status(ref s)) = result.data {
(
s.http_status
.map(|v: u16| v.to_string())
.unwrap_or_default(),
s.http_status_text.clone().unwrap_or_default(),
s.title.clone().unwrap_or_default(),
s.certificate
.as_ref()
.map(|c| c.issuer.clone())
.unwrap_or_default(),
s.certificate
.as_ref()
.map(|c| c.valid_until.format("%Y-%m-%d").to_string())
.unwrap_or_default(),
s.certificate
.as_ref()
.map(|c| c.days_until_expiry.to_string())
.unwrap_or_default(),
s.domain_expiration
.as_ref()
.map(|d| d.expiration_date.format("%Y-%m-%d").to_string())
.unwrap_or_default(),
s.domain_expiration
.as_ref()
.map(|d| d.days_until_expiry.to_string())
.unwrap_or_default(),
s.domain_expiration
.as_ref()
.and_then(|d| d.registrar.clone())
.unwrap_or_default(),
)
} else {
Default::default()
};
let (dns_resolves, dns_a, dns_aaaa, dns_cname, dns_ns) =
if let Some(BulkResultData::Status(ref s)) = result.data {
(
s.dns_resolution
.as_ref()
.map(|d| d.resolves.to_string())
.unwrap_or_default(),
s.dns_resolution
.as_ref()
.map(|d| d.a_records.join(";"))
.unwrap_or_default(),
s.dns_resolution
.as_ref()
.map(|d| d.aaaa_records.join(";"))
.unwrap_or_default(),
s.dns_resolution
.as_ref()
.and_then(|d| d.cname_target.clone())
.unwrap_or_default(),
s.dns_resolution
.as_ref()
.map(|d| d.nameservers.join(";"))
.unwrap_or_default(),
)
} else {
Default::default()
};
csv.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
domain,
success,
http_status,
escape_csv_field(&http_text),
escape_csv_field(&title),
escape_csv_field(&ssl_issuer),
ssl_valid_until,
ssl_days,
domain_expires,
domain_days,
escape_csv_field(®istrar),
dns_resolves,
escape_csv_field(&dns_a),
escape_csv_field(&dns_aaaa),
escape_csv_field(&dns_cname),
escape_csv_field(&dns_ns),
duration_ms,
error
));
}
"lookup" => {
let (registrar, created, expires, updated) = if let Some(ref data) = result.data {
match data {
BulkResultData::Lookup(seer_core::lookup::LookupResult::Rdap {
data: r,
..
}) => extract_rdap_dates(r),
BulkResultData::Lookup(seer_core::lookup::LookupResult::Whois {
data: w,
..
}) => (
w.registrar.clone().unwrap_or_default(),
w.creation_date
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_default(),
w.expiration_date
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_default(),
w.updated_date
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_default(),
),
_ => Default::default(),
}
} else {
Default::default()
};
csv.push_str(&format!(
"{},{},{},{},{},{},{},{}\n",
domain,
success,
escape_csv_field(®istrar),
created,
expires,
updated,
duration_ms,
error
));
}
"whois" => {
let (registrar, created, expires, updated) =
if let Some(BulkResultData::Whois(ref w)) = result.data {
(
w.registrar.clone().unwrap_or_default(),
w.creation_date
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_default(),
w.expiration_date
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_default(),
w.updated_date
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_default(),
)
} else {
Default::default()
};
csv.push_str(&format!(
"{},{},{},{},{},{},{},{}\n",
domain,
success,
escape_csv_field(®istrar),
created,
expires,
updated,
duration_ms,
error
));
}
"rdap" => {
let (registrar, created, expires, updated) =
if let Some(BulkResultData::Rdap(ref r)) = result.data {
extract_rdap_dates(r)
} else {
Default::default()
};
csv.push_str(&format!(
"{},{},{},{},{},{},{},{}\n",
domain,
success,
escape_csv_field(®istrar),
created,
expires,
updated,
duration_ms,
error
));
}
"dig" | "dns" => {
let (record_type, records) =
if let Some(BulkResultData::Dns(ref recs)) = result.data {
let rt = recs
.first()
.map(|r| r.record_type.to_string())
.unwrap_or_default();
let vals: Vec<String> = recs
.iter()
.map(seer_core::DnsRecord::format_short)
.collect();
(rt, vals.join("; "))
} else {
Default::default()
};
csv.push_str(&format!(
"{},{},{},{},{},{}\n",
domain,
success,
record_type,
escape_csv_field(&records),
duration_ms,
error
));
}
"propagation" | "prop" => {
let (pct, total, responded) =
if let Some(BulkResultData::Propagation(ref p)) = result.data {
let total = p.results.len();
let responded = p.results.iter().filter(|r| r.success).count();
let pct = if total > 0 {
(responded as f64 / total as f64) * 100.0
} else {
0.0
};
(
format!("{:.1}", pct),
total.to_string(),
responded.to_string(),
)
} else {
Default::default()
};
csv.push_str(&format!(
"{},{},{},{},{},{},{}\n",
domain, success, pct, total, responded, duration_ms, error
));
}
"avail" => {
let (available, confidence, method, details) =
if let Some(BulkResultData::Avail(ref a)) = result.data {
(
a.available.to_string(),
a.confidence.clone(),
a.method.clone(),
a.details.clone().unwrap_or_default(),
)
} else {
Default::default()
};
csv.push_str(&format!(
"{},{},{},{},{},{},{},{}\n",
domain,
success,
available,
confidence,
method,
escape_csv_field(&details),
duration_ms,
error
));
}
"info" => {
if let Some(BulkResultData::Info(ref info)) = result.data {
csv.push_str(&format!(
"{},{},{:?},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
domain,
success,
info.source,
escape_csv_field(info.registrar.as_deref().unwrap_or("")),
escape_csv_field(info.registrant.as_deref().unwrap_or("")),
escape_csv_field(info.organization.as_deref().unwrap_or("")),
info.creation_date.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default(),
info.expiration_date.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default(),
info.updated_date.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default(),
escape_csv_field(&info.nameservers.join(";")),
escape_csv_field(&info.status.join(";")),
escape_csv_field(info.dnssec.as_deref().unwrap_or("")),
escape_csv_field(info.registrant_email.as_deref().unwrap_or("")),
escape_csv_field(info.registrant_phone.as_deref().unwrap_or("")),
escape_csv_field(info.registrant_address.as_deref().unwrap_or("")),
escape_csv_field(info.registrant_country.as_deref().unwrap_or("")),
escape_csv_field(info.admin_name.as_deref().unwrap_or("")),
escape_csv_field(info.admin_organization.as_deref().unwrap_or("")),
escape_csv_field(info.admin_email.as_deref().unwrap_or("")),
escape_csv_field(info.admin_phone.as_deref().unwrap_or("")),
escape_csv_field(info.tech_name.as_deref().unwrap_or("")),
escape_csv_field(info.tech_organization.as_deref().unwrap_or("")),
escape_csv_field(info.tech_email.as_deref().unwrap_or("")),
escape_csv_field(info.tech_phone.as_deref().unwrap_or("")),
escape_csv_field(info.whois_server.as_deref().unwrap_or("")),
escape_csv_field(info.rdap_url.as_deref().unwrap_or("")),
duration_ms,
error
));
} else {
csv.push_str(&format!(
"{},{},,,,,,,,,,,,,,,,,,,,,,,,,{},{}\n",
domain, success, duration_ms, error
));
}
}
_ => {
csv.push_str(&format!(
"{},{},{},{}\n",
domain, success, duration_ms, error
));
}
}
}
csv
}
pub fn escape_csv_field(s: &str) -> String {
let s = if s.starts_with('=')
|| s.starts_with('+')
|| s.starts_with('-')
|| s.starts_with('@')
|| s.starts_with('\t')
|| s.starts_with('\r')
{
format!("'{}", s)
} else {
s.to_string()
};
if s.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s
}
}
pub fn get_domain_from_operation(op: &BulkOperation) -> String {
match op {
BulkOperation::Whois { domain } => domain.clone(),
BulkOperation::Rdap { domain } => domain.clone(),
BulkOperation::Dns { domain, .. } => domain.clone(),
BulkOperation::Propagation { domain, .. } => domain.clone(),
BulkOperation::Lookup { domain } => domain.clone(),
BulkOperation::Status { domain } => domain.clone(),
BulkOperation::Avail { domain } => domain.clone(),
BulkOperation::Info { domain } => domain.clone(),
}
}
pub fn extract_rdap_dates(r: &seer_core::rdap::RdapResponse) -> (String, String, String, String) {
let registrar = r.get_registrar().unwrap_or_default();
let created = r
.creation_date()
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_default();
let expires = r
.expiration_date()
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_default();
let updated = r
.last_updated()
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_default();
(registrar, created, expires, updated)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_bulk_input_rejects_directory() {
let dir = std::env::temp_dir();
let md = std::fs::metadata(&dir).expect("temp dir should exist");
assert!(!md.is_file(), "temp dir should not be a regular file");
let err = read_bulk_input(&dir).expect_err("directory must be rejected");
assert!(
err.contains("not a regular file"),
"unexpected error message: {err}"
);
}
#[test]
fn read_bulk_input_rejects_missing_path() {
let missing = std::env::temp_dir().join("seer-bulk-input-does-not-exist-xyzzy");
let _ = std::fs::remove_file(&missing);
let err = read_bulk_input(&missing).expect_err("missing path must error");
assert!(
err.contains("cannot stat"),
"unexpected error message: {err}"
);
}
#[test]
fn read_bulk_input_reads_regular_file() {
let path = std::env::temp_dir().join("seer-bulk-input-regular-file.txt");
std::fs::write(&path, "example.com\n").expect("write temp file");
let content = read_bulk_input(&path).expect("regular file should be readable");
assert_eq!(content, "example.com\n");
let _ = std::fs::remove_file(&path);
}
#[cfg(unix)]
#[test]
fn read_bulk_input_rejects_fifo() {
use std::os::unix::fs::FileTypeExt;
use std::process::Command;
let path =
std::env::temp_dir().join(format!("seer-bulk-input-fifo-{}", std::process::id()));
let _ = std::fs::remove_file(&path);
let status = Command::new("mkfifo").arg(&path).status();
let ok = match status {
Ok(s) => s.success(),
Err(_) => false,
};
if !ok {
eprintln!("skipping FIFO test: mkfifo binary unavailable or failed");
return;
}
let md = std::fs::metadata(&path).expect("stat fifo");
assert!(md.file_type().is_fifo(), "expected a FIFO");
assert!(
!md.is_file(),
"FIFO must not be classified as a regular file"
);
let err = read_bulk_input(&path).expect_err("FIFO must be rejected");
assert!(
err.contains("not a regular file"),
"unexpected error message: {err}"
);
let _ = std::fs::remove_file(&path);
}
}