1use std::collections::BTreeMap;
36
37use serde::{Deserialize, Serialize};
38use tatara_lisp::DeriveTataraDomain;
39
40use crate::SpecError;
41
42#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
49#[tatara(keyword = "defdockerfile-graph")]
50pub struct DockerfileGraphSpec {
51 pub name: String,
52 pub description: String,
53 #[serde(rename = "sourceText")]
54 pub source_text: String,
55}
56
57#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
63pub enum DockerfileInstruction {
64 From {
65 image: String,
66 stage_alias: Option<String>,
67 },
68 Run {
69 command: String,
70 mount_bind_source: Option<String>,
71 mount_bind_target: Option<String>,
72 },
73 Copy {
74 sources: Vec<String>,
75 destination: String,
76 from_stage: Option<String>,
77 },
78 Arg {
79 name: String,
80 default_value: Option<String>,
81 },
82 Env {
83 name: String,
84 value: String,
85 },
86 Workdir {
87 path: String,
88 },
89 Cmd {
90 argv: Vec<String>,
91 },
92 Entrypoint {
93 argv: Vec<String>,
94 },
95}
96
97#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
100pub enum InstructionKind {
101 From,
102 Run,
103 Copy,
104 Arg,
105 Env,
106 Workdir,
107 Cmd,
108 Entrypoint,
109}
110
111impl DockerfileInstruction {
112 fn kind(&self) -> InstructionKind {
113 match self {
114 Self::From { .. } => InstructionKind::From,
115 Self::Run { .. } => InstructionKind::Run,
116 Self::Copy { .. } => InstructionKind::Copy,
117 Self::Arg { .. } => InstructionKind::Arg,
118 Self::Env { .. } => InstructionKind::Env,
119 Self::Workdir { .. } => InstructionKind::Workdir,
120 Self::Cmd { .. } => InstructionKind::Cmd,
121 Self::Entrypoint { .. } => InstructionKind::Entrypoint,
122 }
123 }
124
125 fn content_bytes(&self) -> Vec<u8> {
130 let mut buf = Vec::new();
131 match self {
132 Self::From { image, stage_alias } => {
133 buf.extend_from_slice(image.as_bytes());
134 buf.push(0);
135 if let Some(alias) = stage_alias {
136 buf.extend_from_slice(alias.as_bytes());
137 }
138 }
139 Self::Run { command, mount_bind_source, mount_bind_target } => {
140 buf.extend_from_slice(command.as_bytes());
141 buf.push(0);
142 if let Some(s) = mount_bind_source {
143 buf.extend_from_slice(s.as_bytes());
144 }
145 buf.push(0);
146 if let Some(t) = mount_bind_target {
147 buf.extend_from_slice(t.as_bytes());
148 }
149 }
150 Self::Copy { sources, destination, from_stage } => {
151 for s in sources {
152 buf.extend_from_slice(s.as_bytes());
153 buf.push(0);
154 }
155 buf.extend_from_slice(destination.as_bytes());
156 buf.push(0);
157 if let Some(stage) = from_stage {
158 buf.extend_from_slice(stage.as_bytes());
159 }
160 }
161 Self::Arg { name, default_value } => {
162 buf.extend_from_slice(name.as_bytes());
163 buf.push(0);
164 if let Some(v) = default_value {
165 buf.extend_from_slice(v.as_bytes());
166 }
167 }
168 Self::Env { name, value } => {
169 buf.extend_from_slice(name.as_bytes());
170 buf.push(0);
171 buf.extend_from_slice(value.as_bytes());
172 }
173 Self::Workdir { path } => buf.extend_from_slice(path.as_bytes()),
174 Self::Cmd { argv } | Self::Entrypoint { argv } => {
175 for a in argv {
176 buf.extend_from_slice(a.as_bytes());
177 buf.push(0);
178 }
179 }
180 }
181 buf
182 }
183}
184
185#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
191pub struct DockerfileNode {
192 pub kind: InstructionKind,
193 pub instruction: DockerfileInstruction,
194 #[serde(rename = "contentHash")]
195 pub content_hash: String,
196 #[serde(rename = "parentHash")]
197 pub parent_hash: Option<String>,
198}
199
200#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
205pub struct DockerfileGraph {
206 pub nodes: Vec<DockerfileNode>,
207}
208
209impl DockerfileGraph {
210 #[must_use]
213 pub fn root_hash(&self) -> Option<&str> {
214 self.nodes.last().map(|n| n.content_hash.as_str())
215 }
216}
217
218pub trait DockerfileEnvironment {
226 fn read_dockerfile(&self, path: &str) -> Result<String, String>;
236
237 fn resolve_build_arg(&self, name: &str) -> Option<String>;
242}
243
244#[derive(Default)]
247pub struct MockDockerfileEnvironment {
248 pub dockerfiles: BTreeMap<String, String>,
249 pub build_args: BTreeMap<String, String>,
250}
251
252impl MockDockerfileEnvironment {
253 #[must_use]
254 pub fn with_dockerfile(mut self, path: &str, text: &str) -> Self {
255 self.dockerfiles.insert(path.to_string(), text.to_string());
256 self
257 }
258
259 #[must_use]
260 pub fn with_build_arg(mut self, name: &str, value: &str) -> Self {
261 self.build_args.insert(name.to_string(), value.to_string());
262 self
263 }
264}
265
266impl DockerfileEnvironment for MockDockerfileEnvironment {
267 fn read_dockerfile(&self, path: &str) -> Result<String, String> {
268 self.dockerfiles
269 .get(path)
270 .cloned()
271 .ok_or_else(|| format!("no canned Dockerfile at `{path}`"))
272 }
273
274 fn resolve_build_arg(&self, name: &str) -> Option<String> {
275 self.build_args.get(name).cloned()
276 }
277}
278
279pub struct DockerfileArgs {
285 pub path: String,
286}
287
288pub fn apply<E: DockerfileEnvironment>(
302 args: &DockerfileArgs,
303 env: &E,
304) -> Result<DockerfileGraph, SpecError> {
305 let text = env.read_dockerfile(&args.path).map_err(|e| SpecError::Interp {
306 phase: "read-dockerfile".into(),
307 message: format!("reading `{}`: {e}", args.path),
308 })?;
309
310 let mut resolved_args: BTreeMap<String, String> = BTreeMap::new();
311 let mut nodes = Vec::new();
312 let mut parent_hash: Option<String> = None;
313
314 for (line_no, raw_line) in join_continuations(&text).into_iter().enumerate() {
315 let line = raw_line.trim();
316 if line.is_empty() || line.starts_with('#') {
317 continue;
318 }
319
320 let instruction = parse_instruction(line, line_no + 1, &resolved_args, env)?;
321
322 if let DockerfileInstruction::Arg { name, default_value } = &instruction {
323 let value = env
324 .resolve_build_arg(name)
325 .or_else(|| default_value.clone());
326 if let Some(v) = value {
327 resolved_args.insert(name.clone(), v);
328 }
329 }
330
331 let node = hash_node(instruction, parent_hash.as_deref(), &resolved_args);
332 parent_hash = Some(node.content_hash.clone());
333 nodes.push(node);
334 }
335
336 Ok(DockerfileGraph { nodes })
337}
338
339fn join_continuations(text: &str) -> Vec<String> {
343 let mut out = Vec::new();
344 let mut current = String::new();
345 for raw in text.lines() {
346 let trimmed_end = raw.trim_end();
347 if let Some(stripped) = trimmed_end.strip_suffix('\\') {
348 current.push_str(stripped);
349 current.push(' ');
350 } else {
351 current.push_str(trimmed_end);
352 out.push(std::mem::take(&mut current));
353 }
354 }
355 if !current.trim().is_empty() {
356 out.push(current);
357 }
358 out
359}
360
361fn hash_node(
362 instruction: DockerfileInstruction,
363 parent_hash: Option<&str>,
364 resolved_args: &BTreeMap<String, String>,
365) -> DockerfileNode {
366 let kind = instruction.kind();
367 let mut hasher = blake3::Hasher::new();
368 hasher.update(format!("{kind:?}").as_bytes());
369 hasher.update(b"\0");
370 if let Some(parent) = parent_hash {
371 hasher.update(parent.as_bytes());
372 }
373 hasher.update(b"\0");
374 for (k, v) in resolved_args {
375 hasher.update(k.as_bytes());
376 hasher.update(b"=");
377 hasher.update(v.as_bytes());
378 hasher.update(b"\0");
379 }
380 hasher.update(b"\0");
381 hasher.update(&instruction.content_bytes());
382 let content_hash = hasher.finalize().to_hex().to_string();
383
384 DockerfileNode {
385 kind,
386 instruction,
387 content_hash,
388 parent_hash: parent_hash.map(ToString::to_string),
389 }
390}
391
392fn substitute_arg_refs(
397 text: &str,
398 resolved_args: &BTreeMap<String, String>,
399) -> Result<String, SpecError> {
400 let mut out = String::with_capacity(text.len());
401 let bytes = text.as_bytes();
402 let mut i = 0;
403 while i < bytes.len() {
404 if bytes[i] == b'$' {
405 let (name, consumed) = if bytes.get(i + 1) == Some(&b'{') {
406 let end = text[i + 2..]
407 .find('}')
408 .map_or(text.len(), |p| i + 2 + p);
409 (text[i + 2..end].to_string(), end + 1 - i)
410 } else {
411 let start = i + 1;
412 let end = text[start..]
413 .find(|c: char| !(c.is_alphanumeric() || c == '_'))
414 .map_or(text.len(), |p| start + p);
415 (text[start..end].to_string(), end - i)
416 };
417 if name.is_empty() {
418 out.push('$');
419 i += 1;
420 continue;
421 }
422 match resolved_args.get(&name) {
423 Some(v) => out.push_str(v),
424 None => {
425 return Err(SpecError::Interp {
426 phase: "unresolved-arg-reference".into(),
427 message: format!(
428 "`${name}` referenced but never resolved by ARG default or build-arg",
429 ),
430 });
431 }
432 }
433 i += consumed;
434 } else {
435 out.push(bytes[i] as char);
436 i += 1;
437 }
438 }
439 Ok(out)
440}
441
442fn parse_instruction<E: DockerfileEnvironment>(
443 line: &str,
444 line_no: usize,
445 resolved_args: &BTreeMap<String, String>,
446 _env: &E,
447) -> Result<DockerfileInstruction, SpecError> {
448 let (keyword, rest) = split_keyword(line).ok_or_else(|| SpecError::Interp {
449 phase: "parse-line".into(),
450 message: format!("line {line_no}: no instruction keyword found in `{line}`"),
451 })?;
452
453 match keyword.to_ascii_uppercase().as_str() {
454 "FROM" => parse_from(rest, line_no),
455 "RUN" => parse_run(rest, line_no, resolved_args),
456 "COPY" => parse_copy(rest, line_no),
457 "ARG" => parse_arg(rest, line_no),
458 "ENV" => parse_env(rest, line_no, resolved_args),
459 "WORKDIR" => Ok(DockerfileInstruction::Workdir { path: rest.trim().to_string() }),
460 "CMD" => Ok(DockerfileInstruction::Cmd { argv: parse_argv(rest) }),
461 "ENTRYPOINT" => Ok(DockerfileInstruction::Entrypoint { argv: parse_argv(rest) }),
462 other => Err(SpecError::Interp {
463 phase: "parse-line".into(),
464 message: format!(
465 "line {line_no}: unsupported instruction `{other}` — this scoped \
466 parser handles FROM/RUN/COPY/ARG/ENV/WORKDIR/CMD/ENTRYPOINT only",
467 ),
468 }),
469 }
470}
471
472fn split_keyword(line: &str) -> Option<(&str, &str)> {
473 let idx = line.find(char::is_whitespace)?;
474 Some((&line[..idx], line[idx..].trim_start()))
475}
476
477fn parse_from(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
478 let mut parts = rest.split_whitespace();
479 let image = parts.next().ok_or_else(|| SpecError::Interp {
480 phase: "parse-line".into(),
481 message: format!("line {line_no}: FROM with no image"),
482 })?;
483 let stage_alias = match parts.next() {
484 Some(as_kw) if as_kw.eq_ignore_ascii_case("as") => {
485 parts.next().map(ToString::to_string)
486 }
487 _ => None,
488 };
489 Ok(DockerfileInstruction::From { image: image.to_string(), stage_alias })
490}
491
492fn parse_run(
493 rest: &str,
494 line_no: usize,
495 resolved_args: &BTreeMap<String, String>,
496) -> Result<DockerfileInstruction, SpecError> {
497 let (flags, command) = split_flags(rest);
498 let mut mount_bind_source = None;
499 let mut mount_bind_target = None;
500 for flag in &flags {
501 if let Some(mount_spec) = flag.strip_prefix("--mount=") {
502 for kv in mount_spec.split(',') {
503 if let Some(v) = kv.strip_prefix("source=") {
504 mount_bind_source = Some(v.to_string());
505 } else if let Some(v) = kv.strip_prefix("target=") {
506 mount_bind_target = Some(v.to_string());
507 }
508 }
509 }
510 }
511 if command.trim().is_empty() {
512 return Err(SpecError::Interp {
513 phase: "parse-line".into(),
514 message: format!("line {line_no}: RUN with no command"),
515 });
516 }
517 let command = substitute_arg_refs(command.trim(), resolved_args)?;
518 Ok(DockerfileInstruction::Run { command, mount_bind_source, mount_bind_target })
519}
520
521fn parse_copy(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
522 let (flags, body) = split_flags(rest);
523 let mut from_stage = None;
524 for flag in &flags {
525 if let Some(v) = flag.strip_prefix("--from=") {
526 from_stage = Some(v.to_string());
527 }
528 }
529 let tokens: Vec<&str> = body.split_whitespace().collect();
530 if tokens.len() < 2 {
531 return Err(SpecError::Interp {
532 phase: "parse-line".into(),
533 message: format!(
534 "line {line_no}: COPY requires at least one source and a destination, got `{rest}`",
535 ),
536 });
537 }
538 let (destination, sources) = tokens.split_last().expect("checked len >= 2 above");
539 Ok(DockerfileInstruction::Copy {
540 sources: sources.iter().map(ToString::to_string).collect(),
541 destination: (*destination).to_string(),
542 from_stage,
543 })
544}
545
546fn parse_arg(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
547 let rest = rest.trim();
548 if rest.is_empty() {
549 return Err(SpecError::Interp {
550 phase: "parse-line".into(),
551 message: format!("line {line_no}: ARG with no name"),
552 });
553 }
554 match rest.split_once('=') {
555 Some((name, value)) => Ok(DockerfileInstruction::Arg {
556 name: name.trim().to_string(),
557 default_value: Some(value.trim().trim_matches('"').to_string()),
558 }),
559 None => Ok(DockerfileInstruction::Arg { name: rest.to_string(), default_value: None }),
560 }
561}
562
563fn parse_env(
564 rest: &str,
565 line_no: usize,
566 resolved_args: &BTreeMap<String, String>,
567) -> Result<DockerfileInstruction, SpecError> {
568 let rest = rest.trim();
569 let (name, value) = rest.split_once('=').ok_or_else(|| SpecError::Interp {
570 phase: "parse-line".into(),
571 message: format!("line {line_no}: ENV requires `NAME=value`, got `{rest}`"),
572 })?;
573 let value = substitute_arg_refs(value.trim().trim_matches('"'), resolved_args)?;
574 Ok(DockerfileInstruction::Env { name: name.trim().to_string(), value })
575}
576
577fn parse_argv(rest: &str) -> Vec<String> {
581 let rest = rest.trim();
582 if let Some(inner) = rest.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
583 inner
584 .split(',')
585 .map(|s| s.trim().trim_matches('"').to_string())
586 .filter(|s| !s.is_empty())
587 .collect()
588 } else {
589 vec![rest.to_string()]
590 }
591}
592
593fn split_flags(rest: &str) -> (Vec<String>, &str) {
597 let mut flags = Vec::new();
598 let mut remainder = rest.trim_start();
599 while let Some(stripped) = remainder.strip_prefix("--") {
600 let end = stripped.find(char::is_whitespace).unwrap_or(stripped.len());
601 let mut flag = String::with_capacity(2 + end);
602 flag.push_str("--");
603 flag.push_str(&stripped[..end]);
604 flags.push(flag);
605 remainder = remainder[2 + end..].trim_start();
606 }
607 (flags, remainder)
608}
609
610pub const CANONICAL_DOCKERFILE_LISP: &str = include_str!("../specs/dockerfile.lisp");
613
614pub fn load_canonical() -> Result<Vec<DockerfileGraphSpec>, SpecError> {
620 crate::loader::load_all::<DockerfileGraphSpec>(CANONICAL_DOCKERFILE_LISP)
621}
622
623pub fn load_named(name: &str) -> Result<DockerfileGraphSpec, SpecError> {
629 load_canonical()?
630 .into_iter()
631 .find(|d| d.name == name)
632 .ok_or_else(|| SpecError::Load(format!(
633 "no (defdockerfile-graph) with :name {name:?}",
634 )))
635}
636
637pub fn apply_canonical(name: &str, build_args: &[(&str, &str)]) -> Result<DockerfileGraph, SpecError> {
645 let spec = load_named(name)?;
646 let mut env = MockDockerfileEnvironment::default().with_dockerfile(name, &spec.source_text);
647 for (k, v) in build_args {
648 env = env.with_build_arg(k, v);
649 }
650 apply(&DockerfileArgs { path: name.to_string() }, &env)
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 #[test]
658 fn canonical_dockerfile_specs_parse() {
659 let specs = load_canonical().expect("canonical dockerfile specs must compile");
660 assert_eq!(specs.len(), 2);
661 }
662
663 #[test]
664 fn simple_three_instruction_shape() {
665 let graph = apply_canonical("simple-three-instruction", &[]).unwrap();
666 let kinds: Vec<InstructionKind> = graph.nodes.iter().map(|n| n.kind).collect();
667 assert_eq!(
668 kinds,
669 vec![InstructionKind::From, InstructionKind::Run, InstructionKind::Cmd],
670 );
671 }
672
673 #[test]
674 fn nonroot_gateway_shape() {
675 let graph = apply_canonical("nonroot-gateway", &[("TARGETARCH", "amd64")]).unwrap();
676 assert_eq!(graph.nodes.len(), 11);
679
680 let run_with_mount = graph
681 .nodes
682 .iter()
683 .find(|n| matches!(&n.instruction, DockerfileInstruction::Run { mount_bind_source: Some(_), .. }))
684 .expect("expected a RUN with --mount=type=bind");
685 match &run_with_mount.instruction {
686 DockerfileInstruction::Run { mount_bind_source, mount_bind_target, command } => {
687 assert_eq!(mount_bind_source.as_deref(), Some("."));
688 assert_eq!(mount_bind_target.as_deref(), Some("/build"));
689 assert!(command.contains("amd64"), "TARGETARCH should have been substituted: {command}");
690 }
691 _ => unreachable!(),
692 }
693 }
694
695 #[test]
696 fn identical_inputs_produce_hash_identical_graphs() {
697 let g1 = apply_canonical("nonroot-gateway", &[("TARGETARCH", "arm64")]).unwrap();
698 let g2 = apply_canonical("nonroot-gateway", &[("TARGETARCH", "arm64")]).unwrap();
699 assert_eq!(g1, g2);
700 assert_eq!(g1.root_hash(), g2.root_hash());
701 for (a, b) in g1.nodes.iter().zip(g2.nodes.iter()) {
702 assert_eq!(a.content_hash, b.content_hash);
703 }
704 }
705
706 #[test]
707 fn different_build_arg_changes_downstream_hashes_only() {
708 let amd = apply_canonical("nonroot-gateway", &[("TARGETARCH", "amd64")]).unwrap();
709 let arm = apply_canonical("nonroot-gateway", &[("TARGETARCH", "arm64")]).unwrap();
710
711 assert_eq!(amd.nodes[0].content_hash, arm.nodes[0].content_hash);
719
720 let diverge_from = amd
723 .nodes
724 .iter()
725 .zip(arm.nodes.iter())
726 .position(|(a, b)| a.content_hash != b.content_hash)
727 .expect("amd64 vs arm64 runs must diverge somewhere");
728 assert!(diverge_from > 0, "must not diverge at the FROM root");
729
730 for (a, b) in amd.nodes[diverge_from..].iter().zip(arm.nodes[diverge_from..].iter()) {
733 assert_ne!(a.content_hash, b.content_hash);
734 }
735 }
736
737 #[test]
738 fn changing_one_instruction_only_invalidates_downstream() {
739 let mut env = MockDockerfileEnvironment::default().with_dockerfile(
740 "a",
741 "FROM debian:bookworm-slim\nRUN echo one\nRUN echo two\nCMD [\"/bin/true\"]\n",
742 );
743 let g1 = apply(&DockerfileArgs { path: "a".into() }, &env).unwrap();
744
745 env = MockDockerfileEnvironment::default().with_dockerfile(
746 "a",
747 "FROM debian:bookworm-slim\nRUN echo one\nRUN echo CHANGED\nCMD [\"/bin/true\"]\n",
748 );
749 let g2 = apply(&DockerfileArgs { path: "a".into() }, &env).unwrap();
750
751 assert_eq!(g1.nodes[0].content_hash, g2.nodes[0].content_hash);
753 assert_eq!(g1.nodes[1].content_hash, g2.nodes[1].content_hash);
754 assert_ne!(g1.nodes[2].content_hash, g2.nodes[2].content_hash);
757 assert_ne!(g1.nodes[3].content_hash, g2.nodes[3].content_hash);
758 }
759
760 #[test]
761 fn copy_with_no_source_is_a_typed_error() {
762 let env = MockDockerfileEnvironment::default()
763 .with_dockerfile("bad", "FROM x\nCOPY onlydestination\n");
764 let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
765 match err {
766 SpecError::Interp { phase, .. } => assert_eq!(phase, "parse-line"),
767 _ => panic!("expected parse-line error"),
768 }
769 }
770
771 #[test]
772 fn unresolvable_arg_reference_is_a_typed_error() {
773 let env = MockDockerfileEnvironment::default()
774 .with_dockerfile("bad", "FROM x\nRUN echo $NEVER_DECLARED\n");
775 let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
776 match err {
777 SpecError::Interp { phase, message } => {
778 assert_eq!(phase, "unresolved-arg-reference");
779 assert!(message.contains("NEVER_DECLARED"));
780 }
781 _ => panic!("expected unresolved-arg-reference error"),
782 }
783 }
784
785 #[test]
786 fn missing_dockerfile_is_a_typed_error() {
787 let env = MockDockerfileEnvironment::default();
788 let err = apply(&DockerfileArgs { path: "missing".into() }, &env).unwrap_err();
789 match err {
790 SpecError::Interp { phase, .. } => assert_eq!(phase, "read-dockerfile"),
791 _ => panic!("expected read-dockerfile error"),
792 }
793 }
794
795 #[test]
796 fn unsupported_instruction_is_a_typed_error() {
797 let env = MockDockerfileEnvironment::default()
798 .with_dockerfile("bad", "FROM x\nHEALTHCHECK CMD curl -f http://localhost/ || exit 1\n");
799 let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
800 match err {
801 SpecError::Interp { phase, message } => {
802 assert_eq!(phase, "parse-line");
803 assert!(message.contains("HEALTHCHECK"));
804 }
805 _ => panic!("expected parse-line error"),
806 }
807 }
808
809 #[test]
810 fn line_continuation_is_joined() {
811 let env = MockDockerfileEnvironment::default().with_dockerfile(
812 "cont",
813 "FROM x\nRUN apt-get update && \\\n apt-get install -y curl\n",
814 );
815 let graph = apply(&DockerfileArgs { path: "cont".into() }, &env).unwrap();
816 assert_eq!(graph.nodes.len(), 2);
817 match &graph.nodes[1].instruction {
818 DockerfileInstruction::Run { command, .. } => {
819 assert!(command.contains("apt-get install"));
820 }
821 _ => panic!("expected RUN"),
822 }
823 }
824}