use std::collections::BTreeSet;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use fs2::FileExt;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::ForgeError;
use crate::fsutil::{
acquire_path_lock, atomic_write_file, create_dir_all, lock_exclusive_cancellable,
read_to_string,
};
use crate::model::{BackendKind, InstallKind, RegistryEntry};
use crate::paths::{app_home, registry_path};
use crate::util::{now_secs, valid_sha256, valid_storage_id};
pub(crate) mod cache;
pub(crate) mod journal;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RegistryDocument {
pub revision: u64,
pub updated_at: u64,
pub entries: Vec<RegistryEntry>,
}
impl Default for RegistryDocument {
fn default() -> Self {
Self {
revision: 0,
updated_at: now_secs(),
entries: Vec::new(),
}
}
}
pub fn read_registry() -> Result<Vec<RegistryEntry>, ForgeError> {
Ok(read_registry_document()?.entries)
}
pub fn read_registry_document() -> Result<RegistryDocument, ForgeError> {
let path = registry_path();
if !path.is_file() {
return Ok(RegistryDocument::default());
}
read_json_registry(&path)
}
fn read_json_registry(path: &Path) -> Result<RegistryDocument, ForgeError> {
let document: RegistryDocument =
serde_json::from_str(&read_to_string(path)?).map_err(|error| {
ForgeError::Parse(format!(
"state file {} is corrupted: {error}",
path.display()
))
})?;
validate_registry_document(&document).map_err(|error| {
ForgeError::Parse(format!(
"state file {} is semantically invalid: {error}",
path.display()
))
})?;
Ok(document)
}
pub(crate) fn update_registry<T>(
expected_revision: Option<u64>,
action: impl FnOnce(&mut Vec<RegistryEntry>) -> Result<T, ForgeError>,
) -> Result<T, ForgeError> {
with_registry_lock(|current| {
require_revision(expected_revision, current.revision)?;
let mut document = current;
let result = action(&mut document.entries)?;
document.revision = document.revision.saturating_add(1);
document.updated_at = now_secs();
write_document(&document)?;
Ok(result)
})
}
pub(crate) fn append_registry(entry: RegistryEntry) -> Result<(), ForgeError> {
update_registry(None, |entries| {
let id = entry.stable_id();
if let Some(existing) = entries
.iter_mut()
.find(|existing| existing.stable_id() == id)
{
*existing = entry;
} else {
entries.push(entry);
}
Ok(())
})
}
fn with_registry_lock<T>(
action: impl FnOnce(RegistryDocument) -> Result<T, ForgeError>,
) -> Result<T, ForgeError> {
let directory = app_home().join("locks");
create_dir_all(&directory)?;
let path = directory.join("registry.lock");
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&path)
.map_err(|source| ForgeError::Io {
path: path.clone(),
source,
})?;
lock_exclusive_cancellable(&file, &path, "registry lock")?;
let current = read_registry_document()?;
let result = action(current);
let _ = FileExt::unlock(&file);
result
}
fn write_document(document: &RegistryDocument) -> Result<(), ForgeError> {
validate_registry_document(document).map_err(|error| {
ForgeError::Config(format!("refusing to write an invalid registry: {error}"))
})?;
let output = serde_json::to_vec_pretty(document)
.map_err(|error| ForgeError::Parse(format!("failed to serialize state file: {error}")))?;
let path = registry_path();
atomic_write_file(&path, &output)
}
fn require_revision(expected: Option<u64>, actual: u64) -> Result<(), ForgeError> {
if let Some(expected) = expected.filter(|expected| *expected != actual) {
return Err(ForgeError::Config(format!(
"registry changed from revision {expected} to {actual}; preview again before continuing"
)));
}
Ok(())
}
fn validate_registry_document(document: &RegistryDocument) -> Result<(), String> {
let mut entry_ids = BTreeSet::new();
for entry in &document.entries {
if entry.name.is_empty() || entry.source.is_empty() || entry.profile.is_empty() {
return Err("name, source, and profile cannot be empty".into());
}
if !entry_ids.insert(entry.stable_id()) {
return Err(format!(
"duplicate installation record: {}",
entry.stable_id()
));
}
let mut target_paths = BTreeSet::new();
let mut binaries = BTreeSet::new();
for target in &entry.targets {
if target.path.as_os_str().is_empty() || !target_paths.insert(target.path.clone()) {
return Err(format!(
"{} contains an empty path or duplicate target",
entry.name
));
}
if let Some(binary) = &target.binary
&& (!valid_storage_id(binary) || !binaries.insert(binary))
{
return Err(format!(
"{} contains an empty name or duplicate binary",
entry.name
));
}
}
validate_registry_entry(entry)?;
}
Ok(())
}
fn validate_registry_entry(entry: &RegistryEntry) -> Result<(), String> {
for artifact_id in entry
.artifact_id
.iter()
.chain(entry.previous_artifact_id.iter())
{
if !valid_storage_id(artifact_id) {
return Err(format!("{} contains an invalid artifact id", entry.name));
}
}
for hash in entry.config_hash.iter().chain(entry.plan_hash.iter()) {
if !valid_sha256(hash) {
return Err(format!(
"{} contains an invalid plan/config hash",
entry.name
));
}
}
let managed_metadata = entry.artifact_id.is_some()
&& entry.config_hash.is_some()
&& entry.plan_hash.is_some()
&& !entry.targets.is_empty()
&& entry.targets.iter().all(|target| target.binary.is_some());
let empty_managed_metadata = entry.artifact_id.is_none()
&& entry.previous_artifact_id.is_none()
&& entry.config_hash.is_none()
&& entry.plan_hash.is_none();
match (entry.kind, entry.backend) {
(InstallKind::Skill, None)
if empty_managed_metadata
&& !entry.targets.is_empty()
&& entry.targets.iter().all(|target| target.binary.is_none()) =>
{
Ok(())
}
(InstallKind::Tool, Some(backend @ (BackendKind::Cargo | BackendKind::Git)))
if managed_metadata
&& entry.source == format!("backend:{}", backend.as_str())
&& (backend != BackendKind::Git || entry.source_revision.is_some()) =>
{
Ok(())
}
(InstallKind::Tool, Some(BackendKind::Archive))
if empty_managed_metadata
&& entry.source == "backend:archive"
&& entry.targets.len() == 1
&& entry.targets[0].binary.is_none()
&& entry.source_revision.is_none() =>
{
Ok(())
}
(InstallKind::Tool, Some(backend))
if !matches!(
backend,
BackendKind::Cargo | BackendKind::Git | BackendKind::Archive
) && empty_managed_metadata
&& entry.source == format!("backend:{}", backend.as_str())
&& entry.targets.is_empty()
&& entry.source_revision.is_none() =>
{
Ok(())
}
_ => Err(format!(
"{} has an invalid kind, backend, target, and artifact metadata combination",
entry.name
)),
}
}
pub(crate) fn append_json_line(path: &Path, value: &impl Serialize) -> Result<(), ForgeError> {
if let Some(parent) = path.parent() {
create_dir_all(parent)?;
}
let mut line = serde_json::to_vec(value)
.map_err(|error| ForgeError::Parse(format!("failed to serialize journal: {error}")))?;
line.push(b'\n');
let _lease = acquire_path_lock(&sidecar_lock_path(path), "journal append lock")?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
})?;
let result = file.write_all(&line).map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
});
result.and_then(|()| {
file.sync_all().map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
})
})
}
fn sidecar_lock_path(path: &Path) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(".lock");
path.with_file_name(name)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::fsutil::read_to_string;
use crate::model::{BackendKind, InstallKind, RegistryEntry, RegistryTarget};
use crate::state::{
RegistryDocument, append_json_line, require_revision, sidecar_lock_path,
validate_registry_document, validate_registry_entry,
};
use crate::util::now_secs;
fn hash(byte: char) -> String {
std::iter::repeat_n(byte, 64).collect()
}
#[test]
fn registry_is_strict_and_has_no_development_version_field() {
let encoded = serde_json::to_string(&RegistryDocument::default()).unwrap();
assert!(!encoded.contains("schema_version"));
let stale = r#"{"schema_version":2,"revision":0,"updated_at":0,"entries":[]}"#;
assert!(serde_json::from_str::<RegistryDocument>(stale).is_err());
}
#[test]
fn registry_requires_explicit_nullable_fields() {
let entry = RegistryEntry {
name: "demo".into(),
kind: InstallKind::Tool,
source: "backend:cargo".into(),
profile: "standard".into(),
targets: vec![RegistryTarget {
path: PathBuf::from("/tmp/demo"),
binary: Some("demo".into()),
}],
installed_at: 0,
artifact_id: Some("artifact".into()),
previous_artifact_id: None,
config_hash: Some(hash('a')),
plan_hash: Some(hash('b')),
source_revision: None,
backend: Some(BackendKind::Cargo),
};
let mut document = serde_json::to_value(RegistryDocument {
revision: 1,
updated_at: 0,
entries: vec![entry],
})
.unwrap();
document["entries"][0]
.as_object_mut()
.unwrap()
.remove("artifact_id");
assert!(serde_json::from_value::<RegistryDocument>(document).is_err());
}
#[test]
fn registry_targets_require_explicit_binary_field() {
let stale = r#"{
"revision": 1,
"updated_at": 0,
"entries": [{
"name": "demo",
"kind": "skill",
"source": "local",
"profile": "standard",
"targets": [{"path": "/tmp/demo"}],
"installed_at": 0,
"artifact_id": null,
"previous_artifact_id": null,
"config_hash": null,
"plan_hash": null,
"source_revision": null,
"backend": null
}]
}"#;
assert!(serde_json::from_str::<RegistryDocument>(stale).is_err());
}
#[test]
fn generated_registry_schema_matches_runtime_enums_and_fields() {
let schema = serde_json::to_value(schemars::schema_for!(RegistryDocument)).unwrap();
let text = serde_json::to_string(&schema).unwrap();
assert!(text.contains("\"git\""));
assert!(!text.contains("\"crate\""));
assert!(!text.contains("\"verification\""));
assert!(!text.contains("RegistryArtifact"));
let entry = &schema["$defs"]["RegistryEntry"]["properties"];
assert!(entry.get("artifacts").is_none());
assert!(entry.get("binaries").is_none());
let target = &schema["$defs"]["RegistryTarget"]["properties"];
assert_eq!(target["path"]["type"], "string");
assert!(target.get("binary").is_some());
}
#[test]
fn registry_keeps_recovery_and_audit_fields_without_duplicate_artifact_metadata() {
let entry = RegistryEntry {
name: "demo".into(),
kind: InstallKind::Tool,
source: "backend:cargo".into(),
profile: "standard".into(),
targets: vec![RegistryTarget {
path: PathBuf::from("/managed/bin/demo"),
binary: Some("demo".into()),
}],
installed_at: 1,
artifact_id: Some("demo-current".into()),
previous_artifact_id: Some("demo-previous".into()),
config_hash: Some(hash('a')),
plan_hash: Some(hash('b')),
source_revision: Some("revision".into()),
backend: Some(BackendKind::Cargo),
};
let encoded = serde_json::to_value(entry).unwrap();
assert_eq!(encoded["targets"][0]["binary"], "demo");
assert!(encoded.get("binaries").is_none());
assert!(encoded.get("artifacts").is_none());
assert!(encoded.get("verification").is_none());
}
#[test]
fn registry_rejects_semantically_inconsistent_entries() {
let valid = RegistryEntry {
name: "demo".into(),
kind: InstallKind::Tool,
source: "backend:cargo".into(),
profile: "standard".into(),
targets: vec![RegistryTarget {
path: PathBuf::from("/managed/bin/demo"),
binary: Some("demo".into()),
}],
installed_at: 1,
artifact_id: Some("artifact".into()),
previous_artifact_id: None,
config_hash: Some(hash('a')),
plan_hash: Some(hash('b')),
source_revision: None,
backend: Some(BackendKind::Cargo),
};
assert!(
validate_registry_document(&RegistryDocument {
revision: 1,
updated_at: 1,
entries: vec![valid.clone()],
})
.is_ok()
);
let mut skill_with_backend = valid.clone();
skill_with_backend.kind = InstallKind::Skill;
assert!(validate_registry_entry(&skill_with_backend).is_err());
let mut cargo_without_binary = valid.clone();
cargo_without_binary.targets[0].binary = None;
assert!(validate_registry_entry(&cargo_without_binary).is_err());
let mut unsafe_artifact = valid.clone();
unsafe_artifact.artifact_id = Some("..".into());
assert!(validate_registry_entry(&unsafe_artifact).is_err());
let mut unsafe_binary = valid.clone();
unsafe_binary.targets[0].binary = Some("..".into());
assert!(
validate_registry_document(&RegistryDocument {
revision: 1,
updated_at: 1,
entries: vec![unsafe_binary],
})
.is_err()
);
let mut duplicate = valid;
duplicate.name = "demo".into();
assert!(
validate_registry_document(&RegistryDocument {
revision: 1,
updated_at: 1,
entries: vec![duplicate.clone(), duplicate],
})
.is_err()
);
}
#[test]
fn archive_registry_entry_has_one_removable_target() {
let entry = RegistryEntry {
name: "archive-demo".into(),
kind: InstallKind::Tool,
source: "backend:archive".into(),
profile: "standard".into(),
targets: vec![RegistryTarget {
path: PathBuf::from("/managed/archive-demo"),
binary: None,
}],
installed_at: 1,
artifact_id: None,
previous_artifact_id: None,
config_hash: None,
plan_hash: None,
source_revision: None,
backend: Some(BackendKind::Archive),
};
assert!(validate_registry_entry(&entry).is_ok());
}
#[test]
fn registry_identity_ignores_mutable_source_and_revision_guard_rejects_stale_plan() {
let mut entry = RegistryEntry {
name: "demo".into(),
kind: InstallKind::Tool,
source: "backend:cargo".into(),
profile: "standard".into(),
targets: Vec::new(),
installed_at: 1,
artifact_id: None,
previous_artifact_id: None,
config_hash: None,
plan_hash: None,
source_revision: None,
backend: Some(BackendKind::Apt),
};
let id = entry.stable_id();
entry.source = "backend:archive".into();
assert_eq!(entry.stable_id(), id);
assert!(require_revision(Some(4), 5).is_err());
assert!(require_revision(Some(5), 5).is_ok());
assert!(require_revision(None, 5).is_ok());
}
#[test]
fn concurrent_jsonl_appends_keep_complete_records() {
let path = std::env::temp_dir().join(format!(
"bot-forge-jsonl-{}-{}.jsonl",
std::process::id(),
now_secs()
));
std::thread::scope(|scope| {
for worker in 0..8 {
let path = &path;
scope.spawn(move || {
for sequence in 0..32 {
append_json_line(
path,
&serde_json::json!({"worker": worker, "sequence": sequence}),
)
.unwrap();
}
});
}
});
let content = read_to_string(&path).unwrap();
let records = content
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(records.len(), 8 * 32);
let lock = sidecar_lock_path(&path);
assert!(lock.is_file());
std::fs::remove_file(path).unwrap();
std::fs::remove_file(lock).unwrap();
}
}