Files
fluxer/tools/ci/src/common.rs
T

1631 lines
52 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
use anyhow::{Context, Result, anyhow, bail, ensure};
use aws_config::{BehaviorVersion, Region, retry::RetryConfig};
use aws_sdk_s3::Client as S3Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Timelike, Utc};
use md5::{Digest as _, Md5};
use reqwest::Client;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::ffi::{OsStr, OsString};
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use walkdir::WalkDir;
pub(crate) use crate::functions::{remove_dir_if_exists, remove_file_if_exists};
const DEFAULT_S3_WRITE_CONCURRENCY: usize = 8;
const DEFAULT_S3_RETRY_ATTEMPTS: u32 = 8;
pub(crate) const CALVER_SCHEME: &str = "YYYY.MDD.MICRO";
#[derive(Default)]
pub(crate) struct CalverEnv {
pub(crate) build_version: Option<String>,
pub(crate) fluxer_build_version: Option<String>,
pub(crate) fluxer_build_date: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CommandSpec {
pub(crate) program: OsString,
pub(crate) args: Vec<OsString>,
pub(crate) cwd: Option<PathBuf>,
pub(crate) env: Vec<(OsString, OsString)>,
pub(crate) env_remove: Vec<OsString>,
}
impl CommandSpec {
pub(crate) fn new(program: impl Into<OsString>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
cwd: None,
env: Vec::new(),
env_remove: Vec::new(),
}
}
pub(crate) fn arg(mut self, arg: impl Into<OsString>) -> Self {
self.args.push(arg.into());
self
}
pub(crate) fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.args.extend(args.into_iter().map(Into::into));
self
}
pub(crate) fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
self.env.push((key.into(), value.into()));
self
}
pub(crate) fn env_remove(mut self, key: impl Into<OsString>) -> Self {
self.env_remove.push(key.into());
self
}
pub(crate) fn current_dir(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = Some(cwd.into());
self
}
pub(crate) fn to_command(&self) -> Command {
let mut command = Command::new(&self.program);
command.args(&self.args);
if let Some(cwd) = &self.cwd {
command.current_dir(cwd);
}
for key in &self.env_remove {
command.env_remove(key);
}
for (key, value) in &self.env {
command.env(key, value);
}
command
}
}
#[derive(Debug)]
pub(crate) struct CapturedOutput {
pub(crate) status: i32,
pub(crate) stdout: Vec<u8>,
pub(crate) stderr: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct S3UploadPlanItem {
pub(crate) path: PathBuf,
pub(crate) key: String,
pub(crate) content_type: Option<String>,
pub(crate) cache_control: Option<String>,
pub(crate) repair_existing_metadata: bool,
}
impl S3UploadPlanItem {
pub(crate) fn new(path: PathBuf, key: String) -> Self {
Self {
path,
key,
content_type: None,
cache_control: None,
repair_existing_metadata: false,
}
}
pub(crate) fn with_content_type(mut self, content_type: impl Into<String>) -> Self {
self.content_type = Some(content_type.into());
self
}
pub(crate) fn with_cache_control(mut self, cache_control: impl Into<String>) -> Self {
self.cache_control = Some(cache_control.into());
self
}
pub(crate) fn with_detected_content_type(self) -> Self {
let Some(content_type) = s3_content_type_for_key(&self.key) else {
return self;
};
self.with_content_type(content_type)
}
pub(crate) fn repair_existing_metadata(mut self) -> Self {
self.repair_existing_metadata = true;
self
}
fn has_upload_metadata(&self) -> bool {
self.content_type.is_some() || self.cache_control.is_some()
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct S3UploadStats {
pub(crate) uploaded: usize,
pub(crate) skipped_existing: usize,
pub(crate) metadata_repaired: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum S3UploadDisposition {
Uploaded,
SkippedExisting,
MetadataRepaired,
}
pub(crate) fn env_string(name: &str) -> Option<String> {
env::var(name).ok().filter(|value| !value.is_empty())
}
pub(crate) fn require_env(name: &str) -> Result<String> {
env_string(name).ok_or_else(|| anyhow!("Missing required environment variable: {name}"))
}
pub(crate) fn require_any_env(names: &[&str]) -> Result<String> {
for name in names {
if let Some(value) = env_string(name) {
return Ok(value);
}
}
bail!(
"Missing required environment variable: {}",
names.join(" or ")
)
}
pub(crate) fn parse_bool(value: &str) -> bool {
matches!(
value.to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
pub(crate) fn env_bool(name: &str) -> bool {
env::var(name).ok().is_some_and(|value| parse_bool(&value))
}
pub(crate) fn trim_option(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub(crate) fn append_github_output(pairs: &[(&str, &str)]) -> Result<()> {
append_key_values("GITHUB_OUTPUT", pairs)
}
pub(crate) fn append_github_env(pairs: &[(&str, &str)]) -> Result<()> {
append_key_values("GITHUB_ENV", pairs)
}
pub(crate) fn append_github_path(path: &Path) -> Result<()> {
let github_path = require_env("GITHUB_PATH")?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&github_path)
.with_context(|| format!("Failed to open {github_path}"))?;
writeln!(file, "{}", path.display()).with_context(|| format!("Failed to write {github_path}"))
}
fn append_key_values(path_env_name: &str, pairs: &[(&str, &str)]) -> Result<()> {
let path = require_env(path_env_name)?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("Failed to open {path}"))?;
for (key, value) in pairs {
writeln!(file, "{key}={value}").with_context(|| format!("Failed to write {path}"))?;
}
Ok(())
}
pub(crate) fn run_command(spec: CommandSpec) -> Result<()> {
println!("+ {}", display_command(&spec));
let status = spec
.to_command()
.status()
.with_context(|| format!("Failed to run {}", spec.program.to_string_lossy()))?;
ensure!(
status.success(),
"Command failed with exit code {}: {}",
status.code().unwrap_or(1),
display_command(&spec)
);
Ok(())
}
pub(crate) fn output_text(spec: CommandSpec) -> Result<String> {
let output = output(spec)?;
String::from_utf8(output.stdout)
.map(|value| value.trim().to_string())
.context("Command output was not valid UTF-8")
}
pub(crate) fn output_bytes(spec: CommandSpec) -> Result<Vec<u8>> {
Ok(output(spec)?.stdout)
}
fn output(spec: CommandSpec) -> Result<CapturedOutput> {
let captured = capture(spec.clone())?;
if captured.status == 0 {
Ok(captured)
} else {
let stdout = String::from_utf8_lossy(&captured.stdout);
let stderr = String::from_utf8_lossy(&captured.stderr);
bail!(
"Command failed with exit code {}: {}\n{}{}",
captured.status,
display_command(&spec),
stdout,
stderr
);
}
}
pub(crate) fn capture(spec: CommandSpec) -> Result<CapturedOutput> {
println!("+ {}", display_command(&spec));
let output = spec
.to_command()
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.with_context(|| format!("Failed to run {}", spec.program.to_string_lossy()))?;
print!("{}", String::from_utf8_lossy(&output.stdout));
eprint!("{}", String::from_utf8_lossy(&output.stderr));
Ok(CapturedOutput {
status: output.status.code().unwrap_or(1),
stdout: output.stdout,
stderr: output.stderr,
})
}
pub(crate) fn command_succeeds(spec: CommandSpec) -> bool {
spec.to_command()
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
fn display_command(spec: &CommandSpec) -> String {
std::iter::once(spec.program.to_string_lossy().to_string())
.chain(spec.args.iter().map(|arg| quote_arg(arg)))
.collect::<Vec<_>>()
.join(" ")
}
fn quote_arg(arg: &OsStr) -> String {
let value = arg.to_string_lossy();
if value.chars().all(|ch| {
ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '=' | '\\')
}) {
value.to_string()
} else {
format!("{value:?}")
}
}
pub(crate) fn resolve_calver(calver_env: &CalverEnv, now: DateTime<Utc>) -> Result<String> {
if let Some(version) = calver_env
.build_version
.as_deref()
.or(calver_env.fluxer_build_version.as_deref())
{
parse_version_instant(version)?;
return Ok(version.to_string());
}
Ok(format_calver(parse_instant(calver_env, now)?))
}
fn parse_instant(calver_env: &CalverEnv, now: DateTime<Utc>) -> Result<DateTime<Utc>> {
let Some(override_value) = calver_env.fluxer_build_date.as_deref() else {
return Ok(now);
};
if let Ok(date) = NaiveDate::parse_from_str(override_value, "%Y-%m-%d") {
return date
.and_hms_opt(0, 0, 0)
.map(|instant| Utc.from_utc_datetime(&instant))
.ok_or_else(|| anyhow!("Invalid FLUXER_BUILD_DATE: {override_value}"));
}
if let Ok(instant) = NaiveDateTime::parse_from_str(override_value, "%Y-%m-%dT%H:%M:%SZ") {
return Ok(Utc.from_utc_datetime(&instant));
}
bail!("Invalid FLUXER_BUILD_DATE: {override_value}")
}
fn format_calver(instant: DateTime<Utc>) -> String {
format!(
"{}.{}.{}",
instant.year(),
month_day_segment(instant),
micro_segment(instant)
)
}
fn month_day_segment(instant: DateTime<Utc>) -> String {
format!("{}{:02}", instant.month(), instant.day())
}
fn micro_segment(instant: DateTime<Utc>) -> String {
format!(
"{:02}{:02}{:02}",
instant.hour(),
instant.minute(),
instant.second()
)
.parse::<u32>()
.expect("HHMMSS time segment should parse")
.to_string()
}
pub(crate) fn parse_version_instant(version: &str) -> Result<DateTime<Utc>> {
let mut parts = version.split('.');
let year = parts.next().unwrap_or_default();
let month_day = parts.next().unwrap_or_default();
let micro = parts.next().unwrap_or_default();
if parts.next().is_some()
|| year.len() != 4
|| !valid_nonzero_digits(year, 4)
|| !(month_day.len() == 3 || month_day.len() == 4)
|| !valid_nonzero_digits(month_day, month_day.len())
|| !valid_micro(micro)
{
bail!("Invalid build version: {version} (expected {CALVER_SCHEME}, e.g. 2026.520.0)");
}
let year: i32 = year.parse().map_err(|_| invalid_version_date(version))?;
let (month, day) = if month_day.len() == 3 {
(&month_day[0..1], &month_day[1..3])
} else {
(&month_day[0..2], &month_day[2..4])
};
let month: u32 = month.parse().map_err(|_| invalid_version_date(version))?;
let day: u32 = day.parse().map_err(|_| invalid_version_date(version))?;
let micro: u32 = micro.parse().map_err(|_| invalid_version_date(version))?;
let hour = micro / 10_000;
let minute = (micro / 100) % 100;
let second = micro % 100;
if hour > 23 || minute > 59 || second > 59 {
return Err(invalid_version_date(version));
}
Utc.with_ymd_and_hms(year, month, day, hour, minute, second)
.single()
.ok_or_else(|| invalid_version_date(version))
}
fn valid_nonzero_digits(value: &str, max_len: usize) -> bool {
!value.is_empty()
&& value.len() <= max_len
&& !value.starts_with('0')
&& value.bytes().all(|byte| byte.is_ascii_digit())
}
fn valid_micro(value: &str) -> bool {
value == "0" || valid_nonzero_digits(value, 6)
}
fn invalid_version_date(version: &str) -> anyhow::Error {
anyhow!("Invalid build version date/time: {version}")
}
pub(crate) async fn s3_client(default_endpoint: Option<&str>) -> Result<S3Client> {
let region = env::var("AWS_REGION")
.or_else(|_| env::var("AWS_DEFAULT_REGION"))
.unwrap_or_else(|_| "us-east-1".to_string());
let mut loader = aws_config::defaults(BehaviorVersion::latest()).region(Region::new(region));
let endpoint = env_string("S3_ENDPOINT").or_else(|| default_endpoint.map(ToOwned::to_owned));
if let Some(endpoint) = endpoint.as_deref() {
loader = loader.endpoint_url(endpoint);
}
loader = loader.retry_config(s3_retry_config());
let shared_config = loader.load().await;
let mut s3_config = aws_sdk_s3::config::Builder::from(&shared_config);
if endpoint.is_some() || env_bool("S3_FORCE_PATH_STYLE") {
s3_config = s3_config.force_path_style(true);
}
Ok(S3Client::from_conf(s3_config.build()))
}
pub(crate) async fn upload_directory_to_s3<F>(
client: &S3Client,
bucket: &str,
prefix: &str,
root: &Path,
include: F,
) -> Result<()>
where
F: Fn(&Path) -> bool,
{
let plan = directory_upload_plan(prefix, root, include)?;
let stats = upload_s3_plan_append_only(client, bucket, plan).await?;
println!(
"Append-only upload complete for s3://{bucket}/{prefix}: uploaded {}, skipped existing {}",
stats.uploaded, stats.skipped_existing
);
Ok(())
}
pub(crate) async fn upload_s3_plan_append_only(
client: &S3Client,
bucket: &str,
plan: Vec<S3UploadPlanItem>,
) -> Result<S3UploadStats> {
ensure_unique_s3_keys(&plan)?;
let existing_objects = existing_s3_objects_for_plan(client, bucket, &plan).await?;
let mut stats = S3UploadStats::default();
let (pending, skipped_items) = split_append_only_upload_plan(bucket, plan, &existing_objects)?;
let concurrency = s3_write_concurrency();
let semaphore = Arc::new(Semaphore::new(concurrency));
let bucket = bucket.to_string();
let mut tasks = JoinSet::new();
for item in pending {
let permit = semaphore
.clone()
.acquire_owned()
.await
.context("S3 upload semaphore closed")?;
let client = client.clone();
let bucket = bucket.clone();
tasks.spawn(async move {
let _permit = permit;
let disposition = put_file_to_s3_if_absent(&client, &bucket, &item)
.await
.with_context(|| {
format!("Failed append-only upload for s3://{}/{}", bucket, item.key)
})?;
Ok::<_, anyhow::Error>(disposition)
});
}
for item in skipped_items {
if item.repair_existing_metadata && item.has_upload_metadata() {
let permit = semaphore
.clone()
.acquire_owned()
.await
.context("S3 upload semaphore closed")?;
let client = client.clone();
let bucket = bucket.clone();
tasks.spawn(async move {
let _permit = permit;
repair_existing_s3_object_metadata(&client, &bucket, &item)
.await
.with_context(|| {
format!("Failed to repair metadata for s3://{}/{}", bucket, item.key)
})?;
Ok::<_, anyhow::Error>(S3UploadDisposition::MetadataRepaired)
});
} else {
println!("Skipping existing s3://{bucket}/{}", item.key);
stats.skipped_existing += 1;
}
}
while let Some(result) = tasks.join_next().await {
match result.context("S3 upload task failed")?? {
S3UploadDisposition::Uploaded => stats.uploaded += 1,
S3UploadDisposition::SkippedExisting => stats.skipped_existing += 1,
S3UploadDisposition::MetadataRepaired => stats.metadata_repaired += 1,
}
}
Ok(stats)
}
pub(crate) async fn upload_s3_plan_overwrite(
client: &S3Client,
bucket: &str,
plan: Vec<S3UploadPlanItem>,
) -> Result<S3UploadStats> {
ensure_unique_s3_keys(&plan)?;
let concurrency = s3_write_concurrency();
let semaphore = Arc::new(Semaphore::new(concurrency));
let bucket = bucket.to_string();
let mut tasks = JoinSet::new();
for item in plan {
let permit = semaphore
.clone()
.acquire_owned()
.await
.context("S3 upload semaphore closed")?;
let client = client.clone();
let bucket = bucket.clone();
tasks.spawn(async move {
let _permit = permit;
put_file_to_s3_overwrite(&client, &bucket, &item)
.await
.with_context(|| {
format!("Failed overwrite upload for s3://{}/{}", bucket, item.key)
})?;
Ok::<_, anyhow::Error>(S3UploadDisposition::Uploaded)
});
}
let mut stats = S3UploadStats::default();
while let Some(result) = tasks.join_next().await {
match result.context("S3 upload task failed")?? {
S3UploadDisposition::Uploaded => stats.uploaded += 1,
S3UploadDisposition::SkippedExisting => stats.skipped_existing += 1,
S3UploadDisposition::MetadataRepaired => stats.metadata_repaired += 1,
}
}
Ok(stats)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct S3ObjectMetadata {
pub(crate) e_tag: Option<String>,
pub(crate) size: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct S3ListedObject {
key: String,
metadata: S3ObjectMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct S3FileIdentity {
size: u64,
md5_hex: String,
md5_base64: String,
}
async fn existing_s3_objects_for_plan(
client: &S3Client,
bucket: &str,
plan: &[S3UploadPlanItem],
) -> Result<BTreeMap<String, S3ObjectMetadata>> {
let planned_keys = plan
.iter()
.map(|item| item.key.clone())
.collect::<BTreeSet<_>>();
let prefixes = s3_list_prefixes_for_plan(plan);
let mut existing = BTreeMap::new();
for prefix in prefixes {
println!("Scanning existing S3 objects under s3://{bucket}/{prefix}");
for object in list_s3_objects(client, bucket, &prefix).await? {
if planned_keys.contains(&object.key) {
existing.insert(object.key, object.metadata);
}
}
}
Ok(existing)
}
pub(crate) fn split_append_only_upload_plan(
bucket: &str,
plan: Vec<S3UploadPlanItem>,
existing_objects: &BTreeMap<String, S3ObjectMetadata>,
) -> Result<(Vec<S3UploadPlanItem>, Vec<S3UploadPlanItem>)> {
ensure_unique_s3_keys(&plan)?;
let mut pending = Vec::new();
let mut skipped = Vec::new();
for item in plan {
if let Some(remote) = existing_objects.get(&item.key) {
ensure_existing_s3_object_matches_local(bucket, &item, remote)?;
skipped.push(item);
} else {
pending.push(item);
}
}
Ok((pending, skipped))
}
fn ensure_existing_s3_object_matches_local(
bucket: &str,
item: &S3UploadPlanItem,
remote: &S3ObjectMetadata,
) -> Result<()> {
let local = s3_file_identity(&item.path)?;
if let Some(remote_size) = remote.size {
ensure!(
remote_size >= 0,
"Existing S3 object has invalid negative size: s3://{bucket}/{}",
item.key
);
ensure!(
remote_size as u64 == local.size,
"Existing S3 object differs from local file: s3://{bucket}/{} has {} byte(s), local {} has {} byte(s)",
item.key,
remote_size,
item.path.display(),
local.size
);
}
let Some(e_tag) = remote.e_tag.as_deref() else {
bail!(
"Existing S3 object cannot be verified because ListObjectsV2 did not return an ETag: s3://{bucket}/{}",
item.key
);
};
let Some(remote_md5) = s3_etag_md5(e_tag) else {
bail!(
"Existing S3 object cannot be verified because its ETag is not a single-part MD5: s3://{bucket}/{} (ETag {e_tag})",
item.key
);
};
ensure!(
remote_md5 == local.md5_hex,
"Existing S3 object differs from local file: s3://{bucket}/{} has ETag {e_tag}, local {} has MD5 {}",
item.key,
item.path.display(),
local.md5_hex
);
Ok(())
}
fn s3_file_identity(path: &Path) -> Result<S3FileIdentity> {
let mut file =
fs::File::open(path).with_context(|| format!("Failed to read {}", path.display()))?;
let size = file
.metadata()
.with_context(|| format!("Failed to stat {}", path.display()))?
.len();
let mut hasher = Md5::new();
let mut buffer = [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]);
}
let digest = hasher.finalize();
Ok(S3FileIdentity {
size,
md5_hex: hex::encode(digest),
md5_base64: BASE64.encode(digest),
})
}
fn s3_etag_md5(e_tag: &str) -> Option<String> {
let value = e_tag.trim().trim_matches('"');
if value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
Some(value.to_ascii_lowercase())
} else {
None
}
}
fn s3_list_prefixes_for_plan(plan: &[S3UploadPlanItem]) -> BTreeSet<String> {
plan.iter()
.map(|item| s3_list_prefix_for_key(&item.key))
.collect()
}
fn s3_list_prefix_for_key(key: &str) -> String {
key.find('/')
.map(|index| key[..=index].to_string())
.unwrap_or_else(|| key.to_string())
}
fn ensure_unique_s3_keys(plan: &[S3UploadPlanItem]) -> Result<()> {
let mut keys = BTreeSet::new();
for item in plan {
ensure!(
keys.insert(item.key.clone()),
"Duplicate S3 upload key in local plan: {}",
item.key
);
}
Ok(())
}
async fn put_file_to_s3_if_absent(
client: &S3Client,
bucket: &str,
item: &S3UploadPlanItem,
) -> Result<S3UploadDisposition> {
println!(
"Uploading {} -> s3://{bucket}/{}",
item.path.display(),
item.key
);
let identity = s3_file_identity(&item.path)?;
let attempts = s3_retry_attempts();
for attempt in 1..=attempts {
let body = ByteStream::from_path(&item.path)
.await
.with_context(|| format!("Failed to read {}", item.path.display()))?;
let mut request = client
.put_object()
.bucket(bucket)
.key(&item.key)
.if_none_match("*")
.content_md5(identity.md5_base64.clone())
.body(body);
if let Some(content_type) = &item.content_type {
request = request.content_type(content_type);
}
if let Some(cache_control) = &item.cache_control {
request = request.cache_control(cache_control);
}
match request.send().await {
Ok(_) => return Ok(S3UploadDisposition::Uploaded),
Err(error) => {
let code = error
.as_service_error()
.and_then(|error| error.code())
.map(ToOwned::to_owned);
let status = error
.raw_response()
.map(|response| response.status().as_u16());
if is_existing_object_error(code.as_deref(), status) {
println!("Skipping existing s3://{bucket}/{}", item.key);
return Ok(S3UploadDisposition::SkippedExisting);
}
if is_retryable_s3_error(code.as_deref(), status) && attempt < attempts {
sleep_before_s3_retry(
"upload",
&format!("s3://{bucket}/{}", item.key),
attempt,
attempts,
code.as_deref(),
status,
)
.await;
continue;
}
let summary = s3_error_summary(code.as_deref(), status);
return Err(error).with_context(|| {
format!("Failed to upload s3://{bucket}/{}{summary}", item.key)
});
}
}
}
unreachable!("S3 retry attempts are always greater than zero")
}
async fn repair_existing_s3_object_metadata(
client: &S3Client,
bucket: &str,
item: &S3UploadPlanItem,
) -> Result<()> {
println!(
"Repairing metadata for {} -> s3://{bucket}/{}",
item.path.display(),
item.key
);
let identity = s3_file_identity(&item.path)?;
let attempts = s3_retry_attempts();
for attempt in 1..=attempts {
let body = ByteStream::from_path(&item.path)
.await
.with_context(|| format!("Failed to read {}", item.path.display()))?;
let mut request = client
.put_object()
.bucket(bucket)
.key(&item.key)
.content_md5(identity.md5_base64.clone())
.body(body);
if let Some(content_type) = &item.content_type {
request = request.content_type(content_type);
}
if let Some(cache_control) = &item.cache_control {
request = request.cache_control(cache_control);
}
match request.send().await {
Ok(_) => return Ok(()),
Err(error) => {
let code = error
.as_service_error()
.and_then(|error| error.code())
.map(ToOwned::to_owned);
let status = error
.raw_response()
.map(|response| response.status().as_u16());
if is_retryable_s3_error(code.as_deref(), status) && attempt < attempts {
sleep_before_s3_retry(
"metadata repair",
&format!("s3://{bucket}/{}", item.key),
attempt,
attempts,
code.as_deref(),
status,
)
.await;
continue;
}
let summary = s3_error_summary(code.as_deref(), status);
return Err(error).with_context(|| {
format!(
"Failed to repair metadata for s3://{bucket}/{}{summary}",
item.key
)
});
}
}
}
unreachable!("S3 retry attempts are always greater than zero")
}
async fn put_file_to_s3_overwrite(
client: &S3Client,
bucket: &str,
item: &S3UploadPlanItem,
) -> Result<()> {
println!(
"Overwriting {} -> s3://{bucket}/{}",
item.path.display(),
item.key
);
let identity = s3_file_identity(&item.path)?;
let attempts = s3_retry_attempts();
for attempt in 1..=attempts {
let body = ByteStream::from_path(&item.path)
.await
.with_context(|| format!("Failed to read {}", item.path.display()))?;
let mut request = client
.put_object()
.bucket(bucket)
.key(&item.key)
.content_md5(identity.md5_base64.clone())
.body(body);
if let Some(content_type) = &item.content_type {
request = request.content_type(content_type);
}
if let Some(cache_control) = &item.cache_control {
request = request.cache_control(cache_control);
}
match request.send().await {
Ok(_) => return Ok(()),
Err(error) => {
let code = error
.as_service_error()
.and_then(|error| error.code())
.map(ToOwned::to_owned);
let status = error
.raw_response()
.map(|response| response.status().as_u16());
if is_retryable_s3_error(code.as_deref(), status) && attempt < attempts {
sleep_before_s3_retry(
"overwrite upload",
&format!("s3://{bucket}/{}", item.key),
attempt,
attempts,
code.as_deref(),
status,
)
.await;
continue;
}
let summary = s3_error_summary(code.as_deref(), status);
return Err(error).with_context(|| {
format!(
"Failed to overwrite upload s3://{bucket}/{}{summary}",
item.key
)
});
}
}
}
unreachable!("S3 retry attempts are always greater than zero")
}
fn is_existing_object_error(code: Option<&str>, status: Option<u16>) -> bool {
matches!(
code,
Some("PreconditionFailed" | "ConditionalRequestConflict")
) || matches!(status, Some(409 | 412))
}
fn is_retryable_s3_error(code: Option<&str>, status: Option<u16>) -> bool {
status == Some(429) || matches!(code, Some("RateLimitExceeded" | "TooManyRequests"))
}
async fn sleep_before_s3_retry(
operation: &str,
target: &str,
attempt: u32,
attempts: u32,
code: Option<&str>,
status: Option<u16>,
) {
let delay = s3_retry_delay(attempt);
let summary = s3_error_summary(code, status);
println!(
"Retrying S3 {operation} {target}{summary} in {:.1}s (attempt {}/{attempts})",
delay.as_secs_f32(),
attempt + 1
);
tokio::time::sleep(delay).await;
}
fn s3_retry_delay(attempt: u32) -> Duration {
let shift = attempt.saturating_sub(1).min(5);
Duration::from_millis(500 * (1_u64 << shift))
}
fn s3_error_summary(code: Option<&str>, status: Option<u16>) -> String {
match (code, status) {
(Some(code), Some(status)) => format!(" (S3 code {code}, HTTP status {status})"),
(Some(code), None) => format!(" (S3 code {code})"),
(None, Some(status)) => format!(" (HTTP status {status})"),
(None, None) => String::new(),
}
}
fn s3_retry_config() -> RetryConfig {
RetryConfig::standard()
.with_max_attempts(s3_retry_attempts())
.with_initial_backoff(Duration::from_millis(500))
.with_max_backoff(Duration::from_secs(30))
}
fn s3_retry_attempts() -> u32 {
env::var("S3_RETRY_ATTEMPTS")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.filter(|value| *value > 0)
.unwrap_or(DEFAULT_S3_RETRY_ATTEMPTS)
}
fn s3_write_concurrency() -> usize {
env::var("S3_WRITE_CONCURRENCY")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| *value > 0)
.unwrap_or(DEFAULT_S3_WRITE_CONCURRENCY)
}
pub(crate) fn directory_upload_plan<F>(
prefix: &str,
root: &Path,
include: F,
) -> Result<Vec<S3UploadPlanItem>>
where
F: Fn(&Path) -> bool,
{
Ok(collect_files(root)?
.into_iter()
.filter_map(|file| {
let relative = file.strip_prefix(root).ok()?;
if !include(relative) {
return None;
}
let key = join_s3_key(prefix, &path_to_s3_key(relative));
Some(S3UploadPlanItem::new(file, key).with_detected_content_type())
})
.collect::<Vec<_>>())
}
pub(crate) fn s3_content_type_for_key(key: &str) -> Option<&'static str> {
let ext = key.rsplit('.').next()?.to_ascii_lowercase();
match ext.as_str() {
"html" | "htm" => Some("text/html; charset=utf-8"),
"js" | "mjs" => Some("application/javascript; charset=utf-8"),
"css" => Some("text/css; charset=utf-8"),
"json" | "map" => Some("application/json; charset=utf-8"),
"wasm" => Some("application/wasm"),
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
"avif" => Some("image/avif"),
"svg" => Some("image/svg+xml"),
"ico" => Some("image/x-icon"),
"woff" => Some("font/woff"),
"woff2" => Some("font/woff2"),
"ttf" => Some("font/ttf"),
"otf" => Some("font/otf"),
"eot" => Some("application/vnd.ms-fontobject"),
"mp3" => Some("audio/mpeg"),
"mp4" => Some("video/mp4"),
"webm" => Some("video/webm"),
"ogg" => Some("audio/ogg"),
"wav" => Some("audio/wav"),
"pdf" => Some("application/pdf"),
"txt" => Some("text/plain; charset=utf-8"),
"xml" => Some("application/xml; charset=utf-8"),
"webmanifest" => Some("application/manifest+json"),
_ => None,
}
}
pub(crate) async fn download_s3_prefix(
client: &S3Client,
bucket: &str,
prefix: &str,
target: &Path,
) -> Result<()> {
let list_prefix = s3_directory_prefix(prefix);
let keys = list_s3_keys(client, bucket, &list_prefix).await?;
for key in keys {
let relative = key
.strip_prefix(&list_prefix)
.unwrap_or(&key)
.trim_start_matches('/');
if relative.is_empty() {
continue;
}
let output = safe_download_target(target, relative)?;
if let Some(parent) = output.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let bytes = get_s3_object_bytes(client, bucket, &key).await?;
tokio::fs::write(&output, bytes)
.await
.with_context(|| format!("Failed to write {}", output.display()))?;
println!("Downloaded s3://{bucket}/{key} -> {}", output.display());
}
Ok(())
}
pub(crate) async fn list_s3_keys(
client: &S3Client,
bucket: &str,
prefix: &str,
) -> Result<Vec<String>> {
Ok(list_s3_objects(client, bucket, prefix)
.await?
.into_iter()
.map(|object| object.key)
.collect())
}
async fn list_s3_objects(
client: &S3Client,
bucket: &str,
prefix: &str,
) -> Result<Vec<S3ListedObject>> {
let mut objects = Vec::new();
let mut token: Option<String> = None;
loop {
let response =
send_s3_list_objects_v2_page(client, bucket, prefix, token.as_deref()).await?;
objects.extend(response.contents().iter().filter_map(|object| {
object.key().map(|key| S3ListedObject {
key: key.to_string(),
metadata: S3ObjectMetadata {
e_tag: object.e_tag().map(ToOwned::to_owned),
size: object.size(),
},
})
}));
token = response.next_continuation_token().map(ToOwned::to_owned);
if token.is_none() {
break;
}
}
Ok(objects)
}
async fn send_s3_list_objects_v2_page(
client: &S3Client,
bucket: &str,
prefix: &str,
token: Option<&str>,
) -> Result<aws_sdk_s3::operation::list_objects_v2::ListObjectsV2Output> {
let attempts = s3_retry_attempts();
for attempt in 1..=attempts {
let mut request = client.list_objects_v2().bucket(bucket).prefix(prefix);
if let Some(value) = token {
request = request.continuation_token(value);
}
match request.send().await {
Ok(response) => return Ok(response),
Err(error) => {
let code = error
.as_service_error()
.and_then(|error| error.code())
.map(ToOwned::to_owned);
let status = error
.raw_response()
.map(|response| response.status().as_u16());
if is_retryable_s3_error(code.as_deref(), status) && attempt < attempts {
sleep_before_s3_retry(
"list",
&format!("s3://{bucket}/{prefix}"),
attempt,
attempts,
code.as_deref(),
status,
)
.await;
continue;
}
let summary = s3_error_summary(code.as_deref(), status);
return Err(error)
.with_context(|| format!("Failed to list s3://{bucket}/{prefix}{summary}"));
}
}
}
unreachable!("S3 retry attempts are always greater than zero")
}
pub(crate) async fn get_s3_object_bytes(
client: &S3Client,
bucket: &str,
key: &str,
) -> Result<bytes::Bytes> {
let object = send_s3_get_object(client, bucket, key).await?;
Ok(object
.body
.collect()
.await
.with_context(|| format!("Failed to collect s3://{bucket}/{key} body"))?
.into_bytes())
}
async fn send_s3_get_object(
client: &S3Client,
bucket: &str,
key: &str,
) -> Result<aws_sdk_s3::operation::get_object::GetObjectOutput> {
let attempts = s3_retry_attempts();
for attempt in 1..=attempts {
match client.get_object().bucket(bucket).key(key).send().await {
Ok(response) => return Ok(response),
Err(error) => {
let code = error
.as_service_error()
.and_then(|error| error.code())
.map(ToOwned::to_owned);
let status = error
.raw_response()
.map(|response| response.status().as_u16());
if is_retryable_s3_error(code.as_deref(), status) && attempt < attempts {
sleep_before_s3_retry(
"read",
&format!("s3://{bucket}/{key}"),
attempt,
attempts,
code.as_deref(),
status,
)
.await;
continue;
}
let summary = s3_error_summary(code.as_deref(), status);
return Err(error)
.with_context(|| format!("Failed to read s3://{bucket}/{key}{summary}"));
}
}
}
unreachable!("S3 retry attempts are always greater than zero")
}
pub(crate) fn join_s3_key(prefix: &str, child: &str) -> String {
let prefix = prefix.trim_matches('/');
let child = child.trim_matches('/');
match (prefix.is_empty(), child.is_empty()) {
(true, true) => String::new(),
(true, false) => child.to_string(),
(false, true) => prefix.to_string(),
(false, false) => format!("{prefix}/{child}"),
}
}
pub(crate) fn s3_directory_prefix(prefix: &str) -> String {
let prefix = prefix.trim_matches('/');
if prefix.is_empty() {
String::new()
} else {
format!("{prefix}/")
}
}
pub(crate) fn path_to_s3_key(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
std::path::Component::Normal(value) => Some(value.to_string_lossy().to_string()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
fn safe_download_target(target: &Path, relative: &str) -> Result<PathBuf> {
let candidate = Path::new(relative);
for component in candidate.components() {
ensure!(
matches!(component, std::path::Component::Normal(_)),
"Refusing to write S3 object outside download target: {relative}"
);
}
Ok(target.join(candidate))
}
pub(crate) async fn download_file(url: &str, path: &Path) -> Result<()> {
let bytes = Client::new()
.get(url)
.send()
.await
.with_context(|| format!("Failed to download {url}"))?
.error_for_status()
.with_context(|| format!("Failed to download {url}"))?
.bytes()
.await
.with_context(|| format!("Failed to read response body for {url}"))?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
tokio::fs::write(path, bytes)
.await
.with_context(|| format!("Failed to write {}", path.display()))
}
pub(crate) fn runner_temp() -> PathBuf {
env::var("RUNNER_TEMP")
.map(PathBuf::from)
.unwrap_or_else(|_| env::temp_dir())
}
pub(crate) fn require_home() -> Result<PathBuf> {
env::var("HOME")
.or_else(|_| env::var("USERPROFILE"))
.map(PathBuf::from)
.context("HOME or USERPROFILE is required")
}
pub(crate) fn copy_dir_contents(source: &Path, dest: &Path) -> Result<()> {
for file in collect_files(source)? {
let relative = file.strip_prefix(source)?;
let target = dest.join(relative);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
fs::copy(&file, &target).with_context(|| {
format!("Failed to copy {} to {}", file.display(), target.display())
})?;
}
Ok(())
}
pub(crate) fn collect_files(root: &Path) -> Result<Vec<PathBuf>> {
if !root.exists() {
return Ok(Vec::new());
}
let mut files = WalkDir::new(root)
.into_iter()
.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.filter(|entry| entry.file_type().is_file())
.map(|entry| entry.into_path())
.collect::<Vec<_>>();
files.sort();
Ok(files)
}
pub(crate) fn count_files(root: &Path) -> Result<usize> {
Ok(collect_files(root)?.len())
}
pub(crate) fn count_files_min_depth(root: &Path, min_depth: usize) -> Result<usize> {
Ok(WalkDir::new(root)
.min_depth(min_depth)
.into_iter()
.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.filter(|entry| entry.file_type().is_file())
.count())
}
pub(crate) fn title_case(value: &str) -> String {
let mut chars = value.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dt(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(year, month, day, hour, minute, second)
.single()
.unwrap()
}
#[test]
fn resolves_generated_calver_from_date_override() {
let calver_env = CalverEnv {
fluxer_build_date: Some("2026-05-20T01:02:03Z".to_string()),
..CalverEnv::default()
};
assert_eq!(
resolve_calver(&calver_env, dt(2026, 1, 1, 0, 0, 0)).unwrap(),
"2026.520.10203"
);
}
#[test]
fn rejects_invalid_explicit_time() {
assert_eq!(
parse_version_instant("2026.520.246000")
.unwrap_err()
.to_string(),
"Invalid build version date/time: 2026.520.246000"
);
}
#[test]
fn s3_key_helpers_are_platform_neutral() {
assert_eq!(
join_s3_key("/desktop/", "/canary/linux/"),
"desktop/canary/linux"
);
assert_eq!(
s3_directory_prefix("/_handoff/desktop/build/"),
"_handoff/desktop/build/"
);
assert_eq!(
path_to_s3_key(Path::new("assets").join("chunks").join("a.js").as_path()),
"assets/chunks/a.js"
);
}
#[test]
fn append_only_plan_skips_existing_keys_without_reordering_pending_uploads() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
fs::write(root.join("a"), "a").unwrap();
fs::write(root.join("b"), "b").unwrap();
fs::write(root.join("c"), "c").unwrap();
let plan = vec![
S3UploadPlanItem::new(root.join("a"), "desktop/a".to_string()),
S3UploadPlanItem::new(root.join("b"), "desktop/b".to_string())
.with_content_type("text/plain; charset=utf-8")
.repair_existing_metadata(),
S3UploadPlanItem::new(root.join("c"), "desktop/c".to_string()),
];
let b_identity = s3_file_identity(&root.join("b")).unwrap();
let existing = BTreeMap::from([(
"desktop/b".to_string(),
S3ObjectMetadata {
e_tag: Some(format!("\"{}\"", b_identity.md5_hex)),
size: Some(b_identity.size as i64),
},
)]);
let (pending, skipped) = split_append_only_upload_plan("bucket", plan, &existing).unwrap();
assert_eq!(
pending.into_iter().map(|item| item.key).collect::<Vec<_>>(),
vec!["desktop/a", "desktop/c"]
);
assert_eq!(
skipped
.iter()
.map(|item| item.key.as_str())
.collect::<Vec<_>>(),
vec!["desktop/b"]
);
assert_eq!(
skipped[0].content_type.as_deref(),
Some("text/plain; charset=utf-8")
);
assert!(skipped[0].repair_existing_metadata);
}
#[test]
fn append_only_plan_rejects_existing_key_with_different_checksum() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("asset.svg");
fs::write(&path, "local").unwrap();
let plan = vec![S3UploadPlanItem::new(path, "emoji/asset.svg".to_string())];
let existing = BTreeMap::from([(
"emoji/asset.svg".to_string(),
S3ObjectMetadata {
e_tag: Some("\"00000000000000000000000000000000\"".to_string()),
size: Some(5),
},
)]);
let error = split_append_only_upload_plan("bucket", plan, &existing).unwrap_err();
assert!(error.to_string().contains("differs from local file"));
}
#[test]
fn append_only_plan_rejects_existing_key_without_comparable_etag() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("asset.bin");
fs::write(&path, "local").unwrap();
let plan = vec![S3UploadPlanItem::new(path, "assets/asset.bin".to_string())];
let existing = BTreeMap::from([(
"assets/asset.bin".to_string(),
S3ObjectMetadata {
e_tag: Some("\"multipart-etag-2\"".to_string()),
size: Some(5),
},
)]);
let error = split_append_only_upload_plan("bucket", plan, &existing).unwrap_err();
assert!(error.to_string().contains("not a single-part MD5"));
}
#[test]
fn append_only_plan_rejects_duplicate_local_keys() {
let plan = vec![
S3UploadPlanItem::new(PathBuf::from("a"), "same/key".to_string()),
S3UploadPlanItem::new(PathBuf::from("b"), "same/key".to_string()),
];
assert_eq!(
split_append_only_upload_plan("bucket", plan, &BTreeMap::new())
.unwrap_err()
.to_string(),
"Duplicate S3 upload key in local plan: same/key"
);
}
#[test]
fn s3_conditional_put_errors_are_treated_as_existing_objects() {
assert!(is_existing_object_error(Some("PreconditionFailed"), None));
assert!(is_existing_object_error(
Some("ConditionalRequestConflict"),
None
));
assert!(is_existing_object_error(Some("Error"), Some(412)));
assert!(is_existing_object_error(None, Some(409)));
assert!(!is_existing_object_error(Some("AccessDenied"), Some(403)));
assert!(!is_existing_object_error(None, None));
}
#[test]
fn s3_retryable_errors_include_unmodeled_429s() {
assert!(is_retryable_s3_error(Some("Error"), Some(429)));
assert!(is_retryable_s3_error(Some("TooManyRequests"), None));
assert!(is_retryable_s3_error(Some("RateLimitExceeded"), None));
assert!(!is_retryable_s3_error(None, Some(503)));
assert!(!is_retryable_s3_error(Some("AccessDenied"), Some(403)));
assert!(!is_retryable_s3_error(None, None));
}
#[test]
fn s3_etag_md5_accepts_only_single_part_md5_values() {
assert_eq!(
s3_etag_md5("\"D41D8CD98F00B204E9800998ECF8427E\"").as_deref(),
Some("d41d8cd98f00b204e9800998ecf8427e")
);
assert!(s3_etag_md5("\"d41d8cd98f00b204e9800998ecf8427e-2\"").is_none());
assert!(s3_etag_md5("\"not-md5\"").is_none());
}
#[test]
fn s3_list_prefixes_group_planned_keys_by_top_level_prefix() {
let plan = vec![
S3UploadPlanItem::new(PathBuf::from("index.html"), "index.html".to_string()),
S3UploadPlanItem::new(PathBuf::from("emoji/a.svg"), "emoji/a.svg".to_string()),
S3UploadPlanItem::new(
PathBuf::from("emoji/nested/b.svg"),
"emoji/nested/b.svg".to_string(),
),
S3UploadPlanItem::new(PathBuf::from("assets/app.js"), "assets/app.js".to_string()),
];
assert_eq!(
s3_list_prefixes_for_plan(&plan),
BTreeSet::from([
"assets/".to_string(),
"emoji/".to_string(),
"index.html".to_string()
])
);
}
#[test]
fn s3_error_summary_includes_available_metadata() {
assert_eq!(
s3_error_summary(Some("Error"), Some(404)),
" (S3 code Error, HTTP status 404)"
);
assert_eq!(s3_error_summary(None, Some(500)), " (HTTP status 500)");
}
#[test]
fn directory_upload_plan_filters_and_prefixes_keys() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
fs::create_dir_all(root.join("nested")).unwrap();
fs::write(root.join("keep.txt"), "keep").unwrap();
fs::write(root.join("nested").join("skip.map"), "skip").unwrap();
fs::write(root.join("nested").join("keep.js"), "keep").unwrap();
let plan = directory_upload_plan("static", root, |relative| {
relative.extension().and_then(OsStr::to_str) != Some("map")
})
.unwrap();
assert_eq!(
plan.iter()
.map(|item| item.key.as_str())
.collect::<Vec<_>>(),
vec!["static/keep.txt", "static/nested/keep.js"]
);
assert_eq!(
plan[0].content_type.as_deref(),
Some("text/plain; charset=utf-8")
);
assert_eq!(
plan[1].content_type.as_deref(),
Some("application/javascript; charset=utf-8")
);
}
#[test]
fn s3_content_type_for_key_covers_browser_module_assets() {
assert_eq!(
s3_content_type_for_key("assets/app.js"),
Some("application/javascript; charset=utf-8")
);
assert_eq!(
s3_content_type_for_key("assets/app.mjs"),
Some("application/javascript; charset=utf-8")
);
assert_eq!(
s3_content_type_for_key("assets/voice_bg.wasm"),
Some("application/wasm")
);
assert_eq!(
s3_content_type_for_key("assets/app.css"),
Some("text/css; charset=utf-8")
);
assert_eq!(s3_content_type_for_key("assets/blob.unknown"), None);
}
#[test]
fn collect_and_copy_dir_contents_are_sorted_and_recursive() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("source");
let dest = temp.path().join("dest");
fs::create_dir_all(source.join("b")).unwrap();
fs::write(source.join("b").join("two.txt"), "2").unwrap();
fs::write(source.join("one.txt"), "1").unwrap();
let relative_files = collect_files(&source)
.unwrap()
.into_iter()
.map(|path| path_to_s3_key(path.strip_prefix(&source).unwrap()))
.collect::<Vec<_>>();
assert_eq!(relative_files, vec!["b/two.txt", "one.txt"]);
copy_dir_contents(&source, &dest).unwrap();
assert_eq!(fs::read_to_string(dest.join("one.txt")).unwrap(), "1");
assert_eq!(
fs::read_to_string(dest.join("b").join("two.txt")).unwrap(),
"2"
);
}
}