1use async_trait::async_trait;
5use base64::prelude::*;
6use serde_json::json;
7use tracing::{info, warn};
8
9use super::{IssueOutcome, RenewalWindow, RequestedValidity, SignerBackend, SignerError};
10use crate::config::CustomSignerConfig;
11use crate::script_hook::{ScriptHook, ScriptOutcome, ScriptStdin};
12use crate::sqlite::order::Identifier;
13
14const BAD_CSR_EXIT_CODE: i32 = 3;
20
21#[derive(Debug)]
23pub struct CustomScriptSigner {
24 hook: ScriptHook,
25 supports_crl: bool,
26 supports_renewal_info: bool,
27}
28
29impl CustomScriptSigner {
30 pub fn from_config(cfg: &CustomSignerConfig) -> anyhow::Result<Self> {
32 let Some(hook) = ScriptHook::new(&cfg.script_path, &cfg.args, cfg.timeout_ms) else {
33 anyhow::bail!(
34 "signer.backend is \"custom\" but signer.custom.script_path is empty; \
35 provide a path to an executable script"
36 );
37 };
38
39 info!(
40 event = "signer_custom_loaded",
41 outcome = "success",
42 script_path = %hook.path().display(),
43 timeout_ms = cfg.timeout_ms,
44 supports_crl = cfg.supports_crl,
45 supports_renewal_info = cfg.supports_renewal_info,
46 args = ?cfg.args,
47 );
48
49 Ok(Self {
50 hook,
51 supports_crl: cfg.supports_crl,
52 supports_renewal_info: cfg.supports_renewal_info,
53 })
54 }
55
56 async fn run_script(
63 &self,
64 envs: &[(&str, &str)],
65 payload: &serde_json::Value,
66 ) -> Result<ScriptOutcome, SignerError> {
67 self.hook
68 .run(envs, ScriptStdin::Json(payload))
69 .await
70 .map_err(|error| SignerError::Internal(format!("custom signer {error}")))
71 }
72
73 fn detail_from(outcome: &ScriptOutcome) -> String {
75 ScriptHook::detail(outcome, "custom signer script")
76 }
77}
78
79#[async_trait]
80impl SignerBackend for CustomScriptSigner {
81 async fn issue(
82 &self,
83 order_id: &str,
84 csr_der: &[u8],
85 identifiers: &[Identifier],
86 validity: RequestedValidity,
87 ) -> Result<IssueOutcome, SignerError> {
88 let _ = validity;
92 let identifiers_str = identifiers
93 .iter()
94 .map(|i| i.value.as_str())
95 .collect::<Vec<_>>()
96 .join(",");
97 let envs = [
98 ("ACME_SIGNER_HOOK", "issue"),
99 ("ACME_SIGNER_ORDER_ID", order_id),
100 ("ACME_SIGNER_IDENTIFIERS", identifiers_str.as_str()),
101 ];
102 let payload = json!({
103 "hook": "issue",
104 "order_id": order_id,
105 "identifiers": identifiers,
106 "csr_der_base64": BASE64_STANDARD.encode(csr_der),
107 });
108
109 let outcome = self.run_script(&envs, &payload).await?;
110 let output = &outcome.output;
111 if output.status.success() {
112 let chain = String::from_utf8_lossy(&output.stdout)
120 .trim_end()
121 .to_string()
122 + "\n";
123 return Ok(IssueOutcome::Issued(chain));
124 }
125 if output.status.code() == Some(BAD_CSR_EXIT_CODE) {
126 return Err(SignerError::BadCsr);
127 }
128 Err(SignerError::Internal(Self::detail_from(&outcome)))
129 }
130
131 async fn revoke(&self, cert_der: &[u8], reason: Option<u32>) -> Result<(), SignerError> {
132 let reason_str = reason.map(|r| r.to_string()).unwrap_or_default();
133 let envs = [
134 ("ACME_SIGNER_HOOK", "revoke"),
135 ("ACME_SIGNER_REASON", reason_str.as_str()),
136 ];
137 let payload = json!({
138 "hook": "revoke",
139 "cert_der_base64": BASE64_STANDARD.encode(cert_der),
140 "reason": reason,
141 });
142
143 let outcome = self.run_script(&envs, &payload).await?;
144 let output = &outcome.output;
145 if output.status.success() {
146 Ok(())
147 } else {
148 Err(SignerError::Internal(Self::detail_from(&outcome)))
149 }
150 }
151
152 async fn crl_der(&self) -> Option<Vec<u8>> {
153 if !self.supports_crl {
154 return None;
155 }
156 let envs = [("ACME_SIGNER_HOOK", "crl")];
157 let payload = json!({ "hook": "crl" });
158
159 match self.run_script(&envs, &payload).await {
160 Ok(outcome) if outcome.output.status.success() => {
161 if outcome.output.stdout.is_empty() {
162 None
163 } else {
164 Some(outcome.output.stdout)
165 }
166 }
167 Ok(outcome) => {
172 warn!(
173 event = "signer_custom_crl_rejected",
174 outcome = "failure",
175 detail = %Self::detail_from(&outcome),
176 );
177 None
178 }
179 Err(err) => {
180 warn!(event = "signer_custom_crl_failed", outcome = "failure", detail = %err);
181 None
182 }
183 }
184 }
185
186 async fn renewal_info(&self, cert_der: &[u8]) -> Result<Option<RenewalWindow>, SignerError> {
191 if !self.supports_renewal_info {
192 return Ok(None);
193 }
194 let envs = [("ACME_SIGNER_HOOK", "renewal_info")];
195 let payload = json!({
196 "hook": "renewal_info",
197 "cert_der_base64": BASE64_STANDARD.encode(cert_der),
198 });
199
200 let outcome = self.run_script(&envs, &payload).await?;
201 let output = &outcome.output;
202 if !output.status.success() {
203 return Err(SignerError::Internal(Self::detail_from(&outcome)));
204 }
205
206 let text = String::from_utf8_lossy(&output.stdout);
207 let tokens = text.split_whitespace().collect::<Vec<_>>();
208 let (start, end, explanation_url) = match tokens.as_slice() {
209 [] => return Ok(None),
210 [start, end] => (*start, *end, None),
211 [start, end, url] => (*start, *end, Some((*url).to_string())),
212 other => {
213 return Err(SignerError::Internal(format!(
214 "custom signer renewal_info: expected \"<start> <end> [explanationURL]\" or empty stdout, got {} token(s)",
215 other.len()
216 )));
217 }
218 };
219
220 let start = start.parse::<i64>().map_err(|_| {
221 SignerError::Internal(format!(
222 "custom signer renewal_info: non-integer start `{start}`"
223 ))
224 })?;
225 let end = end.parse::<i64>().map_err(|_| {
226 SignerError::Internal(format!(
227 "custom signer renewal_info: non-integer end `{end}`"
228 ))
229 })?;
230
231 Ok(Some(RenewalWindow {
232 start,
233 end,
234 explanation_url,
235 }))
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::testutil::TempDir;
243 use std::time::Duration;
244
245 fn write_script(dir: &TempDir, name: &str, body: &str) -> CustomSignerConfig {
248 let script_path = crate::testutil::write_script(dir, name, body);
249 CustomSignerConfig {
250 script_path: script_path.to_str().unwrap().to_string(),
251 ..Default::default()
252 }
253 }
254
255 fn identifiers() -> Vec<Identifier> {
256 vec![Identifier::dns("example.com")]
257 }
258
259 #[test]
260 fn missing_script_path_bails() {
261 let cfg = CustomSignerConfig {
262 script_path: " ".to_string(),
263 ..Default::default()
264 };
265 assert!(CustomScriptSigner::from_config(&cfg).is_err());
266 }
267
268 #[tokio::test]
273 async fn a_script_that_cannot_be_spawned_is_internal() {
274 let signer = CustomScriptSigner::from_config(&CustomSignerConfig {
275 script_path: "/nonexistent/sign.sh".to_string(),
276 supports_crl: true,
277 supports_renewal_info: true,
278 ..Default::default()
279 })
280 .unwrap();
281
282 match signer
283 .issue(
284 "ord-1",
285 &[0x30, 0x00],
286 &identifiers(),
287 RequestedValidity::default(),
288 )
289 .await
290 {
291 Err(SignerError::Internal(detail)) => {
292 assert!(
293 detail.contains("failed to spawn script") && detail.contains("/nonexistent"),
294 "{detail}"
295 )
296 }
297 other => panic!("expected an Internal error, got {other:?}"),
298 }
299
300 assert!(matches!(
304 signer.revoke(&[0x30, 0x00], None).await,
305 Err(SignerError::Internal(_))
306 ));
307 assert!(signer.crl_der().await.is_none());
308 assert!(matches!(
309 signer.renewal_info(&[0x30, 0x00]).await,
310 Err(SignerError::Internal(_))
311 ));
312 }
313
314 #[tokio::test]
315 async fn issue_success_returns_the_chain() {
316 let dir = TempDir::new("signer-custom");
317 let cfg = write_script(
318 &dir,
319 "issue.sh",
320 "#!/bin/sh\ncat > /dev/null\necho '-----BEGIN CERTIFICATE-----leaf-----END CERTIFICATE-----'\nexit 0\n",
321 );
322 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
323
324 let outcome = signer
325 .issue(
326 "ord-1",
327 &[0x30, 0x00],
328 &identifiers(),
329 RequestedValidity::default(),
330 )
331 .await
332 .unwrap();
333 match outcome {
334 IssueOutcome::Issued(chain) => {
335 assert!(chain.contains("-----BEGIN CERTIFICATE-----leaf"))
336 }
337 IssueOutcome::Processing => panic!("custom signer must always issue synchronously"),
338 }
339 }
340
341 #[tokio::test]
347 async fn issue_chain_always_ends_with_exactly_one_trailing_newline() {
348 let dir = TempDir::new("signer-custom");
349 let cfg = write_script(
353 &dir,
354 "issue.sh",
355 "#!/bin/sh\ncat > /dev/null\nprintf -- '-----BEGIN CERTIFICATE-----\\nleaf\\n-----END CERTIFICATE-----\\n-----BEGIN CERTIFICATE-----\\nca\\n-----END CERTIFICATE-----'\nexit 0\n",
356 );
357 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
358
359 let outcome = signer
360 .issue(
361 "ord-1",
362 &[0x30, 0x00],
363 &identifiers(),
364 RequestedValidity::default(),
365 )
366 .await
367 .unwrap();
368 let chain = match outcome {
369 IssueOutcome::Issued(chain) => chain,
370 IssueOutcome::Processing => panic!("custom signer must always issue synchronously"),
371 };
372 assert!(
373 chain.ends_with("-----END CERTIFICATE-----\n") && !chain.ends_with("-----\n\n"),
374 "chain must end with exactly one trailing newline, got {chain:?}"
375 );
376 }
377
378 #[tokio::test]
379 async fn issue_bad_csr_exit_code_maps_to_bad_csr() {
380 let dir = TempDir::new("signer-custom");
381 let cfg = write_script(
382 &dir,
383 "bad_csr.sh",
384 "#!/bin/sh\ncat > /dev/null\necho 'csr does not match order'\nexit 3\n",
385 );
386 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
387
388 let result = signer
389 .issue(
390 "ord-1",
391 &[0x30, 0x00],
392 &identifiers(),
393 RequestedValidity::default(),
394 )
395 .await;
396 assert!(matches!(result, Err(SignerError::BadCsr)));
397 }
398
399 #[tokio::test]
400 async fn issue_other_failure_maps_to_internal_with_detail() {
401 let dir = TempDir::new("signer-custom");
402 let cfg = write_script(
403 &dir,
404 "fail.sh",
405 "#!/bin/sh\ncat > /dev/null\necho 'signing backend unreachable'\nexit 1\n",
406 );
407 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
408
409 let result = signer
410 .issue(
411 "ord-1",
412 &[0x30, 0x00],
413 &identifiers(),
414 RequestedValidity::default(),
415 )
416 .await;
417 match result {
418 Err(SignerError::Internal(detail)) => {
419 assert_eq!(detail, "signing backend unreachable")
420 }
421 other => panic!("expected Internal, got {other:?}"),
422 }
423 }
424
425 #[tokio::test]
426 async fn issue_receives_env_and_stdin() {
427 let dir = TempDir::new("signer-custom");
428 let script = r#"#!/bin/sh
429if [ "$ACME_SIGNER_HOOK" != "issue" ]; then echo "wrong hook: $ACME_SIGNER_HOOK"; exit 1; fi
430if [ "$ACME_SIGNER_ORDER_ID" != "ord-42" ]; then echo "wrong order id"; exit 1; fi
431if [ "$ACME_SIGNER_IDENTIFIERS" != "example.com" ]; then echo "wrong identifiers: $ACME_SIGNER_IDENTIFIERS"; exit 1; fi
432STDIN=$(cat)
433case "$STDIN" in
434 *csr_der_base64*) ;;
435 *) echo "stdin missing csr_der_base64"; exit 1 ;;
436esac
437echo '-----BEGIN CERTIFICATE-----leaf-----END CERTIFICATE-----'
438exit 0
439"#;
440 let cfg = write_script(&dir, "check_env.sh", script);
441 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
442
443 let outcome = signer
444 .issue(
445 "ord-42",
446 &[0x30, 0x00],
447 &identifiers(),
448 RequestedValidity::default(),
449 )
450 .await
451 .unwrap();
452 assert!(matches!(outcome, IssueOutcome::Issued(_)));
453 }
454
455 #[tokio::test]
456 async fn revoke_success() {
457 let dir = TempDir::new("signer-custom");
458 let cfg = write_script(&dir, "revoke.sh", "#!/bin/sh\ncat > /dev/null\nexit 0\n");
459 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
460 assert!(signer.revoke(&[0x30, 0x00], Some(1)).await.is_ok());
461 }
462
463 #[tokio::test]
464 async fn revoke_receives_reason() {
465 let dir = TempDir::new("signer-custom");
466 let script = r#"#!/bin/sh
467cat > /dev/null
468if [ "$ACME_SIGNER_REASON" != "4" ]; then echo "wrong reason: $ACME_SIGNER_REASON"; exit 1; fi
469exit 0
470"#;
471 let cfg = write_script(&dir, "revoke_reason.sh", script);
472 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
473 assert!(signer.revoke(&[0x30, 0x00], Some(4)).await.is_ok());
474 }
475
476 #[tokio::test]
477 async fn revoke_failure_maps_to_internal() {
478 let dir = TempDir::new("signer-custom");
479 let cfg = write_script(
480 &dir,
481 "revoke_fail.sh",
482 "#!/bin/sh\ncat > /dev/null\necho 'already gone'\nexit 1\n",
483 );
484 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
485 match signer.revoke(&[0x30, 0x00], None).await {
486 Err(SignerError::Internal(detail)) => assert_eq!(detail, "already gone"),
487 other => panic!("expected Internal, got {other:?}"),
488 }
489 }
490
491 #[tokio::test]
492 async fn crl_der_disabled_by_default_never_spawns() {
493 let dir = TempDir::new("signer-custom");
494 let marker = dir.path().join("ran");
495 let cfg = write_script(
496 &dir,
497 "crl.sh",
498 &format!("#!/bin/sh\ntouch {}\nexit 0\n", marker.to_str().unwrap()),
499 );
500 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
501 assert!(signer.crl_der().await.is_none());
502 assert!(!marker.exists(), "crl hook must not run when disabled");
503 }
504
505 #[tokio::test]
506 async fn crl_der_enabled_returns_raw_bytes() {
507 let dir = TempDir::new("signer-custom");
508 let mut cfg = write_script(
509 &dir,
510 "crl.sh",
511 "#!/bin/sh\ncat > /dev/null\nprintf 'fake-der-bytes'\nexit 0\n",
512 );
513 cfg.supports_crl = true;
514 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
515 assert_eq!(signer.crl_der().await, Some(b"fake-der-bytes".to_vec()));
516 }
517
518 #[tokio::test]
519 async fn crl_der_empty_stdout_is_none() {
520 let dir = TempDir::new("signer-custom");
521 let mut cfg = write_script(&dir, "crl.sh", "#!/bin/sh\ncat > /dev/null\nexit 0\n");
522 cfg.supports_crl = true;
523 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
524 assert!(signer.crl_der().await.is_none());
525 }
526
527 #[tokio::test]
528 async fn crl_der_script_failure_degrades_to_none() {
529 let dir = TempDir::new("signer-custom");
530 let mut cfg = write_script(&dir, "crl.sh", "#!/bin/sh\ncat > /dev/null\nexit 1\n");
531 cfg.supports_crl = true;
532 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
533 assert!(signer.crl_der().await.is_none());
534 }
535
536 #[tokio::test]
537 async fn renewal_info_disabled_by_default_never_spawns() {
538 let dir = TempDir::new("signer-custom");
539 let marker = dir.path().join("ran");
540 let cfg = write_script(
541 &dir,
542 "ari.sh",
543 &format!("#!/bin/sh\ntouch {}\nexit 0\n", marker.to_str().unwrap()),
544 );
545 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
546 assert_eq!(signer.renewal_info(&[0x30, 0x00]).await.unwrap(), None);
547 assert!(
548 !marker.exists(),
549 "renewal_info hook must not run when disabled"
550 );
551 }
552
553 #[tokio::test]
554 async fn renewal_info_enabled_parses_window() {
555 let dir = TempDir::new("signer-custom");
556 let mut cfg = write_script(
557 &dir,
558 "ari.sh",
559 "#!/bin/sh\ncat > /dev/null\necho '1000 2000'\nexit 0\n",
560 );
561 cfg.supports_renewal_info = true;
562 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
563 assert_eq!(
564 signer.renewal_info(&[0x30, 0x00]).await.unwrap(),
565 Some(RenewalWindow::new(1000, 2000))
566 );
567 }
568
569 #[tokio::test]
572 async fn renewal_info_parses_an_optional_explanation_url() {
573 let dir = TempDir::new("signer-custom");
574 let mut cfg = write_script(
575 &dir,
576 "ari.sh",
577 "#!/bin/sh\ncat > /dev/null\necho '1000 2000 https://ca.example/why'\nexit 0\n",
578 );
579 cfg.supports_renewal_info = true;
580 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
581 assert_eq!(
582 signer.renewal_info(&[0x30, 0x00]).await.unwrap(),
583 Some(RenewalWindow {
584 start: 1000,
585 end: 2000,
586 explanation_url: Some("https://ca.example/why".to_string()),
587 })
588 );
589 }
590
591 #[tokio::test]
594 async fn renewal_info_rejects_more_than_three_tokens() {
595 let dir = TempDir::new("signer-custom");
596 let mut cfg = write_script(
597 &dir,
598 "ari.sh",
599 "#!/bin/sh\ncat > /dev/null\necho '1000 2000 a b'\nexit 0\n",
600 );
601 cfg.supports_renewal_info = true;
602 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
603 assert!(matches!(
604 signer.renewal_info(&[0x30, 0x00]).await,
605 Err(SignerError::Internal(_))
606 ));
607 }
608
609 #[tokio::test]
610 async fn renewal_info_enabled_blank_stdout_is_no_opinion() {
611 let dir = TempDir::new("signer-custom");
612 let mut cfg = write_script(&dir, "ari.sh", "#!/bin/sh\ncat > /dev/null\nexit 0\n");
613 cfg.supports_renewal_info = true;
614 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
615 assert_eq!(signer.renewal_info(&[0x30, 0x00]).await.unwrap(), None);
616 }
617
618 #[tokio::test]
619 async fn renewal_info_enabled_garbage_stdout_is_internal_error() {
620 let dir = TempDir::new("signer-custom");
621 let mut cfg = write_script(
622 &dir,
623 "ari.sh",
624 "#!/bin/sh\ncat > /dev/null\necho 'nonsense'\nexit 0\n",
625 );
626 cfg.supports_renewal_info = true;
627 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
628 assert!(signer.renewal_info(&[0x30, 0x00]).await.is_err());
629 }
630
631 #[tokio::test]
636 async fn renewal_info_rejects_a_non_integer_timestamp() {
637 for (script, expected) in [
638 (
639 "echo '2026-01-01T00:00:00Z 1800000000'",
640 "non-integer start",
641 ),
642 ("echo '1700000000 2026-01-01T00:00:00Z'", "non-integer end"),
643 ] {
644 let dir = TempDir::new("signer-custom");
645 let mut cfg = write_script(
646 &dir,
647 "ari.sh",
648 &format!("#!/bin/sh\ncat > /dev/null\n{script}\nexit 0\n"),
649 );
650 cfg.supports_renewal_info = true;
651 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
652
653 match signer.renewal_info(&[0x30, 0x00]).await {
654 Err(SignerError::Internal(detail)) => {
655 assert!(detail.contains(expected), "expected {expected:?}: {detail}")
656 }
657 other => panic!("expected an Internal error, got {other:?}"),
658 }
659 }
660 }
661
662 #[tokio::test]
666 async fn the_script_does_not_inherit_the_server_environment() {
667 assert!(
668 std::env::var_os("CARGO_MANIFEST_DIR").is_some(),
669 "the canary must exist in the parent, otherwise the test proves nothing"
670 );
671
672 let dir = TempDir::new("signer-custom");
673 let cfg = write_script(
674 &dir,
675 "env_leak.sh",
676 r#"#!/bin/sh
677cat > /dev/null
678if [ -n "$CARGO_MANIFEST_DIR" ]; then
679 echo "inherited CARGO_MANIFEST_DIR=$CARGO_MANIFEST_DIR"
680 exit 1
681fi
682if [ -z "$PATH" ]; then
683 echo "no PATH"
684 exit 1
685fi
686if [ "$ACME_SIGNER_HOOK" != "issue" ]; then
687 echo "missing ACME_SIGNER_HOOK"
688 exit 1
689fi
690echo '-----BEGIN CERTIFICATE-----leaf-----END CERTIFICATE-----'
691exit 0
692"#,
693 );
694 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
695 let outcome = signer
696 .issue(
697 "ord-1",
698 &[0x30, 0x00],
699 &identifiers(),
700 RequestedValidity::default(),
701 )
702 .await
703 .unwrap();
704 assert!(matches!(outcome, IssueOutcome::Issued(_)));
705 }
706
707 #[tokio::test]
708 async fn script_timeout_returns_internal() {
709 let dir = TempDir::new("signer-custom");
710 let cfg = CustomSignerConfig {
711 timeout_ms: 100,
712 ..write_script(
713 &dir,
714 "sleep.sh",
715 "#!/bin/sh\ncat > /dev/null\nsleep 2\nexit 0\n",
716 )
717 };
718 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
719
720 match signer
721 .issue(
722 "ord-1",
723 &[0x30, 0x00],
724 &identifiers(),
725 RequestedValidity::default(),
726 )
727 .await
728 {
729 Err(SignerError::Internal(detail)) => assert!(detail.contains("timed out")),
730 other => panic!("expected Internal error on timeout, got {other:?}"),
731 }
732 }
733
734 #[tokio::test]
737 async fn a_timed_out_script_is_killed_rather_than_left_running() {
738 let dir = TempDir::new("signer-custom");
739 let marker = dir.path().join("survived");
740 let cfg = CustomSignerConfig {
741 timeout_ms: 50,
742 ..write_script(
743 &dir,
744 "slow.sh",
745 &format!(
746 "#!/bin/sh\ncat > /dev/null\nsleep 1\ntouch {}\n",
747 marker.to_str().unwrap()
748 ),
749 )
750 };
751 let signer = CustomScriptSigner::from_config(&cfg).unwrap();
752
753 match signer
754 .issue(
755 "ord-1",
756 &[0x30, 0x00],
757 &identifiers(),
758 RequestedValidity::default(),
759 )
760 .await
761 {
762 Err(SignerError::Internal(detail)) => assert!(detail.contains("timed out")),
763 other => panic!("expected Internal error on timeout, got {other:?}"),
764 }
765
766 tokio::time::sleep(Duration::from_millis(1_800)).await;
767 assert!(
768 !marker.exists(),
769 "the script survived the timeout and continued executing"
770 );
771 }
772}