a73x

src/server/releases.rs

Ref:   Size: 26.1 KiB   History

//! Filesystem-backed release artifact store.
//!
//! Layout: `<releases_dir>/<version>/<filename>` plus `<filename>.sha256`
//! (sha256sum-compatible: "<hex>  <filename>\n"). No manifest — the
//! filesystem is the index.
//!
//! Concurrency note: concurrent SSH sessions are not serialized against
//! each other. A single session handles its uploads one at a time, but two
//! separate connections can force-upload the same version/filename at the
//! same time; the last `finish()` to persist wins and the `.sha256`
//! companion may transiently mismatch between the two racing writers. This
//! case is accepted and unguarded — there is no locking across sessions.
//! Both the artifact and its `.sha256` companion are written via
//! temp-file-then-rename, so a reader (e.g. an HTTP download) never
//! observes a partially-written or truncated file for either — only a
//! fully-old or fully-new version, whichever `finish()` happened to win.
//!
//! Crash note: uploads stream into a hidden `.tmp` file inside the version
//! directory, removed on drop. If the process dies mid-upload that file
//! survives. It is never listed or downloadable (listing skips dotfiles),
//! but it does pin an otherwise-empty version directory into existence.
//! There is no reaper — such leftovers need manual cleanup.

use std::io::Write;
use std::path::{Path, PathBuf};

use serde::Serialize;
use sha2::{Digest, Sha256};
use tempfile::NamedTempFile;

use git_collab::release::validate_name;

pub const DEFAULT_MAX_RELEASE_SIZE: u64 = 1024 * 1024 * 1024; // 1 GiB

#[derive(Debug, thiserror::Error)]
pub enum ReleaseError {
    #[error("invalid name: {0}")]
    InvalidName(String),
    #[error("{0} already exists (use --force to replace)")]
    AlreadyExists(String),
    #[error("file exceeds maximum release size ({0} bytes)")]
    TooLarge(u64),
    #[error("not found: {0}")]
    NotFound(String),
    #[error(transparent)]
    Io(#[from] std::io::Error),
}

/// The releases directory for a repo entry: `<gitdir>/collab/releases`.
pub fn releases_dir(entry: &crate::repos::RepoEntry) -> PathBuf {
    if entry.bare {
        entry.path.join("collab").join("releases")
    } else {
        entry.path.join(".git").join("collab").join("releases")
    }
}

/// An in-progress streamed upload. Bytes go to a temp file in the version
/// directory; `finish()` atomically renames into place and writes the
/// `.sha256` companion. Dropping without `finish()` cleans up the temp file.
pub struct ReleaseUpload {
    temp: NamedTempFile,
    hasher: Sha256,
    written: u64,
    max_size: u64,
    dest: PathBuf,
    sha_dest: PathBuf,
    version: String,
    filename: String,
    force: bool,
}

impl ReleaseUpload {
    pub fn begin(
        releases_dir: &Path,
        version: &str,
        filename: &str,
        force: bool,
        max_size: u64,
    ) -> Result<Self, ReleaseError> {
        if !validate_name(version) {
            return Err(ReleaseError::InvalidName(version.to_string()));
        }
        if !validate_name(filename) {
            return Err(ReleaseError::InvalidName(filename.to_string()));
        }
        // `<name>.sha256` is this store's checksum companion namespace.
        // Accepting an upload with that suffix would let a --force upload of
        // `app.tar.gz.sha256` overwrite the real companion of `app.tar.gz`
        // with arbitrary content.
        if filename.ends_with(".sha256") {
            return Err(ReleaseError::InvalidName(format!(
                "{}: .sha256 names are reserved for checksums",
                filename
            )));
        }
        let version_dir = releases_dir.join(version);
        std::fs::create_dir_all(&version_dir)?;
        let dest = version_dir.join(filename);
        if dest.exists() && !force {
            return Err(ReleaseError::AlreadyExists(format!(
                "{}/{}",
                version, filename
            )));
        }
        let temp = NamedTempFile::new_in(&version_dir)?;
        let sha_dest = version_dir.join(format!("{}.sha256", filename));
        Ok(Self {
            temp,
            hasher: Sha256::new(),
            written: 0,
            max_size,
            dest,
            sha_dest,
            version: version.to_string(),
            filename: filename.to_string(),
            force,
        })
    }

