use crate::evidence::{
build_receiver_trust_pack, generate_seed, public_key_hex, verify_bundle, Files, PackRequest,
VerifyOptions,
};
use crate::interference_map::api::{self, DatasetSpec, DEFAULT_CELL_DEG};
use crate::receiver_trust::assess::{self, ExcerptAssessment};
use crate::receiver_trust::live::parse_live_scenario;
use crate::receiver_trust::scenario::{self, ReceiverTrustScenario, TrustOutput};
use serde_json::{json, Value};
pub const MAX_INPUT_BYTES: usize = 64 * 1024 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MapSource {
Adsb,
Ais,
}
impl MapSource {
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"adsb" => Ok(Self::Adsb),
"ais" => Ok(Self::Ais),
o => Err(format!("unknown source `{o}`: use `adsb` or `ais`")),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct CustomDataset {
pub licence: String,
pub licence_url: String,
pub attribution: String,
}
#[derive(Clone, Debug)]
pub struct MapDay {
pub file_name: String,
pub date: String,
pub cells_published: usize,
pub cells_flagged: usize,
pub geojson: String,
}
fn cap(what: &str, text: &str, max: usize) -> Result<(), String> {
cap_len(what, text.len(), max)
}
fn cap_len(what: &str, len: usize, max: usize) -> Result<(), String> {
let limit = max.min(MAX_INPUT_BYTES);
if len > limit {
return Err(format!(
"{what} is {len} bytes, over the {limit}-byte limit"
));
}
Ok(())
}
pub use crate::inline_only::{reject_file_sources, FILE_SOURCE_KEYS, FILE_SOURCE_KEYS_BY_KIND};
pub fn assess_receiver_log_inline(toml: &str, max_bytes: usize) -> Result<TrustOutput, String> {
reject_file_sources(toml, max_bytes)?;
let scn: ReceiverTrustScenario =
toml::from_str(toml).map_err(|e| format!("invalid receiver-trust scenario: {e}"))?;
if scn.log.source.path.is_some() || scn.log.nav.as_ref().is_some_and(|n| n.path.is_some()) {
return Err(
"`path` sources are not accepted here: give the log inline as `text` or `base64`"
.into(),
);
}
scenario::run_scenario(&scn)
}
pub fn assess_vessel_log_json(
session_toml: &str,
log: &[u8],
max_bytes: usize,
) -> Result<String, String> {
cap("session", session_toml, max_bytes)?;
cap_len("log", log.len(), max_bytes)?;
let r = assess::assess_vessel_log(session_toml, log)?;
serde_json::to_string_pretty(&r).map_err(|e| e.to_string())
}
pub fn assess_vessel_excerpt(
session_toml: &str,
excerpt: &[u8],
max_bytes: usize,
) -> Result<ExcerptAssessment, String> {
cap("session", session_toml, max_bytes)?;
cap_len("excerpt", excerpt.len(), max_bytes)?;
assess::assess_stream_excerpt(session_toml, excerpt)
}
fn dataset_spec<'a>(
key: &'a str,
custom: Option<&'a CustomDataset>,
) -> Result<DatasetSpec<'a>, String> {
if key == "custom" {
let c = custom.ok_or("dataset `custom` needs licence, licence_url and attribution")?;
if c.licence.is_empty() || c.licence_url.is_empty() || c.attribution.is_empty() {
return Err("dataset `custom` needs licence, licence_url and attribution".into());
}
Ok(DatasetSpec::Custom {
licence: &c.licence,
licence_url: &c.licence_url,
attribution: &c.attribution,
})
} else {
Ok(DatasetSpec::Preset(key))
}
}
pub fn interference_map(
source: MapSource,
csv: &str,
dataset: &str,
cell_deg: Option<f64>,
custom: Option<&CustomDataset>,
land_geojson: Option<&str>,
max_bytes: usize,
) -> Result<Vec<MapDay>, String> {
cap("input", csv, max_bytes)?;
if let Some(l) = land_geojson {
cap("land file", l, max_bytes)?;
}
let spec = dataset_spec(dataset, custom)?;
let cell = cell_deg.unwrap_or(DEFAULT_CELL_DEG);
let days = match source {
MapSource::Adsb => api::adsb_maps_from_csv(csv, &spec, cell),
MapSource::Ais => api::ais_maps_from_csv(csv, &spec, cell, land_geojson),
}
.map_err(|e| e.to_string())?;
days.into_iter()
.map(|d| {
let features = d.geojson["features"]
.as_array()
.map_or(&[][..], Vec::as_slice);
let flagged = features
.iter()
.filter(|f| f["properties"]["degraded"] == Value::Bool(true))
.count();
Ok(MapDay {
file_name: d.file_name,
date: d.date,
cells_published: features.len(),
cells_flagged: flagged,
geojson: serde_json::to_string_pretty(&d.geojson).map_err(|e| e.to_string())?,
})
})
.collect()
}
pub fn route_exposure(
route_text: &str,
maps: &[&str],
from: Option<&str>,
to: Option<&str>,
max_bytes: usize,
) -> Result<String, String> {
cap("route", route_text, max_bytes)?;
if maps.is_empty() {
return Err("at least one map is required".into());
}
for (i, m) in maps.iter().enumerate() {
cap(&format!("map {}", i + 1), m, max_bytes)?;
}
let v = api::route_exposure(route_text, maps, from, to).map_err(|e| e.to_string())?;
serde_json::to_string_pretty(&v).map_err(|e| e.to_string())
}
#[derive(Clone, Debug)]
pub struct NmeaTraining {
pub nmea: String,
pub log_json: String,
pub log_text: String,
}
pub fn nmea_training(
toml: &str,
seed: Option<u64>,
max_bytes: usize,
) -> Result<NmeaTraining, String> {
cap("scenario", toml, max_bytes)?;
let g = crate::nmea_synth::generate_from_toml(toml, seed)?;
Ok(NmeaTraining {
nmea: g.nmea_text(),
log_json: g.log.to_json(),
log_text: g.log.to_text(),
})
}
pub const BENCH_NOTICE: &str =
"Vehicle motion and labelled event intervals only. Nothing exported \
here is, models or drives a radio-frequency or baseband signal, and an event is a time \
interval, not a recipe for producing interference. The laboratory simulator and its operator \
supply the signals and are responsible for running them only where that is authorised \
(conducted or shielded, per the simulator's own safety instructions). Kshana publishes no \
results from such runs.";
#[derive(Clone, Debug)]
pub struct BenchExport {
pub files: Vec<(String, String)>,
pub notes: Vec<String>,
}
pub fn bench_export(
toml: &str,
epoch: Option<&str>,
max_bytes: usize,
) -> Result<BenchExport, String> {
cap("scenario", toml, max_bytes)?;
let epoch = epoch
.map(crate::interop::UtcEpoch::parse_iso)
.transpose()
.map_err(|e| format!("epoch: {e}"))?;
let (files, notes) =
crate::interop::testbench::export_with_notes(toml, epoch).map_err(|e| e.to_string())?;
let files = files
.into_iter()
.map(|f| {
String::from_utf8(f.bytes)
.map(|t| (f.suffix.clone(), t))
.map_err(|_| format!("{}: not UTF-8", f.suffix))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(BenchExport { files, notes })
}
const ABBREVIATIONS: [&str; 5] = ["et al", "e.g", "i.e", "vs", "cf"];
pub fn first_comment_sentence(toml: &str) -> String {
let paragraph = toml
.lines()
.skip_while(|l| !l.starts_with('#'))
.map_while(|l| l.strip_prefix('#'))
.map(str::trim)
.take_while(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
let mut from = 0;
while let Some(i) = paragraph[from..].find(". ") {
let stop = from + i;
let before = ¶graph[..stop];
let abbreviated = ABBREVIATIONS.iter().any(|a| {
before
.strip_suffix(a)
.is_some_and(|head| !head.ends_with(|c: char| c.is_alphanumeric()))
});
if !abbreviated {
return paragraph[..=stop].to_string();
}
from = stop + 2;
}
paragraph
}
pub fn scenario_kind_name(toml: &str) -> &'static str {
crate::api::ScenarioKind::classify(toml)
.map(|k| k.as_str())
.unwrap_or("clock")
}
#[cfg(feature = "bundled-scenarios")]
pub fn list_examples(kind: Option<&str>) -> Result<Value, String> {
let wanted = kind.map(str::trim).filter(|k| !k.is_empty());
if let Some(k) = wanted {
if !crate::api::list_scenario_kinds()
.iter()
.any(|m| m.name == k)
{
return Err(format!(
"unknown scenario kind `{k}`; the kind list names every kind"
));
}
}
let scenarios: Vec<Value> = crate::bundled_scenarios::BUNDLED
.iter()
.map(|(name, toml)| (*name, scenario_kind_name(toml), *toml))
.filter(|(_, k, _)| wanted.is_none_or(|w| w == *k))
.map(|(name, k, toml)| {
json!({"name": name, "kind": k, "about": first_comment_sentence(toml)})
})
.collect();
Ok(json!({"count": scenarios.len(), "scenarios": scenarios}))
}
#[cfg(feature = "bundled-scenarios")]
pub fn get_example(name: &str) -> Result<&'static str, String> {
let name = name.trim();
if let Some(toml) = crate::bundled_scenarios::get(name) {
return Ok(toml);
}
Err(match crate::bundled_scenarios::repo_only_reason(name) {
Some(why) => format!("`{name}` is not bundled: it {why}"),
None => format!(
"no bundled scenario named `{name}`; the example list names the {} that are",
crate::bundled_scenarios::BUNDLED.len()
),
})
}
pub fn export_formats(toml: &str, max_bytes: usize) -> Result<Value, String> {
cap("scenario", toml, max_bytes)?;
if let Err(e) = toml::from_str::<toml::Value>(toml) {
return Err(format!("invalid TOML: {e}"));
}
Ok(Value::Array(
crate::interop::plan(toml)
.into_iter()
.map(|(format, outcome)| {
json!({
"format": format.as_str(),
"applies": outcome.is_ok(),
"reason": outcome.err(),
"spec_url": format.spec_url(),
})
})
.collect(),
))
}
#[derive(Clone, Debug)]
pub struct ExportedFile {
pub suffix: String,
pub bytes: usize,
pub sha256: String,
pub encoding: &'static str,
pub content: String,
}
#[derive(Clone, Debug)]
pub struct ScenarioExport {
pub format: &'static str,
pub spec_url: &'static str,
pub files: Vec<ExportedFile>,
}
impl ScenarioExport {
pub fn to_json(&self) -> Value {
json!({
"format": self.format,
"spec_url": self.spec_url,
"files": self.files.iter().map(|f| json!({
"suffix": f.suffix, "bytes": f.bytes, "sha256": f.sha256,
"encoding": f.encoding, "content": f.content,
})).collect::<Vec<_>>(),
})
}
}
pub fn export_scenario(
toml: &str,
format: &str,
max_bytes: usize,
) -> Result<ScenarioExport, String> {
cap("scenario", toml, max_bytes)?;
let fmt = crate::interop::Format::parse(format.trim()).map_err(|_| {
format!(
"unknown export format `{}`; expected one of czml, kml, geojson, stk or sigmf",
format.trim()
)
})?;
let files = crate::interop::export(toml, fmt).map_err(|e| format!("{}: {e}", fmt.as_str()))?;
Ok(ScenarioExport {
format: fmt.as_str(),
spec_url: fmt.spec_url(),
files: files
.into_iter()
.map(|f| {
let sha256 = crate::advanced_report::sha256_hex(&f.bytes);
let bytes = f.bytes.len();
let (encoding, content) = match String::from_utf8(f.bytes) {
Ok(t) => ("utf-8", t),
Err(e) => ("base64", crate::permalink::base64_encode(e.as_bytes())),
};
ExportedFile {
suffix: f.suffix,
bytes,
sha256,
encoding,
content,
}
})
.collect(),
})
}
pub fn import_route(toml: &str, geojson: &str, max_bytes: usize) -> Result<String, String> {
cap("scenario", toml, max_bytes)?;
cap("route", geojson, max_bytes)?;
crate::interop::geojson::apply_route(toml, geojson)
}
pub const MAX_ANIMATION_FRAMES: usize = 120;
#[derive(Clone, Debug)]
pub struct AnimationOut {
pub summary: Value,
pub files: Vec<(String, String)>,
}
pub fn animate_scenario(
toml: &str,
format: Option<&str>,
fps: Option<u32>,
duration_s: Option<f64>,
width: Option<u32>,
max_bytes: usize,
) -> Result<AnimationOut, String> {
use crate::animation::{animate_result, animation_meta, AnimationFormat, AnimationOptions};
cap("scenario", toml, max_bytes)?;
let format = AnimationFormat::parse(format.unwrap_or("svg"))?;
let mut opts = AnimationOptions::default();
if let Some(n) = fps {
opts.fps = n;
}
if let Some(d) = duration_s {
opts.duration_s = d;
}
if let Some(w) = width {
opts.width = w;
}
opts.validate()?;
if format == AnimationFormat::Frames && opts.frame_count() > MAX_ANIMATION_FRAMES {
return Err(format!(
"duration_s x fps gives {} frames and at most {MAX_ANIMATION_FRAMES} are returned; \
lower fps or duration_s, or write a longer sequence with the command line",
opts.frame_count()
));
}
let kind = scenario_kind_name(toml);
let out = crate::api::run_toml(toml).map_err(|e| format!("scenario run failed: {e}"))?;
let anim = animate_result(&out.json, Some(kind), format, &opts).map_err(|e| e.to_string())?;
let names: Vec<String> = anim.files.iter().map(|f| f.name.clone()).collect();
let summary = animation_meta(&out.json, Some(kind), &[format], &opts, &names)
.map_err(|e| e.to_string())?;
Ok(AnimationOut {
summary,
files: anim
.files
.into_iter()
.map(|f| (f.name, f.content))
.collect(),
})
}
pub fn iq_labfit_inline(toml_text: &str, max_bytes: usize) -> Result<Value, String> {
cap("scenario", toml_text, max_bytes)?;
let v: toml::Value =
toml::from_str(toml_text).map_err(|e| format!("iq-labfit scenario: {e}"))?;
if v.get("input").and_then(|i| i.get("campaign")).is_some() {
return Err(
"[input] campaign names a campaign folder, and this surface reads no files; \
campaigns are read only by the CLI (`kshana iq labfit`), Python \
(`kshana.iq_labfit`), or the MCP labfit tool inside its IQ work directory"
.into(),
);
}
if let Some(runs) = v.get("runs").and_then(toml::Value::as_array) {
for (i, r) in runs.iter().enumerate() {
let log = r.get("log");
let named = log.and_then(|l| l.get("path")).is_some()
|| log
.and_then(|l| l.get("nav"))
.and_then(|n| n.get("path"))
.is_some();
if named {
return Err(format!(
"runs[{i}].log names a file `path`; give the log as `text` or `base64` \
(this surface reads no files)"
));
}
}
}
let out = crate::iq::labfit::run_toml(toml_text)?;
let report: Value = serde_json::from_str(&out.json).map_err(|e| e.to_string())?;
Ok(json!({
"report": report,
"residuals_csv": out.residuals_csv,
"predictions_csv": out.predictions_csv,
"markdown": out.markdown,
}))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn iq_test_conditions_inline(text: &str, max_bytes: usize) -> Result<Value, String> {
cap("test conditions", text, max_bytes)?;
let tc = crate::iq::campaign::TestConditions::parse(text)?;
let mut v = serde_json::to_value(&tc).map_err(|e| e.to_string())?;
if let Some(m) = v.as_object_mut() {
m.insert("hash".into(), tc.hash().into());
}
Ok(v)
}
pub const MAX_COMPLIANCE_RUNS: usize = 64;
#[derive(Clone, Debug, Default)]
pub struct ComplianceRunText {
pub label: String,
pub result_json: String,
pub scenario_toml: Option<String>,
}
#[derive(Clone, Debug)]
pub struct ComplianceOutput {
pub report: Value,
pub markdown: String,
}
pub fn compliance_report(
runs: &[ComplianceRunText],
max_bytes: usize,
) -> Result<ComplianceOutput, String> {
if runs.len() > MAX_COMPLIANCE_RUNS {
return Err(format!(
"{} runs given; at most {MAX_COMPLIANCE_RUNS} per report",
runs.len()
));
}
let mut total = 0usize;
for r in runs {
cap("result", &r.result_json, max_bytes)?;
total += r.result_json.len();
if let Some(t) = &r.scenario_toml {
cap("scenario", t, max_bytes)?;
total += t.len();
}
}
if total > max_bytes {
return Err(format!(
"the runs total {total} bytes; the limit for one report is {max_bytes}"
));
}
let inputs: Vec<crate::compliance::RunInput<'_>> = runs
.iter()
.map(|r| crate::compliance::RunInput {
label: &r.label,
result_json: &r.result_json,
scenario_toml: r.scenario_toml.as_deref(),
})
.collect();
let rep = crate::compliance::assess_texts(&inputs);
Ok(ComplianceOutput {
report: json!(rep),
markdown: rep.to_markdown(),
})
}
pub fn compliance_mapping(sources: bool) -> String {
use crate::compliance::mapping::{framework_table_md, sources_md, Framework};
let mut s = format!("> {}\n\n", crate::compliance::STATEMENT);
for fw in Framework::ALL {
let body = if sources {
sources_md(fw)
} else {
framework_table_md(fw)
};
s.push_str(&format!("## {}\n\n{}\n", fw.title(), body));
}
s
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TelemetryFormat {
Prometheus,
Cef,
Leef,
Otlp,
}
impl TelemetryFormat {
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"prometheus" => Ok(Self::Prometheus),
"cef" => Ok(Self::Cef),
"leef" => Ok(Self::Leef),
"otlp" => Ok(Self::Otlp),
o => Err(format!(
"unknown format `{o}`: use `prometheus`, `cef`, `leef` or `otlp`"
)),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TelemetryInput {
Result,
Jsonl,
}
impl TelemetryInput {
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"result" => Ok(Self::Result),
"jsonl" => Ok(Self::Jsonl),
o => Err(format!("unknown input_kind `{o}`: use `result` or `jsonl`")),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct TelemetryOptions {
pub expose_position: bool,
pub syslog_all: bool,
pub unix_nano: u64,
}
pub fn trust_telemetry(
input: &str,
format: TelemetryFormat,
host: &str,
input_kind: TelemetryInput,
opts: TelemetryOptions,
max_bytes: usize,
) -> Result<String, String> {
use crate::telemetry::{prometheus::Registry, sample, syslog};
cap("input", input, max_bytes)?;
syslog::check_host(host)?;
let version = env!("CARGO_PKG_VERSION");
let host = if host.is_empty() { "kshana" } else { host };
let mut reg = Registry::new(version);
reg.set_expose_position(opts.expose_position);
let mut samples: Vec<sample::TrustSample> = Vec::new();
match input_kind {
TelemetryInput::Result => samples = sample::from_result_json(input)?,
TelemetryInput::Jsonl => {
for line in input.lines().filter(|l| !l.trim().is_empty()) {
match sample::parse_live_line(line) {
Ok(s) => samples.push(s),
Err(_) => reg.observe_input_error(),
}
}
}
}
if samples.is_empty() {
return Err("the input holds no trust epoch".into());
}
let mut out = String::new();
let mut prev = None;
for s in &samples {
reg.observe(s, None);
if matches!(format, TelemetryFormat::Cef | TelemetryFormat::Leef)
&& (opts.syslog_all || prev != Some(s.band))
{
let payload = if format == TelemetryFormat::Cef {
syslog::cef(s, prev, host, version)
} else {
syslog::leef(s, prev, host, version)
};
out.push_str(&syslog::rfc5424(s.band, None, host, &payload));
out.push('\n');
if out.len() > max_bytes.min(MAX_INPUT_BYTES) {
return Err(format!(
"the reply is over the {}-byte limit; send fewer epochs or leave `syslog_all` off",
max_bytes.min(MAX_INPUT_BYTES)
));
}
}
prev = Some(s.band);
}
match format {
TelemetryFormat::Cef | TelemetryFormat::Leef => Ok(out),
TelemetryFormat::Prometheus => Ok(reg.render()),
#[cfg(feature = "otlp")]
TelemetryFormat::Otlp => {
Ok(crate::telemetry::otlp::build_payload(®, opts.unix_nano).to_string())
}
#[cfg(not(feature = "otlp"))]
TelemetryFormat::Otlp => Err("otlp needs a build with the `otlp` feature".into()),
}
}
#[allow(clippy::too_many_arguments)]
pub fn osnma_verify(
input: &[u8],
format: &str,
start_gst: Option<u32>,
merkle_root_hex: &str,
public_keys: &[String],
reference_time: Option<u32>,
max_time_error: Option<u32>,
max_bytes: usize,
) -> Result<String, String> {
use crate::osnma::cli::{parse_public_key, verify_report, VerifyParams};
use crate::osnma::verifier::Config;
cap_len("input", input.len(), max_bytes)?;
let merkle_root = match merkle_root_hex.trim() {
"" => None,
h => {
let b = hex::decode(h).map_err(|_| "bad Merkle root hex".to_string())?;
Some(
<[u8; 32]>::try_from(b.as_slice())
.map_err(|_| "Merkle root must be 32 bytes".to_string())?,
)
}
};
let keys = public_keys
.iter()
.map(|k| parse_public_key(k.trim()))
.collect::<Result<Vec<_>, _>>()?;
if max_time_error.is_some() && reference_time.is_none() {
return Err("--max-time-error has no effect without --reference-time".into());
}
Config {
max_time_error_s: max_time_error,
..Config::default()
}
.validate()?;
let format = match format.trim() {
"" => None,
f => Some(f),
};
let params = VerifyParams {
label: "(in memory)",
format,
start_gst,
merkle_root,
keys,
reference: reference_time,
max_err: max_time_error,
};
let (report, _failed) = verify_report(input.to_vec(), ¶ms)?;
Ok(report.to_string())
}
#[derive(Clone, Debug)]
pub struct EvidencePack {
pub files: Files,
pub public_key: String,
pub seed: [u8; 32],
pub epochs_in_window: usize,
pub slice: Option<(usize, usize)>,
}
#[derive(Clone, Debug)]
pub struct TimestampedPack {
pub files: Files,
pub notes: Vec<String>,
}
pub fn evidence_attach_timestamp(
files: &Files,
token: &[u8],
replace: bool,
max_bytes: usize,
) -> Result<TimestampedPack, String> {
let total: usize = files.values().map(Vec::len).sum::<usize>() + token.len();
if total > max_bytes {
return Err(format!(
"the pack and token total {total} bytes; the limit is {max_bytes}"
));
}
if token.is_empty() {
return Err("the timestamp token is empty".into());
}
let mut files = files.clone();
let notes = crate::evidence::attach_timestamp(&mut files, token, replace)?;
Ok(TimestampedPack { files, notes })
}
#[derive(Clone, Debug)]
pub struct EvidenceKey {
pub seed_hex: String,
pub public_key: String,
pub fingerprint: String,
}
pub fn evidence_keygen() -> EvidenceKey {
let seed = zeroize::Zeroizing::new(generate_seed());
let public_key = public_key_hex(&seed);
let fingerprint =
crate::evidence::bundle::fingerprint(&hex::decode(&public_key).unwrap_or_default());
EvidenceKey {
seed_hex: hex::encode(*seed),
public_key,
fingerprint,
}
}
pub fn hex32(what: &str, s: &str) -> Result<[u8; 32], String> {
let s = s.trim();
if s.len() != 64 || !s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
return Err(format!("{what} must be 64 lower-case hex digits"));
}
let v = hex::decode(s).map_err(|e| format!("{what}: {e}"))?;
<[u8; 32]>::try_from(v.as_slice()).map_err(|_| format!("{what} must be 32 bytes"))
}
pub fn now_rfc3339_utc() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()) as i64;
let (y, m, d) = crate::interference_map::time::civil_from_days(secs.div_euclid(86_400));
let r = secs.rem_euclid(86_400);
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
r / 3600,
r % 3600 / 60,
r % 60
)
}
#[allow(clippy::too_many_arguments)]
pub fn evidence_create(
session_toml: &str,
log: &[u8],
from_s: f64,
to_s: f64,
title: Option<&str>,
created_utc: Option<&str>,
seed: Option<[u8; 32]>,
max_bytes: usize,
) -> Result<EvidencePack, String> {
cap("session", session_toml, max_bytes)?;
cap_len("log", log.len(), max_bytes)?;
let scn = parse_live_scenario(session_toml)?;
let seed = seed.unwrap_or_else(generate_seed);
let (from, to) = (from_s.to_string(), to_s.to_string());
let summary = build_receiver_trust_pack(
&PackRequest {
scenario: &scn,
log_bytes: log,
nav_bytes: None,
log_file_name: "inline",
from: &from,
to: &to,
title: title.unwrap_or("GNSS trust evidence pack"),
created_utc,
},
&seed,
None,
)?;
Ok(EvidencePack {
public_key: public_key_hex(&seed),
seed,
epochs_in_window: summary.epochs_in_window,
slice: summary.slice,
files: summary.files,
})
}
pub fn evidence_verify(
files: &Files,
public_key: Option<&str>,
full_log: Option<&[u8]>,
require_timestamp: bool,
) -> Result<Value, String> {
let expected = public_key.map(|k| hex32("public key", k)).transpose()?;
let r = verify_bundle(
files,
&VerifyOptions {
expected_public_key: expected,
full_log,
require_timestamp,
tsa_anchors: None,
},
);
let fp = r.signer_fingerprint.clone().unwrap_or_default();
let (verdict, message) = if !r.ok {
(
"failed",
"NOT VERIFIED: at least one check failed; see `failures`.".to_string(),
)
} else if r.signer_pinned {
(
"verified",
format!("VERIFIED against the public key you supplied (signer fingerprint {fp})."),
)
} else {
(
"intact-signer-not-pinned",
format!(
"INTACT, BUT THE SIGNER IS NOT PINNED: the hashes and signature are consistent with key {fp}, which the pack names itself. Supply the signer's public key, obtained from the signer by another route, to establish who signed it."
),
)
};
let mut v = serde_json::to_value(&r).map_err(|e| e.to_string())?;
v["verdict"] = verdict.into();
v["message"] = message.into();
Ok(v)
}
pub fn files_to_json(files: &Files) -> Value {
Value::Object(
files
.iter()
.map(|(k, v)| {
let body = match std::str::from_utf8(v) {
Ok(t) => json!({"utf8": t}),
Err(_) => json!({"base64": crate::permalink::base64_encode(v)}),
};
(k.clone(), body)
})
.collect(),
)
}
pub fn files_from_json(v: &Value) -> Result<Files, String> {
let o = v
.as_object()
.ok_or("files must be a JSON object of name to content")?;
if o.len() > 64 {
return Err("a pack has at most 64 files".into());
}
o.iter()
.map(|(k, c)| {
if k.contains(['/', '\\']) || k.is_empty() {
return Err(format!("file name `{k}` must be a plain name"));
}
let bytes = if let Some(t) = c.as_str().or_else(|| c["utf8"].as_str()) {
t.as_bytes().to_vec()
} else if let Some(b) = c["base64"].as_str() {
crate::permalink::base64_decode(b)
.ok_or_else(|| format!("{k}: `base64` is not valid standard base64"))?
} else {
return Err(format!(
"{k}: give a string, {{\"utf8\": ...}} or {{\"base64\": ...}}"
));
};
Ok((k.clone(), bytes))
})
.collect()
}
pub fn sky_geometry(request: &str, max_bytes: usize) -> Result<String, String> {
cap("sky request", request, max_bytes)?;
crate::geometry::sky_geometry_json(request)
}
pub fn sky_monte_carlo(
request: &str,
seed: u64,
n: usize,
max_bytes: usize,
) -> Result<String, String> {
cap("sky request", request, max_bytes)?;
crate::geometry::sky_monte_carlo_json(request, seed, n)
}
pub fn skyline_presets() -> String {
crate::geometry::skyline_presets_json()
}
pub fn skyline_profile(skyline: &str, max_bytes: usize) -> Result<String, String> {
cap("skyline", skyline, max_bytes)?;
crate::geometry::skyline_profile_json(skyline)
}
pub fn skyplot_svg(request: &str, max_bytes: usize) -> Result<String, String> {
cap("sky request", request, max_bytes)?;
crate::geometry::skyplot_svg_text(request)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::evidence::public_key_hex;
const ADSB: &str = include_str!("../examples/interference-map/input/adsb.csv");
const AIS: &str = include_str!("../examples/interference-map/input/ais.csv");
const LAND: &str = include_str!("../examples/interference-map/input/land.geojson");
const NMEA: &str = include_str!("../examples/maritime-trust/tallinn-helsinki.nmea");
const SESSION: &str = include_str!("../examples/maritime-trust/session.toml");
const TRAINING: &str = include_str!("../scenarios/training/open-sea-jamming.toml");
fn custom() -> CustomDataset {
CustomDataset {
licence: "CC0-1.0".into(),
licence_url: "https://creativecommons.org/publicdomain/zero/1.0/".into(),
attribution:
"Synthetic data generated for Kshana documentation. Not real observations.".into(),
}
}
#[test]
fn adsb_and_ais_maps_match_the_cli_samples() {
for (src, csv, land, sample) in [
(
MapSource::Adsb,
ADSB,
None,
include_str!("../examples/interference-map/output/adsb-custom-2026-03-01.geojson"),
),
(
MapSource::Ais,
AIS,
Some(LAND),
include_str!("../examples/interference-map/output/ais-custom-2026-03-01.geojson"),
),
] {
let days = interference_map(
src,
csv,
"custom",
None,
Some(&custom()),
land,
MAX_INPUT_BYTES,
)
.unwrap();
assert_eq!(days.len(), 1);
let (mut a, mut b): (serde_json::Value, serde_json::Value) = (
serde_json::from_str(&days[0].geojson).unwrap(),
serde_json::from_str(sample).unwrap(),
);
for v in [&mut a, &mut b] {
v["kshana_interference_map"]["kshana_version"] = "X".into();
}
assert_eq!(a, b);
assert_eq!(
a["kshana_interference_map"]["schema"],
"kshana-interference-map/v1"
);
assert!(days[0].cells_published > 0);
}
}
#[test]
fn route_exposure_runs_on_an_in_memory_map() {
let day = interference_map(
MapSource::Adsb,
ADSB,
"custom",
None,
Some(&custom()),
None,
MAX_INPUT_BYTES,
)
.unwrap();
let route = r#"{"type":"LineString","coordinates":[[-10.0,35.0],[10.0,55.0]]}"#;
let rep = route_exposure(route, &[&day[0].geojson], None, None, MAX_INPUT_BYTES).unwrap();
let v: serde_json::Value = serde_json::from_str(&rep).unwrap();
assert!(v.is_object());
assert!(route_exposure(route, &[], None, None, MAX_INPUT_BYTES).is_err());
assert!(route_exposure(
route,
&[&day[0].geojson],
Some("2030-01-01"),
None,
MAX_INPUT_BYTES
)
.unwrap_err()
.contains("no map"));
}
#[test]
fn inputs_are_capped_and_datasets_are_checked() {
assert!(interference_map(
MapSource::Adsb,
ADSB,
"custom",
None,
None,
None,
MAX_INPUT_BYTES
)
.is_err());
assert!(interference_map(
MapSource::Adsb,
ADSB,
"custom",
None,
Some(&custom()),
None,
100
)
.unwrap_err()
.contains("limit"));
assert!(interference_map(
MapSource::Ais,
AIS,
"adsb-lol",
None,
None,
None,
MAX_INPUT_BYTES
)
.unwrap_err()
.contains("data"));
assert!(MapSource::parse("radar").is_err());
}
#[test]
fn inline_receiver_assessment_refuses_paths_and_runs_inline_logs() {
let e = assess_receiver_log_inline(SESSION, MAX_INPUT_BYTES).unwrap_err();
assert!(e.contains("path"), "{e}");
let mut v: toml::Value = toml::from_str(SESSION).unwrap();
let log = v["log"].as_table_mut().unwrap();
log.remove("path");
log.insert("text".into(), toml::Value::String(NMEA.to_string()));
let inline = toml::to_string(&v).unwrap();
let out = assess_receiver_log_inline(&inline, MAX_INPUT_BYTES).unwrap();
assert!(!out.summary.is_empty());
assert!(out.json.contains("score"));
assert!(assess_receiver_log_inline(&inline, 1000)
.unwrap_err()
.contains("limit"));
}
fn excerpt() -> (String, &'static str) {
let at = |t: usize| NMEA[..NMEA.len() * t / 3000].rfind('\n').unwrap() + 1;
let session = SESSION
.replace("path = \"tallinn-helsinki.nmea\"", "")
.replace("calibration_s = 300.0", "calibration_s = 60.0");
(session, &NMEA[at(1400)..at(1800)])
}
#[test]
fn vessel_excerpt_scores_the_synthetic_drag_off_and_is_bounded() {
let (session, nmea) = excerpt();
let r = assess_vessel_excerpt(&session, nmea.as_bytes(), MAX_INPUT_BYTES).unwrap();
assert!(r.summary.epochs > 300 && r.summary.calibrating > 0 && r.summary.nominal > 0);
assert!(
r.summary.untrusted > 0,
"the drag-off must be flagged: {:?}",
r.summary
);
assert!(r.summary.lowest_score.unwrap() < 55.0);
assert!(assess_vessel_excerpt(&session, nmea.as_bytes(), 1000).is_err());
let static_session = "[platform]\nkind = \"static\"";
assert!(assess_vessel_excerpt(static_session, nmea.as_bytes(), MAX_INPUT_BYTES).is_err());
}
#[test]
fn vessel_log_batch_matches_the_excerpt_json_shape() {
let (session, nmea) = excerpt();
let j = assess_vessel_log_json(&session, nmea.as_bytes(), MAX_INPUT_BYTES).unwrap();
let v: Value = serde_json::from_str(&j).unwrap();
assert!(v["epochs"].as_array().unwrap().len() > 300);
assert!(assess_vessel_log_json(&session, nmea.as_bytes(), 1000).is_err());
}
#[test]
fn evidence_pack_round_trips_and_detects_tampering() {
let (session, nmea) = excerpt();
let seed = [7u8; 32];
let p = evidence_create(
&session,
nmea.as_bytes(),
100.0,
300.0,
Some("test pack"),
None,
Some(seed),
MAX_INPUT_BYTES,
)
.unwrap();
assert!(p.epochs_in_window > 100 && p.slice.is_some());
for f in [
"manifest.json",
"manifest.sig",
"epochs.json",
"summary.html",
"log-slice.bin",
] {
assert!(p.files.contains_key(f), "{f}");
}
let again = evidence_create(
&session,
nmea.as_bytes(),
100.0,
300.0,
Some("test pack"),
None,
Some(seed),
MAX_INPUT_BYTES,
)
.unwrap();
assert_eq!(p.files, again.files);
let ok =
evidence_verify(&p.files, Some(&p.public_key), Some(nmea.as_bytes()), false).unwrap();
assert_eq!(ok["ok"], true, "{}", ok["failures"]);
assert_eq!(ok["verdict"], "verified");
assert_eq!(ok["signer_pinned"], true);
let back = files_from_json(&files_to_json(&p.files)).unwrap();
assert_eq!(back, p.files);
let verdict = |files: &Files, key: Option<&str>, log: Option<&[u8]>| {
evidence_verify(files, key, log, false).unwrap()["verdict"].clone()
};
let mut bad = p.files.clone();
bad.get_mut("epochs.json").unwrap()[10] ^= 1;
assert_eq!(verdict(&bad, Some(&p.public_key), None), "failed");
let other = public_key_hex(&[9u8; 32]);
assert_eq!(verdict(&p.files, Some(&other), None), "failed");
assert_eq!(verdict(&p.files, None, Some(b"not the log")), "failed");
let unpinned = evidence_verify(&p.files, None, None, false).unwrap();
assert_eq!(unpinned["verdict"], "intact-signer-not-pinned");
assert_eq!(unpinned["signer_pinned"], false);
assert!(unpinned["message"].as_str().unwrap().contains("NOT PINNED"));
assert_eq!(
evidence_verify(&p.files, Some(&p.public_key), None, true).unwrap()["verdict"],
"failed"
);
assert!(evidence_verify(&p.files, Some("zz"), None, false).is_err());
let digest = <sha2::Sha256 as sha2::Digest>::digest(&p.files["manifest.json"]);
let tsr = crate::evidence::tsr::test_token::token(&digest, "20260102030405Z", true);
let stamped = evidence_attach_timestamp(&p.files, &tsr, false, MAX_INPUT_BYTES).unwrap();
assert!(stamped.files.contains_key("timestamp.tsr"));
assert_eq!(
evidence_verify(&stamped.files, Some(&p.public_key), None, true).unwrap()["verdict"],
"verified"
);
assert!(evidence_attach_timestamp(&stamped.files, &tsr, false, MAX_INPUT_BYTES).is_err());
assert!(evidence_attach_timestamp(&stamped.files, &tsr, true, MAX_INPUT_BYTES).is_ok());
let wrong = crate::evidence::tsr::test_token::token(&[0u8; 32], "20260102030405Z", true);
assert!(evidence_attach_timestamp(&p.files, &wrong, false, MAX_INPUT_BYTES).is_err());
assert!(evidence_attach_timestamp(&p.files, b"", false, MAX_INPUT_BYTES).is_err());
assert!(evidence_attach_timestamp(&p.files, &tsr, false, 100).is_err());
let (k1, k2) = (evidence_keygen(), evidence_keygen());
assert_ne!(k1.seed_hex, k2.seed_hex);
assert_eq!(
k1.public_key,
public_key_hex(&hex32("seed", &k1.seed_hex).unwrap())
);
assert_eq!(k1.fingerprint.len(), 32);
assert!(evidence_create(
&session,
nmea.as_bytes(),
9000.0,
9100.0,
None,
None,
None,
MAX_INPUT_BYTES
)
.is_err());
let hex_seed = hex::encode(seed);
assert!(p
.files
.values()
.all(|b| !String::from_utf8_lossy(b).contains(&hex_seed)));
}
#[test]
fn scenarios_naming_files_are_refused_and_bundled_ones_pass() {
let refused = |t: &str| reject_file_sources(t, MAX_INPUT_BYTES).unwrap_err();
assert!(refused("kind = \"telecom-timing\"\ncsv_path = \"x.csv\"").contains("csv_path"));
assert!(
refused("kind = \"spectrum\"\n[recording]\nmeta_path = \"a.sigmf-meta\"")
.contains("meta_path")
);
assert!(
refused("kind = \"realtime-frame-eop\"\neop_finals2000a = \"/etc/x\"")
.contains("eop_finals2000a")
);
assert!(
refused("kind = \"receiver-trust\"\n[log]\nformat = \"nmea\"\npath = \"x\"")
.contains("path")
);
assert!(refused("[log.nav]\npath = \"x\"").contains("path"));
assert!(refused(
"kind = \"campaign\"\n[[phases]]\n[phases.scenario]\nplanetary_kernel_path = \"k.bsp\""
)
.contains("planetary_kernel_path"));
reject_file_sources(
"kind = \"ephemeris\"\neop_finals2000a = \"inline body\"",
MAX_INPUT_BYTES,
)
.unwrap();
reject_file_sources(
"kind = \"campaign\"\n[[metrics]]\nname = \"a\"\npath = \"quantum.fom.x\"",
MAX_INPUT_BYTES,
)
.unwrap();
reject_file_sources("not toml {{", MAX_INPUT_BYTES).unwrap();
assert!(reject_file_sources("x = 1", 2)
.unwrap_err()
.contains("limit"));
}
#[test]
fn files_json_refuses_paths_and_junk() {
assert!(files_from_json(&json!({"../x": "a"})).is_err());
assert!(files_from_json(&json!({"a": 5})).is_err());
assert!(files_from_json(&json!({"a": {"base64": "!!"}})).is_err());
assert!(files_from_json(&json!([1])).is_err());
assert_eq!(files_from_json(&json!({"a": "hi"})).unwrap()["a"], b"hi");
}
#[test]
fn training_nmea_is_deterministic_per_seed() {
let a = nmea_training(TRAINING, None, MAX_INPUT_BYTES).unwrap();
assert_eq!(
a.nmea,
nmea_training(TRAINING, None, MAX_INPUT_BYTES).unwrap().nmea
);
assert_ne!(
a.nmea,
nmea_training(TRAINING, Some(7), MAX_INPUT_BYTES)
.unwrap()
.nmea
);
assert!(a.nmea.contains("\r\n") && a.log_json.contains("kshana-nmea-training/1"));
assert!(!a.log_text.is_empty());
assert!(nmea_training("[scenario]", None, MAX_INPUT_BYTES).is_err());
}
#[test]
fn compliance_report_fills_the_mapping_from_text() {
let result = r#"{"scenario_hash":"0123456789abcdef0123","log":{"format":"nmea","epochs":10},
"monitors_run":["cn0"],"events_evaluable":2,"events_detected":2,
"predictions_evaluable":1,"predictions_agreeing":1}"#;
let out = compliance_report(
&[
ComplianceRunText {
label: "trust.result.json".into(),
result_json: result.into(),
scenario_toml: None,
},
ComplianceRunText {
label: "bad.json".into(),
result_json: "not json".into(),
scenario_toml: None,
},
],
MAX_INPUT_BYTES,
)
.unwrap();
assert_eq!(out.report["statement"], crate::compliance::STATEMENT);
assert!(out.markdown.contains(crate::compliance::STATEMENT));
assert_eq!(out.report["runs"].as_array().unwrap().len(), 1);
assert_eq!(out.report["unrecognised"].as_array().unwrap().len(), 1);
assert!(!out.report["rows"].as_array().unwrap().is_empty());
assert!(compliance_report(
&[ComplianceRunText {
label: "x".into(),
result_json: result.into(),
scenario_toml: None
}],
10
)
.is_err());
let many = vec![ComplianceRunText::default(); MAX_COMPLIANCE_RUNS + 1];
assert!(compliance_report(&many, MAX_INPUT_BYTES).is_err());
for sources in [false, true] {
let m = compliance_mapping(sources);
assert!(m.starts_with(&format!("> {}", crate::compliance::STATEMENT)));
let low = m
.to_lowercase()
.replace("conformance framework", "")
.replace("conformance_framework", "");
for banned in ["certif", "complies", "compliant", "conform"] {
assert!(!low.contains(banned), "{banned}");
}
}
}
#[test]
fn bench_export_is_in_memory_deterministic_and_checks_its_epoch() {
let toml = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("scenarios/gnss-ins.toml"),
)
.or_else(|_| {
std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("scenarios/automotive-urban-canyon.toml"),
)
})
.unwrap();
let a = bench_export(&toml, Some("2025-03-01T10:00:00Z"), MAX_INPUT_BYTES).unwrap();
let b = bench_export(&toml, Some("2025-03-01T10:00:00"), MAX_INPUT_BYTES).unwrap();
assert_eq!(a.files, b.files);
let suffixes: Vec<&str> = a.files.iter().map(|f| f.0.as_str()).collect();
assert!(suffixes.contains(&".motion.csv") && suffixes.contains(&".events.toml"));
assert!(a.files[0].1.contains("2025-03-01"));
assert!(bench_export(&toml, Some("2025-13-01T00:00:00"), MAX_INPUT_BYTES).is_err());
assert!(bench_export(&toml, None, 10).is_err());
assert!(bench_export("kind = \"orbit\"\n", None, MAX_INPUT_BYTES).is_err());
let low = BENCH_NOTICE.to_lowercase();
for banned in ["certif", "complies", "compliant", "conform"] {
assert!(!low.contains(banned));
}
}
#[cfg(feature = "bundled-scenarios")]
#[test]
fn scenario_tools_work_in_memory() {
let all = list_examples(None).unwrap();
assert!(all["count"].as_u64().unwrap() > 50);
let orbit = list_examples(Some("orbit")).unwrap();
assert!(orbit["scenarios"]
.as_array()
.unwrap()
.iter()
.all(|e| e["kind"] == "orbit"));
assert!(list_examples(Some("no-such-kind")).is_err());
let clock = get_example("clock-holdover").unwrap();
assert!(get_example("no-such-example").is_err());
let plan = export_formats(clock, MAX_INPUT_BYTES).unwrap();
assert_eq!(plan.as_array().unwrap().len(), 5);
let orbit_toml = get_example("orbit-multignss").unwrap();
let czml = export_scenario(orbit_toml, "czml", MAX_INPUT_BYTES).unwrap();
assert_eq!(czml.format, "czml");
assert_eq!(czml.files[0].encoding, "utf-8");
assert_eq!(
czml.files[0].sha256,
crate::advanced_report::sha256_hex(czml.files[0].content.as_bytes())
);
assert_eq!(
czml.to_json(),
export_scenario(orbit_toml, "czml", MAX_INPUT_BYTES)
.unwrap()
.to_json()
);
assert!(export_scenario(clock, "czml", MAX_INPUT_BYTES).is_err());
assert!(export_scenario(orbit_toml, "nope", MAX_INPUT_BYTES).is_err());
assert!(export_scenario(orbit_toml, "czml", 10).is_err());
let route = r#"{"type":"LineString","coordinates":[[10.0,50.0],[10.5,50.0]]}"#;
assert!(import_route(clock, route, MAX_INPUT_BYTES).is_err());
let moved =
import_route(get_example("terrain-nav").unwrap(), route, MAX_INPUT_BYTES).unwrap();
assert!(moved.contains("start_lat_deg = 50"));
let a = animate_scenario(clock, Some("svg"), None, None, None, MAX_INPUT_BYTES).unwrap();
assert_eq!(a.files.len(), 1);
assert!(a.files[0].1.starts_with("<svg") || a.files[0].1.contains("<svg"));
let f = animate_scenario(
clock,
Some("frames"),
Some(10),
Some(3.0),
None,
MAX_INPUT_BYTES,
)
.unwrap();
assert!(f.files.iter().any(|x| x.0 == "manifest.json"));
assert!(animate_scenario(
clock,
Some("frames"),
Some(60),
Some(600.0),
None,
MAX_INPUT_BYTES
)
.is_err());
assert!(animate_scenario(clock, Some("gif"), None, None, None, MAX_INPUT_BYTES).is_err());
}
}