use std::collections::HashSet;
use std::fmt::Write as _;
use std::time::{Instant, SystemTime};
use thiserror::Error;
use super::cache::{self, CacheError};
use super::config::RegistryConfig;
use super::fetch::{self, FetchError, MAX_MANIFEST_BYTES, MAX_SIG_BYTES, MAX_TOOL_BYTES};
use super::manifest::{Manifest, ManifestError};
use crate::update::signature::{
SignatureOutcome, VerifyError, signature_outcome_is_acceptable,
verify_sigstore_signature_with_identity,
};
#[derive(Debug, Error)]
pub enum SyncError {
#[error(
"registry not configured — set [registry] url and enabled = true in ~/.jarvy/config.toml"
)]
NotConfigured,
#[error("registry config is unsafe: {0}")]
UnsafeConfig(String),
#[error("fetch error: {0}")]
Fetch(#[from] FetchError),
#[error("manifest parse error: {0}")]
Manifest(#[from] ManifestError),
#[error("cache error: {0}")]
Cache(#[from] CacheError),
#[error("signature verification failed: {0}")]
Signature(String),
#[error("cosign error: {0}")]
CosignBackend(#[from] VerifyError),
#[error(
"tool {name:?} sha256 mismatch: manifest says {expected}, fetched body hashes to {actual}"
)]
ShaMismatch {
name: String,
expected: String,
actual: String,
},
#[error("tool {name:?} body is not valid utf-8 or not a parseable PluginTool TOML")]
ToolParseFailed { name: String },
}
struct WorkerResult {
filenames: Vec<String>,
parsed_tools: Vec<crate::tools::plugins::PluginTool>,
}
fn set_first_error(
flag: &std::sync::atomic::AtomicBool,
slot: &std::sync::Mutex<Option<SyncError>>,
err: SyncError,
) {
if flag
.compare_exchange(
false,
true,
std::sync::atomic::Ordering::AcqRel,
std::sync::atomic::Ordering::Acquire,
)
.is_ok()
{
*slot.lock().expect("first_error_slot poisoned") = Some(err);
}
}
#[derive(Debug, Clone)]
pub struct SyncReport {
pub tools_synced: usize,
pub tools_removed: usize,
pub signature_verified: bool,
pub registry_url: String,
pub duration_ms: u64,
}
pub fn run_sync() -> Result<SyncReport, SyncError> {
let cfg = RegistryConfig::load().ok_or(SyncError::NotConfigured)?;
run_sync_with_config(&cfg)
}
pub fn run_sync_with_config(cfg: &RegistryConfig) -> Result<SyncReport, SyncError> {
let started_at = Instant::now();
if !cfg.is_active() {
emit(|| {
tracing::warn!(
event = "registry.sync.failed",
stage = "preflight",
reason = "not_configured"
);
});
return Err(SyncError::NotConfigured);
}
if let Err(reason) = cfg.validate_safety() {
emit(|| {
tracing::error!(
event = "registry.sync.failed",
stage = "preflight",
reason = "unsafe_config",
detail = %reason,
);
});
return Err(SyncError::UnsafeConfig(reason));
}
let redacted_url = crate::network::redact_credentials(&cfg.url).into_owned();
emit(|| {
tracing::info!(
event = "registry.sync.started",
registry_url = %redacted_url,
require_signature = cfg.require_signature,
);
});
let manifest_bytes = fetch_with_event(&cfg.manifest_url(), MAX_MANIFEST_BYTES, &redacted_url)?;
let manifest_str =
std::str::from_utf8(&manifest_bytes).map_err(|_| ManifestError::InvalidEncoding)?;
let manifest = Manifest::parse(manifest_str).inspect_err(|e| {
emit(|| {
tracing::error!(
event = "registry.sync.failed",
stage = "manifest_parse",
error = %e,
);
});
})?;
let cache_root = cache::cache_root()?;
let manifest_path = cache_root.join("manifest.json");
let sig_path = cache_root.join("manifest.json.sig");
let pem_path = cache_root.join("manifest.json.pem");
let manifest_unverified = cache_root.join("manifest.json.unverified");
let sig_unverified = cache_root.join("manifest.json.unverified.sig");
let pem_unverified = cache_root.join("manifest.json.unverified.pem");
cache::write_atomic(&manifest_unverified, &manifest_bytes)?;
let signature_verified = if cfg.require_signature {
let sig_bytes = fetch_with_event(&cfg.signature_url(), MAX_SIG_BYTES, &redacted_url)?;
let pem_bytes = fetch_with_event(&cfg.certificate_url(), MAX_SIG_BYTES, &redacted_url)?;
cache::write_atomic(&sig_unverified, &sig_bytes)?;
cache::write_atomic(&pem_unverified, &pem_bytes)?;
let outcome = verify_sigstore_signature_with_identity(
&manifest_unverified,
&cfg.signature_identity_regexp,
&cfg.signature_oidc_issuer,
)?;
if let Err(reason) = signature_outcome_is_acceptable(&outcome, false) {
let _ = std::fs::remove_file(&manifest_unverified);
let _ = std::fs::remove_file(&sig_unverified);
let _ = std::fs::remove_file(&pem_unverified);
emit(|| {
tracing::error!(
event = "registry.sync.signature_refused",
registry_url = %redacted_url,
identity_regexp = %cfg.signature_identity_regexp,
oidc_issuer = %cfg.signature_oidc_issuer,
reason = %reason,
);
});
return Err(SyncError::Signature(reason));
}
std::fs::rename(&manifest_unverified, &manifest_path).map_err(CacheError::from)?;
std::fs::rename(&sig_unverified, &sig_path).map_err(CacheError::from)?;
std::fs::rename(&pem_unverified, &pem_path).map_err(CacheError::from)?;
matches!(outcome, SignatureOutcome::Verified)
} else {
eprintln!(
"jarvy: WARNING — registry signature verification disabled \
(require_signature=false); only safe for local development against \
trusted mirrors"
);
emit(|| {
tracing::warn!(
event = "registry.signature_disabled",
registry_url = %redacted_url,
);
});
std::fs::rename(&manifest_unverified, &manifest_path).map_err(CacheError::from)?;
false
};
let pre_existing = list_cached_tool_files()?;
let staging = cache::fresh_staging_tools_dir()?;
let total = manifest.tools.len();
let first_error_flag = std::sync::atomic::AtomicBool::new(false);
let first_error_slot: std::sync::Mutex<Option<SyncError>> = std::sync::Mutex::new(None);
let next_idx = std::sync::atomic::AtomicUsize::new(0);
let max_parallel = std::env::var("JARVY_REGISTRY_SYNC_PARALLELISM")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(8);
#[allow(clippy::manual_clamp)] let max_parallel = max_parallel.max(1).min(total.max(1)).min(64);
let cfg_ref = cfg;
let manifest_ref = &manifest;
let redacted_ref = redacted_url.as_str();
let first_error_flag_ref = &first_error_flag;
let first_error_slot_ref = &first_error_slot;
let next_idx_ref = &next_idx;
let staging_ref = &staging;
let (parsed_tools, written_filenames) = std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(max_parallel);
for worker_id in 0..max_parallel {
handles.push(scope.spawn(move || -> WorkerResult {
let mut local_filenames: Vec<String> = Vec::with_capacity(total / max_parallel + 1);
let mut local_tools: Vec<crate::tools::plugins::PluginTool> =
Vec::with_capacity(total / max_parallel + 1);
let mut sha_buf = String::with_capacity(64);
let mut filename_buf = String::with_capacity(64);
loop {
if first_error_flag_ref.load(std::sync::atomic::Ordering::Acquire) {
return WorkerResult {
filenames: local_filenames,
parsed_tools: local_tools,
};
}
let idx = next_idx_ref.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if idx >= total {
return WorkerResult {
filenames: local_filenames,
parsed_tools: local_tools,
};
}
let entry = &manifest_ref.tools[idx];
let url = cfg_ref.tool_url(&entry.path);
let url_for_log = crate::network::redact_credentials(&url).into_owned();
emit(|| {
tracing::debug!(
event = "registry.sync.tool.start",
tool = %entry.name,
worker_id = worker_id,
url = %url_for_log,
);
});
let body = match fetch_with_event(&url, MAX_TOOL_BYTES, redacted_ref) {
Ok(b) => b,
Err(e) => {
emit(|| {
tracing::warn!(
event = "registry.sync.tool_fetch_failed",
tool = %entry.name,
worker_id = worker_id,
url = %url_for_log,
error = %e,
);
});
set_first_error(first_error_flag_ref, first_error_slot_ref, e);
return WorkerResult {
filenames: local_filenames,
parsed_tools: local_tools,
};
}
};
sha_buf.clear();
sha256_hex_into(&body, &mut sha_buf);
if sha_buf != entry.sha256 {
emit(|| {
tracing::error!(
event = "registry.sync.sha_mismatch",
tool = %entry.name,
worker_id = worker_id,
url = %url_for_log,
expected = %entry.sha256,
actual = %sha_buf,
);
});
set_first_error(
first_error_flag_ref,
first_error_slot_ref,
SyncError::ShaMismatch {
name: entry.name.clone(),
expected: entry.sha256.clone(),
actual: sha_buf.clone(),
},
);
return WorkerResult {
filenames: local_filenames,
parsed_tools: local_tools,
};
}
let parsed = match std::str::from_utf8(&body)
.ok()
.and_then(|s| toml::from_str::<crate::tools::plugins::PluginTool>(s).ok())
{
Some(p) => p,
None => {
emit(|| {
tracing::error!(
event = "registry.sync.tool_parse_failed",
tool = %entry.name,
worker_id = worker_id,
);
});
set_first_error(
first_error_flag_ref,
first_error_slot_ref,
SyncError::ToolParseFailed {
name: entry.name.clone(),
},
);
return WorkerResult {
filenames: local_filenames,
parsed_tools: local_tools,
};
}
};
filename_buf.clear();
write!(filename_buf, "{}.toml", entry.name)
.expect("write to String never fails");
let dest = staging_ref.join(&filename_buf);
if let Err(e) = cache::write_atomic(&dest, &body) {
emit(|| {
tracing::error!(
event = "registry.sync.tool_write_failed",
tool = %entry.name,
worker_id = worker_id,
error = %e,
);
});
set_first_error(
first_error_flag_ref,
first_error_slot_ref,
SyncError::Cache(e),
);
return WorkerResult {
filenames: local_filenames,
parsed_tools: local_tools,
};
}
emit(|| {
tracing::debug!(
event = "registry.sync.tool.synced",
tool = %entry.name,
worker_id = worker_id,
bytes = body.len() as u64,
);
});
let owned = std::mem::take(&mut filename_buf);
filename_buf = String::with_capacity(64);
local_filenames.push(owned);
local_tools.push(parsed);
}
}));
}
let mut filenames: HashSet<String> = HashSet::with_capacity(total);
let mut tools: Vec<crate::tools::plugins::PluginTool> = Vec::with_capacity(total);
for h in handles {
let WorkerResult {
filenames: f,
parsed_tools: t,
} = h.join().expect("worker thread panicked");
for name in f {
filenames.insert(name);
}
tools.extend(t);
}
(tools, filenames)
});
if let Some(err) = first_error_slot
.into_inner()
.expect("first_error_slot poisoned")
{
return Err(err);
}
cache::swap_staging_into_tools_dir()?;
let removed_count = pre_existing
.iter()
.filter(|f| !written_filenames.contains(*f))
.count();
let duration_ms = started_at.elapsed().as_millis() as u64;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let meta_payload = serde_json::json!({
"last_synced_at_unix": now,
"registry_url": redacted_url,
"tools_count": manifest.tools.len(),
"tools_removed": removed_count,
"signature_verified": signature_verified,
"duration_ms": duration_ms,
});
let meta_path = cache::cache_root()?.join("meta.json");
cache::write_atomic(&meta_path, meta_payload.to_string().as_bytes())?;
if let Err(e) = crate::tools::plugins::build_remote_index(now, parsed_tools) {
emit(|| {
tracing::warn!(
event = "registry.cache.index_build_failed",
error = %e,
);
});
}
emit(|| {
tracing::info!(
event = "registry.sync.completed",
registry_url = %redacted_url,
tools_synced = manifest.tools.len(),
tools_removed = removed_count,
signature_verified = signature_verified,
duration_ms = duration_ms,
);
});
Ok(SyncReport {
tools_synced: manifest.tools.len(),
tools_removed: removed_count,
signature_verified,
registry_url: redacted_url,
duration_ms,
})
}
fn fetch_with_event(
url: &str,
max_bytes: u64,
redacted_registry: &str,
) -> Result<Vec<u8>, SyncError> {
emit(|| {
tracing::debug!(
event = "registry.fetch.start",
url = %url,
max_bytes = max_bytes,
);
});
match fetch::fetch_bounded(url, max_bytes) {
Ok(bytes) => {
emit(|| {
tracing::debug!(
event = "registry.fetch.completed",
url = %url,
bytes = bytes.len() as u64,
);
});
Ok(bytes)
}
Err(e) => {
emit(|| {
tracing::warn!(
event = "registry.fetch.failed",
url = %url,
registry_url = %redacted_registry,
error = %e,
);
});
Err(SyncError::Fetch(e))
}
}
}
fn sha256_hex_into(bytes: &[u8], out: &mut String) {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
let digest = h.finalize();
for b in digest.iter() {
write!(out, "{b:02x}").expect("write to String never fails");
}
}
fn list_cached_tool_files() -> Result<Vec<String>, CacheError> {
let dir = cache::tools_dir()?;
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::with_capacity(64);
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
if entry.file_type()?.is_file() {
if let Some(name) = entry.file_name().to_str() {
if name.ends_with(".toml") {
out.push(name.to_string());
}
}
}
}
Ok(out)
}
use crate::observability::telemetry_gate::emit;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_sync_refuses_disabled_config() {
let cfg = RegistryConfig {
url: "https://example.com/r/".into(),
enabled: false,
..Default::default()
};
let err = run_sync_with_config(&cfg).unwrap_err();
assert!(matches!(err, SyncError::NotConfigured));
}
#[test]
fn run_sync_refuses_empty_url() {
let cfg = RegistryConfig {
url: "".into(),
enabled: true,
..Default::default()
};
let err = run_sync_with_config(&cfg).unwrap_err();
assert!(matches!(err, SyncError::NotConfigured));
}
#[test]
fn run_sync_refuses_http_url() {
let cfg = RegistryConfig {
url: "http://example.com/r/".into(),
enabled: true,
..Default::default()
};
let err = run_sync_with_config(&cfg).unwrap_err();
assert!(matches!(err, SyncError::UnsafeConfig(_)));
}
#[test]
fn run_sync_refuses_unanchored_identity_regex() {
let cfg = RegistryConfig {
url: "https://example.com/r/".into(),
enabled: true,
signature_identity_regexp: "github.com/x/.*".into(),
..Default::default()
};
let err = run_sync_with_config(&cfg).unwrap_err();
assert!(matches!(err, SyncError::UnsafeConfig(_)));
}
#[test]
fn sha256_hex_into_known_value() {
let mut buf = String::with_capacity(64);
sha256_hex_into(b"abc", &mut buf);
assert_eq!(
buf,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(buf.len(), 64);
assert!(
buf.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
}
}