    pub fn write(&mut self, chunk: &[u8]) -> Result<(), ReleaseError> {
        self.written += chunk.len() as u64;
        if self.written > self.max_size {
            return Err(ReleaseError::TooLarge(self.max_size));
        }
        self.hasher.update(chunk);
        self.temp.write_all(chunk)?;
        Ok(())
    }

    /// Finalize: atomic rename + write `.sha256`. Returns the hex digest.
    pub fn finish(self) -> Result<String, ReleaseError> {
        // Durability: File::flush() is a no-op, so force the bytes to disk
        // before we rename into place.
        self.temp.as_file().sync_all()?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            self.temp
                .as_file()
                .set_permissions(std::fs::Permissions::from_mode(0o644))?;
        }

        let digest = self.hasher.finalize();
        let hex = format!("{:x}", digest);

        if self.force {
            // The force path is the only one that can replace an existing
            // artifact, so it's the only one where a stale companion could
            // end up lying about new bytes. Delete it before persisting: a
            // crash (or error) between persist and the companion write then
            // degrades to "companion missing", never "companion wrong". In
            // the no-force path below, persist_noclobber either fails (dest
            // untouched, so the existing companion must survive) or
            // succeeds (dest didn't exist, so the post-persist companion
            // write below overwrites any orphaned stale companion anyway) —
            // no pre-delete needed, and pre-deleting there would destroy a
            // valid companion on a merely-rejected duplicate upload.
            if let Err(e) = std::fs::remove_file(&self.sha_dest) {
                if e.kind() != std::io::ErrorKind::NotFound {
                    return Err(e.into());
                }
            }
            self.temp
                .persist(&self.dest)
                .map_err(|e| ReleaseError::Io(e.error))?;
        } else {
            self.temp.persist_noclobber(&self.dest).map_err(|e| {
                if e.error.kind() == std::io::ErrorKind::AlreadyExists {
                    ReleaseError::AlreadyExists(format!("{}/{}", self.version, self.filename))
                } else {
                    ReleaseError::Io(e.error)
                }
            })?;
        }
        // Write the companion via temp-file-then-rename too: std::fs::write()
        // truncates the destination in place, so a download racing a
        // --force re-upload could observe the companion mid-truncation
        // (empty or partially written). A NamedTempFile + persist makes the
        // companion update atomic from a reader's point of view, just like
        // the artifact rename above.
        let version_dir = self
            .sha_dest
            .parent()
            .expect("sha_dest always has a parent (the version dir)");
        let mut sha_temp = NamedTempFile::new_in(version_dir)?;
        sha_temp.write_all(format!("{}  {}\n", hex, self.filename).as_bytes())?;
        sha_temp.as_file().sync_all()?;
        // std::fs::write() (the previous implementation) created the file
        // honoring the process umask, so under a restrictive umask (e.g.
        // 077) the companion could end up 0600 while the artifact above is
        // explicitly forced to 0644. NamedTempFile also defaults to 0600;
        // force an explicit mode so the two are consistent regardless of
        // umask, before persisting.
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            sha_temp
                .as_file()
                .set_permissions(std::fs::Permissions::from_mode(0o644))?;
        }
        sha_temp
            .persist(&self.sha_dest)
            .map_err(|e| ReleaseError::Io(e.error))?;
        Ok(hex)
    }
}

#[derive(Debug, Serialize)]
pub struct ReleaseFile {
    pub name: String,
    pub size: u64,
    pub sha256: String,
}

#[derive(Debug, Serialize)]
pub struct ReleaseVersion {
    pub version: String,
    pub published: String,
    pub files: Vec<ReleaseFile>,
}

#[derive(Debug, Serialize)]
pub struct ReleaseIndex {
    pub versions: Vec<ReleaseVersion>,
}

