1use std::collections::HashMap;
2
3#[derive(Copy, Clone, Debug, Eq, PartialEq)]
4pub enum Command {
5 InheritEnv,
6 Workdir,
7 Workspace,
8 Env,
9 Echo,
10 Run,
11 RunBg,
12 Copy,
13 WithIo,
14 CopyGit,
15 HashSha256,
16 Symlink,
17 Mkdir,
18 Ls,
19 Cwd,
20 Read,
21 Write,
22 Append,
23 AssertFile,
24 AssertDir,
25 AssertAbsent,
26 AssertStdout,
27 Exit,
28}
29
30pub const COMMANDS: &[Command] = &[
31 Command::InheritEnv,
32 Command::Workdir,
33 Command::Workspace,
34 Command::Env,
35 Command::Echo,
36 Command::Run,
37 Command::RunBg,
38 Command::Copy,
39 Command::WithIo,
40 Command::CopyGit,
41 Command::HashSha256,
42 Command::Symlink,
43 Command::Mkdir,
44 Command::Ls,
45 Command::Cwd,
46 Command::Read,
47 Command::Write,
48 Command::Append,
49 Command::AssertFile,
50 Command::AssertDir,
51 Command::AssertAbsent,
52 Command::AssertStdout,
53 Command::Exit,
54];
55
56impl Command {
57 pub const fn as_str(self) -> &'static str {
58 match self {
59 Command::InheritEnv => "INHERIT_ENV",
60 Command::Workdir => "WORKDIR",
61 Command::Workspace => "WORKSPACE",
62 Command::Env => "ENV",
63 Command::Echo => "ECHO",
64 Command::Run => "RUN",
65 Command::RunBg => "RUN_BG",
66 Command::Copy => "COPY",
67 Command::WithIo => "WITH_IO",
68 Command::CopyGit => "COPY_GIT",
69 Command::HashSha256 => "HASH_SHA256",
70 Command::Symlink => "SYMLINK",
71 Command::Mkdir => "MKDIR",
72 Command::Ls => "LS",
73 Command::Cwd => "CWD",
74 Command::Read => "READ",
75 Command::Write => "WRITE",
76 Command::Append => "APPEND",
77 Command::AssertFile => "ASSERT_FILE",
78 Command::AssertDir => "ASSERT_DIR",
79 Command::AssertAbsent => "ASSERT_ABSENT",
80 Command::AssertStdout => "ASSERT_STDOUT",
81 Command::Exit => "EXIT",
82 }
83 }
84
85 pub const fn expects_inner_command(self) -> bool {
86 matches!(self, Command::WithIo)
87 }
88
89 pub fn parse(s: &str) -> Option<Self> {
90 match s {
91 "INHERIT_ENV" => Some(Command::InheritEnv),
92 "WORKDIR" => Some(Command::Workdir),
93 "WORKSPACE" => Some(Command::Workspace),
94 "ENV" => Some(Command::Env),
95 "ECHO" => Some(Command::Echo),
96 "RUN" => Some(Command::Run),
97 "RUN_BG" => Some(Command::RunBg),
98 "COPY" => Some(Command::Copy),
99 "WITH_IO" => Some(Command::WithIo),
100 "COPY_GIT" => Some(Command::CopyGit),
101 "HASH_SHA256" => Some(Command::HashSha256),
102 "SYMLINK" => Some(Command::Symlink),
103 "MKDIR" => Some(Command::Mkdir),
104 "LS" => Some(Command::Ls),
105 "CWD" => Some(Command::Cwd),
106 "READ" => Some(Command::Read),
107 "WRITE" => Some(Command::Write),
108 "APPEND" => Some(Command::Append),
109 "ASSERT_FILE" => Some(Command::AssertFile),
110 "ASSERT_DIR" => Some(Command::AssertDir),
111 "ASSERT_ABSENT" => Some(Command::AssertAbsent),
112 "ASSERT_STDOUT" => Some(Command::AssertStdout),
113 "EXIT" => Some(Command::Exit),
114 _ => None,
115 }
116 }
117}
118
119#[derive(Copy, Clone, Debug, Eq, PartialEq)]
120pub enum PlatformGuard {
121 Unix,
122 Windows,
123 Macos,
124 Linux,
125}
126
127#[derive(Debug, Clone, Eq, PartialEq)]
128pub enum Guard {
129 Platform {
130 target: PlatformGuard,
131 invert: bool,
132 },
133 EnvExists {
134 key: String,
135 invert: bool,
136 },
137 EnvEquals {
138 key: String,
139 value: String,
140 invert: bool,
141 },
142}
143
144#[derive(Debug, Clone, Eq, PartialEq)]
145pub enum GuardExpr {
146 Predicate(Guard),
147 All(Vec<GuardExpr>),
148 Or(Vec<GuardExpr>),
149 Not(Box<GuardExpr>),
150}
151
152impl GuardExpr {
153 pub fn all(exprs: Vec<GuardExpr>) -> GuardExpr {
154 let mut flat = Vec::new();
155 for expr in exprs {
156 match expr {
157 GuardExpr::All(children) => flat.extend(children),
158 other => flat.push(other),
159 }
160 }
161 match flat.len() {
162 0 => panic!("GuardExpr::all requires at least one expression"),
163 1 => flat.into_iter().next().unwrap(),
164 _ => GuardExpr::All(flat),
165 }
166 }
167
168 pub fn or(exprs: Vec<GuardExpr>) -> GuardExpr {
169 let mut flat = Vec::new();
170 for expr in exprs {
171 match expr {
172 GuardExpr::Or(children) => flat.extend(children),
173 other => flat.push(other),
174 }
175 }
176 match flat.len() {
177 0 => panic!("GuardExpr::or requires at least one expression"),
178 1 => flat.into_iter().next().unwrap(),
179 _ => GuardExpr::Or(flat),
180 }
181 }
182
183 pub fn invert(expr: GuardExpr) -> GuardExpr {
184 match expr {
185 GuardExpr::Not(inner) => *inner,
186 other => GuardExpr::Not(Box::new(other)),
187 }
188 }
189}
190
191impl std::ops::Not for GuardExpr {
192 type Output = GuardExpr;
193
194 fn not(self) -> GuardExpr {
195 match self {
196 GuardExpr::Not(inner) => *inner,
197 other => GuardExpr::Not(Box::new(other)),
198 }
199 }
200}
201
202impl From<Guard> for GuardExpr {
203 fn from(guard: Guard) -> Self {
204 GuardExpr::Predicate(guard)
205 }
206}
207
208#[derive(Debug, Clone, Eq, PartialEq)]
209pub struct TemplateString(pub String);
210
211impl From<String> for TemplateString {
212 fn from(s: String) -> Self {
213 TemplateString(s)
214 }
215}
216
217impl From<&str> for TemplateString {
218 fn from(s: &str) -> Self {
219 TemplateString(s.to_string())
220 }
221}
222
223impl std::fmt::Display for TemplateString {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 write!(f, "{}", self.0)
226 }
227}
228
229impl AsRef<str> for TemplateString {
230 fn as_ref(&self) -> &str {
231 &self.0
232 }
233}
234
235impl PartialEq<str> for TemplateString {
236 fn eq(&self, other: &str) -> bool {
237 self.0 == other
238 }
239}
240
241impl PartialEq<&str> for TemplateString {
242 fn eq(&self, other: &&str) -> bool {
243 self.0 == *other
244 }
245}
246
247impl std::ops::Deref for TemplateString {
248 type Target = str;
249
250 fn deref(&self) -> &Self::Target {
251 &self.0
252 }
253}
254
255#[derive(Debug, Clone, Eq, PartialEq)]
256pub enum IoStream {
257 Stdin,
258 Stdout,
259 Stderr,
260}
261
262#[derive(Debug, Clone, Eq, PartialEq)]
263pub struct IoBinding {
264 pub stream: IoStream,
265 pub pipe: Option<String>,
266}
267
268#[derive(Debug, Clone, Eq, PartialEq)]
269pub enum StepKind {
270 Workdir(TemplateString),
271 Workspace(WorkspaceTarget),
272 Env {
273 key: String,
274 value: TemplateString,
275 },
276 InheritEnv {
279 keys: Vec<String>,
280 },
281 Run(TemplateString),
282 Echo(TemplateString),
283 RunBg(TemplateString),
284 Copy {
285 from_current_workspace: bool,
286 from: TemplateString,
287 to: TemplateString,
288 },
289 Symlink {
290 from: TemplateString,
291 to: TemplateString,
292 },
293 Mkdir(TemplateString),
294 Ls(Option<TemplateString>),
295 Cwd,
296 Read(Option<TemplateString>),
297 Write {
298 path: TemplateString,
299 contents: Option<TemplateString>,
300 },
301 Append {
302 path: TemplateString,
303 contents: Option<TemplateString>,
304 },
305 AssertFile {
309 hash: Option<String>,
310 path: TemplateString,
311 contents: Option<TemplateString>,
312 },
313 AssertDir(TemplateString),
314 AssertAbsent(TemplateString),
315 AssertStdout(TemplateString),
316 WithIo {
317 bindings: Vec<IoBinding>,
318 cmd: Box<StepKind>,
319 },
320 WithIoBlock {
321 bindings: Vec<IoBinding>,
322 },
323 CopyGit {
324 rev: TemplateString,
325 from: TemplateString,
326 to: TemplateString,
327 include_dirty: bool,
328 },
329 HashSha256 {
330 path: TemplateString,
331 },
332 Exit(i32),
333}
334
335#[derive(Debug, Clone, Eq, PartialEq)]
336pub struct Step {
337 pub guard: Option<GuardExpr>,
338 pub kind: StepKind,
339 pub scope_enter: usize,
340 pub scope_exit: usize,
341}
342
343#[derive(Debug, Clone, Eq, PartialEq)]
344pub enum WorkspaceTarget {
345 Snapshot,
346 Local,
347}
348
349fn platform_matches(target: PlatformGuard) -> bool {
350 #[allow(clippy::disallowed_macros)]
351 match target {
352 PlatformGuard::Unix => cfg!(unix),
353 PlatformGuard::Windows => cfg!(windows),
354 PlatformGuard::Macos => cfg!(target_os = "macos"),
355 PlatformGuard::Linux => cfg!(target_os = "linux"),
356 }
357}
358
359pub fn guard_allows(guard: &Guard, script_envs: &HashMap<String, String>) -> bool {
360 match guard {
361 Guard::Platform { target, invert } => {
362 let res = platform_matches(*target);
363 if *invert { !res } else { res }
364 }
365 Guard::EnvExists { key, invert } => {
366 let res = script_envs
367 .get(key)
368 .cloned()
369 .or_else(|| std::env::var(key).ok())
370 .map(|v| !v.is_empty())
371 .unwrap_or(false);
372 if *invert { !res } else { res }
373 }
374 Guard::EnvEquals { key, value, invert } => {
375 let res = script_envs
376 .get(key)
377 .cloned()
378 .or_else(|| std::env::var(key).ok())
379 .map(|v| v == *value)
380 .unwrap_or(false);
381 if *invert { !res } else { res }
382 }
383 }
384}
385
386pub fn guard_expr_allows(expr: &GuardExpr, script_envs: &HashMap<String, String>) -> bool {
387 match expr {
388 GuardExpr::Predicate(guard) => guard_allows(guard, script_envs),
389 GuardExpr::All(children) => children.iter().all(|g| guard_expr_allows(g, script_envs)),
390 GuardExpr::Or(children) => children.iter().any(|g| guard_expr_allows(g, script_envs)),
391 GuardExpr::Not(child) => !guard_expr_allows(child, script_envs),
392 }
393}
394
395pub fn guard_option_allows(
396 expr: Option<&GuardExpr>,
397 script_envs: &HashMap<String, String>,
398) -> bool {
399 match expr {
400 Some(e) => guard_expr_allows(e, script_envs),
401 None => true,
402 }
403}
404
405use std::fmt;
406
407impl fmt::Display for PlatformGuard {
408 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409 match self {
410 PlatformGuard::Unix => write!(f, "unix"),
411 PlatformGuard::Windows => write!(f, "windows"),
412 PlatformGuard::Macos => write!(f, "macos"),
413 PlatformGuard::Linux => write!(f, "linux"),
414 }
415 }
416}
417
418impl fmt::Display for Guard {
419 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420 match self {
421 Guard::Platform { target, invert } => {
422 if *invert {
423 write!(f, "!{}", target)
424 } else {
425 write!(f, "{}", target)
426 }
427 }
428 Guard::EnvExists { key, invert } => {
429 if *invert {
430 write!(f, "!")?
431 }
432 write!(f, "env:{}", key)
433 }
434 Guard::EnvEquals { key, value, invert } => {
435 if *invert {
436 write!(f, "env:{}!={}", key, value)
437 } else {
438 write!(f, "env:{}=={}", key, value)
439 }
440 }
441 }
442 }
443}
444
445impl fmt::Display for WorkspaceTarget {
446 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
447 match self {
448 WorkspaceTarget::Snapshot => write!(f, "SNAPSHOT"),
449 WorkspaceTarget::Local => write!(f, "LOCAL"),
450 }
451 }
452}
453
454fn quote_arg(s: &str) -> String {
455 let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
459 && !s.starts_with(|c: char| c.is_ascii_digit())
460 && super::Command::parse(s).is_none();
463 if is_safe && !s.is_empty() {
464 s.to_string()
465 } else {
466 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
467 }
468}
469
470fn quote_msg(s: &str) -> String {
471 let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
477 && !s.starts_with(|c: char| c.is_ascii_digit())
478 && super::Command::parse(s).is_none();
480
481 if is_safe && !s.is_empty() {
482 s.to_string()
483 } else {
484 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
485 }
486}
487
488fn quote_run(s: &str) -> String {
489 let force_full_quote = s.is_empty()
497 || s.chars().any(|c| c == ';' || c == '\n' || c == '\r')
498 || s.contains("//")
499 || s.contains("/*");
500
501 if force_full_quote {
502 return format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
503 }
504
505 s.split(' ')
506 .map(|word| {
507 let needs_quote = word.starts_with(|c: char| c.is_ascii_digit())
508 || word.starts_with(['/', '.', '-', ':', '=']);
509 if needs_quote {
510 format!("\"{}\"", word.replace('\\', "\\\\").replace('"', "\\\""))
511 } else {
512 word.to_string()
513 }
514 })
515 .collect::<Vec<_>>()
516 .join(" ")
517}
518
519fn format_io_binding(binding: &IoBinding) -> String {
520 let stream = match binding.stream {
521 IoStream::Stdin => "stdin",
522 IoStream::Stdout => "stdout",
523 IoStream::Stderr => "stderr",
524 };
525 if let Some(pipe) = &binding.pipe {
526 format!("{}=pipe:{}", stream, pipe)
527 } else {
528 stream.to_string()
529 }
530}
531
532impl fmt::Display for StepKind {
533 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
534 match self {
535 StepKind::InheritEnv { keys } => {
536 write!(f, "INHERIT_ENV [{}]", keys.join(", "))
537 }
538 StepKind::Workdir(arg) => write!(f, "WORKDIR {}", quote_arg(arg)),
539 StepKind::Workspace(target) => write!(f, "WORKSPACE {}", target),
540 StepKind::Env { key, value } => write!(f, "ENV {}={}", key, quote_arg(value)),
541 StepKind::Run(cmd) => write!(f, "RUN {}", quote_run(cmd)),
542 StepKind::Echo(msg) => write!(f, "ECHO {}", quote_msg(msg)),
543 StepKind::RunBg(cmd) => write!(f, "RUN_BG {}", quote_run(cmd)),
544 StepKind::Copy {
545 from_current_workspace,
546 from,
547 to,
548 } => {
549 if *from_current_workspace {
550 write!(
551 f,
552 "COPY --from-current-workspace {} {}",
553 quote_arg(from),
554 quote_arg(to)
555 )
556 } else {
557 write!(f, "COPY {} {}", quote_arg(from), quote_arg(to))
558 }
559 }
560 StepKind::Symlink { from, to } => {
561 write!(f, "SYMLINK {} {}", quote_arg(from), quote_arg(to))
562 }
563 StepKind::Mkdir(arg) => write!(f, "MKDIR {}", quote_arg(arg)),
564 StepKind::Ls(arg) => {
565 write!(f, "LS")?;
566 if let Some(a) = arg {
567 write!(f, " {}", quote_arg(a))?;
568 }
569 Ok(())
570 }
571 StepKind::Cwd => write!(f, "CWD"),
572 StepKind::Read(arg) => {
573 write!(f, "READ")?;
574 if let Some(a) = arg {
575 write!(f, " {}", quote_arg(a))?;
576 }
577 Ok(())
578 }
579 StepKind::Write { path, contents } => {
580 write!(f, "WRITE {}", quote_arg(path))?;
581 if let Some(body) = contents {
582 write!(f, " {}", quote_msg(body))?;
583 }
584 Ok(())
585 }
586 StepKind::Append { path, contents } => {
587 write!(f, "APPEND {}", quote_arg(path))?;
588 if let Some(body) = contents {
589 write!(f, " {}", quote_msg(body))?;
590 }
591 Ok(())
592 }
593 StepKind::AssertFile {
594 hash,
595 path,
596 contents,
597 } => {
598 if let Some(digest) = hash {
599 write!(f, "ASSERT_FILE --hash {} {}", digest, quote_arg(path))
600 } else {
601 write!(f, "ASSERT_FILE {}", quote_arg(path))?;
602 if let Some(body) = contents {
603 write!(f, " {}", quote_msg(body))?;
604 }
605 Ok(())
606 }
607 }
608 StepKind::AssertDir(arg) => write!(f, "ASSERT_DIR {}", quote_arg(arg)),
609 StepKind::AssertAbsent(arg) => write!(f, "ASSERT_ABSENT {}", quote_arg(arg)),
610 StepKind::AssertStdout(msg) => write!(f, "ASSERT_STDOUT {}", quote_msg(msg)),
611 StepKind::WithIo { bindings, cmd } => {
612 let parts: Vec<String> = bindings.iter().map(format_io_binding).collect();
613 write!(f, "WITH_IO [{}] {}", parts.join(", "), cmd)
614 }
615 StepKind::WithIoBlock { bindings } => {
616 let parts: Vec<String> = bindings.iter().map(format_io_binding).collect();
617 write!(f, "WITH_IO [{}] {{...}}", parts.join(", "))
618 }
619 StepKind::CopyGit {
620 rev,
621 from,
622 to,
623 include_dirty,
624 } => {
625 if *include_dirty {
626 write!(
627 f,
628 "COPY_GIT --include-dirty {} {} {}",
629 quote_arg(rev),
630 quote_arg(from),
631 quote_arg(to)
632 )
633 } else {
634 write!(
635 f,
636 "COPY_GIT {} {} {}",
637 quote_arg(rev),
638 quote_arg(from),
639 quote_arg(to)
640 )
641 }
642 }
643 StepKind::HashSha256 { path } => write!(f, "HASH_SHA256 {}", quote_arg(path)),
644 StepKind::Exit(code) => write!(f, "EXIT {}", code),
645 }
646 }
647}
648
649enum GuardDisplayContext {
650 Root,
651 InOrArg,
652 InNot,
653 InAll,
654}
655
656impl GuardExpr {
657 fn fmt_with_ctx(&self, f: &mut fmt::Formatter<'_>, ctx: GuardDisplayContext) -> fmt::Result {
658 match self {
659 GuardExpr::Predicate(guard) => write!(f, "{}", guard),
660 GuardExpr::All(children) => {
661 let wrap = matches!(
662 ctx,
663 GuardDisplayContext::InOrArg | GuardDisplayContext::InNot
664 ) && children.len() > 1;
665 if wrap {
666 write!(f, "(")?;
667 }
668 for (i, child) in children.iter().enumerate() {
669 if i > 0 {
670 write!(f, ", ")?;
671 }
672 child.fmt_with_ctx(f, GuardDisplayContext::InAll)?;
673 }
674 if wrap {
675 write!(f, ")")?;
676 }
677 Ok(())
678 }
679 GuardExpr::Or(children) => {
680 write!(f, "or(")?;
681 for (i, child) in children.iter().enumerate() {
682 if i > 0 {
683 write!(f, ", ")?;
684 }
685 child.fmt_with_ctx(f, GuardDisplayContext::InOrArg)?;
686 }
687 write!(f, ")")
688 }
689 GuardExpr::Not(child) => {
690 write!(f, "!")?;
691 let needs_paren =
692 !matches!(child.as_ref(), GuardExpr::Predicate(_) | GuardExpr::Not(_));
693 if needs_paren {
694 write!(f, "(")?;
695 }
696 child.fmt_with_ctx(f, GuardDisplayContext::InNot)?;
697 if needs_paren {
698 write!(f, ")")?;
699 }
700 Ok(())
701 }
702 }
703 }
704}
705
706impl fmt::Display for GuardExpr {
707 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
708 self.fmt_with_ctx(f, GuardDisplayContext::Root)
709 }
710}
711
712impl fmt::Display for Step {
713 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
714 if let Some(expr) = &self.guard {
715 write!(f, "[{}] ", expr)?;
716 }
717 write!(f, "{}", self.kind)
718 }
719}