1use std::collections::BTreeMap;
13use std::path::Path;
14
15use runx_contracts::{ClosureDisposition, FanoutReceiptSyncPoint, JsonObject, JsonValue, Receipt};
16use runx_core::state_machine::{GraphStatus, SequentialGraphState, StepAdmissionWitness};
17use runx_parser::ExecutionGraph;
18use serde::{Deserialize, Serialize};
19
20use super::graph::load_graph;
21use crate::RuntimeError;
22use crate::adapter::{InvocationStatus, SkillAdapter, SkillOutput};
23use crate::effects::RuntimeEffectRegistry;
24use crate::host::{Host, NoopHost};
25use crate::journal::ExecutionJournal;
26use crate::lifecycle::LifecycleEvent;
27use crate::receipts::paths::{RUNX_CWD_ENV, RUNX_PROJECT_DIR_ENV, RUNX_RECEIPT_DIR_ENV};
28use crate::receipts::signing::strip_receipt_signing_env;
29use crate::receipts::{
30 RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV, RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV,
31 RUNX_RECEIPT_SIGN_KID_ENV, RuntimeReceiptSignatureConfig, RuntimeReceiptSignaturePolicy,
32 graph_receipt_with_disposition_and_policy, graph_receipt_with_effects_and_signature_policy,
33};
34use crate::services::ReceiptServices;
35use crate::{PROVIDER_PERMISSION_GRANT_ID_ENV, PROVIDER_PERMISSION_GRANTED_SCOPES_ENV};
36
37mod authority;
38mod execution;
39mod host_resolution;
40mod inputs;
41mod scheduler;
42mod step_execution;
43mod steps;
44mod sync;
45
46use execution::GraphExecution;
47
48pub const RUNX_MAX_FANOUT_CONCURRENCY_ENV: &str = "RUNX_MAX_FANOUT_CONCURRENCY";
49pub const RUNX_RUN_ID_ENV: &str = "RUNX_RUN_ID";
50pub const RUNX_LOCAL_ENV_ALLOWLIST_ENV: &str = "RUNX_LOCAL_ENV_ALLOWLIST";
51
52#[derive(Clone, Debug)]
53pub struct RuntimeOptions {
54 pub created_at: String,
55 pub env: BTreeMap<String, String>,
56 pub receipt_signature: RuntimeReceiptSignatureConfig,
57 pub effects: RuntimeEffectRegistry,
58 pub credential_delivery: crate::credentials::CredentialDelivery,
62}
63
64impl RuntimeOptions {
65 #[must_use]
66 pub fn local_development() -> Self {
67 let env = safe_default_env();
68 Self {
69 created_at: crate::time::now_iso8601(),
70 env,
71 receipt_signature: RuntimeReceiptSignatureConfig::local_development(),
72 effects: RuntimeEffectRegistry::default(),
73 credential_delivery: crate::credentials::CredentialDelivery::none(),
74 }
75 }
76
77 pub fn from_process_env() -> Result<Self, RuntimeError> {
78 Self::from_env(safe_default_env())
79 }
80
81 #[must_use]
82 pub fn safe_process_env() -> BTreeMap<String, String> {
83 safe_default_env()
84 }
85
86 pub fn from_env(mut env: BTreeMap<String, String>) -> Result<Self, RuntimeError> {
87 let receipt_services =
88 ReceiptServices::from_env(&env).map_err(|error| RuntimeError::ReceiptInvalid {
89 message: error.to_string(),
90 })?;
91 strip_receipt_signing_env(&mut env);
92 Ok(Self {
93 created_at: crate::time::now_iso8601(),
94 env,
95 receipt_signature: receipt_services.signature_config().clone(),
96 effects: RuntimeEffectRegistry::default(),
97 credential_delivery: crate::credentials::CredentialDelivery::none(),
98 })
99 }
100
101 #[must_use]
102 pub fn signature_policy(&self) -> RuntimeReceiptSignaturePolicy<'_> {
103 self.receipt_signature.signature_policy()
104 }
105}
106
107fn safe_default_env() -> BTreeMap<String, String> {
108 safe_default_env_from(crate::services::process_env_value)
109}
110
111fn safe_default_env_from(
112 mut value_for_key: impl FnMut(&str) -> Option<String>,
113) -> BTreeMap<String, String> {
114 let allowed = [
115 "PATH",
116 "SystemRoot",
117 "PATHEXT",
118 RUNX_RECEIPT_DIR_ENV,
119 RUNX_RECEIPT_SIGN_KID_ENV,
120 RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV,
121 RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV,
122 crate::sandbox::RUNX_SANDBOX_ALLOW_DECLARED_POLICY_ONLY_ENV,
123 RUNX_MAX_FANOUT_CONCURRENCY_ENV,
124 RUNX_RUN_ID_ENV,
125 RUNX_PROJECT_DIR_ENV,
126 RUNX_CWD_ENV,
127 PROVIDER_PERMISSION_GRANT_ID_ENV,
128 PROVIDER_PERMISSION_GRANTED_SCOPES_ENV,
129 "RUNX_HTTP_ALLOW_PRIVATE_NETWORK",
130 "RUNX_REGISTRY_DIR",
131 "RUNX_REGISTRY_URL",
132 ];
133 let mut env = allowed
134 .into_iter()
135 .filter_map(|key| value_for_key(key).map(|value| (key.to_owned(), value)))
136 .collect::<BTreeMap<_, _>>();
137
138 if let Some(raw_allowlist) = value_for_key(RUNX_LOCAL_ENV_ALLOWLIST_ENV) {
139 for key in parse_local_env_allowlist(&raw_allowlist) {
140 if let Some(value) = value_for_key(&key) {
141 env.insert(key, value);
142 }
143 }
144 }
145
146 env
147}
148
149fn parse_local_env_allowlist(raw: &str) -> impl Iterator<Item = String> + '_ {
150 raw.split([',', ' ', '\n', '\t'])
151 .map(str::trim)
152 .filter(|key| !key.is_empty())
153 .filter(|key| {
154 key.bytes()
155 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
156 })
157 .filter(|key| !key.starts_with("RUNX_RECEIPT_SIGN_"))
158 .map(ToOwned::to_owned)
159}
160
161#[derive(Clone, Debug, Serialize, Deserialize)]
162pub struct StepRun {
163 pub step_id: String,
164 pub attempt: u32,
165 pub skill: String,
166 pub runner: Option<String>,
167 pub fanout_group: Option<String>,
168 pub output: SkillOutput,
169 pub outputs: JsonObject,
170 pub receipt: Receipt,
171 pub admission_witness: StepAdmissionWitness,
172}
173
174#[derive(Clone, Debug)]
175pub struct GraphRun {
176 pub graph: ExecutionGraph,
177 pub state: SequentialGraphState,
178 pub steps: Vec<StepRun>,
179 pub sync_points: Vec<FanoutReceiptSyncPoint>,
180 pub receipt: Receipt,
181 pub journal: ExecutionJournal,
182}
183
184#[derive(Clone, Debug, Serialize, Deserialize)]
185pub struct GraphCheckpoint {
186 pub graph_name: String,
187 pub state: SequentialGraphState,
188 pub steps: Vec<StepRun>,
189 pub sync_points: Vec<FanoutReceiptSyncPoint>,
190 pub journal: ExecutionJournal,
191}
192
193pub struct Runtime<A> {
194 adapter: A,
195 options: RuntimeOptions,
196 step_types: steps::StepTypeRegistry<A>,
197}
198
199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
200enum BlockedGraphOutcome {
201 Error,
202 Receipt,
203}
204
205impl<A> Runtime<A>
206where
207 A: SkillAdapter,
208{
209 pub fn new(adapter: A, options: RuntimeOptions) -> Self {
210 Self {
211 adapter,
212 options,
213 step_types: steps::StepTypeRegistry::builtins(),
214 }
215 }
216
217 pub(crate) fn options(&self) -> &RuntimeOptions {
218 &self.options
219 }
220
221 pub fn run_graph_file(&self, graph_path: &Path) -> Result<GraphRun, RuntimeError> {
222 let mut host = NoopHost;
223 self.run_graph_file_with_host(graph_path, &mut host)
224 }
225
226 pub fn run_graph_file_with_host(
227 &self,
228 graph_path: &Path,
229 host: &mut dyn Host,
230 ) -> Result<GraphRun, RuntimeError> {
231 let graph = load_graph(graph_path)?;
232 let graph_dir = graph_path.parent().unwrap_or_else(|| Path::new("."));
233 self.run_graph_with_host_outcome(graph_dir, graph, host, BlockedGraphOutcome::Error)
234 }
235
236 pub(crate) fn run_graph_file_for_harness(
237 &self,
238 graph_path: &Path,
239 host: &mut dyn Host,
240 ) -> Result<GraphRun, RuntimeError> {
241 let graph = load_graph(graph_path)?;
242 let graph_dir = graph_path.parent().unwrap_or_else(|| Path::new("."));
243 self.run_graph_with_host_outcome(graph_dir, graph, host, BlockedGraphOutcome::Receipt)
244 }
245
246 pub fn run_graph_with_host(
247 &self,
248 graph_dir: &Path,
249 graph: ExecutionGraph,
250 host: &mut dyn Host,
251 ) -> Result<GraphRun, RuntimeError> {
252 self.run_graph_with_host_outcome(graph_dir, graph, host, BlockedGraphOutcome::Error)
253 }
254
255 fn run_graph_with_host_outcome(
259 &self,
260 graph_dir: &Path,
261 graph: ExecutionGraph,
262 host: &mut dyn Host,
263 blocked_outcome: BlockedGraphOutcome,
264 ) -> Result<GraphRun, RuntimeError> {
265 let mut execution = GraphExecution::new(&graph);
266 match execution.run(self, graph_dir, &graph, host, None) {
267 Ok(()) => {
268 let receipt = graph_receipt_with_effects_and_signature_policy(
269 &graph.name,
270 &mut execution.runs,
271 execution.sync_points.clone(),
272 &self.options.created_at,
273 self.options.effects.clone(),
274 self.options.signature_policy(),
275 )?;
276 execution.record_lifecycle(
277 host,
278 LifecycleEvent::graph_completed(&graph.name, &receipt),
279 )?;
280 Ok(execution.finish(graph, receipt))
281 }
282 Err(RuntimeError::GraphBlocked { step_id, reason })
283 if blocked_outcome == BlockedGraphOutcome::Receipt =>
284 {
285 let receipt = graph_receipt_with_disposition_and_policy(
286 &graph.name,
287 &mut execution.runs,
288 execution.sync_points.clone(),
289 &self.options.created_at,
290 crate::receipts::GraphClosure {
291 disposition: ClosureDisposition::Blocked,
292 reason_code: "graph_blocked".to_owned(),
293 summary: format!("graph {} blocked at {step_id}: {reason}", graph.name),
294 },
295 self.options.effects.clone(),
296 self.options.signature_policy(),
297 )?;
298 execution.record_lifecycle(
299 host,
300 LifecycleEvent::graph_blocked(&graph.name, &step_id, &receipt),
301 )?;
302 Ok(execution.finish(graph, receipt))
303 }
304 Err(RuntimeError::AuthorityDenied {
308 verb,
309 step_id,
310 reason,
311 }) if blocked_outcome == BlockedGraphOutcome::Receipt => {
312 let receipt = graph_receipt_with_disposition_and_policy(
313 &graph.name,
314 &mut execution.runs,
315 execution.sync_points.clone(),
316 &self.options.created_at,
317 crate::receipts::GraphClosure {
318 disposition: ClosureDisposition::Blocked,
319 reason_code: "authority_denied".to_owned(),
320 summary: format!(
321 "graph {} denied {verb:?} at {step_id}: {reason}",
322 graph.name
323 ),
324 },
325 self.options.effects.clone(),
326 self.options.signature_policy(),
327 )?;
328 execution.record_lifecycle(
329 host,
330 LifecycleEvent::graph_blocked(&graph.name, &step_id, &receipt),
331 )?;
332 Ok(execution.finish(graph, receipt))
333 }
334 Err(error) => Err(error),
335 }
336 }
337
338 pub fn run_graph_file_until_steps(
339 &self,
340 graph_path: &Path,
341 max_steps: usize,
342 ) -> Result<GraphCheckpoint, RuntimeError> {
343 let mut host = NoopHost;
344 self.run_graph_file_until_steps_with_host(graph_path, max_steps, &mut host)
345 }
346
347 pub fn run_graph_file_until_steps_with_host(
348 &self,
349 graph_path: &Path,
350 max_steps: usize,
351 host: &mut dyn Host,
352 ) -> Result<GraphCheckpoint, RuntimeError> {
353 let graph = load_graph(graph_path)?;
354 let graph_dir = graph_path.parent().unwrap_or_else(|| Path::new("."));
355 self.run_graph_until_steps_with_host(graph_dir, &graph, max_steps, host)
356 }
357
358 pub fn run_graph_until_steps_with_host(
359 &self,
360 graph_dir: &Path,
361 graph: &ExecutionGraph,
362 max_steps: usize,
363 host: &mut dyn Host,
364 ) -> Result<GraphCheckpoint, RuntimeError> {
365 let mut execution = GraphExecution::new(graph);
366 execution.run(self, graph_dir, graph, host, Some(max_steps))?;
367 Ok(execution.checkpoint(graph.name.clone()))
368 }
369
370 pub fn resume_graph_file(
371 &self,
372 graph_path: &Path,
373 checkpoint: GraphCheckpoint,
374 ) -> Result<GraphRun, RuntimeError> {
375 let mut host = NoopHost;
376 self.resume_graph_file_with_host(graph_path, checkpoint, &mut host)
377 }
378
379 pub fn resume_graph_file_with_host(
380 &self,
381 graph_path: &Path,
382 checkpoint: GraphCheckpoint,
383 host: &mut dyn Host,
384 ) -> Result<GraphRun, RuntimeError> {
385 let graph = load_graph(graph_path)?;
386 let graph_dir = graph_path.parent().unwrap_or_else(|| Path::new("."));
387 self.resume_graph_with_host(graph_dir, graph, checkpoint, host)
388 }
389
390 pub fn resume_graph_with_host(
391 &self,
392 graph_dir: &Path,
393 graph: ExecutionGraph,
394 checkpoint: GraphCheckpoint,
395 host: &mut dyn Host,
396 ) -> Result<GraphRun, RuntimeError> {
397 let mut execution = GraphExecution::from_checkpoint(&graph, checkpoint)?;
398 execution.run(self, graph_dir, &graph, host, None)?;
399 let receipt = graph_receipt_with_effects_and_signature_policy(
400 &graph.name,
401 &mut execution.runs,
402 execution.sync_points.clone(),
403 &self.options.created_at,
404 self.options.effects.clone(),
405 self.options.signature_policy(),
406 )?;
407 execution.record_lifecycle(host, LifecycleEvent::graph_completed(&graph.name, &receipt))?;
408 Ok(execution.finish(graph, receipt))
409 }
410
411 pub(crate) fn seal_completed_graph_checkpoint_with_host(
412 &self,
413 graph: ExecutionGraph,
414 checkpoint: GraphCheckpoint,
415 host: &mut dyn Host,
416 ) -> Result<GraphRun, RuntimeError> {
417 if checkpoint.state.status != GraphStatus::Succeeded {
418 return Err(RuntimeError::GraphBlocked {
419 step_id: "graph".to_owned(),
420 reason: format!(
421 "cannot seal graph checkpoint with status {:?}",
422 checkpoint.state.status
423 ),
424 });
425 }
426 let mut execution = GraphExecution::from_checkpoint(&graph, checkpoint)?;
427 let receipt = graph_receipt_with_effects_and_signature_policy(
428 &graph.name,
429 &mut execution.runs,
430 execution.sync_points.clone(),
431 &self.options.created_at,
432 self.options.effects.clone(),
433 self.options.signature_policy(),
434 )?;
435 execution.record_lifecycle(host, LifecycleEvent::graph_completed(&graph.name, &receipt))?;
436 Ok(execution.finish(graph, receipt))
437 }
438
439 pub(crate) fn seal_blocked_graph_checkpoint_with_host(
440 &self,
441 graph: ExecutionGraph,
442 checkpoint: GraphCheckpoint,
443 step_id: &str,
444 reason_code: impl Into<String>,
445 summary: impl Into<String>,
446 host: &mut dyn Host,
447 ) -> Result<GraphRun, RuntimeError> {
448 let mut execution = GraphExecution::from_checkpoint(&graph, checkpoint)?;
449 let receipt = graph_receipt_with_disposition_and_policy(
450 &graph.name,
451 &mut execution.runs,
452 execution.sync_points.clone(),
453 &self.options.created_at,
454 crate::receipts::GraphClosure {
455 disposition: ClosureDisposition::Blocked,
456 reason_code: reason_code.into(),
457 summary: summary.into(),
458 },
459 self.options.effects.clone(),
460 self.options.signature_policy(),
461 )?;
462 execution.record_lifecycle(
463 host,
464 LifecycleEvent::graph_blocked(&graph.name, step_id, &receipt),
465 )?;
466 Ok(execution.finish(graph, receipt))
467 }
468
469 pub fn resume_graph_until_steps_with_host(
470 &self,
471 graph_dir: &Path,
472 graph: &ExecutionGraph,
473 checkpoint: GraphCheckpoint,
474 max_steps: usize,
475 host: &mut dyn Host,
476 ) -> Result<GraphCheckpoint, RuntimeError> {
477 let mut execution = GraphExecution::from_checkpoint(graph, checkpoint)?;
478 execution.run(self, graph_dir, graph, host, Some(max_steps))?;
479 Ok(execution.checkpoint(graph.name.clone()))
480 }
481}
482
483#[cfg(feature = "cli-tool")]
484pub fn run_graph_file(graph_path: impl AsRef<Path>) -> Result<GraphRun, RuntimeError> {
485 let runtime = Runtime::new(
486 crate::adapters::cli_tool::CliToolAdapter,
487 RuntimeOptions::from_process_env()?,
488 );
489 runtime.run_graph_file(graph_path.as_ref())
490}
491
492pub(crate) fn graph_run_payload(run: &GraphRun, include_receipt_id: bool) -> JsonValue {
498 let mut payload = JsonObject::new();
499 payload.insert(
500 "graph".to_owned(),
501 JsonValue::String(run.graph.name.clone()),
502 );
503 payload.insert(
504 "graph_status".to_owned(),
505 JsonValue::String(format!("{:?}", run.state.status)),
506 );
507 if include_receipt_id {
508 payload.insert(
509 "graph_receipt_id".to_owned(),
510 JsonValue::String(run.receipt.id.to_string()),
511 );
512 }
513 let mut step_outputs = JsonObject::new();
514 let mut step_summaries = Vec::new();
515 for step in &run.steps {
516 let mut summary = JsonObject::new();
517 summary.insert(
518 "step_id".to_owned(),
519 JsonValue::String(step.step_id.clone()),
520 );
521 summary.insert("skill".to_owned(), JsonValue::String(step.skill.clone()));
522 summary.insert(
523 "status".to_owned(),
524 JsonValue::String(if step.output.succeeded() {
525 "success".to_owned()
526 } else {
527 "failure".to_owned()
528 }),
529 );
530 summary.insert(
531 "receipt_id".to_owned(),
532 JsonValue::String(step.receipt.id.to_string()),
533 );
534 step_summaries.push(JsonValue::Object(summary));
535 step_outputs.insert(
536 step.step_id.clone(),
537 JsonValue::Object(step.outputs.clone()),
538 );
539 }
540 payload.insert("steps".to_owned(), JsonValue::Array(step_summaries));
541 payload.insert("step_outputs".to_owned(), JsonValue::Object(step_outputs));
542 JsonValue::Object(payload)
543}
544
545pub(crate) fn graph_run_skill_output(
546 payload: &JsonValue,
547 run: &GraphRun,
548) -> Result<SkillOutput, RuntimeError> {
549 let stdout = serde_json::to_string(payload)
550 .map_err(|source| RuntimeError::json("serializing graph payload", source))?;
551 Ok(SkillOutput {
552 status: if run.state.status == GraphStatus::Succeeded {
553 InvocationStatus::Success
554 } else {
555 InvocationStatus::Failure
556 },
557 stdout,
558 stderr: String::new(),
559 exit_code: Some(0),
560 duration_ms: 0,
561 metadata: JsonObject::new(),
562 })
563}
564
565#[cfg(test)]
566mod tests {
567 use super::{
568 RUNX_LOCAL_ENV_ALLOWLIST_ENV, RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV,
569 RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV, RUNX_RECEIPT_SIGN_KID_ENV, RuntimeOptions,
570 safe_default_env_from,
571 };
572 use crate::sandbox::RUNX_SANDBOX_ALLOW_DECLARED_POLICY_ONLY_ENV;
573 use std::collections::BTreeMap;
574
575 #[test]
576 fn safe_default_env_preserves_receipt_signing_inputs() {
577 let env = safe_default_env_from(|key| match key {
578 RUNX_RECEIPT_SIGN_KID_ENV => Some("kid_prod".to_owned()),
579 RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV => Some("seed".to_owned()),
580 RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV => Some("hosted".to_owned()),
581 _ => None,
582 });
583
584 assert_eq!(
585 env.get(RUNX_RECEIPT_SIGN_KID_ENV),
586 Some(&"kid_prod".to_owned())
587 );
588 assert_eq!(
589 env.get(RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV),
590 Some(&"seed".to_owned())
591 );
592 assert_eq!(
593 env.get(RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV),
594 Some(&"hosted".to_owned())
595 );
596 }
597
598 #[test]
599 fn safe_default_env_preserves_sandbox_operator_override() {
600 let env = safe_default_env_from(|key| match key {
601 RUNX_SANDBOX_ALLOW_DECLARED_POLICY_ONLY_ENV => Some("local".to_owned()),
602 _ => None,
603 });
604
605 assert_eq!(
606 env.get(RUNX_SANDBOX_ALLOW_DECLARED_POLICY_ONLY_ENV),
607 Some(&"local".to_owned())
608 );
609 }
610
611 #[test]
612 fn safe_default_env_preserves_local_operator_allowlisted_env() {
613 let env = safe_default_env_from(|key| match key {
614 RUNX_LOCAL_ENV_ALLOWLIST_ENV => Some(
615 "NITROSEND_API_KEY,NITROSEND_ADMIN_API_KEY RUNX_RECEIPT_SIGN_SECRET bad-key"
616 .to_owned(),
617 ),
618 "NITROSEND_API_KEY" => Some("nskey_live_test".to_owned()),
619 "NITROSEND_ADMIN_API_KEY" => Some("nskey_live_admin".to_owned()),
620 "RUNX_RECEIPT_SIGN_SECRET" => Some("secret".to_owned()),
621 _ => None,
622 });
623
624 assert_eq!(
625 env.get("NITROSEND_API_KEY"),
626 Some(&"nskey_live_test".to_owned())
627 );
628 assert_eq!(
629 env.get("NITROSEND_ADMIN_API_KEY"),
630 Some(&"nskey_live_admin".to_owned())
631 );
632 assert!(!env.contains_key("RUNX_RECEIPT_SIGN_SECRET"));
633 assert!(!env.contains_key("bad-key"));
634 }
635
636 #[test]
637 fn runtime_options_reject_incomplete_production_signing_env() -> Result<(), String> {
638 let env = [(RUNX_RECEIPT_SIGN_KID_ENV.to_owned(), "kid_prod".to_owned())]
639 .into_iter()
640 .collect::<BTreeMap<_, _>>();
641
642 let error = RuntimeOptions::from_env(env)
643 .err()
644 .ok_or_else(|| "incomplete signing env unexpectedly succeeded".to_owned())?;
645 assert!(
646 error
647 .to_string()
648 .contains("production receipt signing requires")
649 );
650 Ok(())
651 }
652
653 #[test]
654 fn runtime_options_reject_missing_production_signing_env() -> Result<(), String> {
655 let error = RuntimeOptions::from_env(BTreeMap::new())
656 .err()
657 .ok_or_else(|| "missing signing env unexpectedly succeeded".to_owned())?;
658 assert!(
659 error
660 .to_string()
661 .contains("governed runtime receipt signing")
662 );
663 Ok(())
664 }
665
666 #[test]
667 fn runtime_options_reject_malformed_production_signing_seed() -> Result<(), String> {
668 let env = [
669 (RUNX_RECEIPT_SIGN_KID_ENV.to_owned(), "kid_prod".to_owned()),
670 (
671 RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV.to_owned(),
672 "not-base64".to_owned(),
673 ),
674 (
675 RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV.to_owned(),
676 "hosted".to_owned(),
677 ),
678 ]
679 .into_iter()
680 .collect::<BTreeMap<_, _>>();
681
682 let error = RuntimeOptions::from_env(env)
683 .err()
684 .ok_or_else(|| "malformed signing env unexpectedly succeeded".to_owned())?;
685 assert!(
686 error
687 .to_string()
688 .contains("production receipt signer key material is malformed")
689 );
690 Ok(())
691 }
692
693 #[test]
694 fn runtime_options_strip_receipt_signing_env_after_signer_construction() -> Result<(), String> {
695 let env = [
696 (RUNX_RECEIPT_SIGN_KID_ENV.to_owned(), "kid_prod".to_owned()),
697 (
698 RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV.to_owned(),
699 "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=".to_owned(),
700 ),
701 (
702 RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV.to_owned(),
703 "hosted".to_owned(),
704 ),
705 ("RUNX_CWD".to_owned(), "/workspace".to_owned()),
706 ]
707 .into_iter()
708 .collect::<BTreeMap<_, _>>();
709
710 let options = RuntimeOptions::from_env(env).map_err(|error| error.to_string())?;
711
712 assert!(!options.env.contains_key(RUNX_RECEIPT_SIGN_KID_ENV));
713 assert!(
714 !options
715 .env
716 .contains_key(RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV)
717 );
718 assert!(!options.env.contains_key(RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV));
719 assert_eq!(options.env.get("RUNX_CWD"), Some(&"/workspace".to_owned()));
720 Ok(())
721 }
722}