1use std::fmt::Write;
7
8pub fn shell_escape(s: &str) -> String {
16 let mut out = String::with_capacity(s.len() + 2);
17 out.push('\'');
18 for ch in s.chars() {
19 if ch == '\'' {
20 out.push_str("'\\''");
21 } else {
22 out.push(ch);
23 }
24 }
25 out.push('\'');
26 out
27}
28
29pub fn validate_bone_id(id: &str) -> Result<(), ValidationError> {
34 if id.is_empty() {
35 return Err(ValidationError::Empty("bone ID"));
36 }
37 let valid = id.len() <= 20
38 && id.contains('-')
39 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
40 if !valid {
41 return Err(ValidationError::InvalidFormat {
42 field: "bone ID",
43 value: id.to_string(),
44 expected: "<prefix>-[a-z0-9]+",
45 });
46 }
47 Ok(())
48}
49
50pub fn validate_workspace_name(name: &str) -> Result<(), ValidationError> {
52 if name.is_empty() {
53 return Err(ValidationError::Empty("workspace name"));
54 }
55 if name.len() > 64 {
56 return Err(ValidationError::TooLong {
57 field: "workspace name",
58 max: 64,
59 actual: name.len(),
60 });
61 }
62 let valid = name
63 .chars()
64 .next()
65 .map(|c| c.is_ascii_alphanumeric())
66 .unwrap_or(false)
67 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
68 if !valid {
69 return Err(ValidationError::InvalidFormat {
70 field: "workspace name",
71 value: name.to_string(),
72 expected: "[a-z0-9][a-z0-9-]*, max 64 chars",
73 });
74 }
75 Ok(())
76}
77
78pub fn validate_identifier(field: &'static str, value: &str) -> Result<(), ValidationError> {
81 if value.is_empty() {
82 return Err(ValidationError::Empty(field));
83 }
84 let has_unsafe = value.chars().any(|c| {
85 matches!(
86 c,
87 ' ' | '\t'
88 | '\n'
89 | '\r'
90 | '\''
91 | '"'
92 | '`'
93 | '$'
94 | '\\'
95 | '!'
96 | '&'
97 | '|'
98 | ';'
99 | '('
100 | ')'
101 | '{'
102 | '}'
103 | '<'
104 | '>'
105 | '*'
106 | '?'
107 | '['
108 | ']'
109 | '#'
110 | '~'
111 | '\0'
112 )
113 });
114 if has_unsafe {
115 return Err(ValidationError::UnsafeChars {
116 field,
117 value: value.to_string(),
118 });
119 }
120 Ok(())
121}
122
123#[derive(Debug, Clone)]
125pub enum ValidationError {
126 Empty(&'static str),
127 TooLong {
128 field: &'static str,
129 max: usize,
130 actual: usize,
131 },
132 InvalidFormat {
133 field: &'static str,
134 value: String,
135 expected: &'static str,
136 },
137 UnsafeChars {
138 field: &'static str,
139 value: String,
140 },
141}
142
143impl std::fmt::Display for ValidationError {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 match self {
146 ValidationError::Empty(field) => write!(f, "{field} cannot be empty"),
147 ValidationError::TooLong {
148 field, max, actual, ..
149 } => {
150 write!(f, "{field} too long ({actual} chars, max {max})")
151 }
152 ValidationError::InvalidFormat {
153 field,
154 value,
155 expected,
156 } => {
157 write!(f, "invalid {field} '{value}', expected {expected}")
158 }
159 ValidationError::UnsafeChars { field, value } => {
160 write!(f, "{field} '{value}' contains shell metacharacters")
161 }
162 }
163 }
164}
165
166impl std::error::Error for ValidationError {}
167
168pub fn validate_review_id(id: &str) -> Result<(), ValidationError> {
170 if id.is_empty() {
171 return Err(ValidationError::Empty("review ID"));
172 }
173 let valid =
174 id.starts_with("cr-") && id.len() > 3 && id[3..].chars().all(|c| c.is_ascii_alphanumeric());
175 if !valid {
176 return Err(ValidationError::InvalidFormat {
177 field: "review ID",
178 value: id.to_string(),
179 expected: "cr-[a-z0-9]+",
180 });
181 }
182 Ok(())
183}
184
185fn safe_ident(value: &str) -> std::borrow::Cow<'_, str> {
191 if !value.is_empty()
192 && value
193 .chars()
194 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':'))
195 {
196 std::borrow::Cow::Borrowed(value)
197 } else {
198 std::borrow::Cow::Owned(shell_escape(value))
199 }
200}
201
202pub fn claims_stake_cmd(agent: &str, uri: &str, memo: &str) -> String {
209 validate_identifier("agent", agent).expect("invalid agent name");
210 let agent_safe = safe_ident(agent);
211 let mut cmd = String::new();
212 write!(
213 cmd,
214 "bus claims stake --agent {} {}",
215 agent_safe,
216 shell_escape(uri)
217 )
218 .unwrap();
219 if !memo.is_empty() {
220 write!(cmd, " -m {}", shell_escape(memo)).unwrap();
221 }
222 cmd
223}
224
225#[allow(dead_code)]
227pub fn claims_release_cmd(agent: &str, uri: &str) -> String {
228 validate_identifier("agent", agent).expect("invalid agent name");
229 let agent_safe = safe_ident(agent);
230 format!(
231 "bus claims release --agent {} {}",
232 agent_safe,
233 shell_escape(uri)
234 )
235}
236
237pub fn claims_release_all_cmd(agent: &str) -> String {
239 validate_identifier("agent", agent).expect("invalid agent name");
240 let agent_safe = safe_ident(agent);
241 format!("bus claims release --agent {} --all", agent_safe)
242}
243
244pub fn bus_send_cmd(agent: &str, project: &str, message: &str, label: &str) -> String {
246 validate_identifier("agent", agent).expect("invalid agent name");
247 let agent_safe = safe_ident(agent);
248
249 if let Err(_) = validate_identifier("project", project) {
251 let mut cmd = String::new();
253 write!(
254 cmd,
255 "bus send --agent {} {} {}",
256 agent_safe,
257 shell_escape(project),
258 shell_escape(message)
259 )
260 .unwrap();
261 if !label.is_empty() {
262 write!(cmd, " -L {}", shell_escape(label)).unwrap();
263 }
264 return cmd;
265 }
266
267 let mut cmd = String::new();
268 write!(
269 cmd,
270 "bus send --agent {} {} {}",
271 agent_safe,
272 safe_ident(project),
273 shell_escape(message)
274 )
275 .unwrap();
276 if !label.is_empty() {
277 if validate_identifier("label", label).is_ok() {
279 write!(cmd, " -L {}", safe_ident(label)).unwrap();
280 } else {
281 write!(cmd, " -L {}", shell_escape(label)).unwrap();
282 }
283 }
284 cmd
285}
286
287#[allow(dead_code)]
289pub fn bn_do_cmd(bone_id: &str) -> String {
290 let bone_id_safe = if validate_bone_id(bone_id).is_ok() {
292 safe_ident(bone_id)
293 } else {
294 std::borrow::Cow::Owned(shell_escape(bone_id))
295 };
296
297 format!("maw exec default -- bn do {}", bone_id_safe)
298}
299
300#[allow(dead_code)]
302pub fn bn_comment_cmd(bone_id: &str, message: &str) -> String {
303 let bone_id_safe = if validate_bone_id(bone_id).is_ok() {
305 safe_ident(bone_id)
306 } else {
307 std::borrow::Cow::Owned(shell_escape(bone_id))
308 };
309
310 format!(
311 "maw exec default -- bn bone comment add {} {}",
312 bone_id_safe,
313 shell_escape(message)
314 )
315}
316
317pub fn bn_done_cmd(bone_id: &str, reason: &str) -> String {
319 let bone_id_safe = if validate_bone_id(bone_id).is_ok() {
321 safe_ident(bone_id)
322 } else {
323 std::borrow::Cow::Owned(shell_escape(bone_id))
324 };
325
326 let mut cmd = format!("maw exec default -- bn done {}", bone_id_safe);
327 if !reason.is_empty() {
328 write!(cmd, " --reason {}", shell_escape(reason)).unwrap();
329 }
330 cmd
331}
332
333pub fn ws_create_cmd() -> String {
335 "maw ws create --random".to_string()
336}
337
338pub fn ws_merge_cmd(workspace: &str, message: &str) -> String {
343 let workspace_safe = if validate_workspace_name(workspace).is_ok() {
345 safe_ident(workspace)
346 } else {
347 std::borrow::Cow::Owned(shell_escape(workspace))
348 };
349
350 format!(
351 "maw ws merge {} --destroy --message {}",
352 workspace_safe,
353 shell_escape(message)
354 )
355}
356
357pub fn crit_create_cmd(workspace: &str, agent: &str, title: &str, reviewers: &str) -> String {
359 validate_identifier("agent", agent).expect("invalid agent name");
360 let agent_safe = safe_ident(agent);
361
362 let workspace_safe = if validate_workspace_name(workspace).is_ok() {
364 safe_ident(workspace)
365 } else {
366 std::borrow::Cow::Owned(shell_escape(workspace))
367 };
368
369 let reviewers_safe = if validate_identifier("reviewers", reviewers).is_ok() {
370 safe_ident(reviewers)
371 } else {
372 std::borrow::Cow::Owned(shell_escape(reviewers))
373 };
374
375 format!(
376 "maw exec {} -- crit reviews create --agent {} --title {} --reviewers {}",
377 workspace_safe,
378 agent_safe,
379 shell_escape(title),
380 reviewers_safe
381 )
382}
383
384pub fn crit_request_cmd(workspace: &str, review_id: &str, reviewers: &str, agent: &str) -> String {
386 validate_identifier("agent", agent).expect("invalid agent name");
387 let agent_safe = safe_ident(agent);
388
389 let workspace_safe = if validate_workspace_name(workspace).is_ok() {
391 safe_ident(workspace)
392 } else {
393 std::borrow::Cow::Owned(shell_escape(workspace))
394 };
395
396 let review_id_safe = if validate_review_id(review_id).is_ok() {
397 safe_ident(review_id)
398 } else {
399 std::borrow::Cow::Owned(shell_escape(review_id))
400 };
401
402 let reviewers_safe = if validate_identifier("reviewers", reviewers).is_ok() {
403 safe_ident(reviewers)
404 } else {
405 std::borrow::Cow::Owned(shell_escape(reviewers))
406 };
407
408 format!(
409 "maw exec {} -- crit reviews request {} --reviewers {} --agent {}",
410 workspace_safe, review_id_safe, reviewers_safe, agent_safe
411 )
412}
413
414pub fn crit_show_cmd(workspace: &str, review_id: &str) -> String {
416 let workspace_safe = if validate_workspace_name(workspace).is_ok() {
418 safe_ident(workspace)
419 } else {
420 std::borrow::Cow::Owned(shell_escape(workspace))
421 };
422
423 let review_id_safe = if validate_review_id(review_id).is_ok() {
424 safe_ident(review_id)
425 } else {
426 std::borrow::Cow::Owned(shell_escape(review_id))
427 };
428
429 format!(
430 "maw exec {} -- crit review {}",
431 workspace_safe, review_id_safe
432 )
433}
434
435pub fn bus_statuses_clear_cmd(agent: &str) -> String {
437 validate_identifier("agent", agent).expect("invalid agent name");
438 let agent_safe = safe_ident(agent);
439 format!("bus statuses clear --agent {}", agent_safe)
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
449 fn escape_empty() {
450 assert_eq!(shell_escape(""), "''");
451 }
452
453 #[test]
454 fn escape_simple() {
455 assert_eq!(shell_escape("hello"), "'hello'");
456 }
457
458 #[test]
459 fn escape_with_spaces() {
460 assert_eq!(shell_escape("hello world"), "'hello world'");
461 }
462
463 #[test]
464 fn escape_single_quotes() {
465 assert_eq!(shell_escape("it's here"), "'it'\\''s here'");
466 }
467
468 #[test]
469 fn escape_double_quotes() {
470 assert_eq!(shell_escape(r#"say "hi""#), r#"'say "hi"'"#);
471 }
472
473 #[test]
474 fn escape_backslashes() {
475 assert_eq!(shell_escape(r"path\to\file"), r"'path\to\file'");
476 }
477
478 #[test]
479 fn escape_newlines() {
480 assert_eq!(shell_escape("line1\nline2"), "'line1\nline2'");
481 }
482
483 #[test]
484 fn escape_dollar_variables() {
485 assert_eq!(shell_escape("$HOME"), "'$HOME'");
486 }
487
488 #[test]
489 fn escape_backticks() {
490 assert_eq!(shell_escape("`whoami`"), "'`whoami`'");
491 }
492
493 #[test]
494 fn escape_unicode() {
495 assert_eq!(shell_escape("hello 🌍"), "'hello 🌍'");
496 }
497
498 #[test]
499 fn escape_multiple_single_quotes() {
500 assert_eq!(shell_escape("it's Bob's"), "'it'\\''s Bob'\\''s'");
501 }
502
503 #[test]
504 fn escape_all_metacharacters() {
505 assert_eq!(shell_escape("$(rm -rf /)"), "'$(rm -rf /)'");
506 }
507
508 #[test]
511 fn valid_bone_id() {
512 assert!(validate_bone_id("bd-3cqv").is_ok());
513 assert!(validate_bone_id("bd-abc123").is_ok());
514 assert!(validate_bone_id("bd-a").is_ok());
515 assert!(validate_bone_id("bn-m80").is_ok());
517 assert!(validate_bone_id("bm-xyz").is_ok());
518 assert!(validate_bone_id("xx-3cqv").is_ok());
519 }
520
521 #[test]
522 fn invalid_bone_id_empty() {
523 assert!(validate_bone_id("").is_err());
524 }
525
526 #[test]
527 fn invalid_bone_id_no_hyphen() {
528 assert!(validate_bone_id("3cqv").is_err());
529 assert!(validate_bone_id("abcdef").is_err());
530 }
531
532 #[test]
533 fn invalid_bone_id_special_chars() {
534 assert!(validate_bone_id("bd-abc def").is_err());
535 assert!(validate_bone_id("bd-abc;rm").is_err());
536 assert!(validate_bone_id("bd-abc/def").is_err());
537 }
538
539 #[test]
542 fn valid_review_id() {
543 assert!(validate_review_id("cr-2rnh").is_ok());
544 assert!(validate_review_id("cr-abc123").is_ok());
545 assert!(validate_review_id("cr-a").is_ok());
546 }
547
548 #[test]
549 fn invalid_review_id_empty() {
550 assert!(validate_review_id("").is_err());
551 }
552
553 #[test]
554 fn invalid_review_id_no_prefix() {
555 assert!(validate_review_id("2rnh").is_err());
556 assert!(validate_review_id("bd-3cqv").is_err());
557 }
558
559 #[test]
560 fn invalid_review_id_special_chars() {
561 assert!(validate_review_id("cr-abc-def").is_err());
562 assert!(validate_review_id("cr-").is_err());
563 }
564
565 #[test]
568 fn safe_ident_passes_clean_values() {
569 assert_eq!(safe_ident("bd-3cqv").as_ref(), "bd-3cqv");
570 assert_eq!(safe_ident("frost-castle").as_ref(), "frost-castle");
571 assert_eq!(safe_ident("in_progress").as_ref(), "in_progress");
572 assert_eq!(safe_ident("edict-dev").as_ref(), "edict-dev");
573 }
574
575 #[test]
576 fn safe_ident_escapes_unsafe_values() {
577 assert_eq!(safe_ident("bad name").as_ref(), "'bad name'");
579 assert_eq!(safe_ident("$(rm -rf)").as_ref(), "'$(rm -rf)'");
581 assert_eq!(safe_ident("").as_ref(), "''");
583 }
584
585 #[test]
588 fn valid_workspace_names() {
589 assert!(validate_workspace_name("default").is_ok());
590 assert!(validate_workspace_name("frost-castle").is_ok());
591 assert!(validate_workspace_name("a").is_ok());
592 assert!(validate_workspace_name("ws-123-test").is_ok());
593 }
594
595 #[test]
596 fn invalid_workspace_empty() {
597 assert!(validate_workspace_name("").is_err());
598 }
599
600 #[test]
601 fn invalid_workspace_starts_with_dash() {
602 assert!(validate_workspace_name("-foo").is_err());
603 }
604
605 #[test]
606 fn invalid_workspace_special_chars() {
607 assert!(validate_workspace_name("ws name").is_err());
608 assert!(validate_workspace_name("ws_name").is_err());
609 assert!(validate_workspace_name("ws.name").is_err());
610 }
611
612 #[test]
613 fn invalid_workspace_too_long() {
614 let long_name: String = "a".repeat(65);
615 assert!(validate_workspace_name(&long_name).is_err());
616 }
617
618 #[test]
619 fn workspace_exactly_64_chars() {
620 let name: String = "a".repeat(64);
621 assert!(validate_workspace_name(&name).is_ok());
622 }
623
624 #[test]
627 fn valid_identifiers() {
628 assert!(validate_identifier("agent", "edict-dev").is_ok());
629 assert!(validate_identifier("project", "myproject").is_ok());
630 assert!(validate_identifier("agent", "my-agent-123").is_ok());
631 }
632
633 #[test]
634 fn invalid_identifier_empty() {
635 assert!(validate_identifier("agent", "").is_err());
636 }
637
638 #[test]
639 fn invalid_identifier_shell_metacharacters() {
640 assert!(validate_identifier("agent", "foo bar").is_err());
641 assert!(validate_identifier("agent", "foo;rm").is_err());
642 assert!(validate_identifier("agent", "$(whoami)").is_err());
643 assert!(validate_identifier("agent", "foo`bar`").is_err());
644 assert!(validate_identifier("agent", "foo'bar").is_err());
645 assert!(validate_identifier("agent", "foo\"bar").is_err());
646 assert!(validate_identifier("agent", "a|b").is_err());
647 assert!(validate_identifier("agent", "a&b").is_err());
648 }
649
650 #[test]
653 fn claims_stake_basic() {
654 let cmd = claims_stake_cmd("crimson-storm", "bone://myproject/bd-abc", "bd-abc");
655 assert_eq!(
656 cmd,
657 "bus claims stake --agent crimson-storm 'bone://myproject/bd-abc' -m 'bd-abc'"
658 );
659 }
660
661 #[test]
662 fn claims_stake_no_memo() {
663 let cmd = claims_stake_cmd("crimson-storm", "bone://myproject/bd-abc", "");
664 assert_eq!(
665 cmd,
666 "bus claims stake --agent crimson-storm 'bone://myproject/bd-abc'"
667 );
668 }
669
670 #[test]
671 fn claims_release_basic() {
672 let cmd = claims_release_cmd("crimson-storm", "bone://myproject/bd-abc");
673 assert_eq!(
674 cmd,
675 "bus claims release --agent crimson-storm 'bone://myproject/bd-abc'"
676 );
677 }
678
679 #[test]
680 fn claims_release_all() {
681 let cmd = claims_release_all_cmd("crimson-storm");
682 assert_eq!(cmd, "bus claims release --agent crimson-storm --all");
683 }
684
685 #[test]
686 fn bus_send_basic() {
687 let cmd = bus_send_cmd(
688 "crimson-storm",
689 "myproject",
690 "Task claimed: bd-abc",
691 "task-claim",
692 );
693 assert_eq!(
694 cmd,
695 "bus send --agent crimson-storm myproject 'Task claimed: bd-abc' -L task-claim"
696 );
697 }
698
699 #[test]
700 fn bus_send_with_quotes_in_message() {
701 let cmd = bus_send_cmd("crimson-storm", "myproject", "it's done", "task-done");
702 assert_eq!(
703 cmd,
704 "bus send --agent crimson-storm myproject 'it'\\''s done' -L task-done"
705 );
706 }
707
708 #[test]
709 fn bus_send_no_label() {
710 let cmd = bus_send_cmd("crimson-storm", "myproject", "hello", "");
711 assert_eq!(cmd, "bus send --agent crimson-storm myproject 'hello'");
712 }
713
714 #[test]
715 fn bn_do_basic() {
716 let cmd = bn_do_cmd("bd-abc");
717 assert_eq!(cmd, "maw exec default -- bn do bd-abc");
718 }
719
720 #[test]
721 fn bn_comment_with_escaping() {
722 let cmd = bn_comment_cmd("bd-abc", "Started work in ws/frost-castle/");
723 assert_eq!(
724 cmd,
725 "maw exec default -- bn bone comment add bd-abc 'Started work in ws/frost-castle/'"
726 );
727 }
728
729 #[test]
730 fn bn_done_basic() {
731 let cmd = bn_done_cmd("bd-abc", "Completed");
732 assert_eq!(
733 cmd,
734 "maw exec default -- bn done bd-abc --reason 'Completed'"
735 );
736 }
737
738 #[test]
739 fn bn_done_no_reason() {
740 let cmd = bn_done_cmd("bd-abc", "");
741 assert_eq!(cmd, "maw exec default -- bn done bd-abc");
742 }
743
744 #[test]
745 fn ws_merge_with_message() {
746 let cmd = ws_merge_cmd("frost-castle", "feat: add login flow");
747 assert_eq!(
748 cmd,
749 "maw ws merge frost-castle --destroy --message 'feat: add login flow'"
750 );
751 }
752
753 #[test]
754 fn crit_create_with_escaping() {
755 let cmd = crit_create_cmd(
756 "frost-castle",
757 "crimson-storm",
758 "feat: add login",
759 "myproject-security",
760 );
761 assert_eq!(
762 cmd,
763 "maw exec frost-castle -- crit reviews create --agent crimson-storm --title 'feat: add login' --reviewers myproject-security"
764 );
765 }
766
767 #[test]
768 fn crit_request_basic() {
769 let cmd = crit_request_cmd(
770 "frost-castle",
771 "cr-123",
772 "myproject-security",
773 "crimson-storm",
774 );
775 assert_eq!(
776 cmd,
777 "maw exec frost-castle -- crit reviews request cr-123 --reviewers myproject-security --agent crimson-storm"
778 );
779 }
780
781 #[test]
782 fn crit_show_basic() {
783 let cmd = crit_show_cmd("frost-castle", "cr-123");
784 assert_eq!(cmd, "maw exec frost-castle -- crit review cr-123");
785 }
786
787 #[test]
790 fn command_builders_are_deterministic() {
791 let cmd1 = bus_send_cmd("crimson-storm", "proj", "msg", "label");
793 let cmd2 = bus_send_cmd("crimson-storm", "proj", "msg", "label");
794 assert_eq!(cmd1, cmd2);
795 }
796
797 #[test]
800 fn escape_prevents_command_injection() {
801 let malicious = "done'; rm -rf /; echo '";
803 let escaped = shell_escape(malicious);
804 assert!(escaped.starts_with('\''));
806 assert!(escaped.ends_with('\''));
807 assert!(escaped.contains("\\'"));
809 let cmd = bn_comment_cmd("bd-abc", malicious);
811 assert!(cmd.contains(&escaped));
812 }
815
816 #[test]
817 fn escape_prevents_variable_expansion() {
818 let msg = "Status: $HOME/.secret";
819 let escaped = shell_escape(msg);
820 assert_eq!(escaped, "'Status: $HOME/.secret'");
821 }
822}