615 lines
21 KiB
Rust
615 lines
21 KiB
Rust
use std::{
|
|
ffi::{OsStr, OsString},
|
|
fs::File,
|
|
io::{BufWriter, Read},
|
|
path::{Path, PathBuf},
|
|
process::ExitStatus,
|
|
};
|
|
|
|
use anyhow::{Context, Result, anyhow, bail};
|
|
use serde::Serialize;
|
|
use sha2::{Digest, Sha256};
|
|
use tempfile::TempDir;
|
|
use walkdir::WalkDir;
|
|
use zip::write::SimpleFileOptions;
|
|
|
|
use crate::{
|
|
cli::{PackageLocalArgs, ReleaseSmokeVersionArgs},
|
|
workspace::{
|
|
archive_extension_for_target, default_binary_path, host_triple, load_workspace_metadata,
|
|
root_manifest_path, run_command, utc_now_rfc3339, write_utf8_text,
|
|
},
|
|
};
|
|
|
|
#[derive(Debug, Serialize)]
|
|
/// Summary printed after validating a release binary version.
|
|
struct ReleaseSmokeSummary {
|
|
#[serde(rename = "BinaryPath")]
|
|
/// Binary that was executed for the smoke check.
|
|
binary_path: String,
|
|
#[serde(rename = "HostTriple")]
|
|
/// Build host target triple.
|
|
host_triple: String,
|
|
#[serde(rename = "TargetTriple")]
|
|
/// Release artifact target triple.
|
|
target_triple: String,
|
|
#[serde(rename = "Version")]
|
|
/// Expected package version.
|
|
version: String,
|
|
#[serde(rename = "VersionOutput")]
|
|
/// Captured `--version` output.
|
|
version_output: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
/// Machine-readable manifest written beside a release archive.
|
|
struct ReleaseArchiveManifest {
|
|
/// Package version in the archive.
|
|
version: String,
|
|
/// Build host target triple.
|
|
host_triple: String,
|
|
/// Release artifact target triple.
|
|
target_triple: String,
|
|
/// Packaged archive metadata.
|
|
archive: ManifestArtifact,
|
|
/// Source binary metadata.
|
|
binary: ManifestArtifact,
|
|
/// Path to the aggregate checksum list.
|
|
checksum_list: String,
|
|
/// Version smoke result included in the package manifest.
|
|
version_smoke: VersionSmokeManifest,
|
|
/// Files included in the staged archive.
|
|
included_files: Vec<IncludedFile>,
|
|
/// UTC generation timestamp for the manifest.
|
|
generated_at_utc: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
/// File artifact metadata embedded in release manifests.
|
|
struct ManifestArtifact {
|
|
/// Artifact file name.
|
|
file_name: String,
|
|
/// Full artifact path.
|
|
path: String,
|
|
/// Hex-encoded SHA-256 digest.
|
|
sha256: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
/// Captured release version-smoke output.
|
|
struct VersionSmokeManifest {
|
|
/// Whether the smoke check was intentionally skipped.
|
|
skipped: bool,
|
|
/// Captured output lines from the smoke check.
|
|
output: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
/// File included in the staged release archive.
|
|
struct IncludedFile {
|
|
#[serde(rename = "RelativePath")]
|
|
/// Archive-relative file path.
|
|
relative_path: String,
|
|
#[serde(rename = "Size")]
|
|
/// File size in bytes.
|
|
size: u64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
/// Summary printed after creating a local release package.
|
|
struct PackageSummary {
|
|
#[serde(rename = "Version")]
|
|
/// Package version in the archive.
|
|
version: String,
|
|
#[serde(rename = "HostTriple")]
|
|
/// Build host target triple.
|
|
host_triple: String,
|
|
#[serde(rename = "TargetTriple")]
|
|
/// Release artifact target triple.
|
|
target_triple: String,
|
|
#[serde(rename = "SourceBinary")]
|
|
/// Binary copied into the archive.
|
|
source_binary: String,
|
|
#[serde(rename = "ArchivePath")]
|
|
/// Path to the generated archive.
|
|
archive_path: String,
|
|
#[serde(rename = "ManifestPath")]
|
|
/// Path to the generated manifest.
|
|
manifest_path: String,
|
|
#[serde(rename = "ChecksumList")]
|
|
/// Path to the aggregate checksum list.
|
|
checksum_list: String,
|
|
}
|
|
|
|
/// Runs the release binary version smoke workflow.
|
|
pub fn run_release_smoke_version(args: &ReleaseSmokeVersionArgs) -> Result<()> {
|
|
let metadata = load_workspace_metadata()?;
|
|
let host_triple = host_triple()?;
|
|
let target_triple = args
|
|
.target_triple
|
|
.clone()
|
|
.unwrap_or_else(|| host_triple.clone());
|
|
|
|
if args.build {
|
|
invoke_release_build(&target_triple, &host_triple)?;
|
|
}
|
|
|
|
let binary_path = args
|
|
.binary_path
|
|
.clone()
|
|
.unwrap_or_else(|| default_binary_path(&metadata, &target_triple, &host_triple));
|
|
let version_output = invoke_version_smoke(&binary_path, &metadata.package_version)?;
|
|
let binary_path_text = path_display_string(&binary_path);
|
|
|
|
let summary = ReleaseSmokeSummary {
|
|
binary_path: binary_path_text,
|
|
host_triple,
|
|
target_triple,
|
|
version: metadata.package_version,
|
|
version_output: version_output.join("\n"),
|
|
};
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&summary)
|
|
.context("failed to serialize release smoke summary")?
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Builds a local release archive and manifest from a compiled binary.
|
|
pub fn run_package_local(args: &PackageLocalArgs) -> Result<()> {
|
|
let metadata = load_workspace_metadata()?;
|
|
let host_triple = host_triple()?;
|
|
let target_triple = args
|
|
.target_triple
|
|
.clone()
|
|
.unwrap_or_else(|| host_triple.clone());
|
|
let output_root = args
|
|
.output_root
|
|
.clone()
|
|
.unwrap_or_else(|| metadata.workspace_root.join("dist").join("release"));
|
|
|
|
if args.build {
|
|
invoke_release_build(&target_triple, &host_triple)?;
|
|
}
|
|
|
|
let source_binary = args
|
|
.source_binary
|
|
.clone()
|
|
.unwrap_or_else(|| default_binary_path(&metadata, &target_triple, &host_triple));
|
|
ensure_file_exists(&source_binary, "release packaging could not find binary")?;
|
|
|
|
let artifact_base_name = format!(
|
|
"aria2-rust-pro-v{}-{}",
|
|
metadata.package_version, target_triple
|
|
);
|
|
let archive_extension = archive_extension_for_target(&target_triple);
|
|
let version_dir = output_root.join(format!("v{}", metadata.package_version));
|
|
std::fs::create_dir_all(&version_dir)
|
|
.with_context(|| format!("failed to create {}", version_dir.display()))?;
|
|
|
|
let version_output = if args.skip_version_smoke {
|
|
Vec::new()
|
|
} else {
|
|
invoke_version_smoke(&source_binary, &metadata.package_version)?
|
|
};
|
|
|
|
let stage = StageDirectory::new(&artifact_base_name)?;
|
|
let binary_name = file_name_os_string(&source_binary)?;
|
|
std::fs::copy(&source_binary, stage.stage_root.join(&binary_name))
|
|
.with_context(|| format!("failed to stage {}", source_binary.display()))?;
|
|
|
|
if target_triple.contains("windows")
|
|
&& let Some(pdb_path) = find_windows_pdb_path(&source_binary)
|
|
{
|
|
let pdb_name = file_name_os_string(&pdb_path)?;
|
|
std::fs::copy(&pdb_path, stage.stage_root.join(&pdb_name))
|
|
.with_context(|| format!("failed to stage {}", pdb_path.display()))?;
|
|
}
|
|
|
|
add_optional_file(
|
|
&metadata.workspace_root.join("README.md"),
|
|
&stage.stage_root,
|
|
Path::new("README.md"),
|
|
)?;
|
|
add_optional_file(
|
|
&metadata
|
|
.workspace_root
|
|
.join("docs")
|
|
.join("release")
|
|
.join("README.md"),
|
|
&stage.stage_root,
|
|
Path::new("docs")
|
|
.join("release")
|
|
.join("README.md")
|
|
.as_path(),
|
|
)?;
|
|
|
|
let archive_path = version_dir.join(format!("{artifact_base_name}{archive_extension}"));
|
|
if archive_extension == ".zip" {
|
|
create_zip_archive(&stage.stage_root, &archive_path)?;
|
|
} else {
|
|
create_tar_gz_archive(&stage.stage_root, &archive_path)?;
|
|
}
|
|
|
|
let checksum_list = write_checksum_files(std::slice::from_ref(&archive_path), &version_dir)?;
|
|
let binary_hash = sha256_hex(&source_binary)?;
|
|
let archive_hash = sha256_hex(&archive_path)?;
|
|
let manifest_path = version_dir.join(format!("{artifact_base_name}.manifest.json"));
|
|
let included_files = collect_included_files(&stage.stage_root)?;
|
|
let archive_path_text = path_display_string(&archive_path);
|
|
let source_binary_text = path_display_string(&source_binary);
|
|
let checksum_list_text = path_display_string(&checksum_list);
|
|
let manifest_path_text = path_display_string(&manifest_path);
|
|
let manifest = ReleaseArchiveManifest {
|
|
version: metadata.package_version.clone(),
|
|
host_triple: host_triple.clone(),
|
|
target_triple: target_triple.clone(),
|
|
archive: ManifestArtifact {
|
|
file_name: file_name_string(&archive_path)?,
|
|
path: archive_path_text.clone(),
|
|
sha256: archive_hash,
|
|
},
|
|
binary: ManifestArtifact {
|
|
file_name: file_name_string(&source_binary)?,
|
|
path: source_binary_text.clone(),
|
|
sha256: binary_hash,
|
|
},
|
|
checksum_list: checksum_list_text.clone(),
|
|
version_smoke: VersionSmokeManifest {
|
|
skipped: args.skip_version_smoke,
|
|
output: version_output,
|
|
},
|
|
included_files,
|
|
generated_at_utc: utc_now_rfc3339()?,
|
|
};
|
|
let manifest_text =
|
|
serde_json::to_string_pretty(&manifest).context("failed to serialize release manifest")?;
|
|
write_utf8_text(&manifest_path, &(manifest_text + "\n"))?;
|
|
|
|
let summary = PackageSummary {
|
|
version: metadata.package_version,
|
|
host_triple,
|
|
target_triple,
|
|
source_binary: source_binary_text,
|
|
archive_path: archive_path_text,
|
|
manifest_path: manifest_path_text,
|
|
checksum_list: checksum_list_text,
|
|
};
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&summary)
|
|
.context("failed to serialize release package summary")?
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Executes a binary and validates its aria2-compatible version banner.
|
|
fn invoke_version_smoke(binary_path: &Path, expected_version: &str) -> Result<Vec<String>> {
|
|
ensure_file_exists(binary_path, "version smoke could not find binary")?;
|
|
let output = std::process::Command::new(binary_path)
|
|
.arg("--version")
|
|
.output()
|
|
.with_context(|| format!("failed to launch {}", binary_path.display()))?;
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
bail!(
|
|
"version smoke failed with status {}: {}",
|
|
render_exit_status(output.status),
|
|
stderr.trim()
|
|
);
|
|
}
|
|
|
|
let stdout = String::from_utf8(output.stdout)
|
|
.with_context(|| format!("{} emitted non-UTF-8 stdout", binary_path.display()))?;
|
|
let lines = stdout.lines().map(str::to_owned).collect::<Vec<_>>();
|
|
let first_line = lines
|
|
.first()
|
|
.ok_or_else(|| anyhow!("version smoke output was empty"))?;
|
|
let expected_compat_line = "aria2 version 1.37.0";
|
|
if !first_line.starts_with(expected_compat_line) {
|
|
bail!("version smoke output did not start with `{expected_compat_line}`: {first_line}");
|
|
}
|
|
|
|
let expected_package_line = format!("Rust rewrite package: aria2-rust-pro {expected_version}");
|
|
let package_line = lines
|
|
.iter()
|
|
.find(|line| line.starts_with("Rust rewrite package:"))
|
|
.ok_or_else(|| {
|
|
anyhow!("version smoke output did not include a Rust rewrite package line")
|
|
})?;
|
|
if package_line != &expected_package_line {
|
|
bail!("version smoke package line did not match `{expected_package_line}`: {package_line}");
|
|
}
|
|
|
|
Ok(lines)
|
|
}
|
|
|
|
/// Invokes the release build for the requested target triple.
|
|
fn invoke_release_build(target_triple: &str, host_triple: &str) -> Result<()> {
|
|
let manifest_path = root_manifest_path();
|
|
let mut args = vec![
|
|
os("+nightly"),
|
|
os("build"),
|
|
os("-Zbuild-std=std,panic_abort"),
|
|
os("--manifest-path"),
|
|
manifest_path.into_os_string(),
|
|
os("-p"),
|
|
os("aria2-rust-pro-cli"),
|
|
os("--bin"),
|
|
os("aria2-rust-pro"),
|
|
os("--release"),
|
|
];
|
|
if target_triple != host_triple {
|
|
args.push(os("--target"));
|
|
args.push(os(target_triple));
|
|
}
|
|
run_command("cargo", &args)
|
|
}
|
|
|
|
/// Temporary release staging directory.
|
|
struct StageDirectory {
|
|
/// Temporary directory whose lifetime keeps the staging tree alive.
|
|
_temp_dir: TempDir,
|
|
/// Root directory containing staged archive contents.
|
|
stage_root: PathBuf,
|
|
}
|
|
|
|
impl StageDirectory {
|
|
/// Creates a new temporary staging directory for one artifact.
|
|
fn new(artifact_base_name: &str) -> Result<Self> {
|
|
let temp_dir =
|
|
TempDir::new().context("failed to create temporary release staging directory")?;
|
|
let stage_root = temp_dir.path().join(artifact_base_name);
|
|
std::fs::create_dir_all(&stage_root)
|
|
.with_context(|| format!("failed to create {}", stage_root.display()))?;
|
|
Ok(Self {
|
|
_temp_dir: temp_dir,
|
|
stage_root,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Ensures an expected release input file exists.
|
|
fn ensure_file_exists(path: &Path, label: &str) -> Result<()> {
|
|
if path.is_file() {
|
|
Ok(())
|
|
} else {
|
|
bail!("{label}: {}", path.display())
|
|
}
|
|
}
|
|
|
|
/// Finds the optional Windows PDB companion for a release binary.
|
|
fn find_windows_pdb_path(binary_path: &Path) -> Option<PathBuf> {
|
|
let mut candidates = Vec::new();
|
|
let mut direct = binary_path.to_path_buf();
|
|
direct.set_extension("pdb");
|
|
candidates.push(direct);
|
|
let alt = binary_path
|
|
.parent()
|
|
.map(|parent| parent.join("aria2_rust_pro.pdb"));
|
|
if let Some(path) = alt {
|
|
candidates.push(path);
|
|
}
|
|
candidates.into_iter().find(|candidate| candidate.is_file())
|
|
}
|
|
|
|
/// Copies an optional repository file into the staging tree.
|
|
fn add_optional_file(
|
|
source_path: &Path,
|
|
destination_root: &Path,
|
|
relative_path: &Path,
|
|
) -> Result<bool> {
|
|
if !source_path.is_file() {
|
|
return Ok(false);
|
|
}
|
|
|
|
let destination_path = destination_root.join(relative_path);
|
|
if let Some(parent) = destination_path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
|
}
|
|
std::fs::copy(source_path, &destination_path).with_context(|| {
|
|
format!(
|
|
"failed to copy {} to {}",
|
|
source_path.display(),
|
|
destination_path.display()
|
|
)
|
|
})?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Creates a zip archive from the staged release tree.
|
|
fn create_zip_archive(stage_root: &Path, archive_path: &Path) -> Result<()> {
|
|
if archive_path.exists() {
|
|
std::fs::remove_file(archive_path)
|
|
.with_context(|| format!("failed to remove {}", archive_path.display()))?;
|
|
}
|
|
|
|
let archive_root = file_name_string(stage_root)?;
|
|
let file = File::create(archive_path)
|
|
.with_context(|| format!("failed to create {}", archive_path.display()))?;
|
|
let writer = BufWriter::new(file);
|
|
let mut zip = zip::ZipWriter::new(writer);
|
|
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
|
|
|
for entry in WalkDir::new(stage_root).sort_by_file_name() {
|
|
let entry = entry.with_context(|| format!("failed to walk {}", stage_root.display()))?;
|
|
let path = entry.path();
|
|
let relative = path.strip_prefix(stage_root).with_context(|| {
|
|
format!(
|
|
"failed to strip {} from {}",
|
|
stage_root.display(),
|
|
path.display()
|
|
)
|
|
})?;
|
|
let archive_name = if relative.as_os_str().is_empty() {
|
|
archive_root.clone()
|
|
} else {
|
|
format!(
|
|
"{archive_root}/{}",
|
|
relative.to_string_lossy().replace('\\', "/")
|
|
)
|
|
};
|
|
|
|
if entry.file_type().is_dir() {
|
|
if !relative.as_os_str().is_empty() {
|
|
zip.add_directory(format!("{archive_name}/"), options)
|
|
.with_context(|| {
|
|
format!("failed to add directory {} to zip", path.display())
|
|
})?;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
zip.start_file(&archive_name, options)
|
|
.with_context(|| format!("failed to add file {} to zip", path.display()))?;
|
|
let mut input =
|
|
File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
|
|
std::io::copy(&mut input, &mut zip)
|
|
.with_context(|| format!("failed to copy {} into zip", path.display()))?;
|
|
}
|
|
|
|
zip.finish().context("failed to finalize zip archive")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Creates a gzip-compressed tar archive from the staged release tree.
|
|
fn create_tar_gz_archive(stage_root: &Path, archive_path: &Path) -> Result<()> {
|
|
if archive_path.exists() {
|
|
std::fs::remove_file(archive_path)
|
|
.with_context(|| format!("failed to remove {}", archive_path.display()))?;
|
|
}
|
|
|
|
let file = File::create(archive_path)
|
|
.with_context(|| format!("failed to create {}", archive_path.display()))?;
|
|
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
|
let mut builder = tar::Builder::new(encoder);
|
|
let archive_root = stage_root.file_name().ok_or_else(|| {
|
|
anyhow!(
|
|
"stage root did not include a directory name: {}",
|
|
stage_root.display()
|
|
)
|
|
})?;
|
|
builder
|
|
.append_dir_all(archive_root, stage_root)
|
|
.with_context(|| format!("failed to package {}", stage_root.display()))?;
|
|
builder
|
|
.finish()
|
|
.context("failed to finalize tar.gz archive")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Writes per-artifact and aggregate SHA-256 checksum files.
|
|
fn write_checksum_files(paths: &[PathBuf], output_directory: &Path) -> Result<PathBuf> {
|
|
let mut archive_candidates = std::fs::read_dir(output_directory)
|
|
.with_context(|| format!("failed to read {}", output_directory.display()))?
|
|
.filter_map(std::result::Result::ok)
|
|
.map(|entry| entry.path())
|
|
.filter(|path| path.is_file() && is_archive_path(path))
|
|
.collect::<Vec<_>>();
|
|
archive_candidates.sort();
|
|
|
|
for path in paths {
|
|
let hash = sha256_hex(path)?;
|
|
let file_name = file_name_string(path)?;
|
|
let line = format!("{hash} *{file_name}\n");
|
|
write_utf8_text(&PathBuf::from(format!("{}.sha256", path.display())), &line)?;
|
|
}
|
|
|
|
let mut lines = Vec::with_capacity(archive_candidates.len());
|
|
for path in archive_candidates {
|
|
let hash = sha256_hex(&path)?;
|
|
let file_name = file_name_string(&path)?;
|
|
lines.push(format!("{hash} *{file_name}"));
|
|
}
|
|
|
|
let checksum_list_path = output_directory.join("SHA256SUMS.txt");
|
|
write_utf8_text(&checksum_list_path, &(lines.join("\n") + "\n"))?;
|
|
Ok(checksum_list_path)
|
|
}
|
|
|
|
/// Computes a hex-encoded SHA-256 digest for a file.
|
|
fn sha256_hex(path: &Path) -> Result<String> {
|
|
let mut file =
|
|
File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
|
|
let mut hasher = Sha256::new();
|
|
let mut buffer = Box::new([0_u8; 64 * 1024]);
|
|
loop {
|
|
let read = file
|
|
.read(&mut buffer[..])
|
|
.with_context(|| format!("failed to read {}", path.display()))?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
hasher.update(&buffer[..read]);
|
|
}
|
|
Ok(format!("{:x}", hasher.finalize()))
|
|
}
|
|
|
|
/// Collects files included in the staged release tree.
|
|
fn collect_included_files(stage_root: &Path) -> Result<Vec<IncludedFile>> {
|
|
WalkDir::new(stage_root)
|
|
.sort_by_file_name()
|
|
.into_iter()
|
|
.filter_map(std::result::Result::ok)
|
|
.filter(|entry| entry.file_type().is_file())
|
|
.map(|entry| {
|
|
let relative = entry.path().strip_prefix(stage_root).with_context(|| {
|
|
format!(
|
|
"failed to strip {} from {}",
|
|
stage_root.display(),
|
|
entry.path().display()
|
|
)
|
|
})?;
|
|
let metadata = entry.metadata().with_context(|| {
|
|
format!("failed to read metadata for {}", entry.path().display())
|
|
})?;
|
|
Ok(IncludedFile {
|
|
relative_path: relative.to_string_lossy().replace('\\', "/"),
|
|
size: metadata.len(),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Returns the trailing file name as UTF-8 text for manifest entries.
|
|
fn file_name_string(path: &Path) -> Result<String> {
|
|
path.file_name()
|
|
.ok_or_else(|| anyhow!("path did not include a file name: {}", path.display()))
|
|
.map(|name| OsStr::to_string_lossy(name).into_owned())
|
|
}
|
|
|
|
/// Returns the trailing file name as an owned OS string.
|
|
fn file_name_os_string(path: &Path) -> Result<OsString> {
|
|
path.file_name()
|
|
.ok_or_else(|| anyhow!("path did not include a file name: {}", path.display()))
|
|
.map(OsStr::to_os_string)
|
|
}
|
|
|
|
/// Renders a path display adapter into an owned string.
|
|
fn path_display_string(path: &Path) -> String {
|
|
format!("{}", path.display())
|
|
}
|
|
|
|
/// Converts a string slice into an owned OS string for command arguments.
|
|
fn os(value: &str) -> OsString {
|
|
OsString::from(value)
|
|
}
|
|
|
|
/// Renders a process exit status for release diagnostics.
|
|
fn render_exit_status(status: ExitStatus) -> String {
|
|
status
|
|
.code()
|
|
.map_or_else(|| String::from("signal"), |code| code.to_string())
|
|
}
|
|
|
|
/// Returns whether a path looks like a release archive.
|
|
fn is_archive_path(path: &Path) -> bool {
|
|
let file_name = path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy())
|
|
.unwrap_or_default();
|
|
file_name.ends_with(".zip") || file_name.ends_with(".tar.gz") || file_name.ends_with(".tar")
|
|
}
|