1use std::collections::BTreeSet;
8use std::fs::OpenOptions;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12use fs2::FileExt;
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use crate::error::ForgeError;
17use crate::fsutil::{
18 acquire_path_lock, atomic_write_file, create_dir_all, lock_exclusive_cancellable,
19 read_to_string,
20};
21use crate::model::{BackendKind, InstallKind, RegistryEntry};
22use crate::paths::{app_home, registry_path};
23use crate::util::{now_secs, valid_sha256, valid_storage_id};
24
25pub(crate) mod cache;
26pub(crate) mod journal;
27
28#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
29#[serde(deny_unknown_fields)]
30pub struct RegistryDocument {
32 pub revision: u64,
34 pub updated_at: u64,
36 pub entries: Vec<RegistryEntry>,
38}
39
40impl Default for RegistryDocument {
41 fn default() -> Self {
42 Self {
43 revision: 0,
44 updated_at: now_secs(),
45 entries: Vec::new(),
46 }
47 }
48}
49
50pub fn read_registry() -> Result<Vec<RegistryEntry>, ForgeError> {
56 Ok(read_registry_document()?.entries)
57}
58
59pub fn read_registry_document() -> Result<RegistryDocument, ForgeError> {
65 let path = registry_path();
66 if !path.is_file() {
67 return Ok(RegistryDocument::default());
68 }
69 read_json_registry(&path)
70}
71
72fn read_json_registry(path: &Path) -> Result<RegistryDocument, ForgeError> {
73 let document: RegistryDocument =
74 serde_json::from_str(&read_to_string(path)?).map_err(|error| {
75 ForgeError::Parse(format!(
76 "state file {} is corrupted: {error}",
77 path.display()
78 ))
79 })?;
80 validate_registry_document(&document).map_err(|error| {
81 ForgeError::Parse(format!(
82 "state file {} is semantically invalid: {error}",
83 path.display()
84 ))
85 })?;
86 Ok(document)
87}
88
89pub(crate) fn update_registry<T>(
90 expected_revision: Option<u64>,
91 action: impl FnOnce(&mut Vec<RegistryEntry>) -> Result<T, ForgeError>,
92) -> Result<T, ForgeError> {
93 with_registry_lock(|current| {
94 require_revision(expected_revision, current.revision)?;
95 let mut document = current;
96 let result = action(&mut document.entries)?;
97 document.revision = document.revision.saturating_add(1);
98 document.updated_at = now_secs();
99 write_document(&document)?;
100 Ok(result)
101 })
102}
103
104pub(crate) fn append_registry(entry: RegistryEntry) -> Result<(), ForgeError> {
105 update_registry(None, |entries| {
106 let id = entry.stable_id();
107 if let Some(existing) = entries
108 .iter_mut()
109 .find(|existing| existing.stable_id() == id)
110 {
111 *existing = entry;
112 } else {
113 entries.push(entry);
114 }
115 Ok(())
116 })
117}
118
119fn with_registry_lock<T>(
120 action: impl FnOnce(RegistryDocument) -> Result<T, ForgeError>,
121) -> Result<T, ForgeError> {
122 let directory = app_home().join("locks");
123 create_dir_all(&directory)?;
124 let path = directory.join("registry.lock");
125 let file = OpenOptions::new()
126 .create(true)
127 .truncate(false)
128 .read(true)
129 .write(true)
130 .open(&path)
131 .map_err(|source| ForgeError::Io {
132 path: path.clone(),
133 source,
134 })?;
135 lock_exclusive_cancellable(&file, &path, "registry lock")?;
136 let current = read_registry_document()?;
137 let result = action(current);
138 let _ = FileExt::unlock(&file);
139 result
140}
141
142fn write_document(document: &RegistryDocument) -> Result<(), ForgeError> {
143 validate_registry_document(document).map_err(|error| {
144 ForgeError::Config(format!("refusing to write an invalid registry: {error}"))
145 })?;
146 let output = serde_json::to_vec_pretty(document)
147 .map_err(|error| ForgeError::Parse(format!("failed to serialize state file: {error}")))?;
148 let path = registry_path();
149 atomic_write_file(&path, &output)
150}
151
152fn require_revision(expected: Option<u64>, actual: u64) -> Result<(), ForgeError> {
153 if let Some(expected) = expected.filter(|expected| *expected != actual) {
154 return Err(ForgeError::Config(format!(
155 "registry changed from revision {expected} to {actual}; preview again before continuing"
156 )));
157 }
158 Ok(())
159}
160
161fn validate_registry_document(document: &RegistryDocument) -> Result<(), String> {
162 let mut entry_ids = BTreeSet::new();
163 for entry in &document.entries {
164 if entry.name.is_empty() || entry.source.is_empty() || entry.profile.is_empty() {
165 return Err("name, source, and profile cannot be empty".into());
166 }
167 if !entry_ids.insert(entry.stable_id()) {
168 return Err(format!(
169 "duplicate installation record: {}",
170 entry.stable_id()
171 ));
172 }
173 let mut target_paths = BTreeSet::new();
174 let mut binaries = BTreeSet::new();
175 for target in &entry.targets {
176 if target.path.as_os_str().is_empty() || !target_paths.insert(target.path.clone()) {
177 return Err(format!(
178 "{} contains an empty path or duplicate target",
179 entry.name
180 ));
181 }
182 if let Some(binary) = &target.binary
183 && (!valid_storage_id(binary) || !binaries.insert(binary))
184 {
185 return Err(format!(
186 "{} contains an empty name or duplicate binary",
187 entry.name
188 ));
189 }
190 }
191 validate_registry_entry(entry)?;
192 }
193 Ok(())
194}
195
196fn validate_registry_entry(entry: &RegistryEntry) -> Result<(), String> {
197 for artifact_id in entry
198 .artifact_id
199 .iter()
200 .chain(entry.previous_artifact_id.iter())
201 {
202 if !valid_storage_id(artifact_id) {
203 return Err(format!("{} contains an invalid artifact id", entry.name));
204 }
205 }
206 for hash in entry.config_hash.iter().chain(entry.plan_hash.iter()) {
207 if !valid_sha256(hash) {
208 return Err(format!(
209 "{} contains an invalid plan/config hash",
210 entry.name
211 ));
212 }
213 }
214 let managed_metadata = entry.artifact_id.is_some()
215 && entry.config_hash.is_some()
216 && entry.plan_hash.is_some()
217 && !entry.targets.is_empty()
218 && entry.targets.iter().all(|target| target.binary.is_some());
219 let empty_managed_metadata = entry.artifact_id.is_none()
220 && entry.previous_artifact_id.is_none()
221 && entry.config_hash.is_none()
222 && entry.plan_hash.is_none();
223
224 match (entry.kind, entry.backend) {
225 (InstallKind::Skill, None)
226 if empty_managed_metadata
227 && !entry.targets.is_empty()
228 && entry.targets.iter().all(|target| target.binary.is_none()) =>
229 {
230 Ok(())
231 }
232 (InstallKind::Tool, Some(backend @ (BackendKind::Cargo | BackendKind::Git)))
233 if managed_metadata
234 && entry.source == format!("backend:{}", backend.as_str())
235 && (backend != BackendKind::Git || entry.source_revision.is_some()) =>
236 {
237 Ok(())
238 }
239 (InstallKind::Tool, Some(BackendKind::Archive))
240 if empty_managed_metadata
241 && entry.source == "backend:archive"
242 && entry.targets.len() == 1
243 && entry.targets[0].binary.is_none()
244 && entry.source_revision.is_none() =>
245 {
246 Ok(())
247 }
248 (InstallKind::Tool, Some(backend))
249 if !matches!(
250 backend,
251 BackendKind::Cargo | BackendKind::Git | BackendKind::Archive
252 ) && empty_managed_metadata
253 && entry.source == format!("backend:{}", backend.as_str())
254 && entry.targets.is_empty()
255 && entry.source_revision.is_none() =>
256 {
257 Ok(())
258 }
259 _ => Err(format!(
260 "{} has an invalid kind, backend, target, and artifact metadata combination",
261 entry.name
262 )),
263 }
264}
265
266pub(crate) fn append_json_line(path: &Path, value: &impl Serialize) -> Result<(), ForgeError> {
267 if let Some(parent) = path.parent() {
268 create_dir_all(parent)?;
269 }
270 let mut line = serde_json::to_vec(value)
271 .map_err(|error| ForgeError::Parse(format!("failed to serialize journal: {error}")))?;
272 line.push(b'\n');
273 let _lease = acquire_path_lock(&sidecar_lock_path(path), "journal append lock")?;
274 let mut file = OpenOptions::new()
275 .create(true)
276 .append(true)
277 .open(path)
278 .map_err(|source| ForgeError::Io {
279 path: path.to_path_buf(),
280 source,
281 })?;
282 let result = file.write_all(&line).map_err(|source| ForgeError::Io {
283 path: path.to_path_buf(),
284 source,
285 });
286 result.and_then(|()| {
287 file.sync_all().map_err(|source| ForgeError::Io {
288 path: path.to_path_buf(),
289 source,
290 })
291 })
292}
293
294fn sidecar_lock_path(path: &Path) -> PathBuf {
295 let mut name = path.file_name().unwrap_or_default().to_os_string();
296 name.push(".lock");
297 path.with_file_name(name)
298}
299
300#[cfg(test)]
301mod tests {
302 use std::path::PathBuf;
303
304 use crate::fsutil::read_to_string;
305 use crate::model::{BackendKind, InstallKind, RegistryEntry, RegistryTarget};
306 use crate::state::{
307 RegistryDocument, append_json_line, require_revision, sidecar_lock_path,
308 validate_registry_document, validate_registry_entry,
309 };
310 use crate::util::now_secs;
311
312 fn hash(byte: char) -> String {
313 std::iter::repeat_n(byte, 64).collect()
314 }
315
316 #[test]
317 fn registry_is_strict_and_has_no_development_version_field() {
318 let encoded = serde_json::to_string(&RegistryDocument::default()).unwrap();
319 assert!(!encoded.contains("schema_version"));
320 let stale = r#"{"schema_version":2,"revision":0,"updated_at":0,"entries":[]}"#;
321 assert!(serde_json::from_str::<RegistryDocument>(stale).is_err());
322 }
323
324 #[test]
325 fn registry_requires_explicit_nullable_fields() {
326 let entry = RegistryEntry {
327 name: "demo".into(),
328 kind: InstallKind::Tool,
329 source: "backend:cargo".into(),
330 profile: "standard".into(),
331 targets: vec![RegistryTarget {
332 path: PathBuf::from("/tmp/demo"),
333 binary: Some("demo".into()),
334 }],
335 installed_at: 0,
336 artifact_id: Some("artifact".into()),
337 previous_artifact_id: None,
338 config_hash: Some(hash('a')),
339 plan_hash: Some(hash('b')),
340 source_revision: None,
341 backend: Some(BackendKind::Cargo),
342 };
343 let mut document = serde_json::to_value(RegistryDocument {
344 revision: 1,
345 updated_at: 0,
346 entries: vec![entry],
347 })
348 .unwrap();
349
350 document["entries"][0]
351 .as_object_mut()
352 .unwrap()
353 .remove("artifact_id");
354 assert!(serde_json::from_value::<RegistryDocument>(document).is_err());
355 }
356
357 #[test]
358 fn registry_targets_require_explicit_binary_field() {
359 let stale = r#"{
360 "revision": 1,
361 "updated_at": 0,
362 "entries": [{
363 "name": "demo",
364 "kind": "skill",
365 "source": "local",
366 "profile": "standard",
367 "targets": [{"path": "/tmp/demo"}],
368 "installed_at": 0,
369 "artifact_id": null,
370 "previous_artifact_id": null,
371 "config_hash": null,
372 "plan_hash": null,
373 "source_revision": null,
374 "backend": null
375 }]
376 }"#;
377 assert!(serde_json::from_str::<RegistryDocument>(stale).is_err());
378 }
379
380 #[test]
381 fn generated_registry_schema_matches_runtime_enums_and_fields() {
382 let schema = serde_json::to_value(schemars::schema_for!(RegistryDocument)).unwrap();
383 let text = serde_json::to_string(&schema).unwrap();
384 assert!(text.contains("\"git\""));
385 assert!(!text.contains("\"crate\""));
386 assert!(!text.contains("\"verification\""));
387 assert!(!text.contains("RegistryArtifact"));
388 let entry = &schema["$defs"]["RegistryEntry"]["properties"];
389 assert!(entry.get("artifacts").is_none());
390 assert!(entry.get("binaries").is_none());
391 let target = &schema["$defs"]["RegistryTarget"]["properties"];
392 assert_eq!(target["path"]["type"], "string");
393 assert!(target.get("binary").is_some());
394 }
395
396 #[test]
397 fn registry_keeps_recovery_and_audit_fields_without_duplicate_artifact_metadata() {
398 let entry = RegistryEntry {
399 name: "demo".into(),
400 kind: InstallKind::Tool,
401 source: "backend:cargo".into(),
402 profile: "standard".into(),
403 targets: vec![RegistryTarget {
404 path: PathBuf::from("/managed/bin/demo"),
405 binary: Some("demo".into()),
406 }],
407 installed_at: 1,
408 artifact_id: Some("demo-current".into()),
409 previous_artifact_id: Some("demo-previous".into()),
410 config_hash: Some(hash('a')),
411 plan_hash: Some(hash('b')),
412 source_revision: Some("revision".into()),
413 backend: Some(BackendKind::Cargo),
414 };
415 let encoded = serde_json::to_value(entry).unwrap();
416 assert_eq!(encoded["targets"][0]["binary"], "demo");
417 assert!(encoded.get("binaries").is_none());
418 assert!(encoded.get("artifacts").is_none());
419 assert!(encoded.get("verification").is_none());
420 }
421
422 #[test]
423 fn registry_rejects_semantically_inconsistent_entries() {
424 let valid = RegistryEntry {
425 name: "demo".into(),
426 kind: InstallKind::Tool,
427 source: "backend:cargo".into(),
428 profile: "standard".into(),
429 targets: vec![RegistryTarget {
430 path: PathBuf::from("/managed/bin/demo"),
431 binary: Some("demo".into()),
432 }],
433 installed_at: 1,
434 artifact_id: Some("artifact".into()),
435 previous_artifact_id: None,
436 config_hash: Some(hash('a')),
437 plan_hash: Some(hash('b')),
438 source_revision: None,
439 backend: Some(BackendKind::Cargo),
440 };
441 assert!(
442 validate_registry_document(&RegistryDocument {
443 revision: 1,
444 updated_at: 1,
445 entries: vec![valid.clone()],
446 })
447 .is_ok()
448 );
449
450 let mut skill_with_backend = valid.clone();
451 skill_with_backend.kind = InstallKind::Skill;
452 assert!(validate_registry_entry(&skill_with_backend).is_err());
453
454 let mut cargo_without_binary = valid.clone();
455 cargo_without_binary.targets[0].binary = None;
456 assert!(validate_registry_entry(&cargo_without_binary).is_err());
457
458 let mut unsafe_artifact = valid.clone();
459 unsafe_artifact.artifact_id = Some("..".into());
460 assert!(validate_registry_entry(&unsafe_artifact).is_err());
461
462 let mut unsafe_binary = valid.clone();
463 unsafe_binary.targets[0].binary = Some("..".into());
464 assert!(
465 validate_registry_document(&RegistryDocument {
466 revision: 1,
467 updated_at: 1,
468 entries: vec![unsafe_binary],
469 })
470 .is_err()
471 );
472
473 let mut duplicate = valid;
474 duplicate.name = "demo".into();
475 assert!(
476 validate_registry_document(&RegistryDocument {
477 revision: 1,
478 updated_at: 1,
479 entries: vec![duplicate.clone(), duplicate],
480 })
481 .is_err()
482 );
483 }
484
485 #[test]
486 fn archive_registry_entry_has_one_removable_target() {
487 let entry = RegistryEntry {
488 name: "archive-demo".into(),
489 kind: InstallKind::Tool,
490 source: "backend:archive".into(),
491 profile: "standard".into(),
492 targets: vec![RegistryTarget {
493 path: PathBuf::from("/managed/archive-demo"),
494 binary: None,
495 }],
496 installed_at: 1,
497 artifact_id: None,
498 previous_artifact_id: None,
499 config_hash: None,
500 plan_hash: None,
501 source_revision: None,
502 backend: Some(BackendKind::Archive),
503 };
504 assert!(validate_registry_entry(&entry).is_ok());
505 }
506
507 #[test]
508 fn registry_identity_ignores_mutable_source_and_revision_guard_rejects_stale_plan() {
509 let mut entry = RegistryEntry {
510 name: "demo".into(),
511 kind: InstallKind::Tool,
512 source: "backend:cargo".into(),
513 profile: "standard".into(),
514 targets: Vec::new(),
515 installed_at: 1,
516 artifact_id: None,
517 previous_artifact_id: None,
518 config_hash: None,
519 plan_hash: None,
520 source_revision: None,
521 backend: Some(BackendKind::Apt),
522 };
523 let id = entry.stable_id();
524 entry.source = "backend:archive".into();
525 assert_eq!(entry.stable_id(), id);
526 assert!(require_revision(Some(4), 5).is_err());
527 assert!(require_revision(Some(5), 5).is_ok());
528 assert!(require_revision(None, 5).is_ok());
529 }
530
531 #[test]
532 fn concurrent_jsonl_appends_keep_complete_records() {
533 let path = std::env::temp_dir().join(format!(
534 "bot-forge-jsonl-{}-{}.jsonl",
535 std::process::id(),
536 now_secs()
537 ));
538 std::thread::scope(|scope| {
539 for worker in 0..8 {
540 let path = &path;
541 scope.spawn(move || {
542 for sequence in 0..32 {
543 append_json_line(
544 path,
545 &serde_json::json!({"worker": worker, "sequence": sequence}),
546 )
547 .unwrap();
548 }
549 });
550 }
551 });
552
553 let content = read_to_string(&path).unwrap();
554 let records = content
555 .lines()
556 .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
557 .collect::<Vec<_>>();
558 assert_eq!(records.len(), 8 * 32);
559 let lock = sidecar_lock_path(&path);
560 assert!(lock.is_file());
561 std::fs::remove_file(path).unwrap();
562 std::fs::remove_file(lock).unwrap();
563 }
564}