1use choir_hash::ContentHash;
32
33pub const PROTOCOL_VERSION: u64 = 1;
35
36const MAX_IDENTIFIER: usize = 512;
43
44const MAX_WORKSPACE_KEY: usize = 160;
47
48const KEY_PREFIX: usize = 80;
50
51const FINGERPRINT_IN_NAME: usize = 16;
59
60#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Failure {
69 pub code: String,
71 pub retryable: bool,
73 pub message: String,
75}
76
77impl Failure {
78 #[must_use]
80 pub fn terminal(code: &str, message: impl Into<String>) -> Self {
81 Self {
82 code: code.to_string(),
83 retryable: false,
84 message: message.into(),
85 }
86 }
87
88 #[must_use]
90 pub fn transient(code: &str, message: impl Into<String>) -> Self {
91 Self {
92 code: code.to_string(),
93 retryable: true,
94 message: message.into(),
95 }
96 }
97
98 #[must_use]
100 pub fn to_json(&self) -> serde_json::Value {
101 serde_json::json!({
102 "protocol_version": PROTOCOL_VERSION,
103 "error": {
104 "code": self.code,
105 "retryable": self.retryable,
106 "message": self.message,
107 }
108 })
109 }
110}
111
112#[must_use]
126pub fn is_retryable(code: &str) -> bool {
127 !matches!(
128 code,
129 "workspace_state"
130 | "change_state"
131 | "stale_head"
132 | "malformed_request"
133 | "malformed_op"
134 | "unknown_key"
135 | "channel_not_owned"
136 | "identity_state"
137 | "bad_signature"
138 | "node_only"
139 | "duplicate_submission"
140 | "foreign_scope"
141 )
142}
143
144#[must_use]
149pub fn safe_segment(segment: &str) -> bool {
150 !segment.is_empty()
151 && segment
152 .chars()
153 .next()
154 .is_some_and(|c| c.is_ascii_alphanumeric())
155 && segment
156 .chars()
157 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
158}
159
160pub fn split_repo(repo: &str) -> Result<(&str, &str), Failure> {
166 let mut segments = repo.split('/');
167 let (Some(owner), Some(name), None) = (segments.next(), segments.next(), segments.next())
168 else {
169 return Err(Failure::terminal(
170 "invalid_config",
171 "repo must be owner/repo",
172 ));
173 };
174 if !safe_segment(owner) || !safe_segment(name) {
175 return Err(Failure::terminal(
176 "invalid_config",
177 "repo contains an unsafe path segment",
178 ));
179 }
180 Ok((owner, name))
181}
182
183const SCHEME_TAG: &str = "choir-runner-1";
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum Scheme {
199 FromName,
209 FromExternal,
218}
219
220impl Scheme {
221 #[must_use]
223 pub fn as_str(self) -> &'static str {
224 match self {
225 Self::FromName => "from-name",
226 Self::FromExternal => "from-external",
227 }
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct Identity {
239 pub workspace_name: String,
241 pub workspace_id: String,
243 pub change_id: String,
245 pub idempotency_key: String,
247 pub fingerprint: String,
250 pub scheme: Scheme,
254}
255
256fn checked_namespace(namespace: &str) -> Result<(), Failure> {
258 if safe_segment(namespace) {
259 Ok(())
260 } else {
261 Err(Failure::terminal(
262 "invalid_request",
263 "namespace must be a safe path segment",
264 ))
265 }
266}
267
268impl Identity {
269 pub fn from_name(namespace: &str, repo: &str, workspace_name: &str) -> Result<Self, Failure> {
283 split_repo(repo)?;
284 checked_namespace(namespace)?;
285 if !safe_segment(workspace_name) || workspace_name.len() > MAX_WORKSPACE_KEY {
286 return Err(Failure::terminal(
287 "invalid_request",
288 "workspace name is unsafe or too long",
289 ));
290 }
291 Ok(Self {
292 workspace_id: format!("{repo}/{workspace_name}"),
293 change_id: format!("{namespace}:{repo}:{workspace_name}"),
294 idempotency_key: format!("{namespace}-create:{repo}:{workspace_name}"),
295 workspace_name: workspace_name.to_string(),
296 fingerprint: String::new(),
297 scheme: Scheme::FromName,
298 })
299 }
300
301 pub fn from_external(
325 namespace: &str,
326 repo: &str,
327 workspace_key: &str,
328 external_id: &str,
329 generation: &str,
330 ) -> Result<Self, Failure> {
331 split_repo(repo)?;
332 checked_namespace(namespace)?;
333 if !safe_segment(workspace_key) || workspace_key.len() > MAX_WORKSPACE_KEY {
334 return Err(Failure::terminal(
335 "invalid_request",
336 "workspace_key is unsafe or too long",
337 ));
338 }
339 for (field, value) in [("external_id", external_id), ("generation", generation)] {
340 if value.is_empty() || value.len() > MAX_IDENTIFIER {
341 return Err(Failure::terminal(
342 "invalid_request",
343 format!("{field} must be non-empty and at most {MAX_IDENTIFIER} bytes"),
344 ));
345 }
346 }
347
348 let mut material = Vec::new();
351 for field in [SCHEME_TAG, repo, external_id, generation] {
352 material.extend_from_slice(field.len().to_string().as_bytes());
353 material.push(b':');
354 material.extend_from_slice(field.as_bytes());
355 material.push(b'\n');
356 }
357 let hashed = ContentHash::blake3(&material).to_hex();
358 let digest = hashed
360 .split_once('-')
361 .map_or(hashed.as_str(), |(_, rest)| rest);
362
363 let prefix: String = workspace_key.chars().take(KEY_PREFIX).collect();
364 let short: String = digest.chars().take(FINGERPRINT_IN_NAME).collect();
365 let workspace_name = format!("{namespace}-{prefix}-{short}");
366
367 Ok(Self {
368 workspace_id: format!("{repo}/{workspace_name}"),
369 workspace_name,
370 change_id: format!("{namespace}:{repo}:{digest}"),
371 idempotency_key: format!("{namespace}-create:{repo}:{digest}"),
372 fingerprint: digest.to_string(),
373 scheme: Scheme::FromExternal,
374 })
375 }
376}
377
378pub fn base_from_view(view: &serde_json::Value, base_ref: &str) -> Result<String, Failure> {
390 let Some(revision) = view
391 .get("refs")
392 .and_then(|refs| refs.get(base_ref))
393 .and_then(serde_json::Value::as_str)
394 else {
395 return Err(Failure::terminal(
396 "base_ref_missing",
397 format!("{base_ref} is absent from the Choir view"),
398 ));
399 };
400 let Some(oid) = revision
401 .strip_prefix("11-")
402 .or_else(|| revision.strip_prefix("12-"))
403 else {
404 return Err(Failure::terminal(
405 "base_ref_invalid",
406 format!("{base_ref} names a non-Git revision"),
407 ));
408 };
409 let git_width = oid.len() == 40 || oid.len() == 64;
410 if !git_width || !oid.chars().all(|c| c.is_ascii_hexdigit()) {
411 return Err(Failure::terminal(
412 "base_ref_invalid",
413 format!("{base_ref} names an invalid Git object id"),
414 ));
415 }
416 Ok(oid.to_string())
417}
418
419#[must_use]
429pub fn bound_base(response: &serde_json::Value, requested: &str) -> String {
430 let reported = response
431 .get("base_revision")
432 .and_then(serde_json::Value::as_str)
433 .and_then(|value| {
434 value
435 .strip_prefix("11-")
436 .or_else(|| value.strip_prefix("12-"))
437 })
438 .filter(|oid| oid.len() == 40 || oid.len() == 64)
439 .filter(|oid| oid.chars().all(|c| c.is_ascii_hexdigit()));
440 reported.map_or_else(|| requested.to_string(), str::to_string)
441}
442
443pub fn verify_binding(response: &serde_json::Value, expected: &Identity) -> Result<(), Failure> {
456 let field = |key: &str| -> Result<String, Failure> {
457 response
458 .get(key)
459 .and_then(serde_json::Value::as_str)
460 .filter(|value| !value.is_empty())
461 .map(str::to_string)
462 .ok_or_else(|| {
463 Failure::transient(
464 "invalid_response",
465 format!("Choir returned no {key} to check the binding against"),
466 )
467 })
468 };
469 let (workspace, change) = (field("workspace")?, field("change_id")?);
470 if workspace != expected.workspace_id || change != expected.change_id {
471 return Err(Failure::terminal(
472 "binding_mismatch",
473 format!(
474 "Choir returned workspace {workspace} change {change}, \
475 expected workspace {} change {}",
476 expected.workspace_id, expected.change_id
477 ),
478 ));
479 }
480 Ok(())
481}
482
483#[must_use]
489pub fn failure_from_response(body: &str, context: &str) -> Failure {
490 let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
491 let code = parsed
492 .as_ref()
493 .and_then(|value| value.get("code"))
494 .and_then(serde_json::Value::as_str);
495 let detail = parsed
496 .as_ref()
497 .and_then(|value| {
498 value
499 .get("detail")
500 .or_else(|| value.get("error"))
501 .and_then(serde_json::Value::as_str)
502 })
503 .map(str::to_string);
504 match code {
505 Some(code) => Failure {
506 code: code.to_string(),
507 retryable: is_retryable(code),
508 message: detail.unwrap_or_else(|| format!("Choir did not complete {context}")),
509 },
510 None => Failure::transient(
511 "choir_unavailable",
512 detail.unwrap_or_else(|| format!("Choir did not complete {context}")),
513 ),
514 }
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
519pub enum Operation {
520 Ensure,
522 Checkpoint,
524 Archive,
526}
527
528impl Operation {
529 fn parse(value: &str) -> Result<Self, Failure> {
530 match value {
531 "ensure" => Ok(Self::Ensure),
532 "checkpoint" => Ok(Self::Checkpoint),
533 "archive" => Ok(Self::Archive),
534 other => Err(Failure::terminal(
535 "unsupported_operation",
536 format!("operation must be ensure, checkpoint or archive, not {other}"),
537 )),
538 }
539 }
540}
541
542#[derive(Debug, Clone, PartialEq, Eq)]
547pub struct Config {
548 pub api: String,
550 pub repo: String,
552 pub owner: String,
554 pub key_file: String,
556 pub namespace: String,
558 pub base_ref: Option<String>,
560 pub auth_file: Option<String>,
562 pub auth_user: Option<String>,
564}
565
566fn string_field(object: &serde_json::Value, key: &str) -> Option<String> {
567 object
568 .get(key)
569 .and_then(serde_json::Value::as_str)
570 .filter(|value| !value.is_empty())
571 .map(str::to_string)
572}
573
574impl Config {
575 pub fn parse(value: &serde_json::Value) -> Result<Self, Failure> {
587 let missing = |field: &str| {
588 Failure::terminal("invalid_config", format!("config needs string {field}"))
589 };
590 let api = string_field(value, "api").ok_or_else(|| missing("api"))?;
591 let repo = string_field(value, "repo").ok_or_else(|| missing("repo"))?;
592 let owner = string_field(value, "owner").ok_or_else(|| missing("owner"))?;
593 let key_file = string_field(value, "key_file").ok_or_else(|| missing("key_file"))?;
594 let namespace = string_field(value, "namespace").ok_or_else(|| missing("namespace"))?;
595
596 if !(api.starts_with("http://") || api.starts_with("https://")) {
597 return Err(Failure::terminal(
598 "invalid_config",
599 "config api must use http:// or https://",
600 ));
601 }
602 split_repo(&repo)?;
603 checked_namespace(&namespace)?;
604 if !key_file.starts_with('/') {
605 return Err(Failure::terminal(
606 "invalid_config",
607 "config key_file must be an absolute path",
608 ));
609 }
610 let auth_file = string_field(value, "auth_file");
611 let auth_user = string_field(value, "auth_user");
612 if auth_file
613 .as_ref()
614 .is_some_and(|path| !path.starts_with('/'))
615 {
616 return Err(Failure::terminal(
617 "invalid_config",
618 "config auth_file must be an absolute path",
619 ));
620 }
621 if auth_user.is_some() && auth_file.is_none() {
622 return Err(Failure::terminal(
623 "invalid_config",
624 "config auth_user needs auth_file",
625 ));
626 }
627 let base_ref = string_field(value, "base_ref");
628 if let Some(base_ref) = &base_ref {
631 if !base_ref.starts_with(&format!("{repo}.git:refs/")) {
632 return Err(Failure::terminal(
633 "invalid_config",
634 "config base_ref must name this repository as owner/repo.git:refs/...",
635 ));
636 }
637 }
638 Ok(Self {
639 api,
640 repo,
641 owner,
642 key_file,
643 namespace,
644 base_ref,
645 auth_file,
646 auth_user,
647 })
648 }
649}
650
651#[derive(Debug, Clone, PartialEq, Eq)]
653pub struct Request {
654 pub operation: Operation,
656 pub identity: Identity,
658 pub workspace_path: Option<String>,
660 pub base: Option<String>,
663}
664
665impl Request {
666 pub fn parse(value: &serde_json::Value, config: &Config) -> Result<Self, Failure> {
678 if value
679 .get("protocol_version")
680 .and_then(serde_json::Value::as_u64)
681 != Some(PROTOCOL_VERSION)
682 {
683 return Err(Failure::terminal(
684 "invalid_request",
685 format!("request must be a protocol_version {PROTOCOL_VERSION} object"),
686 ));
687 }
688 let operation = Operation::parse(&string_field(value, "operation").ok_or_else(|| {
689 Failure::terminal("invalid_request", "request needs string operation")
690 })?)?;
691
692 let needs = |field: &str| {
693 Failure::terminal(
694 "invalid_request",
695 format!("this scheme needs string {field}"),
696 )
697 };
698 let identity = match string_field(value, "scheme").as_deref() {
699 Some("from-name") => Identity::from_name(
700 &config.namespace,
701 &config.repo,
702 &string_field(value, "workspace_name").ok_or_else(|| needs("workspace_name"))?,
703 )?,
704 Some("from-external") => Identity::from_external(
705 &config.namespace,
706 &config.repo,
707 &string_field(value, "workspace_key").ok_or_else(|| needs("workspace_key"))?,
708 &string_field(value, "external_id").ok_or_else(|| needs("external_id"))?,
709 &string_field(value, "generation").ok_or_else(|| needs("generation"))?,
710 )?,
711 _ => {
712 return Err(Failure::terminal(
713 "invalid_request",
714 "request needs scheme to be from-name or from-external",
715 ))
716 }
717 };
718
719 Ok(Self {
720 operation,
721 identity,
722 workspace_path: string_field(value, "workspace_path"),
723 base: string_field(value, "base"),
724 })
725 }
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 fn config_json() -> serde_json::Value {
733 serde_json::json!({
734 "api": "http://127.0.0.1:9000",
735 "repo": "owner/repo",
736 "owner": "operator/agent",
737 "key_file": "/keys/owner.key",
738 "namespace": "sy",
739 "base_ref": "owner/repo.git:refs/heads/main",
740 })
741 }
742
743 fn config() -> Config {
744 Config::parse(&config_json()).expect("config parses")
745 }
746
747 fn identity(external: &str, generation: &str) -> Identity {
748 Identity::from_external("sy", "owner/repo", "issue-7", external, generation)
749 .expect("derives")
750 }
751
752 #[test]
757 fn a_from_name_binding_is_recoverable_from_the_path_alone() {
758 let created =
759 Identity::from_name("claude-code", "owner/repo", "cc-feature-abc123").expect("derives");
760 let recovered =
763 Identity::from_name("claude-code", "owner/repo", "cc-feature-abc123").expect("derives");
764 assert_eq!(created, recovered);
765 assert_eq!(created.scheme, Scheme::FromName);
766 assert_eq!(
767 created.change_id,
768 "claude-code:owner/repo:cc-feature-abc123"
769 );
770 assert!(
771 created.fingerprint.is_empty(),
772 "a name-derived binding must not imply a fingerprint it cannot recover"
773 );
774 }
775
776 #[test]
781 fn a_from_external_binding_is_not_recoverable_from_the_name() {
782 let derived = identity("ISSUE-7", "1");
783 assert!(
784 !derived.change_id.contains(&derived.workspace_name),
785 "the name would have carried the whole change id"
786 );
787 let truncated = derived.workspace_name.rsplit('-').next().expect("a suffix");
788 assert!(
789 truncated.len() < derived.fingerprint.len(),
790 "the name carries the full fingerprint, so durable state is not needed \
791 and this scheme has no cost to justify it"
792 );
793 }
794
795 #[test]
799 fn the_two_schemes_do_not_collide() {
800 let named = Identity::from_name("sy", "owner/repo", "sy-issue-7").expect("derives");
801 let fingerprinted = identity("sy-issue-7", "1");
802 assert_ne!(named.change_id, fingerprinted.change_id);
803 assert_ne!(named.scheme, fingerprinted.scheme);
804 }
805
806 #[test]
812 fn the_scheme_tag_participates_in_the_fingerprint() {
813 let material_without_tag = {
814 let mut material = Vec::new();
815 for field in ["owner/repo", "ISSUE-7", "1"] {
816 material.extend_from_slice(field.len().to_string().as_bytes());
817 material.push(b':');
818 material.extend_from_slice(field.as_bytes());
819 material.push(b'\n');
820 }
821 ContentHash::blake3(&material).to_hex()
822 };
823 assert!(
824 !material_without_tag.contains(&identity("ISSUE-7", "1").fingerprint),
825 "the scheme tag is not covered, so a derivation change could keep the same ids"
826 );
827 }
828
829 #[test]
830 fn a_binding_is_stable_for_one_unit_of_work_and_attempt() {
831 let first = identity("ISSUE-7", "1");
832 let second = identity("ISSUE-7", "1");
833 assert_eq!(first, second, "derivation is not deterministic");
834 assert_eq!(
835 first.workspace_id,
836 format!("owner/repo/{}", first.workspace_name)
837 );
838 assert!(first.workspace_name.starts_with("sy-issue-7-"));
839 assert!(first.change_id.starts_with("sy:owner/repo:"));
840 }
841
842 #[test]
847 fn a_different_unit_or_attempt_derives_a_different_binding() {
848 let base = identity("ISSUE-7", "1");
849 for (external, generation, why) in [
850 ("ISSUE-8", "1", "a different unit of work"),
851 ("ISSUE-7", "2", "a different attempt"),
852 ] {
853 let other = identity(external, generation);
854 assert_ne!(base.change_id, other.change_id, "{why} shared a change id");
855 assert_ne!(
856 base.workspace_name, other.workspace_name,
857 "{why} shared a workspace name"
858 );
859 }
860 }
861
862 #[test]
865 fn the_namespace_separates_orchestrators() {
866 let symphony =
867 Identity::from_external("sy", "owner/repo", "k", "ISSUE-7", "1").expect("derives");
868 let claude =
869 Identity::from_external("cc", "owner/repo", "k", "ISSUE-7", "1").expect("derives");
870 assert_ne!(symphony.change_id, claude.change_id);
871 assert_ne!(symphony.workspace_name, claude.workspace_name);
872 }
873
874 #[test]
878 fn a_separator_cannot_be_moved_between_fingerprint_fields() {
879 let left = Identity::from_external("sy", "owner/repo", "k", "a:b", "c").expect("derives");
880 let right = Identity::from_external("sy", "owner/repo", "k", "a", "b:c").expect("derives");
881 assert_ne!(
882 left.fingerprint, right.fingerprint,
883 "a moved separator collided two distinct bindings"
884 );
885 }
886
887 #[test]
888 fn unsafe_or_oversized_identity_input_is_refused() {
889 let long = "x".repeat(MAX_IDENTIFIER + 1);
890 let cases = [
891 ("sy", "owner/repo", "../escape", "i", "1", "a traversal key"),
892 ("sy", "owner/repo", ".hidden", "i", "1", "a hidden key"),
893 ("sy", "owner/repo", "", "i", "1", "an empty key"),
894 ("sy", "owner", "k", "i", "1", "a one-segment repo"),
895 ("sy", "a/b/c", "k", "i", "1", "a three-segment repo"),
896 ("../sy", "owner/repo", "k", "i", "1", "an unsafe namespace"),
897 ("sy", "owner/repo", "k", "", "1", "an empty external id"),
898 (
899 "sy",
900 "owner/repo",
901 "k",
902 &long,
903 "1",
904 "an over-long external id",
905 ),
906 (
907 "sy",
908 "owner/repo",
909 "k",
910 "i",
911 &long,
912 "an over-long generation",
913 ),
914 ];
915 for (namespace, repo, key, external, generation, why) in cases {
916 assert!(
917 Identity::from_external(namespace, repo, key, external, generation).is_err(),
918 "accepted {why}"
919 );
920 }
921 }
922
923 #[test]
924 fn an_over_long_workspace_key_is_refused_but_a_long_one_is_truncated_in_the_name() {
925 let too_long = "k".repeat(MAX_WORKSPACE_KEY + 1);
926 assert!(Identity::from_external("sy", "owner/repo", &too_long, "i", "1").is_err());
927
928 let long = "k".repeat(MAX_WORKSPACE_KEY);
929 let derived =
930 Identity::from_external("sy", "owner/repo", &long, "i", "1").expect("derives");
931 assert!(derived.workspace_name.len() < long.len() + KEY_PREFIX);
934 let sibling =
935 Identity::from_external("sy", "owner/repo", &long, "i", "2").expect("derives");
936 assert_ne!(derived.workspace_name, sibling.workspace_name);
937 }
938
939 #[test]
940 fn a_base_ref_resolves_only_to_a_real_git_object() {
941 let view = serde_json::json!({
942 "refs": {
943 "owner/repo.git:refs/heads/main": format!("11-{}", "a".repeat(40)),
944 "sha256": format!("12-{}", "b".repeat(64)),
945 "not-git": format!("1e-{}", "c".repeat(64)),
946 "short": "11-abc",
947 "not-hex": format!("11-{}", "z".repeat(40)),
948 }
949 });
950 assert_eq!(
951 base_from_view(&view, "owner/repo.git:refs/heads/main").expect("resolves"),
952 "a".repeat(40)
953 );
954 assert_eq!(
955 base_from_view(&view, "sha256").expect("resolves"),
956 "b".repeat(64)
957 );
958 for (name, why) in [
959 ("not-git", "a non-Git codec"),
960 ("short", "a truncated oid"),
961 ("not-hex", "a non-hex oid"),
962 ("absent", "a missing ref"),
963 ] {
964 let failure = base_from_view(&view, name).expect_err(why);
965 assert!(!failure.retryable, "{why} was reported as retryable");
966 }
967 }
968
969 #[test]
970 fn a_binding_check_refuses_a_receipt_for_another_change() {
971 let expected = identity("ISSUE-7", "1");
972 let good = serde_json::json!({
973 "workspace": expected.workspace_id,
974 "change_id": expected.change_id,
975 });
976 assert!(verify_binding(&good, &expected).is_ok());
977
978 let other = identity("ISSUE-8", "1");
979 for (response, why) in [
980 (
981 serde_json::json!({ "workspace": other.workspace_id, "change_id": expected.change_id }),
982 "another workspace",
983 ),
984 (
985 serde_json::json!({ "workspace": expected.workspace_id, "change_id": other.change_id }),
986 "another change",
987 ),
988 (
989 serde_json::json!({ "change_id": expected.change_id }),
990 "no workspace",
991 ),
992 (
993 serde_json::json!({ "workspace": expected.workspace_id }),
994 "no change",
995 ),
996 (
997 serde_json::json!({ "workspace": "", "change_id": expected.change_id }),
998 "an empty workspace",
999 ),
1000 ] {
1001 assert!(
1002 verify_binding(&response, &expected).is_err(),
1003 "accepted a receipt naming {why}"
1004 );
1005 }
1006 }
1007
1008 #[test]
1011 fn refusals_that_cannot_change_are_terminal_and_the_rest_are_not() {
1012 for code in [
1013 "workspace_state",
1014 "change_state",
1015 "stale_head",
1016 "unknown_key",
1017 "channel_not_owned",
1018 "identity_state",
1019 "bad_signature",
1020 ] {
1021 assert!(!is_retryable(code), "{code} would be retried forever");
1022 }
1023 for code in ["policy_unavailable", "log_evicted", "unclassified"] {
1024 assert!(
1025 is_retryable(code),
1026 "{code} stranded work that could succeed"
1027 );
1028 }
1029 assert!(is_retryable("a_code_from_a_newer_node"));
1033 }
1034
1035 #[test]
1036 fn a_choir_rejection_keeps_its_code_and_a_broken_body_still_decides() {
1037 let typed = failure_from_response(
1038 r#"{"code":"workspace_state","detail":"binding differs"}"#,
1039 "creation",
1040 );
1041 assert_eq!(typed.code, "workspace_state");
1042 assert!(!typed.retryable);
1043 assert_eq!(typed.message, "binding differs");
1044
1045 let garbage = failure_from_response("<html>502</html>", "creation");
1046 assert_eq!(garbage.code, "choir_unavailable");
1047 assert!(garbage.retryable, "an unreadable body stranded the work");
1048 assert!(garbage.message.contains("creation"));
1049 }
1050
1051 #[test]
1055 fn configuration_refuses_what_would_redirect_the_lifecycle() {
1056 for (field, value, why) in [
1057 ("api", serde_json::json!("ftp://host"), "a non-HTTP scheme"),
1058 ("api", serde_json::json!(""), "an empty api"),
1059 ("repo", serde_json::json!("owner"), "a one-segment repo"),
1060 ("repo", serde_json::json!("a/b/c"), "a three-segment repo"),
1061 (
1062 "repo",
1063 serde_json::json!("../etc/passwd"),
1064 "a traversal repo",
1065 ),
1066 (
1067 "key_file",
1068 serde_json::json!("relative.key"),
1069 "a relative key path",
1070 ),
1071 (
1072 "namespace",
1073 serde_json::json!("../sy"),
1074 "an unsafe namespace",
1075 ),
1076 (
1077 "auth_file",
1078 serde_json::json!("relative"),
1079 "a relative auth file",
1080 ),
1081 (
1082 "base_ref",
1083 serde_json::json!("other/repo.git:refs/heads/main"),
1084 "a base ref naming another repository",
1085 ),
1086 ] {
1087 let mut raw = config_json();
1088 raw[field] = value;
1089 assert!(Config::parse(&raw).is_err(), "config accepted {why}");
1090 }
1091
1092 let mut raw = config_json();
1093 raw["auth_user"] = serde_json::json!("someone");
1094 assert!(
1095 Config::parse(&raw).is_err(),
1096 "config accepted a username with no credentials file"
1097 );
1098 }
1099
1100 #[test]
1105 fn the_scheme_is_named_rather_than_guessed_from_the_fields() {
1106 let config = config();
1107 let complete = serde_json::json!({
1108 "protocol_version": PROTOCOL_VERSION,
1109 "operation": "ensure",
1110 "workspace_key": "issue-7",
1111 "external_id": "ISSUE-7",
1112 "generation": "1",
1113 "workspace_name": "sy-issue-7",
1114 });
1115 assert!(Request::parse(&complete, &config).is_err());
1117
1118 let mut named = complete.clone();
1119 named["scheme"] = serde_json::json!("from-name");
1120 let mut external = complete;
1121 external["scheme"] = serde_json::json!("from-external");
1122 let named = Request::parse(&named, &config).expect("from-name parses");
1123 let external = Request::parse(&external, &config).expect("from-external parses");
1124 assert_eq!(named.identity.scheme, Scheme::FromName);
1125 assert_eq!(external.identity.scheme, Scheme::FromExternal);
1126 assert_ne!(
1127 named.identity.change_id, external.identity.change_id,
1128 "the two schemes agreed, so naming one would not matter"
1129 );
1130 }
1131
1132 #[test]
1133 fn a_request_is_refused_without_its_version_operation_or_scheme_fields() {
1134 let config = config();
1135 let good = serde_json::json!({
1136 "protocol_version": PROTOCOL_VERSION,
1137 "operation": "ensure",
1138 "scheme": "from-name",
1139 "workspace_name": "sy-issue-7",
1140 });
1141 assert!(Request::parse(&good, &config).is_ok());
1142
1143 for (mutate, why) in [
1144 (
1145 serde_json::json!({"protocol_version": 2}),
1146 "a future protocol version",
1147 ),
1148 (
1149 serde_json::json!({"protocol_version": null}),
1150 "no protocol version",
1151 ),
1152 (
1153 serde_json::json!({"operation": "delete"}),
1154 "an unknown operation",
1155 ),
1156 (serde_json::json!({"operation": null}), "no operation"),
1157 (
1158 serde_json::json!({"scheme": "invented"}),
1159 "an unknown scheme",
1160 ),
1161 (
1162 serde_json::json!({"workspace_name": null}),
1163 "no workspace name",
1164 ),
1165 (
1166 serde_json::json!({"workspace_name": "../escape"}),
1167 "a traversal name",
1168 ),
1169 ] {
1170 let mut raw = good.clone();
1171 for (key, value) in mutate.as_object().expect("object") {
1172 raw[key] = value.clone();
1173 }
1174 assert!(Request::parse(&raw, &config).is_err(), "accepted {why}");
1175 }
1176 }
1177
1178 #[test]
1181 fn a_from_external_request_binds_what_its_fields_name() {
1182 let config = config();
1183 let raw = serde_json::json!({
1184 "protocol_version": PROTOCOL_VERSION,
1185 "operation": "checkpoint",
1186 "scheme": "from-external",
1187 "workspace_key": "issue-7",
1188 "external_id": "ISSUE-7",
1189 "generation": "1",
1190 "workspace_path": "/work/sy-issue-7",
1191 });
1192 let request = Request::parse(&raw, &config).expect("parses");
1193 assert_eq!(request.operation, Operation::Checkpoint);
1194 assert_eq!(request.workspace_path.as_deref(), Some("/work/sy-issue-7"));
1195 assert_eq!(
1196 request.identity,
1197 Identity::from_external("sy", "owner/repo", "issue-7", "ISSUE-7", "1")
1198 .expect("derives")
1199 );
1200
1201 for missing in ["workspace_key", "external_id", "generation"] {
1202 let mut raw = raw.clone();
1203 raw[missing] = serde_json::Value::Null;
1204 assert!(
1205 Request::parse(&raw, &config).is_err(),
1206 "accepted a from-external request with no {missing}"
1207 );
1208 }
1209 }
1210
1211 #[test]
1216 fn a_reused_change_reports_the_base_it_is_bound_to_not_the_one_requested() {
1217 let requested = "a".repeat(40);
1218 let bound = "b".repeat(40);
1219 let reused = serde_json::json!({
1220 "reused": true,
1221 "base_revision": format!("11-{bound}"),
1222 });
1223 assert_eq!(bound_base(&reused, &requested), bound);
1224
1225 for absent in [
1227 serde_json::json!({}),
1228 serde_json::json!({ "base_revision": "13-not-a-git-object" }),
1229 serde_json::json!({ "base_revision": format!("11-{}", "z".repeat(40)) }),
1230 serde_json::json!({ "base_revision": "11-abc" }),
1231 ] {
1232 assert_eq!(
1233 bound_base(&absent, &requested),
1234 requested,
1235 "an unusable base_revision was trusted: {absent}"
1236 );
1237 }
1238 }
1239
1240 #[test]
1241 fn a_failure_renders_the_wire_shape_an_adapter_forwards() {
1242 let json = Failure::terminal("binding_mismatch", "no").to_json();
1243 assert_eq!(json["protocol_version"], PROTOCOL_VERSION);
1244 assert_eq!(json["error"]["code"], "binding_mismatch");
1245 assert_eq!(json["error"]["retryable"], false);
1246 }
1247}