use super::store::{Device, HubStore};
use cyberbrain_core::{Error, Result};
use cyberbrain_policy::{AuditEvent, bundle};
use serde::Serialize;
pub const QUIET_AFTER_HOURS: i64 = 48;
#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Concern {
NeverReported,
Quiet { hours: i64 },
Refused { reason: String, at: String },
Behind { version: String, hub: String },
}
impl Concern {
pub fn line(&self) -> String {
match self {
Concern::NeverReported => "never reported".to_string(),
Concern::Quiet { hours } => format!("quiet for {hours} h"),
Concern::Refused { reason, at } => {
let short = reason.split(':').next().unwrap_or(reason);
format!("last delivery refused at {at}: {short}")
}
Concern::Behind { version, hub } => format!("version {version}, hub runs {hub}"),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct FleetRow {
#[serde(flatten)]
pub device: Device,
pub concerns: Vec<Concern>,
}
pub fn fleet(hub: &HubStore, now: jiff::Timestamp, hub_version: &str) -> Result<Vec<FleetRow>> {
let mut rows: Vec<FleetRow> = hub
.devices()?
.into_iter()
.map(|d| {
let concerns = concerns_for(&d, now, hub_version);
FleetRow {
device: d,
concerns,
}
})
.collect();
rows.sort_by_key(|r| (r.concerns.is_empty(), r.device.name.clone()));
Ok(rows)
}
fn concerns_for(d: &Device, now: jiff::Timestamp, hub_version: &str) -> Vec<Concern> {
if !d.is_active() {
return Vec::new();
}
let mut out = Vec::new();
match &d.last_seen {
None => out.push(Concern::NeverReported),
Some(seen) => {
if let Ok(t) = seen.parse::<jiff::Timestamp>() {
let hours = (now.as_second() - t.as_second()) / 3_600;
if hours >= QUIET_AFTER_HOURS {
out.push(Concern::Quiet { hours });
}
}
}
}
if let (Some(reason), Some(at)) = (&d.last_refusal, &d.last_refusal_at) {
out.push(Concern::Refused {
reason: reason.clone(),
at: at.clone(),
});
}
if let Some(v) = &d.version
&& v != hub_version
&& is_older(v, hub_version)
{
out.push(Concern::Behind {
version: v.clone(),
hub: hub_version.to_string(),
});
}
out
}
fn is_older(a: &str, b: &str) -> bool {
let parts = |s: &str| -> Option<Vec<u32>> {
s.split('.')
.map(|p| p.split(['-', '+']).next().unwrap_or(p).parse::<u32>().ok())
.collect()
};
match (parts(a), parts(b)) {
(Some(x), Some(y)) => x < y,
_ => false,
}
}
#[derive(Debug, Clone, Serialize)]
pub struct VerifyReport {
pub devices: Vec<DeviceChain>,
pub rows: i64,
pub ok: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct DeviceChain {
pub device: String,
pub name: String,
pub rows: usize,
pub chain: std::result::Result<usize, String>,
}
pub fn verify(hub: &HubStore) -> Result<VerifyReport> {
let mut devices = Vec::new();
let mut ok = true;
for d in hub.devices()? {
let rows = hub.rows_of(&d.id)?;
let chain = cyberbrain_policy::verify_chain_from(super::store::GENESIS, &rows)
.map_err(|e| e.to_string());
if chain.is_err() {
ok = false;
}
devices.push(DeviceChain {
device: d.id,
name: d.name,
rows: rows.len(),
chain,
});
}
Ok(VerifyReport {
devices,
rows: hub.total_entries()?,
ok,
})
}
pub fn device_bundle(
hub: &HubStore,
device: &str,
from: Option<&str>,
to: Option<&str>,
tool: &str,
) -> Result<(String, usize)> {
let all = hub.rows_of(device)?;
let rows: Vec<AuditEvent> = all
.into_iter()
.filter(|e| {
let ts = e.ts.to_string();
from.is_none_or(|f| ts.as_str() >= f) && to.is_none_or(|t| ts.as_str() <= t)
})
.collect();
let n = rows.len();
let text = bundle::render(
&rows,
from.map(str::to_owned),
to.map(str::to_owned),
tool,
&jiff::Timestamp::now().to_string(),
);
Ok((text, n))
}
pub fn write_report(
hub: &HubStore,
dir: &std::path::Path,
from: Option<&str>,
to: Option<&str>,
tool: &str,
) -> Result<serde_json::Value> {
std::fs::create_dir_all(dir).map_err(|e| Error::Io {
path: dir.to_path_buf(),
source: e,
})?;
let mut summary = String::new();
summary.push_str(&format!(
"Audit report\nperiod: {} to {}\nwritten: {} by {}\n\n",
from.unwrap_or("the beginning"),
to.unwrap_or("now"),
jiff::Timestamp::now(),
tool
));
let mut files = Vec::new();
let mut total = 0usize;
for d in hub.devices()? {
let (text, n) = device_bundle(hub, &d.id, from, to, tool)?;
let name = format!("{}.jsonl", d.id);
std::fs::write(dir.join(&name), &text).map_err(|e| Error::Io {
path: dir.join(&name),
source: e,
})?;
let verdict = match bundle::verify(&text) {
Ok(r) => format!("chain holds over {} row(s)", r.rows),
Err(e) => format!("PROBLEM: {e}"),
};
summary.push_str(&format!(
"{:<24} {:<20} {:>6} row(s) {}\n file: {}\n",
d.name, d.id, n, verdict, name
));
total += n;
files.push(name);
}
summary.push_str(&format!("\n{total} row(s) in this period\n"));
summary.push_str(
"\nEach file verifies on its own:\n cyberbrain verify-export <file>\n \
python3 scripts/verify-audit-export.py <file>\n",
);
let summary_path = dir.join("summary.txt");
std::fs::write(&summary_path, &summary).map_err(|e| Error::Io {
path: summary_path.clone(),
source: e,
})?;
Ok(serde_json::json!({
"directory": dir,
"files": files,
"rows": total,
"summary": summary,
}))
}
use super::access::{AccessRequest, Denied, Principal, RequestState, Role};
pub fn disclose(
hub: &HubStore,
token: Option<&str>,
request_id: &str,
dir: &std::path::Path,
now: jiff::Timestamp,
tool: &str,
) -> std::result::Result<serde_json::Value, Denied> {
let who: Principal = hub.principal_for(token, Role::Auditor)?;
let req: AccessRequest = hub
.request(request_id)
.map_err(|e| Denied::NotAuthorised(e.to_string()))?
.ok_or_else(|| Denied::NotApproved(request_id.to_string()))?;
match req.state(now) {
RequestState::Pending => return Err(Denied::NotApproved(req.id)),
RequestState::Closed => return Err(Denied::WindowClosed(req.id)),
RequestState::Open => {}
}
if req.requester != who.id {
return Err(Denied::NotAuthorised(format!(
"request {} was made by {}, not by you",
req.id, req.requester_name
)));
}
let devices: Vec<String> = match &req.device {
Some(d) => vec![d.clone()],
None => hub
.devices()
.map_err(|e| Denied::NotAuthorised(e.to_string()))?
.into_iter()
.map(|d| d.id)
.collect(),
};
std::fs::create_dir_all(dir)
.map_err(|e| Denied::NotAuthorised(format!("cannot write to {}: {e}", dir.display())))?;
let mut files = Vec::new();
let mut total = 0usize;
for id in &devices {
let (text, n) = device_bundle(hub, id, req.from.as_deref(), req.to.as_deref(), tool)
.map_err(|e| Denied::NotAuthorised(e.to_string()))?;
let name = format!("{id}.jsonl");
std::fs::write(dir.join(&name), &text)
.map_err(|e| Denied::NotAuthorised(format!("cannot write {name}: {e}")))?;
total += n;
files.push(name);
}
let _ = hub.note_disclosure(&req.id, &who.id, total, &now.to_string());
Ok(serde_json::json!({
"request": req.id,
"auditor": who.name,
"approved_by": req.approved_by_name,
"devices": devices.len(),
"rows": total,
"files": files,
"directory": dir,
"expires_at": req.expires_at,
}))
}