Files
OneCAD-Draft/src/io/recovery.rs
T
Hakan SevenandClaude Opus 5.5 04b2c07a38 Use the codec audit and repair in place of OCS's corruption guard
Opening a drawing no longer drops entities with its own sanity checks or
rewrites knotless splines; recovery runs the codec's repair and records
its actions and unresolved issues in the drawing's notifications. Xref
merges keep every entity. The dropped-entity counts, their command-line
warnings, recovery report rows and the automation "purged" field go with
the guard.

AUDIT now reports the codec's audit (counts plus the first 50 issues) and
asks whether to fix; Yes applies the codec repair as one undo step.

Cargo: codec 325fa98, kernel dfca391, graph 5d4e713.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 12:02:39 +03:00

705 lines
25 KiB
Rust

use std::path::{Path, PathBuf};
use crate::io::xref::{XrefInfo, XrefStatus};
use crate::scene::OpenTimings;
#[cfg(not(target_arch = "wasm32"))]
use sha2::{Digest, Sha256};
const REPORT_SCHEMA_VERSION: u16 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecoveryStatus {
Recovered,
Failed,
}
#[derive(Debug, Clone)]
pub struct RecoveryReport {
pub report_id: String,
pub status: RecoveryStatus,
pub tab_id: Option<u64>,
pub file_name: String,
pub source_path: Option<PathBuf>,
pub size_bytes: u64,
pub source_sha256: Option<String>,
pub read_stats: Option<codec::ReadStats>,
pub entities_scanned: usize,
pub parser_errors_recovered: usize,
pub diagnostics: Vec<(String, String)>,
pub references_checked: usize,
pub references_loaded: usize,
pub references_recovered: usize,
pub references_missing: usize,
pub references_failed: usize,
pub references_skipped: usize,
pub reference_details: Vec<(String, String, String, Option<String>)>,
pub reference_stats: Vec<(String, codec::ReadStats)>,
pub timings: OpenTimings,
pub total_ms: u32,
pub failure_phase: Option<String>,
pub error: Option<String>,
pub save_as_required: bool,
pub log_path: Option<PathBuf>,
pub log_error: Option<String>,
pub created_unix_seconds: u64,
}
impl RecoveryReport {
pub fn recovered(
tab_id: u64,
path: &Path,
size_bytes: u64,
source_sha256: Option<String>,
read_stats: Option<codec::ReadStats>,
entities_scanned: usize,
references: &[XrefInfo],
notifications: &codec::notification::NotificationCollection,
save_as_required: bool,
timings: OpenTimings,
total_ms: u32,
) -> Self {
let references_loaded = references
.iter()
.filter(|item| matches!(item.status, XrefStatus::Loaded | XrefStatus::Recovered))
.count();
let references_recovered = references
.iter()
.filter(|item| item.status == XrefStatus::Recovered)
.count();
let references_missing = references
.iter()
.filter(|item| item.status == XrefStatus::NotFound)
.count();
let references_failed = references
.iter()
.filter(|item| item.status == XrefStatus::Failed)
.count();
let references_skipped = references
.iter()
.filter(|item| item.status == XrefStatus::Unloaded)
.count();
let reference_details = references
.iter()
.enumerate()
.map(|(index, item)| {
let status = match item.status {
XrefStatus::Loaded => "loaded",
XrefStatus::Recovered => "recovered",
XrefStatus::Failed => "failed",
XrefStatus::NotFound => "not found",
XrefStatus::Unloaded => "skipped",
};
(
format!("reference-{:02}", index + 1),
private_path_label(&item.path),
status.to_string(),
item.source_sha256.clone(),
)
})
.collect();
let reference_stats = references
.iter()
.enumerate()
.filter_map(|(index, item)| {
let mut stats = item.read_stats.clone()?;
for diagnostic in &mut stats.diagnostics {
diagnostic.message = redact_known_paths(&diagnostic.message, path, references);
}
Some((format!("reference-{:02}", index + 1), stats))
})
.collect();
let parser_errors_recovered = read_stats
.as_ref()
.map(|stats| stats.recovered_errors)
.unwrap_or_else(|| {
notifications
.iter()
.filter(|item| {
item.notification_type == codec::notification::NotificationType::Error
})
.count()
});
let mut diagnostics: Vec<(String, String)> = notifications
.iter()
.map(|item| {
(
item.notification_type.to_string(),
redact_known_paths(&item.message, path, references),
)
})
.collect();
if notifications.omitted_count() > 0 {
diagnostics.insert(
0,
(
"truncated".to_string(),
format!(
"{} additional parser notifications were omitted after the safety limit",
notifications.omitted_count()
),
),
);
}
for (index, reference) in references.iter().enumerate() {
diagnostics.extend(reference.diagnostics.iter().map(|message| {
(
format!("Reference {:02}", index + 1),
redact_known_paths(message, path, references),
)
}));
}
let created_unix_seconds = unix_seconds();
Self {
report_id: report_id(source_sha256.as_deref(), created_unix_seconds),
status: RecoveryStatus::Recovered,
tab_id: Some(tab_id),
file_name: display_name(path),
source_path: Some(path.to_path_buf()),
size_bytes,
source_sha256,
read_stats,
entities_scanned,
parser_errors_recovered,
diagnostics,
references_checked: references.len(),
references_loaded,
references_recovered,
references_missing,
references_failed,
references_skipped,
reference_details,
reference_stats,
timings,
total_ms,
failure_phase: None,
error: None,
save_as_required,
log_path: None,
log_error: None,
created_unix_seconds,
}
}
pub fn failed(
path: Option<PathBuf>,
file_name: String,
size_bytes: u64,
source_sha256: Option<String>,
read_stats: Option<codec::ReadStats>,
failure_phase: String,
error: String,
total_ms: u32,
) -> Self {
let created_unix_seconds = unix_seconds();
Self {
report_id: report_id(source_sha256.as_deref(), created_unix_seconds),
status: RecoveryStatus::Failed,
tab_id: None,
file_name,
source_path: path,
size_bytes,
source_sha256,
parser_errors_recovered: read_stats
.as_ref()
.map(|stats| stats.recovered_errors)
.unwrap_or(0),
read_stats,
entities_scanned: 0,
diagnostics: Vec::new(),
references_checked: 0,
references_loaded: 0,
references_recovered: 0,
references_missing: 0,
references_failed: 0,
references_skipped: 0,
reference_details: Vec::new(),
reference_stats: Vec::new(),
timings: OpenTimings::default(),
total_ms,
failure_phase: Some(failure_phase),
error: Some(error),
save_as_required: false,
log_path: None,
log_error: None,
created_unix_seconds,
}
}
pub fn issues_found(&self) -> usize {
self.parser_errors_recovered
.saturating_add(self.references_recovered)
.saturating_add(self.references_failed)
.saturating_add(self.references_missing)
.saturating_add(usize::from(self.error.is_some()))
}
pub fn log_text(&self) -> String {
let mut lines = Vec::with_capacity(24);
lines.push("Open CAD Studio drawing recovery report".to_string());
lines.push(format!("Report schema: {}", REPORT_SCHEMA_VERSION));
lines.push(format!("Report ID: {}", self.report_id));
lines.push(format!("Application version: {}", env!("OCS_APP_VERSION")));
lines.push(format!("Application build: {}", env!("OCS_FULL_VERSION")));
lines.push(format!("Application revision: {}", env!("OCS_GIT_REV")));
lines.push(format!("Build profile: {}", env!("OCS_BUILD_PROFILE")));
lines.push(format!("Build features: {}", env!("OCS_BUILD_FEATURES")));
lines.push(format!("Reader version: {}", codec::VERSION));
lines.push(format!("Reader revision: {}", reader_revision()));
lines.push(format!("Platform: {}", diagnostic_platform()));
lines.push(format!(
"Created (Unix seconds): {}",
self.created_unix_seconds
));
lines.push(format!(
"File: {}",
private_file_label(&self.file_name, self.source_sha256.as_deref())
));
lines.push(format!("Size: {} bytes", self.size_bytes));
lines.push(format!(
"Source SHA-256: {}",
self.source_sha256.as_deref().unwrap_or("unavailable")
));
lines.push(format!(
"Result: {}",
match self.status {
RecoveryStatus::Recovered => "opened with a recovery report",
RecoveryStatus::Failed => "could not be opened",
}
));
lines.push(String::new());
lines.push("Summary".to_string());
if let Some(stats) = &self.read_stats {
lines.push(format!(
"Format: {}",
stats
.source_format
.map(|format| format.to_string())
.unwrap_or_else(|| "unknown".to_string())
));
lines.push(format!("Format version: {}", stats.source_version));
lines.push(format!(
"Maintenance version: {}",
stats.maintenance_version
));
lines.push(format!("Recovery mode: {}", stats.recovery_mode));
lines.push(format!("Stream completed: {}", stats.stream_completed));
lines.push(format!("Source sections: {}", stats.source_sections));
lines.push(format!("Observed source records: {}", stats.source_records));
lines.push(format!(
"Successfully decoded source records: {}",
stats.decoded_source_records
));
lines.push(format!(
"Known skipped source records: {}",
stats.skipped_source_records
));
lines.push(format!("Output entities: {}", stats.output_entities));
lines.push(format!("Output objects: {}", stats.output_objects));
lines.push(format!(
"Output table records: {}",
stats.output_table_records
));
}
lines.push(format!("Entities scanned: {}", self.entities_scanned));
lines.push(format!(
"Parser errors recovered: {}",
self.parser_errors_recovered
));
lines.push(format!("References checked: {}", self.references_checked));
lines.push(format!("References loaded: {}", self.references_loaded));
lines.push(format!(
"References recovered: {}",
self.references_recovered
));
lines.push(format!(
"References unavailable: {}",
self.references_missing
));
lines.push(format!("References failed: {}", self.references_failed));
lines.push(format!("References skipped: {}", self.references_skipped));
lines.push(format!(
"Save as new file required: {}",
if self.save_as_required { "yes" } else { "no" }
));
if !self.reference_details.is_empty() {
lines.push(String::new());
lines.push("References".to_string());
for (name, path, status, source_sha256) in &self.reference_details {
lines.push(format!(
"[{status}] {name}: {path}; SHA-256={}",
source_sha256.as_deref().unwrap_or("unavailable")
));
}
}
if !self.reference_stats.is_empty() {
lines.push(String::new());
lines.push("Referenced source statistics".to_string());
for (name, stats) in &self.reference_stats {
lines.push(format!(
"{name}: format={} version={} recovery={} completed={} observed-records={} decoded={} known-skipped={}",
stats
.source_format
.map(|format| format.to_string())
.unwrap_or_else(|| "unknown".to_string()),
stats.source_version,
stats.recovery_mode,
stats.stream_completed,
stats.source_records,
stats.decoded_source_records,
stats.skipped_source_records,
));
for diagnostic in stats.diagnostics.iter().take(50) {
lines.push(format!("{name} {}", structured_diagnostic_line(diagnostic)));
}
}
}
lines.push(String::new());
lines.push("Timing".to_string());
lines.push(format!("Parse: {} ms", self.timings.parse_ms));
lines.push(format!("Validation: {} ms", self.timings.purge_ms));
lines.push(format!("References: {} ms", self.timings.xref_ms));
lines.push(format!("Scene caches: {} ms", self.timings.caches_ms));
lines.push(format!("Geometry prep: {} ms", self.timings.finalize_ms));
lines.push(format!("Total: {} ms", self.total_ms));
if !self.diagnostics.is_empty() {
lines.push(String::new());
lines.push("Diagnostics".to_string());
for (kind, message) in self.diagnostics.iter().take(200) {
lines.push(format!("[{kind}] {message}"));
}
if self.diagnostics.len() > 200 {
lines.push(format!(
"[truncated] {} additional diagnostics omitted",
self.diagnostics.len() - 200
));
}
}
if let Some(stats) = &self.read_stats {
if !stats.diagnostics.is_empty() {
lines.push(String::new());
lines.push("Structured reader diagnostics".to_string());
for diagnostic in stats.diagnostics.iter().take(100) {
lines.push(redact_source_path(
&structured_diagnostic_line(diagnostic),
self.source_path.as_deref(),
));
}
if stats.diagnostics.len() > 100 {
lines.push(format!(
"[truncated] {} additional diagnostics omitted",
stats.diagnostics.len() - 100
));
}
}
}
if let Some(error) = &self.error {
lines.push(String::new());
lines.push("Failure".to_string());
if let Some(phase) = &self.failure_phase {
lines.push(format!("Phase: {phase}"));
}
lines.push(redact_source_path(error, self.source_path.as_deref()));
}
lines.push(String::new());
lines.push("The source file was not modified by this operation.".to_string());
if self.save_as_required {
lines.push(
"Save the repaired drawing as a new file before continuing work.".to_string(),
);
}
lines.push(String::new());
lines.join("\n")
}
pub fn suggested_download_name(&self) -> String {
format!("drawing_recovery_{}.log", safe_stem(&self.report_id))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn persist(&mut self) {
match write_report(self) {
Ok(path) => self.log_path = Some(path),
Err(error) => self.log_error = Some(error),
}
}
#[cfg(target_arch = "wasm32")]
pub fn persist(&mut self) {}
}
fn display_name(path: &Path) -> String {
path.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn sha256_file(path: &Path) -> Result<String, String> {
use std::io::Read;
let mut file = std::fs::File::open(path).map_err(|error| error.to_string())?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 128 * 1024];
loop {
let count = file.read(&mut buffer).map_err(|error| error.to_string())?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
Ok(hex_digest(hasher.finalize()))
}
#[cfg(target_arch = "wasm32")]
pub fn sha256_file(_path: &Path) -> Result<String, String> {
Err("filesystem hashing is unavailable on this target".to_string())
}
#[cfg(not(target_arch = "wasm32"))]
fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
let bytes = bytes.as_ref();
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
use std::fmt::Write;
let _ = write!(output, "{byte:02x}");
}
output
}
fn report_id(source_sha256: Option<&str>, created_unix_seconds: u64) -> String {
let fingerprint = source_sha256
.and_then(|value| value.get(..12))
.unwrap_or("nohash");
format!("R2-{created_unix_seconds}-{fingerprint}")
}
fn private_file_label(file_name: &str, source_sha256: Option<&str>) -> String {
let extension = Path::new(file_name)
.extension()
.and_then(|value| value.to_str())
.filter(|value| value.chars().all(|ch| ch.is_ascii_alphanumeric()))
.map(|value| format!(".{value}"))
.unwrap_or_default();
let fingerprint = source_sha256
.and_then(|value| value.get(..12))
.unwrap_or("redacted");
format!("drawing-{fingerprint}{extension}")
}
fn private_path_label(value: &str) -> String {
let normalized = value.replace('\\', "/");
let extension = Path::new(&normalized)
.extension()
.and_then(|extension| extension.to_str())
.filter(|extension| extension.chars().all(|ch| ch.is_ascii_alphanumeric()))
.map(|extension| format!(".{extension}"))
.unwrap_or_default();
format!("reference-redacted{extension}")
}
fn redact_known_paths(message: &str, source: &Path, references: &[XrefInfo]) -> String {
let mut redacted = redact_source_path(message, Some(source));
for reference in references {
let normalized = reference.path.replace('\\', "/");
let basename = Path::new(&normalized)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
for candidate in [&reference.path, &normalized, basename] {
if !candidate.is_empty() {
redacted = redacted.replace(candidate, "<reference-path>");
}
}
if !reference.name.is_empty() {
redacted = redacted.replace(&reference.name, "<reference-name>");
}
}
redacted
}
fn redact_source_path(message: &str, source: Option<&Path>) -> String {
let Some(source) = source else {
return message.to_string();
};
let mut redacted = message.replace(&source.to_string_lossy().to_string(), "<source-path>");
if let Some(file_name) = source.file_name().and_then(|value| value.to_str()) {
redacted = redacted.replace(file_name, "<source-file>");
}
redacted
}
fn structured_diagnostic_line(diagnostic: &codec::ReadDiagnostic) -> String {
let section = diagnostic.section.as_deref().unwrap_or("-");
let offset = diagnostic
.source_offset
.map(|value| format!("0x{value:X}"))
.unwrap_or_else(|| "-".to_string());
let offset_basis = diagnostic.source_offset_basis.as_deref().unwrap_or("-");
let line = diagnostic
.source_line
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string());
let handle = diagnostic
.record_handle
.map(|value| format!("0x{value:X}"))
.unwrap_or_else(|| "-".to_string());
let record_type = diagnostic.record_type.as_deref().unwrap_or("-");
format!(
"[code={} stage={} section={} offset={} offset-basis={} line={} handle={} type={}] {}",
diagnostic.code,
diagnostic.stage,
section,
offset,
offset_basis,
line,
handle,
record_type,
diagnostic.message
)
}
fn reader_revision() -> String {
include_str!("../../Cargo.toml")
.lines()
.find(|line| line.trim_start().starts_with("opencadcodec = { git ="))
.and_then(|line| line.split("rev = \"").nth(1))
.and_then(|value| value.split('"').next())
.filter(|value| !value.is_empty())
.unwrap_or("unknown")
.to_string()
}
fn diagnostic_platform() -> String {
format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH)
}
#[cfg(not(target_arch = "wasm32"))]
fn unix_seconds() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
#[cfg(target_arch = "wasm32")]
fn unix_seconds() -> u64 {
(js_sys::Date::now() / 1000.0) as u64
}
fn safe_stem(value: &str) -> String {
let filtered: String = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
ch
} else {
'_'
}
})
.take(80)
.collect();
if filtered.is_empty() {
"drawing".to_string()
} else {
filtered
}
}
#[cfg(not(target_arch = "wasm32"))]
fn write_report(report: &RecoveryReport) -> Result<PathBuf, String> {
let file_name = report.suggested_download_name();
let body = report.log_text();
let mut errors = Vec::new();
if let Some(directory) = crate::config::config_dir().map(|path| path.join("recovery_logs")) {
if let Err(error) = std::fs::create_dir_all(&directory) {
errors.push(format!(
"Could not create private recovery-log directory: {error}"
));
} else {
match write_unique(&directory, &file_name, &body) {
Ok(path) => return Ok(path),
Err(error) => errors.push(error),
}
}
}
Err(if errors.is_empty() {
"No private recovery-log directory was available".to_string()
} else {
errors.join("; ")
})
}
#[cfg(not(target_arch = "wasm32"))]
fn write_unique(directory: &Path, file_name: &str, body: &str) -> Result<PathBuf, String> {
use std::io::Write;
let stem = Path::new(file_name)
.file_stem()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing_recovery".to_string());
for suffix in 0..100u8 {
let candidate = if suffix == 0 {
directory.join(file_name)
} else {
directory.join(format!("{stem}_{suffix}.log"))
};
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&candidate) {
Ok(mut file) => {
file.write_all(body.as_bytes())
.map_err(|error| format!("Could not write recovery log: {error}"))?;
return Ok(candidate);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(format!("Could not create recovery log: {error}")),
}
}
Err("Could not allocate a unique recovery-log name".to_string())
}
#[cfg(test)]
mod tests {
use super::{report_id, RecoveryReport, RecoveryStatus};
#[test]
fn report_id_includes_timestamp_and_source_fingerprint() {
assert_eq!(
report_id(
Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
1_777_777_777,
),
"R2-1777777777-0123456789ab"
);
assert_eq!(report_id(None, 1_777_777_777), "R2-1777777777-nohash");
}
#[test]
fn failed_report_uses_current_timestamp_in_report_id() {
let report = RecoveryReport::failed(
None,
"invalid.dwg".to_string(),
42,
None,
None,
"parse".to_string(),
"invalid drawing".to_string(),
7,
);
assert_eq!(report.status, RecoveryStatus::Failed);
assert!(report.created_unix_seconds > 0);
assert_eq!(
report.report_id,
report_id(report.source_sha256.as_deref(), report.created_unix_seconds)
);
}
}