src/data.rs
//! Transport-agnostic view-model for the holger UI.
//!
//! Everything the UI renders is produced here by calling the core
//! [`HolgerObject`] API. That trait is satisfied by `LocalHolger` (direct
//! in-process rust calls into holger core) or `RemoteHolger` (gRPC) — the UI
//! does not care which. There is **no egui in this module**: every view is plain
//! data with a `state_json()` method, so the UI's state can be asserted
//! headlessly (the nordisk "see what the user sees as data" law).
//!
//! The single [`UiData`] handle owns a private current-thread tokio runtime and
//! runs exactly one core call per UI action (the nornir-viz idiom), so an egui
//! `update` loop can stay synchronous.
use std::sync::Arc;
use serde::Serialize;
use serde_json::{json, Value};
use traits::{
ArtifactId, Health, HolgerObject, PropertyKv, RepositoryInfo, SearchArtifactHit,
SearchPathHit, SearchQuery, ServerProfile,
};
/// Operator-tool config/result types (Tools#1–#5) — split into a submodule and
/// re-exported so `crate::data::{DsConfig, MigrateRun, DeployConfig, …}` is unchanged.
mod tools;
pub use tools::*;
/// The live artifact-lineage view-model (the "proxy-cache-filling" facet) — a
/// transport-agnostic feed of cache arrivals maintained as a growing node/edge/
/// time-event graph. Re-exported so `crate::data::{LineageView, LineageEvent, …}`.
mod lineage;
pub use lineage::*;
/// Archive browse view — backs the Archive tab (`ArchiveService`). Holds the raw
/// file paths inside the znippy archive backing `repository` plus its stats
/// (file count, total uncompressed bytes, archive/repo name). `error` carries a
/// core/transport failure from either the `archive_info` or `list_archive_files`
/// call that fed it.
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct ArchiveView {
pub repository: String,
pub files: Vec<String>,
pub file_count: u64,
pub total_uncompressed_bytes: u64,
pub archive_path: String,
pub error: Option<String>,
}
impl ArchiveView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
/// One repository row in the picker/sidebar — mirrors [`RepositoryInfo`].
///
/// `writable` is exactly the flag the Upload control reads; `has_archive` gates
/// the Archive tab.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct RepoRow {
pub name: String,
pub repo_type: String,
pub writable: bool,
pub has_archive: bool,
}
impl From<RepositoryInfo> for RepoRow {
fn from(r: RepositoryInfo) -> Self {
Self {
name: r.name,
repo_type: r.repo_type,
writable: r.writable,
has_archive: r.has_archive,
}
}
}
/// Status / About view — backs the Status tab (`Health`).
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct StatusView {
pub status: String,
pub version: String,
pub uptime_seconds: i64,
/// `true` once a successful `health()` has populated this view.
pub loaded: bool,
/// Last error from `health()`, if any.
pub error: Option<String>,
}
impl StatusView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
/// Server-profile view — server truth backing the static "silent running"
/// chrome (`AdminService.ServerProfile`). The UI drives `static_mode` and the
/// badge off this, not off a local toggle.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ProfileView {
/// `"static"` (sealed / read-only) or `"dynamic"` (writable / proxy).
pub profile: String,
pub read_only: bool,
/// Human badge text (e.g. the red "silent running" strip).
pub label: String,
pub writable_repo_count: i32,
/// `true` once a successful `server_profile()` has populated this view.
pub loaded: bool,
pub error: Option<String>,
}
impl Default for ProfileView {
fn default() -> Self {
let p = ServerProfile::default();
Self {
profile: p.profile,
read_only: p.read_only,
label: p.label,
writable_repo_count: p.writable_repo_count,
loaded: false,
error: None,
}
}
}
impl ProfileView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
/// Repo picker view — backs the Repos tab (`ListRepositories`).
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct ReposView {
pub repos: Vec<RepoRow>,
/// Index into `repos` of the active selection, if any.
pub selected: Option<usize>,
pub error: Option<String>,
}
impl ReposView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
/// The currently selected repo row, if any.
pub fn selected_repo(&self) -> Option<&RepoRow> {
self.selected.and_then(|i| self.repos.get(i))
}
/// Whether the Upload control should be enabled: a writable repo is selected.
/// Exactly the `RepositoryInfo.writable` gate from the UI brief.
pub fn upload_enabled(&self) -> bool {
self.selected_repo().map(|r| r.writable).unwrap_or(false)
}
}
/// Artifact fetch view — backs the Artifact tab (`FetchArtifact`).
///
/// The raw bytes are kept on the view but **never** serialized into
/// `state_json()` (only `size_bytes`), so headless snapshots stay small and
/// don't leak payloads.
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct ArtifactView {
pub repository: String,
pub namespace: Option<String>,
pub name: String,
pub version: String,
pub size_bytes: u64,
pub content_type: String,
/// `true` if the last fetch returned bytes; `false` for a clean `NOT_FOUND`.
pub found: bool,
/// Transport/server error (distinct from a clean not-found).
pub error: Option<String>,
/// Custom properties on the fetched coordinate (`PropertyService`), loaded
/// alongside a successful fetch. Empty when none (or the store is absent).
pub properties: Vec<PropertyKv>,
/// The hosted SBOM's metadata + a short text preview, when fetched on demand
/// (`SbomService.FetchSbom`). `None` until requested / when none is attached.
pub sbom: Option<SbomView>,
/// Transport/server error from the on-demand SBOM fetch.
pub sbom_error: Option<String>,
/// Where the last ⬇ save wrote the artifact bytes, when one succeeded.
pub saved_to: Option<String>,
/// Why the last ⬇ save failed, when one did.
pub save_error: Option<String>,
#[serde(skip)]
pub data: Vec<u8>,
}
/// The hosted SBOM document's observable summary (bytes are NOT serialized —
/// only size, content address, and a short preview for the inspector).
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct SbomView {
pub size_bytes: i64,
/// blake3 hex of the document (its content address).
pub content_address: String,
/// The document's first ~2 KB as lossy UTF-8 (a human-readable teaser).
pub preview: String,
}
impl ArtifactView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
/// Cross-repo search view — backs the Search tab (`SearchService.Search`).
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct SearchView {
/// The name-axis query the last search ran with.
pub query: String,
pub artifacts: Vec<SearchArtifactHit>,
pub paths: Vec<SearchPathHit>,
/// True when the server capped the result set (more hits exist).
pub truncated: bool,
/// True once a search has run (distinguishes "no hits" from "not searched").
pub searched: bool,
pub error: Option<String>,
}
impl SearchView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
/// One artifact row in a repository listing — mirrors [`traits::ArtifactEntry`]
/// (the proto `ArtifactEntry`), with the id flattened into the columns the
/// Browse table renders.
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct BrowseRow {
pub namespace: Option<String>,
pub name: String,
pub version: String,
pub size_bytes: i64,
pub content_type: String,
}
/// Browse view — backs the Browse tab (`ListArtifacts`). Holds one page of
/// entries for the active `repository` plus the opaque `next_page_token` the
/// core returned (empty when the listing is exhausted).
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct BrowseView {
pub repository: String,
pub entries: Vec<BrowseRow>,
pub next_page_token: String,
pub error: Option<String>,
}
impl BrowseView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
/// One scanned artifact's verdict row in the Security pane — plain, serializable
/// data lifted from a `modgunn::core::Verdict` so this view compiles in the
/// default (non-gui) build with no modgunn types leaking in.
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct SecFinding {
pub name: String,
pub version: String,
/// `"pass"` / `"warn"` / `"block"`.
pub decision: String,
/// Advisory / finding ids (e.g. a CVE, `MODGUNN-LICENSE-DENIED:…`).
pub ids: Vec<String>,
/// Human summary of the strongest finding (for the row tooltip).
pub summary: String,
}
/// Security scan view — backs the Tools#3 (Móðguðr) pane. Holds the aggregate
/// tallies + the overall decision for the scanned repository plus the per-artifact
/// findings (clean artifacts are tallied but not listed). `error` carries a
/// core/transport failure from the listing call that fed it.
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct SecurityView {
pub repository: String,
pub ecosystem: String,
pub scanned: usize,
pub skipped: usize,
pub pass: usize,
pub warn: usize,
pub block: usize,
/// `"pass"` / `"warn"` / `"block"` — strongest across all artifacts.
pub decision: String,
pub findings: Vec<SecFinding>,
/// `true` once a scan has populated this view.
pub loaded: bool,
/// Where the advisory corpus came from: `"demo"` or `"warehouse rev '<rev>'"`.
pub source: String,
pub error: Option<String>,
}
impl SecurityView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
/// One node in the Móðguðr dependency-graph view: a scanned artifact with its
/// verdict decision and any advisory ids (the graph node's sublabel).
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct DepGraphNode {
pub name: String,
pub version: String,
/// `"pass"` / `"warn"` / `"block"` — drives the node colour in the graph.
pub decision: String,
pub ids: Vec<String>,
}
/// The dependency-graph view backing the Móðguðr tool's graph mode: **every**
/// scanned artifact as a node coloured by verdict (block=red, warn=yellow,
/// pass=green) — unlike [`SecurityView::findings`], which lists only the non-clean
/// ones. Inter-dependency `edges` are sourced from the shared warehouse WHEN it has
/// a captured snapshot for this subject (`has_edges`); a served archive usually has
/// none, so the view shows the coloured node set with an honest "no resolved graph"
/// note. `state_json` lets the robot assert the colour tallies + edge count.
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct DepGraphData {
pub repository: String,
pub nodes: Vec<DepGraphNode>,
/// `(from, to)` node-index pairs; empty unless `has_edges`.
pub edges: Vec<(usize, usize)>,
/// `true` when real dependency edges were sourced from the warehouse.
pub has_edges: bool,
pub pass: usize,
pub warn: usize,
pub block: usize,
/// `true` once a scan has populated this view.
pub loaded: bool,
pub error: Option<String>,
}
impl DepGraphData {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
/// Build the Engine A scanner for `ecosystem`. Under `gui`, if `HOLGER_WAREHOUSE`
/// points at a catalog root, load the advisory corpus from modgunn's warehouse
/// (`cve_advisories`) — the real ingested feeds — falling back to the built-in
/// demo corpus when it isn't available. The (non-gui) test build always uses demo
/// (no skade/warehouse dependency). Returns the scanner + a human source label.
#[cfg(any(feature = "tools", test))]
fn scanner_for(ecosystem: &str) -> (server_lib::security::OsvScanner, String) {
use server_lib::security;
let _ = ecosystem; // used only by the gui (warehouse) path below
#[cfg(feature = "warehouse-scan")]
if let Some(root) = std::env::var("HOLGER_WAREHOUSE").ok().filter(|s| !s.is_empty()) {
let loaded = modgunn::warehouse::Warehouse::open(std::path::Path::new(&root))
.ok()
.and_then(|wh| {
wh.ensure_modgunn_tables().ok()?;
wh.load_advisory_db(Some(ecosystem)).ok()
});
return match loaded {
Some(db) => {
let rev = db.rev.clone();
(security::OsvScanner::new(security::demo_policy(), db), format!("warehouse rev '{rev}'"))
}
None => (security::demo_scanner(), "demo (warehouse unavailable)".to_string()),
};
}
(security::demo_scanner(), "demo".to_string())
}
/// The UI's data layer: a holger handle + a private current-thread runtime so the
/// async core calls can be driven synchronously (one blocking call per UI
/// action — the nornir-viz idiom).
pub struct UiData {
holger: Arc<dyn HolgerObject>,
rt: tokio::runtime::Runtime,
pub status: StatusView,
/// Server operating profile (server truth for the static/silent-running mode).
pub profile: ProfileView,
pub repos: ReposView,
pub artifact: ArtifactView,
pub browse: BrowseView,
pub archive: ArchiveView,
/// Cross-repo search view (`SearchService.Search`) — backs the Search tab.
pub search: SearchView,
/// The endpoint this data layer is connected to (empty for in-process /
/// embedded handles) — shown in the chrome so an operator can tell WHICH
/// server the window is looking at.
pub endpoint: String,
pub security: SecurityView,
/// Móðguðr dependency-graph view (all scanned artifacts coloured by verdict),
/// populated alongside [`UiData::security`] by `run_security_scan`.
pub dep_graph: DepGraphData,
/// Tool #5 Airgap populate configuration (typed, pure-Rust plan). The Tools#5
/// pane edits this; `plan()`/`state_json()` are computed with no I/O.
pub airgap: holger_airgap::PopulateArgs,
/// The last Tool #5 Airgap populate run result, if any.
pub airgap_run: Option<AirgapRun>,
/// Tool #1 holger-ds benchmark configuration.
pub holger_ds: DsConfig,
/// Tool #2 Migrate / suck configuration.
pub migrate: MigrateConfig,
/// Tool #4 Deploy (Skíðblaðnir) configuration + last run.
pub deploy: DeployConfig,
/// Tool #6 Demo appliance — the last jera-dispatched demo-container result, if
/// any (the shared "Tools starts containers THROUGH jera" surface). `None` until
/// the operator runs the demo.
pub demo_appliance: Option<crate::infra::DemoDispatch>,
/// The live artifact-lineage graph (the proxy-cache-filling facet): cache
/// arrivals folded into nodes/edges/time-events, grown live (via a connected
/// feed channel) or by replaying the audit log. Backs the Lineage tab.
pub lineage: LineageView,
}
impl UiData {
/// Build a data layer around any `HolgerObject` (embedded or remote).
pub fn new(holger: Arc<dyn HolgerObject>) -> anyhow::Result<Self> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
Ok(Self {
holger,
rt,
status: StatusView::default(),
profile: ProfileView::default(),
repos: ReposView::default(),
artifact: ArtifactView::default(),
browse: BrowseView::default(),
archive: ArchiveView::default(),
search: SearchView::default(),
endpoint: String::new(),
security: SecurityView::default(),
dep_graph: DepGraphData::default(),
airgap: holger_airgap::PopulateArgs::default(),
airgap_run: None,
holger_ds: DsConfig::default(),
migrate: MigrateConfig::default(),
deploy: DeployConfig::default(),
demo_appliance: None,
lineage: LineageView::new(),
})
}
/// Build a data layer from a handle plus a caller-provided runtime. Use this
/// when the handle's transport (e.g. a tonic channel) must be created on the
/// **same** runtime that later drives its calls — the single-runtime tonic
/// idiom. [`UiData::new`] is the common case (it makes its own current-thread
/// runtime).
pub fn with_runtime(holger: Arc<dyn HolgerObject>, rt: tokio::runtime::Runtime) -> Self {
Self {
holger,
rt,
status: StatusView::default(),
profile: ProfileView::default(),
repos: ReposView::default(),
artifact: ArtifactView::default(),
browse: BrowseView::default(),
archive: ArchiveView::default(),
search: SearchView::default(),
endpoint: String::new(),
security: SecurityView::default(),
dep_graph: DepGraphData::default(),
airgap: holger_airgap::PopulateArgs::default(),
airgap_run: None,
holger_ds: DsConfig::default(),
migrate: MigrateConfig::default(),
deploy: DeployConfig::default(),
demo_appliance: None,
lineage: LineageView::new(),
}
}
/// Run one async core call to completion on the private runtime.
fn block<T>(&self, fut: impl std::future::Future<Output = T>) -> T {
self.rt.block_on(fut)
}
/// Refresh the Status tab from `health()`.
pub fn refresh_status(&mut self) {
match self.block(self.holger.health()) {
Ok(Health {
status,
version,
uptime_seconds,
}) => {
self.status = StatusView {
status,
version,
uptime_seconds,
loaded: true,
error: None,
};
}
Err(e) => {
self.status.loaded = false;
self.status.error = Some(e.to_string());
}
}
}
/// Refresh the server profile from `server_profile()` (server truth for the
/// static / "silent running" mode). On error the view is marked unloaded and
/// the error is surfaced, leaving the prior values in place.
pub fn refresh_profile(&mut self) {
match self.block(self.holger.server_profile()) {
Ok(ServerProfile {
profile,
read_only,
label,
writable_repo_count,
}) => {
self.profile = ProfileView {
profile,
read_only,
label,
writable_repo_count,
loaded: true,
error: None,
};
}
Err(e) => {
self.profile.loaded = false;
self.profile.error = Some(e.to_string());
}
}
}
/// Refresh the repo picker from `list_repositories()`. Selection is preserved
/// by name across refreshes; a first load selects the first repo.
pub fn refresh_repos(&mut self) {
let prev = self.repos.selected_repo().map(|r| r.name.clone());
match self.block(self.holger.list_repositories()) {
Ok(list) => {
let repos: Vec<RepoRow> = list.into_iter().map(RepoRow::from).collect();
let selected = prev
.and_then(|name| repos.iter().position(|r| r.name == name))
.or(if repos.is_empty() { None } else { Some(0) });
self.repos = ReposView {
repos,
selected,
error: None,
};
}
Err(e) => self.repos.error = Some(e.to_string()),
}
}
/// Select a repo by index (a click handler / headless-test entry point).
pub fn select_repo(&mut self, idx: usize) {
if idx < self.repos.repos.len() {
self.repos.selected = Some(idx);
}
}
/// Fetch an artifact into the Artifact tab. A clean `NOT_FOUND` is rendered
/// as `found = false` (not an error); transport/server failures land in
/// `error`.
pub fn fetch_artifact(&mut self, repository: &str, id: ArtifactId) {
let res = self.block(self.holger.fetch(repository, &id));
let mut view = ArtifactView {
repository: repository.to_string(),
namespace: id.namespace.clone(),
name: id.name.clone(),
version: id.version.clone(),
..Default::default()
};
match res {
Ok(Some(bytes)) => {
view.size_bytes = bytes.len() as u64;
view.content_type = "application/octet-stream".into();
view.found = true;
view.data = bytes;
// Custom properties ride along with a successful fetch (an open,
// cheap read; empty when the server has no property store).
view.properties = self
.block(self.holger.get_properties(repository, &id))
.unwrap_or_default();
}
Ok(None) => view.found = false,
Err(e) => view.error = Some(e.to_string()),
}
self.artifact = view;
}
/// Fetch the hosted SBOM for the artifact currently in the view (on demand —
/// a document can be large, so it is not loaded with the fetch). `None`
/// (nothing attached) clears the pane; errors land in `sbom_error`.
pub fn fetch_sbom_current(&mut self) {
let id = ArtifactId {
namespace: self.artifact.namespace.clone(),
name: self.artifact.name.clone(),
version: self.artifact.version.clone(),
};
let repository = self.artifact.repository.clone();
match self.block(self.holger.fetch_sbom(&repository, &id)) {
Ok(Some(doc)) => {
let head = &doc.document[..doc.document.len().min(2048)];
self.artifact.sbom = Some(SbomView {
size_bytes: doc.size_bytes,
content_address: doc.content_address,
preview: String::from_utf8_lossy(head).into_owned(),
});
self.artifact.sbom_error = None;
}
Ok(None) => {
self.artifact.sbom = None;
self.artifact.sbom_error = Some("no SBOM attached".into());
}
Err(e) => {
self.artifact.sbom = None;
self.artifact.sbom_error = Some(e.to_string());
}
}
}
/// Save the fetched artifact's bytes to `path` (the ⬇ download). The bytes
/// are already in memory from the fetch — this is a plain local write; the
/// outcome lands on `saved_to` / `save_error` (both observable).
pub fn save_artifact(&mut self, path: &str) {
self.artifact.saved_to = None;
self.artifact.save_error = None;
if !self.artifact.found || self.artifact.data.is_empty() {
self.artifact.save_error = Some("nothing fetched to save".into());
return;
}
match std::fs::write(path, &self.artifact.data) {
Ok(()) => self.artifact.saved_to = Some(path.to_string()),
Err(e) => self.artifact.save_error = Some(format!("{path}: {e}")),
}
}
/// Run a cross-repo search on the name axis (`SearchService.Search`) into
/// the Search view. An empty query clears the view instead of asking the
/// server to enumerate everything.
pub fn refresh_search(&mut self, name_query: &str) {
let q = name_query.trim();
if q.is_empty() {
self.search = SearchView::default();
return;
}
let query = SearchQuery {
name: q.to_string(),
limit: 200,
..Default::default()
};
match self.block(self.holger.search(&query)) {
Ok(res) => {
self.search = SearchView {
query: q.to_string(),
artifacts: res.artifacts,
paths: res.paths,
truncated: res.truncated,
searched: true,
error: None,
};
}
Err(e) => {
self.search = SearchView {
query: q.to_string(),
searched: true,
error: Some(e.to_string()),
..Default::default()
};
}
}
}
/// Page size for the Browse tab.
const BROWSE_PAGE_SIZE: u32 = 100;
/// Refresh the Browse tab from `list_artifacts()` for `repository`,
/// optionally narrowed by a `name_filter` substring — loads the FIRST page
/// (up to [`Self::BROWSE_PAGE_SIZE`]). The continuation token is kept on the
/// view; call [`UiData::load_more_browse`] to append the next page. A core
/// error lands in `error`.
pub fn refresh_browse(&mut self, repository: &str, name_filter: Option<String>) {
let res = self.block(self.holger.list_artifacts(
repository,
name_filter,
Self::BROWSE_PAGE_SIZE,
None,
));
let mut view = BrowseView {
repository: repository.to_string(),
..Default::default()
};
match res {
Ok((entries, next_page_token)) => {
view.entries = entries
.into_iter()
.map(|e| BrowseRow {
namespace: e.id.namespace,
name: e.id.name,
version: e.id.version,
size_bytes: e.size_bytes,
content_type: e.content_type,
})
.collect();
view.next_page_token = next_page_token;
}
Err(e) => view.error = Some(e.to_string()),
}
self.browse = view;
}
/// Append the next page of the current Browse listing using the stored
/// continuation token. No-op when the listing is exhausted (empty token).
pub fn load_more_browse(&mut self) {
if self.browse.next_page_token.is_empty() {
return;
}
let repository = self.browse.repository.clone();
let token = self.browse.next_page_token.clone();
let res = self.block(self.holger.list_artifacts(
&repository,
None,
Self::BROWSE_PAGE_SIZE,
Some(token),
));
match res {
Ok((entries, next_page_token)) => {
self.browse
.entries
.extend(entries.into_iter().map(|e| BrowseRow {
namespace: e.id.namespace,
name: e.id.name,
version: e.id.version,
size_bytes: e.size_bytes,
content_type: e.content_type,
}));
self.browse.next_page_token = next_page_token;
self.browse.error = None;
}
Err(e) => self.browse.error = Some(e.to_string()),
}
}
/// Refresh the Archive tab for `repository`: load the archive stats
/// (`archive_info`) and the raw file paths (`list_archive_files`, optionally
/// narrowed by a path `prefix`). Two core calls feed one view; an error from
/// **either** lands in `error` (and leaves the rest at its default). The
/// `repository` is always recorded so the UI shows which repo it tried.
pub fn refresh_archive(&mut self, repository: &str, prefix: Option<String>) {
let mut view = ArchiveView {
repository: repository.to_string(),
..Default::default()
};
match self.block(self.holger.archive_info(repository)) {
Ok(info) => {
view.file_count = info.file_count;
view.total_uncompressed_bytes = info.total_uncompressed_bytes;
view.archive_path = info.archive_path;
}
Err(e) => view.error = Some(e.to_string()),
}
if view.error.is_none() {
match self.block(self.holger.list_archive_files(repository, prefix)) {
Ok(files) => view.files = files,
Err(e) => view.error = Some(e.to_string()),
}
}
self.archive = view;
}
/// Upload bytes to a (writable) repo. The core error is returned verbatim so
/// the UI can map it to an affordance (PERMISSION_DENIED → "read-only", …).
pub fn put_artifact(&self, repository: &str, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
self.block(self.holger.put(repository, id, data))
}
/// Run a Móðguðr (modgunn) security scan over `repository` (whose ecosystem
/// is `repo_type`): list the backing archive over the core API, map each file
/// to a `modgunn::scan::Artifact`, and fold the per-artifact verdicts into the
/// [`SecurityView`]. One core call (`list_archive_files`); the verdict itself
/// is pure + offline. Gated to where `server-lib` (hence modgunn) is present:
/// the `gui` build and the test build (dev-dependency) — the default
/// transport-only build doesn't link modgunn.
#[cfg(any(feature = "tools", test))]
pub fn run_security_scan(&mut self, repository: &str, repo_type: &str) {
use server_lib::security;
let ecosystem = security::ecosystem_for(repo_type);
let mut view = SecurityView {
repository: repository.to_string(),
ecosystem: ecosystem.clone(),
..Default::default()
};
match self.block(self.holger.list_archive_files(repository, None)) {
Ok(files) => {
let (scanner, source) = scanner_for(&ecosystem);
view.source = source;
let refs: Vec<&str> = files.iter().map(|s| s.as_str()).collect();
let report = security::scan_listing(&ecosystem, refs, &scanner);
view.scanned = report.verdicts.len();
view.skipped = report.skipped;
view.pass = report.pass;
view.warn = report.warn;
view.block = report.block;
view.decision = match report.decision() {
security::Decision::Pass => "pass",
security::Decision::Warn => "warn",
security::Decision::Block => "block",
}
.to_string();
view.findings = report
.verdicts
.iter()
.filter(|v| !v.findings.is_empty())
.map(|v| SecFinding {
name: v.artifact.name.clone(),
version: v.artifact.version.clone(),
decision: match v.decision {
security::Decision::Pass => "pass",
security::Decision::Warn => "warn",
security::Decision::Block => "block",
}
.to_string(),
ids: v.findings.iter().map(|f| f.id.clone()).collect(),
summary: v.findings.first().map(|f| f.summary.clone()).unwrap_or_default(),
})
.collect();
view.loaded = true;
// Dependency-graph view: ALL scanned artifacts as nodes coloured by
// verdict (the table above lists only the non-clean ones). Edges
// come from the warehouse when a captured snapshot exists for this
// subject; a served archive has none, so the view shows the coloured
// node set (has_edges = false). The warehouse-edge plumbing slot is
// here — `read_dep_edges` + `read_sbom_components` when wired.
let mut graph = DepGraphData {
repository: repository.to_string(),
loaded: true,
..Default::default()
};
for v in &report.verdicts {
let decision = match v.decision {
security::Decision::Pass => {
graph.pass += 1;
"pass"
}
security::Decision::Warn => {
graph.warn += 1;
"warn"
}
security::Decision::Block => {
graph.block += 1;
"block"
}
};
graph.nodes.push(DepGraphNode {
name: v.artifact.name.clone(),
version: v.artifact.version.clone(),
decision: decision.to_string(),
ids: v.findings.iter().map(|f| f.id.clone()).collect(),
});
}
self.dep_graph = graph;
}
Err(e) => {
view.error = Some(e.to_string());
self.dep_graph = DepGraphData {
repository: repository.to_string(),
error: Some(e.to_string()),
..Default::default()
};
}
}
self.security = view;
}
/// Tool #1 — actually RUN the `holger-ds` artifact-delivery benchmark at the
/// selected scale, **over the package protocol** (Holger's native HTTP door),
/// and fold the throughput (MB/s · ops/s) into [`DsConfig::result`]. Reuses the
/// exact bench-scenarios path the CLI verb + the head-to-head comparison use:
/// build a synthetic crate corpus → stand up the real HTTP gateway (or drive an
/// EXTERNAL `target` base URL) → gatling-style parallel GET pool.
///
/// Inline + CI-safe: corpus budget + bombard duration are clamped per scale so
/// `micro` completes in ~1s. The gatling pool blocks OS threads and the gateway
/// `tokio::spawn`s its accept loop, so this drives a dedicated **multi-thread**
/// runtime (not the data layer's current-thread one). Gated to where
/// `bench-scenarios`/`server-lib` are linked (the `gui` build + the test build).
#[cfg(any(feature = "bench", test))]
pub fn run_holger_ds(&mut self) {
use bench_scenarios::{build_crate_corpus, cores, free_disk_bytes, served};
use std::time::Duration;
let scale = self.holger_ds.scale.clone();
let target = self.holger_ds.target.trim().to_string();
// Inline-safe corpus budget (MiB) + bombard seconds per scale. micro is
// tiny so the UI run returns quickly; small/large grow but stay bounded.
let (cap_mib, secs): (u64, u64) = match scale.as_str() {
"large" => (96, 3),
"small" => (48, 2),
_ => (16, 1), // micro
};
let mut res = DsResult {
scale: scale.clone(),
target: if target.is_empty() { "self-host".to_string() } else { target.clone() },
..Default::default()
};
let run = (|| -> anyhow::Result<()> {
let data_dir = std::env::temp_dir().join(format!("holger-ds-ui-{scale}"));
std::fs::create_dir_all(&data_dir)?;
// Cap to ≤25% of free disk so a tight filesystem still runs.
let cap = cap_mib.saturating_mul(1024 * 1024);
let budget = match free_disk_bytes(&data_dir) {
Some(f) => cap.min(f / 4).max(8 * 1024 * 1024),
None => cap,
};
let (archive, crates) = build_crate_corpus(&data_dir, budget)?;
res.crates = crates;
let concurrency = cores();
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(concurrency.clamp(2, 8))
.enable_all()
.build()?;
let tgt = if target.is_empty() { None } else { Some(target.as_str()) };
let (files, outcome) = rt.block_on(served::run_crate_fetch(
&archive,
"rust",
tgt,
concurrency,
Duration::from_secs(secs),
))?;
res.files = files;
res.ops = outcome.ops;
res.bytes = outcome.bytes;
res.mbs = outcome.mbs;
res.ops_per_sec = outcome.ops_per_sec;
Ok(())
})();
match run {
Ok(()) => {
res.ran = true;
res.status = format!(
"ran {} [{}] · {} files · {:.1} MB/s · {:.0} ops/s · {} ops",
scale, res.target, res.files, res.mbs, res.ops_per_sec, res.ops
);
}
Err(e) => {
res.ran = false;
res.status = format!("holger-ds failed: {e}");
res.error = Some(e.to_string());
}
}
self.holger_ds.status = res.status.clone();
self.holger_ds.result = Some(res);
}
/// Tool #6 — dispatch the **demo appliance** container THROUGH the shared job
/// manager `jera`: [`crate::infra::dispatch_demo`] declares the pinned demo
/// registry [`ContainerSpec`](jera::container::ContainerSpec), records its
/// bring-up as a durable [`jera::jobs`] `BOOT_CONTAINER` job (returning the job
/// id), and — with holger-server-ui's `containers` feature — boots it live through
/// jera→draupnir. The result (job id + spec + status) lands in
/// [`Self::demo_appliance`] for the pane + `state_json`. The ledger lives under
/// `HOLGER_DATA/holger-server-ui-jobs` when set, else a temp dir (daemon-free default).
pub fn run_demo(&mut self) {
let jobs_root = match std::env::var_os("HOLGER_DATA") {
Some(d) => std::path::PathBuf::from(d).join("holger-server-ui-jobs"),
None => std::env::temp_dir().join("holger-server-ui-demo-jobs"),
};
self.demo_appliance = Some(crate::infra::dispatch_demo(&jobs_root));
}
/// Tool #2 — actually RUN the Migrate ("suck"): build the agent connector for
/// the chosen source (Nexus / Artifactory), list its repositories (optionally
/// filtered), and dump them into the `output` znippy archive via
/// `agent_lib::operations::dump_all_repos_to_znippy`. The connector's HTTP work
/// is driven on the data layer's runtime. A missing URL or an unreachable
/// registry is surfaced as a CLEAR result error — never a silent no-op. Gated
/// to where `agent-lib` is linked (the `gui` build + the test build).
#[cfg(any(feature = "tools", test))]
pub fn run_migrate(&mut self) {
use agent_lib::{operations, ArtifactoryConnector, ConnectorTrait, NexusConnector};
let m = self.migrate.clone();
let mut run = MigrateRun {
source: m.source.clone(),
url: m.url.clone(),
output: m.output.clone(),
..Default::default()
};
if m.url.trim().is_empty() {
run.status = "registry url is required — enter the Nexus/Artifactory base URL".to_string();
run.error = Some("registry url is required".to_string());
self.migrate.status = run.status.clone();
self.migrate.result = Some(run);
return;
}
let res = self.block(async {
let connector: Arc<dyn ConnectorTrait> = match m.source.as_str() {
"artifactory" => Arc::new(ArtifactoryConnector::new(m.url.clone(), None, None)?),
_ => Arc::new(NexusConnector::new(m.url.clone(), None, None)?),
};
let repos = connector.list_repositories().await?;
let repos: Vec<_> = if m.repository.trim().is_empty() {
repos
} else {
repos.into_iter().filter(|r| r.name == m.repository).collect()
};
let n = repos.len();
operations::dump_all_repos_to_znippy(
connector,
repos,
std::path::PathBuf::from(&m.output),
true,
)
.await?;
Ok::<usize, anyhow::Error>(n)
});
match res {
Ok(n) => {
run.ran = true;
run.repositories = n;
run.status = format!(
"migrated {n} repositorie(s) from {} {} → {}",
m.source, m.url, m.output
);
}
Err(e) => {
run.status = format!("migrate failed: {e}");
run.error = Some(e.to_string());
}
}
self.migrate.status = run.status.clone();
self.migrate.result = Some(run);
}
/// Tool #5 — produce a real Airgap populate plan/result. Validates the typed
/// [`PopulateArgs`] first (so a missing mTLS cert/key surfaces a CLEAR message,
/// never a crash); a `--dry-run` returns the pure-Rust plan only, while a real
/// run delegates to the `distribution/airgap/populate` driver (located by
/// walking up from the cwd) and captures its log + exit status. The outcome
/// lands in [`UiData::airgap_run`].
pub fn run_airgap(&mut self) {
let mut run = AirgapRun {
dry_run: self.airgap.dry_run,
plan: self.airgap.plan(),
..Default::default()
};
match self.airgap.validate() {
Err(e) => {
run.status = format!("cannot run: {e}");
run.error = Some(e.to_string());
}
Ok(()) => {
if self.airgap.dry_run {
run.ran = true;
run.success = true;
run.status = format!(
"dry-run plan ready · {} stack(s) · target {}",
self.airgap.stacks().len(),
self.airgap.target.as_arg()
);
} else {
match find_populate_driver() {
Some(driver) => match holger_airgap::execute(&self.airgap, &driver) {
Ok(report) => {
run.ran = true;
run.log = report.log;
run.exit_code = report.exit_code;
run.success = report.success;
run.status = if report.success {
"populate succeeded".to_string()
} else {
format!("populate exited {}", report.exit_code)
};
}
Err(e) => {
run.status = format!("populate failed: {e}");
run.error = Some(e.to_string());
}
},
None => {
run.status = "populate driver not found (distribution/airgap/populate) — use dry-run or run from the holger repo".to_string();
run.error = Some("populate driver not found".to_string());
}
}
}
}
}
self.airgap_run = Some(run);
}
/// Tool #4 — actually RUN the Deploy (Skíðblaðnir): read the selected repo's
/// **sealed znippy bundle** over the core API (`archive_info` — a real gRPC /
/// in-process call, the same one the Archive tab uses) to confirm there is an
/// artifact to deploy and capture its size, then produce a real airgap-engine
/// deploy plan (push-only carry of the already-sealed bundle to `target`). The
/// outcome lands in [`UiData::deploy`]. An empty / missing archive surfaces a
/// CLEAR message — never a silent no-op (this is the "** being implemented"
/// no-op-button fix). The actual carry/push half is the inherently-external
/// airgap driver (Tool #5's `execute` path); here the deploy is **planned** so
/// it can run with no creds against a test target, mirroring the airgap
/// dry-run default.
pub fn run_deploy(&mut self, repository: &str, target: holger_airgap::Target) {
let mut run = DeployRun {
repository: repository.to_string(),
target: target.as_arg().to_string(),
..Default::default()
};
match self.block(self.holger.archive_info(repository)) {
Ok(info) if info.file_count == 0 => {
run.status = format!(
"repo '{repository}' has no sealed znippy bundle to deploy (empty archive)"
);
run.error = Some("no sealed bundle (empty archive)".to_string());
}
Ok(info) => {
run.file_count = info.file_count;
run.bytes = info.total_uncompressed_bytes;
run.archive_path = info.archive_path.clone();
// Real airgap-engine deploy plan: carry the already-sealed bundle to
// the chosen target (push-only). Validated + pure — no creds, no I/O,
// no shell — so it runs against a test target without loosening auth.
let args = holger_airgap::PopulateArgs {
target,
push_only: true,
dry_run: true,
repository: Some(repository.to_string()),
..Default::default()
};
run.plan = args.plan();
run.ran = true;
run.status = format!(
"deploy planned · {} files / {} bytes → {}",
info.file_count,
info.total_uncompressed_bytes,
target.as_arg()
);
}
Err(e) => {
run.status = format!("deploy failed: {e}");
run.error = Some(e.to_string());
}
}
self.deploy.status = run.status.clone();
self.deploy.result = Some(run);
}
/// The combined Airgap state — the typed args plus the last run result — for
/// `state_json` (the Tools#5 pane as DATA).
pub fn airgap_state_json(&self) -> Value {
let mut v = self.airgap.state_json();
if let Value::Object(ref mut map) = v {
map.insert(
"run".to_string(),
self.airgap_run
.as_ref()
.map(|r| serde_json::to_value(r).unwrap_or(Value::Null))
.unwrap_or(Value::Null),
);
// Surface validate() as a pure state pointer: `can_run` gates the
// Run button (the red "cannot run" branch is `can_run == false`),
// with the validation error message when it fails.
match self.airgap.validate() {
Ok(()) => {
map.insert("can_run".to_string(), Value::Bool(true));
map.insert("validate_error".to_string(), Value::Null);
}
Err(e) => {
map.insert("can_run".to_string(), Value::Bool(false));
map.insert("validate_error".to_string(), Value::String(e.to_string()));
}
}
}
v
}
/// One combined snapshot of every view — the headless `$APP_STATE` a robot
/// test asserts against.
pub fn state_json(&self) -> Value {
json!({
"status": self.status.state_json(),
"profile": self.profile.state_json(),
"repos": self.repos.state_json(),
"artifact": self.artifact.state_json(),
"browse": self.browse.state_json(),
"archive": self.archive.state_json(),
"security": self.security.state_json(),
"airgap": self.airgap_state_json(),
"holger_ds": self.holger_ds.state_json(),
"migrate": self.migrate.state_json(),
"deploy": self.deploy.state_json(),
"lineage": self.lineage.state_json(),
})
}
}
/// Walk up from the current working directory looking for the documented
/// `distribution/airgap/populate` driver. `None` when not inside a holger repo
/// (e.g. an installed binary) — the caller then steers the operator to `--dry-run`.
fn find_populate_driver() -> Option<std::path::PathBuf> {
let mut dir = std::env::current_dir().ok();
while let Some(d) = dir {
if let Some(p) = holger_airgap::driver_in(&d) {
return Some(p);
}
dir = d.parent().map(|p| p.to_path_buf());
}
None
}
#[cfg(feature = "gui-core")]
impl UiData {
/// Connect to a remote holger gRPC endpoint (no credentials).
pub fn connect_remote(endpoint: &str) -> anyhow::Result<Self> {
Self::connect_remote_with_token(endpoint, None)
}
/// Connect to a remote holger gRPC endpoint, optionally with an OIDC bearer
/// token injected on every request (needed for write access on an
/// auth-enabled server).
///
/// The data layer's private runtime owns the channel — so the connect and
/// every later call share one current-thread runtime (the single-runtime
/// tonic idiom the nornir viz uses; avoids a channel outliving the runtime
/// that spawned it).
pub fn connect_remote_with_token(endpoint: &str, token: Option<&str>) -> anyhow::Result<Self> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let holger: Arc<dyn HolgerObject> = match token {
Some(t) => Arc::new(
rt.block_on(server_lib::RemoteHolger::connect_with_token(endpoint.to_string(), t))?,
),
None => {
Arc::new(rt.block_on(server_lib::RemoteHolger::connect(endpoint.to_string()))?)
}
};
let mut data = Self::with_runtime(holger, rt);
data.endpoint = endpoint.to_string();
Ok(data)
}
/// Connect to a remote holger gRPC endpoint over TLS (and mTLS when a
/// `client_identity` cert+key is supplied), optionally with a bearer token.
///
/// Same single-runtime pattern as [`UiData::connect_remote_with_token`]: the
/// data layer's private current-thread runtime owns the TLS channel, so the
/// connect and every later call share one runtime.
///
/// NOTE: only the client TLS path is built here; there is no end-to-end mTLS
/// integration test (it needs generated certs + a TLS server — a manual /
/// mannequin step). This path is verified to *compile* only.
pub fn connect_remote_with_tls(
endpoint: &str,
ca: Option<Vec<u8>>,
client_identity: Option<(Vec<u8>, Vec<u8>)>,
token: Option<&str>,
) -> anyhow::Result<Self> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let holger: Arc<dyn HolgerObject> = Arc::new(rt.block_on(
server_lib::RemoteHolger::connect_with_tls(
endpoint.to_string(),
ca,
client_identity,
token.map(|t| t.to_string()),
),
)?);
let mut data = Self::with_runtime(holger, rt);
data.endpoint = endpoint.to_string();
Ok(data)
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
/// A configurable in-memory `HolgerObject` for driving the view-model with no
/// server. `fail` flips every call to an error (transport-failure path).
#[derive(Default)]
struct FakeHolger {
repos: Vec<RepositoryInfo>,
artifacts: HashMap<(String, String), Vec<u8>>, // (repo, name) -> bytes
/// Canned listings keyed by repository name — what `list_artifacts`
/// returns (filtered by `name_filter` substring when one is given).
listings: HashMap<String, Vec<traits::ArtifactEntry>>,
puts: Mutex<Vec<(String, String, Vec<u8>)>>,
fail: bool,
}
impl FakeHolger {
fn key(repo: &str, id: &ArtifactId) -> (String, String) {
(repo.to_string(), id.name.clone())
}
}
#[async_trait]
impl HolgerObject for FakeHolger {
async fn fetch(&self, repository: &str, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
if self.fail {
anyhow::bail!("boom");
}
Ok(self.artifacts.get(&Self::key(repository, id)).cloned())
}
async fn put(&self, repository: &str, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
if self.fail {
anyhow::bail!("boom");
}
// Mirror the server: reject writes to a read-only repo.
let writable = self
.repos
.iter()
.find(|r| r.name == repository)
.map(|r| r.writable)
.unwrap_or(false);
if !writable {
anyhow::bail!("Repository is read-only");
}
self.puts
.lock()
.unwrap()
.push((repository.to_string(), id.name.clone(), data.to_vec()));
Ok(())
}
async fn list_repositories(&self) -> anyhow::Result<Vec<RepositoryInfo>> {
if self.fail {
anyhow::bail!("boom");
}
Ok(self.repos.clone())
}
async fn list_artifacts(
&self,
repository: &str,
name_filter: Option<String>,
_limit: u32,
_page_token: Option<String>,
) -> anyhow::Result<(Vec<traits::ArtifactEntry>, String)> {
if self.fail {
anyhow::bail!("boom");
}
// An unknown / empty repo yields an empty page (mirrors a server
// returning nothing), not an error.
let entries = self
.listings
.get(repository)
.cloned()
.unwrap_or_default()
.into_iter()
.filter(|e| match &name_filter {
Some(f) => e.id.name.contains(f.as_str()),
None => true,
})
.collect();
Ok((entries, String::new()))
}
async fn list_archive_files(
&self,
repository: &str,
prefix: Option<String>,
) -> anyhow::Result<Vec<String>> {
if self.fail {
anyhow::bail!("boom");
}
// Canned archive listing for the known repo "rust-arch"; unknown
// repos yield an empty listing (mirrors a backend with no archive).
let files: Vec<String> = if repository == "rust-arch" {
vec![
"crates/serde-1.0.0.crate".to_string(),
"crates/tokio-1.2.0.crate".to_string(),
"index/config.json".to_string(),
]
} else if repository == "rust-vuln" {
// A corpus carrying the demo High-severity openssl advisory.
vec![
"crates/serde-1.0.0.crate".to_string(),
"crates/openssl-0.10.55.crate".to_string(),
"index/config.json".to_string(),
]
} else {
Vec::new()
};
Ok(match prefix {
Some(p) => files.into_iter().filter(|f| f.starts_with(&p)).collect(),
None => files,
})
}
async fn archive_info(&self, repository: &str) -> anyhow::Result<traits::ArchiveInfo> {
if self.fail {
anyhow::bail!("boom");
}
if repository == "rust-arch" {
Ok(traits::ArchiveInfo {
file_count: 3,
total_uncompressed_bytes: 600,
archive_path: "rust-arch".into(),
})
} else {
Ok(traits::ArchiveInfo::default())
}
}
async fn health(&self) -> anyhow::Result<Health> {
if self.fail {
anyhow::bail!("boom");
}
Ok(Health {
status: "ok".into(),
version: "9.9.9".into(),
uptime_seconds: 42,
})
}
}
fn repo(name: &str, writable: bool) -> RepositoryInfo {
RepositoryInfo {
name: name.into(),
repo_type: "Rust".into(),
writable,
has_archive: true,
}
}
fn ui(fake: FakeHolger) -> UiData {
UiData::new(Arc::new(fake)).expect("runtime")
}
fn entry(name: &str, version: &str, size: i64) -> traits::ArtifactEntry {
traits::ArtifactEntry {
id: ArtifactId {
namespace: None,
name: name.into(),
version: version.into(),
},
size_bytes: size,
content_type: "application/octet-stream".into(),
}
}
#[test]
fn status_ok_populates_view_and_state_json() {
let mut d = ui(FakeHolger::default());
d.refresh_status();
assert!(d.status.loaded);
assert_eq!(d.status.version, "9.9.9");
assert_eq!(d.status.uptime_seconds, 42);
assert!(d.status.error.is_none());
let s = d.status.state_json();
assert_eq!(s["loaded"], json!(true));
assert_eq!(s["version"], json!("9.9.9"));
}
#[test]
fn status_error_is_surfaced_not_loaded() {
let mut d = ui(FakeHolger {
fail: true,
..Default::default()
});
d.refresh_status();
assert!(!d.status.loaded);
assert_eq!(d.status.error.as_deref(), Some("boom"));
}
#[test]
fn repos_load_selects_first_and_preserves_selection_by_name() {
let mut d = ui(FakeHolger {
repos: vec![repo("rust-prod", false), repo("rust-dev", true)],
..Default::default()
});
d.refresh_repos();
assert_eq!(d.repos.repos.len(), 2);
assert_eq!(d.repos.selected, Some(0));
assert_eq!(d.repos.selected_repo().unwrap().name, "rust-prod");
// Select the writable one, then refresh: selection sticks to "rust-dev".
d.select_repo(1);
d.refresh_repos();
assert_eq!(d.repos.selected_repo().unwrap().name, "rust-dev");
}
#[test]
fn upload_enabled_tracks_selected_repo_writable() {
let mut d = ui(FakeHolger {
repos: vec![repo("rust-prod", false), repo("rust-dev", true)],
..Default::default()
});
d.refresh_repos();
// rust-prod (read-only) selected first.
assert!(!d.repos.upload_enabled());
d.select_repo(1); // rust-dev (writable)
assert!(d.repos.upload_enabled());
}
#[test]
fn fetch_found_sets_size_and_does_not_leak_bytes_into_state_json() {
let mut arts = HashMap::new();
arts.insert(("rust-prod".to_string(), "serde".to_string()), vec![1u8; 100]);
let mut d = ui(FakeHolger {
artifacts: arts,
..Default::default()
});
let id = ArtifactId {
namespace: None,
name: "serde".into(),
version: "1.0.0".into(),
};
d.fetch_artifact("rust-prod", id);
assert!(d.artifact.found);
assert_eq!(d.artifact.size_bytes, 100);
assert_eq!(d.artifact.data.len(), 100);
let s = d.artifact.state_json();
assert_eq!(s["size_bytes"], json!(100));
assert_eq!(s["found"], json!(true));
assert!(s.get("data").is_none(), "raw bytes must not appear in state_json");
}
#[test]
fn fetch_missing_is_not_found_not_error() {
let mut d = ui(FakeHolger::default());
let id = ArtifactId {
namespace: None,
name: "nope".into(),
version: "0.0.0".into(),
};
d.fetch_artifact("rust-prod", id);
assert!(!d.artifact.found);
assert!(d.artifact.error.is_none());
}
#[test]
fn fetch_transport_error_is_surfaced() {
let mut d = ui(FakeHolger {
fail: true,
..Default::default()
});
let id = ArtifactId {
namespace: None,
name: "serde".into(),
version: "1.0.0".into(),
};
d.fetch_artifact("rust-prod", id);
assert!(!d.artifact.found);
assert_eq!(d.artifact.error.as_deref(), Some("boom"));
}
#[test]
fn put_rejected_on_read_only_repo() {
let d = ui(FakeHolger {
repos: vec![repo("rust-prod", false)],
..Default::default()
});
let id = ArtifactId {
namespace: None,
name: "mycrate".into(),
version: "0.1.0".into(),
};
let err = d.put_artifact("rust-prod", &id, b"bytes").unwrap_err();
assert!(err.to_string().contains("read-only"));
}
#[test]
fn put_succeeds_on_writable_repo() {
let d = ui(FakeHolger {
repos: vec![repo("rust-dev", true)],
..Default::default()
});
let id = ArtifactId {
namespace: None,
name: "mycrate".into(),
version: "0.1.0".into(),
};
d.put_artifact("rust-dev", &id, b"bytes").unwrap();
}
#[test]
fn combined_state_json_has_all_three_views() {
// T33: assert REAL content in the combined snapshot, not just key
// presence — seed known repos/listing/archive, drive each refresh, then
// check the rendered values (LAW 2: no empty data).
let mut listings = HashMap::new();
listings.insert(
"rust-arch".to_string(),
vec![entry("serde", "1.0.0", 100), entry("tokio", "1.2.0", 200)],
);
let mut d = ui(FakeHolger {
repos: vec![repo("rust-arch", true), repo("cache", false)],
listings,
..Default::default()
});
d.refresh_status();
d.refresh_profile();
d.refresh_repos();
d.refresh_browse("rust-arch", None);
d.refresh_archive("rust-arch", None);
let s = d.state_json();
// Key presence (the original contract).
for k in ["status", "profile", "repos", "artifact", "browse", "archive",
"security", "airgap", "holger_ds", "migrate", "deploy"] {
assert!(s.get(k).is_some(), "missing key {k}: {s}");
}
// Real rendered values.
assert_eq!(s["status"]["status"], json!("ok"), "status ok: {s}");
assert_eq!(s["status"]["version"], json!("9.9.9"), "version: {s}");
let names: Vec<&str> = s["repos"]["repos"].as_array().unwrap()
.iter().map(|r| r["name"].as_str().unwrap()).collect();
assert!(names.contains(&"rust-arch") && names.contains(&"cache"), "repos listed: {s}");
assert_eq!(s["browse"]["entries"].as_array().unwrap().len(), 2, "browse entries: {s}");
assert_eq!(s["archive"]["file_count"], json!(3), "archive file_count: {s}");
// A profile is derived (FakeHolger has a writable repo → dynamic).
assert_eq!(s["profile"]["read_only"], json!(false), "profile derived: {s}");
// At least one currently-unchecked pane key carries its default shape.
assert!(s["holger_ds"]["scale"].is_string(), "holger_ds scale present: {s}");
assert!(s["airgap"].get("can_run").is_some(), "airgap can_run present: {s}");
}
#[test]
fn archive_lists_files_and_stats() {
let mut d = ui(FakeHolger::default());
// Known archive-backed repo: stats + file paths populate the view.
d.refresh_archive("rust-arch", None);
assert!(d.archive.error.is_none(), "archive error: {:?}", d.archive.error);
assert_eq!(d.archive.repository, "rust-arch");
assert_eq!(d.archive.file_count, 3);
assert_eq!(d.archive.total_uncompressed_bytes, 600);
assert_eq!(d.archive.archive_path, "rust-arch");
assert_eq!(d.archive.files.len(), 3);
assert_eq!(d.archive.files[0], "crates/serde-1.0.0.crate");
// A path prefix narrows the listing (stats stay whole-archive).
d.refresh_archive("rust-arch", Some("index/".into()));
assert_eq!(d.archive.files, vec!["index/config.json".to_string()]);
assert_eq!(d.archive.file_count, 3);
// state_json carries the files + stats.
let s = d.archive.state_json();
assert_eq!(s["repository"], json!("rust-arch"));
assert_eq!(s["file_count"], json!(3));
// An unknown / non-archive repo is empty, not an error.
d.refresh_archive("does-not-exist", None);
assert!(d.archive.error.is_none());
assert!(d.archive.files.is_empty());
assert_eq!(d.archive.file_count, 0);
}
#[test]
fn archive_transport_error_is_surfaced() {
let mut d = ui(FakeHolger {
fail: true,
..Default::default()
});
d.refresh_archive("rust-arch", None);
assert_eq!(d.archive.error.as_deref(), Some("boom"));
assert!(d.archive.files.is_empty());
}
#[test]
fn browse_lists_artifacts_for_repo() {
let mut listings = HashMap::new();
listings.insert(
"rust-prod".to_string(),
vec![entry("serde", "1.0.0", 100), entry("tokio", "1.2.0", 200)],
);
let mut d = ui(FakeHolger {
listings,
..Default::default()
});
d.refresh_browse("rust-prod", None);
assert!(d.browse.error.is_none());
assert_eq!(d.browse.repository, "rust-prod");
assert_eq!(d.browse.entries.len(), 2);
assert_eq!(d.browse.entries[0].name, "serde");
assert_eq!(d.browse.entries[0].size_bytes, 100);
assert_eq!(d.browse.entries[1].name, "tokio");
// name_filter narrows by substring.
d.refresh_browse("rust-prod", Some("ser".into()));
assert_eq!(d.browse.entries.len(), 1);
assert_eq!(d.browse.entries[0].name, "serde");
// state_json carries the entries.
let s = d.browse.state_json();
assert_eq!(s["repository"], json!("rust-prod"));
assert_eq!(s["entries"][0]["name"], json!("serde"));
}
#[test]
fn security_scan_clean_repo_passes() {
let mut d = ui(FakeHolger::default());
d.run_security_scan("rust-arch", "rust");
assert!(d.security.loaded);
assert!(d.security.error.is_none());
assert_eq!(d.security.ecosystem, "cargo");
assert_eq!(d.security.scanned, 2); // index/config.json skipped
assert_eq!(d.security.skipped, 1);
assert_eq!(d.security.pass, 2);
assert_eq!(d.security.block, 0);
assert_eq!(d.security.decision, "pass");
assert!(d.security.findings.is_empty());
// state_json carries the tallies.
let s = d.security.state_json();
assert_eq!(s["decision"], json!("pass"));
assert_eq!(s["scanned"], json!(2));
}
#[test]
fn security_scan_vulnerable_repo_blocks_with_advisory() {
let mut d = ui(FakeHolger::default());
d.run_security_scan("rust-vuln", "rust");
assert_eq!(d.security.decision, "block");
assert_eq!(d.security.block, 1);
assert_eq!(d.security.pass, 1);
let blocked = d.security.findings.iter().find(|f| f.decision == "block").unwrap();
assert_eq!(blocked.name, "openssl");
assert!(blocked.ids.iter().any(|i| i == "RUSTSEC-2023-0044"));
}
#[test]
fn dependency_graph_colours_all_nodes_by_verdict() {
let mut d = ui(FakeHolger::default());
d.run_security_scan("rust-vuln", "rust");
let g = &d.dep_graph;
assert!(g.loaded);
assert_eq!(g.repository, "rust-vuln");
// ALL scanned artifacts are nodes (incl. the clean one the table omits).
assert_eq!(g.nodes.len(), 2);
assert_eq!(g.block, 1);
assert_eq!(g.pass, 1);
let blocked = g.nodes.iter().find(|n| n.decision == "block").unwrap();
assert_eq!(blocked.name, "openssl");
assert!(blocked.ids.iter().any(|i| i == "RUSTSEC-2023-0044"));
// A served archive has no captured snapshot ⇒ honest "no resolved graph".
assert!(!g.has_edges);
assert!(g.edges.is_empty());
// state_json carries the colour tallies + node set for the robot.
let s = g.state_json();
assert_eq!(s["block"], json!(1));
assert_eq!(s["pass"], json!(1));
assert_eq!(s["nodes"].as_array().unwrap().len(), 2);
}
#[test]
fn dependency_graph_surfaces_transport_error() {
let mut d = ui(FakeHolger { fail: true, ..Default::default() });
d.run_security_scan("rust-arch", "rust");
assert_eq!(d.dep_graph.error.as_deref(), Some("boom"));
assert!(!d.dep_graph.loaded);
assert!(d.dep_graph.nodes.is_empty());
}
#[test]
fn migrate_config_cli_command_switches_source_flag() {
let mut m = MigrateConfig::default();
m.url = "http://nexus:8081".into();
assert!(m.cli_command().contains("--from nexus --nexus-url http://nexus:8081"));
m.source = "artifactory".into();
m.url = "https://art".into();
m.repository = "libs-release".into();
let c = m.cli_command();
assert!(c.contains("--from artifactory --artifactory-url https://art"));
assert!(c.contains("--repository libs-release"));
assert_eq!(MigrateConfig::default().state_json()["source"], json!("nexus"));
}
#[test]
fn ds_config_cli_command_reflects_scale_and_target() {
let mut d = ui(FakeHolger::default());
assert_eq!(d.holger_ds.scale, "micro");
assert_eq!(d.holger_ds.cli_command(), "holger-server holger-ds --preset micro");
d.holger_ds.scale = "large".into();
d.holger_ds.target = "http://nexus:8081".into();
assert_eq!(
d.holger_ds.cli_command(),
"holger-server holger-ds --preset large --target http://nexus:8081"
);
// state_json carries the config.
let s = d.holger_ds.state_json();
assert_eq!(s["scale"], json!("large"));
assert_eq!(s["target"], json!("http://nexus:8081"));
}
#[test]
fn security_scan_transport_error_is_surfaced() {
let mut d = ui(FakeHolger { fail: true, ..Default::default() });
d.run_security_scan("rust-arch", "rust");
assert_eq!(d.security.error.as_deref(), Some("boom"));
assert!(!d.security.loaded);
}
#[test]
fn browse_unknown_repo_is_empty_not_error() {
let mut d = ui(FakeHolger::default());
d.refresh_browse("does-not-exist", None);
assert!(d.browse.error.is_none());
assert!(d.browse.entries.is_empty());
assert_eq!(d.browse.next_page_token, "");
}
/// Tool #1 — the holger-ds RUN actually drives the package protocol: a micro
/// self-host run builds a synthetic crate corpus, stands up Holger's real HTTP
/// gateway, bombards it, and folds a REAL throughput result into the view
/// (MB/s · ops/s · ops > 0). This is the no-op-button fix asserted as DATA.
#[test]
fn holger_ds_micro_run_produces_real_throughput() {
let mut d = ui(FakeHolger::default());
assert!(d.holger_ds.result.is_none(), "no result before running");
d.run_holger_ds();
let res = d.holger_ds.result.as_ref().expect("a run result");
assert!(res.error.is_none(), "micro self-host run should not error: {:?}", res.error);
assert!(res.ran, "the benchmark ran");
assert_eq!(res.scale, "micro");
assert_eq!(res.target, "self-host");
assert!(res.files > 0, "the gateway exposed crate files: {res:?}");
assert!(res.ops > 0, "GETs completed over the package protocol: {res:?}");
assert!(res.bytes > 0, "bytes were read off the wire: {res:?}");
// state_json carries the result + a human status line.
let s = d.holger_ds.state_json();
assert_eq!(s["result"]["ran"], json!(true));
assert!(s["status"].as_str().unwrap_or("").contains("MB/s"), "status shows throughput: {s}");
}
/// Tool #2 — Migrate with no URL surfaces a CLEAR validation message (not a
/// silent no-op, not a crash), and records a result the pane renders.
#[test]
fn migrate_without_url_surfaces_clear_validation() {
let mut d = ui(FakeHolger::default());
d.run_migrate();
let r = d.migrate.result.as_ref().expect("a run result");
assert!(!r.ran);
assert!(r.error.as_deref().unwrap_or("").contains("url"), "clear url message: {r:?}");
assert!(d.migrate.status.to_lowercase().contains("url"));
let s = d.migrate.state_json();
assert_eq!(s["result"]["ran"], json!(false));
}
/// Tool #5 — a dry-run produces a real plan/result with no creds (the runnable
/// demo default), and the plan lists the scale's stacks + endpoints.
#[test]
fn airgap_dry_run_produces_plan_result() {
let mut d = ui(FakeHolger::default());
d.airgap.dry_run = true;
d.run_airgap();
let run = d.airgap_run.as_ref().expect("an airgap run");
assert!(run.ran && run.success, "dry-run plan succeeds: {run:?}");
assert!(run.error.is_none());
assert!(run.plan.contains("airgap populate"), "plan present: {}", run.plan);
// The combined airgap state_json carries both the args + the run.
let s = d.airgap_state_json();
assert_eq!(s["run"]["ran"], json!(true));
assert_eq!(s["dry_run"], json!(true));
}
/// Tool #4 — Deploy reads the selected repo's REAL sealed znippy bundle
/// (`archive_info`) and produces a real airgap-engine deploy plan for the
/// chosen target. This is the no-op "** being implemented" button fix asserted
/// as DATA: a ran result carrying the bundle's file count + a push-only plan.
#[test]
fn deploy_plans_over_real_sealed_bundle() {
use holger_airgap::Target;
let mut d = ui(FakeHolger::default());
assert!(d.deploy.result.is_none(), "no result before running");
d.run_deploy("rust-arch", Target::Holger);
let r = d.deploy.result.as_ref().expect("a deploy result");
assert!(r.error.is_none(), "deploy over a sealed bundle should not error: {r:?}");
assert!(r.ran, "the deploy was planned");
assert_eq!(r.repository, "rust-arch");
assert_eq!(r.target, "holger");
assert_eq!(r.file_count, 3, "bundle file count from archive_info: {r:?}");
assert_eq!(r.bytes, 600, "bundle bytes from archive_info: {r:?}");
// The plan is the real airgap-engine push-only deploy plan.
assert!(r.plan.contains("airgap populate"), "real airgap plan: {}", r.plan);
assert!(r.plan.contains("push-only"), "deploy carries the sealed bundle: {}", r.plan);
// state_json carries the result + a human status line.
let s = d.deploy.state_json();
assert_eq!(s["result"]["ran"], json!(true));
assert_eq!(s["result"]["file_count"], json!(3));
assert!(s["status"].as_str().unwrap_or("").contains("deploy planned"), "status: {s}");
}
/// Tool #4 — deploying a repo with no sealed bundle (empty archive) surfaces a
/// CLEAR message (not a silent no-op, not a crash).
#[test]
fn deploy_empty_archive_surfaces_clear_message() {
use holger_airgap::Target;
let mut d = ui(FakeHolger::default());
d.run_deploy("does-not-exist", Target::Nexus);
let r = d.deploy.result.as_ref().expect("a deploy result");
assert!(!r.ran);
assert!(
r.error.as_deref().unwrap_or("").contains("sealed bundle"),
"clear empty-archive message: {r:?}"
);
assert!(d.deploy.status.to_lowercase().contains("no sealed"));
}
/// Tool #4 — a transport failure on `archive_info` is surfaced as a clear
/// deploy error (not a crash).
#[test]
fn deploy_transport_error_is_surfaced() {
use holger_airgap::Target;
let mut d = ui(FakeHolger { fail: true, ..Default::default() });
d.run_deploy("rust-arch", Target::Holger);
let r = d.deploy.result.as_ref().expect("a deploy result");
assert!(!r.ran);
assert_eq!(r.error.as_deref(), Some("boom"));
}
/// Tool #5 — an mTLS push with no client cert/key (and not dry-run) surfaces
/// the CLEAR "needs --client-cert and --client-key" validation in the run
/// result — Rickard's exact error, now handled (not a crash/no-op).
#[test]
fn airgap_mtls_without_certs_surfaces_clear_error() {
use holger_airgap::Auth;
let mut d = ui(FakeHolger::default());
d.airgap.dry_run = false;
d.airgap.auth = Auth::Mtls;
d.airgap.client_cert = None;
d.airgap.client_key = None;
d.run_airgap();
let run = d.airgap_run.as_ref().expect("an airgap run");
assert!(!run.ran);
let e = run.error.as_deref().unwrap_or("");
assert!(e.contains("client-cert") && e.contains("client-key"), "clear mTLS message: {run:?}");
}
}