use super::{DYNDEP_DIR, dyndep_retention::DyndepPublicationLease, dyndep_telemetry as telemetry};
use crate::localization::{self, keys};
use crate::ninja_gen::GeneratedDyndep;
use anyhow::{Context, Result, anyhow};
use camino::{Utf8Path, Utf8PathBuf};
use cap_std::fs_utf8::{Dir, OpenOptions};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io::{Read, Write};
use std::time::{SystemTime, UNIX_EPOCH};
const MAX_TEMP_FILE_ATTEMPTS: usize = 16;
const MAX_VERIFIED_DYNDEP_SIZE: u64 = 16 * 1024 * 1024;
pub(crate) fn materialize_dyndep_files(
dir: &Dir,
dyndep_files: &[GeneratedDyndep],
) -> Result<DyndepPublicationLease> {
telemetry::instrument_materialization(dyndep_files.len(), || {
materialize_dyndep_files_inner(dir, dyndep_files)
})
}
fn materialize_dyndep_files_inner(
dir: &Dir,
dyndep_files: &[GeneratedDyndep],
) -> Result<DyndepPublicationLease> {
if dyndep_files.is_empty() {
return Ok(DyndepPublicationLease::empty());
}
dir.create_dir_all(DYNDEP_DIR).with_context(|| {
localization::message(keys::RUNNER_IO_DYNDEP_CREATE_DIR)
.with_arg("path", DYNDEP_DIR.to_owned())
})?;
let lease = DyndepPublicationLease::acquire(dir)?;
for sidecar in dyndep_files {
materialize_one(dir, sidecar)?;
}
Ok(lease)
}
fn materialize_one(dir: &Dir, sidecar: &GeneratedDyndep) -> Result<()> {
telemetry::instrument_sidecar_materialization(|| materialize_one_inner(dir, sidecar))
}
fn materialize_one_inner(dir: &Dir, sidecar: &GeneratedDyndep) -> Result<()> {
let rel = sidecar.relative_path();
match read_verified(dir, rel, sidecar.content())? {
ReadOutcome::Matching => {
tracing::debug!(operation = "reuse", "reusing existing dyndep sidecar",);
Ok(())
}
ReadOutcome::Mismatch => Err(anyhow!(
localization::message(keys::RUNNER_IO_DYNDEP_CORRUPT).with_arg("path", rel.as_str())
)),
ReadOutcome::Missing => write_atomic(dir, rel, sidecar.content()),
}
}
#[derive(PartialEq)]
enum ReadOutcome {
Matching,
Mismatch,
Missing,
}
fn read_verified(dir: &Dir, rel: &Utf8Path, expected: &str) -> Result<ReadOutcome> {
let Some(mut file) = open_existing_sidecar(dir, rel)? else {
return Ok(ReadOutcome::Missing);
};
let size = verified_sidecar_size(&file, rel)?;
let (content, grew_while_reading) = read_sidecar_content(&mut file, size, rel)?;
Ok(content_outcome(&content, expected, grew_while_reading))
}
fn open_existing_sidecar(dir: &Dir, rel: &Utf8Path) -> Result<Option<cap_std::fs_utf8::File>> {
let mut options = OpenOptions::new();
options.read(true);
match dir.open_with(rel, &options) {
Ok(file) => Ok(Some(file)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err).with_context(|| {
localization::message(keys::RUNNER_IO_DYNDEP_READ).with_arg("path", rel.as_str())
}),
}
}
fn verified_sidecar_size(file: &cap_std::fs_utf8::File, rel: &Utf8Path) -> Result<u64> {
let size = file
.metadata()
.with_context(|| {
localization::message(keys::RUNNER_IO_DYNDEP_READ).with_arg("path", rel.as_str())
})?
.len();
if size > MAX_VERIFIED_DYNDEP_SIZE {
return Err(anyhow!(
localization::message(keys::RUNNER_IO_DYNDEP_TOO_LARGE)
.with_arg("path", rel.as_str())
.with_arg("limit", MAX_VERIFIED_DYNDEP_SIZE)
));
}
Ok(size)
}
fn read_sidecar_content(
file: &mut cap_std::fs_utf8::File,
size: u64,
rel: &Utf8Path,
) -> Result<(Vec<u8>, bool)> {
let verified_size = usize::try_from(size).with_context(|| {
localization::message(keys::RUNNER_IO_DYNDEP_READ).with_arg("path", rel.as_str())
})?;
let mut buf = Vec::with_capacity(verified_size);
Read::by_ref(file)
.take(size)
.read_to_end(&mut buf)
.with_context(|| {
localization::message(keys::RUNNER_IO_DYNDEP_READ).with_arg("path", rel.as_str())
})?;
let mut growth_probe = [0_u8; 1];
let grew_while_reading = file.read(&mut growth_probe).with_context(|| {
localization::message(keys::RUNNER_IO_DYNDEP_READ).with_arg("path", rel.as_str())
})? != 0;
Ok((buf, grew_while_reading))
}
fn content_outcome(content: &[u8], expected: &str, grew_while_reading: bool) -> ReadOutcome {
if !grew_while_reading && content == expected.as_bytes() {
ReadOutcome::Matching
} else {
ReadOutcome::Mismatch
}
}
fn write_atomic(dir: &Dir, rel: &Utf8Path, content: &str) -> Result<()> {
let (temp, mut file) = create_unique_temp_file(dir, rel)?;
let write_result = write_and_sync_temp_file(&mut file, rel, content);
drop(file);
let result = write_result.and_then(|()| rename_temp_file(dir, &temp, rel, content));
if result.is_err() {
drop(dir.remove_file(&temp));
}
result
}
fn create_unique_temp_file(
dir: &Dir,
rel: &Utf8Path,
) -> Result<(Utf8PathBuf, cap_std::fs_utf8::File)> {
let mut names = TempNameSource::for_operation();
create_unique_temp_file_with_source(dir, rel, &mut names)
}
fn create_unique_temp_file_with_source(
dir: &Dir,
rel: &Utf8Path,
names: &mut TempNameSource,
) -> Result<(Utf8PathBuf, cap_std::fs_utf8::File)> {
for _ in 0..MAX_TEMP_FILE_ATTEMPTS {
let temp = names.next_name(rel);
if let Some(file) = create_temp_file(dir, &temp, rel)? {
return Ok((temp, file));
}
telemetry::record_temp_file_retry("retry");
}
telemetry::record_temp_file_retry("exhausted");
Err(anyhow!(
localization::message(keys::RUNNER_IO_DYNDEP_TEMP_COLLISIONS)
.with_arg("path", rel.as_str())
))
}
fn create_temp_file(
dir: &Dir,
temp: &Utf8Path,
rel: &Utf8Path,
) -> Result<Option<cap_std::fs_utf8::File>> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
match dir.open_with(temp, &options) {
Ok(file) => Ok(Some(file)),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
Err(err) => Err(err).with_context(|| {
localization::message(keys::RUNNER_IO_DYNDEP_WRITE).with_arg("path", rel.as_str())
}),
}
}
fn write_and_sync_temp_file(
file: &mut cap_std::fs_utf8::File,
rel: &Utf8Path,
content: &str,
) -> Result<()> {
file.write_all(content.as_bytes()).with_context(|| {
localization::message(keys::RUNNER_IO_DYNDEP_WRITE).with_arg("path", rel.as_str())
})?;
file.flush().with_context(|| {
localization::message(keys::RUNNER_IO_DYNDEP_WRITE).with_arg("path", rel.as_str())
})?;
file.sync_all().with_context(|| {
localization::message(keys::RUNNER_IO_DYNDEP_WRITE).with_arg("path", rel.as_str())
})?;
Ok(())
}
fn rename_temp_file(dir: &Dir, temp: &Utf8Path, rel: &Utf8Path, content: &str) -> Result<()> {
match dir.rename(temp, dir, rel) {
Ok(()) => Ok(()),
Err(rename_error) => {
handle_rename_failure(dir, &RenameAttempt { temp, rel, content }, rename_error)
}
}
}
struct RenameAttempt<'a> {
temp: &'a Utf8Path,
rel: &'a Utf8Path,
content: &'a str,
}
fn handle_rename_failure(
dir: &Dir,
attempt: &RenameAttempt<'_>,
rename_error: std::io::Error,
) -> Result<()> {
if read_verified(dir, attempt.rel, attempt.content)? == ReadOutcome::Matching {
drop(dir.remove_file(attempt.temp));
return Ok(());
}
Err(rename_error).with_context(|| {
localization::message(keys::RUNNER_IO_DYNDEP_RENAME).with_arg("path", attempt.rel.as_str())
})
}
struct TempNameSource {
nonce: String,
sequence: u64,
}
impl TempNameSource {
fn for_operation() -> Self {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let mut thread_hasher = DefaultHasher::new();
std::thread::current().id().hash(&mut thread_hasher);
let nonce = format!(
"{}.{}.{}",
std::process::id(),
timestamp,
thread_hasher.finish()
);
Self::new(nonce)
}
const fn new(nonce: String) -> Self {
Self { nonce, sequence: 0 }
}
fn next_name(&mut self, rel: &Utf8Path) -> Utf8PathBuf {
let name = rel.file_name().unwrap_or("sidecar.dd");
let sequence = self.sequence;
self.sequence += 1;
let temp_name = format!("{name}.{}.{sequence}.tmp", self.nonce);
rel.parent().map_or_else(
|| Utf8PathBuf::from(&temp_name),
|parent| parent.join(&temp_name),
)
}
}
#[cfg(test)]
#[path = "dyndep_files_tests.rs"]
mod tests;