Skip to content
807 linesCodeBlameRaw
1//! Artifacts and the cache: `actions/upload-artifact`, its `merge`,
2//! `actions/download-artifact` and `actions/cache`, done natively against
3//! g1t. Artifacts belong to the run; cache entries to the repository, found
4//! by exact key or by the newest under a `restore-keys` prefix.
5//!
6//! An artifact is a ZIP file, as GitHub's v4 actions make it (zip.rs), of
7//! the files its `path` finds (glob.rs): uploaded in parts with its SHA-256,
8//! and downloaded straight to a file before it is unpacked.
9//!
10//! A cache entry is a tar archive, compressed with zstd where the machine
11//! has it (gzip otherwise), of up to 2 GB: uploaded in parts, and
12//! downloaded straight to a file. Its `path` takes globs (`**` included)
13//! and `!` patterns that leave paths out, as `actions/cache` does. Each is
14//! saved and found under a version, the hash of its `path` and compression,
15//! and g1t keeps it under the ref whose run saved it.
16
17use std::collections::BTreeMap;
18use std::io::{Read, Write};
19use std::path::Path;
20use std::process::Command;
21use std::time::{Duration, Instant};
22
23use sha2::{Digest, Sha256};
24
25use super::process::{self, Commands, Ended};
26use super::{Job, glob, zip};
27
28
29/// How often a long transfer says how far it has got.
30const PROGRESS_EVERY: Duration = Duration::from_secs(5);
31
32/// A transfer's progress as GitHub's cache action words it:
33/// `Received 46137344 of 523190823 (8.8%), 43.9 MBs/sec`.
34fn progress(verb: &str, done: u64, total: Option<u64>, took: Duration) -> String {
35 let rate = done as f64 / 1_048_576.0 / took.as_secs_f64().max(0.001);
36 match total.filter(|total| *total > 0) {
37 Some(total) => format!("{verb} {done} of {total} ({:.1}%), {rate:.1} MBs/sec", done as f64 * 100.0 / total as f64),
38 None => format!("{verb} {done} bytes, {rate:.1} MBs/sec"),
39 }
40}
41
42fn lines(text: &str) -> Vec<String> {
43 text.lines().map(str::trim).filter(|line| !line.is_empty() && !line.starts_with('#')).map(str::to_owned).collect()
44}
45
46fn quote(text: &str) -> String {
47 format!("'{}'", text.replace('\'', "'\\''"))
48}
49
50impl Job {
51 fn url(&self, rest: &str) -> String {
52 format!("{}/actions/jobs/{}/{rest}", self.log.api.base, self.log.api.job)
53 }
54
55 fn auth(&self) -> String {
56 format!("Bearer {}", self.log.api.token)
57 }
58
59 /// Runs a shell line, logging its output; whether it succeeded.
60 fn shell(&mut self, script: &str) -> bool {
61 let mut command = Command::new("bash");
62 command.args(["-c", script]).current_dir(&self.workspace);
63 let mut commands = Commands::default();
64 matches!(process::run(command, Duration::from_secs(1800).min(self.remaining_time()), &mut self.log, &mut commands), Ok(Ended::Exited(0)))
65 }
66
67 /// Reads a download to its end, handing `each` every piece, and says
68 /// how it is going every few seconds as GitHub's cache and artifact
69 /// actions do (`Received 46137344 of 523190823 (8.8%), 43.9 MBs/sec`),
70 /// so a long download is never a silent step. Its total in bytes.
71 fn receive(&mut self, reader: &mut dyn Read, total: Option<u64>, mut each: impl FnMut(&[u8]) -> std::io::Result<()>) -> Result<u64, String> {
72 let started = Instant::now();
73 let mut told = Instant::now();
74 let mut received = 0u64;
75 let mut buffer = vec![0u8; 256 * 1024];
76 loop {
77 let n = reader.read(&mut buffer).map_err(|e| e.to_string())?;
78 if n == 0 {
79 break;
80 }
81 each(&buffer[..n]).map_err(|e| e.to_string())?;
82 received += n as u64;
83 if told.elapsed() >= PROGRESS_EVERY {
84 told = Instant::now();
85 self.log.line(&progress("Received", received, total, started.elapsed()));
86 }
87 self.log.tick();
88 }
89 Ok(received)
90 }
91
92 /// Sends `file` in parts of `part_bytes`; each part's number and etag.
93 fn send_parts(&mut self, base: &str, upload: &str, file: &Path, part_bytes: u64) -> Result<Vec<serde_json::Value>, String> {
94 let mut reader = std::fs::File::open(file).map_err(|e| e.to_string())?;
95 let size = reader.metadata().map(|m| m.len()).unwrap_or(0);
96 let (started, mut told, mut sent) = (Instant::now(), Instant::now(), 0u64);
97 let mut parts = Vec::new();
98 let mut buffer = vec![0u8; part_bytes as usize];
99 for number in 1u32.. {
100 let mut filled = 0;
101 while filled < buffer.len() {
102 let read = reader.read(&mut buffer[filled..]).map_err(|e| e.to_string())?;
103 if read == 0 {
104 break;
105 }
106 filled += read;
107 }
108 if filled == 0 && number > 1 {
109 break;
110 }
111 let url = self.url(&format!("{base}/{number}?upload={}", urlencode(upload)));
112 // A part that fails is sent again, twice at most.
113 let mut tries = 0;
114 let answer = loop {
115 tries += 1;
116 match ureq::put(&url).set("authorization", &self.auth()).timeout(Duration::from_secs(600)).send_bytes(&buffer[..filled]) {
117 Ok(response) => break response.into_json::<serde_json::Value>().map_err(|e| e.to_string())?,
118 Err(ureq::Error::Status(status, response)) if status < 500 => return Err(refusal(response)),
119 Err(error) if tries >= 3 => return Err(error.to_string()),
120 Err(_) => std::thread::sleep(Duration::from_secs(2 * tries)),
121 }
122 };
123 parts.push(serde_json::json!({ "part": number, "etag": answer["etag"] }));
124 sent += filled as u64;
125 if filled < buffer.len() {
126 break;
127 }
128 // More to send: say how far it has got, as a download does.
129 if told.elapsed() >= PROGRESS_EVERY {
130 told = Instant::now();
131 self.log.line(&progress("Sent", sent, Some(size), started.elapsed()));
132 }
133 self.log.tick();
134 }
135 Ok(parts)
136 }
137
138 /// A value of the `github` context, as text.
139 fn github_value(&self, key: &str) -> String {
140 match self.contexts.get("github").and_then(|github| github.get(key)) {
141 Some(serde_json::Value::String(text)) => text.clone(),
142 Some(serde_json::Value::Null) | None => String::new(),
143 Some(other) => other.to_string(),
144 }
145 }
146
147 fn home(&self) -> String {
148 self.base_env_value("HOME").unwrap_or_else(|| "/home/node".into())
149 }
150
151 /// `actions/upload-artifact`: the files `path` names, packed into one
152 /// ZIP and uploaded in parts.
153 pub(crate) fn upload_artifact(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
154 let name = input(with, "name").unwrap_or("artifact").to_owned();
155 let missing = input(with, "if-no-files-found").unwrap_or("warn").to_ascii_lowercase();
156 let checked = check_name(&name)
157 .and_then(|()| keep(with, true))
158 .and_then(|keep| if matches!(missing.as_str(), "warn" | "error" | "ignore") { Ok(keep) } else { Err(format!("`if-no-files-found` is `{missing}`; it takes warn, error or ignore.")) });
159 let keep = match checked {
160 Ok(keep) => keep,
161 Err(message) => {
162 self.log.line(&format!("##[error]{message}"));
163 return (false, BTreeMap::new());
164 }
165 };
166 let paths = lines(input(with, "path").unwrap_or_default());
167 let found = glob::find(&paths, &self.workspace.display().to_string(), &self.home(), keep.hidden);
168 if found.files.is_empty() {
169 let message = format!("No files were found with the provided path: {}. No artifacts will be uploaded.", paths.join(", "));
170 return match missing.as_str() {
171 "error" => {
172 self.log.line(&format!("##[error]{message}"));
173 (false, BTreeMap::new())
174 }
175 "ignore" => {
176 self.log.line(&message);
177 (true, BTreeMap::new())
178 }
179 _ => {
180 self.log.line(&format!("##[warning]{message}"));
181 (true, BTreeMap::new())
182 }
183 };
184 }
185 self.log.line(&format!("Found {} with the provided path, stored relative to {}.", count(found.files.len(), "file"), found.root));
186 self.send_artifact(&name, &found.files, &keep)
187 }
188
189 /// Packs `files` into a ZIP and uploads it as the artifact `name`;
190 /// whether it worked, and the outputs `upload-artifact` sets.
191 fn send_artifact(&mut self, name: &str, files: &[(String, std::path::PathBuf)], keep: &Keep) -> (bool, BTreeMap<String, String>) {
192 let archive = self.temp.join(format!("artifact-{}.zip", super::rand_id()));
193 let sent = zip::write(&archive, files, keep.level)
194 .map_err(|e| format!("The files could not be packed: {e}"))
195 .and_then(|packed| self.post_artifact(name, &archive, keep).map(|done| (packed, done)));
196 let _ = std::fs::remove_file(&archive);
197 let (packed, done) = match sent {
198 Ok(sent) => sent,
199 Err(error) => {
200 self.log.line(&format!("##[error]{error}"));
201 return (false, BTreeMap::new());
202 }
203 };
204 let kept = done.retention.map(|days| format!(" It is kept for {}.", count(days as usize, "day"))).unwrap_or_default();
205 self.log.line(&format!("Uploaded artifact {name} ({}, {}).{kept}", megabytes(packed.size), count(packed.files, "file")));
206 let url = format!(
207 "{}/{}/actions/runs/{}/artifacts/{}",
208 self.github_value("server_url").trim_end_matches('/'),
209 self.github_value("repository"),
210 self.github_value("run_id"),
211 done.id
212 );
213 self.log.line(&format!("Artifact download URL: {url}"));
214 let mut outputs = BTreeMap::new();
215 outputs.insert("artifact-id".into(), done.id.to_string());
216 outputs.insert("artifact-url".into(), url);
217 outputs.insert("artifact-digest".into(), done.digest);
218 (true, outputs)
219 }
220
221 /// Uploads the ZIP file `archive` as the artifact `name`, in parts,
222 /// giving the upload up when a part or the end fails.
223 fn post_artifact(&mut self, name: &str, archive: &Path, keep: &Keep) -> Result<Sent, String> {
224 let size = std::fs::metadata(archive).map_err(|e| e.to_string())?.len();
225 let digest = sha256_file(archive).map_err(|e| e.to_string())?;
226 let query = format!(
227 "artifacts/uploads?name={}&size={size}&retention_days={}&overwrite={}&format=zip",
228 urlencode(name),
229 keep.retention,
230 keep.overwrite
231 );
232 let failed = |e: ureq::Error| match e {
233 ureq::Error::Status(_, response) => format!("The artifact could not be uploaded: {}", refusal(response)),
234 other => format!("The artifact could not be uploaded: {other}"),
235 };
236 let started = ureq::post(&self.url(&query))
237 .set("authorization", &self.auth())
238 .timeout(Duration::from_secs(60))
239 .call()
240 .map_err(failed)?
241 .into_json::<serde_json::Value>()
242 .map_err(|e| e.to_string())?;
243 let id = number(&started["id"]).ok_or("g1t did not say which artifact the upload is")?;
244 let upload = started["upload"].as_str().unwrap_or_default().to_owned();
245 let part_bytes = started["part_bytes"].as_u64().filter(|n| *n > 0).unwrap_or(32 * 1024 * 1024);
246 let retention = started["retention_days"].as_u64();
247 if let Some(applied) = retention
248 && keep.retention != 0
249 && applied != u64::from(keep.retention)
250 {
251 self.log.line(&format!(
252 "##[notice]The artifact is kept for {}, not the {} asked for: the most this repository keeps artifacts.",
253 count(applied as usize, "day"),
254 keep.retention
255 ));
256 }
257 let base = format!("artifacts/uploads/{id}");
258 let give_up = |job: &Job| {
259 let _ = ureq::delete(&job.url(&format!("{base}?upload={}", urlencode(&upload)))).set("authorization", &job.auth()).call();
260 };
261 let parts = match self.send_parts(&base, &upload, archive, part_bytes) {
262 Ok(parts) => parts,
263 Err(error) => {
264 give_up(self);
265 return Err(format!("The artifact could not be uploaded: {error}"));
266 }
267 };
268 let done = ureq::post(&self.url(&format!("{base}/complete?upload={}", urlencode(&upload))))
269 .set("authorization", &self.auth())
270 .timeout(Duration::from_secs(120))
271 .send_json(serde_json::json!({ "size": size, "parts": parts, "digest": format!("sha256:{digest}") }));
272 let done = match done {
273 Ok(response) => response.into_json::<serde_json::Value>().unwrap_or_default(),
274 Err(error) => {
275 give_up(self);
276 return Err(failed(error));
277 }
278 };
279 Ok(Sent { id: number(&done["id"]).unwrap_or(id), digest, retention: retention.map(|d| d as u32).or((keep.retention != 0).then_some(keep.retention)) })
280 }
281
282 /// The artifacts of this run, or of the run `run_id` of this
283 /// repository; one per name, the newest.
284 fn list_artifacts(&mut self, run_id: Option<&str>) -> Result<Vec<Listed>, String> {
285 let rest = match run_id {
286 Some(run) => format!("artifacts?run_id={}", urlencode(run)),
287 None => "artifacts".to_owned(),
288 };
289 let listed = ureq::get(&self.url(&rest))
290 .set("authorization", &self.auth())
291 .timeout(Duration::from_secs(60))
292 .call()
293 .map_err(|e| match e {
294 ureq::Error::Status(_, response) => refusal(response),
295 other => other.to_string(),
296 })?
297 .into_json::<Vec<serde_json::Value>>()
298 .map_err(|e| e.to_string())?;
299 Ok(newest(listed.iter().filter_map(Listed::from_json).collect()))
300 }
301
302 /// Downloads an artifact to a file, as it comes, and unpacks it into
303 /// `into`.
304 fn fetch_artifact(&mut self, artifact: &Listed, into: &Path) -> Result<(), String> {
305 let file = self.temp.join(format!("download-{}.zip", super::rand_id()));
306 let fetched = self.fetch_to(artifact, &file).and_then(|format| {
307 if format == "tgz" {
308 std::fs::create_dir_all(into).map_err(|e| e.to_string())?;
309 let script = format!("tar{} -xzf {} -C {}", tar_local(), quote(&file.display().to_string()), quote(&into.display().to_string()));
310 if self.shell(&script) { Ok(()) } else { Err("it could not be unpacked".into()) }
311 } else {
312 zip::extract(&file, into).map(|_| ()).map_err(|e| format!("it could not be unpacked: {e}"))
313 }
314 });
315 let _ = std::fs::remove_file(&file);
316 fetched
317 }
318
319 /// Downloads an artifact into `file`, checking its digest; how it is
320 /// packed, `zip` or `tgz`.
321 fn fetch_to(&mut self, artifact: &Listed, file: &Path) -> Result<String, String> {
322 let response = match ureq::get(&self.url(&format!("artifacts/{}/download", artifact.id)))
323 .set("authorization", &self.auth())
324 .timeout(Duration::from_secs(4 * 3600))
325 .call()
326 {
327 Ok(response) => response,
328 Err(ureq::Error::Status(404, _)) => return Err("it is gone: it expired or was deleted".into()),
329 Err(ureq::Error::Status(_, response)) => return Err(refusal(response)),
330 Err(error) => return Err(error.to_string()),
331 };
332 let format = response.header("x-g1t-format").map(str::to_owned).unwrap_or_else(|| artifact.format.clone());
333 let total = response.header("content-length").and_then(|n| n.parse::<u64>().ok()).or(Some(artifact.size));
334 let mut reader = response.into_reader();
335 let mut out = std::io::BufWriter::new(std::fs::File::create(file).map_err(|e| e.to_string())?);
336 let mut hasher = Sha256::new();
337 self.receive(&mut reader, total, |bytes| {
338 hasher.update(bytes);
339 out.write_all(bytes)
340 })?;
341 out.flush().map_err(|e| e.to_string())?;
342 let got = hex::encode(hasher.finalize());
343 if let Some(expected) = artifact.digest.as_deref().map(|d| d.trim_start_matches("sha256:"))
344 && !expected.is_empty()
345 && !expected.eq_ignore_ascii_case(&got)
346 {
347 self.log.line(&format!("##[warning]Artifact {} does not match its digest (sha256:{got}, not sha256:{expected}): it may have been changed since it was uploaded.", artifact.name));
348 }
349 Ok(format)
350 }
351
352 /// Where artifacts are downloaded: `path`, under the workspace unless
353 /// absolute, `~` the home folder.
354 fn download_dir(&self, path: Option<&str>) -> std::path::PathBuf {
355 match path {
356 None => self.workspace.clone(),
357 Some(path) => {
358 let home = self.home();
359 let path = if path == "~" {
360 home
361 } else if let Some(rest) = path.strip_prefix("~/") {
362 format!("{}/{rest}", home.trim_end_matches('/'))
363 } else {
364 path.to_owned()
365 };
366 self.workspace.join(path)
367 }
368 }
369 }
370
371 /// `actions/download-artifact`: one artifact by name straight into
372 /// `path`, or the run's artifacts (by `pattern` or `artifact-ids`) each
373 /// into a folder of its name, or all into `path` with `merge-multiple`.
374 pub(crate) fn download_artifact(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
375 let fail = |job: &mut Job, message: &str| {
376 job.log.line(&format!("##[error]{message}"));
377 (false, BTreeMap::new())
378 };
379 let name = input(with, "name");
380 let ids = match artifact_ids(input(with, "artifact-ids").unwrap_or_default()) {
381 Ok(ids) => ids,
382 Err(message) => return fail(self, &message),
383 };
384 if name.is_some() && !ids.is_empty() {
385 return fail(self, "Inputs 'name' and 'artifact-ids' cannot be used together. Please specify only one.");
386 }
387 let merge = match flag(with, "merge-multiple", false) {
388 Ok(merge) => merge,
389 Err(message) => return fail(self, &message),
390 };
391 let dest = self.download_dir(input(with, "path"));
392 // Another run's artifacts, as v4 has it: with a token and a run id.
393 let run_id = match (input(with, "github-token"), input(with, "run-id")) {
394 (Some(_), Some(run)) => {
395 let own = self.github_value("repository");
396 let repository = input(with, "repository").unwrap_or(&own);
397 if !repository.eq_ignore_ascii_case(&own) {
398 return fail(self, &format!("g1t downloads artifacts from other runs of the same repository only; {repository} is not this run's repository, {own}."));
399 }
400 Some(run.to_owned())
401 }
402 _ => None,
403 };
404 let listed = match self.list_artifacts(run_id.as_deref()) {
405 Ok(listed) => listed,
406 Err(error) => return fail(self, &format!("The artifacts could not be listed: {error}")),
407 };
408 let chosen: Vec<Listed> = if let Some(name) = name {
409 match listed.into_iter().find(|a| a.name == name) {
410 Some(artifact) => vec![artifact],
411 None => return fail(self, &format!("Unable to download artifact(s): Artifact not found for name: {name}")),
412 }
413 } else if !ids.is_empty() {
414 let chosen: Vec<Listed> = listed.into_iter().filter(|a| ids.contains(&a.id)).collect();
415 if chosen.is_empty() {
416 return fail(self, "Unable to download artifact(s): None of the provided artifact IDs were found.");
417 }
418 if chosen.len() < ids.len() {
419 self.log.line(&format!("##[warning]Could only find {} of {} artifact IDs.", chosen.len(), ids.len()));
420 }
421 chosen
422 } else {
423 match input(with, "pattern") {
424 Some(pattern) => listed.into_iter().filter(|a| glob::matches_part(pattern, &a.name)).collect(),
425 None => listed,
426 }
427 };
428 let mut outputs = BTreeMap::new();
429 outputs.insert("download-path".into(), dest.display().to_string());
430 if chosen.is_empty() {
431 match input(with, "pattern") {
432 Some(pattern) => self.log.line(&format!("No artifacts matched the pattern {pattern}; nothing was downloaded.")),
433 None => self.log.line("The run has no artifacts; nothing was downloaded."),
434 }
435 return (true, outputs);
436 }
437 let targets = download_targets(&dest, &chosen, name.is_some(), !ids.is_empty(), merge);
438 for (artifact, target) in chosen.iter().zip(targets) {
439 let Some(target) = target else {
440 return fail(self, &format!("The artifact {} cannot be downloaded into a folder of its name.", artifact.name));
441 };
442 if let Err(error) = self.fetch_artifact(artifact, &target) {
443 return fail(self, &format!("The artifact {} could not be downloaded: {error}", artifact.name));
444 }
445 self.log.line(&format!("Downloaded artifact {} ({}) into {}", artifact.name, megabytes(artifact.size), target.display()));
446 }
447 if chosen.len() > 1 {
448 self.log.line(&format!("Downloaded {}.", count(chosen.len(), "artifact")));
449 }
450 (true, outputs)
451 }
452
453 /// `actions/upload-artifact/merge`: the run's artifacts matching
454 /// `pattern`, downloaded together and uploaded again as one.
455 pub(crate) fn merge_artifacts(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
456 let fail = |job: &mut Job, message: &str| {
457 job.log.line(&format!("##[error]{message}"));
458 (false, BTreeMap::new())
459 };
460 let name = input(with, "name").unwrap_or("merged-artifacts").to_owned();
461 let pattern = input(with, "pattern").unwrap_or("*").to_owned();
462 let options = check_name(&name).and_then(|()| {
463 let keep = keep(with, false)?;
464 Ok((keep, flag(with, "separate-directories", false)?, flag(with, "delete-merged", false)?))
465 });
466 let (keep, separate, delete) = match options {
467 Ok(options) => options,
468 Err(message) => return fail(self, &message),
469 };
470 let listed = match self.list_artifacts(None) {
471 Ok(listed) => listed,
472 Err(error) => return fail(self, &format!("The artifacts could not be listed: {error}")),
473 };
474 let chosen: Vec<Listed> = listed.into_iter().filter(|a| glob::matches_part(&pattern, &a.name)).collect();
475 if chosen.is_empty() {
476 return fail(self, &format!("No artifacts found matching pattern '{pattern}'."));
477 }
478 self.log.line(&format!("Merging {}: {}", count(chosen.len(), "artifact"), chosen.iter().map(|a| a.name.as_str()).collect::<Vec<_>>().join(", ")));
479 let folder = self.temp.join(format!("merge-{}", super::rand_id()));
480 let targets = download_targets(&folder, &chosen, false, false, !separate);
481 let mut result = None;
482 for (artifact, target) in chosen.iter().zip(targets) {
483 let Some(target) = target else {
484 result = Some(fail(self, &format!("The artifact {} cannot be merged into a folder of its name.", artifact.name)));
485 break;
486 };
487 if let Err(error) = self.fetch_artifact(artifact, &target) {
488 result = Some(fail(self, &format!("The artifact {} could not be downloaded: {error}", artifact.name)));
489 break;
490 }
491 }
492 let result = result.unwrap_or_else(|| {
493 let root = folder.display().to_string();
494 let found = glob::find(std::slice::from_ref(&root), &root, &self.home(), keep.hidden);
495 if found.files.is_empty() {
496 fail(self, "The artifacts matched hold no files to merge.")
497 } else {
498 self.send_artifact(&name, &found.files, &keep)
499 }
500 });
501 let _ = std::fs::remove_dir_all(&folder);
502 if result.0 && delete {
503 for artifact in &chosen {
504 match ureq::delete(&self.url(&format!("artifacts/{}", artifact.id))).set("authorization", &self.auth()).timeout(Duration::from_secs(60)).call() {
505 Ok(_) => self.log.line(&format!("Deleted artifact {}.", artifact.name)),
506 Err(ureq::Error::Status(_, response)) => self.log.line(&format!("##[warning]Artifact {} could not be deleted: {}", artifact.name, refusal(response))),
507 Err(error) => self.log.line(&format!("##[warning]Artifact {} could not be deleted: {error}", artifact.name)),
508 }
509 }
510 }
511 result
512 }
513
514}
515
516/// An input, trimmed; `None` when empty.
517fn input<'a>(with: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
518 with.get(key).map(|v| v.trim()).filter(|v| !v.is_empty())
519}
520
521/// A true-or-false input, as `core.getBooleanInput` reads it.
522fn flag(with: &BTreeMap<String, String>, key: &str, default: bool) -> Result<bool, String> {
523 match input(with, key) {
524 None => Ok(default),
525 Some("true" | "True" | "TRUE") => Ok(true),
526 Some("false" | "False" | "FALSE") => Ok(false),
527 Some(other) => Err(format!("`{key}` is `{other}`; it takes true or false.")),
528 }
529}
530
531/// `1 file`, `3 files`.
532fn count(n: usize, what: &str) -> String {
533 if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") }
534}
535
536/// An id that came as a number or as text.
537fn number(value: &serde_json::Value) -> Option<u64> {
538 value.as_u64().or_else(|| value.as_str().and_then(|s| s.parse().ok()))
539}
540
541/// Whether a name is one GitHub takes for an artifact: not empty, at most
542/// 256 characters, none of `" : < > | * ? \ /` or a line break.
543fn check_name(name: &str) -> Result<(), String> {
544 if name.is_empty() {
545 return Err("The artifact needs a name.".into());
546 }
547 if name.chars().count() > 256 {
548 return Err(format!("The artifact name `{name}` is longer than 256 characters."));
549 }
550 if let Some(bad) = name.chars().find(|c| matches!(c, '"' | ':' | '<' | '>' | '|' | '*' | '?' | '\r' | '\n' | '\\' | '/')) {
551 let shown = match bad {
552 '\r' => "a carriage return".to_owned(),
553 '\n' => "a line break".to_owned(),
554 c => format!("`{c}`"),
555 };
556 return Err(format!("The artifact name `{name}` is not valid: it contains {shown}. A name may not contain \" : < > | * ? \\ / or line breaks."));
557 }
558 Ok(())
559}
560
561/// How an artifact is packed and kept, from the inputs `upload-artifact`
562/// and its merge share.
563#[derive(Debug, PartialEq)]
564struct Keep {
565 /// Days; 0 for the repository's own setting.
566 retention: u32,
567 /// 0 (stored) to 9.
568 level: u32,
569 overwrite: bool,
570 /// Whether files and folders whose names start with `.` are taken.
571 hidden: bool,
572}
573
574fn keep(with: &BTreeMap<String, String>, takes_overwrite: bool) -> Result<Keep, String> {
575 let retention = match input(with, "retention-days") {
576 None => 0,
577 Some(text) => match text.parse::<u32>() {
578 Ok(days @ 0..=90) => days,
579 _ => return Err(format!("`retention-days` is `{text}`; it takes a number of days from 1 to 90, or nothing for the repository's setting.")),
580 },
581 };
582 let level = match input(with, "compression-level") {
583 None => 6,
584 Some(text) => match text.parse::<u32>() {
585 Ok(level @ 0..=9) => level,
586 _ => return Err(format!("`compression-level` is `{text}`; it takes 0 (no compression) to 9.")),
587 },
588 };
589 Ok(Keep {
590 retention,
591 level,
592 overwrite: takes_overwrite && flag(with, "overwrite", false)?,
593 hidden: flag(with, "include-hidden-files", false)?,
594 })
595}
596
597/// What finishing an upload gave: the artifact's id, the ZIP's SHA-256 in
598/// hex, and the days it is kept, when known.
599struct Sent {
600 id: u64,
601 digest: String,
602 retention: Option<u32>,
603}
604
605/// An artifact as g1t lists it.
606#[derive(Debug, Clone, PartialEq)]
607struct Listed {
608 id: u64,
609 name: String,
610 size: u64,
611 digest: Option<String>,
612 /// `zip`, or `tgz` for an artifact an older runner stored.
613 format: String,
614}
615
616impl Listed {
617 fn from_json(value: &serde_json::Value) -> Option<Listed> {
618 Some(Listed {
619 id: number(&value["id"])?,
620 name: value["name"].as_str()?.to_owned(),
621 size: value["size"].as_u64().unwrap_or(0),
622 digest: value["digest"].as_str().map(str::to_owned),
623 format: value["format"].as_str().unwrap_or("zip").to_owned(),
624 })
625 }
626}
627
628/// One artifact per name, the newest (highest id), in name order.
629fn newest(listed: Vec<Listed>) -> Vec<Listed> {
630 let mut by_name: BTreeMap<String, Listed> = BTreeMap::new();
631 for artifact in listed {
632 if by_name.get(&artifact.name).is_none_or(|kept| kept.id < artifact.id) {
633 by_name.insert(artifact.name.clone(), artifact);
634 }
635 }
636 by_name.into_values().collect()
637}
638
639/// `artifact-ids`: numbers, separated by commas.
640fn artifact_ids(text: &str) -> Result<Vec<u64>, String> {
641 text.split(',')
642 .map(str::trim)
643 .filter(|id| !id.is_empty())
644 .map(|id| id.parse::<u64>().map_err(|_| format!("`artifact-ids` takes artifact ids, numbers separated by commas; `{id}` is not one.")))
645 .collect()
646}
647
648/// The folder each artifact is unpacked into: `dest` itself for one
649/// artifact by name, for `merge-multiple`, and for a single artifact by
650/// id; otherwise a folder of the artifact's name in `dest`. `None` for a
651/// name that cannot be a folder.
652fn download_targets(dest: &Path, chosen: &[Listed], by_name: bool, by_ids: bool, merge: bool) -> Vec<Option<std::path::PathBuf>> {
653 let straight = by_name || merge || (by_ids && chosen.len() == 1);
654 chosen
655 .iter()
656 .map(|artifact| {
657 if straight {
658 Some(dest.to_path_buf())
659 } else if artifact.name.is_empty() || artifact.name == "." || artifact.name == ".." || artifact.name.contains(['/', '\\']) {
660 None
661 } else {
662 Some(dest.join(&artifact.name))
663 }
664 })
665 .collect()
666}
667
668/// The SHA-256 of a file, in hex, read as it comes.
669fn sha256_file(path: &Path) -> std::io::Result<String> {
670 let mut file = std::fs::File::open(path)?;
671 let mut hasher = Sha256::new();
672 let mut buffer = vec![0u8; 256 * 1024];
673 loop {
674 let n = file.read(&mut buffer)?;
675 if n == 0 {
676 break;
677 }
678 hasher.update(&buffer[..n]);
679 }
680 Ok(hex::encode(hasher.finalize()))
681}
682
683/// The message of a refused request, from its JSON body.
684fn refusal(response: ureq::Response) -> String {
685 let status = response.status();
686 let body: serde_json::Value = response.into_json().unwrap_or_default();
687 body["error"]["message"].as_str().map_or_else(|| format!("g1t answered {status}"), str::to_owned)
688}
689
690fn megabytes(bytes: u64) -> String {
691 if bytes < 1_048_576 { format!("{} KB", bytes.div_ceil(1024)) } else { format!("{:.1} MB", bytes as f64 / 1_048_576.0) }
692}
693
694/// GNU tar on Windows reads `C:...` as a host and a file; `--force-local`
695/// makes every path a local one. Elsewhere the flag is left off, since
696/// bsdtar (macOS) doesn't know it.
697pub(crate) fn tar_local() -> &'static str {
698 if cfg!(windows) {
699 " --force-local"
700 } else {
701 ""
702 }
703}
704
705fn urlencode(text: &str) -> String {
706 let mut out = String::new();
707 for byte in text.bytes() {
708 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
709 out.push(byte as char);
710 } else {
711 out.push_str(&format!("%{byte:02X}"));
712 }
713 }
714 out
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720
721 #[test]
722 fn transfers_say_how_far_they_have_got_as_githubs_do() {
723 assert_eq!(progress("Received", 46_137_344, Some(523_190_823), Duration::from_secs(1)), "Received 46137344 of 523190823 (8.8%), 44.0 MBs/sec");
724 assert_eq!(progress("Sent", 1_048_576, None, Duration::from_secs(2)), "Sent 1048576 bytes, 0.5 MBs/sec");
725 assert_eq!(progress("Received", 10, Some(0), Duration::ZERO), "Received 10 bytes, 0.0 MBs/sec");
726 }
727
728 #[test]
729 fn keys_are_encoded_and_names_checked() {
730 assert_eq!(urlencode("Linux-node-abc/1 2"), "Linux-node-abc%2F1%202");
731 assert!(check_name("coverage report").is_ok());
732 assert!(check_name("dist-linux_x64.1 (debug)").is_ok());
733 assert!(check_name("ünïcødé").is_ok());
734 assert!(check_name(&"a".repeat(256)).is_ok());
735 assert!(check_name(&"a".repeat(257)).is_err());
736 assert!(check_name("").is_err());
737 for bad in ["a/b", "a\\b", "a:b", "a*b", "a?b", "a\"b", "a<b", "a>b", "a|b", "a\nb", "a\rb"] {
738 assert!(check_name(bad).is_err(), "{bad}");
739 }
740 assert_eq!(lines("dist/\n\n# note\n coverage \n"), ["dist/", "coverage"]);
741 }
742
743 fn with(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
744 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
745 }
746
747 #[test]
748 fn upload_inputs_are_read_as_v4_reads_them() {
749 assert_eq!(keep(&with(&[]), true).unwrap(), Keep { retention: 0, level: 6, overwrite: false, hidden: false });
750 let set = with(&[("retention-days", "14"), ("compression-level", "0"), ("overwrite", "true"), ("include-hidden-files", "True")]);
751 assert_eq!(keep(&set, true).unwrap(), Keep { retention: 14, level: 0, overwrite: true, hidden: true });
752 // The merge takes no `overwrite`.
753 assert!(!keep(&set, false).unwrap().overwrite);
754 assert_eq!(keep(&with(&[("retention-days", "0")]), true).unwrap().retention, 0);
755 assert!(keep(&with(&[("retention-days", "91")]), true).is_err());
756 assert!(keep(&with(&[("retention-days", "-1")]), true).is_err());
757 assert!(keep(&with(&[("retention-days", "two")]), true).is_err());
758 assert!(keep(&with(&[("compression-level", "10")]), true).is_err());
759 assert!(keep(&with(&[("overwrite", "yes")]), true).is_err());
760 assert_eq!(artifact_ids(" 12, 34 ,,").unwrap(), [12, 34]);
761 assert!(artifact_ids("12,abc").is_err());
762 assert_eq!(count(1, "file"), "1 file");
763 assert_eq!(count(37, "file"), "37 files");
764 }
765
766 fn listed(id: u64, name: &str) -> Listed {
767 Listed { id, name: name.into(), size: 0, digest: None, format: "zip".into() }
768 }
769
770 #[test]
771 fn listings_read_and_keep_the_newest_of_a_name() {
772 let json = serde_json::json!([
773 { "id": 1, "name": "dist", "size": 10, "digest": null, "format": "tgz", "created_at": "x", "expires_at": "y" },
774 { "id": "3", "name": "dist", "size": 30, "digest": "sha256:ab", "format": "zip" },
775 { "id": 2, "name": "logs", "size": 5 },
776 { "name": "no id" }
777 ]);
778 let all: Vec<Listed> = json.as_array().unwrap().iter().filter_map(Listed::from_json).collect();
779 assert_eq!(all.len(), 3);
780 assert_eq!(all[0].format, "tgz");
781 let kept = newest(all);
782 assert_eq!(kept.iter().map(|a| (a.id, a.name.as_str())).collect::<Vec<_>>(), [(3, "dist"), (2, "logs")]);
783 assert_eq!(kept[0].digest.as_deref(), Some("sha256:ab"));
784 }
785
786 #[test]
787 fn downloads_land_where_v4_puts_them() {
788 let dest = Path::new("/w/out");
789 let one = [listed(1, "dist")];
790 let two = [listed(1, "dist"), listed(2, "logs")];
791 let at = |targets: Vec<Option<std::path::PathBuf>>| targets.into_iter().map(|t| t.unwrap()).collect::<Vec<_>>();
792 // By name: straight into `path`.
793 assert_eq!(at(download_targets(dest, &one, true, false, false)), [dest.to_path_buf()]);
794 // Every artifact, or by pattern: a folder each.
795 assert_eq!(at(download_targets(dest, &two, false, false, false)), [dest.join("dist"), dest.join("logs")]);
796 assert_eq!(at(download_targets(dest, &one, false, false, false)), [dest.join("dist")]);
797 // merge-multiple: all into `path`.
798 assert_eq!(at(download_targets(dest, &two, false, false, true)), [dest.to_path_buf(), dest.to_path_buf()]);
799 // By id: one straight into `path`, several a folder each.
800 assert_eq!(at(download_targets(dest, &one, false, true, false)), [dest.to_path_buf()]);
801 assert_eq!(at(download_targets(dest, &two, false, true, false)), [dest.join("dist"), dest.join("logs")]);
802 // A name that is no folder is refused.
803 assert!(download_targets(dest, &[listed(1, ".."), listed(2, "x")], false, false, false)[0].is_none());
804 assert!(glob::matches_part("dist-*", "dist-linux"));
805 assert!(!glob::matches_part("dist-*", "logs"));
806 }
807}