Skip to main content

team_core/
validate.rs

1//! Invariant checks for a loaded [`Compose`] tree.
2//!
3//! Errors are collected rather than returned on first failure so the CLI can
4//! pretty-print the full list.
5
6use std::collections::BTreeSet;
7
8use thiserror::Error;
9
10use crate::compose::{ChannelMembers, Compose};
11
12#[derive(Debug, Error, PartialEq, Eq)]
13pub enum ValidationError {
14    #[error("project `{0}`: duplicate agent id `{1}` in managers and workers")]
15    DuplicateAgent(String, String),
16
17    #[error(
18        "project `{project}`: unknown agent `{agent}` referenced in channel `{channel}` members"
19    )]
20    ChannelUnknownMember {
21        project: String,
22        channel: String,
23        agent: String,
24    },
25
26    #[error("project `{project}`: agent `{agent}` `can_dm` lists unknown agent `{target}`")]
27    DmUnknownTarget {
28        project: String,
29        agent: String,
30        target: String,
31    },
32
33    #[error(
34        "project `{project}`: agent `{agent}` `can_broadcast` lists unknown channel `{channel}`"
35    )]
36    BroadcastUnknownChannel {
37        project: String,
38        agent: String,
39        channel: String,
40    },
41
42    #[error(
43        "project `{project}`: agent `{agent}` has an `interfaces.telegram` block but is not a manager"
44    )]
45    TelegramInboxOnWorker { project: String, agent: String },
46
47    #[error(
48        "worker `{project}:{agent}` declares `reports_to: {target}` but no such manager exists"
49    )]
50    UnknownManager {
51        project: String,
52        agent: String,
53        target: String,
54    },
55
56    #[error("broker type `{0}` not supported (known: sqlite)")]
57    UnknownBroker(String),
58
59    #[error("supervisor type `{0}` not supported (known: tmux, systemd, launchd)")]
60    UnknownSupervisor(String),
61
62    #[error("duplicate project id `{0}`")]
63    DuplicateProject(String),
64
65    #[error(
66        "project id `{0}` has disallowed characters; allowed: ASCII letters, digits, and `.` `_` `-` (no whitespace, shell metacharacters, or control chars; `:` is reserved as the project:agent separator)"
67    )]
68    InvalidProjectId(String),
69
70    #[error(
71        "project `{project}`: agent id `{agent}` has disallowed characters; allowed: ASCII letters, digits, and `.` `_` `-` (no whitespace, shell metacharacters, or control chars; `:` is reserved as the project:agent separator)"
72    )]
73    InvalidAgentId { project: String, agent: String },
74
75    #[error("project `{project}`: agent `{agent}` uses runtime `{runtime}`, which is not built in and not declared in `<root>/runtimes/{runtime}.yaml`")]
76    UnknownRuntime {
77        project: String,
78        agent: String,
79        runtime: String,
80    },
81
82    #[error("supervisor.drain_timeout_secs={0} is unreasonable; expected 0..=600")]
83    DrainTimeoutOutOfRange(u64),
84
85    #[error(
86        "compose schema `version: {got}` is not a valid semver string (expected e.g. `\"2.0.0\"`)"
87    )]
88    SchemaVersionInvalid { got: String },
89
90    #[error(
91        "project `{project}`: agent `{agent}` has a blank `role_prompt` (empty string or empty list)"
92    )]
93    BlankRolePrompt { project: String, agent: String },
94
95    #[error("project `{project}`: agent `{agent}` has a blank `display_name`")]
96    BlankDisplayName { project: String, agent: String },
97
98    #[error("project `{project}`: agent `{agent}` `display_name` is {got} chars (max {max})")]
99    DisplayNameTooLong {
100        project: String,
101        agent: String,
102        got: usize,
103        max: usize,
104    },
105
106    #[error(
107        "project `{project}`: agent `{agent}` declares an MCP server named `team`, which is reserved for the built-in mailbox server"
108    )]
109    ReservedMcpServerName { project: String, agent: String },
110}
111
112/// T-160: max length for `display_name`. 64 is a sensible upper bound
113/// matching ratatui column widths in the TUI roster pane; longer names
114/// would force unsightly truncation downstream. Counted in Unicode
115/// scalar values (`chars().count()`), not grapheme clusters — most
116/// operator-typed labels are simple text where the two coincide, and
117/// the saved dependency on `unicode-segmentation` is not worth the
118/// fidelity gain for an at-most-64-cell rendering window.
119pub const DISPLAY_NAME_MAX_CHARS: usize = 64;
120
121/// T-310: charset rule for `project.id` and agent ids — both flow,
122/// unquoted, into shell-bound strings (`{project}:{agent}` in
123/// `supervisor::build_up_command`) and into tmux session names. A
124/// conservative ASCII allowlist keeps every downstream consumer safe
125/// at the boundary instead of forcing each call site to quote
126/// defensively. `:` is intentionally NOT allowed — it's reserved as
127/// the canonical `<project>:<agent>` join separator everywhere in
128/// teamctl, and admitting it into an id would make `id()` parse
129/// ambiguous.
130///
131/// Rejected by construction: any whitespace, any shell metacharacter
132/// (`;`, `|`, `&`, `$`, backtick, `(`, `)`, `<`, `>`, `*`, `?`, `~`,
133/// `!`, `#`, `'`, `"`, `\`), any control char, any non-ASCII (incl.
134/// emoji), the empty string.
135pub fn is_valid_id(s: &str) -> bool {
136    !s.is_empty()
137        && s.chars()
138            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
139}
140
141pub fn validate(compose: &Compose) -> Vec<ValidationError> {
142    let mut errs = Vec::new();
143
144    // Known runtimes. Embedded defaults are always present, so the
145    // validator can always enforce that every referenced runtime resolves
146    // to a descriptor (built in or user-supplied override).
147    let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
148    let check_runtime = !runtimes.is_empty();
149
150    match compose.global.broker.r#type.as_str() {
151        "sqlite" => {}
152        other => errs.push(ValidationError::UnknownBroker(other.into())),
153    }
154    match compose.global.supervisor.r#type.as_str() {
155        "tmux" | "systemd" | "launchd" => {}
156        other => errs.push(ValidationError::UnknownSupervisor(other.into())),
157    }
158    if compose.global.supervisor.drain_timeout_secs > 600 {
159        errs.push(ValidationError::DrainTimeoutOutOfRange(
160            compose.global.supervisor.drain_timeout_secs,
161        ));
162    }
163
164    // T-265 PR-a: compose schema version must be a valid semver
165    // string. Deserialization already accepted only (a) a string
166    // (passed verbatim into `SchemaVersion::value`) or (b) the
167    // legacy integer `2` (coerced to `"2.0.0"`); this validate-time
168    // check rejects in-string garbage like `"abc"` or `"2"` (the
169    // bare `"2"` is NOT semver — it needs the `.0.0` suffix).
170    // Delegated to the `semver` crate to get prerelease /
171    // build-metadata edge cases right rather than hand-rolling.
172    if semver::Version::parse(&compose.global.version.value).is_err() {
173        errs.push(ValidationError::SchemaVersionInvalid {
174            got: compose.global.version.value.clone(),
175        });
176    }
177
178    let mut seen_projects = BTreeSet::new();
179    for p in &compose.projects {
180        if !seen_projects.insert(p.project.id.clone()) {
181            errs.push(ValidationError::DuplicateProject(p.project.id.clone()));
182        }
183        // T-310: id charset gate. Both `project.id` and the agent-id
184        // map keys flow unquoted into shell-bound strings + tmux
185        // session names downstream; rejecting unsafe chars at the
186        // boundary hardens every consumer at once.
187        if !is_valid_id(&p.project.id) {
188            errs.push(ValidationError::InvalidProjectId(p.project.id.clone()));
189        }
190        for id in p.managers.keys().chain(p.workers.keys()) {
191            if !is_valid_id(id) {
192                errs.push(ValidationError::InvalidAgentId {
193                    project: p.project.id.clone(),
194                    agent: id.clone(),
195                });
196            }
197        }
198
199        let mgr_ids: BTreeSet<&str> = p.managers.keys().map(|s| s.as_str()).collect();
200        let wrk_ids: BTreeSet<&str> = p.workers.keys().map(|s| s.as_str()).collect();
201        for dup in mgr_ids.intersection(&wrk_ids) {
202            errs.push(ValidationError::DuplicateAgent(
203                p.project.id.clone(),
204                (*dup).to_string(),
205            ));
206        }
207        let all_agents: BTreeSet<&str> = mgr_ids.union(&wrk_ids).copied().collect();
208
209        // Channel members reference known agents.
210        let channel_names: BTreeSet<&str> = p.channels.iter().map(|c| c.name.as_str()).collect();
211        for ch in &p.channels {
212            if let ChannelMembers::Explicit(members) = &ch.members {
213                for m in members {
214                    if !all_agents.contains(m.as_str()) {
215                        errs.push(ValidationError::ChannelUnknownMember {
216                            project: p.project.id.clone(),
217                            channel: ch.name.clone(),
218                            agent: m.clone(),
219                        });
220                    }
221                }
222            }
223        }
224
225        // Per-agent checks.
226        let check_agent = |errs: &mut Vec<ValidationError>,
227                           id: &str,
228                           a: &crate::compose::Agent,
229                           is_manager: bool| {
230            if a.telegram().is_some() && !is_manager {
231                errs.push(ValidationError::TelegramInboxOnWorker {
232                    project: p.project.id.clone(),
233                    agent: id.into(),
234                });
235            }
236            for t in &a.can_dm {
237                if !all_agents.contains(t.as_str()) {
238                    errs.push(ValidationError::DmUnknownTarget {
239                        project: p.project.id.clone(),
240                        agent: id.into(),
241                        target: t.clone(),
242                    });
243                }
244            }
245            for c in &a.can_broadcast {
246                if !channel_names.contains(c.as_str()) {
247                    errs.push(ValidationError::BroadcastUnknownChannel {
248                        project: p.project.id.clone(),
249                        agent: id.into(),
250                        channel: c.clone(),
251                    });
252                }
253            }
254            if let Some(t) = &a.reports_to {
255                if !mgr_ids.contains(t.as_str()) {
256                    errs.push(ValidationError::UnknownManager {
257                        project: p.project.id.clone(),
258                        agent: id.into(),
259                        target: t.clone(),
260                    });
261                }
262            }
263            if check_runtime && !runtimes.contains_key(a.runtime.as_str()) {
264                errs.push(ValidationError::UnknownRuntime {
265                    project: p.project.id.clone(),
266                    agent: id.into(),
267                    runtime: a.runtime.clone(),
268                });
269            }
270            if let Some(rp) = &a.role_prompt {
271                if rp.is_blank() {
272                    errs.push(ValidationError::BlankRolePrompt {
273                        project: p.project.id.clone(),
274                        agent: id.into(),
275                    });
276                }
277            }
278            if let Some(dn) = &a.display_name {
279                // T-160: trim before checking so `display_name: "   "`
280                // is rejected as blank rather than slipping through
281                // (whitespace-only labels render as a void cell in the
282                // TUI — same operator-confusion shape as empty).
283                let trimmed_len = dn.trim().chars().count();
284                if trimmed_len == 0 {
285                    errs.push(ValidationError::BlankDisplayName {
286                        project: p.project.id.clone(),
287                        agent: id.into(),
288                    });
289                } else if dn.chars().count() > DISPLAY_NAME_MAX_CHARS {
290                    errs.push(ValidationError::DisplayNameTooLong {
291                        project: p.project.id.clone(),
292                        agent: id.into(),
293                        got: dn.chars().count(),
294                        max: DISPLAY_NAME_MAX_CHARS,
295                    });
296                }
297            }
298            // #383 Phase 4: `team` is the built-in mailbox MCP server,
299            // injected on every agent. A declared server of the same name
300            // would shadow the bus, so reject it at validate (render also
301            // skips it defensively).
302            if a.mcps.contains_key("team") {
303                errs.push(ValidationError::ReservedMcpServerName {
304                    project: p.project.id.clone(),
305                    agent: id.into(),
306                });
307            }
308        };
309
310        for (id, a) in &p.managers {
311            check_agent(&mut errs, id, a, true);
312        }
313        for (id, a) in &p.workers {
314            check_agent(&mut errs, id, a, false);
315        }
316    }
317
318    errs
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::compose::*;
325    use std::collections::BTreeMap;
326    use std::path::PathBuf;
327
328    fn toy_compose(agent_dm_target: &str) -> Compose {
329        let mut managers = BTreeMap::new();
330        managers.insert(
331            "mgr".into(),
332            Agent {
333                runtime: "claude-code".into(),
334                model: Some("claude-opus-4-8".into()),
335                role_prompt: None,
336                permission_mode: None,
337                autonomy: "low_risk_only".into(),
338                can_dm: vec![agent_dm_target.into()],
339                can_broadcast: vec!["team".into()],
340                reports_to: None,
341                on_rate_limit: None,
342                effort: None,
343                ultracode: false,
344                interfaces: None,
345                display_name: None,
346                hooks: vec![],
347                mcps: Default::default(),
348                subagents: vec![],
349                skills: vec![],
350            },
351        );
352        let mut workers = BTreeMap::new();
353        workers.insert(
354            "dev".into(),
355            Agent {
356                runtime: "claude-code".into(),
357                model: None,
358                role_prompt: None,
359                permission_mode: None,
360                autonomy: "low_risk_only".into(),
361                can_dm: vec!["mgr".into()],
362                can_broadcast: vec!["team".into()],
363                reports_to: Some("mgr".into()),
364                on_rate_limit: None,
365                effort: None,
366                ultracode: false,
367                interfaces: None,
368                display_name: None,
369                hooks: vec![],
370                mcps: Default::default(),
371                subagents: vec![],
372                skills: vec![],
373            },
374        );
375        Compose {
376            root: PathBuf::from("."),
377            global: Global {
378                version: crate::compose::SchemaVersion::new("2.0.0"),
379                broker: Default::default(),
380                supervisor: Default::default(),
381                budget: Default::default(),
382                hitl: Default::default(),
383                rate_limits: Default::default(),
384                interfaces: vec![],
385                projects: vec![],
386                attachments: Default::default(),
387            },
388            projects: vec![Project {
389                version: 2,
390                project: ProjectMeta {
391                    id: "hello".into(),
392                    name: "Hello".into(),
393                    cwd: PathBuf::from("."),
394                },
395                channels: vec![Channel {
396                    name: "team".into(),
397                    members: ChannelMembers::All("*".into()),
398                }],
399                managers,
400                workers,
401                interfaces: None,
402            }],
403        }
404    }
405
406    #[test]
407    fn clean_compose_validates() {
408        let c = toy_compose("dev");
409        assert_eq!(validate(&c), vec![]);
410    }
411
412    #[test]
413    fn dm_to_unknown_agent_flags() {
414        let c = toy_compose("ghost");
415        let e = validate(&c);
416        assert!(matches!(
417            e.as_slice(),
418            [ValidationError::DmUnknownTarget { .. }]
419        ));
420    }
421
422    #[test]
423    fn unknown_broker_flags() {
424        let mut c = toy_compose("dev");
425        c.global.broker.r#type = "redis".into();
426        assert!(validate(&c)
427            .iter()
428            .any(|e| matches!(e, ValidationError::UnknownBroker(_))));
429    }
430
431    #[test]
432    fn drain_timeout_above_600s_flags() {
433        let mut c = toy_compose("dev");
434        c.global.supervisor.drain_timeout_secs = 86_400;
435        assert!(validate(&c)
436            .iter()
437            .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(86_400))));
438    }
439
440    #[test]
441    fn drain_timeout_zero_is_valid() {
442        let mut c = toy_compose("dev");
443        c.global.supervisor.drain_timeout_secs = 0;
444        assert!(!validate(&c)
445            .iter()
446            .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(_))));
447    }
448
449    #[test]
450    fn empty_role_prompt_list_flags() {
451        let mut c = toy_compose("dev");
452        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
453            Some(crate::compose::RolePrompt::Multiple(vec![]));
454        assert!(validate(&c)
455            .iter()
456            .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
457    }
458
459    #[test]
460    fn empty_role_prompt_string_flags() {
461        // Pre-existing hole in the old `Option<PathBuf>` schema: an
462        // empty string slipped through and rendered
463        // `SYSTEM_PROMPT_PATH=<root>/`. Closed alongside the list
464        // form's empty-list check.
465        let mut c = toy_compose("dev");
466        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
467            Some(crate::compose::RolePrompt::Single(PathBuf::from("")));
468        assert!(validate(&c)
469            .iter()
470            .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
471    }
472
473    #[test]
474    fn single_role_prompt_validates() {
475        let mut c = toy_compose("dev");
476        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
477            crate::compose::RolePrompt::Single(PathBuf::from("roles/mgr.md")),
478        );
479        assert!(!validate(&c)
480            .iter()
481            .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
482    }
483
484    #[test]
485    fn populated_role_prompt_list_validates() {
486        let mut c = toy_compose("dev");
487        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
488            crate::compose::RolePrompt::Multiple(vec![PathBuf::from("roles/mgr.md")]),
489        );
490        assert!(!validate(&c)
491            .iter()
492            .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
493    }
494
495    #[test]
496    fn blank_display_name_flags() {
497        let mut c = toy_compose("dev");
498        c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(String::new());
499        assert!(validate(&c)
500            .iter()
501            .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
502    }
503
504    #[test]
505    fn declared_mcp_server_named_team_flags() {
506        // #383 Phase 4: `team` is the reserved built-in mailbox server;
507        // declaring one of the same name must be a validation error.
508        let mut c = toy_compose("dev");
509        let mut mcps = std::collections::BTreeMap::new();
510        mcps.insert(
511            "team".into(),
512            crate::compose::McpServer {
513                command: "evil".into(),
514                args: vec![],
515                env: Default::default(),
516            },
517        );
518        c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
519        assert!(validate(&c)
520            .iter()
521            .any(|e| matches!(e, ValidationError::ReservedMcpServerName { .. })));
522    }
523
524    #[test]
525    fn declared_mcp_server_with_normal_name_validates() {
526        // A non-reserved server name must NOT trip the reserved check.
527        let mut c = toy_compose("dev");
528        let mut mcps = std::collections::BTreeMap::new();
529        mcps.insert(
530            "github".into(),
531            crate::compose::McpServer {
532                command: "npx".into(),
533                args: vec![],
534                env: Default::default(),
535            },
536        );
537        c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
538        assert!(!validate(&c)
539            .iter()
540            .any(|e| matches!(e, ValidationError::ReservedMcpServerName { .. })));
541    }
542
543    #[test]
544    fn display_name_at_max_length_validates() {
545        let mut c = toy_compose("dev");
546        let exactly_max = "x".repeat(DISPLAY_NAME_MAX_CHARS);
547        c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(exactly_max);
548        assert!(!validate(&c).iter().any(|e| matches!(
549            e,
550            ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
551        )));
552    }
553
554    #[test]
555    fn display_name_above_max_length_flags() {
556        let mut c = toy_compose("dev");
557        let too_long = "x".repeat(DISPLAY_NAME_MAX_CHARS + 1);
558        c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(too_long);
559        assert!(validate(&c)
560            .iter()
561            .any(|e| matches!(e, ValidationError::DisplayNameTooLong { .. })));
562    }
563
564    #[test]
565    fn display_name_counts_chars_not_bytes() {
566        // T-160: limit applies to Unicode-scalar-value (`chars()`)
567        // count, not bytes. Each `🦀` is one char but four UTF-8
568        // bytes; 64 crabs must still validate even though that's 256
569        // bytes on disk.
570        let mut c = toy_compose("dev");
571        let sixty_four_crabs = "🦀".repeat(DISPLAY_NAME_MAX_CHARS);
572        c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(sixty_four_crabs);
573        assert!(!validate(&c).iter().any(|e| matches!(
574            e,
575            ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
576        )));
577    }
578
579    #[test]
580    fn whitespace_only_display_name_flags_blank() {
581        // T-160 qa follow-up: whitespace-only labels render as a void
582        // cell in the TUI — reject under the same `BlankDisplayName`
583        // error as empty strings so the operator gets a clear message.
584        let mut c = toy_compose("dev");
585        c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some("   ".into());
586        assert!(validate(&c)
587            .iter()
588            .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
589    }
590
591    #[test]
592    fn populated_display_name_validates() {
593        let mut c = toy_compose("dev");
594        c.projects[0].managers.get_mut("mgr").unwrap().display_name =
595            Some("Sage (Visionary)".into());
596        assert!(!validate(&c).iter().any(|e| matches!(
597            e,
598            ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
599        )));
600    }
601
602    // ── T-310: id charset validation ────────────────────────────────────
603
604    #[test]
605    fn is_valid_id_accepts_existing_id_shapes() {
606        // Pin the conformant shapes currently in use across dogfood,
607        // cookbook, tests, and conventional fixtures — none of these
608        // may regress.
609        for ok in [
610            "teamctl",
611            "ops",
612            "nico",
613            "eng_lead",
614            "pr-22-review",
615            "blog-site",
616            "my.team",
617            "a1",
618            "x-2.0",
619            "a",
620            "0",
621            "A",
622            "_",
623            "-",
624            ".",
625        ] {
626            assert!(is_valid_id(ok), "must accept conformant id `{ok}`");
627        }
628    }
629
630    #[test]
631    fn is_valid_id_rejects_shell_metacharacter_class() {
632        // The qa PoC class on #310: any shell metacharacter in an id
633        // would flow unquoted into `build_up_command`. Each of these
634        // must be rejected at the boundary.
635        for bad in [
636            "evil; rm",
637            "proj$(id)",
638            "with space",
639            "back`ticks`",
640            "p|ipe",
641            "p&amp",
642            "p*g",
643            "p?g",
644            "p~e",
645            "p!g",
646            "p#g",
647            "p'q",
648            "p\"q",
649            "p\\g",
650            "p<g",
651            "p>g",
652            "p(g",
653            "p)g",
654            "p\tg",
655            "p\ng",
656        ] {
657            assert!(!is_valid_id(bad), "must reject `{bad:?}`");
658        }
659    }
660
661    #[test]
662    fn is_valid_id_rejects_colon_as_reserved_separator() {
663        // `:` is reserved for the canonical `<project>:<agent>` join;
664        // admitting it into an id would make `AgentHandle::id()`
665        // ambiguous *and* would still flow unquoted into shell-bound
666        // strings.
667        assert!(!is_valid_id("p:rj"));
668        assert!(!is_valid_id(":"));
669        assert!(!is_valid_id("a:"));
670        assert!(!is_valid_id(":a"));
671    }
672
673    #[test]
674    fn is_valid_id_rejects_empty_and_control_chars() {
675        assert!(!is_valid_id(""));
676        assert!(!is_valid_id("\0"));
677        assert!(!is_valid_id("p\x07q"));
678    }
679
680    #[test]
681    fn is_valid_id_rejects_non_ascii() {
682        // Emoji and Unicode letters are operationally bad even though
683        // some are shell-inert; stay strictly ASCII per the issue.
684        assert!(!is_valid_id("crab🦀"));
685        assert!(!is_valid_id("café"));
686    }
687
688    #[test]
689    fn clean_compose_passes_id_charset() {
690        // Regression for conformant teams (acceptance criterion 5):
691        // existing valid ids are unaffected.
692        let c = toy_compose("dev");
693        let errs = validate(&c);
694        assert!(
695            !errs.iter().any(|e| matches!(
696                e,
697                ValidationError::InvalidProjectId(_) | ValidationError::InvalidAgentId { .. }
698            )),
699            "clean compose unexpectedly flagged for id charset: {errs:?}",
700        );
701    }
702
703    #[test]
704    fn project_id_with_shell_metacharacters_flags() {
705        // qa PoC class regression (acceptance criterion 4): a
706        // `project.id` containing shell-metacharacter content is
707        // rejected by `validate` *before* it could reach
708        // `build_up_command`'s unquoted shell interpolation on
709        // `teamctl up` / `reload` / `down`.
710        let mut c = toy_compose("dev");
711        c.projects[0].project.id = "evil; rm -rf ~".into();
712        let errs = validate(&c);
713        assert!(
714            errs.iter().any(|e| matches!(
715                e,
716                ValidationError::InvalidProjectId(s) if s == "evil; rm -rf ~"
717            )),
718            "expected InvalidProjectId, got {errs:?}",
719        );
720    }
721
722    #[test]
723    fn manager_id_with_shell_metacharacters_flags() {
724        // Same PoC class via the manager-key path — `compose.agents()`
725        // yields these ids and the supervisor flows them unquoted into
726        // `{project}:{agent}`.
727        let mut c = toy_compose("dev");
728        let bad = "$(id)";
729        let mgr = c.projects[0].managers.remove("mgr").unwrap();
730        c.projects[0].managers.insert(bad.into(), mgr);
731        let errs = validate(&c);
732        assert!(
733            errs.iter().any(|e| matches!(
734                e,
735                ValidationError::InvalidAgentId { project, agent }
736                    if project == "hello" && agent == bad
737            )),
738            "expected InvalidAgentId for manager, got {errs:?}",
739        );
740    }
741
742    #[test]
743    fn worker_id_with_shell_metacharacters_flags() {
744        // Same PoC class via the worker-key path.
745        let mut c = toy_compose("dev");
746        let bad = "rogue|pipe";
747        let wkr = c.projects[0].workers.remove("dev").unwrap();
748        c.projects[0].workers.insert(bad.into(), wkr);
749        // The dm target `dev` is also stale now — filter for the
750        // charset error specifically.
751        let errs = validate(&c);
752        assert!(
753            errs.iter().any(|e| matches!(
754                e,
755                ValidationError::InvalidAgentId { project, agent }
756                    if project == "hello" && agent == bad
757            )),
758            "expected InvalidAgentId for worker, got {errs:?}",
759        );
760    }
761
762    #[test]
763    fn project_id_with_reserved_colon_flags() {
764        // `:` is the canonical project:agent join — admitting it would
765        // make routing parse ambiguous *and* still leak unquoted into
766        // shell strings.
767        let mut c = toy_compose("dev");
768        c.projects[0].project.id = "foo:bar".into();
769        let errs = validate(&c);
770        assert!(
771            errs.iter()
772                .any(|e| matches!(e, ValidationError::InvalidProjectId(s) if s == "foo:bar")),
773            "expected InvalidProjectId on colon, got {errs:?}",
774        );
775    }
776
777    // T-265 PR-a: schema version semver-shape check. Deserialize
778    // accepts any string verbatim (narrow concern); this validate
779    // step is what enforces the shape.
780
781    #[test]
782    fn valid_semver_string_validates() {
783        // Default fixture uses "2.0.0" — the canonical form.
784        let c = toy_compose("dev");
785        assert!(
786            !validate(&c)
787                .iter()
788                .any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
789            "canonical version `2.0.0` must validate"
790        );
791    }
792
793    #[test]
794    fn malformed_semver_string_flags() {
795        let mut c = toy_compose("dev");
796        c.global.version = crate::compose::SchemaVersion::new("abc");
797        let errs = validate(&c);
798        assert!(
799            errs.iter().any(|e| matches!(
800                e,
801                ValidationError::SchemaVersionInvalid { got } if got == "abc"
802            )),
803            "non-semver string must surface SchemaVersionInvalid; got {errs:?}"
804        );
805    }
806
807    #[test]
808    fn bare_two_string_flags_too() {
809        // `"2"` is NOT semver (needs `.0.0`). Bare-2-string would
810        // sneak past if our check were too loose; pin it explicitly.
811        let mut c = toy_compose("dev");
812        c.global.version = crate::compose::SchemaVersion::new("2");
813        assert!(
814            validate(&c).iter().any(|e| matches!(
815                e,
816                ValidationError::SchemaVersionInvalid { got } if got == "2"
817            )),
818            "bare-2-string must NOT pass the semver shape check"
819        );
820    }
821
822    #[test]
823    fn semver_with_prerelease_and_build_metadata_validates() {
824        // Real-world semver supports `-pre` + `+build` suffixes.
825        // Delegating to the `semver` crate gets these right;
826        // pin the contract so a future "let's hand-roll a regex"
827        // refactor surfaces here.
828        for ok in [
829            "1.0.0",
830            "2.3.4",
831            "2.0.0-alpha",
832            "1.0.0+build.5",
833            "2.0.0-rc.1+build.7",
834        ] {
835            let mut c = toy_compose("dev");
836            c.global.version = crate::compose::SchemaVersion::new(ok);
837            assert!(
838                !validate(&c)
839                    .iter()
840                    .any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
841                "semver `{ok}` must validate"
842            );
843        }
844    }
845}