1use std::{
7 ffi::OsStr,
8 fs::{self, File, OpenOptions},
9 io::{self, BufReader, BufWriter, Read, Write},
10 path::{Path, PathBuf},
11 sync::atomic::{AtomicU64, Ordering},
12 time::{SystemTime, UNIX_EPOCH},
13};
14
15use crate::run::{CommandOutput, CommandSpec};
16
17static RUN_SEQUENCE: AtomicU64 = AtomicU64::new(0);
18
19#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct RunLogSpec {
26 pub root: PathBuf,
27 pub label: String,
28 pub artifacts: Vec<PathBuf>,
29}
30
31impl RunLogSpec {
32 pub fn new(root: impl Into<PathBuf>, label: impl Into<String>) -> Self {
33 Self {
34 root: root.into(),
35 label: label.into(),
36 artifacts: Vec::new(),
37 }
38 }
39
40 pub fn artifact(mut self, path: impl Into<PathBuf>) -> Self {
41 self.artifacts.push(path.into());
42 self
43 }
44
45 pub fn artifacts<I, P>(mut self, paths: I) -> Self
46 where
47 I: IntoIterator<Item = P>,
48 P: Into<PathBuf>,
49 {
50 self.artifacts.extend(paths.into_iter().map(Into::into));
51 self
52 }
53}
54
55struct ArtifactCopy {
56 source: PathBuf,
57 input: PathBuf,
58 output: PathBuf,
59}
60
61pub(crate) struct ActiveRunLog {
62 directory: PathBuf,
63 artifacts: Vec<ArtifactCopy>,
64}
65
66impl ActiveRunLog {
67 pub(crate) fn start(spec: &CommandSpec, settings: &RunLogSpec) -> io::Result<Self> {
68 let directory = unique_directory(&settings.root, &settings.label)?;
69 fs::create_dir_all(directory.join("inputs"))?;
70 fs::create_dir_all(directory.join("outputs"))?;
71
72 let mut invocation = BufWriter::new(File::create(directory.join("invocation.txt"))?);
73 writeln!(invocation, "command: {}", spec.display_command())?;
74 writeln!(invocation, "argv:")?;
75 writeln!(invocation, " [0] {:?}", spec.program)?;
76 for (index, argument) in spec.arguments.iter().enumerate() {
77 writeln!(invocation, " [{}] {:?}", index + 1, argument)?;
78 }
79 writeln!(
80 invocation,
81 "working directory: {}",
82 spec.current_dir
83 .as_deref()
84 .map(Path::display)
85 .map(|value| value.to_string())
86 .unwrap_or_else(|| "(inherited)".to_owned())
87 )?;
88 writeln!(
89 invocation,
90 "timeout seconds: {}",
91 spec.timeout
92 .map(|value| value.as_secs_f64().to_string())
93 .unwrap_or_else(|| "none".to_owned())
94 )?;
95 writeln!(
96 invocation,
97 "environment policy: {:?}",
98 spec.environment_policy
99 )?;
100 if spec.environment.is_empty() {
101 writeln!(invocation, "environment overrides: none")?;
102 } else {
103 writeln!(invocation, "environment overrides:")?;
104 for (name, value) in &spec.environment {
105 writeln!(invocation, " {:?}={:?}", name, value)?;
106 }
107 }
108 invocation.flush()?;
109
110 if let Some(stdin) = &spec.stdin {
111 fs::write(directory.join("stdin.txt"), stdin)?;
112 }
113
114 let mut artifacts = Vec::new();
115 let mut manifest = BufWriter::new(File::create(directory.join("artifacts.txt"))?);
116 for (index, source) in settings.artifacts.iter().enumerate() {
117 let source = absolute_artifact(source, spec.current_dir.as_deref())?;
118 let name = source
119 .file_name()
120 .and_then(OsStr::to_str)
121 .filter(|name| !name.is_empty())
122 .unwrap_or("artifact");
123 let entry = format!("{index:02}-{}", safe_component(name));
124 let input = directory.join("inputs").join(&entry);
125 let output = directory.join("outputs").join(&entry);
126 writeln!(manifest, "{entry}: {}", source.display())?;
127 if source.exists() || fs::symlink_metadata(&source).is_ok() {
128 copy_path(&source, &input)?;
129 } else {
130 writeln!(manifest, " absent before launch")?;
131 }
132 artifacts.push(ArtifactCopy {
133 source,
134 input,
135 output,
136 });
137 }
138 manifest.flush()?;
139
140 Ok(Self {
141 directory,
142 artifacts,
143 })
144 }
145
146 pub(crate) fn directory(&self) -> &Path {
147 &self.directory
148 }
149
150 pub(crate) fn stdout_file(&self) -> io::Result<File> {
151 File::create(self.directory.join("stdout.txt"))
152 }
153
154 pub(crate) fn stderr_file(&self) -> io::Result<File> {
155 File::create(self.directory.join("stderr.txt"))
156 }
157
158 pub(crate) fn record_start_error(&self, error: &io::Error) -> io::Result<()> {
159 fs::write(
160 self.directory.join("error.txt"),
161 format!("The command could not be started: {error}\n"),
162 )?;
163 self.write_combined_log(None, Some(&error.to_string()))
164 }
165
166 pub(crate) fn finish(&self, output: &CommandOutput, timed_out: bool) -> io::Result<()> {
167 let mut deleted = Vec::new();
168 for artifact in &self.artifacts {
169 copy_changes(
170 &artifact.source,
171 &artifact.input,
172 &artifact.output,
173 Path::new(""),
174 &mut deleted,
175 )?;
176 }
177 if !deleted.is_empty() {
178 fs::write(
179 self.directory.join("deleted_files.txt"),
180 deleted.join("\n") + "\n",
181 )?;
182 }
183
184 let status = if timed_out {
185 "timed out".to_owned()
186 } else {
187 output
188 .return_code()
189 .map(|code| format!("exit code {code}"))
190 .unwrap_or_else(|| "terminated without an exit code".to_owned())
191 };
192 fs::write(
193 self.directory.join("result.txt"),
194 format!(
195 "status: {status}\nelapsed seconds: {}\n",
196 output.elapsed.as_secs_f64()
197 ),
198 )?;
199 self.write_combined_log(Some(output), None)
200 }
201
202 fn write_combined_log(
203 &self,
204 output: Option<&CommandOutput>,
205 start_error: Option<&str>,
206 ) -> io::Result<()> {
207 let mut combined = BufWriter::new(File::create(self.directory.join("run.log"))?);
208 copy_section(
209 &mut combined,
210 "INVOCATION",
211 &self.directory.join("invocation.txt"),
212 )?;
213 if self.directory.join("stdin.txt").is_file() {
214 copy_section(&mut combined, "STDIN", &self.directory.join("stdin.txt"))?;
215 } else {
216 writeln!(
217 combined,
218 "\n===== STDIN =====\n(not supplied; the child received EOF)"
219 )?;
220 }
221 if let Some(output) = output {
222 writeln!(
223 combined,
224 "\n===== RESULT =====\nreturn code: {}\nelapsed seconds: {}",
225 output
226 .return_code()
227 .map(|code| code.to_string())
228 .unwrap_or_else(|| "none".to_owned()),
229 output.elapsed.as_secs_f64()
230 )?;
231 } else if let Some(error) = start_error {
232 writeln!(combined, "\n===== RESULT =====\nstart error: {error}")?;
233 }
234 copy_section(&mut combined, "STDOUT", &self.directory.join("stdout.txt"))?;
235 copy_section(&mut combined, "STDERR", &self.directory.join("stderr.txt"))?;
236 combined.flush()
237 }
238}
239
240fn unique_directory(root: &Path, label: &str) -> io::Result<PathBuf> {
241 let tool_root = root.join(safe_component(label));
242 fs::create_dir_all(&tool_root)?;
243 let timestamp = SystemTime::now()
244 .duration_since(UNIX_EPOCH)
245 .unwrap_or_default()
246 .as_millis();
247 for _ in 0..100 {
248 let sequence = RUN_SEQUENCE.fetch_add(1, Ordering::Relaxed);
249 let path = tool_root.join(format!("{timestamp}-{}-{sequence}", std::process::id()));
250 match fs::create_dir(&path) {
251 Ok(()) => return Ok(path),
252 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
253 Err(error) => return Err(error),
254 }
255 }
256 Err(io::Error::new(
257 io::ErrorKind::AlreadyExists,
258 "could not allocate a unique run-log directory",
259 ))
260}
261
262fn safe_component(value: &str) -> String {
263 let cleaned: String = value
264 .chars()
265 .map(|character| {
266 if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
267 character
268 } else {
269 '_'
270 }
271 })
272 .collect();
273 if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
274 "command".to_owned()
275 } else {
276 cleaned
277 }
278}
279
280fn absolute_artifact(path: &Path, current_dir: Option<&Path>) -> io::Result<PathBuf> {
281 if path.is_absolute() {
282 return Ok(path.to_owned());
283 }
284 if let Some(current_dir) = current_dir {
285 return Ok(current_dir.join(path));
286 }
287 Ok(std::env::current_dir()?.join(path))
288}
289
290fn copy_path(source: &Path, destination: &Path) -> io::Result<()> {
291 let metadata = fs::symlink_metadata(source)?;
292 if metadata.file_type().is_symlink() {
293 return copy_symlink(source, destination);
294 }
295 if metadata.is_dir() {
296 fs::create_dir_all(destination)?;
297 for entry in fs::read_dir(source)? {
298 let entry = entry?;
299 copy_path(&entry.path(), &destination.join(entry.file_name()))?;
300 }
301 return Ok(());
302 }
303 if metadata.is_file() {
304 if let Some(parent) = destination.parent() {
305 fs::create_dir_all(parent)?;
306 }
307 fs::copy(source, destination)?;
308 }
309 Ok(())
310}
311
312fn copy_changes(
313 source: &Path,
314 input: &Path,
315 output: &Path,
316 relative: &Path,
317 deleted: &mut Vec<String>,
318) -> io::Result<bool> {
319 let source_metadata = fs::symlink_metadata(source).ok();
320 let input_metadata = fs::symlink_metadata(input).ok();
321
322 let Some(source_metadata) = source_metadata else {
323 if input_metadata.is_some() {
324 deleted.push(relative.to_string_lossy().into_owned());
325 }
326 return Ok(false);
327 };
328
329 if source_metadata.is_dir() {
330 let mut changed = false;
331 for entry in fs::read_dir(source)? {
332 let entry = entry?;
333 let name = entry.file_name();
334 changed |= copy_changes(
335 &entry.path(),
336 &input.join(&name),
337 &output.join(&name),
338 &relative.join(&name),
339 deleted,
340 )?;
341 }
342 if input_metadata.is_some_and(|metadata| metadata.is_dir()) {
343 for entry in fs::read_dir(input)? {
344 let entry = entry?;
345 if fs::symlink_metadata(source.join(entry.file_name())).is_err() {
346 deleted.push(
347 relative
348 .join(entry.file_name())
349 .to_string_lossy()
350 .into_owned(),
351 );
352 }
353 }
354 }
355 return Ok(changed);
356 }
357
358 let unchanged = match input_metadata {
359 Some(metadata)
360 if metadata.file_type().is_symlink() && source_metadata.file_type().is_symlink() =>
361 {
362 fs::read_link(source)? == fs::read_link(input)?
363 }
364 Some(metadata) if metadata.is_file() && source_metadata.is_file() => {
365 files_equal(source, input)?
366 }
367 _ => false,
368 };
369 if unchanged {
370 return Ok(false);
371 }
372 copy_path(source, output)?;
373 Ok(true)
374}
375
376fn files_equal(left: &Path, right: &Path) -> io::Result<bool> {
377 if fs::metadata(left)?.len() != fs::metadata(right)?.len() {
378 return Ok(false);
379 }
380 let mut left = BufReader::new(File::open(left)?);
381 let mut right = BufReader::new(File::open(right)?);
382 let mut left_buffer = [0_u8; 64 * 1024];
383 let mut right_buffer = [0_u8; 64 * 1024];
384 loop {
385 let left_count = left.read(&mut left_buffer)?;
386 let right_count = right.read(&mut right_buffer)?;
387 if left_count != right_count || left_buffer[..left_count] != right_buffer[..right_count] {
388 return Ok(false);
389 }
390 if left_count == 0 {
391 return Ok(true);
392 }
393 }
394}
395
396fn copy_section(writer: &mut impl Write, name: &str, path: &Path) -> io::Result<()> {
397 writeln!(writer, "\n===== {name} =====")?;
398 if path.is_file() {
399 io::copy(&mut File::open(path)?, writer)?;
400 } else {
401 writeln!(writer, "(not available)")?;
402 }
403 Ok(())
404}
405
406#[cfg(unix)]
407fn copy_symlink(source: &Path, destination: &Path) -> io::Result<()> {
408 use std::os::unix::fs::symlink;
409 if let Some(parent) = destination.parent() {
410 fs::create_dir_all(parent)?;
411 }
412 symlink(fs::read_link(source)?, destination)
413}
414
415#[cfg(windows)]
416fn copy_symlink(source: &Path, destination: &Path) -> io::Result<()> {
417 use std::os::windows::fs::{symlink_dir, symlink_file};
418 if let Some(parent) = destination.parent() {
419 fs::create_dir_all(parent)?;
420 }
421 let target = fs::read_link(source)?;
422 if source.is_dir() {
423 symlink_dir(target, destination)
424 } else {
425 symlink_file(target, destination)
426 }
427}
428
429pub(crate) fn append_log_error(directory: &Path, action: &str, error: &io::Error) {
430 let _ = OpenOptions::new()
431 .create(true)
432 .append(true)
433 .open(directory.join("logging_errors.txt"))
434 .and_then(|mut file| writeln!(file, "{action}: {error}"));
435}