/// List versions newest-first (by directory mtime), files alphabetically.
/// `.sha256` companions and dotfiles are not listed as files.
/// A missing releases dir is an empty index.
pub fn list_releases(releases_dir: &Path) -> Result<ReleaseIndex, ReleaseError> {
    let mut versions: Vec<(std::time::SystemTime, ReleaseVersion)> = Vec::new();
    let read_dir = match std::fs::read_dir(releases_dir) {
        Ok(rd) => rd,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Ok(ReleaseIndex {
                versions: Vec::new(),
            })
        }
        Err(e) => return Err(e.into()),
    };

    for entry in read_dir {
        let entry = entry?;
        if !entry.path().is_dir() {
            continue;
        }
        let version = entry.file_name().to_string_lossy().to_string();
        if !validate_name(&version) {
            continue;
        }
        let mtime = entry
            .metadata()?
            .modified()
            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
        let published = chrono::DateTime::<chrono::Utc>::from(mtime)
            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);

        let mut files = Vec::new();
        for file_entry in std::fs::read_dir(entry.path())? {
            let file_entry = file_entry?;
            let name = file_entry.file_name().to_string_lossy().to_string();
            if name.starts_with('.') || name.ends_with(".sha256") || !file_entry.path().is_file() {
                continue;
            }
            // Never advertise a name `artifact_path` would refuse to resolve:
            // a listing entry that 404s on download is worse than no entry.
            if !validate_name(&name) {
                continue;
            }
            let size = file_entry.metadata()?.len();
            let sha256 = std::fs::read_to_string(entry.path().join(format!("{}.sha256", name)))
                .ok()
                .and_then(|s| s.split_whitespace().next().map(|t| t.to_string()))
                .unwrap_or_default();
            files.push(ReleaseFile { name, size, sha256 });
        }
        files.sort_by(|a, b| a.name.cmp(&b.name));
        // begin() creates the version directory before any bytes have
        // arrived, so an aborted upload can leave an empty version dir.
        // Don't list phantom versions with zero files.
        if files.is_empty() {
            continue;
        }
        versions.push((
            mtime,
            ReleaseVersion {
                version,
                published,
                files,
            },
        ));
    }

    versions.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.version.cmp(&a.1.version)));
    Ok(ReleaseIndex {
        versions: versions.into_iter().map(|(_, v)| v).collect(),
    })
}

/// Delete one file (and its `.sha256`) or, with `filename: None`, a whole
/// version. Removing the last file of a version removes the version dir.
pub fn delete_release(
    releases_dir: &Path,
    version: &str,
    filename: Option<&str>,
) -> Result<(), ReleaseError> {
    if !validate_name(version) {
        return Err(ReleaseError::InvalidName(version.to_string()));
    }
    let version_dir = releases_dir.join(version);
    if !version_dir.is_dir() {
        return Err(ReleaseError::NotFound(version.to_string()));
    }
    match filename {
        None => {
            std::fs::remove_dir_all(&version_dir)?;
        }
        Some(name) => {
            if !validate_name(name) {
                return Err(ReleaseError::InvalidName(name.to_string()));
            }
            let file = version_dir.join(name);
            if !file.is_file() {
                return Err(ReleaseError::NotFound(format!("{}/{}", version, name)));
            }
            std::fs::remove_file(&file)?;
            let _ = std::fs::remove_file(version_dir.join(format!("{}.sha256", name)));
            let is_empty = std::fs::read_dir(&version_dir)?.next().is_none();
            if is_empty {
                std::fs::remove_dir(&version_dir)?;
            }
        }
    }
    Ok(())
}

