pr_01m47d15m3e54sn21z27rpy5n9/crates/runner/src/actions/files.rs

164 lines6,264 bytesCodeBlame
1//! The files a step writes to talk back (`GITHUB_OUTPUT`, `GITHUB_ENV`,
2//! `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY`), and `hashFiles`.
3
4use std::collections::BTreeMap;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7
8use g1t_actions::filter::Patterns;
9use sha2::{Digest, Sha256};
10
11/// `name=value` lines and `name<<DELIMITER` … `DELIMITER` blocks.
12pub(crate) fn key_values(text: &str) -> Result<BTreeMap<String, String>, String> {
13 let mut out = BTreeMap::new();
14 let mut lines = text.lines();
15 while let Some(line) = lines.next() {
16 if line.trim().is_empty() {
17 continue;
18 }
19 let heredoc = line.find("<<");
20 let equals = line.find('=');
21 match (heredoc, equals) {
22 (Some(at), eq) if eq.is_none_or(|eq| at < eq) => {
23 let name = line[..at].to_owned();
24 let delimiter = &line[at + 2..];
25 if name.is_empty() || delimiter.is_empty() {
26 return Err(format!("`{line}` is not a name and a delimiter."));
27 }
28 let mut value = Vec::new();
29 let mut closed = false;
30 for body in lines.by_ref() {
31 if body == delimiter {
32 closed = true;
33 break;
34 }
35 value.push(body);
36 }
37 if !closed {
38 return Err(format!("The value of `{name}` never reaches its delimiter `{delimiter}`."));
39 }
40 out.insert(name, value.join("\n"));
41 }
42 (_, Some(eq)) => {
43 out.insert(line[..eq].to_owned(), line[eq + 1..].to_owned());
44 }
45 _ => return Err(format!("`{line}` is not `name=value`.")),
46 }
47 }
48 Ok(out)
49}
50
51/// The files of one step, made empty before it runs.
52pub(crate) struct StepFiles {
53 pub(crate) output: PathBuf,
54 pub(crate) env: PathBuf,
55 pub(crate) path: PathBuf,
56 pub(crate) state: PathBuf,
57 pub(crate) summary: PathBuf,
58}
59
60impl StepFiles {
61 pub(crate) fn new(temp: &Path, id: &str) -> std::io::Result<StepFiles> {
62 let dir = temp.join("_runner_file_commands");
63 std::fs::create_dir_all(&dir)?;
64 let files = StepFiles {
65 output: dir.join(format!("set_output_{id}")),
66 env: dir.join(format!("set_env_{id}")),
67 path: dir.join(format!("add_path_{id}")),
68 state: dir.join(format!("save_state_{id}")),
69 summary: dir.join(format!("step_summary_{id}")),
70 };
71 for file in [&files.output, &files.env, &files.path, &files.state, &files.summary] {
72 std::fs::write(file, "")?;
73 }
74 Ok(files)
75 }
76
77 pub(crate) fn read(path: &Path) -> String {
78 let mut text = String::new();
79 if let Ok(mut file) = std::fs::File::open(path) {
80 let _ = file.read_to_string(&mut text);
81 }
82 text
83 }
84
85 pub(crate) fn variables(&self) -> [(&'static str, String); 5] {
86 [
87 ("GITHUB_OUTPUT", self.output.display().to_string()),
88 ("GITHUB_ENV", self.env.display().to_string()),
89 ("GITHUB_PATH", self.path.display().to_string()),
90 ("GITHUB_STATE", self.state.display().to_string()),
91 ("GITHUB_STEP_SUMMARY", self.summary.display().to_string()),
92 ]
93 }
94}
95
96fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
97 let Ok(entries) = std::fs::read_dir(dir) else { return };
98 for entry in entries.flatten() {
99 let path = entry.path();
100 let Ok(kind) = entry.file_type() else { continue };
101 if kind.is_dir() {
102 if entry.file_name() == ".git" {
103 continue;
104 }
105 walk(root, &path, out);
106 } else if kind.is_file()
107 && let Ok(relative) = path.strip_prefix(root)
108 {
109 out.push(relative.to_string_lossy().replace('\\', "/"));
110 }
111 }
112}
113
114/// `hashFiles(patterns)`: the SHA-256 of the SHA-256 of each matching file
115/// in the workspace, in path order; empty when nothing matches.
116pub(crate) fn hash_files(workspace: &Path, patterns: &[String]) -> String {
117 // Patterns may be absolute under the workspace, or relative to it.
118 let prefix = format!("{}/", workspace.display());
119 let relative: Vec<String> = patterns.iter().map(|p| p.strip_prefix(&prefix).unwrap_or(p).to_owned()).collect();
120 let patterns = Patterns::new(&relative);
121 let mut files = Vec::new();
122 walk(workspace, workspace, &mut files);
123 files.sort();
124 let mut all = Sha256::new();
125 let mut any = false;
126 for file in files.iter().filter(|file| patterns.includes(file)) {
127 let Ok(bytes) = std::fs::read(workspace.join(file)) else { continue };
128 all.update(Sha256::digest(&bytes));
129 any = true;
130 }
131 if any { hex::encode(all.finalize()) } else { String::new() }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn values_and_heredocs() {
140 let text = "version=1.2.3\nnotes<<EOF\nline one\nline=two\nEOF\nempty=\n";
141 let values = key_values(text).unwrap();
142 assert_eq!(values["version"], "1.2.3");
143 assert_eq!(values["notes"], "line one\nline=two");
144 assert_eq!(values["empty"], "");
145 assert!(key_values("x<<EOF\nnever closed").is_err());
146 assert!(key_values("no equals").is_err());
147 // A value holding `<<` after its `=` is a plain value.
148 assert_eq!(key_values("cmd=a << b").unwrap()["cmd"], "a << b");
149 }
150
151 #[test]
152 fn hashes_files_by_pattern() {
153 let dir = std::env::temp_dir().join(format!("g1t-hash-{}", std::process::id()));
154 std::fs::create_dir_all(dir.join("a")).unwrap();
155 std::fs::write(dir.join("a/package-lock.json"), "{}").unwrap();
156 std::fs::write(dir.join("README.md"), "hi").unwrap();
157 let one = hash_files(&dir, &["**/package-lock.json".to_owned()]);
158 assert_eq!(one.len(), 64);
159 assert_eq!(one, hash_files(&dir, &["**/package-lock.json".to_owned()]));
160 assert_ne!(one, hash_files(&dir, &["**/*".to_owned()]));
161 assert_eq!(hash_files(&dir, &["**/Cargo.lock".to_owned()]), "");
162 let _ = std::fs::remove_dir_all(&dir);
163 }
164}