use uuid::Uuid;
use crate::error::ArtifactError;
pub const MAX_ARTIFACT_NAME_LEN: usize = 255;
pub const STORAGE_PREFIX: &str = "artifacts";
pub fn validate_artifact_name(name: &str) -> Result<(), ArtifactError> {
let reject = |reason: &'static str| {
Err(ArtifactError::InvalidName {
name: name.to_string(),
reason,
})
};
if name.is_empty() {
return reject("must not be empty");
}
if name.len() > MAX_ARTIFACT_NAME_LEN {
return reject("must be at most 255 bytes");
}
if name == "." || name == ".." {
return reject("must not be a relative path component");
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return reject("may only contain ASCII letters, digits, '.', '_' and '-'");
}
if name.starts_with('-') {
return reject("must not start with '-'");
}
Ok(())
}
pub fn storage_key(run_id: Uuid, step_id: Uuid, artifact_id: Uuid) -> String {
format!("{STORAGE_PREFIX}/{run_id}/{step_id}/{artifact_id}")
}
pub fn guess_content_type(name: &str) -> String {
mime_guess::from_path(name)
.first_raw()
.unwrap_or("application/octet-stream")
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_a_plain_file_name() {
assert!(validate_artifact_name("report.html").is_ok());
}
#[test]
fn accepts_dots_underscores_and_dashes() {
assert!(validate_artifact_name("my_build-2.tar.gz").is_ok());
}
#[test]
fn accepts_a_leading_dot() {
assert!(validate_artifact_name(".gitignore").is_ok());
}
#[test]
fn rejects_empty() {
assert!(validate_artifact_name("").is_err());
}
#[test]
fn rejects_path_traversal() {
assert!(validate_artifact_name("../../etc/passwd").is_err());
assert!(validate_artifact_name("..").is_err());
assert!(validate_artifact_name(".").is_err());
}
#[test]
fn rejects_path_separators() {
assert!(validate_artifact_name("dir/file.txt").is_err());
assert!(validate_artifact_name("dir\\file.txt").is_err());
}
#[test]
fn rejects_a_leading_dash() {
assert!(validate_artifact_name("-rf").is_err());
}
#[test]
fn rejects_unicode() {
assert!(validate_artifact_name("rapport-été.html").is_err());
}
#[test]
fn rejects_null_and_control_characters() {
assert!(validate_artifact_name("a\0b").is_err());
assert!(validate_artifact_name("a\nb").is_err());
}
#[test]
fn accepts_exactly_255_bytes_and_rejects_256() {
let ok = "a".repeat(MAX_ARTIFACT_NAME_LEN);
assert!(validate_artifact_name(&ok).is_ok());
let too_long = "a".repeat(MAX_ARTIFACT_NAME_LEN + 1);
assert!(validate_artifact_name(&too_long).is_err());
}
#[test]
fn storage_key_contains_only_uuids() {
let run = Uuid::now_v7();
let step = Uuid::now_v7();
let artifact = Uuid::now_v7();
let key = storage_key(run, step, artifact);
assert_eq!(key, format!("artifacts/{run}/{step}/{artifact}"));
assert!(!key.contains(".."));
}
#[test]
fn guesses_common_types() {
assert_eq!(guess_content_type("a.json"), "application/json");
assert_eq!(guess_content_type("a.txt"), "text/plain");
assert_eq!(guess_content_type("a.png"), "image/png");
}
#[test]
fn guesses_octet_stream_without_extension() {
assert_eq!(guess_content_type("build"), "application/octet-stream");
}
}