@@ -29,44 +29,75 @@
.collect()
}
/// What an artifact-upload pass did, and anything the job's author needs to
/// be told about it.
///
/// `notices` exists because the interesting outcomes here are refusals, and a
/// refusal explained only on the runner host's stderr is invisible to the
/// person whose pipeline it affected. The caller writes these into the job log.
pub struct UploadOutcome {
pub uploaded: u32,
pub notices: Vec<String>,
}
/// Upload all artifacts matching the specs from the workspace directory.
///
/// Resolves paths relative to workspace, supports glob patterns.
/// Resolves paths relative to workspace, supports glob patterns, and confines
/// every result to the workspace (see [`confine_to_workspace`]).
/// Skips files over MAX_ARTIFACT_SIZE with a warning.
/// Returns the number of successfully uploaded artifacts.
pub async fn upload_artifacts(
config: &RunnerConfig,
job_id: &str,
workspace: &Path,
specs: &[ArtifactSpec],
) -> u32 {
) -> UploadOutcome {
let mut notices = Vec::new();
if specs.is_empty() {
return 0;
return UploadOutcome {
uploaded: 0,
notices,
};
}
// Resolve the workspace once: it is both the confinement boundary and the
// base that multi-file artifact names are relative to, and those two must
// agree or a confined path won't strip.
let Ok(root) = workspace.canonicalize() else {
notices.push(format!(
"Collected no artifacts: workspace {} does not resolve",
workspace.display()
));
return UploadOutcome {
uploaded: 0,
notices,
};
};
let client = reqwest::Client::new();
let mut uploaded = 0u32;
for spec in specs {
let resolved = resolve_paths(&root, &spec.path);
notices.extend(resolved.refusals);
let files = resolve_paths(workspace, &spec.path);
let files = resolved.files;
if files.is_empty() {
eprintln!(
" [artifacts] Warning: no files matched '{}' for artifact '{}'",
notices.push(format!(
"No files matched '{}' for artifact '{}'",
spec.path, spec.name
));
);
continue;
}
// Name from the shape of the *spec*, not from how many files happened
// to survive: a glob is always namespaced, so dropping one match can
// never silently rename the artifact its sibling is published under.
let namespaced = is_glob(&spec.path);
for file_path in &files {
let artifact_name = if files.len() > 1 {
// Multiple files: use relative path as name
let rel = file_path
.strip_prefix(workspace)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
format!("{}/{}", spec.name, rel)
let artifact_name = if namespaced {
let rel = file_path.strip_prefix(&root).unwrap_or(file_path);
format!("{}/{}", spec.name, rel.to_string_lossy())
} else {
spec.name.clone()
};
@@ -77,45 +108,156 @@
uploaded += 1;
}
Err(e) => {
eprintln!(" [artifacts] Failed to upload '{}': {}", artifact_name, e);
notices.push(format!("Failed to upload '{artifact_name}': {e}"));
}
}
}
}
for n in ¬ices {
eprintln!(" [artifacts] {n}");
uploaded
}
UploadOutcome { uploaded, notices }
}
/// Resolve a path pattern relative to workspace.
/// Supports simple glob patterns (*, **).
/// Whether a spec path is a pattern rather than a single literal file.
fn resolve_paths(workspace: &Path, pattern: &str) -> Vec<PathBuf> {
let full_pattern = workspace.join(pattern);
let pattern_str = full_pattern.to_string_lossy();
fn is_glob(pattern: &str) -> bool {
pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
}
/// Files a spec resolved to, plus the reasons anything was turned away.
// Check if it's a glob pattern
if pattern_str.contains('*') || pattern_str.contains('?') || pattern_str.contains('[') {
match glob::glob(&pattern_str) {
struct Resolved {
files: Vec<PathBuf>,
refusals: Vec<String>,
}
/// Resolve a path pattern relative to `root` (already canonicalized).
///
/// Every returned path is itself canonical and confined to the workspace —
/// see [`confine_to_workspace`].
fn resolve_paths(root: &Path, pattern: &str) -> Resolved {
let mut refusals = Vec::new();
let candidates = if is_glob(pattern) {
let full_pattern = root.join(pattern);
match glob::glob(&full_pattern.to_string_lossy()) {
Ok(paths) => paths
.filter_map(Result::ok)
.filter(|p| p.is_file())
.collect(),
Err(e) => {
eprintln!(" [artifacts] Invalid glob pattern '{}': {}", pattern, e);
refusals.push(format!("Invalid glob pattern '{pattern}': {e}"));
Vec::new()
}
}
} else {
let path = root.join(pattern);
// Literal path
let path = workspace.join(pattern);
if path.is_file() {
vec![path]
} else {
Vec::new()
}
};
let files = confine_to_workspace(root, candidates, pattern, &mut refusals);
Resolved { files, refusals }
}
/// Keep only files that really live inside the workspace, and return them in
/// their *resolved* form.
///
/// `Path::join` treats an absolute argument as a replacement, not an append:
/// `workspace.join("/etc/passwd")` is simply `/etc/passwd`, and a `../` prefix
/// escapes just as easily. Artifact specs originate in the built branch's
/// `.anvil.yml`, so without this an ordinary contributor can name any file the
/// runner account can read and have it uploaded as a build artifact.
///
/// Three distinct escapes are closed:
///
/// * **Symlinks** — both sides are canonicalized before comparing, so a link
/// planted in the workspace cannot point out of it.
/// * **Re-resolution** — the *canonical* path is what gets returned and
/// later opened. Returning the unresolved path would mean the file that is
/// read is not the file that was checked, and the workspace stays writable
/// (a container that outlived its timeout still has it bind-mounted) right
/// up until the upload.
/// * **Hardlinks** — a hardlink has no link to resolve, so it canonicalizes
/// inside the workspace and would otherwise sail through. A link count
/// above one, or a device that differs from the workspace's, means the
/// bytes may live outside the tree we are willing to publish.
///
/// `Path::starts_with` compares whole components, so a sibling directory named
/// like the workspace plus a suffix (`/work-evil` against `/work`) is not a
/// prefix match.
fn confine_to_workspace(
root: &Path,
files: Vec<PathBuf>,
pattern: &str,
refusals: &mut Vec<String>,
) -> Vec<PathBuf> {
#[cfg(unix)]
let root_dev = std::fs::metadata(root)
.map(|m| {
use std::os::unix::fs::MetadataExt;
m.dev()
})
.ok();
let mut kept = Vec::new();
for p in files {
let Ok(real) = p.canonicalize() else {
continue;
};
if !real.starts_with(root) {
refusals.push(format!(
"Refusing '{pattern}': {} is outside the job workspace",
real.display()
));
continue;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let Ok(meta) = std::fs::metadata(&real) else {
continue;
};
if meta.nlink() > 1 {
refusals.push(format!(
"Refusing '{pattern}': {} has {} hard links, so its contents \
may also exist outside the workspace — copy it instead of linking",
real.display(),
meta.nlink()
));
continue;
}
if root_dev.is_some_and(|d| d != meta.dev()) {
refusals.push(format!(
"Refusing '{pattern}': {} is on a different filesystem from the \
job workspace",
real.display()
));
continue;
}
}
kept.push(real);
}
kept
}
/// Upload a single file as a multipart form to the artifact endpoint.
///
/// The file is opened **once** and both the size check and the read are served
/// from that handle, so the bytes that are sent are the bytes that were
/// measured — a stat-then-read pair leaves a window in which the path can be
/// swapped. On Unix the open additionally refuses to follow a symlink at the
/// final component: the caller resolved this path already, so a link appearing
/// there now is something that changed underneath us.
async fn upload_single(
client: &reqwest::Client,
config: &RunnerConfig,
@@ -123,8 +265,21 @@
file_path: &Path,
artifact_name: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Check file size
let metadata = std::fs::metadata(file_path)?;
use std::io::Read;
let mut opts = std::fs::OpenOptions::new();
opts.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_NOFOLLOW);
}
let mut file = opts.open(file_path)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err("not a regular file".into());
}
if metadata.len() > MAX_ARTIFACT_SIZE {
return Err(format!(
"file too large ({} bytes, max {} bytes)",
@@ -134,11 +289,11 @@
.into());
}
let file_bytes = std::fs::read(file_path)?;
let mut file_bytes = Vec::with_capacity(metadata.len() as usize);
file.read_to_end(&mut file_bytes)?;
let url = config.api_url(&format!("/runners/jobs/{job_id}/artifacts"));
// Try multipart upload first
let file_part = multipart::Part::bytes(file_bytes)
.file_name(artifact_name.to_string())
.mime_str("application/octet-stream")?;
@@ -160,5 +315,678 @@
} else {
let body = resp.text().await.unwrap_or_default();
Err(format!("upload failed ({status}): {body}").into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::TempDir;
use serde_json::json;
use wiremock::matchers::{method, path as req_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn names(specs: &[ArtifactSpec]) -> Vec<(&str, &str)> {
specs
.iter()
.map(|s| (s.name.as_str(), s.path.as_str()))
.collect()
}
/// resolve_paths takes an already-canonical root, as upload_artifacts
/// resolves it once up front.
fn resolve(ws: &TempDir, pattern: &str) -> Resolved {
resolve_paths(&ws.canonical(), pattern)
}
fn file_names(r: &Resolved) -> Vec<String> {
let mut v: Vec<String> = r
.files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
v.sort();
v
}
// ── parse_specs ───────────────────────────────────────────────────
#[test]
fn specs_are_read_in_order_from_the_claim_response() {
let job = json!({"artifact_specs": [
{"name": "coverage", "path": "cover/"},
{"name": "logs", "path": "tmp/*.log"}
]});
let specs = parse_specs(&job);
assert_eq!(
names(&specs),
vec![("coverage", "cover/"), ("logs", "tmp/*.log")]
);
}
#[test]
fn a_job_with_no_artifact_specs_collects_nothing() {
assert!(parse_specs(&json!({})).is_empty());
assert!(parse_specs(&json!({"artifact_specs": null})).is_empty());
}
#[test]
fn a_non_array_artifact_specs_field_is_ignored_rather_than_panicking() {
// A server sending the wrong shape must not take the runner down
// mid-job — the build already succeeded by this point.
assert!(parse_specs(&json!({"artifact_specs": "cover/"})).is_empty());
assert!(parse_specs(&json!({"artifact_specs": {"name": "x"}})).is_empty());
}
#[test]
fn a_spec_missing_name_or_path_is_skipped_but_its_siblings_survive() {
// Dropping the whole list would lose good artifacts to one bad entry.
let job = json!({"artifact_specs": [
{"path": "no-name.txt"},
{"name": "no-path"},
{"name": "good", "path": "out.txt"},
{"name": 7, "path": "wrong-type.txt"}
]});
assert_eq!(names(&parse_specs(&job)), vec![("good", "out.txt")]);
}
// ── is_glob ───────────────────────────────────────────────────────
#[test]
fn glob_detection_drives_artifact_naming_so_it_must_match_resolution() {
// resolve_paths globs exactly when this says so, and upload_artifacts
// namespaces exactly when this says so. If the two disagreed, a
// pattern could resolve as a glob but publish under a literal name.
assert!(is_glob("tmp/*.log"));
assert!(is_glob("tmp/**/*.log"));
assert!(is_glob("out-?.txt"));
assert!(is_glob("out-[ab].txt"));
assert!(!is_glob("out.txt"));
assert!(!is_glob("cover/index.html"));
}
// ── resolve_paths: ordinary use ───────────────────────────────────
#[test]
fn a_literal_path_resolves_relative_to_the_workspace() {
let ws = TempDir::new("art-literal");
ws.write("out.txt", "hi");
let found = resolve(&ws, "out.txt");
assert_eq!(file_names(&found), vec!["out.txt"]);
assert!(found.refusals.is_empty(), "{:?}", found.refusals);
}
#[test]
fn resolved_paths_are_canonical_so_the_file_read_is_the_file_checked() {
// The confinement check proves things about the *resolved* path. If
// the unresolved one were returned, upload_single would re-resolve it
// at read time and the check would not cover the bytes sent.
let ws = TempDir::new("art-canonical");
ws.write("real/out.txt", "hi");
std::os::unix::fs::symlink(ws.join("real"), ws.join("link")).unwrap();
let found = resolve(&ws, "link/out.txt");
assert_eq!(found.files.len(), 1, "{:?}", found.files);
assert_eq!(
found.files[0],
found.files[0].canonicalize().unwrap(),
"resolve_paths must return an already-canonical path"
);
assert!(
found.files[0].starts_with(ws.canonical().join("real")),
"expected the resolved target, got {:?}",
found.files[0]
);
}
#[test]
fn a_literal_path_that_does_not_exist_resolves_to_nothing() {
let ws = TempDir::new("art-missing");
ws.write("out.txt", "hi");
assert!(resolve(&ws, "missing.txt").files.is_empty());
}
#[test]
fn a_directory_is_not_collected_as_a_file() {
let ws = TempDir::new("art-dir");
ws.write("cover/index.html", "<html>");
assert!(
resolve(&ws, "cover").files.is_empty(),
"a bare directory has no bytes to upload"
);
}
#[test]
fn a_glob_matches_every_file_and_skips_directories() {
let ws = TempDir::new("art-glob");
ws.write("tmp/a.log", "a");
ws.write("tmp/b.log", "b");
ws.write("tmp/c.txt", "c");
ws.write("tmp/nested/d.log", "d");
let found = resolve(&ws, "tmp/*.log");
assert_eq!(
file_names(&found),
vec!["a.log", "b.log"],
"a single star must not descend into tmp/nested"
);
}
#[test]
fn a_recursive_glob_descends_into_subdirectories() {
let ws = TempDir::new("art-globrec");
ws.write("tmp/a.log", "a");
ws.write("tmp/nested/deep/d.log", "d");
assert_eq!(resolve(&ws, "tmp/**/*.log").files.len(), 2);
}
#[test]
fn a_glob_matching_nothing_resolves_to_nothing() {
let ws = TempDir::new("art-globnone");
ws.write("out.txt", "hi");
assert!(resolve(&ws, "*.nope").files.is_empty());
}
#[test]
fn a_malformed_glob_is_refused_with_a_reason_rather_than_panicking() {
let ws = TempDir::new("art-globbad");
ws.write("out.txt", "hi");
let found = resolve(&ws, "a[");
assert!(found.files.is_empty());
assert!(
found.refusals.iter().any(|r| r.contains("Invalid glob")),
"the author needs to be told their pattern is malformed: {:?}",
found.refusals
);
}
// ── resolve_paths: confinement ────────────────────────────────────
//
// Artifact specs come from the built branch's `.anvil.yml`, so anyone who
// can open a pull request controls these strings.
#[test]
fn an_absolute_path_cannot_reach_outside_the_workspace() {
// `Path::join` REPLACES on an absolute argument, so the naive
// workspace.join("/etc/hostname") is just "/etc/hostname".
let ws = TempDir::new("art-abs");
let outside = TempDir::new("art-abs-out");
let secret = outside.write("secret.txt", "SECRET");
let found = resolve(&ws, secret.to_str().unwrap());
assert!(
found.files.is_empty(),
"an absolute artifact path escaped: {:?}",
found.files
);
assert!(
found
.refusals
.iter()
.any(|r| r.contains("outside the job workspace")),
"the refusal must be explained: {:?}",
found.refusals
);
}
#[test]
fn a_dot_dot_path_cannot_climb_out_of_the_workspace() {
let ws = TempDir::new("art-climb");
let outside = TempDir::new("art-climb-out");
let secret = outside.write("secret.txt", "SECRET");
let rel = format!(
"../{}/secret.txt",
outside.path().file_name().unwrap().to_string_lossy()
);
assert!(secret.exists());
assert!(
resolve(&ws, &rel).files.is_empty(),
"a ../ artifact path escaped the workspace"
);
}
#[test]
fn a_symlink_planted_in_the_workspace_cannot_smuggle_a_file_out() {
// Confinement has to resolve symlinks, or a build step can just
// `ln -s /etc/passwd out.txt` and name out.txt as an artifact.
let ws = TempDir::new("art-link");
let outside = TempDir::new("art-link-out");
let secret = outside.write("secret.txt", "SECRET");
ws.write("keep.txt", "ok");
std::os::unix::fs::symlink(&secret, ws.join("sneaky.txt")).unwrap();
assert!(
resolve(&ws, "sneaky.txt").files.is_empty(),
"a symlink out of the workspace was collected"
);
assert_eq!(
resolve(&ws, "keep.txt").files.len(),
1,
"confinement must not break ordinary files"
);
}
#[test]
fn a_hardlink_to_a_file_outside_the_workspace_is_refused() {
// A hardlink has no link to resolve, so it canonicalizes *inside* the
// workspace and sails through a symlink-only check. Same attacker,
// same result: bytes from outside the tree get published.
let ws = TempDir::new("art-hard");
let outside = TempDir::new("art-hard-out");
let secret = outside.write("secret.txt", "SECRET");
// Same filesystem (both under the temp dir), so the link is allowed
// to be created — which is exactly the situation being defended.
std::fs::hard_link(&secret, ws.join("out.txt")).expect("hard link within one filesystem");
let found = resolve(&ws, "out.txt");
assert!(
found.files.is_empty(),
"a hardlinked outside file was collected: {:?}",
found.files
);
assert!(
found.refusals.iter().any(|r| r.contains("hard link")),
"the refusal must name the reason so the author can copy instead: {:?}",
found.refusals
);
}
#[test]
fn a_sibling_directory_sharing_the_workspaces_name_prefix_is_not_inside_it() {
// `/work-evil` must not count as inside `/work`. Rust's
// `Path::starts_with` is component-wise, which is what makes this
// safe — a naive string prefix check would not be.
let base = TempDir::new("art-prefix");
let ws = base.join("work");
std::fs::create_dir_all(&ws).unwrap();
let evil = base.join("work-evil");
std::fs::create_dir_all(&evil).unwrap();
std::fs::write(evil.join("secret.txt"), "SECRET").unwrap();
let root = ws.canonicalize().unwrap();
let found = resolve_paths(&root, evil.join("secret.txt").to_str().unwrap());
assert!(
found.files.is_empty(),
"a sibling with a shared name prefix was treated as inside: {:?}",
found.files
);
}
#[test]
fn a_glob_still_collects_files_reached_through_an_in_workspace_symlinked_dir() {
// Confinement resolves symlinks, so a legitimate symlinked build
// directory *inside* the workspace must still be collected.
let ws = TempDir::new("art-linkdir");
ws.write("real/out.log", "data");
std::os::unix::fs::symlink(ws.join("real"), ws.join("link")).unwrap();
assert_eq!(resolve(&ws, "link/*.log").files.len(), 1);
}
// ── upload_artifacts ──────────────────────────────────────────────
fn config_for(server: &MockServer) -> RunnerConfig {
RunnerConfig::for_test(&server.uri(), "http://ollama.invalid")
}
async fn accepting_server() -> MockServer {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(201))
.mount(&server)
.await;
server
}
#[tokio::test]
async fn no_specs_means_no_requests_and_no_uploads() {
let server = MockServer::start().await;
let ws = TempDir::new("art-nospec");
ws.write("out.txt", "hi");
let out = upload_artifacts(&config_for(&server), "job-1", ws.path(), &[]).await;
assert_eq!(out.uploaded, 0);
assert!(
server.received_requests().await.unwrap().is_empty(),
"an empty spec list must not touch the network"
);
}
#[tokio::test]
async fn an_unresolvable_workspace_uploads_nothing_and_says_why() {
let server = MockServer::start().await;
let missing = std::env::temp_dir().join("anvil-artifacts-definitely-absent");
let specs = vec![ArtifactSpec {
name: "x".into(),
path: "out.txt".into(),
}];
let out = upload_artifacts(&config_for(&server), "job-1", &missing, &specs).await;
assert_eq!(out.uploaded, 0);
assert!(
out.notices.iter().any(|n| n.contains("does not resolve")),
"{:?}",
out.notices
);
}
#[tokio::test]
async fn a_single_match_uploads_under_the_spec_name_to_the_job_endpoint() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(req_path("/api/v1/runners/jobs/job-42/artifacts"))
.respond_with(ResponseTemplate::new(201))
.expect(1)
.mount(&server)
.await;
let ws = TempDir::new("art-single");
ws.write("out.txt", "hi");
let specs = vec![ArtifactSpec {
name: "report".into(),
path: "out.txt".into(),
}];
let out = upload_artifacts(&config_for(&server), "job-42", ws.path(), &specs).await;
assert_eq!(out.uploaded, 1);
let reqs = server.received_requests().await.unwrap();
let body = String::from_utf8_lossy(&reqs[0].body);
assert!(
body.contains("report"),
"the multipart body must carry the artifact name, got: {body}"
);
assert_eq!(
reqs[0].headers.get("authorization").unwrap(),
"Bearer rt_secret",
"artifact upload must authenticate as the runner"
);
}
#[tokio::test]
async fn several_matches_are_namespaced_by_relative_path_so_they_do_not_collide() {
let server = accepting_server().await;
let ws = TempDir::new("art-multi");
ws.write("tmp/a.log", "a");
ws.write("tmp/b.log", "b");
let specs = vec![ArtifactSpec {
name: "logs".into(),
path: "tmp/*.log".into(),
}];
let out = upload_artifacts(&config_for(&server), "job-1", ws.path(), &specs).await;
assert_eq!(out.uploaded, 2);
let joined: String = server
.received_requests()
.await
.unwrap()
.iter()
.map(|r| String::from_utf8_lossy(&r.body).to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(
joined.contains("logs/tmp/a.log") && joined.contains("logs/tmp/b.log"),
"multi-file artifacts must keep distinct names, got: {joined}"
);
}
#[tokio::test]
async fn a_glob_that_matches_one_file_still_publishes_the_namespaced_name() {
// The name comes from the spec's shape, not the surviving count.
// Deriving it from `files.len() > 1` meant that dropping a sibling —
// a symlink refused by confinement, or a file deleted mid-job —
// silently renamed the survivor from `logs/tmp/a.log` to `logs`, and
// any downstream fetch by the documented name 404s.
let server = accepting_server().await;
let ws = TempDir::new("art-onematch");
ws.write("tmp/a.log", "a");
let specs = vec![ArtifactSpec {
name: "logs".into(),
path: "tmp/*.log".into(),
}];
let out = upload_artifacts(&config_for(&server), "job-1", ws.path(), &specs).await;
assert_eq!(out.uploaded, 1);
let body =
String::from_utf8_lossy(&server.received_requests().await.unwrap()[0].body).to_string();
assert!(
body.contains("logs/tmp/a.log"),
"a glob must publish a namespaced name even when it matches once: {body}"
);
}
#[tokio::test]
async fn refusing_one_match_does_not_rename_its_surviving_sibling() {
let server = accepting_server().await;
let ws = TempDir::new("art-sibling");
let outside = TempDir::new("art-sibling-out");
let secret = outside.write("secret", "SECRET");
ws.write("tmp/a.log", "a");
std::os::unix::fs::symlink(&secret, ws.join("tmp/b.log")).unwrap();
let specs = vec![ArtifactSpec {
name: "logs".into(),
path: "tmp/*.log".into(),
}];
let out = upload_artifacts(&config_for(&server), "job-1", ws.path(), &specs).await;
assert_eq!(out.uploaded, 1, "only the legitimate file uploads");
let body =
String::from_utf8_lossy(&server.received_requests().await.unwrap()[0].body).to_string();
assert!(
body.contains("logs/tmp/a.log"),
"the survivor keeps its name, got: {body}"
);
}
#[tokio::test]
async fn a_spec_matching_nothing_is_reported_and_does_not_fail_the_others() {
let server = accepting_server().await;
let ws = TempDir::new("art-ghost");
ws.write("out.txt", "hi");
let specs = vec![
ArtifactSpec {
name: "ghost".into(),
path: "never-written.txt".into(),
},
ArtifactSpec {
name: "real".into(),
path: "out.txt".into(),
},
];
let out = upload_artifacts(&config_for(&server), "job-1", ws.path(), &specs).await;
assert_eq!(out.uploaded, 1, "the surviving spec must still upload");
assert!(
out.notices
.iter()
.any(|n| n.contains("never-written.txt") && n.contains("ghost")),
"the empty spec must be named in the job log: {:?}",
out.notices
);
}
#[tokio::test]
async fn a_refusal_is_surfaced_as_a_notice_for_the_job_log() {
// Explaining the refusal only on the runner host's stderr leaves the
// pipeline author with artifacts that silently stopped appearing.
let server = accepting_server().await;
let ws = TempDir::new("art-notice");
let outside = TempDir::new("art-notice-out");
let secret = outside.write("secret.txt", "SECRET");
let specs = vec![ArtifactSpec {
name: "secrets".into(),
path: secret.to_string_lossy().to_string(),
}];
let out = upload_artifacts(&config_for(&server), "job-1", ws.path(), &specs).await;
assert_eq!(out.uploaded, 0);
assert!(
out.notices
.iter()
.any(|n| n.contains("Refusing") && n.contains("outside the job workspace")),
"the reason must reach the caller, got: {:?}",
out.notices
);
}
#[tokio::test]
async fn a_rejected_upload_is_not_counted_and_is_reported() {
// The count is what the operator sees; reporting a success the
// server refused would hide a broken artifact pipeline entirely.
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(413).set_body_string("too large"))
.mount(&server)
.await;
let ws = TempDir::new("art-rejected");
ws.write("out.txt", "hi");
let specs = vec![ArtifactSpec {
name: "report".into(),
path: "out.txt".into(),
}];
let out = upload_artifacts(&config_for(&server), "job-1", ws.path(), &specs).await;
assert_eq!(out.uploaded, 0);
assert!(
out.notices.iter().any(|n| n.contains("Failed to upload")),
"{:?}",
out.notices
);
}
#[tokio::test]
async fn one_failed_upload_does_not_abandon_the_remaining_artifacts() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.up_to_n_times(1)
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(201))
.mount(&server)
.await;
let ws = TempDir::new("art-partial");
ws.write("tmp/a.log", "a");
ws.write("tmp/b.log", "b");
let specs = vec![ArtifactSpec {
name: "logs".into(),
path: "tmp/*.log".into(),
}];
let out = upload_artifacts(&config_for(&server), "job-1", ws.path(), &specs).await;
assert_eq!(
out.uploaded, 1,
"the second artifact must still be attempted"
);
}
#[tokio::test]
async fn a_file_over_the_size_cap_is_refused_before_it_is_sent() {
// The cap exists to protect the server; checking it after the upload
// would defeat the point.
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(201))
.expect(0)
.mount(&server)
.await;
let ws = TempDir::new("art-toobig");
let big = ws.join("big.bin");
let f = std::fs::File::create(&big).unwrap();
f.set_len(MAX_ARTIFACT_SIZE + 1).unwrap();
drop(f);
let specs = vec![ArtifactSpec {
name: "big".into(),
path: "big.bin".into(),
}];
let out = upload_artifacts(&config_for(&server), "job-1", ws.path(), &specs).await;
assert_eq!(
out.uploaded, 0,
"an oversized artifact must not be uploaded"
);
}
#[tokio::test]
async fn upload_single_refuses_a_symlink_that_appears_after_resolution() {
// The last line of defence for the resolve/read window: even handed a
// path that has since become a symlink, the upload must not follow it.
let server = accepting_server().await;
let ws = TempDir::new("art-nofollow");
let outside = TempDir::new("art-nofollow-out");
let secret = outside.write("secret.txt", "SECRET");
let swapped = ws.join("out.txt");
std::os::unix::fs::symlink(&secret, &swapped).unwrap();
let err = upload_single(
&reqwest::Client::new(),
&config_for(&server),
"job-1",
&swapped,
"report",
)
.await
.expect_err("a symlinked final component must not be followed");
assert!(
server.received_requests().await.unwrap().is_empty(),
"nothing may be sent: {err}"
);
}
#[tokio::test]
async fn an_absolute_spec_path_uploads_nothing() {
// End-to-end companion to the resolve_paths confinement tests: the
// escape must be closed on the path the runner actually walks.
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(201))
.expect(0)
.mount(&server)
.await;
let ws = TempDir::new("art-e2e");
ws.write("out.txt", "hi");
let outside = TempDir::new("art-e2e-out");
let secret = outside.write("secret.txt", "SECRET");
let specs = vec![ArtifactSpec {
name: "secrets".into(),
path: secret.to_string_lossy().to_string(),
}];
let out = upload_artifacts(&config_for(&server), "job-1", ws.path(), &specs).await;
assert_eq!(
out.uploaded, 0,
"a file outside the workspace must never be uploaded"
);
}
}