use std::fmt::Write as _;
use std::fs::File;
use std::io::{self, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use anyhow::{anyhow, bail, Context, Result};
use cadmpeg_ir::codec::ExportPlan;
use cadmpeg_ir::report::{DecodeReport, ExportReport};
use cadmpeg_ir::{decode_sidecar_path, DecodeSidecar, SourceFidelity};
use sha2::{Digest, Sha256};
#[derive(Debug, Default, Clone, Copy)]
pub struct ArtifactStore;
impl ArtifactStore {
pub fn sidecar_path(cadir_path: &Path) -> PathBuf {
decode_sidecar_path(cadir_path)
}
pub fn read_prefix(path: &Path, n: usize) -> Result<Vec<u8>> {
let mut f = File::open(path).with_context(|| format!("opening {}", path.display()))?;
let mut buf = Vec::with_capacity(n);
let mut chunk = vec![0_u8; 64 * 1024].into_boxed_slice();
while buf.len() < n {
let remaining = n - buf.len();
let chunk_len = remaining.min(chunk.len());
let read = f.read(&mut chunk[..chunk_len])?;
if read == 0 {
break;
}
buf.extend_from_slice(&chunk[..read]);
}
Ok(buf)
}
pub fn read_bounded_text(path: &Path, max_bytes: u64) -> Result<String> {
let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
let mut limited = file.take(max_bytes.saturating_add(1));
let mut text = String::new();
limited
.read_to_string(&mut text)
.with_context(|| format!("reading UTF-8 text from {}", path.display()))?;
if text.len() as u64 > max_bytes {
return Err(anyhow!(
"{} exceeds the configured {}-byte input limit",
path.display(),
max_bytes
));
}
Ok(text)
}
pub fn load_matching_sidecar(
cadir_path: &Path,
cadir_bytes: &[u8],
max_bytes: u64,
) -> Result<Option<DecodeSidecar>> {
let path = Self::sidecar_path(cadir_path);
if !path.exists() {
return Ok(None);
}
let text = Self::read_bounded_text(&path, max_bytes)
.with_context(|| format!("reading decode sidecar {}", path.display()))?;
let sidecar = DecodeSidecar::from_json(&text)
.with_context(|| format!("parsing decode sidecar {}", path.display()))?;
if !sidecar.matches(cadir_bytes) {
return Err(anyhow!(
"decode sidecar {} does not match {}",
path.display(),
cadir_path.display()
));
}
Ok(Some(sidecar))
}
pub fn check_output_path(input: &Path, output: &Path, force: bool) -> Result<()> {
let input = std::fs::canonicalize(input)
.with_context(|| format!("canonicalizing {}", input.display()))?;
let parent = output
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let output_absolute = if output.exists() {
std::fs::canonicalize(output)?
} else {
std::fs::canonicalize(parent)?.join(
output
.file_name()
.ok_or_else(|| anyhow!("output path has no filename"))?,
)
};
if input == output_absolute {
bail!("refusing to overwrite input {}", input.display());
}
if output.exists() && !force {
bail!("{} exists; pass --force to overwrite", output.display());
}
Ok(())
}
pub fn write_bytes_atomic(output: &Path, bytes: &[u8]) -> Result<()> {
let parent = output
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let mut temporary = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("creating temporary output in {}", parent.display()))?;
temporary
.write_all(bytes)
.with_context(|| format!("writing temporary output for {}", output.display()))?;
temporary
.persist(output)
.map_err(|error| error.error)
.with_context(|| format!("persisting temporary output to {}", output.display()))?;
Ok(())
}
pub fn write_output(input: &Path, output: &Path, bytes: &[u8], force: bool) -> Result<()> {
Self::check_output_path(input, output, force)?;
Self::write_bytes_atomic(output, bytes)
}
pub fn write_plan_atomic(
output: &Path,
plan: ExportPlan<'_>,
with_digest: bool,
) -> Result<(ExportReport, Option<String>)> {
let parent = output
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let mut temporary = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("creating temporary output in {}", parent.display()))?;
let mut sink = TempFileWriter {
file: &mut temporary,
hasher: with_digest.then(Sha256::new),
};
let mut writer = BufWriter::new(&mut sink);
let report = plan
.write_to(&mut writer)
.with_context(|| format!("writing temporary output for {}", output.display()))?;
writer
.flush()
.with_context(|| format!("flushing temporary output for {}", output.display()))?;
drop(writer);
let digest = sink.finish();
temporary
.persist(output)
.map_err(|error| error.error)
.with_context(|| format!("persisting temporary output to {}", output.display()))?;
Ok((report, digest))
}
pub fn persist_decode_sidecar(
cadir_path: &Path,
cadir_sha256: Option<&str>,
report: Option<&DecodeReport>,
fidelity: Option<&SourceFidelity>,
) -> Result<SidecarPersistOutcome> {
let path = Self::sidecar_path(cadir_path);
match (report, fidelity) {
(Some(report), Some(fidelity)) => {
let cadir_sha256 = cadir_sha256.ok_or_else(|| {
anyhow!("missing CADIR digest while writing decode-fidelity sidecar")
})?;
let sidecar =
DecodeSidecar::bind_sha256(cadir_sha256, report.clone(), fidelity.clone());
let mut bytes = sidecar.to_canonical_json()?.into_bytes();
bytes.push(b'\n');
Self::write_bytes_atomic(&path, &bytes)?;
Ok(SidecarPersistOutcome::Wrote(path))
}
_ if path.exists() => {
std::fs::remove_file(&path)
.with_context(|| format!("removing stale decode sidecar {}", path.display()))?;
Ok(SidecarPersistOutcome::RemovedStale(path))
}
_ => Ok(SidecarPersistOutcome::Absent),
}
}
}
#[derive(Debug)]
pub enum SidecarPersistOutcome {
Wrote(PathBuf),
RemovedStale(PathBuf),
Absent,
}
struct TempFileWriter<'a> {
file: &'a mut tempfile::NamedTempFile,
hasher: Option<Sha256>,
}
impl TempFileWriter<'_> {
fn finish(self) -> Option<String> {
self.hasher.map(|hasher| {
let digest = hasher.finalize();
let mut encoded = String::with_capacity(digest.len() * 2);
for byte in digest {
write!(encoded, "{byte:02x}").expect("writing a digest to a String");
}
encoded
})
}
}
impl Write for TempFileWriter<'_> {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
let written = self.file.write(bytes)?;
if let Some(hasher) = &mut self.hasher {
hasher.update(&bytes[..written]);
}
Ok(written)
}
fn flush(&mut self) -> io::Result<()> {
self.file.flush()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::default_trait_access)]
mod tests {
use super::*;
use cadmpeg_ir::units::Units;
use cadmpeg_ir::{CadIr, DecodeReport, SourceFidelity};
#[test]
fn matching_sidecar_loads_and_mismatch_fails_closed() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("part.cadir.json");
let text = CadIr::empty(Units::default()).to_canonical_json().unwrap();
std::fs::write(&path, &text).unwrap();
let report = DecodeReport {
format: "test".into(),
container_only: false,
geometry_transferred: false,
coverage: Default::default(),
transfer_ledger: cadmpeg_ir::report::TransferLedger::default(),
losses: Vec::new(),
notes: Vec::new(),
};
let sidecar = DecodeSidecar::bind(text.as_bytes(), report, SourceFidelity::default());
std::fs::write(
ArtifactStore::sidecar_path(&path),
sidecar.to_canonical_json().unwrap(),
)
.unwrap();
let loaded =
ArtifactStore::load_matching_sidecar(&path, text.as_bytes(), 1024 * 1024).unwrap();
assert!(loaded.is_some());
std::fs::write(&path, format!("{text}\n")).unwrap();
let error = ArtifactStore::load_matching_sidecar(
&path,
format!("{text}\n").as_bytes(),
1024 * 1024,
)
.unwrap_err();
assert!(error.to_string().contains("does not match"));
}
#[test]
fn text_reader_refuses_input_above_the_configured_limit() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("large.json");
std::fs::write(&path, "12345").unwrap();
let error = ArtifactStore::read_bounded_text(&path, 4).unwrap_err();
assert!(error.to_string().contains("4-byte input limit"));
}
}