use crate::{Result, RevitFile};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
pub fn copy_file(src: &Path, dst: &Path) -> Result<()> {
let mut rf = RevitFile::open(src)?;
let streams = rf.stream_names();
let out_file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(dst)?;
let mut out = cfb::CompoundFile::create(out_file)
.map_err(|e| crate::Error::Cfb(format!("create dst: {e}")))?;
let mut created: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for name in &streams {
let norm = if name.starts_with('/') {
name.clone()
} else {
format!("/{name}")
};
let parts: Vec<&str> = norm.split('/').filter(|s| !s.is_empty()).collect();
for n in 1..parts.len() {
let parent = format!("/{}", parts[..n].join("/"));
if created.insert(parent.clone()) {
out.create_storage(&parent)
.map_err(|e| crate::Error::Cfb(format!("create_storage {parent}: {e}")))?;
}
}
}
for name in streams {
let data = rf.read_stream(&name)?;
let path = if name.starts_with('/') {
name.clone()
} else {
format!("/{name}")
};
let mut s = out
.create_stream(&path)
.map_err(|e| crate::Error::Cfb(format!("create_stream {path}: {e}")))?;
s.write_all(&data)
.map_err(|e| crate::Error::Cfb(format!("write_all {path}: {e}")))?;
}
out.flush()
.map_err(|e| crate::Error::Cfb(format!("flush: {e}")))?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct StreamPatch {
pub stream_name: String,
pub new_decompressed: Vec<u8>,
pub framing: StreamFraming,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamFraming {
RawGzipFromZero,
CustomPrefix8,
Verbatim,
}
pub fn write_with_patches(src: &Path, dst: &Path, patches: &[StreamPatch]) -> Result<()> {
use crate::compression;
let mut rf = RevitFile::open(src)?;
let streams = rf.stream_names();
let stream_set: std::collections::BTreeSet<&str> = streams.iter().map(|s| s.as_str()).collect();
for p in patches {
if !stream_set.contains(p.stream_name.as_str()) {
return Err(crate::Error::StreamNotFound(p.stream_name.clone()));
}
}
if patches.is_empty() && src != dst {
let dst_parent = dst.parent().unwrap_or_else(|| Path::new("."));
let dst_name = dst
.file_name()
.ok_or_else(|| crate::Error::Cfb("dst has no filename component".into()))?
.to_string_lossy()
.to_string();
let tmp_name = format!(".{dst_name}.copy-{}", std::process::id());
let tmp_path = dst_parent.join(&tmp_name);
std::fs::copy(src, &tmp_path)?;
std::fs::rename(&tmp_path, dst)?;
return Ok(());
}
if src != dst {
drop(rf);
let dst_parent = dst.parent().unwrap_or_else(|| Path::new("."));
let dst_name = dst
.file_name()
.ok_or_else(|| crate::Error::Cfb("dst has no filename component".into()))?
.to_string_lossy()
.to_string();
let tmp_name = format!(".{dst_name}.rw-{}", std::process::id());
let tmp_path = dst_parent.join(&tmp_name);
struct InplaceGuard {
path: Option<std::path::PathBuf>,
}
impl Drop for InplaceGuard {
fn drop(&mut self) {
if let Some(p) = self.path.take() {
let _ = std::fs::remove_file(&p);
}
}
}
let mut guard = InplaceGuard {
path: Some(tmp_path.clone()),
};
std::fs::copy(src, &tmp_path)?;
{
let mut rw = cfb::open_rw(&tmp_path)
.map_err(|e| crate::Error::Cfb(format!("open_rw {}: {e}", tmp_path.display())))?;
for p in patches {
let data = match p.framing {
StreamFraming::RawGzipFromZero => {
compression::truncated_gzip_encode(&p.new_decompressed)?
}
StreamFraming::CustomPrefix8 => {
compression::truncated_gzip_encode_with_prefix8(&p.new_decompressed)?
}
StreamFraming::Verbatim => p.new_decompressed.clone(),
};
let path = if p.stream_name.starts_with('/') {
p.stream_name.clone()
} else {
format!("/{}", p.stream_name)
};
let mut s = rw
.create_stream(&path)
.map_err(|e| crate::Error::Cfb(format!("create_stream {path}: {e}")))?;
s.write_all(&data)
.map_err(|e| crate::Error::Cfb(format!("write_all {path}: {e}")))?;
s.flush()
.map_err(|e| crate::Error::Cfb(format!("flush {path}: {e}")))?;
}
rw.flush()
.map_err(|e| crate::Error::Cfb(format!("flush: {e}")))?;
}
std::fs::rename(&tmp_path, dst)?;
guard.path = None;
return Ok(());
}
let dst_parent = dst.parent().unwrap_or_else(|| Path::new("."));
let dst_name = dst
.file_name()
.ok_or_else(|| crate::Error::Cfb("dst has no filename component".into()))?
.to_string_lossy()
.to_string();
let tmp_name = format!(".{dst_name}.tmp-{}", std::process::id());
let tmp_path = dst_parent.join(&tmp_name);
struct TempGuard {
path: Option<std::path::PathBuf>,
}
impl Drop for TempGuard {
fn drop(&mut self) {
if let Some(p) = self.path.take() {
let _ = std::fs::remove_file(&p);
}
}
}
let mut guard = TempGuard {
path: Some(tmp_path.clone()),
};
let out_file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)?;
let mut out = cfb::CompoundFile::create(out_file)
.map_err(|e| crate::Error::Cfb(format!("create tmp: {e}")))?;
let mut created: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for name in &streams {
let norm = if name.starts_with('/') {
name.clone()
} else {
format!("/{name}")
};
let parts: Vec<&str> = norm.split('/').filter(|s| !s.is_empty()).collect();
for n in 1..parts.len() {
let parent = format!("/{}", parts[..n].join("/"));
if created.insert(parent.clone()) {
out.create_storage(&parent)
.map_err(|e| crate::Error::Cfb(format!("create_storage {parent}: {e}")))?;
}
}
}
for name in streams {
let patch = patches.iter().find(|p| p.stream_name == name);
let data = if let Some(p) = patch {
match p.framing {
StreamFraming::RawGzipFromZero => {
compression::truncated_gzip_encode(&p.new_decompressed)?
}
StreamFraming::CustomPrefix8 => {
compression::truncated_gzip_encode_with_prefix8(&p.new_decompressed)?
}
StreamFraming::Verbatim => p.new_decompressed.clone(),
}
} else {
rf.read_stream(&name)?
};
let path = if name.starts_with('/') {
name.clone()
} else {
format!("/{name}")
};
let mut s = out
.create_stream(&path)
.map_err(|e| crate::Error::Cfb(format!("create_stream {path}: {e}")))?;
s.write_all(&data)
.map_err(|e| crate::Error::Cfb(format!("write_all {path}: {e}")))?;
}
out.flush()
.map_err(|e| crate::Error::Cfb(format!("flush: {e}")))?;
drop(out);
std::fs::rename(&tmp_path, dst).map_err(|e| {
crate::Error::Cfb(format!(
"rename {} -> {}: {e}",
tmp_path.display(),
dst.display()
))
})?;
guard.path = None;
Ok(())
}
pub fn file_guid(path: &Path) -> Result<Option<String>> {
let mut rf = RevitFile::open(path)?;
let stream_name = "BasicFileInfo";
if !rf.stream_names().iter().any(|s| s == stream_name) {
return Ok(None);
}
let bytes = rf.read_stream(stream_name)?;
let info = crate::basic_file_info::BasicFileInfo::from_bytes(&bytes)
.map_err(|e| crate::Error::BasicFileInfo(format!("parse: {e}")))?;
Ok(info.guid)
}
pub fn guid_preserved(src: &Path, dst: &Path) -> Result<bool> {
let a = file_guid(src)?;
let b = file_guid(dst)?;
Ok(a == b)
}
pub fn file_history_entries(path: &Path) -> Result<Vec<String>> {
let mut rf = RevitFile::open(path)?;
let stream_name = crate::streams::GLOBAL_LATEST;
if !rf.stream_names().iter().any(|s| s == stream_name) {
return Ok(Vec::new());
}
match crate::object_graph::DocumentHistory::from_revit_file(&mut rf) {
Ok(h) => Ok(h.entries),
Err(_) => Ok(Vec::new()),
}
}
pub fn history_entries_preserved(src: &Path, dst: &Path) -> Result<bool> {
let a = file_history_entries(src)?;
let b = file_history_entries(dst)?;
Ok(a == b)
}
#[derive(Debug, Clone)]
pub struct StreamVerification {
pub stream_name: String,
pub match_: bool,
pub first_diff_at: Option<usize>,
pub decompress_error: Option<String>,
pub actual_len: usize,
pub expected_len: usize,
}
#[derive(Debug, Clone, Default)]
pub struct StreamVerificationReport {
pub streams: Vec<StreamVerification>,
}
impl StreamVerificationReport {
pub fn all_matched(&self) -> bool {
!self.streams.is_empty() && self.streams.iter().all(|s| s.match_)
}
pub fn failure_count(&self) -> usize {
self.streams.iter().filter(|s| !s.match_).count()
}
pub fn failures(&self) -> impl Iterator<Item = &StreamVerification> {
self.streams.iter().filter(|s| !s.match_)
}
}
pub fn decompress_stream(dst: &Path, name: &str, framing: StreamFraming) -> Result<Vec<u8>> {
let mut rf = RevitFile::open(dst)?;
let raw = rf.read_stream(name)?;
match framing {
StreamFraming::RawGzipFromZero => crate::compression::inflate_at(&raw, 0),
StreamFraming::CustomPrefix8 => {
if raw.len() < 8 {
return Err(crate::Error::Cfb(format!(
"stream '{name}' too short for CustomPrefix8 framing: {} bytes",
raw.len()
)));
}
crate::compression::inflate_at(&raw, 8)
}
StreamFraming::Verbatim => Ok(raw),
}
}
pub fn verify_patches_applied(
dst: &Path,
patches: &[StreamPatch],
) -> Result<StreamVerificationReport> {
let mut report = StreamVerificationReport::default();
for p in patches {
match decompress_stream(dst, &p.stream_name, p.framing) {
Ok(actual) => {
let first_diff = actual
.iter()
.zip(p.new_decompressed.iter())
.position(|(a, b)| a != b)
.or_else(|| {
if actual.len() != p.new_decompressed.len() {
Some(actual.len().min(p.new_decompressed.len()))
} else {
None
}
});
let match_ = actual == p.new_decompressed;
report.streams.push(StreamVerification {
stream_name: p.stream_name.clone(),
match_,
first_diff_at: if match_ { None } else { first_diff },
decompress_error: None,
actual_len: actual.len(),
expected_len: p.new_decompressed.len(),
});
}
Err(e) => {
report.streams.push(StreamVerification {
stream_name: p.stream_name.clone(),
match_: false,
first_diff_at: None,
decompress_error: Some(e.to_string()),
actual_len: 0,
expected_len: p.new_decompressed.len(),
});
}
}
}
Ok(report)
}
pub fn write_with_patches_verified(
src: &Path,
dst: &Path,
patches: &[StreamPatch],
) -> Result<StreamVerificationReport> {
write_with_patches(src, dst, patches)?;
verify_patches_applied(dst, patches)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stream_patch_struct_builds() {
let p = StreamPatch {
stream_name: "Formats/Latest".into(),
new_decompressed: vec![0x1fu8, 0x8b, 0x08],
framing: StreamFraming::RawGzipFromZero,
};
assert_eq!(p.stream_name, "Formats/Latest");
assert_eq!(p.framing, StreamFraming::RawGzipFromZero);
}
fn temp_path(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"rvt-writer-test-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
name
));
p
}
fn build_tiny_cfb(path: &Path, payload: &[u8]) -> Result<()> {
use crate::compression::truncated_gzip_encode;
let compressed = truncated_gzip_encode(payload)?;
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(path)?;
let mut cfb =
cfb::CompoundFile::create(f).map_err(|e| crate::Error::Cfb(format!("create: {e}")))?;
cfb.create_storage("/Formats")
.map_err(|e| crate::Error::Cfb(format!("storage: {e}")))?;
let mut s = cfb
.create_stream("/Formats/Latest")
.map_err(|e| crate::Error::Cfb(format!("stream: {e}")))?;
s.write_all(&compressed)
.map_err(|e| crate::Error::Cfb(format!("write: {e}")))?;
drop(s);
cfb.flush()
.map_err(|e| crate::Error::Cfb(format!("flush: {e}")))?;
Ok(())
}
fn build_cfb_with_raw_formats_latest(path: &Path, bytes: &[u8]) -> Result<()> {
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(path)?;
let mut cfb =
cfb::CompoundFile::create(f).map_err(|e| crate::Error::Cfb(format!("create: {e}")))?;
cfb.create_storage("/Formats")
.map_err(|e| crate::Error::Cfb(format!("storage: {e}")))?;
let mut s = cfb
.create_stream("/Formats/Latest")
.map_err(|e| crate::Error::Cfb(format!("stream: {e}")))?;
s.write_all(bytes)
.map_err(|e| crate::Error::Cfb(format!("write: {e}")))?;
drop(s);
cfb.flush()
.map_err(|e| crate::Error::Cfb(format!("flush: {e}")))?;
Ok(())
}
#[test]
fn verify_patches_applied_round_trips_clean_edit() {
let src = temp_path("src.rvt");
let dst = temp_path("dst.rvt");
build_tiny_cfb(&src, b"old-bytes-for-stream").unwrap();
let new_payload = b"new-payload-after-patch".to_vec();
let patches = vec![StreamPatch {
stream_name: "Formats/Latest".into(),
new_decompressed: new_payload.clone(),
framing: StreamFraming::RawGzipFromZero,
}];
write_with_patches(&src, &dst, &patches).unwrap();
let report = verify_patches_applied(&dst, &patches).unwrap();
assert!(
report.all_matched(),
"expected all streams to verify: {:?}",
report.streams
);
assert_eq!(report.failure_count(), 0);
assert_eq!(report.streams.len(), 1);
assert_eq!(report.streams[0].expected_len, new_payload.len());
assert_eq!(report.streams[0].actual_len, new_payload.len());
assert!(report.streams[0].decompress_error.is_none());
std::fs::remove_file(&src).ok();
std::fs::remove_file(&dst).ok();
}
#[test]
fn empty_patch_round_trip_is_byte_identical_stored_accurate() {
let src = temp_path("empty_patch_src.rvt");
let dst = temp_path("empty_patch_dst.rvt");
build_tiny_cfb(&src, b"identity-payload-bytes").unwrap();
write_with_patches(&src, &dst, &[]).unwrap();
let a = std::fs::read(&src).unwrap();
let b = std::fs::read(&dst).unwrap();
assert_eq!(a, b, "empty-patch must copy stored CFB bytes unchanged");
std::fs::remove_file(&src).ok();
std::fs::remove_file(&dst).ok();
}
#[test]
fn large_formats_patch_round_trips_without_false_page_strip() {
use crate::compression::{
REVIT_STORED_PAGE_BYTES, strip_revit_page_checksums, truncated_gzip_encode,
};
let src = temp_path("large_patch_src.rvt");
let dst = temp_path("large_patch_dst.rvt");
build_tiny_cfb(&src, b"seed").unwrap();
let mut state = 0xA5A5_u32;
let payload: Vec<u8> = (0..200_000)
.map(|_| {
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(state >> 16) as u8
})
.collect();
let encoded = truncated_gzip_encode(&payload).unwrap();
assert!(
encoded.len() >= REVIT_STORED_PAGE_BYTES,
"encoded fixture must be ≥ one stored page (got {})",
encoded.len()
);
assert_ne!(
strip_revit_page_checksums(&encoded),
encoded,
"strip on strip-clean writer output must change bytes at page boundaries"
);
let patches = vec![StreamPatch {
stream_name: "Formats/Latest".into(),
new_decompressed: payload.clone(),
framing: StreamFraming::RawGzipFromZero,
}];
write_with_patches(&src, &dst, &patches).unwrap();
let report = verify_patches_applied(&dst, &patches).unwrap();
assert!(
report.all_matched(),
"large writer patch must verify without page-strip: {:?}",
report.streams
);
let mut rf = RevitFile::open(&dst).unwrap();
let stored = rf.read_stream("Formats/Latest").unwrap();
assert_eq!(stored, encoded);
std::fs::remove_file(&src).ok();
std::fs::remove_file(&dst).ok();
}
#[test]
fn verify_patches_applied_detects_byte_mismatch() {
let src = temp_path("src.rvt");
let dst = temp_path("dst.rvt");
build_tiny_cfb(&src, b"original").unwrap();
let written_patches = vec![StreamPatch {
stream_name: "Formats/Latest".into(),
new_decompressed: b"actual-patch".to_vec(),
framing: StreamFraming::RawGzipFromZero,
}];
write_with_patches(&src, &dst, &written_patches).unwrap();
let expected_patches = vec![StreamPatch {
stream_name: "Formats/Latest".into(),
new_decompressed: b"different-expected".to_vec(),
framing: StreamFraming::RawGzipFromZero,
}];
let report = verify_patches_applied(&dst, &expected_patches).unwrap();
assert!(!report.all_matched());
assert_eq!(report.failure_count(), 1);
let fail = &report.streams[0];
assert!(!fail.match_);
assert!(fail.first_diff_at.is_some());
std::fs::remove_file(&src).ok();
std::fs::remove_file(&dst).ok();
}
#[test]
fn write_with_patches_verified_is_all_in_one() {
let src = temp_path("src.rvt");
let dst = temp_path("dst.rvt");
build_tiny_cfb(&src, b"seed").unwrap();
let patches = vec![StreamPatch {
stream_name: "Formats/Latest".into(),
new_decompressed: b"fresh-payload".to_vec(),
framing: StreamFraming::RawGzipFromZero,
}];
let report = write_with_patches_verified(&src, &dst, &patches).unwrap();
assert!(report.all_matched());
std::fs::remove_file(&src).ok();
std::fs::remove_file(&dst).ok();
}
#[test]
fn verify_report_failures_iterator_yields_only_fails() {
let ok = StreamVerification {
stream_name: "Good".into(),
match_: true,
first_diff_at: None,
decompress_error: None,
actual_len: 4,
expected_len: 4,
};
let bad = StreamVerification {
stream_name: "Bad".into(),
match_: false,
first_diff_at: Some(2),
decompress_error: None,
actual_len: 4,
expected_len: 4,
};
let report = StreamVerificationReport {
streams: vec![ok, bad],
};
let fails: Vec<&str> = report.failures().map(|s| s.stream_name.as_str()).collect();
assert_eq!(fails, vec!["Bad"]);
assert_eq!(report.failure_count(), 1);
assert!(!report.all_matched());
}
#[test]
fn all_matched_false_for_empty_report() {
let report = StreamVerificationReport::default();
assert!(!report.all_matched());
}
#[test]
fn decompress_stream_surfaces_framing_errors() {
let src = temp_path("src.rvt");
build_tiny_cfb(&src, b"hello").unwrap();
let result = decompress_stream(&src, "Formats/Latest", StreamFraming::CustomPrefix8);
assert!(result.is_err());
std::fs::remove_file(&src).ok();
}
#[test]
fn verify_patches_applied_reports_corrupt_gzip() {
let src = temp_path("corrupt-gzip.rvt");
build_cfb_with_raw_formats_latest(&src, b"not a gzip stream").unwrap();
let patches = vec![StreamPatch {
stream_name: "Formats/Latest".into(),
new_decompressed: b"expected".to_vec(),
framing: StreamFraming::RawGzipFromZero,
}];
let report = verify_patches_applied(&src, &patches).unwrap();
assert_eq!(report.streams.len(), 1);
assert_eq!(report.failure_count(), 1);
let failure = &report.streams[0];
assert!(!failure.match_);
assert_eq!(failure.actual_len, 0);
assert!(
failure.decompress_error.is_some(),
"corrupt gzip must be reported as a decompression failure"
);
std::fs::remove_file(&src).ok();
}
fn build_cfb_with_basic_file_info(path: &Path, info_bytes: &[u8]) -> Result<()> {
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(path)?;
let mut cfb =
cfb::CompoundFile::create(f).map_err(|e| crate::Error::Cfb(format!("create: {e}")))?;
let mut s = cfb
.create_stream("/BasicFileInfo")
.map_err(|e| crate::Error::Cfb(format!("stream: {e}")))?;
s.write_all(info_bytes)
.map_err(|e| crate::Error::Cfb(format!("write: {e}")))?;
drop(s);
cfb.flush()
.map_err(|e| crate::Error::Cfb(format!("flush: {e}")))?;
Ok(())
}
#[test]
fn file_guid_round_trips_via_basic_file_info() {
let src = temp_path("guid.rvt");
let info = crate::basic_file_info::BasicFileInfo {
version: 2024,
build: Some("20230308_1635(x64)".into()),
original_path: None,
guid: Some("aabbccdd-1122-3344-5566-778899aabbcc".into()),
locale: None,
properties: Vec::new(),
raw_text: String::new(),
};
build_cfb_with_basic_file_info(&src, &info.encode()).unwrap();
let guid = file_guid(&src).unwrap();
assert_eq!(
guid.as_deref(),
Some("aabbccdd-1122-3344-5566-778899aabbcc")
);
std::fs::remove_file(&src).ok();
}
#[test]
fn file_guid_returns_none_when_no_basic_file_info() {
let src = temp_path("no_guid.rvt");
build_tiny_cfb(&src, b"nothing-here").unwrap();
let guid = file_guid(&src).unwrap();
assert!(guid.is_none());
std::fs::remove_file(&src).ok();
}
#[test]
fn copy_file_preserves_guid() {
let src = temp_path("src_guid.rvt");
let dst = temp_path("dst_guid.rvt");
let info = crate::basic_file_info::BasicFileInfo {
version: 2024,
build: Some("20230308_1635(x64)".into()),
original_path: None,
guid: Some("12345678-9abc-def0-1234-56789abcdef0".into()),
locale: None,
properties: Vec::new(),
raw_text: String::new(),
};
build_cfb_with_basic_file_info(&src, &info.encode()).unwrap();
copy_file(&src, &dst).unwrap();
assert!(guid_preserved(&src, &dst).unwrap());
std::fs::remove_file(&src).ok();
std::fs::remove_file(&dst).ok();
}
#[test]
fn write_with_no_basic_file_info_patch_preserves_guid() {
let src = temp_path("src_pres.rvt");
let dst = temp_path("dst_pres.rvt");
let info = crate::basic_file_info::BasicFileInfo {
version: 2024,
build: Some("20230308_1635(x64)".into()),
original_path: None,
guid: Some("99999999-8888-7777-6666-555544443333".into()),
locale: None,
properties: Vec::new(),
raw_text: String::new(),
};
{
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&src)
.unwrap();
let mut cfb = cfb::CompoundFile::create(f).unwrap();
cfb.create_storage("/Formats").unwrap();
let mut s = cfb.create_stream("/BasicFileInfo").unwrap();
s.write_all(&info.encode()).unwrap();
drop(s);
let compressed = crate::compression::truncated_gzip_encode(b"original").unwrap();
let mut s2 = cfb.create_stream("/Formats/Latest").unwrap();
s2.write_all(&compressed).unwrap();
drop(s2);
cfb.flush().unwrap();
}
let patches = vec![StreamPatch {
stream_name: "Formats/Latest".into(),
new_decompressed: b"patched-bytes".to_vec(),
framing: StreamFraming::RawGzipFromZero,
}];
write_with_patches(&src, &dst, &patches).unwrap();
assert!(guid_preserved(&src, &dst).unwrap());
std::fs::remove_file(&src).ok();
std::fs::remove_file(&dst).ok();
}
#[test]
fn guid_diverges_when_dst_has_different_basic_file_info() {
let src = temp_path("a_guid.rvt");
let dst = temp_path("b_guid.rvt");
let info_a = crate::basic_file_info::BasicFileInfo {
version: 2024,
build: None,
original_path: None,
guid: Some("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".into()),
locale: None,
properties: Vec::new(),
raw_text: String::new(),
};
let info_b = crate::basic_file_info::BasicFileInfo {
guid: Some("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb".into()),
..info_a.clone()
};
build_cfb_with_basic_file_info(&src, &info_a.encode()).unwrap();
build_cfb_with_basic_file_info(&dst, &info_b.encode()).unwrap();
assert!(!guid_preserved(&src, &dst).unwrap());
std::fs::remove_file(&src).ok();
std::fs::remove_file(&dst).ok();
}
fn synth_global_latest_with_history(versions: &[&str]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&[0u8; 64]);
for v in versions {
out.extend_from_slice(&[0u8; 4]);
for unit in v.encode_utf16() {
out.extend_from_slice(&unit.to_le_bytes());
}
out.extend_from_slice(&[0, 0]);
}
out
}
fn build_cfb_with_global_latest(path: &Path, decompressed: &[u8]) -> Result<()> {
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(path)?;
let mut cfb =
cfb::CompoundFile::create(f).map_err(|e| crate::Error::Cfb(format!("create: {e}")))?;
cfb.create_storage("/Global")
.map_err(|e| crate::Error::Cfb(format!("storage: {e}")))?;
let compressed = crate::compression::truncated_gzip_encode_with_prefix8(decompressed)?;
let mut s = cfb
.create_stream("/Global/Latest")
.map_err(|e| crate::Error::Cfb(format!("stream: {e}")))?;
s.write_all(&compressed)
.map_err(|e| crate::Error::Cfb(format!("write: {e}")))?;
drop(s);
cfb.flush()
.map_err(|e| crate::Error::Cfb(format!("flush: {e}")))?;
Ok(())
}
#[test]
fn file_history_recovers_synthetic_chain() {
let src = temp_path("history.rvt");
let decomp = synth_global_latest_with_history(&[
"Revit 2018 - Initial",
"Revit 2020 - Upgrade",
"Revit 2024",
]);
build_cfb_with_global_latest(&src, &decomp).unwrap();
let entries = file_history_entries(&src).unwrap();
assert!(entries.iter().any(|e| e.contains("2018")));
assert!(entries.iter().any(|e| e.contains("2024")));
std::fs::remove_file(&src).ok();
}
#[test]
fn file_history_returns_empty_when_no_global_latest() {
let src = temp_path("no_history.rvt");
build_tiny_cfb(&src, b"nothing").unwrap();
let entries = file_history_entries(&src).unwrap();
assert!(entries.is_empty());
std::fs::remove_file(&src).ok();
}
#[test]
fn copy_file_preserves_history_chain() {
let src = temp_path("src_hist.rvt");
let dst = temp_path("dst_hist.rvt");
let decomp =
synth_global_latest_with_history(&["Revit 2022 - Original", "Revit 2024 - Upgrade"]);
build_cfb_with_global_latest(&src, &decomp).unwrap();
copy_file(&src, &dst).unwrap();
assert!(history_entries_preserved(&src, &dst).unwrap());
std::fs::remove_file(&src).ok();
std::fs::remove_file(&dst).ok();
}
#[test]
fn write_with_patches_non_global_preserves_history() {
let src = temp_path("src_hist2.rvt");
let dst = temp_path("dst_hist2.rvt");
let decomp = synth_global_latest_with_history(&["Revit 2024 - only"]);
{
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&src)
.unwrap();
let mut cfb = cfb::CompoundFile::create(f).unwrap();
cfb.create_storage("/Global").unwrap();
cfb.create_storage("/Formats").unwrap();
let glob = crate::compression::truncated_gzip_encode_with_prefix8(&decomp).unwrap();
let mut s1 = cfb.create_stream("/Global/Latest").unwrap();
s1.write_all(&glob).unwrap();
drop(s1);
let fmt = crate::compression::truncated_gzip_encode(b"formats-body").unwrap();
let mut s2 = cfb.create_stream("/Formats/Latest").unwrap();
s2.write_all(&fmt).unwrap();
drop(s2);
cfb.flush().unwrap();
}
let patches = vec![StreamPatch {
stream_name: "Formats/Latest".into(),
new_decompressed: b"new-formats".to_vec(),
framing: StreamFraming::RawGzipFromZero,
}];
write_with_patches(&src, &dst, &patches).unwrap();
assert!(history_entries_preserved(&src, &dst).unwrap());
std::fs::remove_file(&src).ok();
std::fs::remove_file(&dst).ok();
}
#[test]
fn history_diverges_when_global_latest_changed() {
let src = temp_path("hist_a.rvt");
let dst = temp_path("hist_b.rvt");
let decomp_a = synth_global_latest_with_history(&["Revit 2018"]);
let decomp_b = synth_global_latest_with_history(&["Revit 2024"]);
build_cfb_with_global_latest(&src, &decomp_a).unwrap();
build_cfb_with_global_latest(&dst, &decomp_b).unwrap();
assert!(!history_entries_preserved(&src, &dst).unwrap());
std::fs::remove_file(&src).ok();
std::fs::remove_file(&dst).ok();
}
}