/// Resolve an artifact (or `.sha256` companion) path safely. Validates both
/// segments; the filename may carry a single ".sha256" suffix. Returns
/// `NotFound` if the file does not exist.
pub fn artifact_path(
    releases_dir: &Path,
    version: &str,
    filename: &str,
) -> Result<PathBuf, ReleaseError> {
    if !validate_name(version) {
        return Err(ReleaseError::InvalidName(version.to_string()));
    }
    let base_name = filename.strip_suffix(".sha256").unwrap_or(filename);
    if !validate_name(base_name) {
        return Err(ReleaseError::InvalidName(filename.to_string()));
    }
    let path = releases_dir.join(version).join(filename);
    if !path.is_file() {
        return Err(ReleaseError::NotFound(format!("{}/{}", version, filename)));
    }
    Ok(path)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn upload(
        dir: &Path,
        version: &str,
        name: &str,
        content: &[u8],
        force: bool,
    ) -> Result<String, ReleaseError> {
        let mut up = ReleaseUpload::begin(dir, version, name, force, DEFAULT_MAX_RELEASE_SIZE)?;
        up.write(content)?;
        up.finish()
    }

    #[test]
    fn begin_rejects_sha256_filename() {
        // `.sha256` is our checksum companion namespace. Allowing an upload
        // named `app.tar.gz.sha256` would let a force-upload overwrite the
        // real companion of `app.tar.gz` with attacker-chosen content.
        let tmp = TempDir::new().unwrap();
        // ReleaseUpload isn't Debug, so match rather than unwrap_err().
        let err = match ReleaseUpload::begin(
            tmp.path(),
            "v1",
            "app.tar.gz.sha256",
            false,
            DEFAULT_MAX_RELEASE_SIZE,
        ) {
            Ok(_) => panic!("expected a .sha256 filename to be rejected"),
            Err(e) => e,
        };
        assert!(
            matches!(err, ReleaseError::InvalidName(_)),
            "expected InvalidName, got {:?}",
            err
        );
        assert!(err.to_string().contains("reserved"), "got: {}", err);

        // Even with --force, which is the dangerous direction.
        assert!(ReleaseUpload::begin(
            tmp.path(),
            "v1",
            "app.tar.gz.sha256",
            true,
            DEFAULT_MAX_RELEASE_SIZE
        )
        .is_err());
    }

    #[test]
    fn list_skips_files_failing_validate_name() {
        // A name the listing advertises must be one `artifact_path` will
        // resolve, or the UI links to a guaranteed 404.
        let tmp = TempDir::new().unwrap();
        upload(tmp.path(), "v1", "good.tar.gz", b"ok", false).unwrap();
        let version_dir = tmp.path().join("v1");
        std::fs::write(version_dir.join("-leading-dash.tar.gz"), b"x").unwrap();
        std::fs::write(version_dir.join("has space.tar.gz"), b"x").unwrap();

        let index = list_releases(tmp.path()).unwrap();
        let files: Vec<&str> = index.versions[0]
            .files
            .iter()
            .map(|f| f.name.as_str())
            .collect();
        assert_eq!(files, vec!["good.tar.gz"]);
        for name in &files {
            assert!(
                artifact_path(tmp.path(), "v1", name).is_ok(),
                "listed name {} is not resolvable",
                name
            );
        }
    }

    #[test]
    fn upload_writes_file_and_checksum() {
        let tmp = TempDir::new().unwrap();
        let sha = upload(tmp.path(), "v1.0.0", "app.tar.gz", b"hello", false).unwrap();
        // sha256 of "hello"
        assert_eq!(
            sha,
            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
        );
        let file = tmp.path().join("v1.0.0").join("app.tar.gz");
        assert_eq!(std::fs::read(&file).unwrap(), b"hello");
        let sha_file = tmp.path().join("v1.0.0").join("app.tar.gz.sha256");
        assert_eq!(
            std::fs::read_to_string(&sha_file).unwrap(),
            format!("{}  app.tar.gz\n", sha)
        );
    }

    #[cfg(unix)]
    #[test]
    fn companion_mode_is_explicit_0644() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = TempDir::new().unwrap();
        upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap();

        // finish() sets an explicit mode on the companion (rather than
        // relying on std::fs::write()'s umask-dependent default), so this
        // holds regardless of the process umask.
        let sha_file = tmp.path().join("v1").join("a.tar.gz.sha256");
        let mode = std::fs::metadata(&sha_file).unwrap().permissions().mode();
        assert_eq!(mode & 0o777, 0o644);
    }

    #[test]
    fn duplicate_upload_rejected_without_force() {
        let tmp = TempDir::new().unwrap();
        upload(tmp.path(), "v1", "a.tar.gz", b"one", false).unwrap();
        let err = upload(tmp.path(), "v1", "a.tar.gz", b"two", false).unwrap_err();
        assert!(matches!(err, ReleaseError::AlreadyExists(_)));
        // original content untouched
        assert_eq!(
            std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(),
            b"one"
        );
    }

    #[test]
    fn rejected_duplicate_keeps_existing_companion() {
        let tmp = TempDir::new().unwrap();
        let original_sha = upload(tmp.path(), "v1", "a.tar.gz", b"one", false).unwrap();
        let err = upload(tmp.path(), "v1", "a.tar.gz", b"two", false).unwrap_err();
        assert!(matches!(err, ReleaseError::AlreadyExists(_)));

        let companion = std::fs::read_to_string(tmp.path().join("v1").join("a.tar.gz.sha256"))
            .expect("companion must still exist after a rejected duplicate upload");
        assert_eq!(companion, format!("{}  a.tar.gz\n", original_sha));
    }

    #[test]
    fn force_replaces_file_and_checksum() {
        let tmp = TempDir::new().unwrap();
        upload(tmp.path(), "v1", "a.tar.gz", b"one", false).unwrap();
        let sha_after = upload(tmp.path(), "v1", "a.tar.gz", b"two", true).unwrap();
        assert_eq!(
            std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(),
            b"two"
        );
        assert!(
            std::fs::read_to_string(tmp.path().join("v1").join("a.tar.gz.sha256"))
                .unwrap()
                .starts_with(&sha_after)
        );
    }

    #[test]
    fn oversize_upload_rejected_and_cleaned_up() {
        let tmp = TempDir::new().unwrap();
        let mut up = ReleaseUpload::begin(tmp.path(), "v1", "big.tar.gz", false, 4).unwrap();
        let err = up.write(b"12345").unwrap_err();
        assert!(matches!(err, ReleaseError::TooLarge(4)));
        drop(up);
        // no artifact, no stray temp files
        assert!(!tmp.path().join("v1").join("big.tar.gz").exists());
        let leftovers: Vec<_> = std::fs::read_dir(tmp.path().join("v1")).unwrap().collect();
        assert!(
            leftovers.is_empty(),
            "temp files left behind: {:?}",
            leftovers
        );
    }

    #[test]
    fn invalid_names_rejected() {
        let tmp = TempDir::new().unwrap();
        assert!(matches!(
            ReleaseUpload::begin(tmp.path(), "../v1", "a.tar.gz", false, 100),
            Err(ReleaseError::InvalidName(_))
        ));
        assert!(matches!(
            ReleaseUpload::begin(tmp.path(), "v1", "../../a", false, 100),
            Err(ReleaseError::InvalidName(_))
        ));
        assert!(matches!(
            delete_release(tmp.path(), "..", None),
            Err(ReleaseError::InvalidName(_))
        ));
    }

    #[test]
    fn list_orders_versions_newest_first() {
        let tmp = TempDir::new().unwrap();
        upload(tmp.path(), "v1.0.0", "a.tar.gz", b"aaa", false).unwrap();
        std::thread::sleep(std::time::Duration::from_millis(20));
        upload(tmp.path(), "v1.1.0", "b.tar.gz", b"bbb", false).unwrap();

        let index = list_releases(tmp.path()).unwrap();
        assert_eq!(index.versions.len(), 2);
        assert_eq!(index.versions[0].version, "v1.1.0");
        assert_eq!(index.versions[1].version, "v1.0.0");
        assert_eq!(index.versions[0].files.len(), 1);
        assert_eq!(index.versions[0].files[0].name, "b.tar.gz");
        assert_eq!(index.versions[0].files[0].size, 3);
        assert_eq!(index.versions[0].files[0].sha256.len(), 64);
        assert!(!index.versions[0].published.is_empty());
    }

    #[test]
    fn list_missing_dir_is_empty() {
        let tmp = TempDir::new().unwrap();
        let index = list_releases(&tmp.path().join("nope")).unwrap();
        assert!(index.versions.is_empty());
    }

    #[test]
    fn list_excludes_sha256_companions() {
        let tmp = TempDir::new().unwrap();
        upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap();
        let index = list_releases(tmp.path()).unwrap();
        let names: Vec<_> = index.versions[0].files.iter().map(|f| &f.name).collect();
        assert_eq!(names, vec!["a.tar.gz"]);
    }

    #[test]
    fn delete_file_and_version() {
        let tmp = TempDir::new().unwrap();
        upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap();
        upload(tmp.path(), "v1", "b.tar.gz", b"y", false).unwrap();

        delete_release(tmp.path(), "v1", Some("a.tar.gz")).unwrap();
        assert!(!tmp.path().join("v1").join("a.tar.gz").exists());
        assert!(!tmp.path().join("v1").join("a.tar.gz.sha256").exists());
        assert!(tmp.path().join("v1").exists());

        // deleting the last file removes the version dir
        delete_release(tmp.path(), "v1", Some("b.tar.gz")).unwrap();
        assert!(!tmp.path().join("v1").exists());
    }

    #[test]
    fn delete_whole_version_and_missing_targets_error() {
        let tmp = TempDir::new().unwrap();
        upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap();
        delete_release(tmp.path(), "v1", None).unwrap();
        assert!(!tmp.path().join("v1").exists());

        assert!(matches!(
            delete_release(tmp.path(), "v1", None),
            Err(ReleaseError::NotFound(_))
        ));
        assert!(matches!(
            delete_release(tmp.path(), "v2", Some("z.tar.gz")),
            Err(ReleaseError::NotFound(_))
        ));
    }

    #[test]
    fn multi_chunk_upload_streams_correctly() {
        let tmp = TempDir::new().unwrap();
        let mut up = ReleaseUpload::begin(
            tmp.path(),
            "v1",
            "app.tar.gz",
            false,
            DEFAULT_MAX_RELEASE_SIZE,
        )
        .unwrap();
        up.write(b"hel").unwrap();
        up.write(b"lo, ").unwrap();
        up.write(b"world").unwrap();
        let sha = up.finish().unwrap();

        let expected = format!("{:x}", Sha256::digest(b"hello, world"));
        assert_eq!(sha, expected);
        assert_eq!(
            std::fs::read(tmp.path().join("v1").join("app.tar.gz")).unwrap(),
            b"hello, world"
        );
    }

    #[test]
    fn exact_size_boundary_accepted() {
        let tmp = TempDir::new().unwrap();
        let mut up = ReleaseUpload::begin(tmp.path(), "v1", "a.tar.gz", false, 5).unwrap();
        assert!(up.write(b"12345").is_ok());
        let sha = up.finish().unwrap();
        assert_eq!(
            std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(),
            b"12345"
        );
        assert_eq!(sha.len(), 64);
    }

    #[test]
    fn orphaned_companion_yields_empty_sha256() {
        let tmp = TempDir::new().unwrap();
        upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap();
        std::fs::remove_file(tmp.path().join("v1").join("a.tar.gz.sha256")).unwrap();

        let index = list_releases(tmp.path()).unwrap();
        assert_eq!(index.versions[0].files[0].sha256, "");
    }

    #[test]
    fn empty_version_dir_not_listed() {
        let tmp = TempDir::new().unwrap();
        std::fs::create_dir_all(tmp.path().join("v1-empty")).unwrap();
        upload(tmp.path(), "v2", "a.tar.gz", b"x", false).unwrap();

        let index = list_releases(tmp.path()).unwrap();
        assert_eq!(index.versions.len(), 1);
        assert_eq!(index.versions[0].version, "v2");
    }

    #[test]
    fn concurrent_dest_creation_maps_to_already_exists() {
        let tmp = TempDir::new().unwrap();
        let mut up = ReleaseUpload::begin(
            tmp.path(),
            "v1",
            "a.tar.gz",
            false,
            DEFAULT_MAX_RELEASE_SIZE,
        )
        .unwrap();
        up.write(b"race").unwrap();
        // Simulate another writer creating the dest file after begin()
        // checked for its absence but before this upload persists.
        std::fs::write(tmp.path().join("v1").join("a.tar.gz"), b"other").unwrap();

        let err = up.finish().unwrap_err();
        assert!(matches!(err, ReleaseError::AlreadyExists(_)));
    }

    #[test]
    fn artifact_path_resolves_existing_and_companion() {
        let tmp = TempDir::new().unwrap();
        upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap();

        let file = artifact_path(tmp.path(), "v1", "a.tar.gz").unwrap();
        assert_eq!(file, tmp.path().join("v1").join("a.tar.gz"));

        let companion = artifact_path(tmp.path(), "v1", "a.tar.gz.sha256").unwrap();
        assert_eq!(companion, tmp.path().join("v1").join("a.tar.gz.sha256"));
    }

    #[test]
    fn artifact_path_rejects_traversal() {
        let tmp = TempDir::new().unwrap();
        assert!(matches!(
            artifact_path(tmp.path(), "../v1", "a.tar.gz"),
            Err(ReleaseError::InvalidName(_))
        ));
        assert!(matches!(
            artifact_path(tmp.path(), "v1", "../../a.tar.gz"),
            Err(ReleaseError::InvalidName(_))
        ));
    }

    #[test]
    fn artifact_path_missing_is_not_found() {
        let tmp = TempDir::new().unwrap();
        assert!(matches!(
            artifact_path(tmp.path(), "v1", "nope.tar.gz"),
            Err(ReleaseError::NotFound(_))
        ));
    }
}