Skip to content
550 linesCodeBlameRaw
1//! Artifacts and the cache: `actions/upload-artifact`,
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//! A cache entry is a tar archive, compressed with zstd where the machine
7//! has it (gzip otherwise), of up to 2 GB: uploaded in parts, and
8//! downloaded straight to a file. Its `path` takes globs (`**` included)
9//! and `!` patterns that leave paths out, as `actions/cache` does. Each is
10//! saved and found under a version, the hash of its `path` and compression,
11//! and g1t keeps it under the ref whose run saved it.
12
13use std::collections::BTreeMap;
14use std::io::Read;
15use std::path::Path;
16use std::process::Command;
17use std::time::{Duration, Instant};
18
19use super::process::{self, Commands, Ended};
20use super::{Job, Post, PostRun};
21
22/// The largest artifact g1t takes at once, as the platform limits a request.
23const MAX_UPLOAD: u64 = 60 * 1024 * 1024;
24/// The largest cache entry, compressed (`g1t_contracts::actions::CACHE_MAX_ENTRY_BYTES`).
25const MAX_CACHE_ENTRY: u64 = 2 * 1024 * 1024 * 1024;
26
27fn lines(text: &str) -> Vec<String> {
28 text.lines().map(str::trim).filter(|line| !line.is_empty() && !line.starts_with('#')).map(str::to_owned).collect()
29}
30
31fn quote(text: &str) -> String {
32 format!("'{}'", text.replace('\'', "'\\''"))
33}
34
35fn safe_name(name: &str) -> bool {
36 !name.is_empty() && name.len() <= 200 && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ' ')) && !name.starts_with('.')
37}
38
39impl Job {
40 fn url(&self, rest: &str) -> String {
41 format!("{}/actions/jobs/{}/{rest}", self.log.api.base, self.log.api.job)
42 }
43
44 fn auth(&self) -> String {
45 format!("Bearer {}", self.log.api.token)
46 }
47
48 /// Runs a shell line, logging its output; whether it succeeded.
49 fn shell(&mut self, script: &str) -> bool {
50 let mut command = Command::new("bash");
51 command.args(["-c", script]).current_dir(&self.workspace);
52 let mut commands = Commands::default();
53 matches!(process::run(command, Duration::from_secs(1800), &mut self.log, &mut commands), Ok(Ended::Exited(0)))
54 }
55
56 fn upload(&mut self, rest: &str, file: &Path) -> Result<u64, String> {
57 let size = std::fs::metadata(file).map_err(|e| e.to_string())?.len();
58 if size > MAX_UPLOAD {
59 return Err(format!("it is {} MB, more than g1t takes at once ({} MB)", size / 1_048_576, MAX_UPLOAD / 1_048_576));
60 }
61 let bytes = std::fs::read(file).map_err(|e| e.to_string())?;
62 ureq::put(&self.url(rest))
63 .set("authorization", &self.auth())
64 .set("content-type", "application/gzip")
65 .timeout(Duration::from_secs(600))
66 .send_bytes(&bytes)
67 .map_err(|e| e.to_string())?;
68 Ok(size)
69 }
70
71 /// Downloads into `file`, as it comes; `Ok(None)` when there is
72 /// nothing there.
73 fn download(&mut self, rest: &str, file: &Path) -> Result<Option<String>, String> {
74 let response = match ureq::get(&self.url(rest)).set("authorization", &self.auth()).timeout(Duration::from_secs(1800)).call() {
75 Ok(response) => response,
76 Err(ureq::Error::Status(404, _)) => return Ok(None),
77 Err(error) => return Err(error.to_string()),
78 };
79 let matched = response.header("x-g1t-key").map(str::to_owned).unwrap_or_default();
80 let mut out = std::fs::File::create(file).map_err(|e| e.to_string())?;
81 std::io::copy(&mut response.into_reader().take(MAX_CACHE_ENTRY + 1024), &mut out).map_err(|e| e.to_string())?;
82 Ok(Some(matched))
83 }
84
85 /// Saves `file` as the cache entry `key`, in parts. `Ok(false)` when
86 /// the key is already cached.
87 fn upload_cache(&mut self, key: &str, version: &str, file: &Path) -> Result<bool, String> {
88 let size = std::fs::metadata(file).map_err(|e| e.to_string())?.len();
89 if size > MAX_CACHE_ENTRY {
90 return Err(format!("it is {} MB, more than a cache entry may be ({} MB)", size / 1_048_576, MAX_CACHE_ENTRY / 1_048_576));
91 }
92 let started = match ureq::post(&self.url(&format!("cache/uploads?key={}&size={size}&version={}", urlencode(key), urlencode(version))))
93 .set("authorization", &self.auth())
94 .timeout(Duration::from_secs(60))
95 .call()
96 {
97 Ok(response) => response.into_json::<serde_json::Value>().map_err(|e| e.to_string())?,
98 Err(ureq::Error::Status(409, _)) => return Ok(false),
99 Err(ureq::Error::Status(_, response)) => return Err(refusal(response)),
100 Err(error) => return Err(error.to_string()),
101 };
102 let id = started["id"].as_str().unwrap_or_default().to_owned();
103 let upload = started["upload"].as_str().unwrap_or_default().to_owned();
104 let part_bytes = started["part_bytes"].as_u64().filter(|n| *n > 0).unwrap_or(32 * 1024 * 1024);
105 let base = format!("cache/uploads/{}", urlencode(&id));
106 let sent = self.send_parts(&base, &upload, file, part_bytes);
107 let parts = match sent {
108 Ok(parts) => parts,
109 Err(error) => {
110 let _ = ureq::delete(&self.url(&format!("{base}?upload={}", urlencode(&upload)))).set("authorization", &self.auth()).call();
111 return Err(error);
112 }
113 };
114 ureq::post(&self.url(&format!("{base}/complete?upload={}", urlencode(&upload))))
115 .set("authorization", &self.auth())
116 .timeout(Duration::from_secs(120))
117 .send_json(serde_json::json!({ "size": size, "parts": parts }))
118 .map_err(|e| match e {
119 ureq::Error::Status(_, response) => refusal(response),
120 other => other.to_string(),
121 })?;
122 Ok(true)
123 }
124
125 /// Sends `file` in parts of `part_bytes`; each part's number and etag.
126 fn send_parts(&mut self, base: &str, upload: &str, file: &Path, part_bytes: u64) -> Result<Vec<serde_json::Value>, String> {
127 let mut reader = std::fs::File::open(file).map_err(|e| e.to_string())?;
128 let mut parts = Vec::new();
129 let mut buffer = vec![0u8; part_bytes as usize];
130 for number in 1u32.. {
131 let mut filled = 0;
132 while filled < buffer.len() {
133 let read = reader.read(&mut buffer[filled..]).map_err(|e| e.to_string())?;
134 if read == 0 {
135 break;
136 }
137 filled += read;
138 }
139 if filled == 0 && number > 1 {
140 break;
141 }
142 let url = self.url(&format!("{base}/{number}?upload={}", urlencode(upload)));
143 // A part that fails is sent again, twice at most.
144 let mut tries = 0;
145 let answer = loop {
146 tries += 1;
147 match ureq::put(&url).set("authorization", &self.auth()).timeout(Duration::from_secs(600)).send_bytes(&buffer[..filled]) {
148 Ok(response) => break response.into_json::<serde_json::Value>().map_err(|e| e.to_string())?,
149 Err(ureq::Error::Status(status, response)) if status < 500 => return Err(refusal(response)),
150 Err(error) if tries >= 3 => return Err(error.to_string()),
151 Err(_) => std::thread::sleep(Duration::from_secs(2 * tries)),
152 }
153 };
154 parts.push(serde_json::json!({ "part": number, "etag": answer["etag"] }));
155 if filled < buffer.len() {
156 break;
157 }
158 }
159 Ok(parts)
160 }
161
162 /// `actions/upload-artifact`.
163 pub(crate) fn upload_artifact(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
164 let name = with.get("name").filter(|n| !n.is_empty()).cloned().unwrap_or_else(|| "artifact".into());
165 if !safe_name(&name) {
166 self.log.line(&format!("##[error]`{name}` is not an artifact name g1t takes: letters, digits, spaces, `-`, `_` and `.`."));
167 return (false, BTreeMap::new());
168 }
169 let paths = lines(with.get("path").map(String::as_str).unwrap_or_default());
170 let missing = with.get("if-no-files-found").map(String::as_str).unwrap_or("warn").to_owned();
171 let archive = self.temp.join(format!("artifact-{name}.tgz"));
172 // As on GitHub: one folder uploads its contents; otherwise paths
173 // are kept relative to the workspace.
174 let single_dir = paths.len() == 1 && self.workspace.join(&paths[0]).is_dir();
175 let script = if single_dir {
176 format!("tar -czf {} -C {} .", quote(&archive.display().to_string()), quote(&paths[0]))
177 } else {
178 let patterns: Vec<String> = paths.iter().filter(|p| !p.starts_with('!')).map(|p| p.replace('\'', "")).collect();
179 format!(
180 "shopt -s globstar nullglob dotglob; files=( {} ); if [ ${{#files[@]}} -eq 0 ]; then exit 3; fi; tar -czf {} -- \"${{files[@]}}\"",
181 patterns.join(" "),
182 quote(&archive.display().to_string())
183 )
184 };
185 let mut command = Command::new("bash");
186 command.args(["-c", &script]).current_dir(&self.workspace);
187 let mut commands = Commands::default();
188 match process::run(command, Duration::from_secs(1800), &mut self.log, &mut commands) {
189 Ok(Ended::Exited(0)) => {}
190 Ok(Ended::Exited(3)) => {
191 let message = format!("No files were found at {}.", paths.join(", "));
192 return match missing.as_str() {
193 "error" => {
194 self.log.line(&format!("##[error]{message}"));
195 (false, BTreeMap::new())
196 }
197 "ignore" => (true, BTreeMap::new()),
198 _ => {
199 self.log.line(&format!("##[warning]{message} Nothing was uploaded."));
200 (true, BTreeMap::new())
201 }
202 };
203 }
204 _ => {
205 self.log.line("##[error]The files could not be packed.");
206 return (false, BTreeMap::new());
207 }
208 }
209 match self.upload(&format!("artifacts/{name}"), &archive) {
210 Ok(size) => {
211 self.log.line(&format!("Uploaded artifact {name} ({} KB). It is kept with the run for 14 days.", size.div_ceil(1024)));
212 let mut outputs = BTreeMap::new();
213 outputs.insert("artifact-id".into(), name.clone());
214 (true, outputs)
215 }
216 Err(error) => {
217 self.log.line(&format!("##[error]The artifact could not be uploaded: {error}"));
218 (false, BTreeMap::new())
219 }
220 }
221 }
222
223 /// `actions/download-artifact`: one by name, or every artifact of the
224 /// run, each into a folder of its name.
225 pub(crate) fn download_artifact(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
226 let dest = with.get("path").filter(|p| !p.is_empty()).map_or(self.workspace.clone(), |p| self.workspace.join(p));
227 let names: Vec<String> = match with.get("name").filter(|n| !n.is_empty()) {
228 Some(name) => vec![name.clone()],
229 None => {
230 let listed = ureq::get(&self.url("artifacts")).set("authorization", &self.auth()).call().ok().and_then(|r| r.into_json::<Vec<serde_json::Value>>().ok()).unwrap_or_default();
231 listed.iter().filter_map(|a| a["name"].as_str().map(str::to_owned)).collect()
232 }
233 };
234 let merge = with.get("merge-multiple").is_some_and(|m| m == "true");
235 let single = with.get("name").is_some_and(|n| !n.is_empty());
236 for name in &names {
237 let archive = self.temp.join(format!("download-{name}.tgz"));
238 match self.download(&format!("artifacts/{name}"), &archive) {
239 Ok(Some(_)) => {}
240 Ok(None) => {
241 self.log.line(&format!("##[error]This run has no artifact called {name}."));
242 return (false, BTreeMap::new());
243 }
244 Err(error) => {
245 self.log.line(&format!("##[error]The artifact {name} could not be downloaded: {error}"));
246 return (false, BTreeMap::new());
247 }
248 }
249 let target = if single || merge { dest.clone() } else { dest.join(name) };
250 let _ = std::fs::create_dir_all(&target);
251 if !self.shell(&format!("tar -xzf {} -C {}", quote(&archive.display().to_string()), quote(&target.display().to_string()))) {
252 return (false, BTreeMap::new());
253 }
254 self.log.line(&format!("Downloaded artifact {name} into {}", target.display()));
255 }
256 let mut outputs = BTreeMap::new();
257 outputs.insert("download-path".into(), dest.display().to_string());
258 (true, outputs)
259 }
260
261 /// The cache's `path`, each made absolute (`~/` is the home folder,
262 /// anything else is under the workspace), `!` patterns kept as they
263 /// came, with their `!`.
264 fn cache_paths(&self, with: &BTreeMap<String, String>) -> Vec<String> {
265 let home = self.base_env_value("HOME").unwrap_or_else(|| "/home/node".into());
266 let workspace = self.workspace.display().to_string();
267 cache_patterns(with.get("path").map(String::as_str).unwrap_or_default(), &home, &workspace)
268 }
269
270 /// `actions/cache` and `actions/cache/restore`: restores what it can,
271 /// and for `actions/cache`, saves at the end of the job on a miss.
272 pub(crate) fn cache(&mut self, with: &BTreeMap<String, String>, save_after: bool, title: &str) -> (bool, BTreeMap<String, String>) {
273 let key = with.get("key").cloned().unwrap_or_default();
274 if key.is_empty() {
275 self.log.line("##[error]The cache needs a `key`.");
276 return (false, BTreeMap::new());
277 }
278 let paths = self.cache_paths(with);
279 let version = cache_version(with.get("path").map(String::as_str).unwrap_or_default(), compression());
280 let restore = lines(with.get("restore-keys").map(String::as_str).unwrap_or_default());
281 let query = format!(
282 "cache?key={}&restore={}&version={}",
283 urlencode(&key),
284 urlencode(&restore.join("\n")),
285 urlencode(&version)
286 );
287 let archive = self.temp.join("cache-restore.tar");
288 let started = Instant::now();
289 let mut outputs = BTreeMap::new();
290 let exact = match self.download(&query, &archive) {
291 Ok(Some(matched)) => {
292 let lookup_only = with.get("lookup-only").is_some_and(|v| v == "true");
293 let size = std::fs::metadata(&archive).map(|m| m.len()).unwrap_or(0);
294 // tar finds out from the archive whether it is zstd or gzip.
295 if !lookup_only && !self.shell(&format!("tar -xPf {}", quote(&archive.display().to_string()))) {
296 self.log.line("##[warning]The cache was found but could not be unpacked.");
297 }
298 let _ = std::fs::remove_file(&archive);
299 self.log.line(&format!("Cache restored from key: {matched} ({} in {:.1}s)", megabytes(size), started.elapsed().as_secs_f64()));
300 outputs.insert("cache-matched-key".into(), matched.clone());
301 matched == key
302 }
303 Ok(None) => {
304 self.log.line(&format!("Cache not found for input keys: {}", std::iter::once(key.clone()).chain(restore).collect::<Vec<_>>().join(", ")));
305 if with.get("fail-on-cache-miss").is_some_and(|v| v == "true") {
306 self.log.line("##[error]The cache missed, and `fail-on-cache-miss` is set.");
307 return (false, outputs);
308 }
309 false
310 }
311 Err(error) => {
312 self.log.line(&format!("##[warning]The cache could not be read: {error}"));
313 false
314 }
315 };
316 outputs.insert("cache-hit".into(), exact.to_string());
317 outputs.insert("cache-primary-key".into(), key.clone());
318 if save_after && !exact {
319 self.posts.push(Post {
320 name: format!("Post {title}"),
321 condition: "success()".into(),
322 env: BTreeMap::new(),
323 run: PostRun::CacheSave { key, paths, version },
324 });
325 }
326 (true, outputs)
327 }
328
329 /// Saves paths under a key, unless the key is taken.
330 pub(crate) fn cache_save(&mut self, key: &str, paths: &[String], version: &str) -> bool {
331 if paths.iter().all(|p| p.starts_with('!')) {
332 self.log.line("##[warning]Nothing to cache: no `path`.");
333 return true;
334 }
335 let archive = self.temp.join("cache-save.tar");
336 let started = Instant::now();
337 match self.run_shell(&pack_script(paths, &archive.display().to_string())) {
338 Some(0) => {}
339 Some(3) => {
340 self.log.line("##[warning]None of the cache's paths exist; nothing was saved.");
341 return true;
342 }
343 _ => {
344 self.log.line("##[warning]The cache could not be packed; nothing was saved.");
345 return true;
346 }
347 }
348 let size = std::fs::metadata(&archive).map(|m| m.len()).unwrap_or(0);
349 let packed = started.elapsed().as_secs_f64();
350 match self.upload_cache(key, version, &archive) {
351 Ok(true) => self.log.line(&format!(
352 "Cache saved with key: {key} ({}, packed in {packed:.1}s, sent in {:.1}s)",
353 megabytes(size),
354 started.elapsed().as_secs_f64() - packed
355 )),
356 Ok(false) => self.log.line(&format!("Cache not saved: {key} is already cached.")),
357 // A cache that cannot be saved does not fail the job, as on GitHub.
358 Err(error) => self.log.line(&format!("##[warning]The cache could not be saved: {error}")),
359 }
360 let _ = std::fs::remove_file(&archive);
361 true
362 }
363
364 /// Runs a shell line, logging its output; its exit code.
365 fn run_shell(&mut self, script: &str) -> Option<i32> {
366 let mut command = Command::new("bash");
367 command.args(["-c", script]).current_dir(&self.workspace);
368 let mut commands = Commands::default();
369 match process::run(command, Duration::from_secs(1800), &mut self.log, &mut commands) {
370 Ok(Ended::Exited(code)) => Some(code),
371 _ => None,
372 }
373 }
374
375 /// `actions/cache/save`.
376 pub(crate) fn cache_save_now(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
377 let key = with.get("key").cloned().unwrap_or_default();
378 let paths = self.cache_paths(with);
379 let version = cache_version(with.get("path").map(String::as_str).unwrap_or_default(), compression());
380 (self.cache_save(&key, &paths, &version), BTreeMap::new())
381 }
382}
383
384/// The message of a refused request, from its JSON body.
385fn refusal(response: ureq::Response) -> String {
386 let status = response.status();
387 let body: serde_json::Value = response.into_json().unwrap_or_default();
388 body["error"]["message"].as_str().map_or_else(|| format!("g1t answered {status}"), str::to_owned)
389}
390
391fn megabytes(bytes: u64) -> String {
392 if bytes < 1_048_576 { format!("{} KB", bytes.div_ceil(1024)) } else { format!("{:.1} MB", bytes as f64 / 1_048_576.0) }
393}
394
395/// How this machine compresses cache entries: zstd where it has it, else
396/// gzip, as `pack_script` decides.
397fn compression() -> &'static str {
398 let zstd = Command::new("sh").args(["-c", "command -v zstd"]).output().is_ok_and(|out| out.status.success());
399 if zstd { "zstd" } else { "gzip" }
400}
401
402/// An entry's version: the hash of its `path` lines, as written, and its
403/// compression, as `actions/cache` makes one. The same key saved for other
404/// paths, or packed another way, is another entry.
405fn cache_version(path: &str, compression: &str) -> String {
406 use sha2::{Digest, Sha256};
407 let mut parts = lines(path);
408 parts.push(compression.to_owned());
409 hex::encode(Sha256::digest(parts.join("|").as_bytes()))
410}
411
412/// The lines of a cache's `path`, absolute: `~/` is `home`, a relative
413/// path is under `workspace`. A `!` pattern keeps its `!`.
414fn cache_patterns(path: &str, home: &str, workspace: &str) -> Vec<String> {
415 lines(path)
416 .into_iter()
417 .map(|line| {
418 let (bang, p) = match line.strip_prefix('!') {
419 Some(rest) => ("!", rest.trim().to_owned()),
420 None => ("", line),
421 };
422 let p = if p == "~" {
423 home.to_owned()
424 } else if let Some(rest) = p.strip_prefix("~/") {
425 format!("{home}/{rest}")
426 } else {
427 p
428 };
429 let p = if p.starts_with('/') { p } else { format!("{}/{}", workspace.trim_end_matches('/'), p.trim_start_matches("./")) };
430 format!("{bang}{}", p.trim_end_matches('/'))
431 })
432 .collect()
433}
434
435/// A path pattern as a word bash expands as a glob: everything but `*`,
436/// `?` and `[...]` escaped, so spaces and quotes stay literal.
437fn glob_word(pattern: &str) -> String {
438 let mut out = String::new();
439 for c in pattern.chars() {
440 if c.is_ascii_alphanumeric() || matches!(c, '*' | '?' | '[' | ']' | '/' | '.' | '-' | '_' | '~' | '+' | ',' | '=' | '@' | ':') {
441 out.push(c);
442 } else {
443 out.push('\\');
444 out.push(c);
445 }
446 }
447 out
448}
449
450/// The script that packs a cache: its patterns expanded (`**` reaching
451/// any depth), `!` patterns left out, into a tar archive compressed with
452/// zstd where there is one, else gzip. Exits 3 when nothing matched.
453fn pack_script(patterns: &[String], archive: &str) -> String {
454 let includes: Vec<String> = patterns.iter().filter(|p| !p.starts_with('!')).map(|p| glob_word(p)).collect();
455 let excludes: Vec<String> = patterns
456 .iter()
457 .filter_map(|p| p.strip_prefix('!'))
458 // tar's patterns: `*` already crosses `/`, so `**` is the same.
459 .map(|p| format!("--exclude={}", quote(&p.replace("**", "*"))))
460 .collect();
461 format!(
462 "set -o pipefail; shopt -s globstar nullglob dotglob; found=( {} ); files=(); \
463 for f in \"${{found[@]}}\"; do if [ -e \"$f\" ]; then files+=(\"$f\"); fi; done; \
464 if [ ${{#files[@]}} -eq 0 ]; then exit 3; fi; \
465 if command -v zstd >/dev/null; then compress='zstd -T0 -3'; else compress=gzip; fi; \
466 tar -cPf {} -I \"$compress\" {} -- \"${{files[@]}}\"",
467 includes.join(" "),
468 quote(archive),
469 excludes.join(" ")
470 )
471}
472
473fn urlencode(text: &str) -> String {
474 let mut out = String::new();
475 for byte in text.bytes() {
476 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
477 out.push(byte as char);
478 } else {
479 out.push_str(&format!("%{byte:02X}"));
480 }
481 }
482 out
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn a_cache_entrys_version_follows_its_paths_and_compression() {
491 let version = cache_version("~/.cargo/registry\ntarget", "zstd");
492 assert_eq!(version.len(), 64);
493 assert_eq!(version, cache_version("~/.cargo/registry\n\ntarget\n", "zstd"), "blank lines do not count");
494 assert_ne!(version, cache_version("~/.cargo/registry", "zstd"));
495 assert_ne!(version, cache_version("~/.cargo/registry\ntarget", "gzip"));
496 }
497
498 #[test]
499 fn cache_paths_take_home_globs_and_exclusions() {
500 let paths = cache_patterns("~/.cargo/registry/cache\ntarget/*/release/\n!target/**/incremental\n./dist\n/abs/x\n~", "/home/node", "/w/repo/");
501 assert_eq!(
502 paths,
503 ["/home/node/.cargo/registry/cache", "/w/repo/target/*/release", "!/w/repo/target/**/incremental", "/w/repo/dist", "/abs/x", "/home/node"]
504 );
505 assert_eq!(glob_word("/w/my repo/target/**/*.rlib"), "/w/my\\ repo/target/**/*.rlib");
506 assert_eq!(glob_word("/w/a'b"), "/w/a\\'b");
507 }
508
509 #[test]
510 fn packing_expands_globs_and_leaves_exclusions_out() {
511 let script = pack_script(&["/w/target/*/release".into(), "!/w/target/**/incremental".into()], "/t/c.tar");
512 assert!(script.contains("found=( /w/target/*/release )"), "{script}");
513 assert!(script.contains("--exclude='/w/target/*/incremental'"), "{script}");
514 assert!(script.contains("zstd -T0"), "{script}");
515 assert!(script.contains("exit 3"), "{script}");
516 }
517
518 /// Packs and unpacks for real, where bash and tar are (not on Windows).
519 #[test]
520 #[cfg(unix)]
521 fn a_packed_cache_unpacks_without_what_was_left_out() {
522 let dir = std::env::temp_dir().join(format!("g1t-cache-test-{}", std::process::id()));
523 let _ = std::fs::remove_dir_all(&dir);
524 for file in ["target/release/deps/a.rlib", "target/release/incremental/x.bin", "target/wasm/release/deps/b.rlib", "src/main.rs"] {
525 let path = dir.join(file);
526 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
527 std::fs::write(&path, file).unwrap();
528 }
529 let root = dir.display().to_string();
530 let patterns = cache_patterns("target/**/deps\ntarget/release/incremental\n!target/**/incremental", "/home/node", &root);
531 let archive = dir.join("c.tar").display().to_string();
532 let status = Command::new("bash").args(["-c", &pack_script(&patterns, &archive)]).status().unwrap();
533 assert!(status.success());
534 let listed = Command::new("tar").args(["-tPf", &archive]).output().unwrap();
535 let listed = String::from_utf8_lossy(&listed.stdout);
536 assert!(listed.contains("deps/a.rlib") && listed.contains("deps/b.rlib"), "{listed}");
537 assert!(!listed.contains("incremental") && !listed.contains("main.rs"), "{listed}");
538 let none = Command::new("bash").args(["-c", &pack_script(&[format!("{root}/nothing/*")], &archive)]).status().unwrap();
539 assert_eq!(none.code(), Some(3));
540 let _ = std::fs::remove_dir_all(&dir);
541 }
542
543 #[test]
544 fn keys_are_encoded_and_names_checked() {
545 assert_eq!(urlencode("Linux-node-abc/1 2"), "Linux-node-abc%2F1%202");
546 assert!(safe_name("coverage report"));
547 assert!(!safe_name("../etc"));
548 assert_eq!(lines("dist/\n\n# note\n coverage \n"), ["dist/", "coverage"]);
549 }
550}