Skip to main content

bot_forge/state/
mod.rs

1//! Persistent registry, cache, and transaction journal state.
2//!
3//! Registry updates are serialized under an exclusive lock and committed atomically. Public read
4//! APIs return an empty default document when no registry exists, while malformed or semantically
5//! invalid files are reported as parse errors.
6
7use 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)]
30/// Versioned persistent record of managed installations.
31pub struct RegistryDocument {
32    /// Monotonically increasing revision used for optimistic concurrency checks.
33    pub revision: u64,
34    /// Unix timestamp of the last committed update.
35    pub updated_at: u64,
36    /// Managed installation entries.
37    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
50/// Read managed installation entries, returning an empty list when no registry exists.
51///
52/// # Errors
53///
54/// Returns [`ForgeError`] when the registry cannot be read, parsed, or validated.
55pub fn read_registry() -> Result<Vec<RegistryEntry>, ForgeError> {
56    Ok(read_registry_document()?.entries)
57}
58
59/// Read and validate the complete persistent registry document.
60///
61/// # Errors
62///
63/// Returns [`ForgeError`] for I/O, JSON, or semantic validation failures.
64pub 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}