Skip to main content

agentmux_srv/backend/
bundle_validate.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Structural Armory Bundle Format (ABF) validator — ABF UI-alignment
5//! pass (docs/specs/SPEC_ABF_V0_2_PROVIDER_AWARE_COMPONENTS_AND_NATIVE_MEMORY_2026_08_10.md).
6//!
7//! Pure functions only — no I/O, no Store access, same convention as
8//! `bundle_export.rs` / `bundle_import.rs`. Checks a bundle's raw JSON
9//! columns for the classes of bug `bundle_export.rs`'s own review cycle
10//! found and fixed one at a time (unknown provider keys, unsafe/colliding
11//! paths, malformed JSON) — surfaced proactively here, before export time,
12//! rather than only as an export warning after the fact.
13//!
14//! This is advisory only: a bundle can be saved with validation errors
15//! present. The `bundle.validate` RPC is a read-only, on-demand check the
16//! Memory → Bundles editor's "Validate" button calls.
17
18use std::collections::{HashMap, HashSet};
19
20use super::bundle_export::{parse_json_field_or_warn, sanitize_context_relative_path, ContextFileEntry};
21use super::providers;
22use super::storage::store::Bundle;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
25#[ts(export, export_to = "../../../frontend/types/rpc/")]
26#[serde(rename_all = "lowercase")]
27pub enum IssueSeverity {
28    Error,
29    Warning,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
33#[ts(export, export_to = "../../../frontend/types/rpc/")]
34pub struct ValidationIssue {
35    pub severity: IssueSeverity,
36    /// Which bundle field/component this issue is about:
37    /// "instructions_by_provider" or "context_files".
38    pub field: String,
39    pub message: String,
40}
41
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ts_rs::TS)]
43#[ts(export, export_to = "../../../frontend/types/rpc/")]
44pub struct ValidationReport {
45    /// True when `issues` contains no `Error`-severity entries. Warnings
46    /// alone do not affect this — they're surfaced but non-blocking.
47    pub is_valid: bool,
48    pub issues: Vec<ValidationIssue>,
49}
50
51/// Run every structural check against a bundle. A bundle carries no MCP
52/// servers (`SPEC_BUNDLE_CONTENTS_MEMORY_NOT_MCP_2026_10_07.md` §3.1), so
53/// there are none to check.
54pub fn validate_bundle(bundle: &Bundle) -> ValidationReport {
55    let mut issues = Vec::new();
56    validate_instructions_by_provider(bundle, &mut issues);
57    validate_context_files(bundle, &mut issues);
58    // `skills` had exactly two checks, malformed JSON and a duplicate id, and
59    // the ref table makes both unrepresentable: `db_bundle_skills_ref` has a
60    // PRIMARY KEY (bundle_id, skill_id), and every id it yields came from a
61    // join against the catalog. There is nothing structural left to check.
62
63    let is_valid = !issues.iter().any(|i| i.severity == IssueSeverity::Error);
64    ValidationReport { is_valid, issues }
65}
66
67fn push_error(issues: &mut Vec<ValidationIssue>, field: &str, message: String) {
68    issues.push(ValidationIssue {
69        severity: IssueSeverity::Error,
70        field: field.to_string(),
71        message,
72    });
73}
74
75/// Every key must resolve to a known harness provider (`providers::get_provider`
76/// handles both canonical ids and aliases). Unlike `context_files`'
77/// arbitrary user-supplied paths, provider keys are always exact matches
78/// against `providers.rs`'s hardcoded registry/alias strings — none of
79/// which contain `/` or `.` — so a path-normalization collision between two
80/// *valid* keys structurally cannot happen here; no collision check needed.
81fn validate_instructions_by_provider(bundle: &Bundle, issues: &mut Vec<ValidationIssue>) {
82    const FIELD: &str = "instructions_by_provider";
83    if bundle.instructions_by_provider.trim().is_empty() {
84        return;
85    }
86    let parsed: HashMap<String, String> = match serde_json::from_str(&bundle.instructions_by_provider) {
87        Ok(v) => v,
88        Err(e) => {
89            push_error(issues, FIELD, format!("malformed JSON: {e}"));
90            return;
91        }
92    };
93    // Sorted for deterministic message ordering across runs.
94    let mut sorted: Vec<&String> = parsed.keys().collect();
95    sorted.sort();
96    for key in sorted {
97        if providers::get_provider(key).is_none() {
98            push_error(issues, FIELD, format!("\"{key}\" is not a known harness provider"));
99        }
100    }
101}
102
103/// Mirrors `bundle_export.rs::export_bundle`'s own context-file handling:
104/// each path must survive `sanitize_context_relative_path` (no absolute
105/// paths, no `..` traversal, no drive letters), and no two entries may
106/// normalize to the same output path case-insensitively (the most common
107/// export/extract targets have case-insensitive filesystems).
108fn validate_context_files(bundle: &Bundle, issues: &mut Vec<ValidationIssue>) {
109    const FIELD: &str = "context_files";
110    let mut warnings = Vec::new();
111    let entries: Vec<ContextFileEntry> =
112        parse_json_field_or_warn(&bundle.context_files, FIELD, &mut warnings);
113    for w in warnings {
114        push_error(issues, FIELD, w);
115    }
116
117    let mut used_paths: HashSet<String> = HashSet::new();
118    for entry in entries {
119        match sanitize_context_relative_path(&entry.path) {
120            None => push_error(
121                issues,
122                FIELD,
123                format!(
124                    "\"{}\" is not a safe relative path (absolute, contains \"..\", \
125                     a drive letter, or empty)",
126                    entry.path
127                ),
128            ),
129            Some(safe) => {
130                if !used_paths.insert(safe.to_lowercase()) {
131                    push_error(
132                        issues,
133                        FIELD,
134                        format!(
135                            "\"{}\" collides with another context file after path \
136                             normalization",
137                            entry.path
138                        ),
139                    );
140                }
141            }
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn make_bundle(
151        instructions_by_provider: &str,
152        context_files: &str,
153        mcp_servers: &str,
154        skills: &str,
155    ) -> Bundle {
156        Bundle {
157            id: "bundle-1".to_string(),
158            name: "Backend Dev Bundle".to_string(),
159            description: "Backend dev conventions".to_string(),
160            is_blank: false,
161            is_global: false,
162            provider: String::new(),
163            model: String::new(),
164            instructions: "Be terse.".to_string(),
165            instructions_by_provider: instructions_by_provider.to_string(),
166            context_files: context_files.to_string(),
167            mcp_servers: mcp_servers.to_string(),
168            skills: skills.to_string(),
169            sort_order: 0,
170            created_at: 1_700_000_000_000,
171            updated_at: 1_700_000_000_000,
172            is_system: false,
173        }
174    }
175
176    #[test]
177    fn empty_bundle_is_valid_with_no_issues() {
178        let bundle = make_bundle("{}", "[]", "[]", "[]");
179        let report = validate_bundle(&bundle);
180        assert!(report.is_valid);
181        assert!(report.issues.is_empty());
182    }
183
184    #[test]
185    fn known_provider_keys_pass() {
186        let bundle = make_bundle(r#"{"claude":"x","codex":"y"}"#, "[]", "[]", "[]");
187        let report = validate_bundle(&bundle);
188        assert!(report.is_valid, "{:?}", report.issues);
189    }
190
191    #[test]
192    fn unknown_provider_key_is_an_error() {
193        let bundle = make_bundle(r#"{"chatgpt-desktop":"x"}"#, "[]", "[]", "[]");
194        let report = validate_bundle(&bundle);
195        assert!(!report.is_valid);
196        assert!(report.issues.iter().any(|i| i.severity == IssueSeverity::Error
197            && i.field == "instructions_by_provider"
198            && i.message.contains("chatgpt-desktop")));
199    }
200
201    #[test]
202    fn malformed_instructions_by_provider_json_is_an_error() {
203        let bundle = make_bundle("not json", "[]", "[]", "[]");
204        let report = validate_bundle(&bundle);
205        assert!(!report.is_valid);
206        assert!(report.issues.iter().any(|i| i.field == "instructions_by_provider"
207            && i.message.contains("malformed JSON")));
208    }
209
210    #[test]
211    fn provider_alias_resolves_like_the_canonical_id() {
212        // "claude-code" is a registered alias for "claude" — must pass just
213        // like the canonical id does, since export/import both treat them
214        // as the same provider (providers::get_provider handles aliases).
215        let bundle = make_bundle(r#"{"claude-code":"x"}"#, "[]", "[]", "[]");
216        let report = validate_bundle(&bundle);
217        assert!(report.is_valid, "{:?}", report.issues);
218    }
219
220    #[test]
221    fn multiple_unknown_provider_keys_are_each_reported() {
222        let bundle = make_bundle(r#"{"foo":"a","bar":"b"}"#, "[]", "[]", "[]");
223        let report = validate_bundle(&bundle);
224        assert!(!report.is_valid);
225        let provider_errors: Vec<_> = report
226            .issues
227            .iter()
228            .filter(|i| i.field == "instructions_by_provider")
229            .collect();
230        assert_eq!(provider_errors.len(), 2, "{:?}", report.issues);
231    }
232
233    #[test]
234    fn unsafe_context_file_path_is_an_error() {
235        let bundle = make_bundle("{}", r#"[{"path":"../../etc/passwd","content":"x"}]"#, "[]", "[]");
236        let report = validate_bundle(&bundle);
237        assert!(!report.is_valid);
238        assert!(report.issues.iter().any(|i| i.field == "context_files"
239            && i.message.contains("not a safe relative path")));
240    }
241
242    #[test]
243    fn colliding_context_file_paths_are_an_error() {
244        let bundle = make_bundle(
245            "{}",
246            r#"[{"path":"docs/a.md","content":"one"},{"path":"Docs/A.md","content":"two"}]"#,
247            "[]",
248            "[]",
249        );
250        let report = validate_bundle(&bundle);
251        assert!(!report.is_valid);
252        assert!(report.issues.iter().any(|i| i.field == "context_files" && i.message.contains("collides")));
253    }
254
255    #[test]
256    fn malformed_context_files_json_is_an_error() {
257        let bundle = make_bundle("{}", "not json", "[]", "[]");
258        let report = validate_bundle(&bundle);
259        assert!(!report.is_valid);
260        assert!(report.issues.iter().any(|i| i.field == "context_files" && i.message.contains("malformed")));
261    }
262
263    #[test]
264    fn mcp_servers_are_not_validated_because_a_bundle_carries_none() {
265        // A leftover inline column is never read, so it never reports.
266        let bundle = make_bundle("{}", "[]", r#"["not-an-object"]"#, "[]");
267        let report = validate_bundle(&bundle);
268        assert!(report.is_valid, "{:?}", report.issues);
269        assert!(report.issues.is_empty());
270    }
271
272    #[test]
273    fn skills_are_no_longer_validated_because_both_checks_became_impossible() {
274        // This replaces `duplicate_skill_id_is_a_warning_not_an_error` and
275        // `malformed_skills_json_is_an_error`. Those checked the inline
276        // `skills` column, which nothing reads now that `db_bundle_skills_ref`
277        // is authoritative (Phase 0b). Neither failure it guarded can be
278        // represented any more: the ref table has a PRIMARY KEY
279        // (bundle_id, skill_id) so an id cannot appear twice, and ids come out
280        // of a join against the catalog rather than out of parsed JSON, so
281        // there is nothing to be malformed.
282        //
283        // Kept as a record of where those guarantees went, rather than a
284        // silent deletion of two tests.
285        let bundle = make_bundle("{}", "[]", "[]", r#"["skill-1","skill-1"]"#);
286        let report = validate_bundle(&bundle);
287        assert!(report.is_valid, "{:?}", report.issues);
288        assert!(
289            !report.issues.iter().any(|i| i.field == "skills"),
290            "the inline skills column must no longer produce issues: {:?}",
291            report.issues
292        );
293    }
294}