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(_))
));
}
}