use std::sync::Arc;
use serde::Serialize;
use serde_json::{json, Value};
use traits::{ArtifactId, Health, HolgerObject, RepositoryInfo, ServerProfile};
mod tools;
pub use tools::*;
mod lineage;
pub use lineage::*;
#[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)
}
}
#[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,
}
}
}
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct StatusView {
pub status: String,
pub version: String,
pub uptime_seconds: i64,
pub loaded: bool,
pub error: Option<String>,
}
impl StatusView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ProfileView {
pub profile: String,
pub read_only: bool,
pub label: String,
pub writable_repo_count: i32,
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)
}
}
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct ReposView {
pub repos: Vec<RepoRow>,
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)
}
pub fn selected_repo(&self) -> Option<&RepoRow> {
self.selected.and_then(|i| self.repos.get(i))
}
pub fn upload_enabled(&self) -> bool {
self.selected_repo().map(|r| r.writable).unwrap_or(false)
}
}
#[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,
pub found: bool,
pub error: Option<String>,
#[serde(skip)]
pub data: Vec<u8>,
}
impl ArtifactView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
#[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,
}
#[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)
}
}
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct SecFinding {
pub name: String,
pub version: String,
pub decision: String,
pub ids: Vec<String>,
pub summary: String,
}
#[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,
pub decision: String,
pub findings: Vec<SecFinding>,
pub loaded: bool,
pub source: String,
pub error: Option<String>,
}
impl SecurityView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct DepGraphNode {
pub name: String,
pub version: String,
pub decision: String,
pub ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct DepGraphData {
pub repository: String,
pub nodes: Vec<DepGraphNode>,
pub edges: Vec<(usize, usize)>,
pub has_edges: bool,
pub pass: usize,
pub warn: usize,
pub block: usize,
pub loaded: bool,
pub error: Option<String>,
}
impl DepGraphData {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
#[cfg(any(feature = "gui", test))]
fn scanner_for(ecosystem: &str) -> (server_lib::security::OsvScanner, String) {
use server_lib::security;
let _ = ecosystem; #[cfg(feature = "gui")]
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())
}
pub struct UiData {
holger: Arc<dyn HolgerObject>,
rt: tokio::runtime::Runtime,
pub status: StatusView,
pub profile: ProfileView,
pub repos: ReposView,
pub artifact: ArtifactView,
pub browse: BrowseView,
pub archive: ArchiveView,
pub security: SecurityView,
pub dep_graph: DepGraphData,
pub airgap: holger_airgap::PopulateArgs,
pub airgap_run: Option<AirgapRun>,
pub holger_ds: DsConfig,
pub migrate: MigrateConfig,
pub deploy: DeployConfig,
pub demo_appliance: Option<crate::infra::DemoDispatch>,
pub lineage: LineageView,
}
impl UiData {
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(),
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(),
})
}
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(),
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(),
}
}
fn block<T>(&self, fut: impl std::future::Future<Output = T>) -> T {
self.rt.block_on(fut)
}
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());
}
}
}
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());
}
}
}
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()),
}
}
pub fn select_repo(&mut self, idx: usize) {
if idx < self.repos.repos.len() {
self.repos.selected = Some(idx);
}
}
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,
name: id.name,
version: id.version,
..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;
}
Ok(None) => view.found = false,
Err(e) => view.error = Some(e.to_string()),
}
self.artifact = view;
}
const BROWSE_PAGE_SIZE: u32 = 100;
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;
}
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()),
}
}
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;
}
pub fn put_artifact(&self, repository: &str, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
self.block(self.holger.put(repository, id, data))
}
#[cfg(any(feature = "gui", 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;
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;
}
#[cfg(any(feature = "gui", 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();
let (cap_mib, secs): (u64, u64) = match scale.as_str() {
"large" => (96, 3),
"small" => (48, 2),
_ => (16, 1), };
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)?;
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);
}
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-ui-jobs"),
None => std::env::temp_dir().join("holger-ui-demo-jobs"),
};
self.demo_appliance = Some(crate::infra::dispatch_demo(&jobs_root));
}
#[cfg(any(feature = "gui", 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);
}
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);
}
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();
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);
}
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),
);
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
}
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(),
})
}
}
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")]
impl UiData {
pub fn connect_remote(endpoint: &str) -> anyhow::Result<Self> {
Self::connect_remote_with_token(endpoint, None)
}
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()))?)
}
};
Ok(Self::with_runtime(holger, rt))
}
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()),
),
)?);
Ok(Self::with_runtime(holger, rt))
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Default)]
struct FakeHolger {
repos: Vec<RepositoryInfo>,
artifacts: HashMap<(String, String), Vec<u8>>, 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");
}
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");
}
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");
}
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" {
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");
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();
assert!(!d.repos.upload_enabled());
d.select_repo(1); 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() {
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();
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}");
}
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}");
assert_eq!(s["profile"]["read_only"], json!(false), "profile derived: {s}");
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());
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");
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);
let s = d.archive.state_json();
assert_eq!(s["repository"], json!("rust-arch"));
assert_eq!(s["file_count"], json!(3));
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");
d.refresh_browse("rust-prod", Some("ser".into()));
assert_eq!(d.browse.entries.len(), 1);
assert_eq!(d.browse.entries[0].name, "serde");
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); 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());
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");
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"));
assert!(!g.has_edges);
assert!(g.edges.is_empty());
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"
);
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, "");
}
#[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:?}");
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}");
}
#[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));
}
#[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);
let s = d.airgap_state_json();
assert_eq!(s["run"]["ran"], json!(true));
assert_eq!(s["dry_run"], json!(true));
}
#[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:?}");
assert!(r.plan.contains("airgap populate"), "real airgap plan: {}", r.plan);
assert!(r.plan.contains("push-only"), "deploy carries the sealed bundle: {}", r.plan);
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}");
}
#[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"));
}
#[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"));
}
#[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:?}");
}
}