From 92df148f53c928659dcf4c98e06bb4aa9217e9a4 Mon Sep 17 00:00:00 2001 From: AndreyStartsev <83749987+AndreyStartsev@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:47:45 +0200 Subject: [PATCH 1/4] Implement lossless RVT write and save-as foundation - Add RvtWriter, RvtWriteCapabilities, RvtWriteRequest, RvtWriteResult, RvtWriteRefusedException, and RvtDocument.SaveAs for local 2020-2027 .rvt and .rfa files - Add PagedStreamCodec for Formats/* and Global/* paged gzip streams and PartitionStreamEditor for targeted Partitions/ record rewrites with Adler-32 checksums, 128 KB segment bounds, and untouched block, chain, and opaque payload preservation - Add rvtio save-as CLI command (--check and --report) and DocFX documentation (docs/writing.md) - Add synthetic and optional fixture tests across net8.0 and net10.0 Closes #20 --- docs/public-api.md | 13 +- docs/toc.yml | 2 + docs/writing.md | 95 +++ src/RvtIO.Cli/Program.cs | 4 + src/RvtIO.Cli/SaveAsCommand.cs | 201 ++++++ src/RvtIO/RvtDocument.cs | 19 +- src/RvtIO/Writing/PagedStreamCodec.cs | 69 ++ src/RvtIO/Writing/PartitionStreamEditor.cs | 474 ++++++++++++++ src/RvtIO/Writing/RvtWriter.cs | 614 ++++++++++++++++++ .../RvtIO.Tests/PartitionStreamEditorTests.cs | 474 ++++++++++++++ tests/RvtIO.Tests/RvtIO.Tests.csproj | 1 + tests/RvtIO.Tests/RvtWriterSampleTests.cs | 241 +++++++ tests/RvtIO.Tests/RvtWriterTests.cs | 550 ++++++++++++++++ tests/RvtIO.Tests/SaveAsCommandTests.cs | 224 +++++++ 14 files changed, 2977 insertions(+), 4 deletions(-) create mode 100644 docs/writing.md create mode 100644 src/RvtIO.Cli/SaveAsCommand.cs create mode 100644 src/RvtIO/Writing/PagedStreamCodec.cs create mode 100644 src/RvtIO/Writing/PartitionStreamEditor.cs create mode 100644 src/RvtIO/Writing/RvtWriter.cs create mode 100644 tests/RvtIO.Tests/PartitionStreamEditorTests.cs create mode 100644 tests/RvtIO.Tests/RvtWriterSampleTests.cs create mode 100644 tests/RvtIO.Tests/RvtWriterTests.cs create mode 100644 tests/RvtIO.Tests/SaveAsCommandTests.cs diff --git a/docs/public-api.md b/docs/public-api.md index f12a65a..c828140 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -8,12 +8,12 @@ projects through `InternalsVisibleTo`. | Type | Use | |---|---| -| `RvtDocument` | Opens a file (`Open`) and gives the elements, project information, file metadata and category lookups. | +| `RvtDocument` | Opens a file (`Open`), saves a verified lossless copy (`SaveAs`), and gives the elements, project information, file metadata and category lookups. | | `RvtFileReader` | Lower level read (`Read`) that returns the `RevitFileInfo` behind a document. | | `RvtReaderOptions` | Options of a read, including `DiagnosticOutput`, `ResolveCurrentCopies` (keeps only the current copy of an element that several partition streams hold; default true, set it to false to decode every copy), `RaiseLimits` and `Diagnostics`. `RevitLocalizationDirectory` is `[Obsolete]` and ignored: the texts are built in. | | `RvtReadDiagnostics`, `StreamRecord`, `ReaderLimitHit` | What a read collects when `RvtReaderOptions.Diagnostics` is set: the partition streams read and the streams cut by a limit. | | `ExportReport`, `SkipLog` | The machine-readable report of a run (`ToJson`) and the elements an export left out; see [the export report](export-report.md). | -| `RvtExitCodes`, `RvtInputException`, `RvtLimitException`, `RvtContainerCheck` | The exit codes 0 to 4 and 6 of the command line tool (code 5 is returned only by `downgrade --check`), the exceptions `RvtFileReader.Read` throws for a file it cannot read, and the check that raises them. | +| `RvtExitCodes`, `RvtInputException`, `RvtLimitException`, `RvtContainerCheck` | The exit codes 0 to 4 and 6 of the command line tool (code 5 is returned by `downgrade --check` and `save-as` when refused), the exceptions `RvtFileReader.Read` throws for a file it cannot read, and the check that raises them. | | `FilteredElementCollector` | Query over the elements of a document (`OfCategory`, `WhereElementIsNotElementType`, `Where`, ...). | ## Model @@ -66,6 +66,15 @@ hidden Geometry nodes follow its rules: they are listed in the documentation com that are not applied (the "export to IFC" flag, topography, walls that are not visible and others), so the meshes can include elements that the IFC leaves out. Curved surfaces are the dense polygons the reader decodes for them. +## Writing and save-as + +| Type | Use | +|---|---| +| `RvtWriter` | Inspects the write-capability matrix (`InspectCapabilities`), writes lossless copies (`SaveAs`), and persists targeted stream and partition-record edits (`Rewrite`) for local `2020` to `2027` `.rvt` and `.rfa` files; see [writing and save-as](writing.md). | +| `RvtWriteCapabilities`, `RvtOperationSupport`, `RvtFileKind`, `RvtWriteOperation` | Capability classification by file kind (`Project`, `Family`), release (`2020` to `2027`), element ID width (`4` or `8` bytes), worksharing/cloud flags, and operation (`SaveAs`, `PagedStreamRewrite`, `PartitionRecordRewrite`). | +| `RvtWriteRequest`, `RvtPagedStreamEdit`, `RvtPartitionRecordEdit`, `RvtWriteResult` | Edit specifications for `Formats/Latest`, `Global/*`, and `Partitions/` records, and the verified write summary. | +| `RvtWriteRefusedException` | Thrown with a stable `Kind` code when a file state or edit request is outside the supported write scope. | + ## Diagnostics The library never writes to the console. A diagnostic message is written only when the caller gives a `TextWriter`: diff --git a/docs/toc.yml b/docs/toc.yml index 0a1af61..33355a4 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -7,6 +7,8 @@ href: public-api.md - name: Scene adapter href: scene.md + - name: Writing and save-as + href: writing.md - name: Export report and exit codes href: export-report.md - name: Coverage report diff --git a/docs/writing.md b/docs/writing.md new file mode 100644 index 0000000..edc0e2d --- /dev/null +++ b/docs/writing.md @@ -0,0 +1,95 @@ +# Writing and save-as + +`RvtWriter` and `RvtDocument.SaveAs` provide lossless container round-trips and targeted paged-stream and partition-record +rewrites for local Revit projects (`.rvt`) and families (`.rfa`), without requiring Revit or the Revit API. + +```csharp +using RvtIO; + +RvtWriteCapabilities capabilities = RvtWriter.InspectCapabilities("project.rvt"); +if (capabilities.Supports(RvtWriteOperation.SaveAs)) +{ + RvtWriteResult saved = RvtWriter.SaveAs("project.rvt", "project.copy.rvt"); +} +``` + +From the command line: + +``` +rvtio save-as project.rvt --check +rvtio save-as project.rvt project.copy.rvt --report save-as.report.json +``` + +## Write-capability matrix + +Every write call inspects the source file first and checks the requested operation against the capability matrix: + +| Dimension | Supported | Refused | +| --- | --- | --- | +| File kind | Local project (`.rvt`, `.rte`) and family (`.rfa`, `.rft`) containers (`RvtFileKind.Project`, `RvtFileKind.Family`) | Containers whose kind cannot be identified (`unknown-file-kind`) | +| Sharing and hosting | Non-workshared local files (`Worksharing: Not enabled`, `IsSingleUserCloudModel: False`) | Workshared central or local copies (`workshared-model`) and cloud models (`cloud-model`) | +| Release and ID width | Releases `2020` to `2023` (4-byte element IDs) and `2024` to `2027` (8-byte element IDs) when `BasicFileInfo` and `Formats/Latest` agree | Releases outside `2020` to `2027` (`unsupported-release`), unrecognised schemas (`unrecognised-release`), or saved/schema mismatches (`release-mismatch`) | +| `SaveAs` | All streams, storages, external references, preview images, and unknown payloads copied byte-for-byte with sector size (`512` or `4,096` bytes) preserved | Truncated containers (`RvtInputException`) or corrupt page parity (`corrupt-page-parity`) | +| `PagedStreamRewrite` | Replacing the uncompressed payload of `Formats/Latest` or `Global/` while preserving stream prefixes, gzip framing, and page parity | Unreadable `Global/*` streams (`unreadable-paged-stream`) or invalid replacement `Formats/Latest` schemas (`invalid-schema-payload`) | +| `PartitionRecordRewrite` | Replacing targeted record bodies inside `Partitions/` (`101`, `102`, `103`) while preserving untouched blocks, loaded families, channels, and unknown class payloads | Missing framing marker classes (`missing-framing-markers`), missing partition streams (`missing-partitions`), framing errors (`partition-framing-failed`), or mismatched input Adler-32 checksums (`partition-checksum-failed`) | + +## Framing, checksums, and preservation invariants + +Cross-stream tables in a Revit container (`Global/ElemTable`, `Global/DocumentIncrementTable`, `Global/PartitionTable`) store +element identifiers, episode numbers, and partition identifiers, never byte offsets or compressed stream lengths. Changing the +byte length of a record inside `Partitions/` therefore requires no cross-stream offset table edits. + +When rewriting data, `RvtWriter` enforces the following invariants: + +1. **Untouched streams stay byte-for-byte identical.** `SaveAs` decodes nothing; every stream (`BasicFileInfo`, `TransmissionData`, + `ProjectInformation`, `PartAtom`, `RevitPreview4.0`, `Formats/*`, `Global/*`, `Partitions/*`, and any unrecognised streams) + is copied verbatim. +2. **Paged stream prefixes and parity are preserved.** `Formats/Latest` starts directly with its gzip member; `Global/*` streams + keep their exact 8-byte header prefix before the gzip member. Every rewritten paged stream is sealed with cyclic Hamming + parity pages (`64,896` payload bytes + `353` parity bytes per full page) and read back through both the parity verifier and + gzip decoder before being written. +3. **Untouched blocks, chains, and opaque payloads stay byte-for-byte identical.** Inside an edited `Partitions/` stream: + - Untouched content blocks (including every keyed block belonging to a loaded family document) are copied verbatim with their + original deflate payloads and `SignatureMarker` intact. + - Within an edited block, untouched segment chains (including untouched chains on the same channel) are copied verbatim. + - Within an edited chain, untouched records (including records whose class tag has no known field layout or carries opaque + trailing bytes) are copied verbatim in file order. + - Edited records receive updated `[size][body][size]` framing and fresh Adler-32 checksums on channels `102` and `103` + (channel `101` carries no record checksum). + - Edited chains respect Revit's `131,072`-byte (`128 KB`) segment body bound, emitting standalone segments (`flags & 3 == 0`) + for batches up to `131,072` bytes and multi-segment chains (`2 -> 3 -> 1`) for larger record bodies while preserving the + source chain's upper flag bits and opaque dword. + - Edited blocks write an empty `SignatureMarker` (`n = 0, m = 0`) because their bytes are no longer signed by Revit. +4. **Post-encode verification.** Every rewritten partition stream is verified in memory before container assembly: page parity + must pass, full `PartitionFraming` must walk every block and segment without error, Adler-32 checksums must match on every + record, every unedited record must match the input byte-for-byte, and every edited record must match its requested body. +5. **Atomic persistence and source immutability.** Output is written to a sibling `.tmp` file, read back from disk to verify + every directory entry's declared length and SHA-256 hash, and moved to the destination with `overwrite: false`. Existing files + (including the source file itself) are never overwritten, and any failure deletes the temporary file while leaving the source + untouched. + +## Refusal codes + +`RvtWriteRefusedException.Kind` and `RvtOperationSupport.RefusalCode` report stable machine-readable strings: + +| Code | Meaning | +| --- | --- | +| `unknown-file-kind` | Container has neither `PartAtom` nor `ProjectInformation` and its path extension is not `.rvt`/`.rte`/`.rfa`/`.rft`. | +| `cloud-model` | `BasicFileInfo` marks a single-user or workshared cloud model (`IsSingleUserCloudModel: True` or cloud central path). | +| `workshared-model` | `BasicFileInfo` reports worksharing enabled. | +| `corrupt-page-parity` | A `Formats/*`, `Global/*`, or `Partitions/*` stream fails Hamming page parity verification. | +| `missing-schema` | `Formats/Latest` is missing, unreadable, or cannot be parsed. | +| `unrecognised-release` | `Formats/Latest` does not match a known Revit schema release. | +| `release-mismatch` | `BasicFileInfo` saving release differs from the `Formats/Latest` schema release. | +| `unsupported-release` | Effective release is outside `2020` to `2027`. | +| `unreadable-paged-stream` | A `Global/*` stream fails decompression or prefix verification. | +| `missing-framing-markers` | `Formats/Latest` lacks one or more partition framing marker classes. | +| `missing-partitions` | No numbered stream exists under `Partitions/`. | +| `partition-framing-failed` | A `Partitions/` stream fails strict block, segment, gzip, or owner-census framing. | +| `partition-checksum-failed` | An input record on channel `102` or `103` has a mismatched Adler-32 checksum. | +| `invalid-stream-target` | A `RvtPagedStreamEdit` names a missing or non-paged stream. | +| `invalid-schema-payload` | A replacement `Formats/Latest` payload does not parse as a valid class schema. | +| `duplicate-edit` | The same stream or `(PartitionNumber, Block, Channel, OwnerId)` record is targeted twice in one request. | +| `missing-partition` | A `RvtPartitionRecordEdit` names a `Partitions/` stream that does not exist in the source container. | +| `missing-record` | A `RvtPartitionRecordEdit` names a `(Block, Channel, OwnerId)` record that does not exist in the target partition. | +| `invalid-record-target` | A `RvtPartitionRecordEdit` uses an invalid partition number, block index, channel, `-1` terminator owner, 32-bit overflow owner, or body shorter than 2 bytes. | diff --git a/src/RvtIO.Cli/Program.cs b/src/RvtIO.Cli/Program.cs index 5db9d58..85f8135 100644 --- a/src/RvtIO.Cli/Program.cs +++ b/src/RvtIO.Cli/Program.cs @@ -69,6 +69,9 @@ Developer options (measurement and comparison dumps, not a stable interface): (first argument) plan the class table edit that would let an older release load a newer project and report it; the profile is a project or family saved by that older release; writes nothing but the report; exit code 5 when the releases are out of scope + save-as [] [--check] [--report ] + (first argument) inspect the write-capability matrix (--check) or write a verified lossless + copy of a local 2020-2027 project or family; exit code 5 when refused by the matrix coverage [--report ] [--top ] (first argument) count the records of the partition streams that the class schema of the file walks to their last byte, per channel and class; writes nothing but the report @@ -134,6 +137,7 @@ private static int Main(string[] args) // --schema-dump / --schema-check / --schema-diff: the class schema embedded in a Revit file (Formats/Latest), see TfSchemaTools. if (args[0].StartsWith("--schema-")) return RvtIO.Parsing.TfSchemaTools.Run(args, Console.Out, Console.Error); if (args[0] == "downgrade") return RvtIO.Cli.DowngradeCommand.Run(args[1..], Console.Out, Console.Error); + if (args[0] == "save-as") return RvtIO.Cli.SaveAsCommand.Run(args[1..], Console.Out, Console.Error); if (args[0] == "coverage") return RvtIO.Cli.CoverageCommand.Run(args[1..], Console.Out, Console.Error); if (args[0] == "--rebar-dump") return RvtIO.RebarDump.Run(args[1..]); if (args[0] == "--parse-block") return ParseBlock(args); diff --git a/src/RvtIO.Cli/SaveAsCommand.cs b/src/RvtIO.Cli/SaveAsCommand.cs new file mode 100644 index 0000000..6ac81d6 --- /dev/null +++ b/src/RvtIO.Cli/SaveAsCommand.cs @@ -0,0 +1,201 @@ +using System.Globalization; + +namespace RvtIO.Cli; + +/// +/// rvtio save-as <source> [<output>] [--check] [--report report.json]: inspects a local Revit project (.rvt) or family +/// (.rfa) against the write-capability matrix (--check) or writes a verified lossless copy to <output>. +/// Exit codes: 0 done, 1 usage error, 2 input or I/O failure, 5 refused by the write-capability matrix. +/// +internal static class SaveAsCommand +{ + internal const int Refused = 5; + internal const string ReportSchema = "rvtio-save-as/1"; + + internal static int Run(string[] args, TextWriter output, TextWriter error) + { + string? source = null, destination = null, reportPath = null; + bool check = false; + + for (int i = 0; i < args.Length; i++) + { + string arg = args[i]; + switch (arg) + { + case "--check": + check = true; + break; + case "--report": + if (i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal)) + return Usage(error, "--report requires one value."); + if (reportPath is not null) + return Usage(error, "--report is given twice."); + reportPath = args[++i]; + break; + default: + if (arg.StartsWith("--", StringComparison.Ordinal)) + return Usage(error, $"Unknown option for save-as: {arg}."); + if (source is null) source = arg; + else if (destination is null) destination = arg; + else return Usage(error, "save-as takes at most two file paths ( and )."); + break; + } + } + + if (source is null) + return Usage(error, "save-as requires ."); + if (!check && destination is null) + return Usage(error, "save-as requires , or --check to inspect capabilities without writing."); + if (check && destination is not null) + return Usage(error, "save-as --check does not take an file path."); + if (PathProblem(source, destination, reportPath) is { } problem) + return Usage(error, problem + " Nothing was read or written."); + + string sourceName = Path.GetFileName(source); + try + { + var capabilities = RvtWriter.InspectCapabilities(source); + if (check) + { + PrintCapabilities(output, sourceName, capabilities); + bool ok = capabilities.Supports(RvtWriteOperation.SaveAs); + int exit = ok ? RvtExitCodes.Success : Refused; + return DowngradeCommand.WriteReport(error, reportPath, BuildCheckReport(sourceName, capabilities), exit); + } + + var result = RvtWriter.SaveAs(source, destination!); + var inv = CultureInfo.InvariantCulture; + output.WriteLine($"Saved: {Path.GetFileName(result.OutputPath)} ({KindName(result.FileKind)}, release {result.EffectiveRelease.ToString(inv)}, {result.StreamCount.ToString(inv)} streams verified)"); + return DowngradeCommand.WriteReport(error, reportPath, BuildWriteReport(sourceName, capabilities, result), RvtExitCodes.Success); + } + catch (RvtWriteRefusedException ex) + { + return EndError(error, reportPath, Refused, "refused", ex.Kind, ex.Message, sourceName); + } + catch (RvtInputException ex) + { + return EndError(error, reportPath, RvtExitCodes.InputError, "unreadable", ex.Kind, ex.Message, sourceName); + } + catch (RvtLimitException ex) + { + return EndError(error, reportPath, RvtExitCodes.InputError, "unreadable", "limit-exceeded", ex.Message, sourceName); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return EndError(error, reportPath, RvtExitCodes.InputError, "io-error", "io-error", ex.Message, sourceName); + } + } + + private static void PrintCapabilities(TextWriter output, string sourceName, RvtWriteCapabilities capabilities) + { + var inv = CultureInfo.InvariantCulture; + string rel = capabilities.EffectiveRelease?.ToString(inv) ?? "unknown"; + string width = capabilities.ElementIdWidth is int w ? $"{w.ToString(inv)}-byte ids" : "unknown id width"; + output.WriteLine($"Source: {sourceName} ({KindName(capabilities.FileKind)}, release {rel}, {width}, {capabilities.StreamCount.ToString(inv)} streams, {capabilities.PartitionStreamCount.ToString(inv)} partitions)"); + foreach (RvtWriteOperation op in Enum.GetValues()) + { + var support = capabilities.Operations[op]; + string state = support.IsSupported ? "supported" : $"refused ({support.RefusalCode}: {support.Reason})"; + output.WriteLine($" {OperationName(op)}: {state}"); + } + } + + private static string? PathProblem(string source, string? destination, string? report) + { + foreach (var (label, path) in new[] { ("source", source), ("output", destination), ("--report", report) }) + if (path is not null && string.IsNullOrWhiteSpace(path)) + return $"The path of {label} is empty."; + try + { + Path.GetFullPath(source); + if (destination is not null) Path.GetFullPath(destination); + if (report is null) return null; + Path.GetFullPath(report); + if (Directory.Exists(report)) return "--report names a directory, not a file."; + if (ExportReport.SameFile(report, source) || (destination is not null && ExportReport.SameFile(report, destination))) + return "--report must not name the source or output file."; + return null; + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException or IOException or UnauthorizedAccessException) + { + return $"A path is not usable ({ex.Message})."; + } + } + + private static int Usage(TextWriter error, string message) + { + error.WriteLine($"{message} Usage: rvtio save-as [] [--check] [--report ]"); + return RvtExitCodes.Usage; + } + + private static int EndError(TextWriter error, string? reportPath, int exitCode, string status, string kind, string message, string sourceName) + { + error.WriteLine($"error: {message} Nothing was written (exit code {exitCode.ToString(CultureInfo.InvariantCulture)})."); + var report = new + { + schema = ReportSchema, + status, + kind, + message, + source = new { file = sourceName }, + }; + return DowngradeCommand.WriteReport(error, reportPath, report, exitCode); + } + + private static object BuildCheckReport(string sourceName, RvtWriteCapabilities capabilities) => new + { + schema = ReportSchema, + status = capabilities.Supports(RvtWriteOperation.SaveAs) ? "ok" : "refused", + mode = "check", + source = DescribeCapabilities(sourceName, capabilities), + }; + + private static object BuildWriteReport(string sourceName, RvtWriteCapabilities capabilities, RvtWriteResult result) => new + { + schema = ReportSchema, + status = "ok", + mode = "write", + source = DescribeCapabilities(sourceName, capabilities), + output = new + { + file = Path.GetFileName(result.OutputPath), + streams = result.StreamCount, + replacedStreams = result.ReplacedStreamCount, + rewrittenRecords = result.RewrittenRecordCount, + }, + }; + + private static object DescribeCapabilities(string sourceName, RvtWriteCapabilities capabilities) => new + { + file = sourceName, + fileKind = KindName(capabilities.FileKind), + savedRelease = capabilities.SavedRelease, + schemaRelease = capabilities.SchemaRelease, + effectiveRelease = capabilities.EffectiveRelease, + elementIdWidth = capabilities.ElementIdWidth, + workshared = capabilities.IsWorkshared, + cloudModel = capabilities.IsCloudModel, + streams = capabilities.StreamCount, + partitions = capabilities.PartitionStreamCount, + operations = Enum.GetValues().Select(op => + { + var s = capabilities.Operations[op]; + return new { operation = OperationName(op), supported = s.IsSupported, refusalCode = s.RefusalCode, reason = s.Reason }; + }), + }; + + private static string KindName(RvtFileKind kind) => kind switch + { + RvtFileKind.Project => "project", + RvtFileKind.Family => "family", + _ => "unknown", + }; + + private static string OperationName(RvtWriteOperation operation) => operation switch + { + RvtWriteOperation.SaveAs => "save-as", + RvtWriteOperation.PagedStreamRewrite => "paged-stream-rewrite", + RvtWriteOperation.PartitionRecordRewrite => "partition-record-rewrite", + _ => operation.ToString(), + }; +} diff --git a/src/RvtIO/RvtDocument.cs b/src/RvtIO/RvtDocument.cs index 34f0a54..cb2649b 100644 --- a/src/RvtIO/RvtDocument.cs +++ b/src/RvtIO/RvtDocument.cs @@ -10,6 +10,7 @@ namespace RvtIO; public class RvtDocument { private readonly RevitFileInfo _info; + private readonly string? _sourcePath; private readonly Dictionary _elemById; private readonly Dictionary? _elemByUniqueId; private readonly HashSet _typeElementIds; @@ -20,6 +21,9 @@ public class RvtDocument /// Project metadata (Revit version, build, file path). public FileMetadata Metadata => _info.Metadata; + /// Full local file path when opened via ; null when created via . + public string? SourcePath => _sourcePath; + /// Project information (name, number, client, address, etc.). public ProjectInfo ProjectInformation => _info.ProjectInfo; @@ -32,9 +36,10 @@ public class RvtDocument /// Number of elements in the document. public int ElementCount => _info.Elements.Count; - private RvtDocument(RevitFileInfo info) + private RvtDocument(RevitFileInfo info, string? sourcePath = null) { _info = info; + _sourcePath = sourcePath; _elemById = new Dictionary(info.Elements.Count); foreach (var e in info.Elements) @@ -231,7 +236,7 @@ public static RvtDocument Open(string rvtPath, RvtReaderOptions? options = null) { var reader = new RvtFileReader(options); var info = reader.Read(rvtPath); - return new RvtDocument(info); + return new RvtDocument(info, Path.GetFullPath(rvtPath)); } /// @@ -240,6 +245,16 @@ public static RvtDocument Open(string rvtPath, RvtReaderOptions? options = null) /// public static RvtDocument FromInfo(RevitFileInfo info) => new(info); + /// Writes a verified lossless copy of this document's backing file to . + /// This document was constructed via and has no backing file path. + /// The backing file is outside the supported write-capability matrix. + public RvtWriteResult SaveAs(string outputPath) + { + if (string.IsNullOrEmpty(_sourcePath)) + throw new InvalidOperationException("This document has no backing source file path; use RvtWriter.SaveAs(sourcePath, outputPath)."); + return RvtWriter.SaveAs(_sourcePath, outputPath); + } + /// Get element by ID. Returns null if not found. public Element? GetElement(int elementId) diff --git a/src/RvtIO/Writing/PagedStreamCodec.cs b/src/RvtIO/Writing/PagedStreamCodec.cs new file mode 100644 index 0000000..f4469aa --- /dev/null +++ b/src/RvtIO/Writing/PagedStreamCodec.cs @@ -0,0 +1,69 @@ +using RvtIO.Parsing; + +namespace RvtIO.Writing; + +/// +/// Re-encodes single-member paged streams (Formats/* and Global/*): preserves the stream's header prefix (empty for +/// Formats/*, 8 bytes for Global/*), writes one gzip member (), seals page parity trailers +/// (), and verifies both parity and round-trip decompression before returning. +/// +internal static class PagedStreamCodec +{ + /// Extracts the 0-byte or 8-byte header prefix that precedes the gzip member of . + /// The stored stream has corrupt page parity or no valid gzip member at offset 0 or 8. + internal static byte[] ReadPrefix(byte[] storedStream) + { + var pageCheck = PageCodec.Verify(storedStream); + if (pageCheck.Status is PageCheckStatus.BadPage or PageCheckStatus.BadTail) + throw new InvalidDataException($"The stored paged stream fails page parity at page {pageCheck.Page} ({pageCheck.Status})."); + + byte[]? body = PagedGzipStream.RemoveSegments(storedStream) + ?? PagedGzipStream.RemoveSegments(storedStream, tolerateTail: true); + if (body is null || PagedGzipStream.Decompress(storedStream) is null) + throw new InvalidDataException("The stored paged stream cannot be decompressed."); + + if (IsGzipMagic(body, 0)) return []; + if (IsGzipMagic(body, PagedGzipStream.PrefixLength)) return body[..PagedGzipStream.PrefixLength]; + throw new InvalidDataException("The stored paged stream has no gzip header at offset 0 or 8."); + } + + /// Re-encodes with the header prefix of . + internal static byte[] Reencode(byte[] originalStoredStream, ReadOnlySpan uncompressedPayload) => + Encode(ReadPrefix(originalStoredStream), uncompressedPayload); + + /// Encodes after (0 or 8 bytes) as a verified paged stream. + internal static byte[] Encode(ReadOnlySpan prefix, ReadOnlySpan uncompressedPayload) + { + if (prefix.Length is not (0 or PagedGzipStream.PrefixLength)) + throw new ArgumentException($"A paged stream prefix must be 0 or {PagedGzipStream.PrefixLength} bytes.", nameof(prefix)); + + byte[] member = GzipMember.Write(uncompressedPayload); + byte[] content = new byte[checked(prefix.Length + member.Length)]; + prefix.CopyTo(content); + member.CopyTo(content.AsSpan(prefix.Length)); + + byte[] encoded = PageCodec.EncodePadded(content); + Verify(encoded, prefix, uncompressedPayload); + return encoded; + } + + private static void Verify(byte[] encoded, ReadOnlySpan expectedPrefix, ReadOnlySpan expectedPayload) + { + var check = PageCodec.Verify(encoded); + if (check.Status != PageCheckStatus.Valid) + throw new InvalidDataException($"Encoded paged stream failed parity check ({check.Status} at page {check.Page})."); + + if (expectedPrefix.Length > 0 && !encoded.AsSpan(0, expectedPrefix.Length).SequenceEqual(expectedPrefix)) + throw new InvalidDataException("Encoded paged stream lost its header prefix."); + + byte[]? roundTrip = PagedGzipStream.Decompress(encoded); + if (roundTrip is null || !roundTrip.AsSpan().SequenceEqual(expectedPayload)) + throw new InvalidDataException("Encoded paged stream did not round-trip to the expected payload."); + } + + private static bool IsGzipMagic(byte[] data, int offset) => + data.Length >= offset + 18 + && data[offset] == 0x1F + && data[offset + 1] == 0x8B + && data[offset + 2] == 0x08; +} diff --git a/src/RvtIO/Writing/PartitionStreamEditor.cs b/src/RvtIO/Writing/PartitionStreamEditor.cs new file mode 100644 index 0000000..31bbe9a --- /dev/null +++ b/src/RvtIO/Writing/PartitionStreamEditor.cs @@ -0,0 +1,474 @@ +using System.Buffers.Binary; +using ICSharpCode.SharpZipLib.Checksum; +using ICSharpCode.SharpZipLib.Zip.Compression; +using RvtIO.Parsing; + +namespace RvtIO.Writing; + +/// One record replacement inside a single partition stream, addressed by block index (0 = the project or family's own +/// element map, > 0 = a loaded-family keyed block), channel (101..103), and owner element identifier. +internal sealed record PartitionRecordEdit(int Block, int Channel, ulong Owner, ReadOnlyMemory Body); + +/// +/// Lossless editor of a stored Partitions/<n> stream (). +/// +/// Untouched blocks (including loaded-family keyed blocks) and untouched segment chains within an edited block are copied byte-for-byte +/// from the page-stripped stream, preserving their original deflate payloads, segment flags, opaque dwords, and block signatures. +/// Only segment chains that hold an edited record are rebuilt: untouched records in that chain are copied byte-for-byte, edited records +/// receive updated [size]...[size] framing and channel 102/103 Adler-32 checksums, and the chain is emitted in 128 KB-bounded +/// segments (flags & 3 = 0 for batches up to 131,072 body bytes, or 2 -> 3 -> 1 slices for larger bodies) with the +/// upper flag bits and opaque dword of the original chain preserved. An edited block carries an empty SignatureMarker (n = 0, m = 0) +/// because its bytes are no longer signed by Revit. +/// +/// Every output stream is sealed with and verified with both and +/// before returning: block count, record count, keys, channels, owners, Adler-32 checksums, and +/// every unedited record body must match the input byte-for-byte. +/// +internal static class PartitionStreamEditor +{ + /// Maximum record body bytes placed in one segment (matches Revit's 128 KB segment buffer). + internal const int MaxSegmentBodyBytes = 131_072; + + /// Applies to without mutating the input array. Without edits, returns a + /// copy of once its parity and framing are verified. + /// An edit has an invalid block, channel, terminator owner, tagless body, duplicate target, or missing target. + /// The input stream fails page parity or partition framing, or post-write verification detects any divergence. + internal static byte[] Apply(byte[] raw, PartitionFraming.Tags tags, int idWidth, IReadOnlyList edits) + { + ArgumentNullException.ThrowIfNull(raw); + ArgumentNullException.ThrowIfNull(edits); + if (idWidth is not (4 or 8)) + throw new ArgumentOutOfRangeException(nameof(idWidth), idWidth, "Element identifier byte width must be 4 or 8."); + + var pageCheck = PageCodec.Verify(raw); + if (pageCheck.Status is PageCheckStatus.BadPage or PageCheckStatus.BadTail) + throw new InvalidDataException($"Partition stream fails page parity at page {pageCheck.Page} ({pageCheck.Status})."); + + var before = new List(); + if (!PartitionFraming.Walk(raw, tags, idWidth, r => before.Add(SnapshotRecord.Capture(r)), out string? failure, out int blocks)) + throw new InvalidDataException($"Partition stream framing is invalid: {failure}."); + + ulong terminator = idWidth == 8 ? ulong.MaxValue : uint.MaxValue; + VerifyChecksums(before, terminator); + var editMap = ValidateEdits(before, blocks, idWidth, terminator, edits); + if (edits.Count == 0) return [.. raw]; + + byte[] body = PartitionFraming.RemoveTails(raw) + ?? throw new InvalidDataException("Partition stream page tails could not be removed."); + + var editedBlocks = editMap.Keys.Select(k => k.Block).ToHashSet(); + using var rebuiltStream = new MemoryStream(body.Length); + rebuiltStream.Write(body, 0, 8); // format 9, 0 + + Span unsignedSig = stackalloc byte[10]; + unsignedSig.Clear(); + BinaryPrimitives.WriteUInt16LittleEndian(unsignedSig, tags.SignatureMarker); + + int pos = 8; + for (int blockIndex = 0; ; blockIndex++) + { + int blockStart = pos; + pos += 2; // ContentMarker + uint pointer = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); + pos += 4; + if (pointer != 0) pos += 2; // ContentKey class + uint expectedOwners = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); + pos += 4; + if (pointer != 0) pos += 16; // 16-byte key + + if (expectedOwners == uint.MaxValue) + { + rebuiltStream.Write(body, blockStart, pos - blockStart); + break; + } + + int headerEnd = pos; + var chains = ReadBlockChains(body, ref pos); + int emptySegmentStart = pos - 18; + + pos += 2; // SignatureMarker + uint sigBytes = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); + pos += 4 + checked((int)sigBytes); + uint sigChars = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); + pos += 4 + checked((int)(2L * sigChars)); + int blockEnd = pos; + + if (!editedBlocks.Contains(blockIndex)) + { + rebuiltStream.Write(body, blockStart, blockEnd - blockStart); + continue; + } + + rebuiltStream.Write(body, blockStart, headerEnd - blockStart); + foreach (var chain in chains) + { + if (!ChainHasEdit(chain, blockIndex, idWidth, editMap)) + { + rebuiltStream.Write(body, chain.SegmentStart, chain.SegmentEnd - chain.SegmentStart); + continue; + } + WriteEditedChain(rebuiltStream, chain, blockIndex, tags, idWidth, editMap); + } + + rebuiltStream.Write(body, emptySegmentStart, 18); + rebuiltStream.Write(unsignedSig); + } + + byte[] encoded = PageCodec.EncodePadded(rebuiltStream.ToArray()); + VerifyOutput(encoded, tags, idWidth, terminator, blocks, before, editMap); + return encoded; + } + + private readonly record struct TargetKey(int Block, int Channel, ulong Owner); + + private readonly record struct SnapshotRecord(int Block, string Key, int Channel, ulong Owner, uint Extra, byte[] Body) + { + internal static SnapshotRecord Capture(PartitionFraming.Record record) => + new(record.Block, record.Key, record.Channel, record.Owner, record.Extra, record.Body.ToArray()); + } + + private sealed record ChainSpan(int SegmentStart, int SegmentEnd, int Channel, uint UpperFlags, uint FirstOpaque, byte[] Inflated); + + private static void VerifyChecksums(List records, ulong terminator) + { + var adler = new Adler32(); + foreach (var record in records) + { + if (record.Owner == terminator && record.Body.Length == 0) continue; + if (record.Channel == 101) continue; + adler.Reset(); + adler.Update(record.Body); + if ((uint)adler.Value != record.Extra) + throw new InvalidDataException($"Input record (block {record.Block}, channel {record.Channel}, owner {record.Owner}) has a mismatched Adler-32 checksum."); + } + } + + private static Dictionary ValidateEdits( + List before, + int blocks, + int idWidth, + ulong terminator, + IReadOnlyList edits) + { + var counts = new Dictionary(); + foreach (var record in before) + { + if (record.Owner == terminator && record.Body.Length == 0) continue; + var key = new TargetKey(record.Block, record.Channel, record.Owner); + counts[key] = counts.GetValueOrDefault(key) + 1; + } + + var map = new Dictionary(edits.Count); + foreach (var edit in edits) + { + if (edit.Block < 0 || edit.Block >= blocks) + throw new ArgumentException($"Block index {edit.Block} is outside the 0..{blocks - 1} range of this partition stream.", nameof(edits)); + if (edit.Channel is < 101 or > 103) + throw new ArgumentException($"Channel {edit.Channel} is invalid; expected 101, 102, or 103.", nameof(edits)); + if (edit.Owner == terminator) + throw new ArgumentException("The chain terminator record (-1) cannot be edited.", nameof(edits)); + if (idWidth == 4 && edit.Owner > uint.MaxValue) + throw new ArgumentException($"Owner identifier {edit.Owner} exceeds 32-bit element identifier width.", nameof(edits)); + if (edit.Body.Length < 2) + throw new ArgumentException($"Replacement body for owner {edit.Owner} on channel {edit.Channel} is shorter than the 2-byte class tag.", nameof(edits)); + + var target = new TargetKey(edit.Block, edit.Channel, edit.Owner); + if (!map.TryAdd(target, edit.Body.ToArray())) + throw new ArgumentException($"Record (block {edit.Block}, channel {edit.Channel}, owner {edit.Owner}) is edited twice.", nameof(edits)); + + int occurrences = counts.GetValueOrDefault(target); + if (occurrences == 0) + throw new ArgumentException($"Record (block {edit.Block}, channel {edit.Channel}, owner {edit.Owner}) does not exist in this partition stream.", nameof(edits)); + if (occurrences > 1) + throw new ArgumentException($"Record (block {edit.Block}, channel {edit.Channel}, owner {edit.Owner}) occurs {occurrences} times and cannot be targeted unambiguously.", nameof(edits)); + } + return map; + } + + private static List ReadBlockChains(byte[] body, ref int pos) + { + var chains = new List(); + using var inflated = new MemoryStream(); + int chainStart = -1; + uint firstFlags = 0, firstOpaque = 0; + int channel = 0; + + while (true) + { + int segStart = pos; + pos += 2; // SegmentMarker + uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); pos += 4; + pos += 4; // count + uint size = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); pos += 4; + pos += 4; // unc + if (size == 0) break; + + uint index = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); pos += 4; + uint opaque = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); pos += 4; + int payloadLength = checked((int)size - 8); + InflateGzipMember(body, pos, payloadLength, inflated, resetFirst: (flags & 3) is 0 or 2); + pos += payloadLength + 6; // SegmentCheckback + size + + uint mode = flags & 3; + if (mode is 0 or 2) + { + chainStart = segStart; + firstFlags = flags & ~3u; + firstOpaque = opaque; + channel = (int)index; + } + if (mode is 0 or 1) + chains.Add(new ChainSpan(chainStart, pos, channel, firstFlags, firstOpaque, inflated.ToArray())); + } + return chains; + } + + private static void InflateGzipMember(byte[] source, int offset, int length, MemoryStream destination, bool resetFirst) + { + if (resetFirst) + { + destination.SetLength(0); + destination.Position = 0; + } + var inflater = new Inflater(noHeader: true); + inflater.SetInput(source, offset + 10, length - 18); + byte[] buffer = new byte[32_768]; + while (!inflater.IsFinished) + { + int count = inflater.Inflate(buffer); + if (count == 0 && !inflater.IsFinished) + throw new InvalidDataException("Unexpected end of deflate member while reading partition segment."); + destination.Write(buffer, 0, count); + } + } + + private static bool ChainHasEdit(ChainSpan chain, int block, int idWidth, Dictionary editMap) + { + ReadOnlySpan inf = chain.Inflated; + int at = 0; + while (at < inf.Length) + { + ulong owner = idWidth == 8 + ? BinaryPrimitives.ReadUInt64LittleEndian(inf.Slice(at, 8)) + : BinaryPrimitives.ReadUInt32LittleEndian(inf.Slice(at, 4)); + at += idWidth + (chain.Channel != 101 ? 4 : 0); + int bodySize = checked((int)BinaryPrimitives.ReadUInt32LittleEndian(inf.Slice(at, 4))); + at += 4 + bodySize + 4; + if (editMap.ContainsKey(new TargetKey(block, chain.Channel, owner))) return true; + } + return false; + } + + private static void WriteEditedChain( + MemoryStream output, + ChainSpan chain, + int block, + PartitionFraming.Tags tags, + int idWidth, + Dictionary editMap) + { + var adler = new Adler32(); + using var batch = new MemoryStream(); + uint batchCount = 0; + long batchBodyBytes = 0; + + void FlushBatch() + { + if (batchCount == 0) return; + WriteSegment(output, tags, chain.UpperFlags, batchCount, checked((uint)batchBodyBytes), (uint)chain.Channel, chain.FirstOpaque, batch.ToArray()); + batch.SetLength(0); + batch.Position = 0; + batchCount = 0; + batchBodyBytes = 0; + } + + byte[] inf = chain.Inflated; + byte[] headerBuffer = new byte[20]; + int at = 0; + while (at < inf.Length) + { + int itemStart = at; + ulong owner = idWidth == 8 + ? BinaryPrimitives.ReadUInt64LittleEndian(inf.AsSpan(at, 8)) + : BinaryPrimitives.ReadUInt32LittleEndian(inf.AsSpan(at, 4)); + at += idWidth + (chain.Channel != 101 ? 4 : 0); + int storedBodySize = checked((int)BinaryPrimitives.ReadUInt32LittleEndian(inf.AsSpan(at, 4))); + int storedBodyAt = at + 4; + at = storedBodyAt + storedBodySize + 4; + + if (!editMap.TryGetValue(new TargetKey(block, chain.Channel, owner), out byte[]? replacement)) + { + int itemLength = at - itemStart; + if (storedBodySize > MaxSegmentBodyBytes) + { + FlushBatch(); + WriteSplitRecord(output, tags, chain, idWidth, inf.AsSpan(itemStart, storedBodyAt - itemStart), inf.AsSpan(storedBodyAt, storedBodySize)); + } + else + { + if (batchCount > 0 && batch.Length + itemLength > MaxSegmentBodyBytes) FlushBatch(); + batch.Write(inf, itemStart, itemLength); + batchCount++; + batchBodyBytes += storedBodySize; + } + continue; + } + + int hdrLen = BuildRecordHeader(headerBuffer, idWidth, chain.Channel, owner, replacement, adler); + Span footer = headerBuffer.AsSpan(hdrLen - 4, 4); + int newItemLength = checked(hdrLen + replacement.Length + 4); + if (replacement.Length > MaxSegmentBodyBytes) + { + FlushBatch(); + WriteSplitRecord(output, tags, chain, idWidth, headerBuffer.AsSpan(0, hdrLen), replacement); + } + else + { + if (batchCount > 0 && batch.Length + newItemLength > MaxSegmentBodyBytes) FlushBatch(); + batch.Write(headerBuffer, 0, hdrLen); + batch.Write(replacement, 0, replacement.Length); + batch.Write(footer); + batchCount++; + batchBodyBytes += replacement.Length; + } + } + FlushBatch(); + } + + private static int BuildRecordHeader(byte[] buffer, int idWidth, int channel, ulong owner, byte[] body, Adler32 adler) + { + int pos = 0; + if (idWidth == 8) + { + BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(0, 8), owner); + pos = 8; + } + else + { + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0, 4), (uint)owner); + pos = 4; + } + if (channel != 101) + { + adler.Reset(); + adler.Update(body); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(pos, 4), (uint)adler.Value); + pos += 4; + } + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(pos, 4), (uint)body.Length); + return pos + 4; + } + + private static void WriteSplitRecord( + MemoryStream output, + PartitionFraming.Tags tags, + ChainSpan chain, + int idWidth, + ReadOnlySpan recordHeader, + ReadOnlySpan recordBody) + { + _ = idWidth; + // First segment (mode 2 = start of multi-segment chain): record header + first 128 KB of body. + byte[] firstSlice = new byte[recordHeader.Length + MaxSegmentBodyBytes]; + recordHeader.CopyTo(firstSlice); + recordBody[..MaxSegmentBodyBytes].CopyTo(firstSlice.AsSpan(recordHeader.Length)); + WriteSegment(output, tags, chain.UpperFlags | 2u, 1u, MaxSegmentBodyBytes, (uint)chain.Channel, chain.FirstOpaque, firstSlice); + + int offset = MaxSegmentBodyBytes; + while (recordBody.Length - offset > MaxSegmentBodyBytes) + { + byte[] middleSlice = recordBody.Slice(offset, MaxSegmentBodyBytes).ToArray(); + WriteSegment(output, tags, chain.UpperFlags | 3u, 0u, MaxSegmentBodyBytes, (uint)chain.Channel, chain.FirstOpaque, middleSlice); + offset += MaxSegmentBodyBytes; + } + + // Closing segment (mode 1 = end of multi-segment chain): remaining 1..131,072 body bytes + 4-byte closing size. + int remaining = recordBody.Length - offset; + byte[] lastSlice = new byte[remaining + 4]; + recordBody[offset..].CopyTo(lastSlice); + BinaryPrimitives.WriteUInt32LittleEndian(lastSlice.AsSpan(remaining, 4), (uint)recordBody.Length); + WriteSegment(output, tags, chain.UpperFlags | 1u, 0u, (uint)remaining, (uint)chain.Channel, chain.FirstOpaque, lastSlice); + } + + private static void WriteSegment( + MemoryStream output, + PartitionFraming.Tags tags, + uint flags, + uint recordCount, + uint uncompressedBodyLength, + uint channel, + uint opaque, + ReadOnlySpan inflatedBytes) + { + byte[] gzPayload = GzipMember.Write(inflatedBytes); + uint segmentSize = checked((uint)(gzPayload.Length + 8)); + + Span header = stackalloc byte[26]; + BinaryPrimitives.WriteUInt16LittleEndian(header[0..], tags.SegmentMarker); + BinaryPrimitives.WriteUInt32LittleEndian(header[2..], flags); + BinaryPrimitives.WriteUInt32LittleEndian(header[6..], recordCount); + BinaryPrimitives.WriteUInt32LittleEndian(header[10..], segmentSize); + BinaryPrimitives.WriteUInt32LittleEndian(header[14..], uncompressedBodyLength); + BinaryPrimitives.WriteUInt32LittleEndian(header[18..], channel); + BinaryPrimitives.WriteUInt32LittleEndian(header[22..], opaque); + output.Write(header); + output.Write(gzPayload); + + Span footer = stackalloc byte[6]; + BinaryPrimitives.WriteUInt16LittleEndian(footer[0..], tags.SegmentCheckback); + BinaryPrimitives.WriteUInt32LittleEndian(footer[2..], segmentSize); + output.Write(footer); + } + + private static void VerifyOutput( + byte[] encoded, + PartitionFraming.Tags tags, + int idWidth, + ulong terminator, + int expectedBlocks, + List before, + Dictionary editMap) + { + var pageCheck = PageCodec.Verify(encoded); + if (pageCheck.Status != PageCheckStatus.Valid) + throw new InvalidDataException($"Rewritten partition stream failed page parity ({pageCheck.Status} at page {pageCheck.Page})."); + + var after = new List(before.Count); + if (!PartitionFraming.Walk(encoded, tags, idWidth, r => after.Add(SnapshotRecord.Capture(r)), out string? failure, out int actualBlocks)) + throw new InvalidDataException($"Rewritten partition stream failed framing verification: {failure}."); + + if (actualBlocks != expectedBlocks) + throw new InvalidDataException($"Block count changed from {expectedBlocks} to {actualBlocks}."); + if (after.Count != before.Count) + throw new InvalidDataException($"Record count changed from {before.Count} to {after.Count}."); + + var adler = new Adler32(); + for (int i = 0; i < before.Count; i++) + { + SnapshotRecord was = before[i], now = after[i]; + if (now.Block != was.Block || now.Key != was.Key || now.Channel != was.Channel || now.Owner != was.Owner) + throw new InvalidDataException($"Record {i} header changed from ({was.Block}, '{was.Key}', {was.Channel}, {was.Owner}) to ({now.Block}, '{now.Key}', {now.Channel}, {now.Owner})."); + + bool isTerminator = was.Owner == terminator && was.Body.Length == 0; + byte[] expectedBody = !isTerminator && editMap.TryGetValue(new TargetKey(was.Block, was.Channel, was.Owner), out byte[]? rep) + ? rep + : was.Body; + + if (!now.Body.AsSpan().SequenceEqual(expectedBody)) + throw new InvalidDataException($"Record (block {was.Block}, channel {was.Channel}, owner {was.Owner}) body differs from expected bytes."); + + if (isTerminator || now.Channel == 101) + { + if (now.Extra != (isTerminator ? was.Extra : 0u)) + throw new InvalidDataException($"Record (block {was.Block}, channel {was.Channel}, owner {was.Owner}) carries unexpected extra value {now.Extra}."); + } + else + { + adler.Reset(); + adler.Update(expectedBody); + if (now.Extra != (uint)adler.Value) + throw new InvalidDataException($"Record (block {was.Block}, channel {was.Channel}, owner {was.Owner}) carries invalid Adler-32 checksum."); + } + } + } +} diff --git a/src/RvtIO/Writing/RvtWriter.cs b/src/RvtIO/Writing/RvtWriter.cs new file mode 100644 index 0000000..acb0c76 --- /dev/null +++ b/src/RvtIO/Writing/RvtWriter.cs @@ -0,0 +1,614 @@ +using System.Globalization; +using ICSharpCode.SharpZipLib.Checksum; +using OpenMcdf; +using RvtIO.Parsing; +using RvtIO.Writing; + +namespace RvtIO; + +/// Kind of Revit compound file container. +public enum RvtFileKind +{ + /// Neither family nor project markers were found and the file extension is not .rvt or .rfa. + Unknown = 0, + /// Revit project or project template (.rvt, .rte). + Project = 1, + /// Revit family or family template (.rfa, .rft). + Family = 2, +} + +/// Write operations classified by the capability matrix. +public enum RvtWriteOperation +{ + /// Lossless container round-trip preserving the directory tree, sector size, and every stream's stored bytes. + SaveAs = 0, + /// Rewriting the uncompressed payload of Formats/Latest or a Global/* single-member paged stream. + PagedStreamRewrite = 1, + /// Rewriting targeted records inside Partitions/<n> while preserving untouched blocks, channels, and payloads. + PartitionRecordRewrite = 2, +} + +/// Support status of one on an inspected file. +/// The evaluated write operation. +/// True when the operation is supported for this file. +/// Stable refusal code when is false; otherwise null. +/// Human-readable refusal reason when is false; otherwise null. +public sealed record RvtOperationSupport( + RvtWriteOperation Operation, + bool IsSupported, + string? RefusalCode = null, + string? Reason = null); + +/// Write-capability inspection result for a Revit container across file kind, release, and operation. +public sealed class RvtWriteCapabilities +{ + /// Detected container kind ( or ). + public required RvtFileKind FileKind { get; init; } + + /// Release year recorded in BasicFileInfo, or null when absent. + public required int? SavedRelease { get; init; } + + /// Release year identified from the embedded Formats/Latest class schema, or null when unrecognised. + public required int? SchemaRelease { get; init; } + + /// Effective layout release year (2020 to 2027 when supported), or null when unknown. + public required int? EffectiveRelease { get; init; } + + /// Byte width of stored element identifiers (4 up to Revit 2023, 8 from Revit 2024), or null when unknown. + public required int? ElementIdWidth { get; init; } + + /// True when BasicFileInfo reports worksharing enabled. + public required bool IsWorkshared { get; init; } + + /// True when BasicFileInfo marks a cloud model or cloud central path. + public required bool IsCloudModel { get; init; } + + /// Total number of streams in the compound file container. + public required int StreamCount { get; init; } + + /// Number of numbered partition streams under Partitions/. + public required int PartitionStreamCount { get; init; } + + /// Per-operation support decisions keyed by . + public required IReadOnlyDictionary Operations { get; init; } + + /// Returns true when is supported on this file. + public bool Supports(RvtWriteOperation operation) => + Operations.TryGetValue(operation, out var support) && support.IsSupported; +} + +/// Thrown when a write or save-as operation is refused because the source file state or edit request is unsupported. +public sealed class RvtWriteRefusedException : Exception +{ + /// Stable machine-readable refusal code. + public string Kind { get; } + + /// Creates the exception with its stable code and diagnostic message. + public RvtWriteRefusedException(string kind, string message, Exception? inner = null) : base(message, inner) => + Kind = kind; +} + +/// Replacement of the uncompressed payload of a single-member paged stream (Formats/Latest or Global/<Name>). +/// Container path of the target paged stream, using / between storage and stream name. +/// Replacement uncompressed bytes to compress and page-encode. +public sealed record RvtPagedStreamEdit(string StreamPath, ReadOnlyMemory UncompressedPayload); + +/// Replacement of one framed record body inside Partitions/<PartitionNumber>. +/// Non-negative number of the target Partitions/<n> stream. +/// Target channel (101, 102, or 103). +/// Owner element identifier of the record inside the block. +/// Replacement record body starting with its 2-byte class tag. +/// Content block index (0 = project/family own elements; > 0 = loaded-family keyed block). +public sealed record RvtPartitionRecordEdit( + int PartitionNumber, + int Channel, + ulong OwnerId, + ReadOnlyMemory Body, + int Block = 0); + +/// Set of stream and partition record edits to persist atomically into a new Revit container. +public sealed class RvtWriteRequest +{ + /// Edits targeting single-member paged streams (Formats/Latest or Global/*). + public IReadOnlyList PagedStreamEdits { get; init; } = []; + + /// Edits targeting framed records inside Partitions/<n> streams. + public IReadOnlyList PartitionRecordEdits { get; init; } = []; +} + +/// Summary of a completed or operation. +public sealed class RvtWriteResult +{ + /// Full path of the written output file. + public required string OutputPath { get; init; } + + /// Container kind of the written file. + public required RvtFileKind FileKind { get; init; } + + /// Effective Revit release year of the written file. + public required int EffectiveRelease { get; init; } + + /// Total number of streams verified in the written container. + public required int StreamCount { get; init; } + + /// Number of container streams replaced during the write (0 for an unchanged ). + public required int ReplacedStreamCount { get; init; } + + /// Number of partition records rewritten across all Partitions/<n> streams. + public required int RewrittenRecordCount { get; init; } +} + +/// +/// Lossless save-as and targeted stream/record writer for local, non-workshared Revit projects (.rvt) and families (.rfa) +/// from releases 2020 through 2027. +/// +/// Every write validates the source file against the capability matrix first, builds and self-verifies any rewritten paged or partition +/// streams in memory, writes to a temporary file next to the destination, reads the temporary file back to verify every stream's length +/// and SHA-256, and moves it into place without ever overwriting an existing file or mutating the source file. +/// +public static class RvtWriter +{ + /// Oldest Revit release supported by the writer capability matrix. + public const int OldestSupportedRelease = 2020; + + /// Newest Revit release supported by the writer capability matrix. + public const int NewestSupportedRelease = 2027; + + private static readonly IReadOnlyDictionary NoStreamReplacements = new Dictionary(); + + /// Inspects and returns its write-capability matrix across . + /// is null. + /// The file is missing, empty, not a compound file, or truncated. + /// A stream exceeds the maximum single-array byte length. + public static RvtWriteCapabilities InspectCapabilities(string sourcePath) + { + ArgumentNullException.ThrowIfNull(sourcePath); + RvtContainerCheck.Verify(sourcePath); + using var root = RootStorage.OpenRead(sourcePath); + return InspectRoot(root, sourcePath).Capabilities; + } + + /// Writes a verified lossless copy of to . + /// or is null. + /// is empty or whitespace. + /// already exists, its directory is missing, or the source is locked for writing. + /// The source file is missing, empty, not a compound file, or truncated. + /// A stream exceeds the maximum single-array byte length. + /// The source file is outside the supported write-capability matrix. + public static RvtWriteResult SaveAs(string sourcePath, string outputPath) => + Rewrite(sourcePath, outputPath, new RvtWriteRequest()); + + /// Writes from with the requested paged-stream and partition-record + /// edits applied, preserving every other stream, block, channel, and record byte-for-byte. + /// , , or is null. + /// is empty or whitespace. + /// already exists, its directory is missing, or the source is locked for writing. + /// The source file is missing, empty, not a compound file, or truncated. + /// A stream exceeds the maximum single-array byte length. + /// The source file state or edit request is outside the supported scope. + public static RvtWriteResult Rewrite(string sourcePath, string outputPath, RvtWriteRequest request) + { + ArgumentNullException.ThrowIfNull(sourcePath); + ArgumentNullException.ThrowIfNull(outputPath); + ArgumentNullException.ThrowIfNull(request); + if (request.PagedStreamEdits is null) + throw new ArgumentException("PagedStreamEdits cannot be null.", nameof(request)); + if (request.PartitionRecordEdits is null) + throw new ArgumentException("PartitionRecordEdits cannot be null.", nameof(request)); + + string fullOutput = ValidateOutputPath(outputPath); + RvtContainerCheck.Verify(sourcePath); + + Dictionary? replacements = null; + RvtWriteCapabilities capabilities; + using (var sourceFile = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var root = RootStorage.Open(sourceFile, StorageModeFlags.LeaveOpen)) + { + var inspection = InspectRoot(root, sourcePath); + capabilities = inspection.Capabilities; + + RequireSupported(capabilities, RvtWriteOperation.SaveAs); + if (request.PagedStreamEdits.Count > 0) + RequireSupported(capabilities, RvtWriteOperation.PagedStreamRewrite); + if (request.PartitionRecordEdits.Count > 0) + RequireSupported(capabilities, RvtWriteOperation.PartitionRecordRewrite); + + if (request.PagedStreamEdits.Count > 0 || request.PartitionRecordEdits.Count > 0) + replacements = BuildReplacements(root, inspection, request); + } + + var containerResult = ContainerRewriter.Rewrite( + sourcePath, + fullOutput, + replacements ?? NoStreamReplacements); + + return new RvtWriteResult + { + OutputPath = fullOutput, + FileKind = capabilities.FileKind, + EffectiveRelease = capabilities.EffectiveRelease!.Value, + StreamCount = containerResult.Entries.Values.Count(e => e.IsStream), + ReplacedStreamCount = containerResult.Replaced, + RewrittenRecordCount = request.PartitionRecordEdits.Count, + }; + } + + private sealed record Inspection( + RvtWriteCapabilities Capabilities, + PartitionFraming.Tags? FramingTags, + IReadOnlyDictionary CanonicalStreamPaths); + + private static string ValidateOutputPath(string outputPath) + { + if (string.IsNullOrWhiteSpace(outputPath)) + throw new ArgumentException("Output file path cannot be empty.", nameof(outputPath)); + + string fullOutput; + try { fullOutput = Path.GetFullPath(outputPath); } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { throw new IOException($"'{outputPath}' is not a usable output file path.", ex); } + + string? directory = Path.GetDirectoryName(fullOutput); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + throw new DirectoryNotFoundException($"The output directory for '{fullOutput}' does not exist."); + + if (File.Exists(fullOutput) || Directory.Exists(fullOutput)) + throw new IOException($"'{Path.GetFileName(fullOutput)}' already exists; the output is never replaced."); + + return fullOutput; + } + + private static void RequireSupported(RvtWriteCapabilities capabilities, RvtWriteOperation operation) + { + var support = capabilities.Operations[operation]; + if (!support.IsSupported) + throw new RvtWriteRefusedException(support.RefusalCode!, support.Reason!); + } + + private static Inspection InspectRoot(RootStorage root, string sourcePath) + { + var bfi = BasicFileInfoReader.Read(root); + int? savedRelease = SchemaRelease.Saved(bfi.GetValueOrDefault("RevitVersion")); + bool isWorkshared = bfi.TryGetValue("Worksharing", out string? ws) + && !string.IsNullOrWhiteSpace(ws) + && !string.Equals(ws.Trim(), "Not enabled", StringComparison.OrdinalIgnoreCase); + bool isCloudModel = string.Equals(bfi.GetValueOrDefault("IsSingleUserCloudModel"), "True", StringComparison.OrdinalIgnoreCase) + || IsCloudCentralPath(bfi.GetValueOrDefault("Central Model Path")); + + var streamEntries = new List<(string Path, Storage Parent, string Name, long Length)>(); + CollectStreams(root, "", streamEntries); + var canonicalPaths = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in streamEntries) canonicalPaths.TryAdd(entry.Path, entry.Path); + + RvtFileKind fileKind = DetectFileKind(canonicalPaths, bfi.GetValueOrDefault("LastSavePath"), sourcePath); + + var partitionEntries = streamEntries + .Where(e => PartitionNumber(e.Path) is not null) + .OrderBy(e => PartitionNumber(e.Path)!.Value) + .ToList(); + + (string Code, string Reason)? parityFailure = null; + (string Code, string Reason)? globalDecodeFailure = null; + foreach (var entry in streamEntries) + { + bool isFormats = entry.Path.StartsWith("Formats/", StringComparison.OrdinalIgnoreCase); + bool isGlobal = entry.Path.StartsWith("Global/", StringComparison.OrdinalIgnoreCase); + bool isPartition = PartitionNumber(entry.Path) is not null; + if (!isFormats && !isGlobal && !isPartition) continue; + + byte[] raw = ReadStreamBytes(entry.Parent, entry.Name, entry.Length); + var check = PageCodec.Verify(raw); + if (check.Status is PageCheckStatus.BadPage or PageCheckStatus.BadTail) + { + parityFailure ??= ("corrupt-page-parity", $"Stream '{entry.Path}' fails page parity at page {check.Page} ({check.Status})."); + continue; + } + + if (isGlobal && globalDecodeFailure is null) + { + try { _ = PagedStreamCodec.ReadPrefix(raw); } + catch (InvalidDataException ex) + { + globalDecodeFailure = ("unreadable-paged-stream", $"Paged stream '{entry.Path}' cannot be decoded cleanly: {ex.Message}"); + } + } + } + + SchemaDocument? schemaDoc = TfSchemaReader.Read(root); + FormatsSchema? formatsSchema = schemaDoc is null ? null : new FormatsSchema(schemaDoc); + int? schemaRelease = formatsSchema is null ? null : SchemaRelease.FromSchema(formatsSchema); + int? effectiveRelease = SchemaRelease.Effective(savedRelease, schemaRelease); + int? idWidth = SchemaRelease.IdWidth(effectiveRelease); + PartitionFraming.Tags? framingTags = formatsSchema is null ? null : PartitionFraming.TagsOf(formatsSchema); + + (string Code, string Reason)? baseFailure = EvaluateBaseEligibility( + fileKind, + isCloudModel, + isWorkshared, + parityFailure, + schemaDoc, + savedRelease, + schemaRelease); + + RvtOperationSupport saveAsSupport; + RvtOperationSupport pagedSupport; + RvtOperationSupport partitionSupport; + + if (baseFailure is { } refusal) + { + saveAsSupport = new RvtOperationSupport(RvtWriteOperation.SaveAs, false, refusal.Code, refusal.Reason); + pagedSupport = new RvtOperationSupport(RvtWriteOperation.PagedStreamRewrite, false, refusal.Code, refusal.Reason); + partitionSupport = new RvtOperationSupport(RvtWriteOperation.PartitionRecordRewrite, false, refusal.Code, refusal.Reason); + } + else + { + saveAsSupport = new RvtOperationSupport(RvtWriteOperation.SaveAs, true); + pagedSupport = globalDecodeFailure is { } gFail + ? new RvtOperationSupport(RvtWriteOperation.PagedStreamRewrite, false, gFail.Code, gFail.Reason) + : new RvtOperationSupport(RvtWriteOperation.PagedStreamRewrite, true); + partitionSupport = EvaluatePartitionEligibility(partitionEntries, framingTags, idWidth); + } + + var operations = new Dictionary + { + [RvtWriteOperation.SaveAs] = saveAsSupport, + [RvtWriteOperation.PagedStreamRewrite] = pagedSupport, + [RvtWriteOperation.PartitionRecordRewrite] = partitionSupport, + }; + + var capabilities = new RvtWriteCapabilities + { + FileKind = fileKind, + SavedRelease = savedRelease, + SchemaRelease = schemaRelease, + EffectiveRelease = effectiveRelease, + ElementIdWidth = idWidth, + IsWorkshared = isWorkshared, + IsCloudModel = isCloudModel, + StreamCount = streamEntries.Count, + PartitionStreamCount = partitionEntries.Count, + Operations = operations, + }; + + return new Inspection(capabilities, framingTags, canonicalPaths); + } + + private static (string Code, string Reason)? EvaluateBaseEligibility( + RvtFileKind fileKind, + bool isCloudModel, + bool isWorkshared, + (string Code, string Reason)? parityFailure, + SchemaDocument? schemaDoc, + int? savedRelease, + int? schemaRelease) + { + if (fileKind == RvtFileKind.Unknown) + return ("unknown-file-kind", "The file kind (.rvt project or .rfa family) could not be determined from container streams or file extension."); + if (isCloudModel) + return ("cloud-model", "Cloud-hosted Revit models are outside the supported local-file write scope."); + if (isWorkshared) + return ("workshared-model", "Workshared Revit models are outside the supported non-workshared local-file write scope."); + if (parityFailure is not null) + return parityFailure; + if (schemaDoc is null) + return ("missing-schema", "The embedded class schema (Formats/Latest) is missing or unreadable."); + if (schemaRelease is null) + return ("unrecognised-release", "The Revit release of Formats/Latest is not recognised."); + if (savedRelease is int saved && saved != schemaRelease.Value) + return ("release-mismatch", $"The file was saved by Revit {saved} while Formats/Latest carries the schema of Revit {schemaRelease.Value}."); + if (schemaRelease.Value is < OldestSupportedRelease or > NewestSupportedRelease) + return ("unsupported-release", $"Revit release {schemaRelease.Value} is outside the supported write range ({OldestSupportedRelease}-{NewestSupportedRelease})."); + return null; + } + + private static RvtOperationSupport EvaluatePartitionEligibility( + List<(string Path, Storage Parent, string Name, long Length)> partitionEntries, + PartitionFraming.Tags? framingTags, + int? idWidth) + { + if (framingTags is null) + return new RvtOperationSupport(RvtWriteOperation.PartitionRecordRewrite, false, "missing-framing-markers", "Formats/Latest lacks the partition framing marker classes."); + if (idWidth is not (4 or 8)) + return new RvtOperationSupport(RvtWriteOperation.PartitionRecordRewrite, false, "unknown-id-width", "The element identifier byte width is unknown."); + if (partitionEntries.Count == 0) + return new RvtOperationSupport(RvtWriteOperation.PartitionRecordRewrite, false, "missing-partitions", "The file contains no numbered partition streams under Partitions/."); + + var adler = new Adler32(); + ulong terminator = idWidth.Value == 8 ? ulong.MaxValue : uint.MaxValue; + foreach (var entry in partitionEntries) + { + byte[] raw = ReadStreamBytes(entry.Parent, entry.Name, entry.Length); + bool checksumMismatch = false; + ulong badOwner = 0; + int badChannel = 0; + bool ok = PartitionFraming.Walk(raw, framingTags.Value, idWidth.Value, record => + { + if (checksumMismatch) return; + if (record.Owner == terminator && record.Body.Length == 0) return; + if (record.Channel == 101) return; + adler.Reset(); + adler.Update(record.Body.ToArray()); + if ((uint)adler.Value != record.Extra) + { + checksumMismatch = true; + badOwner = record.Owner; + badChannel = record.Channel; + } + }, out string? reason, out _); + + if (!ok) + return new RvtOperationSupport( + RvtWriteOperation.PartitionRecordRewrite, + false, + "partition-framing-failed", + $"Partition stream '{entry.Path}' fails framing verification ({reason})."); + + if (checksumMismatch) + return new RvtOperationSupport( + RvtWriteOperation.PartitionRecordRewrite, + false, + "partition-checksum-failed", + $"Partition stream '{entry.Path}' has a mismatched Adler-32 checksum on channel {badChannel}, owner {badOwner}."); + } + + return new RvtOperationSupport(RvtWriteOperation.PartitionRecordRewrite, true); + } + + private static Dictionary BuildReplacements( + RootStorage root, + Inspection inspection, + RvtWriteRequest request) + { + var replacements = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var pagedEdit in request.PagedStreamEdits) + { + if (pagedEdit is null) + throw new RvtWriteRefusedException("invalid-stream-target", "A paged stream edit entry is null."); + string trimmed = pagedEdit.StreamPath?.Trim() ?? ""; + if (!IsSupportedPagedStreamTarget(trimmed)) + throw new RvtWriteRefusedException( + "invalid-stream-target", + $"'{pagedEdit.StreamPath}' is not a supported single-member paged stream; only Formats/Latest and Global/ are allowed."); + if (!inspection.CanonicalStreamPaths.TryGetValue(trimmed, out string? canonical)) + throw new RvtWriteRefusedException("invalid-stream-target", $"Stream '{trimmed}' does not exist in the source container."); + if (replacements.ContainsKey(canonical)) + throw new RvtWriteRefusedException("duplicate-edit", $"Stream '{canonical}' is targeted more than once."); + + if (string.Equals(canonical, "Formats/Latest", StringComparison.OrdinalIgnoreCase)) + { + var parsed = TfSchemaReader.TryParse(pagedEdit.UncompressedPayload.ToArray()); + if (parsed is null) + throw new RvtWriteRefusedException("invalid-schema-payload", "Replacement payload for Formats/Latest does not parse as a valid class schema."); + } + + byte[] originalStored = ReadStreamByPath(root, canonical); + replacements[canonical] = PagedStreamCodec.Reencode(originalStored, pagedEdit.UncompressedPayload.Span); + } + + if (request.PartitionRecordEdits.Count > 0) + { + foreach (var recEdit in request.PartitionRecordEdits) + { + if (recEdit is null) + throw new RvtWriteRefusedException("invalid-record-target", "A partition record edit entry is null."); + if (recEdit.PartitionNumber < 0) + throw new RvtWriteRefusedException("invalid-record-target", $"Partition number {recEdit.PartitionNumber} cannot be negative."); + } + + foreach (var group in request.PartitionRecordEdits.GroupBy(e => e.PartitionNumber)) + { + string partKey = "Partitions/" + group.Key.ToString(CultureInfo.InvariantCulture); + if (!inspection.CanonicalStreamPaths.TryGetValue(partKey, out string? canonical)) + throw new RvtWriteRefusedException("missing-partition", $"Partition stream '{partKey}' does not exist in the source container."); + if (replacements.ContainsKey(canonical)) + throw new RvtWriteRefusedException("duplicate-edit", $"Stream '{canonical}' is targeted more than once."); + + byte[] originalStored = ReadStreamByPath(root, canonical); + var internalEdits = group + .Select(e => new PartitionRecordEdit(e.Block, e.Channel, e.OwnerId, e.Body)) + .ToList(); + + try + { + replacements[canonical] = PartitionStreamEditor.Apply( + originalStored, + inspection.FramingTags!.Value, + inspection.Capabilities.ElementIdWidth!.Value, + internalEdits); + } + catch (ArgumentException ex) + { + string code = ClassifyRecordEditArgumentError(ex.Message); + throw new RvtWriteRefusedException(code, $"In '{canonical}': {ex.Message}", ex); + } + } + } + + return replacements; + } + + private static string ClassifyRecordEditArgumentError(string message) + { + if (message.Contains("edited twice", StringComparison.Ordinal)) return "duplicate-edit"; + if (message.Contains("does not exist", StringComparison.Ordinal)) return "missing-record"; + return "invalid-record-target"; + } + + private static bool IsSupportedPagedStreamTarget(string path) + { + if (string.Equals(path, "Formats/Latest", StringComparison.OrdinalIgnoreCase)) return true; + if (!path.StartsWith("Global/", StringComparison.OrdinalIgnoreCase)) return false; + ReadOnlySpan rest = path.AsSpan("Global/".Length); + return rest.Length > 0 && !rest.Contains('/'); + } + + private static RvtFileKind DetectFileKind( + Dictionary canonicalPaths, + string? lastSavePath, + string sourcePath) + { + if (canonicalPaths.ContainsKey("PartAtom")) return RvtFileKind.Family; + if (canonicalPaths.ContainsKey("ProjectInformation")) return RvtFileKind.Project; + return KindFromPath(lastSavePath) is not RvtFileKind.Unknown and var fromMeta + ? fromMeta + : KindFromPath(sourcePath); + } + + private static RvtFileKind KindFromPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return RvtFileKind.Unknown; + string ext = Path.GetExtension(path); + if (ext.Equals(".rfa", StringComparison.OrdinalIgnoreCase) || ext.Equals(".rft", StringComparison.OrdinalIgnoreCase)) + return RvtFileKind.Family; + if (ext.Equals(".rvt", StringComparison.OrdinalIgnoreCase) || ext.Equals(".rte", StringComparison.OrdinalIgnoreCase)) + return RvtFileKind.Project; + return RvtFileKind.Unknown; + } + + private static bool IsCloudCentralPath(string? centralPath) => + !string.IsNullOrWhiteSpace(centralPath) + && (centralPath.StartsWith("Autodesk Docs://", StringComparison.OrdinalIgnoreCase) + || centralPath.StartsWith("BIM 360://", StringComparison.OrdinalIgnoreCase) + || centralPath.StartsWith("CLD://", StringComparison.OrdinalIgnoreCase)); + + private static void CollectStreams( + Storage storage, + string prefix, + List<(string Path, Storage Parent, string Name, long Length)> entries) + { + foreach (var entry in storage.EnumerateEntries()) + { + if (entry.Type == EntryType.Storage) + CollectStreams(storage.OpenStorage(entry.Name), prefix + entry.Name + "/", entries); + else if (entry.Type == EntryType.Stream) + entries.Add((prefix + entry.Name, storage, entry.Name, entry.Length)); + } + } + + private static int? PartitionNumber(string path) => + path.StartsWith("Partitions/", StringComparison.OrdinalIgnoreCase) + && int.TryParse(path.AsSpan("Partitions/".Length), NumberStyles.None, CultureInfo.InvariantCulture, out int number) + ? number + : null; + + private static byte[] ReadStreamBytes(Storage parent, string name, long length) + { + var bytes = new byte[checked((int)length)]; + using var stream = parent.OpenStream(name); + stream.ReadExactly(bytes); + return bytes; + } + + private static byte[] ReadStreamByPath(RootStorage root, string streamPath) + { + string[] parts = streamPath.Split('/'); + Storage current = root; + for (int i = 0; i < parts.Length - 1; i++) + current = current.OpenStorage(parts[i]); + using var stream = current.OpenStream(parts[^1]); + var bytes = new byte[checked((int)stream.Length)]; + stream.ReadExactly(bytes); + return bytes; + } +} diff --git a/tests/RvtIO.Tests/PartitionStreamEditorTests.cs b/tests/RvtIO.Tests/PartitionStreamEditorTests.cs new file mode 100644 index 0000000..590ded3 --- /dev/null +++ b/tests/RvtIO.Tests/PartitionStreamEditorTests.cs @@ -0,0 +1,474 @@ +using System.Buffers.Binary; +using System.Text; +using ICSharpCode.SharpZipLib.Checksum; +using RvtIO.Parsing; +using RvtIO.Writing; +using Xunit; + +namespace RvtIO.Tests; + +public sealed class PartitionStreamEditorTests +{ + private static readonly PartitionFraming.Tags SampleTags = new( + ContentMarker: 913, + ContentKey: 912, + SegmentMarker: 914, + SegmentCheckback: 915, + SignatureMarker: 916); + + internal sealed record SpecRecord(ulong Owner, byte[] Body, uint? CustomExtra = null); + + internal sealed record SpecChain(int Channel, SpecRecord[] Records, uint UpperFlags = 4u, uint Opaque = 0u, bool MultiSegment = false); + + internal sealed record SpecBlock(bool Keyed, SpecChain[] Chains, byte[]? SignatureBytes = null, string? SignatureText = null); + + internal static uint ComputeAdler(ReadOnlySpan bytes) + { + var adler = new Adler32(); + adler.Update(bytes.ToArray()); + return (uint)adler.Value; + } + + internal static byte[] TaggedBody(ushort classTag, ReadOnlySpan payload) + { + byte[] result = new byte[2 + payload.Length]; + BinaryPrimitives.WriteUInt16LittleEndian(result, classTag); + payload.CopyTo(result.AsSpan(2)); + return result; + } + + internal static byte[] BuildPartitionStream(int idWidth, PartitionFraming.Tags tags, params SpecBlock[] blocks) + { + ulong terminator = idWidth == 8 ? ulong.MaxValue : uint.MaxValue; + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + writer.Write(9u); + writer.Write(0u); + + for (int blockIndex = 0; blockIndex < blocks.Length; blockIndex++) + { + var block = blocks[blockIndex]; + uint ownerCount = (uint)block.Chains + .Where(c => c.Channel == 101) + .SelectMany(c => c.Records) + .Count(r => r.Owner != terminator); + + writer.Write(tags.ContentMarker); + writer.Write(block.Keyed ? uint.MaxValue : 0u); + if (block.Keyed) writer.Write(tags.ContentKey); + writer.Write(ownerCount); + if (block.Keyed) + { + byte[] key = new byte[16]; + key[0] = (byte)(blockIndex + 1); + key[15] = 0xAA; + writer.Write(key); + } + + foreach (var chain in block.Chains) + { + using var inflated = new MemoryStream(); + using var itemWriter = new BinaryWriter(inflated); + long bodySum = 0; + foreach (var rec in chain.Records) + { + if (idWidth == 8) itemWriter.Write(rec.Owner); + else itemWriter.Write((uint)rec.Owner); + + if (chain.Channel != 101) + { + uint extra = rec.CustomExtra ?? (rec.Owner == terminator && rec.Body.Length == 0 ? 0u : ComputeAdler(rec.Body)); + itemWriter.Write(extra); + } + itemWriter.Write((uint)rec.Body.Length); + itemWriter.Write(rec.Body); + itemWriter.Write((uint)rec.Body.Length); + bodySum += rec.Body.Length; + } + + byte[] allItems = inflated.ToArray(); + if (!chain.MultiSegment || allItems.Length < 6) + { + EmitRawSegment(writer, tags, chain.UpperFlags | 0u, (uint)chain.Records.Length, (uint)bodySum, (uint)chain.Channel, chain.Opaque, allItems); + } + else + { + int third = allItems.Length / 3; + EmitRawSegment(writer, tags, chain.UpperFlags | 2u, (uint)chain.Records.Length, (uint)bodySum, (uint)chain.Channel, chain.Opaque, allItems.AsSpan(0, third)); + EmitRawSegment(writer, tags, chain.UpperFlags | 3u, 0u, 0u, (uint)chain.Channel, chain.Opaque, allItems.AsSpan(third, third)); + EmitRawSegment(writer, tags, chain.UpperFlags | 1u, 0u, 0u, (uint)chain.Channel, chain.Opaque, allItems.AsSpan(2 * third)); + } + } + + // Empty segment closing the block's segment list + writer.Write(tags.SegmentMarker); + writer.Write(new byte[16]); + + // SignatureMarker + writer.Write(tags.SignatureMarker); + byte[] sigBytes = block.SignatureBytes ?? []; + string sigText = block.SignatureText ?? ""; + byte[] sigUtf16 = Encoding.Unicode.GetBytes(sigText); + writer.Write((uint)sigBytes.Length); + writer.Write(sigBytes); + writer.Write((uint)sigText.Length); + writer.Write(sigUtf16); + } + + // Terminal marker + writer.Write(tags.ContentMarker); + writer.Write(0u); + writer.Write(uint.MaxValue); + + return PageCodec.EncodePadded(stream.ToArray()); + } + + private static void EmitRawSegment( + BinaryWriter writer, + PartitionFraming.Tags tags, + uint flags, + uint count, + uint unc, + uint channel, + uint opaque, + ReadOnlySpan inflatedSlice) + { + byte[] gz = GzipMember.Write(inflatedSlice); + uint size = (uint)gz.Length + 8; + writer.Write(tags.SegmentMarker); + writer.Write(flags); + writer.Write(count); + writer.Write(size); + writer.Write(unc); + writer.Write(channel); + writer.Write(opaque); + writer.Write(gz); + writer.Write(tags.SegmentCheckback); + writer.Write(size); + } + + internal static SpecBlock MakeStandardBlock( + int idWidth, + bool keyed, + (ulong Owner, ushort ClassTag, byte[] Payload)[] elements, + uint upperFlags = 4u, + uint opaque = 0x12345678u, + bool splitChannel102IntoTwoChains = false, + bool multiSegmentChannel102 = false) + { + ulong terminator = idWidth == 8 ? ulong.MaxValue : uint.MaxValue; + var ch101 = elements.Select(e => new SpecRecord(e.Owner, TaggedBody(1, [(byte)(e.Owner & 0xFF)]))).Append(new SpecRecord(terminator, [])).ToArray(); + var ch103 = elements.Select(e => new SpecRecord(e.Owner, TaggedBody(3, [9, 8, 7]))).Append(new SpecRecord(terminator, [])).ToArray(); + + SpecChain[] chains; + if (splitChannel102IntoTwoChains && elements.Length >= 2) + { + int mid = elements.Length / 2; + var first102 = elements[..mid].Select(e => new SpecRecord(e.Owner, TaggedBody(e.ClassTag, e.Payload))).ToArray(); + var second102 = elements[mid..].Select(e => new SpecRecord(e.Owner, TaggedBody(e.ClassTag, e.Payload))).Append(new SpecRecord(terminator, [])).ToArray(); + chains = + [ + new SpecChain(101, ch101, upperFlags, opaque), + new SpecChain(102, first102, upperFlags, opaque), + new SpecChain(102, second102, upperFlags, opaque, multiSegmentChannel102), + new SpecChain(103, ch103, upperFlags, opaque), + ]; + } + else + { + var ch102 = elements.Select(e => new SpecRecord(e.Owner, TaggedBody(e.ClassTag, e.Payload))).Append(new SpecRecord(terminator, [])).ToArray(); + chains = + [ + new SpecChain(101, ch101, upperFlags, opaque), + new SpecChain(102, ch102, upperFlags, opaque, multiSegmentChannel102), + new SpecChain(103, ch103, upperFlags, opaque), + ]; + } + + byte[] sig = new byte[64]; + Array.Fill(sig, (byte)0x5A); + return new SpecBlock(keyed, chains, sig, "Data generated by Autodesk\u00AE Revit\u00AE"); + } + + [Fact] + public void PagedStreamCodec_PreservesZeroAndEightBytePrefixes_AndVerifiesParity() + { + byte[] formatsOriginal = PageCodec.EncodePadded(GzipMember.Write([1, 2, 3, 4])); + Assert.Empty(PagedStreamCodec.ReadPrefix(formatsOriginal)); + + byte[] newFormatsPayload = Encoding.UTF8.GetBytes("updated-formats-payload"); + byte[] reencodedFormats = PagedStreamCodec.Reencode(formatsOriginal, newFormatsPayload); + Assert.Equal(PageCheckStatus.Valid, PageCodec.Verify(reencodedFormats).Status); + Assert.Equal(newFormatsPayload, PagedGzipStream.Decompress(reencodedFormats)!); + + byte[] prefix = [5, 0, 0, 0, 0, 0, 0, 0]; + byte[] globalOriginal = PagedStreamCodec.Encode(prefix, [10, 20, 30]); + Assert.Equal(prefix, PagedStreamCodec.ReadPrefix(globalOriginal)); + + byte[] newGlobalPayload = [99, 100, 101, 102, 103]; + byte[] reencodedGlobal = PagedStreamCodec.Reencode(globalOriginal, newGlobalPayload); + Assert.Equal(PageCheckStatus.Valid, PageCodec.Verify(reencodedGlobal).Status); + Assert.Equal(prefix, reencodedGlobal[..8]); + Assert.Equal(newGlobalPayload, PagedGzipStream.Decompress(reencodedGlobal)!); + } + + [Fact] + public void Apply_WithEmptyEdits_ReturnsExactByteCopyAfterVerifyingFraming() + { + byte[] raw = BuildPartitionStream( + 8, + SampleTags, + MakeStandardBlock(8, keyed: false, [(100UL, 42, [1, 2, 3]), (200UL, 5000, [9, 9, 9, 9])])); + + byte[] output = PartitionStreamEditor.Apply(raw, SampleTags, 8, []); + + Assert.Equal(raw, output); + Assert.NotSame(raw, output); + } + + [Theory] + [InlineData(4)] + [InlineData(8)] + public void Apply_RewritesTargetedRecords_PreservesUntouchedBlocksChainsFlagsOpaqueAndUnknownPayloads(int idWidth) + { + byte[] opaqueUnknownPayload = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03]; + ulong largeId = idWidth == 8 ? 0x0000_0001_0000_0042UL : 4200UL; + var projectBlock = MakeStandardBlock( + idWidth, + keyed: false, + [ + (10UL, 50, [1, 2, 3, 4]), + (20UL, 5000, opaqueUnknownPayload), + (largeId, 51, [7, 8]), + ], + upperFlags: 4u, + opaque: 0x0BAD_F00Du, + splitChannel102IntoTwoChains: true, + multiSegmentChannel102: true); + + var familyBlock = MakeStandardBlock( + idWidth, + keyed: true, + [ + (10UL, 60, [55, 66, 77]), + (99UL, 61, [88, 99]), + ], + upperFlags: 4u, + opaque: 0x1122_3344u); + + byte[] raw = BuildPartitionStream(idWidth, SampleTags, projectBlock, familyBlock); + byte[] replacementBody = TaggedBody(51, Encoding.Unicode.GetBytes("Expanded synthetic record payload!")); + + byte[] edited = PartitionStreamEditor.Apply( + raw, + SampleTags, + idWidth, + [new PartitionRecordEdit(Block: 0, Channel: 102, Owner: largeId, Body: replacementBody)]); + + Assert.Equal(PageCheckStatus.Valid, PageCodec.Verify(edited).Status); + + var records = new List(); + Assert.True( + PartitionFraming.Walk( + edited, + SampleTags, + idWidth, + r => records.Add(r with { Body = r.Body.ToArray(), Item = r.Item.ToArray() }), + out string? failure, + out int blocks), + failure); + Assert.Equal(2, blocks); + + // Edited record updated with fresh Adler-32 + var target = records.Single(r => r.Block == 0 && r.Channel == 102 && r.Owner == largeId); + Assert.Equal(replacementBody, target.Body.ToArray()); + Assert.Equal(ComputeAdler(replacementBody), target.Extra); + + // Unknown class payload (#5000) in project block preserved byte-for-byte + var unknown = records.Single(r => r.Block == 0 && r.Channel == 102 && r.Owner == 20UL); + Assert.Equal(TaggedBody(5000, opaqueUnknownPayload), unknown.Body.ToArray()); + + // Same owner ID (10) in loaded-family keyed block (Block 1) is completely untouched + var familyOwner10 = records.Single(r => r.Block == 1 && r.Channel == 102 && r.Owner == 10UL); + Assert.Equal(TaggedBody(60, [55, 66, 77]), familyOwner10.Body.ToArray()); + + // Inspect raw segment flags, opaque dword, and block signatures: + // Block 0 (edited) has unsigned SignatureMarker (n=0, m=0) and keeps upper flag 0x04 + opaque 0x0BADF00D; + // Block 1 (unedited family) keeps its 64-byte signature and text verbatim! + byte[] stripped = PartitionFraming.RemoveTails(edited)!; + InspectBlockMetadata(stripped, out var block0Meta, out var block1Meta); + Assert.Equal(4u, block0Meta.FirstUpperFlags); + Assert.Equal(0x0BAD_F00Du, block0Meta.FirstOpaque); + Assert.Equal(0u, block0Meta.SignatureBytes); + Assert.Equal(0u, block0Meta.SignatureChars); + + Assert.Equal(64u, block1Meta.SignatureBytes); + Assert.Equal(34u, block1Meta.SignatureChars); + } + + [Fact] + public void Apply_SplitsReplacementBodyLargerThan128KiB_IntoMultiSegmentChain() + { + byte[] raw = BuildPartitionStream( + 8, + SampleTags, + MakeStandardBlock(8, keyed: false, [(1UL, 42, [1, 2]), (2UL, 43, [3, 4])])); + + // 300,000 bytes > 2 * 131,072 => must emit mode 2 (131,072) -> mode 3 (131,072) -> mode 1 (37,856) + byte[] bigPayload = new byte[299_998]; + new Random(2026).NextBytes(bigPayload); + byte[] bigBody = TaggedBody(42, bigPayload); + Assert.Equal(300_000, bigBody.Length); + + byte[] edited = PartitionStreamEditor.Apply( + raw, + SampleTags, + 8, + [new PartitionRecordEdit(Block: 0, Channel: 102, Owner: 1UL, Body: bigBody)]); + + var segmentLengths = new List(); + var records = new List(); + Assert.True( + PartitionFraming.Walk( + edited, + SampleTags, + 8, + r => records.Add(r with { Body = r.Body.ToArray(), Item = r.Item.ToArray() }), + out string? failure, + out _, + segmentLengths), + failure); + + var updated = records.Single(r => r.Block == 0 && r.Channel == 102 && r.Owner == 1UL); + Assert.Equal(bigBody, updated.Body.ToArray()); + Assert.Equal(ComputeAdler(bigBody), updated.Extra); + // ch101 (1) + split ch102 record 1 (3 segments) + ch102 remaining batch (1) + ch103 (1) = 6 segments + Assert.Equal(6, segmentLengths.Count); + } + + [Fact] + public void Apply_SupportsChannel101AndChannel103Rewrites() + { + byte[] raw = BuildPartitionStream( + 4, + SampleTags, + MakeStandardBlock(4, keyed: false, [(7UL, 42, [10, 20, 30])])); + + byte[] new101 = TaggedBody(1, [0xAA, 0xBB, 0xCC, 0xDD]); + byte[] new103 = TaggedBody(3, [0x11, 0x22]); + + byte[] edited = PartitionStreamEditor.Apply( + raw, + SampleTags, + 4, + [ + new PartitionRecordEdit(0, 101, 7UL, new101), + new PartitionRecordEdit(0, 103, 7UL, new103), + ]); + + var records = new List(); + Assert.True( + PartitionFraming.Walk( + edited, + SampleTags, + 4, + r => records.Add(r with { Body = r.Body.ToArray(), Item = r.Item.ToArray() }), + out _, + out _)); + + var rec101 = records.Single(r => r.Channel == 101 && r.Owner == 7UL); + Assert.Equal(0u, rec101.Extra); + Assert.Equal(new101, rec101.Body.ToArray()); + + var rec103 = records.Single(r => r.Channel == 103 && r.Owner == 7UL); + Assert.Equal(ComputeAdler(new103), rec103.Extra); + Assert.Equal(new103, rec103.Body.ToArray()); + } + + [Fact] + public void Apply_RefusesInvalidEditsAndDamagedInputs() + { + byte[] valid = BuildPartitionStream( + 4, + SampleTags, + MakeStandardBlock(4, keyed: false, [(5UL, 42, [1, 2, 3])])); + byte[] validBody = TaggedBody(42, [9, 9]); + + // Invalid block + Assert.Throws(() => PartitionStreamEditor.Apply(valid, SampleTags, 4, [new(1, 102, 5UL, validBody)])); + // Invalid channel + Assert.Throws(() => PartitionStreamEditor.Apply(valid, SampleTags, 4, [new(0, 104, 5UL, validBody)])); + // Terminator owner (-1 for 4-byte IDs) + Assert.Throws(() => PartitionStreamEditor.Apply(valid, SampleTags, 4, [new(0, 102, uint.MaxValue, validBody)])); + // 64-bit owner on 4-byte ID stream + Assert.Throws(() => PartitionStreamEditor.Apply(valid, SampleTags, 4, [new(0, 102, (ulong)uint.MaxValue + 10UL, validBody)])); + // Tagless body (< 2 bytes) + Assert.Throws(() => PartitionStreamEditor.Apply(valid, SampleTags, 4, [new(0, 102, 5UL, new byte[] { 0x01 })])); + // Missing owner + Assert.Throws(() => PartitionStreamEditor.Apply(valid, SampleTags, 4, [new(0, 102, 999UL, validBody)])); + // Duplicate target + Assert.Throws(() => PartitionStreamEditor.Apply(valid, SampleTags, 4, [new(0, 102, 5UL, validBody), new(0, 102, 5UL, validBody)])); + + // Corrupt page parity + byte[] badParity = [.. valid]; + badParity[100] ^= 0x01; + Assert.Throws(() => PartitionStreamEditor.Apply(badParity, SampleTags, 4, [new(0, 102, 5UL, validBody)])); + + // Mismatched input Adler-32 checksum + var badCrcBlock = new SpecBlock( + false, + [ + new SpecChain(101, [new SpecRecord(5UL, TaggedBody(1, [1])), new SpecRecord(uint.MaxValue, [])]), + new SpecChain(102, [new SpecRecord(5UL, TaggedBody(42, [1, 2]), CustomExtra: 0xDEAD_BEEFu), new SpecRecord(uint.MaxValue, [])]), + new SpecChain(103, [new SpecRecord(5UL, TaggedBody(3, [1])), new SpecRecord(uint.MaxValue, [])]), + ]); + byte[] badAdlerStream = BuildPartitionStream(4, SampleTags, badCrcBlock); + Assert.Throws(() => PartitionStreamEditor.Apply(badAdlerStream, SampleTags, 4, [new(0, 102, 5UL, validBody)])); + } + + private static void InspectBlockMetadata( + byte[] strippedBody, + out (uint FirstUpperFlags, uint FirstOpaque, uint SignatureBytes, uint SignatureChars) block0, + out (uint FirstUpperFlags, uint FirstOpaque, uint SignatureBytes, uint SignatureChars) block1) + { + int pos = 8; + block0 = ReadOneBlock(strippedBody, ref pos); + block1 = ReadOneBlock(strippedBody, ref pos); + } + + private static (uint FirstUpperFlags, uint FirstOpaque, uint SignatureBytes, uint SignatureChars) ReadOneBlock(byte[] body, ref int pos) + { + pos += 2; // ContentMarker + uint ptr = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); pos += 4; + if (ptr != 0) pos += 2; + pos += 4; // owner count + if (ptr != 0) pos += 16; + + uint firstUpperFlags = 0, firstOpaque = 0; + bool seenFirst = false; + while (true) + { + pos += 2; + uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); pos += 4; + pos += 4; + uint size = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); pos += 4; + pos += 4; + if (size == 0) break; + pos += 4; // channel + uint opq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); pos += 4; + if (!seenFirst) + { + firstUpperFlags = flags & ~3u; + firstOpaque = opq; + seenFirst = true; + } + pos += (int)size - 8 + 6; + } + pos += 2; // SignatureMarker + uint sigBytes = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); + pos += 4 + (int)sigBytes; + uint sigChars = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); + pos += 4 + (int)(2 * sigChars); + return (firstUpperFlags, firstOpaque, sigBytes, sigChars); + } +} diff --git a/tests/RvtIO.Tests/RvtIO.Tests.csproj b/tests/RvtIO.Tests/RvtIO.Tests.csproj index 8d698cd..4e0078f 100644 --- a/tests/RvtIO.Tests/RvtIO.Tests.csproj +++ b/tests/RvtIO.Tests/RvtIO.Tests.csproj @@ -36,6 +36,7 @@ + diff --git a/tests/RvtIO.Tests/RvtWriterSampleTests.cs b/tests/RvtIO.Tests/RvtWriterSampleTests.cs new file mode 100644 index 0000000..e4b5a8e --- /dev/null +++ b/tests/RvtIO.Tests/RvtWriterSampleTests.cs @@ -0,0 +1,241 @@ +using System.Globalization; +using System.Security.Cryptography; +using OpenMcdf; +using RvtIO.Parsing; +using RvtIO.Writing; +using Xunit; +using Xunit.Abstractions; + +namespace RvtIO.Tests; + +/// +/// Exercises and across real Revit project (.rvt) and family (.rfa) +/// files located in the private fixture folder (). Skipped cleanly when no private fixtures are present. +/// +public sealed class RvtWriterSampleTests : IDisposable +{ + private const long MaxTargetedRewriteBytes = 32L * 1024 * 1024; + private const int MaxTargetedRewriteFiles = 6; + private readonly ITestOutputHelper _output; + private readonly string _folder = Path.Combine(Path.GetTempPath(), "rvtio-writer-samples-" + Guid.NewGuid().ToString("N")); + + public RvtWriterSampleTests(ITestOutputHelper output) + { + _output = output; + Directory.CreateDirectory(_folder); + } + + public void Dispose() => Directory.Delete(_folder, recursive: true); + + private static List SampleRevitFiles() + { + var files = new SortedSet(StringComparer.Ordinal); + foreach (string root in TestFiles.Roots()) + { + foreach (string ext in new[] { "*.rvt", "*.rfa" }) + { + foreach (string file in Directory.EnumerateFiles(root, ext)) + files.Add(file); + foreach (string folder in Directory.EnumerateDirectories(root)) + foreach (string file in Directory.EnumerateFiles(folder, ext)) + files.Add(file); + } + } + if (files.Count == 0) + throw TestFiles.Missing("*.rvt / *.rfa Revit sample files"); + return [.. files]; + } + + private static SortedDictionary StreamHashes(string path) + { + var hashes = new SortedDictionary(StringComparer.Ordinal); + using var root = RootStorage.OpenRead(path); + byte[] buffer = new byte[1 << 20]; + void Walk(Storage storage, string prefix) + { + foreach (var entry in storage.EnumerateEntries()) + { + if (entry.Type == EntryType.Storage) + { + Walk(storage.OpenStorage(entry.Name), prefix + entry.Name + "/"); + continue; + } + using var stream = storage.OpenStream(entry.Name); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + long length = 0; + int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + { + hash.AppendData(buffer.AsSpan(0, read)); + length += read; + } + hashes.Add(prefix + entry.Name, (length, Convert.ToHexString(hash.GetHashAndReset()))); + } + } + Walk(root, ""); + return hashes; + } + + private string Fresh(string name) + { + string path = Path.Combine(_folder, name); + File.Delete(path); + return path; + } + + [SkippableFact] + public void SaveAs_OnSupportedSampleFiles_PreservesStreamsIdentitiesRelationshipsGeometryAndExternalRefs() + { + int supportedCount = 0; + int refusedCount = 0; + + foreach (string source in SampleRevitFiles()) + { + var caps = RvtWriter.InspectCapabilities(source); + string ext = Path.GetExtension(source); + string copy = Fresh("rvtwriter-saveas" + ext); + try + { + if (!caps.Supports(RvtWriteOperation.SaveAs)) + { + refusedCount++; + Assert.Throws(() => RvtWriter.SaveAs(source, copy)); + Assert.False(File.Exists(copy)); + continue; + } + + supportedCount++; + var writeResult = RvtWriter.SaveAs(source, copy); + Assert.Equal(caps.FileKind, writeResult.FileKind); + Assert.Equal(caps.EffectiveRelease, writeResult.EffectiveRelease); + + var expectedStreams = StreamHashes(source); + var actualStreams = StreamHashes(copy); + Assert.Equal( + expectedStreams.Select(s => (s.Key, s.Value.Length, s.Value.Sha256)), + actualStreams.Select(s => (s.Key, s.Value.Length, s.Value.Sha256))); + + var srcDoc = RvtDocument.Open(source); + var dstDoc = RvtDocument.Open(copy); + + Assert.Equal(srcDoc.Elements.Count, dstDoc.Elements.Count); + Assert.Equal( + srcDoc.Elements.Select(e => (e.Id, e.UniqueId, e.CategoryId, e.FamilyTypeId, e.HostId, e.References?.Count ?? 0, e.Geometry?.Solids?.Count ?? -1, e.Geometry?.Faces?.Count ?? -1)), + dstDoc.Elements.Select(e => (e.Id, e.UniqueId, e.CategoryId, e.FamilyTypeId, e.HostId, e.References?.Count ?? 0, e.Geometry?.Solids?.Count ?? -1, e.Geometry?.Faces?.Count ?? -1))); + + var srcRefs = srcDoc.TransmissionData?.ExternalReferences.Select(r => (r.ElementId, r.ReferenceType, r.Path, r.PathType, r.LoadState)).ToArray() ?? []; + var dstRefs = dstDoc.TransmissionData?.ExternalReferences.Select(r => (r.ElementId, r.ReferenceType, r.Path, r.PathType, r.LoadState)).ToArray() ?? []; + Assert.Equal(srcRefs, dstRefs); + } + finally + { + File.Delete(copy); + } + } + + _output.WriteLine($"supported={supportedCount}, refused={refusedCount}"); + Assert.True(supportedCount + refusedCount > 0); + } + + [SkippableFact] + public void TargetedRewrite_OnSampleFiles_VerifiesChecksumsAndPreservesModelGraph() + { + var candidates = SampleRevitFiles() + .Where(p => new FileInfo(p).Length <= MaxTargetedRewriteBytes) + .Where(p => RvtWriter.InspectCapabilities(p).Supports(RvtWriteOperation.PartitionRecordRewrite)) + .OrderBy(p => new FileInfo(p).Length) + .Take(MaxTargetedRewriteFiles) + .ToList(); + + if (candidates.Count == 0) + throw new SkipException("No supported sample file <= 32 MB with partition streams found."); + + int exercised = 0; + foreach (string source in candidates) + { + int? targetPart = null; + ulong targetOwner = 0; + byte[]? targetBody = null; + byte[]? globalPtPayload; + + using (var root = RootStorage.OpenRead(source)) + { + var schema = FormatsSchema.TryRead(root)!; + var tags = PartitionFraming.TagsOf(schema)!.Value; + int idWidth = schema.Release >= 2024 ? 8 : 4; + ulong terminator = idWidth == 8 ? ulong.MaxValue : uint.MaxValue; + + globalPtPayload = PagedGzipStream.Read(root, "Global", "PartitionTable"); + + var partStorage = root.OpenStorage("Partitions"); + foreach (var entry in partStorage.EnumerateEntries().Where(e => e.Type == EntryType.Stream && int.TryParse(e.Name, out _))) + { + int partNo = int.Parse(entry.Name, CultureInfo.InvariantCulture); + using var s = partStorage.OpenStream(entry.Name); + byte[] raw = new byte[s.Length]; + s.ReadExactly(raw); + bool walked = PartitionFraming.Walk( + raw, + tags, + idWidth, + r => + { + if (targetPart is null && r.Block == 0 && r.Channel == 102 && r.Owner != terminator && r.Body.Length >= 2) + { + targetPart = partNo; + targetOwner = r.Owner; + targetBody = r.Body.ToArray(); + } + }, + out _, + out _); + Assert.True(walked); + if (targetPart is not null) break; + } + } + + if (targetPart is null || targetBody is null) + continue; + + string ext = Path.GetExtension(source); + string rewritten = Fresh("rvtwriter-rewrite" + ext); + try + { + var pagedEdits = globalPtPayload is not null + ? new[] { new RvtPagedStreamEdit("Global/PartitionTable", globalPtPayload) } + : Array.Empty(); + + var res = RvtWriter.Rewrite(source, rewritten, new RvtWriteRequest + { + PagedStreamEdits = pagedEdits, + PartitionRecordEdits = + [ + new RvtPartitionRecordEdit(targetPart.Value, 102, targetOwner, targetBody), + ], + }); + + Assert.Equal(1, res.RewrittenRecordCount); + + var srcCensus = CoverageCensus.Read(source); + var dstCensus = CoverageCensus.Read(rewritten); + Assert.Equal(0, dstCensus.Totals.ChecksumMismatches); + Assert.Equal(srcCensus.Totals.Items, dstCensus.Totals.Items); + + var srcDoc = RvtDocument.Open(source); + var dstDoc = RvtDocument.Open(rewritten); + Assert.Equal(srcDoc.Elements.Count, dstDoc.Elements.Count); + Assert.Equal( + srcDoc.Elements.Select(e => (e.Id, e.UniqueId)), + dstDoc.Elements.Select(e => (e.Id, e.UniqueId))); + exercised++; + } + finally + { + File.Delete(rewritten); + } + } + + _output.WriteLine($"exercised={exercised} of {candidates.Count}"); + Assert.True(exercised > 0); + } +} diff --git a/tests/RvtIO.Tests/RvtWriterTests.cs b/tests/RvtIO.Tests/RvtWriterTests.cs new file mode 100644 index 0000000..3ab520e --- /dev/null +++ b/tests/RvtIO.Tests/RvtWriterTests.cs @@ -0,0 +1,550 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using OpenMcdf; +using RvtIO.Parsing; +using RvtIO.Writing; +using Xunit; +using Cls = RvtIO.Tests.SchemaStreamEditorTests.Cls; +using Fld = RvtIO.Tests.SchemaStreamEditorTests.Fld; +using NoRef = RvtIO.Tests.SchemaStreamEditorTests.NoRef; + +namespace RvtIO.Tests; + +public sealed class RvtWriterTests : IDisposable +{ + private readonly string _folder = Path.Combine(Path.GetTempPath(), "rvtio-writer-" + Guid.NewGuid().ToString("N")); + + public RvtWriterTests() => Directory.CreateDirectory(_folder); + + public void Dispose() => Directory.Delete(_folder, recursive: true); + + private string P(string name) => Path.Combine(_folder, name); + + private static readonly IReadOnlyDictionary ReleaseTableSpecs = + new Dictionary + { + [2020] = (779, 4164), + [2021] = (801, 4168), + [2022] = (817, 4307), + [2023] = (869, 4418), + [2024] = (891, 4492), + [2025] = (913, 4600), + [2026] = (931, 4690), + [2027] = (951, 4757), + }; + + internal static byte[] BuildSchemaBytesForRelease(int release, bool includeFramingMarkers = true) + { + var writer = new SchemaStreamEditorTests.Writer(); + if (release == 2019) + { + for (int i = 0; i < 4098; i++) + writer.Top(new Cls("R19Class" + i, new NoRef(), 1, [], [])); + return writer.Build(); + } + + var (markerId, totalClasses) = ReleaseTableSpecs[release]; + // SchemaStreamEditorTests.Writer assigns class IDs starting at 12. + int currentId = 12; + int targetBeforeContentKey = includeFramingMarkers ? markerId - 1 : markerId + 10; + while (currentId < targetBeforeContentKey) + { + writer.Top(new Cls("Filler" + currentId, new NoRef(), 1, [], [])); + currentId++; + } + + if (includeFramingMarkers) + { + writer.Top(new Cls("ContentKey", new NoRef(), 1, [], [])); // markerId - 1 + writer.Top(new Cls("ContentMarker", new NoRef(), 1, [], [])); // markerId + writer.Top(new Cls("SegmentMarker", new NoRef(), 1, [], [])); // markerId + 1 + writer.Top(new Cls("SegmentCheckback", new NoRef(), 1, [], [])); // markerId + 2 + writer.Top(new Cls("SignatureMarker", new NoRef(), 1, [], [])); // markerId + 3 + // Sample domain class with one int32 field (type 0x05) so WideBlockWalker walks it cleanly! + writer.Top(new Cls("SyntheticItem", new NoRef(), 2, [new Fld("m_code", 0x05)], ["00112233445566778899aabbccddeeff"])); + } + + while (writer.Placed.Count < totalClasses) + writer.Top(new Cls("TailClass" + writer.Placed.Count, new NoRef(), 1, [], [])); + + return writer.Build(); + } + + internal static byte[] BuildBasicFileInfoBytes( + int? savedRelease, + string worksharing = "Not enabled", + string cloudModel = "False", + string? centralPath = null, + string? lastSavePath = null) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(13); // StreamVersion + void WriteLengthPrefixedUtf16(string text) + { + bw.Write(text.Length); + bw.Write(Encoding.Unicode.GetBytes(text)); + } + + if (savedRelease is int year) + WriteLengthPrefixedUtf16(year.ToString(System.Globalization.CultureInfo.InvariantCulture)); + if (lastSavePath is not null) + WriteLengthPrefixedUtf16(lastSavePath); + bw.Write(0); // Separator before the UTF-16 key-value lines block + + var lines = new StringBuilder(); + lines.Append("Worksharing: ").Append(worksharing).Append("\r\n"); + if (savedRelease is int formatYear) + lines.Append("Format: ").Append(formatYear).Append("\r\n"); + if (centralPath is not null) + lines.Append("Central Model Path: ").Append(centralPath).Append("\r\n"); + lines.Append("IsSingleUserCloudModel: ").Append(cloudModel).Append("\r\n"); + bw.Write(Encoding.Unicode.GetBytes(lines.ToString())); + bw.Write((ushort)0); // Trailing UTF-16 NUL terminator ending the text run in BasicFileInfo + return ms.ToArray(); + } + + internal static byte[] BuildTransmissionDataBytes(string linkedPath) + { + string xml = $"""501Revit Link{linkedPath}RelativeLoaded"""; + byte[] utf16 = Encoding.Unicode.GetBytes(xml); + byte[] data = new byte[4 + utf16.Length]; + BinaryPrimitives.WriteInt32LittleEndian(data, xml.Length); + utf16.CopyTo(data.AsSpan(4)); + return data; + } + + internal sealed class SyntheticFileOptions + { + public string FileName { get; init; } = "model.rvt"; + public RvtFileKind Kind { get; init; } = RvtFileKind.Project; + public int SchemaRelease { get; init; } = 2025; + public int? SavedRelease { get; init; } = 2025; + public string Worksharing { get; init; } = "Not enabled"; + public string CloudModel { get; init; } = "False"; + public string? CentralPath { get; init; } + public OpenMcdf.Version SectorVersion { get; init; } = OpenMcdf.Version.V4; + public bool IncludeSchema { get; init; } = true; + public bool IncludeFramingMarkers { get; init; } = true; + public bool IncludePartitions { get; init; } = true; + public string? LinkedPath { get; init; } = @"links\Arch_Link.rvt"; + } + + internal string CreateSyntheticRevitFile(SyntheticFileOptions? options = null) + { + var opt = options ?? new SyntheticFileOptions(); + return CreateSyntheticRevitFile(P(opt.FileName), opt); + } + + internal static string CreateSyntheticRevitFile(string path, SyntheticFileOptions? options = null) + { + var opt = options ?? new SyntheticFileOptions(); + using var root = RootStorage.Create(path, opt.SectorVersion); + + // BasicFileInfo + using (var bfi = root.CreateStream("BasicFileInfo")) + { + string defaultLastSave = opt.Kind == RvtFileKind.Family ? @"C:\Models\Sample.rfa" : opt.Kind == RvtFileKind.Project ? @"C:\Models\Sample.rvt" : ""; + bfi.Write(BuildBasicFileInfoBytes( + opt.SavedRelease, + opt.Worksharing, + opt.CloudModel, + opt.CentralPath, + string.IsNullOrEmpty(defaultLastSave) ? null : defaultLastSave)); + } + + // Kind-defining stream + custom opaque vendor stream + if (opt.Kind == RvtFileKind.Family) + { + using var atom = root.CreateStream("PartAtom"); + atom.Write(Encoding.UTF8.GetBytes("SyntheticFamily")); + } + else if (opt.Kind == RvtFileKind.Project) + { + using var pi = root.CreateStream("ProjectInformation"); + pi.Write([1, 2, 3, 4, 5, 6, 7, 8]); + } + + if (opt.LinkedPath is not null) + { + using var td = root.CreateStream("TransmissionData"); + td.Write(BuildTransmissionDataBytes(opt.LinkedPath)); + } + + using (var custom = root.CreateStream("CustomOpaqueVendorStream")) + custom.Write([0xCA, 0xFE, 0xBA, 0xBE, 0x42]); + + // Global storage with 8-byte prefixes matching real Revit files + var global = root.CreateStorage("Global"); + using (var gl = global.CreateStream("Latest")) + gl.Write(PagedStreamCodec.Encode([5, 0, 0, 0, 0, 0, 0, 0], [10, 20, 30, 40])); + using (var pt = global.CreateStream("PartitionTable")) + pt.Write(PagedStreamCodec.Encode([0, 0, 0, 0, 0, 0, 0, 0], [1, 0, 0, 0])); + + // Formats storage + var formats = root.CreateStorage("Formats"); + SchemaDocument? parsedSchema = null; + if (opt.IncludeSchema) + { + byte[] schemaBytes = BuildSchemaBytesForRelease(opt.SchemaRelease, opt.IncludeFramingMarkers); + parsedSchema = TfSchemaReader.Parse(schemaBytes); + using var fl = formats.CreateStream("Latest"); + fl.Write(PagedStreamCodec.Encode([], schemaBytes)); + } + + // Partitions storage + var partitions = root.CreateStorage("Partitions"); + if (opt.IncludePartitions && parsedSchema is not null && opt.IncludeFramingMarkers && opt.SchemaRelease >= 2020) + { + var tags = PartitionFraming.TagsOf(new FormatsSchema(parsedSchema))!.Value; + int idWidth = opt.SchemaRelease >= 2024 ? 8 : 4; + ushort syntheticItemTag = (ushort)parsedSchema.Classes.Single(c => c.Name == "SyntheticItem").Id; + + byte[] code100 = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(code100, 100); + byte[] unknownOpaqueBody = [0x99, 0x88, 0x77, 0x66, 0x55]; + + var projectBlock = PartitionStreamEditorTests.MakeStandardBlock( + idWidth, + keyed: false, + [ + (101UL, syntheticItemTag, code100), + (202UL, 65000, unknownOpaqueBody), // unknown class tag + opaque payload + ]); + var familyBlock = PartitionStreamEditorTests.MakeStandardBlock( + idWidth, + keyed: true, + [(101UL, syntheticItemTag, code100)]); + + byte[] partBytes = PartitionStreamEditorTests.BuildPartitionStream(idWidth, tags, projectBlock, familyBlock); + using var p1 = partitions.CreateStream("1"); + p1.Write(partBytes); + } + + return path; + } + + private static string FileSha256(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); + + [Theory] + [InlineData(2020, RvtFileKind.Project, 4)] + [InlineData(2020, RvtFileKind.Family, 4)] + [InlineData(2021, RvtFileKind.Project, 4)] + [InlineData(2022, RvtFileKind.Family, 4)] + [InlineData(2023, RvtFileKind.Project, 4)] + [InlineData(2024, RvtFileKind.Project, 8)] + [InlineData(2025, RvtFileKind.Family, 8)] + [InlineData(2026, RvtFileKind.Project, 8)] + [InlineData(2027, RvtFileKind.Project, 8)] + [InlineData(2027, RvtFileKind.Family, 8)] + public void InspectCapabilities_SupportsAllOperations_Across2020To2027ProjectsAndFamilies( + int release, + RvtFileKind kind, + int expectedIdWidth) + { + string ext = kind == RvtFileKind.Family ? ".rfa" : ".rvt"; + string path = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = $"model_{release}{ext}", + Kind = kind, + SchemaRelease = release, + SavedRelease = release, + }); + + var caps = RvtWriter.InspectCapabilities(path); + + Assert.Equal(kind, caps.FileKind); + Assert.Equal(release, caps.SavedRelease); + Assert.Equal(release, caps.SchemaRelease); + Assert.Equal(release, caps.EffectiveRelease); + Assert.Equal(expectedIdWidth, caps.ElementIdWidth); + Assert.False(caps.IsWorkshared); + Assert.False(caps.IsCloudModel); + Assert.Equal(1, caps.PartitionStreamCount); + Assert.True(caps.Supports(RvtWriteOperation.SaveAs)); + Assert.True(caps.Supports(RvtWriteOperation.PagedStreamRewrite)); + Assert.True(caps.Supports(RvtWriteOperation.PartitionRecordRewrite)); + } + + [Fact] + public void InspectCapabilities_AndWrite_RefuseUnsupportedStatesWithStableCodes() + { + // 1. Release 2019 -> unsupported-release + string r2019 = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "r2019.rvt", + SchemaRelease = 2019, + SavedRelease = 2019, + IncludePartitions = false, + }); + var caps2019 = RvtWriter.InspectCapabilities(r2019); + Assert.False(caps2019.Supports(RvtWriteOperation.SaveAs)); + Assert.Equal("unsupported-release", caps2019.Operations[RvtWriteOperation.SaveAs].RefusalCode); + var ex2019 = Assert.Throws(() => RvtWriter.SaveAs(r2019, P("r2019_out.rvt"))); + Assert.Equal("unsupported-release", ex2019.Kind); + Assert.False(File.Exists(P("r2019_out.rvt"))); + + // 2. SavedRelease != SchemaRelease -> release-mismatch + string mismatch = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "mismatch.rvt", + SchemaRelease = 2024, + SavedRelease = 2025, + }); + Assert.Equal("release-mismatch", RvtWriter.InspectCapabilities(mismatch).Operations[RvtWriteOperation.SaveAs].RefusalCode); + + // 3. Workshared model -> workshared-model + string workshared = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "workshared.rvt", + Worksharing = "Central", + }); + string wsHash = FileSha256(workshared); + var exWs = Assert.Throws(() => RvtWriter.SaveAs(workshared, P("ws_out.rvt"))); + Assert.Equal("workshared-model", exWs.Kind); + Assert.Equal(wsHash, FileSha256(workshared)); + Assert.False(File.Exists(P("ws_out.rvt"))); + + // 4. Single-user cloud model & cloud central path -> cloud-model + string cloud1 = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "cloud1.rvt", + CloudModel = "True", + }); + Assert.Equal("cloud-model", Assert.Throws(() => RvtWriter.SaveAs(cloud1, P("c1_out.rvt"))).Kind); + + string cloud2 = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "cloud2.rvt", + CentralPath = "Autodesk Docs://Project/Model.rvt", + }); + Assert.Equal("cloud-model", Assert.Throws(() => RvtWriter.SaveAs(cloud2, P("c2_out.rvt"))).Kind); + + // 5. Unknown file kind -> unknown-file-kind + string unknownKind = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "mystery.bin", + Kind = RvtFileKind.Unknown, + }); + Assert.Equal("unknown-file-kind", RvtWriter.InspectCapabilities(unknownKind).Operations[RvtWriteOperation.SaveAs].RefusalCode); + + // 6. Missing schema -> missing-schema + string noSchema = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "noschema.rvt", + IncludeSchema = false, + IncludePartitions = false, + }); + Assert.Equal("missing-schema", RvtWriter.InspectCapabilities(noSchema).Operations[RvtWriteOperation.SaveAs].RefusalCode); + + // 7. Missing partitions -> SaveAs & PagedStreamRewrite supported, PartitionRecordRewrite refused with missing-partitions! + string noParts = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "noparts.rvt", + IncludePartitions = false, + }); + var capsNoParts = RvtWriter.InspectCapabilities(noParts); + Assert.True(capsNoParts.Supports(RvtWriteOperation.SaveAs)); + Assert.True(capsNoParts.Supports(RvtWriteOperation.PagedStreamRewrite)); + Assert.False(capsNoParts.Supports(RvtWriteOperation.PartitionRecordRewrite)); + Assert.Equal("missing-partitions", capsNoParts.Operations[RvtWriteOperation.PartitionRecordRewrite].RefusalCode); + } + + [Theory] + [InlineData(OpenMcdf.Version.V3)] + [InlineData(OpenMcdf.Version.V4)] + public void SaveAs_AndDocumentSaveAs_PreserveEveryStreamExternalRefAndUnknownPayload(OpenMcdf.Version sectorVersion) + { + string source = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = $"source_{sectorVersion}.rvt", + SectorVersion = sectorVersion, + SchemaRelease = 2026, + SavedRelease = 2026, + LinkedPath = @"links\Structural_2026.rvt", + }); + string sourceSha = FileSha256(source); + string copy1 = P($"copy1_{sectorVersion}.rvt"); + string copy2 = P($"copy2_{sectorVersion}.rvt"); + + var writeResult = RvtWriter.SaveAs(source, copy1); + Assert.Equal(RvtFileKind.Project, writeResult.FileKind); + Assert.Equal(2026, writeResult.EffectiveRelease); + Assert.Equal(0, writeResult.ReplacedStreamCount); + Assert.Equal(0, writeResult.RewrittenRecordCount); + + var doc = RvtDocument.Open(copy1); + Assert.Equal(Path.GetFullPath(copy1), doc.SourcePath); + Assert.NotNull(doc.TransmissionData); + var extRef = Assert.Single(doc.TransmissionData!.ExternalReferences); + Assert.Equal(@"links\Structural_2026.rvt", extRef.Path); + Assert.Equal(501, extRef.ElementId); + + // SaveAs directly from RvtDocument + var docSaveResult = doc.SaveAs(copy2); + Assert.Equal(writeResult.StreamCount, docSaveResult.StreamCount); + + // Census matches identically with 0 checksum mismatches, including unknown class #65000 + var censusSource = CoverageCensus.Read(source); + var censusCopy = CoverageCensus.Read(copy2); + Assert.Equal(0, censusCopy.Totals.ChecksumMismatches); + Assert.Equal(censusSource.Totals.Items, censusCopy.Totals.Items); + Assert.Equal(censusSource.Totals.Exact, censusCopy.Totals.Exact); + Assert.Contains(censusCopy.Classes, c => c.Class == "(class 65000)"); + + // Opaque custom stream preserved byte-for-byte + using (var root = RootStorage.OpenRead(copy2)) + using (var custom = root.OpenStream("CustomOpaqueVendorStream")) + { + byte[] buf = new byte[custom.Length]; + custom.ReadExactly(buf); + Assert.Equal(new byte[] { 0xCA, 0xFE, 0xBA, 0xBE, 0x42 }, buf); + } + + Assert.Equal(sourceSha, FileSha256(source)); + + // Document FromInfo without backing path refuses SaveAs cleanly + var inMemoryDoc = RvtDocument.FromInfo(doc.RawData); + Assert.Null(inMemoryDoc.SourcePath); + Assert.Throws(() => inMemoryDoc.SaveAs(P("should_not_exist.rvt"))); + } + + [Fact] + public void Rewrite_AppliesTargetedPagedAndPartitionEdits_WhilePreservingUnchangedStreamsAndUnknownPayloads() + { + string source = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "editable_2025.rfa", + Kind = RvtFileKind.Family, + SchemaRelease = 2025, + SavedRelease = 2025, + }); + string sourceSha = FileSha256(source); + + SchemaDocument schemaDoc; + using (var srcRoot = RootStorage.OpenRead(source)) + schemaDoc = TfSchemaReader.Read(srcRoot)!; + ushort syntheticItemTag = (ushort)schemaDoc.Classes.Single(c => c.Name == "SyntheticItem").Id; + + // Lower SyntheticItem version from 2 -> 1 in Formats/Latest via SchemaStreamEditor + byte[] rawSchemaPayload; + using (var srcRoot = RootStorage.OpenRead(source)) + rawSchemaPayload = PagedGzipStream.Read(srcRoot, "Formats", "Latest")!; + byte[] editedSchemaPayload = SchemaStreamEditor.Apply(rawSchemaPayload, [new SchemaEdit("SyntheticItem", Version: 1)]); + + // Replace SyntheticItem (owner 101) m_code from 100 -> 777777 in Partitions/1 + byte[] newCodePayload = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(newCodePayload, 777_777); + byte[] newRecordBody = PartitionStreamEditorTests.TaggedBody(syntheticItemTag, newCodePayload); + + string output = P("edited_2025.rfa"); + var result = RvtWriter.Rewrite(source, output, new RvtWriteRequest + { + PagedStreamEdits = + [ + new RvtPagedStreamEdit("Formats/Latest", editedSchemaPayload), + new RvtPagedStreamEdit("Global/Latest", new byte[] { 88, 99, 111 }), + ], + PartitionRecordEdits = + [ + new RvtPartitionRecordEdit(PartitionNumber: 1, Channel: 102, OwnerId: 101UL, Body: newRecordBody), + ], + }); + + Assert.Equal(RvtFileKind.Family, result.FileKind); + Assert.Equal(2025, result.EffectiveRelease); + Assert.Equal(3, result.ReplacedStreamCount); + Assert.Equal(1, result.RewrittenRecordCount); + Assert.Equal(sourceSha, FileSha256(source)); + + // Read back and verify all invariants + var census = CoverageCensus.Read(output); + Assert.Null(census.RecordsFailure); + Assert.Equal(0, census.Totals.ChecksumMismatches); + // SyntheticItem records in both project block and family block walk to exact end; unknown #65000 stays preserved! + var syntheticRow = census.Classes.Single(c => !c.Family && c.Channel == 102 && c.Class == "SyntheticItem"); + Assert.Equal(1, syntheticRow.Sums.Exact); + Assert.Contains(census.Classes, c => !c.Family && c.Channel == 102 && c.Class == "(class 65000)"); + + using var outRoot = RootStorage.OpenRead(output); + Assert.Equal(1, TfSchemaReader.Read(outRoot)!.Classes.Single(c => c.Name == "SyntheticItem").Version); + Assert.Equal(new byte[] { 88, 99, 111 }, PagedGzipStream.Read(outRoot, "Global", "Latest")!); + + // Global/Latest kept its (5,0) 8-byte prefix! + using var glStream = outRoot.OpenStorage("Global").OpenStream("Latest"); + byte[] glStored = new byte[glStream.Length]; + glStream.ReadExactly(glStored); + Assert.Equal(5u, BinaryPrimitives.ReadUInt32LittleEndian(glStored.AsSpan(0, 4))); + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(glStored.AsSpan(4, 4))); + } + + [Fact] + public void Rewrite_RefusesInvalidTargetsAndLeavesSourceAndDestinationUntouched() + { + string source = CreateSyntheticRevitFile(new SyntheticFileOptions { FileName = "intact.rvt" }); + string sourceSha = FileSha256(source); + + // Existing destination is never overwritten + string existing = P("existing.rvt"); + File.WriteAllBytes(existing, [1, 2, 3]); + Assert.Throws(() => RvtWriter.SaveAs(source, existing)); + Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(existing)); + + // Source as destination is refused + Assert.Throws(() => RvtWriter.SaveAs(source, source)); + Assert.Equal(sourceSha, FileSha256(source)); + + // Non-paged stream in PagedStreamEdits + var exPlain = Assert.Throws(() => RvtWriter.Rewrite(source, P("bad1.rvt"), new RvtWriteRequest + { + PagedStreamEdits = [new RvtPagedStreamEdit("BasicFileInfo", new byte[] { 1, 2 })], + })); + Assert.Equal("invalid-stream-target", exPlain.Kind); + + // Corrupt schema payload for Formats/Latest + var exBadSchema = Assert.Throws(() => RvtWriter.Rewrite(source, P("bad2.rvt"), new RvtWriteRequest + { + PagedStreamEdits = [new RvtPagedStreamEdit("Formats/Latest", new byte[] { 0xFF, 0xFF, 0xFF })], + })); + Assert.Equal("invalid-schema-payload", exBadSchema.Kind); + + // Missing partition number + var exMissingPart = Assert.Throws(() => RvtWriter.Rewrite(source, P("bad3.rvt"), new RvtWriteRequest + { + PartitionRecordEdits = [new RvtPartitionRecordEdit(99, 102, 101UL, new byte[] { 1, 2, 3 })], + })); + Assert.Equal("missing-partition", exMissingPart.Kind); + + // Missing record owner + var exMissingRec = Assert.Throws(() => RvtWriter.Rewrite(source, P("bad4.rvt"), new RvtWriteRequest + { + PartitionRecordEdits = [new RvtPartitionRecordEdit(1, 102, 99999UL, new byte[] { 1, 2, 3 })], + })); + Assert.Equal("missing-record", exMissingRec.Kind); + + // Duplicate record edit + var exDupRec = Assert.Throws(() => RvtWriter.Rewrite(source, P("bad5.rvt"), new RvtWriteRequest + { + PartitionRecordEdits = + [ + new RvtPartitionRecordEdit(1, 102, 101UL, new byte[] { 1, 2, 3 }), + new RvtPartitionRecordEdit(1, 102, 101UL, new byte[] { 1, 2, 4 }), + ], + })); + Assert.Equal("duplicate-edit", exDupRec.Kind); + + // Invalid record body (< 2 bytes) + var exShort = Assert.Throws(() => RvtWriter.Rewrite(source, P("bad6.rvt"), new RvtWriteRequest + { + PartitionRecordEdits = [new RvtPartitionRecordEdit(1, 102, 101UL, new byte[] { 1 })], + })); + Assert.Equal("invalid-record-target", exShort.Kind); + + // Verify source file untouched and zero temp/bad files left behind + Assert.Equal(sourceSha, FileSha256(source)); + Assert.Equal( + new[] { existing, source }.Order(StringComparer.Ordinal), + Directory.EnumerateFileSystemEntries(_folder).Order(StringComparer.Ordinal)); + } +} diff --git a/tests/RvtIO.Tests/SaveAsCommandTests.cs b/tests/RvtIO.Tests/SaveAsCommandTests.cs new file mode 100644 index 0000000..bd22709 --- /dev/null +++ b/tests/RvtIO.Tests/SaveAsCommandTests.cs @@ -0,0 +1,224 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text.Json; +using RvtIO.Cli; +using Xunit; + +namespace RvtIO.Tests; + +/// +/// Tests rvtio save-as ( and Program.Main routing) across supported and refused +/// synthetic Revit files, JSON report generation, and usage/input error handling without requiring private fixtures. +/// +public sealed class SaveAsCommandTests : IDisposable +{ + private readonly string _folder = Path.Combine(Path.GetTempPath(), "rvtio-saveas-cli-" + Guid.NewGuid().ToString("N")); + + public SaveAsCommandTests() => Directory.CreateDirectory(_folder); + + public void Dispose() => Directory.Delete(_folder, recursive: true); + + private string P(string name) => Path.Combine(_folder, name); + + private static string FileSha256(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); + + private static (int Exit, string Out, string Error) Run(params string[] args) + { + var output = new StringWriter(); + var error = new StringWriter(); + int exit = SaveAsCommand.Run(args, output, error); + return (exit, output.ToString(), error.ToString()); + } + + private static (int Exit, string Out, string Error) RunCliProcess(params string[] arguments) + { + var start = new ProcessStartInfo("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "RvtIO.Cli.dll")); + foreach (string argument in arguments) + start.ArgumentList.Add(argument); + + using var process = Process.Start(start)!; + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + Assert.True(process.WaitForExit(120_000), "The CLI process did not finish within two minutes."); + Task.WaitAll(output, error); + return (process.ExitCode, output.Result, error.Result); + } + + [Fact] + public void Check_OnSupportedProjectAndFamily_ReturnsZero_WritesCheckReport_AndLeavesSourceIntact() + { + string source = RvtWriterTests.CreateSyntheticRevitFile( + P("project_2025.rvt"), + new RvtWriterTests.SyntheticFileOptions + { + FileName = "project_2025.rvt", + Kind = RvtFileKind.Project, + SchemaRelease = 2025, + SavedRelease = 2025, + }); + string sourceHash = FileSha256(source); + string report = P("check_report.json"); + + var (exit, output, error) = Run(source, "--check", "--report", report); + + Assert.Equal(RvtExitCodes.Success, exit); + Assert.Contains("Report written to", error); + Assert.Contains("Source: project_2025.rvt (project, release 2025, 8-byte ids", output); + Assert.Contains("save-as: supported", output); + Assert.Contains("paged-stream-rewrite: supported", output); + Assert.Contains("partition-record-rewrite: supported", output); + Assert.Equal(sourceHash, FileSha256(source)); + + using var json = JsonDocument.Parse(File.ReadAllText(report)); + Assert.Equal(SaveAsCommand.ReportSchema, json.RootElement.GetProperty("schema").GetString()); + Assert.Equal("ok", json.RootElement.GetProperty("status").GetString()); + Assert.Equal("check", json.RootElement.GetProperty("mode").GetString()); + var srcNode = json.RootElement.GetProperty("source"); + Assert.Equal("project", srcNode.GetProperty("fileKind").GetString()); + Assert.Equal(2025, srcNode.GetProperty("effectiveRelease").GetInt32()); + Assert.Equal(8, srcNode.GetProperty("elementIdWidth").GetInt32()); + Assert.Equal(3, srcNode.GetProperty("operations").GetArrayLength()); + } + + [Fact] + public void Check_AndWrite_OnRefusedFile_ReturnExitFive_WriteNoOutput_AndRecordRefusalReason() + { + string workshared = RvtWriterTests.CreateSyntheticRevitFile( + P("workshared_2026.rvt"), + new RvtWriterTests.SyntheticFileOptions + { + FileName = "workshared_2026.rvt", + SchemaRelease = 2026, + SavedRelease = 2026, + Worksharing = "Central", + }); + string sourceHash = FileSha256(workshared); + string checkReport = P("refused_check.json"); + + var (checkExit, checkOut, checkErr) = Run(workshared, "--check", "--report", checkReport); + Assert.Equal(SaveAsCommand.Refused, checkExit); + Assert.Contains("Report written to", checkErr); + Assert.Contains("save-as: refused (workshared-model:", checkOut); + + using (var json = JsonDocument.Parse(File.ReadAllText(checkReport))) + { + Assert.Equal("refused", json.RootElement.GetProperty("status").GetString()); + Assert.Equal("check", json.RootElement.GetProperty("mode").GetString()); + } + + string blockedOut = P("should_not_be_created.rvt"); + string writeReport = P("refused_write.json"); + var (writeExit, writeOut, writeErr) = Run(workshared, blockedOut, "--report", writeReport); + + Assert.Equal(SaveAsCommand.Refused, writeExit); + Assert.Equal("", writeOut); + Assert.Contains("Nothing was written (exit code 5)", writeErr); + Assert.False(File.Exists(blockedOut)); + Assert.Equal(sourceHash, FileSha256(workshared)); + + using (var json = JsonDocument.Parse(File.ReadAllText(writeReport))) + { + Assert.Equal(SaveAsCommand.ReportSchema, json.RootElement.GetProperty("schema").GetString()); + Assert.Equal("refused", json.RootElement.GetProperty("status").GetString()); + Assert.Equal("workshared-model", json.RootElement.GetProperty("kind").GetString()); + } + } + + [Fact] + public void Write_ViaCommandAndCliEntryPoint_CreatesVerifiedLosslessCopy_AndWritesReport() + { + string source = RvtWriterTests.CreateSyntheticRevitFile( + P("family_2024.rfa"), + new RvtWriterTests.SyntheticFileOptions + { + FileName = "family_2024.rfa", + Kind = RvtFileKind.Family, + SchemaRelease = 2024, + SavedRelease = 2024, + }); + string sourceHash = FileSha256(source); + string copy1 = P("copy1.rfa"); + string report1 = P("write_report.json"); + + var (exit, output, error) = Run(source, copy1, "--report", report1); + + Assert.Equal(RvtExitCodes.Success, exit); + Assert.Contains("Report written to", error); + Assert.Contains("Saved: copy1.rfa (family, release 2024,", output); + Assert.True(File.Exists(copy1)); + Assert.Equal(sourceHash, FileSha256(source)); + + using (var json = JsonDocument.Parse(File.ReadAllText(report1))) + { + Assert.Equal("ok", json.RootElement.GetProperty("status").GetString()); + Assert.Equal("write", json.RootElement.GetProperty("mode").GetString()); + var outNode = json.RootElement.GetProperty("output"); + Assert.Equal("copy1.rfa", outNode.GetProperty("file").GetString()); + Assert.Equal(0, outNode.GetProperty("replacedStreams").GetInt32()); + Assert.Equal(0, outNode.GetProperty("rewrittenRecords").GetInt32()); + } + + // Also exercise the compiled CLI process entry point (`rvtio save-as ...` and `rvtio --help`) + var (helpExit, helpOut, _) = RunCliProcess("--help"); + Assert.Equal(RvtExitCodes.Success, helpExit); + Assert.Contains("save-as ", helpOut); + + string copy2 = P("copy2.rfa"); + var (procExit, procOut, procErr) = RunCliProcess("save-as", copy1, copy2); + Assert.Equal(RvtExitCodes.Success, procExit); + Assert.Equal("", procErr); + Assert.Contains("Saved: copy2.rfa", procOut); + + var reopened = RvtDocument.Open(copy2); + Assert.NotNull(reopened.TransmissionData); + } + + [Fact] + public void InputAndIoFailures_ReturnExitTwo_AndPreserveExistingFiles() + { + // Missing input file -> exit 2 + var (missingExit, _, missingErr) = Run(P("does_not_exist.rvt"), P("out.rvt")); + Assert.Equal(RvtExitCodes.InputError, missingExit); + Assert.Contains("exit code 2", missingErr); + + // Non-CFB file -> exit 2 + string garbage = P("not_cfb.rvt"); + File.WriteAllText(garbage, "plain text file"); + var (badExit, _, _) = Run(garbage, P("out2.rvt")); + Assert.Equal(RvtExitCodes.InputError, badExit); + Assert.False(File.Exists(P("out2.rvt"))); + + // Existing destination file -> exit 2 and destination untouched + string source = RvtWriterTests.CreateSyntheticRevitFile(P("valid.rvt")); + string existing = P("existing_target.rvt"); + File.WriteAllBytes(existing, [9, 8, 7, 6]); + var (overExit, _, _) = Run(source, existing); + Assert.Equal(RvtExitCodes.InputError, overExit); + Assert.Equal(new byte[] { 9, 8, 7, 6 }, File.ReadAllBytes(existing)); + } + + [Fact] + public void UsageErrors_ReturnExitOne_BeforeReadingOrWriting() + { + string source = P("nonexistent_for_usage.rvt"); + string dest = P("dest.rvt"); + + Assert.Equal(RvtExitCodes.Usage, Run().Exit); + Assert.Equal(RvtExitCodes.Usage, Run(source).Exit); + Assert.Equal(RvtExitCodes.Usage, Run(source, dest, "--check").Exit); + Assert.Equal(RvtExitCodes.Usage, Run(source, dest, P("extra.rvt")).Exit); + Assert.Equal(RvtExitCodes.Usage, Run(source, dest, "--unknown-flag").Exit); + Assert.Equal(RvtExitCodes.Usage, Run(source, dest, "--report").Exit); + Assert.Equal(RvtExitCodes.Usage, Run(source, dest, "--report", "--check").Exit); + Assert.Equal(RvtExitCodes.Usage, Run(source, dest, "--report", P("r1.json"), "--report", P("r2.json")).Exit); + Assert.Equal(RvtExitCodes.Usage, Run(source, dest, "--report", source).Exit); + Assert.Equal(RvtExitCodes.Usage, Run(source, dest, "--report", dest).Exit); + Assert.Equal(RvtExitCodes.Usage, Run(source, dest, "--report", _folder).Exit); + } +} From 2e4fe00a781dcabd27e020a6487efbb284313fdf Mon Sep 17 00:00:00 2001 From: AndreyStartsev <83749987+AndreyStartsev@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:34:34 +0200 Subject: [PATCH 2/4] Tighten RvtWriter metadata, storage preservation, and schema checks - Refuse writes with missing-metadata when BasicFileInfo is missing, unreadable, or lacks recognised Worksharing, IsSingleUserCloudModel, or saving-release fields; expose IsWorkshared and IsCloudModel as nullable bool? and classify Worksharing: Enabled as workshared. - Preserve and verify root and child storage directory-entry metadata (CLSID, StateBits, CreationFileTime, ModifiedFileTime, HeaderCLSID) in ContainerRewriter across SaveAs and Rewrite. - Validate replacement Formats/Latest payloads against SavedRelease, supported release bounds, ElementIdWidth, and existing partition framing marker class IDs, and re-inspect edited temporary containers before publishing. - Add regression tests covering missing/incomplete BasicFileInfo, opaque storage metadata preservation and tampering detection, and valid-but-incompatible replacement schemas. --- docs/writing.md | 25 +- src/RvtIO/Writing/ContainerRewriter.cs | 104 ++++++-- src/RvtIO/Writing/RvtWriter.cs | 162 +++++++++++-- tests/RvtIO.Tests/ContainerRewriterTests.cs | 129 +++++++++- tests/RvtIO.Tests/RvtWriterTests.cs | 250 ++++++++++++++++++-- tests/RvtIO.Tests/SaveAsCommandTests.cs | 22 ++ 6 files changed, 616 insertions(+), 76 deletions(-) diff --git a/docs/writing.md b/docs/writing.md index edc0e2d..f16e75e 100644 --- a/docs/writing.md +++ b/docs/writing.md @@ -27,10 +27,10 @@ Every write call inspects the source file first and checks the requested operati | Dimension | Supported | Refused | | --- | --- | --- | | File kind | Local project (`.rvt`, `.rte`) and family (`.rfa`, `.rft`) containers (`RvtFileKind.Project`, `RvtFileKind.Family`) | Containers whose kind cannot be identified (`unknown-file-kind`) | -| Sharing and hosting | Non-workshared local files (`Worksharing: Not enabled`, `IsSingleUserCloudModel: False`) | Workshared central or local copies (`workshared-model`) and cloud models (`cloud-model`) | +| Sharing and hosting | Non-workshared local files (`Worksharing: Not enabled`, `IsSingleUserCloudModel: False`) | Workshared central or local copies (`workshared-model`), cloud models (`cloud-model`), or missing/unrecognised `BasicFileInfo` sharing/hosting/release fields (`missing-metadata`) | | Release and ID width | Releases `2020` to `2023` (4-byte element IDs) and `2024` to `2027` (8-byte element IDs) when `BasicFileInfo` and `Formats/Latest` agree | Releases outside `2020` to `2027` (`unsupported-release`), unrecognised schemas (`unrecognised-release`), or saved/schema mismatches (`release-mismatch`) | -| `SaveAs` | All streams, storages, external references, preview images, and unknown payloads copied byte-for-byte with sector size (`512` or `4,096` bytes) preserved | Truncated containers (`RvtInputException`) or corrupt page parity (`corrupt-page-parity`) | -| `PagedStreamRewrite` | Replacing the uncompressed payload of `Formats/Latest` or `Global/` while preserving stream prefixes, gzip framing, and page parity | Unreadable `Global/*` streams (`unreadable-paged-stream`) or invalid replacement `Formats/Latest` schemas (`invalid-schema-payload`) | +| `SaveAs` | All streams, storages, storage directory-entry metadata (`CLSID`, `StateBits`, timestamps), external references, preview images, and unknown payloads copied with sector size (`512` or `4,096` bytes) preserved | Truncated containers (`RvtInputException`) or corrupt page parity (`corrupt-page-parity`) | +| `PagedStreamRewrite` | Replacing the uncompressed payload of `Formats/Latest` or `Global/` while preserving stream prefixes, gzip framing, page parity, and container compatibility | Unreadable `Global/*` streams (`unreadable-paged-stream`) or malformed/incompatible replacement `Formats/Latest` schemas (`invalid-schema-payload`, `release-mismatch`, `incompatible-schema-payload`) | | `PartitionRecordRewrite` | Replacing targeted record bodies inside `Partitions/` (`101`, `102`, `103`) while preserving untouched blocks, loaded families, channels, and unknown class payloads | Missing framing marker classes (`missing-framing-markers`), missing partition streams (`missing-partitions`), framing errors (`partition-framing-failed`), or mismatched input Adler-32 checksums (`partition-checksum-failed`) | ## Framing, checksums, and preservation invariants @@ -41,9 +41,10 @@ byte length of a record inside `Partitions/` therefore requires no cross-stre When rewriting data, `RvtWriter` enforces the following invariants: -1. **Untouched streams stay byte-for-byte identical.** `SaveAs` decodes nothing; every stream (`BasicFileInfo`, `TransmissionData`, - `ProjectInformation`, `PartAtom`, `RevitPreview4.0`, `Formats/*`, `Global/*`, `Partitions/*`, and any unrecognised streams) - is copied verbatim. +1. **Untouched streams and storage metadata stay identical.** `SaveAs` decodes nothing; every stream (`BasicFileInfo`, + `TransmissionData`, `ProjectInformation`, `PartAtom`, `RevitPreview4.0`, `Formats/*`, `Global/*`, `Partitions/*`, and any + unrecognised streams) is copied verbatim, and root/child storage directory-entry metadata (`HeaderCLSID`, `CLSID`, `StateBits`, + and creation/modified FILETIMEs) is preserved and verified on read-back. 2. **Paged stream prefixes and parity are preserved.** `Formats/Latest` starts directly with its gzip member; `Global/*` streams keep their exact 8-byte header prefix before the gzip member. Every rewritten paged stream is sealed with cyclic Hamming parity pages (`64,896` payload bytes + `353` parity bytes per full page) and read back through both the parity verifier and @@ -63,10 +64,10 @@ When rewriting data, `RvtWriter` enforces the following invariants: 4. **Post-encode verification.** Every rewritten partition stream is verified in memory before container assembly: page parity must pass, full `PartitionFraming` must walk every block and segment without error, Adler-32 checksums must match on every record, every unedited record must match the input byte-for-byte, and every edited record must match its requested body. -5. **Atomic persistence and source immutability.** Output is written to a sibling `.tmp` file, read back from disk to verify - every directory entry's declared length and SHA-256 hash, and moved to the destination with `overwrite: false`. Existing files - (including the source file itself) are never overwritten, and any failure deletes the temporary file while leaving the source - untouched. +5. **Atomic persistence and source immutability.** Output is written to a sibling `.tmp` file, re-inspected against the capability + matrix when edits were applied, read back from disk to verify every storage's directory-entry metadata and every stream's + declared length and SHA-256 hash, and moved to the destination with `overwrite: false`. Existing files (including the source + file itself) are never overwritten, and any failure deletes the temporary file while leaving the source untouched. ## Refusal codes @@ -77,10 +78,11 @@ When rewriting data, `RvtWriter` enforces the following invariants: | `unknown-file-kind` | Container has neither `PartAtom` nor `ProjectInformation` and its path extension is not `.rvt`/`.rte`/`.rfa`/`.rft`. | | `cloud-model` | `BasicFileInfo` marks a single-user or workshared cloud model (`IsSingleUserCloudModel: True` or cloud central path). | | `workshared-model` | `BasicFileInfo` reports worksharing enabled. | +| `missing-metadata` | `BasicFileInfo` is missing, unreadable, or lacks a recognised `Worksharing`, `IsSingleUserCloudModel`, or saving-release field. | | `corrupt-page-parity` | A `Formats/*`, `Global/*`, or `Partitions/*` stream fails Hamming page parity verification. | | `missing-schema` | `Formats/Latest` is missing, unreadable, or cannot be parsed. | | `unrecognised-release` | `Formats/Latest` does not match a known Revit schema release. | -| `release-mismatch` | `BasicFileInfo` saving release differs from the `Formats/Latest` schema release. | +| `release-mismatch` | `BasicFileInfo` saving release differs from the `Formats/Latest` schema release (on source inspection or after replacing `Formats/Latest`). | | `unsupported-release` | Effective release is outside `2020` to `2027`. | | `unreadable-paged-stream` | A `Global/*` stream fails decompression or prefix verification. | | `missing-framing-markers` | `Formats/Latest` lacks one or more partition framing marker classes. | @@ -89,6 +91,7 @@ When rewriting data, `RvtWriter` enforces the following invariants: | `partition-checksum-failed` | An input record on channel `102` or `103` has a mismatched Adler-32 checksum. | | `invalid-stream-target` | A `RvtPagedStreamEdit` names a missing or non-paged stream. | | `invalid-schema-payload` | A replacement `Formats/Latest` payload does not parse as a valid class schema. | +| `incompatible-schema-payload` | A replacement `Formats/Latest` payload changes element ID width or framing marker class identifiers required by existing partition streams. | | `duplicate-edit` | The same stream or `(PartitionNumber, Block, Channel, OwnerId)` record is targeted twice in one request. | | `missing-partition` | A `RvtPartitionRecordEdit` names a `Partitions/` stream that does not exist in the source container. | | `missing-record` | A `RvtPartitionRecordEdit` names a `(Block, Channel, OwnerId)` record that does not exist in the target partition. | diff --git a/src/RvtIO/Writing/ContainerRewriter.cs b/src/RvtIO/Writing/ContainerRewriter.cs index 271ad63..0f6cb4c 100644 --- a/src/RvtIO/Writing/ContainerRewriter.cs +++ b/src/RvtIO/Writing/ContainerRewriter.cs @@ -5,24 +5,42 @@ namespace RvtIO.Writing; -/// One storage or stream of a container: its kind, and for a stream its length and SHA-256 (upper-case hexadecimal). -internal readonly record struct ContainerEntry(bool IsStream, long Length, string Sha256); +/// Directory-entry metadata of a storage in a compound file (and header CLSID for the root storage). +internal readonly record struct StorageDirectoryMetadata( + Guid Clsid, + uint StateBits, + ulong CreationFileTime, + ulong ModifiedFileTime, + Guid HeaderClsid = default); + +/// One storage or stream of a container: its kind, for a stream its length and SHA-256 (upper-case hexadecimal), and for a +/// storage its directory-entry metadata. +internal readonly record struct ContainerEntry( + bool IsStream, + long Length, + string Sha256, + StorageDirectoryMetadata Storage = default); /// What wrote: every storage and stream by path ('/' between the names, no leading -/// '/'), with the length and SHA-256 of the bytes that were written, and how many streams came from the replacement map. -internal sealed record ContainerRewriteResult(IReadOnlyDictionary Entries, int Replaced); +/// '/'), with the length and SHA-256 of the bytes that were written, how many streams came from the replacement map, and the root +/// storage directory-entry metadata. +internal sealed record ContainerRewriteResult( + IReadOnlyDictionary Entries, + int Replaced, + StorageDirectoryMetadata RootStorage = default); /// /// Copies a compound file (the container of a Revit file) into a new compound file, stream by stream, with some streams replaced by /// given bytes. The stored bytes of a stream are copied as they are; nothing is decoded. The directory tree is kept (storages, stream -/// names), and so is the sector size (512 or 4,096 bytes) of the source. Time stamps, class ids and state bits of the entries are not -/// copied. Streams are copied through a buffer of 1 MB, so the memory use does not depend on the size of the streams; only the -/// replacement bytes and the directory and allocation tables of the two files are held. +/// names), and so are the sector size (512 or 4,096 bytes) of the source and the directory-entry metadata of the root and child storages +/// (CLSID, state bits, and creation/modified FILETIMEs; plus the compound file header CLSID). Streams are copied through a buffer of +/// 1 MB, so the memory use does not depend on the size of the streams; only the replacement bytes and the directory and allocation +/// tables of the two files are held. /// /// The output is written to a temporary file in the folder of the output and moved to the output path only after the written file was -/// read back: the same storages and streams must be there, and every stream must have the length and SHA-256 of the bytes that were -/// meant to be written (the bytes read from the source, or the replacement). The temporary file is removed when anything fails, and an -/// existing output is never replaced. +/// read back: the same storages and streams must be there with the expected storage directory-entry metadata, and every stream must have +/// the length and SHA-256 of the bytes that were meant to be written (the bytes read from the source, or the replacement). The temporary +/// file is removed when anything fails, and an existing output is never replaced. /// /// On Windows the source is opened for reading with writers denied for as long as it is read, so the copy is one state of the source /// and not a mix of two. When another process already holds the source open for writing (an application that has the file open), the @@ -62,7 +80,7 @@ internal static ContainerRewriteResult Rewrite(string sourcePath, string outputP { var result = WriteTemporary(sourcePath, temporary, replacementByName); beforeCheck?.Invoke(temporary); - CheckWritten(temporary, result.Entries); + CheckWritten(temporary, result.Entries, result.RootStorage); File.Move(temporary, output, overwrite: false); return result; } @@ -86,17 +104,21 @@ private static ContainerRewriteResult WriteTemporary(string sourcePath, string t var entries = new Dictionary(StringComparer.Ordinal); var used = new HashSet(StringComparer.OrdinalIgnoreCase); + StorageDirectoryMetadata rootMetadata; using (var temporaryFile = new FileStream(temporary, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) { using (var destination = RootStorage.Create(temporaryFile, version, StorageModeFlags.LeaveOpen)) + { Copy(source, destination, "", replacements, used, entries, new byte[BufferLength], 0); + rootMetadata = CopyRootMetadata(source, destination); + } temporaryFile.Flush(flushToDisk: true); } foreach (string name in replacements.Keys) if (!used.Contains(name)) throw new ArgumentException($"The replacement map names '{name}', which is not a stream of the source.", nameof(replacements)); - return new ContainerRewriteResult(entries, used.Count); + return new ContainerRewriteResult(entries, used.Count, rootMetadata); } private static OpenMcdf.Version SectorVersion(Stream file) @@ -122,8 +144,12 @@ private static void Copy(Storage from, Storage to, string prefix, Dictionary + new(storage.CLSID, storage.StateBits, storage.CreationFileTime, storage.ModifiedFileTime); + + private static void ApplyStorageMetadata(Storage storage, StorageDirectoryMetadata metadata) + { + storage.CLSID = metadata.Clsid; + storage.StateBits = metadata.StateBits; + storage.CreationFileTime = metadata.CreationFileTime; + storage.ModifiedFileTime = metadata.ModifiedFileTime; + } + + private static StorageDirectoryMetadata CopyRootMetadata(RootStorage from, RootStorage to) + { + to.HeaderCLSID = from.HeaderCLSID; + to.CLSID = from.CLSID; + to.StateBits = from.StateBits; + to.ModifiedFileTime = from.ModifiedFileTime; + return new StorageDirectoryMetadata(from.CLSID, from.StateBits, to.CreationFileTime, from.ModifiedFileTime, from.HeaderCLSID); + } + private static void Add(Dictionary entries, string path, ContainerEntry entry) { if (!entries.TryAdd(path, entry)) throw new InvalidDataException($"The directory holds '{path}' twice."); } /// Reads the compound file at to its end and requires exactly the storages and streams of - /// , each stream with its length and SHA-256. + /// , each storage with its directory-entry metadata and each stream with its length and SHA-256. /// The first difference, named by its path. - internal static void CheckWritten(string path, IReadOnlyDictionary expected) + internal static void CheckWritten(string path, IReadOnlyDictionary expected, + StorageDirectoryMetadata? expectedRoot = null) { using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); using var root = RootStorage.Open(file, StorageModeFlags.LeaveOpen); + if (expectedRoot is { } rootWant) + VerifyRootMetadata(root, rootWant); var found = new HashSet(StringComparer.Ordinal); Verify(root, "", expected, found, new byte[BufferLength], 0); foreach (string name in expected.Keys) @@ -175,6 +224,25 @@ internal static void CheckWritten(string path, IReadOnlyDictionary", want); + } + + private static void VerifyStorageMetadata(Storage storage, string path, StorageDirectoryMetadata want) + { + if (storage.CLSID != want.Clsid) + throw new InvalidDataException($"Storage '{path}' was written with CLSID {want.Clsid} and has {storage.CLSID}."); + if (storage.StateBits != want.StateBits) + throw new InvalidDataException($"Storage '{path}' was written with state bits 0x{want.StateBits:X8} and has 0x{storage.StateBits:X8}."); + if (storage.CreationFileTime != want.CreationFileTime) + throw new InvalidDataException($"Storage '{path}' was written with creation FILETIME {want.CreationFileTime.ToString(CultureInfo.InvariantCulture)} and has {storage.CreationFileTime.ToString(CultureInfo.InvariantCulture)}."); + if (storage.ModifiedFileTime != want.ModifiedFileTime) + throw new InvalidDataException($"Storage '{path}' was written with modified FILETIME {want.ModifiedFileTime.ToString(CultureInfo.InvariantCulture)} and has {storage.ModifiedFileTime.ToString(CultureInfo.InvariantCulture)}."); + } + private static void Verify(Storage storage, string prefix, IReadOnlyDictionary expected, HashSet found, byte[] buffer, int depth) { if (depth > MaxDepth) throw new InvalidDataException($"The written container nests storages deeper than {MaxDepth} levels at '{prefix}'."); @@ -186,7 +254,9 @@ private static void Verify(Storage storage, string prefix, IReadOnlyDictionaryByte width of stored element identifiers (4 up to Revit 2023, 8 from Revit 2024), or null when unknown. public required int? ElementIdWidth { get; init; } - /// True when BasicFileInfo reports worksharing enabled. - public required bool IsWorkshared { get; init; } + /// True when BasicFileInfo reports worksharing enabled, false when explicitly disabled, or null when unavailable or unrecognised. + public required bool? IsWorkshared { get; init; } - /// True when BasicFileInfo marks a cloud model or cloud central path. - public required bool IsCloudModel { get; init; } + /// True when BasicFileInfo marks a cloud model or cloud central path, false when explicitly non-cloud, or null when unavailable or unrecognised. + public required bool? IsCloudModel { get; init; } /// Total number of streams in the compound file container. public required int StreamCount { get; init; } @@ -163,9 +163,7 @@ public static class RvtWriter public static RvtWriteCapabilities InspectCapabilities(string sourcePath) { ArgumentNullException.ThrowIfNull(sourcePath); - RvtContainerCheck.Verify(sourcePath); - using var root = RootStorage.OpenRead(sourcePath); - return InspectRoot(root, sourcePath).Capabilities; + return InspectContainer(sourcePath); } /// Writes a verified lossless copy of to . @@ -217,10 +215,16 @@ public static RvtWriteResult Rewrite(string sourcePath, string outputPath, RvtWr replacements = BuildReplacements(root, inspection, request); } + string outputKindHint = KindFromPath(fullOutput) is not RvtFileKind.Unknown ? fullOutput : sourcePath; + Action? verifyEditedOutput = replacements is null + ? null + : temporaryPath => VerifyOutputEligibility(temporaryPath, outputKindHint, capabilities, request); + var containerResult = ContainerRewriter.Rewrite( sourcePath, fullOutput, - replacements ?? NoStreamReplacements); + replacements ?? NoStreamReplacements, + verifyEditedOutput); return new RvtWriteResult { @@ -238,6 +242,27 @@ private sealed record Inspection( PartitionFraming.Tags? FramingTags, IReadOnlyDictionary CanonicalStreamPaths); + private static RvtWriteCapabilities InspectContainer(string containerPath, string? pathHint = null) + { + RvtContainerCheck.Verify(containerPath); + using var root = RootStorage.OpenRead(containerPath); + return InspectRoot(root, pathHint ?? containerPath).Capabilities; + } + + private static void VerifyOutputEligibility( + string temporaryPath, + string pathHint, + RvtWriteCapabilities sourceCapabilities, + RvtWriteRequest request) + { + var outputCapabilities = InspectContainer(temporaryPath, pathHint); + RequireSupported(outputCapabilities, RvtWriteOperation.SaveAs); + if (request.PagedStreamEdits.Count > 0 || sourceCapabilities.Supports(RvtWriteOperation.PagedStreamRewrite)) + RequireSupported(outputCapabilities, RvtWriteOperation.PagedStreamRewrite); + if (request.PartitionRecordEdits.Count > 0 || sourceCapabilities.Supports(RvtWriteOperation.PartitionRecordRewrite)) + RequireSupported(outputCapabilities, RvtWriteOperation.PartitionRecordRewrite); + } + private static string ValidateOutputPath(string outputPath) { if (string.IsNullOrWhiteSpace(outputPath)) @@ -267,13 +292,28 @@ private static void RequireSupported(RvtWriteCapabilities capabilities, RvtWrite private static Inspection InspectRoot(RootStorage root, string sourcePath) { - var bfi = BasicFileInfoReader.Read(root); + bool hasBasicFileInfo = false; + Dictionary bfi; + if (root.TryOpenStream("BasicFileInfo", out var bfiStream)) + { + hasBasicFileInfo = true; + using (bfiStream) + { + byte[] bfiBytes = new byte[checked((int)bfiStream.Length)]; + bfiStream.ReadExactly(bfiBytes); + bfi = BasicFileInfoReader.Parse(bfiBytes); + } + } + else + { + bfi = new Dictionary(); + } + int? savedRelease = SchemaRelease.Saved(bfi.GetValueOrDefault("RevitVersion")); - bool isWorkshared = bfi.TryGetValue("Worksharing", out string? ws) - && !string.IsNullOrWhiteSpace(ws) - && !string.Equals(ws.Trim(), "Not enabled", StringComparison.OrdinalIgnoreCase); - bool isCloudModel = string.Equals(bfi.GetValueOrDefault("IsSingleUserCloudModel"), "True", StringComparison.OrdinalIgnoreCase) - || IsCloudCentralPath(bfi.GetValueOrDefault("Central Model Path")); + bool? isWorkshared = ParseWorksharingState(bfi.GetValueOrDefault("Worksharing")); + bool? isCloudModel = ParseCloudModelState( + bfi.GetValueOrDefault("IsSingleUserCloudModel"), + bfi.GetValueOrDefault("Central Model Path")); var streamEntries = new List<(string Path, Storage Parent, string Name, long Length)>(); CollectStreams(root, "", streamEntries); @@ -323,6 +363,7 @@ private static Inspection InspectRoot(RootStorage root, string sourcePath) (string Code, string Reason)? baseFailure = EvaluateBaseEligibility( fileKind, + hasBasicFileInfo, isCloudModel, isWorkshared, parityFailure, @@ -373,10 +414,34 @@ private static Inspection InspectRoot(RootStorage root, string sourcePath) return new Inspection(capabilities, framingTags, canonicalPaths); } + private static bool? ParseWorksharingState(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + string trimmed = value.Trim(); + if (trimmed.Equals("Not enabled", StringComparison.OrdinalIgnoreCase)) + return false; + if (trimmed.Equals("Local", StringComparison.OrdinalIgnoreCase) + || trimmed.Equals("Central", StringComparison.OrdinalIgnoreCase) + || trimmed.Equals("Enabled", StringComparison.OrdinalIgnoreCase)) + return true; + return null; + } + + private static bool? ParseCloudModelState(string? singleUserCloudValue, string? centralPath) + { + if (IsCloudCentralPath(centralPath)) return true; + if (string.IsNullOrWhiteSpace(singleUserCloudValue)) return null; + string trimmed = singleUserCloudValue.Trim(); + if (trimmed.Equals("True", StringComparison.OrdinalIgnoreCase)) return true; + if (trimmed.Equals("False", StringComparison.OrdinalIgnoreCase)) return false; + return null; + } + private static (string Code, string Reason)? EvaluateBaseEligibility( RvtFileKind fileKind, - bool isCloudModel, - bool isWorkshared, + bool hasBasicFileInfo, + bool? isCloudModel, + bool? isWorkshared, (string Code, string Reason)? parityFailure, SchemaDocument? schemaDoc, int? savedRelease, @@ -384,18 +449,26 @@ private static (string Code, string Reason)? EvaluateBaseEligibility( { if (fileKind == RvtFileKind.Unknown) return ("unknown-file-kind", "The file kind (.rvt project or .rfa family) could not be determined from container streams or file extension."); - if (isCloudModel) + if (isCloudModel == true) return ("cloud-model", "Cloud-hosted Revit models are outside the supported local-file write scope."); - if (isWorkshared) + if (isWorkshared == true) return ("workshared-model", "Workshared Revit models are outside the supported non-workshared local-file write scope."); + if (!hasBasicFileInfo) + return ("missing-metadata", "BasicFileInfo is missing from the container, so sharing, hosting, and saving-release state cannot be established."); + if (isWorkshared is null) + return ("missing-metadata", "BasicFileInfo lacks a recognised Worksharing field, so non-workshared state cannot be established."); + if (isCloudModel is null) + return ("missing-metadata", "BasicFileInfo lacks a recognised IsSingleUserCloudModel field, so local non-cloud hosting cannot be established."); + if (savedRelease is null) + return ("missing-metadata", "BasicFileInfo lacks a recognised saving release, so compatibility with Formats/Latest cannot be established."); if (parityFailure is not null) return parityFailure; if (schemaDoc is null) return ("missing-schema", "The embedded class schema (Formats/Latest) is missing or unreadable."); if (schemaRelease is null) return ("unrecognised-release", "The Revit release of Formats/Latest is not recognised."); - if (savedRelease is int saved && saved != schemaRelease.Value) - return ("release-mismatch", $"The file was saved by Revit {saved} while Formats/Latest carries the schema of Revit {schemaRelease.Value}."); + if (savedRelease.Value != schemaRelease.Value) + return ("release-mismatch", $"The file was saved by Revit {savedRelease.Value} while Formats/Latest carries the schema of Revit {schemaRelease.Value}."); if (schemaRelease.Value is < OldestSupportedRelease or > NewestSupportedRelease) return ("unsupported-release", $"Revit release {schemaRelease.Value} is outside the supported write range ({OldestSupportedRelease}-{NewestSupportedRelease})."); return null; @@ -476,11 +549,7 @@ private static Dictionary BuildReplacements( throw new RvtWriteRefusedException("duplicate-edit", $"Stream '{canonical}' is targeted more than once."); if (string.Equals(canonical, "Formats/Latest", StringComparison.OrdinalIgnoreCase)) - { - var parsed = TfSchemaReader.TryParse(pagedEdit.UncompressedPayload.ToArray()); - if (parsed is null) - throw new RvtWriteRefusedException("invalid-schema-payload", "Replacement payload for Formats/Latest does not parse as a valid class schema."); - } + ValidateReplacementSchema(pagedEdit.UncompressedPayload.ToArray(), inspection); byte[] originalStored = ReadStreamByPath(root, canonical); replacements[canonical] = PagedStreamCodec.Reencode(originalStored, pagedEdit.UncompressedPayload.Span); @@ -528,6 +597,49 @@ private static Dictionary BuildReplacements( return replacements; } + private static void ValidateReplacementSchema(byte[] schemaBytes, Inspection inspection) + { + var parsed = TfSchemaReader.TryParse(schemaBytes); + if (parsed is null) + throw new RvtWriteRefusedException( + "invalid-schema-payload", + "Replacement payload for Formats/Latest does not parse as a valid class schema."); + + var replacementFormats = new FormatsSchema(parsed); + int? replacementRelease = SchemaRelease.FromSchema(replacementFormats); + if (replacementRelease is null) + throw new RvtWriteRefusedException( + "unrecognised-release", + "Replacement Formats/Latest schema does not match a recognised Revit release."); + + if (inspection.Capabilities.SavedRelease is int saved && replacementRelease.Value != saved) + throw new RvtWriteRefusedException( + "release-mismatch", + $"Replacement Formats/Latest carries the schema of Revit {replacementRelease.Value} while the container was saved by Revit {saved}."); + + if (replacementRelease.Value is < OldestSupportedRelease or > NewestSupportedRelease) + throw new RvtWriteRefusedException( + "unsupported-release", + $"Replacement Formats/Latest release {replacementRelease.Value} is outside the supported write range ({OldestSupportedRelease}-{NewestSupportedRelease})."); + + int? replacementIdWidth = SchemaRelease.IdWidth(replacementRelease); + if (replacementIdWidth != inspection.Capabilities.ElementIdWidth) + throw new RvtWriteRefusedException( + "incompatible-schema-payload", + $"Replacement Formats/Latest changes element identifier byte width from {inspection.Capabilities.ElementIdWidth} to {replacementIdWidth}."); + + var replacementTags = PartitionFraming.TagsOf(replacementFormats); + if (replacementTags is null && inspection.Capabilities.PartitionStreamCount > 0) + throw new RvtWriteRefusedException( + "missing-framing-markers", + "Replacement Formats/Latest lacks the partition framing marker classes required by existing partition streams."); + + if (inspection.Capabilities.PartitionStreamCount > 0 && replacementTags != inspection.FramingTags) + throw new RvtWriteRefusedException( + "incompatible-schema-payload", + "Replacement Formats/Latest changes partition framing marker class identifiers required by existing partition streams."); + } + private static string ClassifyRecordEditArgumentError(string message) { if (message.Contains("edited twice", StringComparison.Ordinal)) return "duplicate-edit"; diff --git a/tests/RvtIO.Tests/ContainerRewriterTests.cs b/tests/RvtIO.Tests/ContainerRewriterTests.cs index 9058ad0..775b095 100644 --- a/tests/RvtIO.Tests/ContainerRewriterTests.cs +++ b/tests/RvtIO.Tests/ContainerRewriterTests.cs @@ -72,6 +72,25 @@ void Walk(Storage storage, string prefix) return tree; } + private static (StorageDirectoryMetadata Root, Dictionary Storages) ReadStorageMetadata(string path) + { + var storages = new Dictionary(StringComparer.Ordinal); + using var root = RootStorage.OpenRead(path); + var rootMeta = new StorageDirectoryMetadata(root.CLSID, root.StateBits, root.CreationFileTime, root.ModifiedFileTime, root.HeaderCLSID); + void Walk(Storage storage, string prefix) + { + foreach (var entry in storage.EnumerateEntries()) + { + if (entry.Type != EntryType.Storage) continue; + var child = storage.OpenStorage(entry.Name); + storages.Add(prefix + entry.Name, new StorageDirectoryMetadata(child.CLSID, child.StateBits, child.CreationFileTime, child.ModifiedFileTime)); + Walk(child, prefix + entry.Name + "/"); + } + } + Walk(root, ""); + return (rootMeta, storages); + } + private static string Sha(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)); private static readonly IReadOnlyDictionary NoReplacements = new Dictionary(); @@ -108,9 +127,18 @@ public void Copy_WithoutReplacements_KeepsTheTreeTheStreamsAndTheSectorSize(Open Assert.Equal((ushort)major, MajorVersion(P("out.rvt"))); Assert.Equal(0, result.Replaced); + var expectedStorages = ReadStorageMetadata(source); + var actualStorages = ReadStorageMetadata(P("out.rvt")); + Assert.Equal(expectedStorages.Root, result.RootStorage); + Assert.Equal(expectedStorages.Root, actualStorages.Root); + Assert.Equal(expectedStorages.Storages, actualStorages.Storages); Assert.Equal(expected.Keys.Order(StringComparer.Ordinal), result.Entries.Keys.Order(StringComparer.Ordinal)); foreach (var (path, bytes) in expected) - Assert.Equal(bytes is null ? new ContainerEntry(false, 0, "") : new ContainerEntry(true, bytes.Length, Sha(bytes)), result.Entries[path]); + Assert.Equal( + bytes is null + ? new ContainerEntry(false, 0, "", expectedStorages.Storages[path]) + : new ContainerEntry(true, bytes.Length, Sha(bytes)), + result.Entries[path]); Assert.Equal(sourceHash, Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(source)))); AssertOnlyFiles("source.rvt", "out.rvt"); @@ -523,4 +551,103 @@ public void CheckWritten_Accepts_TheExpectedFile_AndRejectsAnotherFileWithTheSam var error = Assert.Throws(() => ContainerRewriter.CheckWritten(other, result.Entries)); Assert.Contains("'Small'", error.Message); } + + [Fact] + public void Copy_PreservesRootAndChildStorageMetadata_ClsidStateBitsAndTimestamps() + { + string source = P("storage_meta.rvt"); + var rootHeaderClsid = Guid.Parse("aabbccdd-1122-3344-5566-77889900aabb"); + var rootClsid = Guid.Parse("11223344-5566-7788-99aa-bbccddeeff00"); + var childClsid = Guid.Parse("01020304-0506-0708-090a-0b0c0d0e0f10"); + var nestedClsid = Guid.Parse("f0e1d2c3-b4a5-9687-7869-5a4b3c2d1e0f"); + + using (var root = RootStorage.Create(source, OpenMcdf.Version.V4)) + { + var custom = root.CreateStorage("CustomVendorStorage"); + var nested = custom.CreateStorage("Nested"); + using (var payload = nested.CreateStream("Payload")) + payload.Write([0x10, 0x20, 0x30]); + + nested.CLSID = nestedClsid; + nested.StateBits = 0xCAFEBABEu; + nested.CreationFileTime = 129_000_000_000_000_000UL; + nested.ModifiedFileTime = 129_500_000_000_000_000UL; + + custom.CLSID = childClsid; + custom.StateBits = 0x12345678u; + custom.CreationFileTime = 130_000_000_000_000_000UL; + custom.ModifiedFileTime = 131_000_000_000_000_000UL; + + root.HeaderCLSID = rootHeaderClsid; + root.CLSID = rootClsid; + root.StateBits = 0x89ABCDEFu; + root.ModifiedFileTime = 132_000_000_000_000_000UL; + } + + string copy = P("storage_meta_copy.rvt"); + var result = ContainerRewriter.Rewrite(source, copy, NoReplacements); + + var (actualRoot, actualStorages) = ReadStorageMetadata(copy); + Assert.Equal(new StorageDirectoryMetadata(rootClsid, 0x89ABCDEFu, 0UL, 132_000_000_000_000_000UL, rootHeaderClsid), actualRoot); + Assert.Equal(actualRoot, result.RootStorage); + Assert.Equal( + new StorageDirectoryMetadata(childClsid, 0x12345678u, 130_000_000_000_000_000UL, 131_000_000_000_000_000UL), + actualStorages["CustomVendorStorage"]); + Assert.Equal( + new StorageDirectoryMetadata(nestedClsid, 0xCAFEBABEu, 129_000_000_000_000_000UL, 129_500_000_000_000_000UL), + actualStorages["CustomVendorStorage/Nested"]); + } + + [Fact] + public void CheckWritten_RejectsTamperedChildOrRootStorageMetadata_AndRewriteCleansTemporaryFile() + { + string source = CreateSource(); + using (var root = RootStorage.Open(source, FileMode.Open)) + { + var emptyStorage = root.OpenStorage("EmptyStorage"); + emptyStorage.StateBits = 0x12345678u; + emptyStorage.CLSID = Guid.Parse("01020304-0506-0708-090a-0b0c0d0e0f10"); + emptyStorage.CreationFileTime = 130_000_000_000_000_000UL; + emptyStorage.ModifiedFileTime = 131_000_000_000_000_000UL; + root.StateBits = 0x55AA55AAu; + } + + // Tampering StateBits in beforeCheck aborts Rewrite, removes temporary file, and leaves no output. + var rewriteErr = Assert.Throws(() => ContainerRewriter.Rewrite( + source, + P("aborted.rvt"), + NoReplacements, + temporary => + { + using var root = RootStorage.Open(temporary, FileMode.Open); + root.OpenStorage("EmptyStorage").StateBits = 0u; + })); + Assert.Contains("'EmptyStorage'", rewriteErr.Message); + Assert.Contains("0x12345678", rewriteErr.Message); + AssertOnlyFiles("source.rvt"); + + // Verify CheckWritten catches CLSID, CreationFileTime, ModifiedFileTime, and root metadata tampering. + string okCopy = P("ok.rvt"); + var result = ContainerRewriter.Rewrite(source, okCopy, NoReplacements); + ContainerRewriter.CheckWritten(okCopy, result.Entries, result.RootStorage); + + using (var root = RootStorage.Open(okCopy, FileMode.Open)) + root.OpenStorage("EmptyStorage").CLSID = Guid.Empty; + Assert.Contains("CLSID", Assert.Throws(() => ContainerRewriter.CheckWritten(okCopy, result.Entries, result.RootStorage)).Message); + + using (var root = RootStorage.Open(okCopy, FileMode.Open)) + { + var s = root.OpenStorage("EmptyStorage"); + s.CLSID = Guid.Parse("01020304-0506-0708-090a-0b0c0d0e0f10"); + s.ModifiedFileTime = 139_000_000_000_000_000UL; + } + Assert.Contains("modified FILETIME", Assert.Throws(() => ContainerRewriter.CheckWritten(okCopy, result.Entries, result.RootStorage)).Message); + + using (var root = RootStorage.Open(okCopy, FileMode.Open)) + { + root.OpenStorage("EmptyStorage").ModifiedFileTime = 131_000_000_000_000_000UL; + root.StateBits = 0u; + } + Assert.Contains("''", Assert.Throws(() => ContainerRewriter.CheckWritten(okCopy, result.Entries, result.RootStorage)).Message); + } } diff --git a/tests/RvtIO.Tests/RvtWriterTests.cs b/tests/RvtIO.Tests/RvtWriterTests.cs index 3ab520e..a0605bf 100644 --- a/tests/RvtIO.Tests/RvtWriterTests.cs +++ b/tests/RvtIO.Tests/RvtWriterTests.cs @@ -73,8 +73,8 @@ internal static byte[] BuildSchemaBytesForRelease(int release, bool includeFrami internal static byte[] BuildBasicFileInfoBytes( int? savedRelease, - string worksharing = "Not enabled", - string cloudModel = "False", + string? worksharing = "Not enabled", + string? cloudModel = "False", string? centralPath = null, string? lastSavePath = null) { @@ -94,12 +94,14 @@ void WriteLengthPrefixedUtf16(string text) bw.Write(0); // Separator before the UTF-16 key-value lines block var lines = new StringBuilder(); - lines.Append("Worksharing: ").Append(worksharing).Append("\r\n"); + if (worksharing is not null) + lines.Append("Worksharing: ").Append(worksharing).Append("\r\n"); if (savedRelease is int formatYear) lines.Append("Format: ").Append(formatYear).Append("\r\n"); if (centralPath is not null) lines.Append("Central Model Path: ").Append(centralPath).Append("\r\n"); - lines.Append("IsSingleUserCloudModel: ").Append(cloudModel).Append("\r\n"); + if (cloudModel is not null) + lines.Append("IsSingleUserCloudModel: ").Append(cloudModel).Append("\r\n"); bw.Write(Encoding.Unicode.GetBytes(lines.ToString())); bw.Write((ushort)0); // Trailing UTF-16 NUL terminator ending the text run in BasicFileInfo return ms.ToArray(); @@ -121,9 +123,11 @@ internal sealed class SyntheticFileOptions public RvtFileKind Kind { get; init; } = RvtFileKind.Project; public int SchemaRelease { get; init; } = 2025; public int? SavedRelease { get; init; } = 2025; - public string Worksharing { get; init; } = "Not enabled"; - public string CloudModel { get; init; } = "False"; + public string? Worksharing { get; init; } = "Not enabled"; + public string? CloudModel { get; init; } = "False"; public string? CentralPath { get; init; } + public bool IncludeBasicFileInfo { get; init; } = true; + public byte[]? RawBasicFileInfoBytes { get; init; } public OpenMcdf.Version SectorVersion { get; init; } = OpenMcdf.Version.V4; public bool IncludeSchema { get; init; } = true; public bool IncludeFramingMarkers { get; init; } = true; @@ -143,18 +147,26 @@ internal static string CreateSyntheticRevitFile(string path, SyntheticFileOption using var root = RootStorage.Create(path, opt.SectorVersion); // BasicFileInfo - using (var bfi = root.CreateStream("BasicFileInfo")) - { - string defaultLastSave = opt.Kind == RvtFileKind.Family ? @"C:\Models\Sample.rfa" : opt.Kind == RvtFileKind.Project ? @"C:\Models\Sample.rvt" : ""; - bfi.Write(BuildBasicFileInfoBytes( - opt.SavedRelease, - opt.Worksharing, - opt.CloudModel, - opt.CentralPath, - string.IsNullOrEmpty(defaultLastSave) ? null : defaultLastSave)); + if (opt.IncludeBasicFileInfo) + { + using var bfi = root.CreateStream("BasicFileInfo"); + if (opt.RawBasicFileInfoBytes is not null) + { + bfi.Write(opt.RawBasicFileInfoBytes); + } + else + { + string defaultLastSave = opt.Kind == RvtFileKind.Family ? @"C:\Models\Sample.rfa" : opt.Kind == RvtFileKind.Project ? @"C:\Models\Sample.rvt" : ""; + bfi.Write(BuildBasicFileInfoBytes( + opt.SavedRelease, + opt.Worksharing, + opt.CloudModel, + opt.CentralPath, + string.IsNullOrEmpty(defaultLastSave) ? null : defaultLastSave)); + } } - // Kind-defining stream + custom opaque vendor stream + // Kind-defining stream + custom opaque vendor stream + custom opaque vendor storage with metadata if (opt.Kind == RvtFileKind.Family) { using var atom = root.CreateStream("PartAtom"); @@ -175,6 +187,14 @@ internal static string CreateSyntheticRevitFile(string path, SyntheticFileOption using (var custom = root.CreateStream("CustomOpaqueVendorStream")) custom.Write([0xCA, 0xFE, 0xBA, 0xBE, 0x42]); + var vendorStorage = root.CreateStorage("CustomOpaqueVendorStorage"); + using (var vendorStream = vendorStorage.CreateStream("OpaqueBlob")) + vendorStream.Write([0xDE, 0xAD, 0xBE, 0xEF]); + vendorStorage.CLSID = Guid.Parse("01020304-0506-0708-090a-0b0c0d0e0f10"); + vendorStorage.StateBits = 0x12345678u; + vendorStorage.CreationFileTime = 130_111_222_333_444_555UL; + vendorStorage.ModifiedFileTime = 131_222_333_444_555_666UL; + // Global storage with 8-byte prefixes matching real Revit files var global = root.CreateStorage("Global"); using (var gl = global.CreateStream("Latest")) @@ -393,13 +413,28 @@ public void SaveAs_AndDocumentSaveAs_PreserveEveryStreamExternalRefAndUnknownPay Assert.Equal(censusSource.Totals.Exact, censusCopy.Totals.Exact); Assert.Contains(censusCopy.Classes, c => c.Class == "(class 65000)"); - // Opaque custom stream preserved byte-for-byte - using (var root = RootStorage.OpenRead(copy2)) - using (var custom = root.OpenStream("CustomOpaqueVendorStream")) + // Opaque custom stream and opaque vendor storage directory-entry metadata preserved across both SaveAs hops + foreach (string savedCopy in new[] { copy1, copy2 }) { - byte[] buf = new byte[custom.Length]; - custom.ReadExactly(buf); - Assert.Equal(new byte[] { 0xCA, 0xFE, 0xBA, 0xBE, 0x42 }, buf); + using var root = RootStorage.OpenRead(savedCopy); + using (var custom = root.OpenStream("CustomOpaqueVendorStream")) + { + byte[] buf = new byte[custom.Length]; + custom.ReadExactly(buf); + Assert.Equal(new byte[] { 0xCA, 0xFE, 0xBA, 0xBE, 0x42 }, buf); + } + + var vendorStorage = root.OpenStorage("CustomOpaqueVendorStorage"); + Assert.Equal(Guid.Parse("01020304-0506-0708-090a-0b0c0d0e0f10"), vendorStorage.CLSID); + Assert.Equal(0x12345678u, vendorStorage.StateBits); + Assert.Equal(130_111_222_333_444_555UL, vendorStorage.CreationFileTime); + Assert.Equal(131_222_333_444_555_666UL, vendorStorage.ModifiedFileTime); + using (var vendorStream = vendorStorage.OpenStream("OpaqueBlob")) + { + byte[] blob = new byte[vendorStream.Length]; + vendorStream.ReadExactly(blob); + Assert.Equal(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }, blob); + } } Assert.Equal(sourceSha, FileSha256(source)); @@ -547,4 +582,175 @@ public void Rewrite_RefusesInvalidTargetsAndLeavesSourceAndDestinationUntouched( new[] { existing, source }.Order(StringComparer.Ordinal), Directory.EnumerateFileSystemEntries(_folder).Order(StringComparer.Ordinal)); } + + [Fact] + public void InspectCapabilities_AndWrite_RefuseMissingUnreadableOrIncompleteBasicFileInfo() + { + // 1. Workshared + cloud 2025 container stays refused even after BasicFileInfo is deleted + string cloudWorkshared = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "stripped_cloud_ws.rvt", + SchemaRelease = 2025, + SavedRelease = 2025, + Worksharing = "Enabled", + CloudModel = "True", + }); + var initialCaps = RvtWriter.InspectCapabilities(cloudWorkshared); + Assert.True(initialCaps.IsWorkshared); + Assert.True(initialCaps.IsCloudModel); + Assert.False(initialCaps.Supports(RvtWriteOperation.SaveAs)); + + using (var root = RootStorage.Open(cloudWorkshared, FileMode.Open)) + root.Delete("BasicFileInfo"); + + var strippedCaps = RvtWriter.InspectCapabilities(cloudWorkshared); + Assert.Null(strippedCaps.IsWorkshared); + Assert.Null(strippedCaps.IsCloudModel); + Assert.Null(strippedCaps.SavedRelease); + foreach (RvtWriteOperation op in Enum.GetValues()) + { + Assert.False(strippedCaps.Supports(op)); + Assert.Equal("missing-metadata", strippedCaps.Operations[op].RefusalCode); + } + var exStripped = Assert.Throws(() => RvtWriter.SaveAs(cloudWorkshared, P("stripped_out.rvt"))); + Assert.Equal("missing-metadata", exStripped.Kind); + Assert.False(File.Exists(P("stripped_out.rvt"))); + + // 2. Worksharing: Enabled with IsSingleUserCloudModel: False -> workshared-model + string wsEnabled = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "ws_enabled.rvt", + Worksharing = "Enabled", + CloudModel = "False", + }); + var wsEnabledCaps = RvtWriter.InspectCapabilities(wsEnabled); + Assert.True(wsEnabledCaps.IsWorkshared); + Assert.False(wsEnabledCaps.IsCloudModel); + Assert.Equal("workshared-model", Assert.Throws(() => RvtWriter.SaveAs(wsEnabled, P("ws_enabled_out.rvt"))).Kind); + + // 3. Truncated / undecodable BasicFileInfo -> missing-metadata + string truncatedBfi = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "truncated_bfi.rvt", + RawBasicFileInfoBytes = [1, 2, 3, 4], + }); + var truncCaps = RvtWriter.InspectCapabilities(truncatedBfi); + Assert.Null(truncCaps.IsWorkshared); + Assert.Null(truncCaps.IsCloudModel); + Assert.Equal("missing-metadata", Assert.Throws(() => RvtWriter.SaveAs(truncatedBfi, P("trunc_out.rvt"))).Kind); + + // 4. Missing or unrecognised Worksharing field -> missing-metadata + string missingWs = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "missing_ws.rvt", + Worksharing = null, + }); + Assert.Null(RvtWriter.InspectCapabilities(missingWs).IsWorkshared); + Assert.Equal("missing-metadata", Assert.Throws(() => RvtWriter.SaveAs(missingWs, P("mws_out.rvt"))).Kind); + + string unknownWs = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "unknown_ws.rvt", + Worksharing = "CustomMode", + }); + Assert.Null(RvtWriter.InspectCapabilities(unknownWs).IsWorkshared); + Assert.Equal("missing-metadata", Assert.Throws(() => RvtWriter.SaveAs(unknownWs, P("uws_out.rvt"))).Kind); + + // 5. Missing or unrecognised IsSingleUserCloudModel field -> missing-metadata + string missingCloud = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "missing_cloud.rvt", + CloudModel = null, + }); + Assert.Null(RvtWriter.InspectCapabilities(missingCloud).IsCloudModel); + Assert.Equal("missing-metadata", Assert.Throws(() => RvtWriter.SaveAs(missingCloud, P("mc_out.rvt"))).Kind); + + string unknownCloud = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "unknown_cloud.rvt", + CloudModel = "Maybe", + }); + Assert.Null(RvtWriter.InspectCapabilities(unknownCloud).IsCloudModel); + Assert.Equal("missing-metadata", Assert.Throws(() => RvtWriter.SaveAs(unknownCloud, P("uc_out.rvt"))).Kind); + + // 6. Missing saving release in BasicFileInfo -> missing-metadata + string missingSavedRel = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "missing_rel.rvt", + SavedRelease = null, + }); + Assert.Null(RvtWriter.InspectCapabilities(missingSavedRel).SavedRelease); + Assert.Equal("missing-metadata", Assert.Throws(() => RvtWriter.SaveAs(missingSavedRel, P("mr_out.rvt"))).Kind); + } + + [Fact] + public void Rewrite_RefusesValidButIncompatibleReplacementSchema_AndWritesNoOutput() + { + string source = CreateSyntheticRevitFile(new SyntheticFileOptions + { + FileName = "source_2025.rvt", + SchemaRelease = 2025, + SavedRelease = 2025, + }); + string sourceSha = FileSha256(source); + + // 1. Valid 2024 schema into a 2025 file -> release-mismatch, no output published + byte[] valid2024Schema = BuildSchemaBytesForRelease(2024); + var exMismatch = Assert.Throws(() => RvtWriter.Rewrite(source, P("out_2024.rvt"), new RvtWriteRequest + { + PagedStreamEdits = [new RvtPagedStreamEdit("Formats/Latest", valid2024Schema)], + })); + Assert.Equal("release-mismatch", exMismatch.Kind); + + // 2. Valid 2019 schema into a 2025 file -> release-mismatch, no output published + byte[] valid2019Schema = BuildSchemaBytesForRelease(2019); + var ex2019 = Assert.Throws(() => RvtWriter.Rewrite(source, P("out_2019.rvt"), new RvtWriteRequest + { + PagedStreamEdits = [new RvtPagedStreamEdit("Formats/Latest", valid2019Schema)], + })); + Assert.Equal("release-mismatch", ex2019.Kind); + + // 3. Syntactically valid schema with unrecognised release -> unrecognised-release + var customWriter = new SchemaStreamEditorTests.Writer(); + for (int i = 0; i < 64; i++) + customWriter.Top(new Cls("Custom" + i, new NoRef(), 1, [], [])); + var exUnrec = Assert.Throws(() => RvtWriter.Rewrite(source, P("out_unrec.rvt"), new RvtWriteRequest + { + PagedStreamEdits = [new RvtPagedStreamEdit("Formats/Latest", customWriter.Build())], + })); + Assert.Equal("unrecognised-release", exUnrec.Kind); + + // 4. Release-2025 class count without framing markers when Partitions/1 exists -> missing-framing-markers + byte[] missingMarkers2025 = BuildSchemaBytesForRelease(2025, includeFramingMarkers: false); + var exNoMarkers = Assert.Throws(() => RvtWriter.Rewrite(source, P("out_nomarkers.rvt"), new RvtWriteRequest + { + PagedStreamEdits = [new RvtPagedStreamEdit("Formats/Latest", missingMarkers2025)], + })); + Assert.Equal("missing-framing-markers", exNoMarkers.Kind); + + // 5. Release-2025 schema with ContentMarker still at 913 and 4600 classes, but swapped SegmentMarker / SegmentCheckback IDs -> incompatible-schema-payload + var shiftedWriter = new SchemaStreamEditorTests.Writer(); + int currentId = 12; + while (currentId < 912) + { + shiftedWriter.Top(new Cls("Filler" + currentId, new NoRef(), 1, [], [])); + currentId++; + } + shiftedWriter.Top(new Cls("ContentKey", new NoRef(), 1, [], [])); // 912 + shiftedWriter.Top(new Cls("ContentMarker", new NoRef(), 1, [], [])); // 913 (still matches 2025!) + shiftedWriter.Top(new Cls("SegmentCheckback", new NoRef(), 1, [], [])); // 914 (swapped!) + shiftedWriter.Top(new Cls("SegmentMarker", new NoRef(), 1, [], [])); // 915 (swapped!) + shiftedWriter.Top(new Cls("SignatureMarker", new NoRef(), 1, [], [])); // 916 + while (shiftedWriter.Placed.Count < 4600) + shiftedWriter.Top(new Cls("TailClass" + shiftedWriter.Placed.Count, new NoRef(), 1, [], [])); + + var exShifted = Assert.Throws(() => RvtWriter.Rewrite(source, P("out_shifted.rvt"), new RvtWriteRequest + { + PagedStreamEdits = [new RvtPagedStreamEdit("Formats/Latest", shiftedWriter.Build())], + })); + Assert.Equal("incompatible-schema-payload", exShifted.Kind); + + Assert.Equal(sourceSha, FileSha256(source)); + Assert.Equal(new[] { source }, Directory.EnumerateFileSystemEntries(_folder).Order(StringComparer.Ordinal)); + } } diff --git a/tests/RvtIO.Tests/SaveAsCommandTests.cs b/tests/RvtIO.Tests/SaveAsCommandTests.cs index bd22709..ca9c8e7 100644 --- a/tests/RvtIO.Tests/SaveAsCommandTests.cs +++ b/tests/RvtIO.Tests/SaveAsCommandTests.cs @@ -83,6 +83,8 @@ public void Check_OnSupportedProjectAndFamily_ReturnsZero_WritesCheckReport_AndL Assert.Equal("project", srcNode.GetProperty("fileKind").GetString()); Assert.Equal(2025, srcNode.GetProperty("effectiveRelease").GetInt32()); Assert.Equal(8, srcNode.GetProperty("elementIdWidth").GetInt32()); + Assert.False(srcNode.GetProperty("workshared").GetBoolean()); + Assert.False(srcNode.GetProperty("cloudModel").GetBoolean()); Assert.Equal(3, srcNode.GetProperty("operations").GetArrayLength()); } @@ -128,6 +130,26 @@ public void Check_AndWrite_OnRefusedFile_ReturnExitFive_WriteNoOutput_AndRecordR Assert.Equal("refused", json.RootElement.GetProperty("status").GetString()); Assert.Equal("workshared-model", json.RootElement.GetProperty("kind").GetString()); } + + // Missing BasicFileInfo -> refused with missing-metadata and null workshared/cloudModel in check report + string noBfi = RvtWriterTests.CreateSyntheticRevitFile( + P("missing_bfi_2025.rvt"), + new RvtWriterTests.SyntheticFileOptions + { + FileName = "missing_bfi_2025.rvt", + SchemaRelease = 2025, + IncludeBasicFileInfo = false, + }); + string noBfiReport = P("missing_bfi_check.json"); + var (noBfiExit, noBfiOut, _) = Run(noBfi, "--check", "--report", noBfiReport); + Assert.Equal(SaveAsCommand.Refused, noBfiExit); + Assert.Contains("save-as: refused (missing-metadata:", noBfiOut); + using (var json = JsonDocument.Parse(File.ReadAllText(noBfiReport))) + { + var srcNode = json.RootElement.GetProperty("source"); + Assert.False(srcNode.TryGetProperty("workshared", out _)); + Assert.False(srcNode.TryGetProperty("cloudModel", out _)); + } } [Fact] From e6c624f7769daf1600b3bf81126b7f93a0d2aa29 Mon Sep 17 00:00:00 2001 From: AndreyStartsev <83749987+AndreyStartsev@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:13:58 +0200 Subject: [PATCH 3/4] Refuse cross-directory writes that break relative external references --- docs/test-fixtures.md | 11 +- docs/writing.md | 6 +- src/RvtIO/Writing/RvtWriter.cs | 304 +++++++++++++++++++++- tests/RvtIO.Tests/RvtWriterSampleTests.cs | 43 ++- tests/RvtIO.Tests/RvtWriterTests.cs | 241 ++++++++++++++++- tests/RvtIO.Tests/SaveAsCommandTests.cs | 26 ++ 6 files changed, 607 insertions(+), 24 deletions(-) diff --git a/docs/test-fixtures.md b/docs/test-fixtures.md index feb9b09..1496837 100644 --- a/docs/test-fixtures.md +++ b/docs/test-fixtures.md @@ -25,6 +25,10 @@ A file may lie directly in the folder or in one level of subfolders (`2020`, `20 `ContainerRewriterSampleTests` copies every `.rvt` file in the folder and in one level of subfolders to a temporary file (deleted after each file; it needs free disk space for the largest file) and compares the compact JSON of the copy with that of the source for the ten largest files of up to 8 MB. +`RvtWriterSampleTests` runs `RvtWriter.SaveAs` and targeted partition rewrites on every `.rvt` and `.rfa` file in the folder and in one +level of subfolders, checking stream hashes, element identities, references, geometry and framing checksums (or refusal on unsupported +containers and cross-directory relative references). + `SchemaStreamEditorSampleTests` reads the `Formats/Latest` stream of every `.rvt` file in the folder and in one level of subfolders, whatever its name, and tests one stream per distinct schema, so one project per release is enough. @@ -45,8 +49,8 @@ folder (`TestFiles.Scratch`); no test uses a fixed absolute path. ## What runs without the private files -With no private file at all, 10450 of the 10894 test cases run and pass on net8.0 and 444 are skipped (net10.0: 10454 of -10898, 444 skipped): 438 need private files (listed below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`) +With no private file at all, 10483 of the 10929 test cases run and pass on net8.0 and 446 are skipped (net10.0: 10487 of +10933, 446 skipped): 440 need private files (listed below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`) and 6 need other inputs (two need symbolic links, three need `RVTIO_FREEFORM_FULL`, `RVTIO_CYL_FULL` or `RVTIO_MESH_FULL`, one is a known gap). The tests that run use synthetic data built in the tests and the small fixtures committed under `tests/RvtIO.Tests/Fixtures`. With the full private fixture folder the @@ -56,7 +60,7 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: ## Tests that need the private files -295 test methods (438 cases, theory rows counted) are skipped without the files: +297 test methods (440 cases, theory rows counted) are skipped without the files: | Class | Methods | Cases | Files | |---|---|---|---| @@ -109,6 +113,7 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: | `RecordRoutingTests` | 1 | 1 | TESTFILE.rvt | | `ReferenceApiTests` | 18 | 18 | TESTFILE.rvt, bim_dump.txt | | `RvtSceneIdentitySampleTests` | 1 | 1 | TESTFILE.rvt | +| `RvtWriterSampleTests` | 2 | 2 | any `.rvt` or `.rfa` files | | `SchemaModelTests` | 1 | 1 | Test_2022.rvt | | `SchemaReleaseTests` | 2 | 7 | Test_2020.rvt, Test_2022.rvt, Test_2025.rvt, TESTFILE.rvt | | `SchemaStreamEditorSampleTests` | 2 | 2 | any `.rvt` project files | diff --git a/docs/writing.md b/docs/writing.md index f16e75e..24e3353 100644 --- a/docs/writing.md +++ b/docs/writing.md @@ -29,7 +29,7 @@ Every write call inspects the source file first and checks the requested operati | File kind | Local project (`.rvt`, `.rte`) and family (`.rfa`, `.rft`) containers (`RvtFileKind.Project`, `RvtFileKind.Family`) | Containers whose kind cannot be identified (`unknown-file-kind`) | | Sharing and hosting | Non-workshared local files (`Worksharing: Not enabled`, `IsSingleUserCloudModel: False`) | Workshared central or local copies (`workshared-model`), cloud models (`cloud-model`), or missing/unrecognised `BasicFileInfo` sharing/hosting/release fields (`missing-metadata`) | | Release and ID width | Releases `2020` to `2023` (4-byte element IDs) and `2024` to `2027` (8-byte element IDs) when `BasicFileInfo` and `Formats/Latest` agree | Releases outside `2020` to `2027` (`unsupported-release`), unrecognised schemas (`unrecognised-release`), or saved/schema mismatches (`release-mismatch`) | -| `SaveAs` | All streams, storages, storage directory-entry metadata (`CLSID`, `StateBits`, timestamps), external references, preview images, and unknown payloads copied with sector size (`512` or `4,096` bytes) preserved | Truncated containers (`RvtInputException`) or corrupt page parity (`corrupt-page-parity`) | +| `SaveAs` | All streams, storages, storage directory-entry metadata (`CLSID`, `StateBits`, timestamps), external references, preview images, and unknown payloads copied with sector size (`512` or `4,096` bytes) preserved | Truncated containers (`RvtInputException`), corrupt page parity (`corrupt-page-parity`), or cross-directory relocation that changes relative external reference targets (`relative-reference-relocation`, `unreadable-transmission-data`) | | `PagedStreamRewrite` | Replacing the uncompressed payload of `Formats/Latest` or `Global/` while preserving stream prefixes, gzip framing, page parity, and container compatibility | Unreadable `Global/*` streams (`unreadable-paged-stream`) or malformed/incompatible replacement `Formats/Latest` schemas (`invalid-schema-payload`, `release-mismatch`, `incompatible-schema-payload`) | | `PartitionRecordRewrite` | Replacing targeted record bodies inside `Partitions/` (`101`, `102`, `103`) while preserving untouched blocks, loaded families, channels, and unknown class payloads | Missing framing marker classes (`missing-framing-markers`), missing partition streams (`missing-partitions`), framing errors (`partition-framing-failed`), or mismatched input Adler-32 checksums (`partition-checksum-failed`) | @@ -78,7 +78,9 @@ When rewriting data, `RvtWriter` enforces the following invariants: | `unknown-file-kind` | Container has neither `PartAtom` nor `ProjectInformation` and its path extension is not `.rvt`/`.rte`/`.rfa`/`.rft`. | | `cloud-model` | `BasicFileInfo` marks a single-user or workshared cloud model (`IsSingleUserCloudModel: True` or cloud central path). | | `workshared-model` | `BasicFileInfo` reports worksharing enabled. | -| `missing-metadata` | `BasicFileInfo` is missing, unreadable, or lacks a recognised `Worksharing`, `IsSingleUserCloudModel`, or saving-release field. | +| `missing-metadata` | `BasicFileInfo` is missing, unreadable, or lacks a recognised `Worksharing`, `IsSingleUserCloudModel`, or saving-release field, or `TransmissionData` is missing when relocating a file with `RvtLinkSymbol` records. | +| `relative-reference-relocation` | Saving to a different directory would change the resolved target of a relative external file reference (or an unverified `PointCloudType` path). | +| `unreadable-transmission-data` | `TransmissionData` cannot be parsed cleanly when saving to a different directory. | | `corrupt-page-parity` | A `Formats/*`, `Global/*`, or `Partitions/*` stream fails Hamming page parity verification. | | `missing-schema` | `Formats/Latest` is missing, unreadable, or cannot be parsed. | | `unrecognised-release` | `Formats/Latest` does not match a known Revit schema release. | diff --git a/src/RvtIO/Writing/RvtWriter.cs b/src/RvtIO/Writing/RvtWriter.cs index c42a53d..66c67eb 100644 --- a/src/RvtIO/Writing/RvtWriter.cs +++ b/src/RvtIO/Writing/RvtWriter.cs @@ -1,4 +1,7 @@ +using System.Buffers.Binary; using System.Globalization; +using System.Text; +using System.Xml; using ICSharpCode.SharpZipLib.Checksum; using OpenMcdf; using RvtIO.Parsing; @@ -196,6 +199,7 @@ public static RvtWriteResult Rewrite(string sourcePath, string outputPath, RvtWr string fullOutput = ValidateOutputPath(outputPath); RvtContainerCheck.Verify(sourcePath); + string fullSource = Path.GetFullPath(sourcePath); Dictionary? replacements = null; RvtWriteCapabilities capabilities; @@ -211,6 +215,8 @@ public static RvtWriteResult Rewrite(string sourcePath, string outputPath, RvtWr if (request.PartitionRecordEdits.Count > 0) RequireSupported(capabilities, RvtWriteOperation.PartitionRecordRewrite); + ValidateExternalReferenceRelocation(root, inspection, fullSource, fullOutput); + if (request.PagedStreamEdits.Count > 0 || request.PartitionRecordEdits.Count > 0) replacements = BuildReplacements(root, inspection, request); } @@ -240,7 +246,9 @@ public static RvtWriteResult Rewrite(string sourcePath, string outputPath, RvtWr private sealed record Inspection( RvtWriteCapabilities Capabilities, PartitionFraming.Tags? FramingTags, - IReadOnlyDictionary CanonicalStreamPaths); + IReadOnlyDictionary CanonicalStreamPaths, + bool HasPartitionRevitLinks, + bool HasPartitionPointClouds); private static RvtWriteCapabilities InspectContainer(string containerPath, string? pathHint = null) { @@ -360,6 +368,8 @@ private static Inspection InspectRoot(RootStorage root, string sourcePath) int? effectiveRelease = SchemaRelease.Effective(savedRelease, schemaRelease); int? idWidth = SchemaRelease.IdWidth(effectiveRelease); PartitionFraming.Tags? framingTags = formatsSchema is null ? null : PartitionFraming.TagsOf(formatsSchema); + ushort? rvtLinkClassTag = FindClassTag(schemaDoc, "RvtLinkSymbol"); + ushort? pointCloudClassTag = FindClassTag(schemaDoc, "PointCloudType"); (string Code, string Reason)? baseFailure = EvaluateBaseEligibility( fileKind, @@ -374,6 +384,8 @@ private static Inspection InspectRoot(RootStorage root, string sourcePath) RvtOperationSupport saveAsSupport; RvtOperationSupport pagedSupport; RvtOperationSupport partitionSupport; + bool hasPartitionRevitLinks = false; + bool hasPartitionPointClouds = false; if (baseFailure is { } refusal) { @@ -387,7 +399,14 @@ private static Inspection InspectRoot(RootStorage root, string sourcePath) pagedSupport = globalDecodeFailure is { } gFail ? new RvtOperationSupport(RvtWriteOperation.PagedStreamRewrite, false, gFail.Code, gFail.Reason) : new RvtOperationSupport(RvtWriteOperation.PagedStreamRewrite, true); - partitionSupport = EvaluatePartitionEligibility(partitionEntries, framingTags, idWidth); + partitionSupport = EvaluatePartitionEligibility( + partitionEntries, + framingTags, + idWidth, + rvtLinkClassTag, + pointCloudClassTag, + out hasPartitionRevitLinks, + out hasPartitionPointClouds); } var operations = new Dictionary @@ -411,7 +430,24 @@ private static Inspection InspectRoot(RootStorage root, string sourcePath) Operations = operations, }; - return new Inspection(capabilities, framingTags, canonicalPaths); + return new Inspection( + capabilities, + framingTags, + canonicalPaths, + hasPartitionRevitLinks, + hasPartitionPointClouds); + } + + private static ushort? FindClassTag(SchemaDocument? schemaDoc, string className) + { + if (schemaDoc is null) return null; + foreach (var cls in schemaDoc.Classes) + { + if (string.Equals(cls.Name, className, StringComparison.Ordinal) + && cls.Id is >= 0 and <= ushort.MaxValue) + return (ushort)cls.Id; + } + return null; } private static bool? ParseWorksharingState(string? value) @@ -477,8 +513,15 @@ private static (string Code, string Reason)? EvaluateBaseEligibility( private static RvtOperationSupport EvaluatePartitionEligibility( List<(string Path, Storage Parent, string Name, long Length)> partitionEntries, PartitionFraming.Tags? framingTags, - int? idWidth) + int? idWidth, + ushort? rvtLinkClassTag, + ushort? pointCloudClassTag, + out bool hasPartitionRevitLinks, + out bool hasPartitionPointClouds) { + hasPartitionRevitLinks = false; + hasPartitionPointClouds = false; + if (framingTags is null) return new RvtOperationSupport(RvtWriteOperation.PartitionRecordRewrite, false, "missing-framing-markers", "Formats/Latest lacks the partition framing marker classes."); if (idWidth is not (4 or 8)) @@ -488,6 +531,8 @@ private static RvtOperationSupport EvaluatePartitionEligibility( var adler = new Adler32(); ulong terminator = idWidth.Value == 8 ? ulong.MaxValue : uint.MaxValue; + bool sawRevitLink = false; + bool sawPointCloud = false; foreach (var entry in partitionEntries) { byte[] raw = ReadStreamBytes(entry.Parent, entry.Name, entry.Length); @@ -498,6 +543,12 @@ private static RvtOperationSupport EvaluatePartitionEligibility( { if (checksumMismatch) return; if (record.Owner == terminator && record.Body.Length == 0) return; + if (record.Block == 0 && record.Channel == 102 && record.Body.Length >= 2) + { + ushort classTag = BinaryPrimitives.ReadUInt16LittleEndian(record.Body.Span); + if (rvtLinkClassTag == classTag) sawRevitLink = true; + if (pointCloudClassTag == classTag) sawPointCloud = true; + } if (record.Channel == 101) return; adler.Reset(); adler.Update(record.Body.ToArray()); @@ -509,6 +560,9 @@ private static RvtOperationSupport EvaluatePartitionEligibility( } }, out string? reason, out _); + hasPartitionRevitLinks = sawRevitLink; + hasPartitionPointClouds = sawPointCloud; + if (!ok) return new RvtOperationSupport( RvtWriteOperation.PartitionRecordRewrite, @@ -527,6 +581,248 @@ private static RvtOperationSupport EvaluatePartitionEligibility( return new RvtOperationSupport(RvtWriteOperation.PartitionRecordRewrite, true); } + private sealed record TransmissionRefEntry( + string? ElementId, + string? LastSavedPath, + string? LastSavedPathType, + string? LastSavedLoadState, + string? DesiredPath, + string? DesiredPathType, + string? DesiredLoadState); + + private static void ValidateExternalReferenceRelocation( + RootStorage root, + Inspection inspection, + string fullSource, + string fullOutput) + { + string sourceDir = Path.GetDirectoryName(fullSource) ?? ""; + string outputDir = Path.GetDirectoryName(fullOutput) ?? ""; + bool sameDirectory = SameDirectory(sourceDir, outputDir); + + if (inspection.HasPartitionPointClouds && !sameDirectory) + { + throw new RvtWriteRefusedException( + "relative-reference-relocation", + "Saving to a different directory cannot verify external file paths of PointCloudType records, which are not listed in TransmissionData."); + } + + if (!inspection.CanonicalStreamPaths.TryGetValue("TransmissionData", out string? tdPath)) + { + if (inspection.HasPartitionRevitLinks && !sameDirectory) + { + throw new RvtWriteRefusedException( + "missing-metadata", + "TransmissionData is missing while RvtLinkSymbol records exist in Partitions/, so external reference preservation across directories cannot be verified."); + } + return; + } + + byte[] tdBytes = ReadStreamByPath(root, tdPath); + if (!TryParseTransmissionReferences(tdBytes, out var references)) + { + if (!sameDirectory) + { + throw new RvtWriteRefusedException( + "unreadable-transmission-data", + "TransmissionData cannot be parsed cleanly, so relative external reference targets cannot be verified when changing directories."); + } + return; + } + + foreach (var entry in references) + { + CheckRelativeReferenceTarget( + entry.LastSavedPath, + entry.LastSavedPathType, + entry.LastSavedLoadState, + entry.ElementId, + sourceDir, + outputDir, + sameDirectory); + + if (entry.DesiredPath is not null || entry.DesiredPathType is not null) + { + CheckRelativeReferenceTarget( + entry.DesiredPath, + entry.DesiredPathType, + entry.DesiredLoadState, + entry.ElementId, + sourceDir, + outputDir, + sameDirectory); + } + } + } + + private static void CheckRelativeReferenceTarget( + string? rawPath, + string? pathType, + string? loadState, + string? elementId, + string sourceDir, + string outputDir, + bool sameDirectory) + { + if (string.Equals(loadState?.Trim(), "Imported", StringComparison.OrdinalIgnoreCase)) + return; + + string? trimmedType = pathType?.Trim(); + bool isExplicitRelative = string.Equals(trimmedType, "Relative", StringComparison.OrdinalIgnoreCase); + bool isExplicitNonRelative = !string.IsNullOrEmpty(trimmedType) + && (string.Equals(trimmedType, "Absolute", StringComparison.OrdinalIgnoreCase) + || string.Equals(trimmedType, "Server", StringComparison.OrdinalIgnoreCase) + || string.Equals(trimmedType, "Content", StringComparison.OrdinalIgnoreCase) + || string.Equals(trimmedType, "Library", StringComparison.OrdinalIgnoreCase)); + + bool isImplicitRelative = !isExplicitNonRelative + && string.IsNullOrEmpty(trimmedType) + && !string.IsNullOrWhiteSpace(rawPath) + && !IsRootedOrUriLikePath(rawPath); + + if (!isExplicitRelative && !isImplicitRelative) + return; + + string elementSuffix = string.IsNullOrWhiteSpace(elementId) ? "" : $" (element {elementId.Trim()})"; + if (string.IsNullOrWhiteSpace(rawPath)) + { + if (!sameDirectory) + { + throw new RvtWriteRefusedException( + "relative-reference-relocation", + $"Saving to a different directory cannot preserve relative external reference{elementSuffix} because its relative path is empty."); + } + return; + } + + string normalizedRel = rawPath.Trim() + .Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + + string sourceTarget; + string outputTarget; + try + { + sourceTarget = Path.GetFullPath(Path.Combine(sourceDir, normalizedRel)); + outputTarget = Path.GetFullPath(Path.Combine(outputDir, normalizedRel)); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + if (sameDirectory) return; + throw new RvtWriteRefusedException( + "relative-reference-relocation", + $"Saving to a different directory cannot resolve relative external reference '{rawPath.Trim()}'{elementSuffix}.", + ex); + } + + if (!ExportReport.SameFile(sourceTarget, outputTarget)) + { + throw new RvtWriteRefusedException( + "relative-reference-relocation", + $"Saving to a different directory changes the resolved target of relative external reference '{rawPath.Trim()}'{elementSuffix}; relative external references cannot be rewritten losslessly."); + } + } + + private static bool TryParseTransmissionReferences(byte[] data, out List references) + { + references = []; + if (data.Length < 4) return false; + + int charCount = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(0, 4)); + if (charCount <= 0 || 4L + (long)charCount * 2L > data.Length) + return false; + + string xml = Encoding.Unicode.GetString(data, 4, charCount * 2); + XmlDocument doc; + try + { + doc = new XmlDocument { XmlResolver = null }; + using var stringReader = new StringReader(xml); + using var xmlReader = XmlReader.Create( + stringReader, + new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + doc.Load(xmlReader); + } + catch (XmlException) + { + return false; + } + + var rootElement = doc.DocumentElement; + if (rootElement is null || !string.Equals(rootElement.Name, "TransmissionData", StringComparison.Ordinal)) + return false; + + foreach (XmlNode node in rootElement.ChildNodes) + { + if (node.NodeType != XmlNodeType.Element + || !string.Equals(node.Name, "ExternalFileReference", StringComparison.Ordinal)) + continue; + + string? elementId = null; + string? lastPath = null; + string? lastPathType = null; + string? lastLoadState = null; + string? desiredPath = null; + string? desiredPathType = null; + string? desiredLoadState = null; + + foreach (XmlNode child in node.ChildNodes) + { + if (child.NodeType != XmlNodeType.Element) continue; + switch (child.Name) + { + case "ElementId": elementId = child.InnerText; break; + case "LastSavedPath": lastPath = child.InnerText; break; + case "LastSavedPathType": lastPathType = child.InnerText; break; + case "LastSavedLoadState": lastLoadState = child.InnerText; break; + case "DesiredPath": desiredPath = child.InnerText; break; + case "DesiredPathType": desiredPathType = child.InnerText; break; + case "DesiredLoadState": desiredLoadState = child.InnerText; break; + } + } + + references.Add(new TransmissionRefEntry( + elementId, + lastPath, + lastPathType, + lastLoadState, + desiredPath, + desiredPathType, + desiredLoadState)); + } + + return true; + } + + private static bool SameDirectory(string sourceDir, string outputDir) + { + if (string.IsNullOrEmpty(sourceDir) || string.IsNullOrEmpty(outputDir)) + return false; + var comparison = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + string trimmedSource = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sourceDir)); + string trimmedOutput = Path.TrimEndingDirectorySeparator(Path.GetFullPath(outputDir)); + return string.Equals(trimmedSource, trimmedOutput, comparison) + && ExportReport.SameFile(trimmedSource, trimmedOutput); + } + + private static bool IsRootedOrUriLikePath(string rawPath) + { + string trimmed = rawPath.Trim(); + if (Path.IsPathRooted(trimmed)) return true; + if (trimmed.Length >= 3 + && char.IsAsciiLetter(trimmed[0]) + && trimmed[1] == ':' + && (trimmed[2] == '\\' || trimmed[2] == '/')) + return true; + if (trimmed.StartsWith(@"\\", StringComparison.Ordinal) + || trimmed.StartsWith("//", StringComparison.Ordinal) + || trimmed.Contains("://", StringComparison.Ordinal)) + return true; + return false; + } + private static Dictionary BuildReplacements( RootStorage root, Inspection inspection, diff --git a/tests/RvtIO.Tests/RvtWriterSampleTests.cs b/tests/RvtIO.Tests/RvtWriterSampleTests.cs index e4b5a8e..d519b9c 100644 --- a/tests/RvtIO.Tests/RvtWriterSampleTests.cs +++ b/tests/RvtIO.Tests/RvtWriterSampleTests.cs @@ -94,6 +94,7 @@ public void SaveAs_OnSupportedSampleFiles_PreservesStreamsIdentitiesRelationship var caps = RvtWriter.InspectCapabilities(source); string ext = Path.GetExtension(source); string copy = Fresh("rvtwriter-saveas" + ext); + string? stagedSource = null; try { if (!caps.Supports(RvtWriteOperation.SaveAs)) @@ -105,17 +106,29 @@ public void SaveAs_OnSupportedSampleFiles_PreservesStreamsIdentitiesRelationship } supportedCount++; - var writeResult = RvtWriter.SaveAs(source, copy); + RvtWriteResult writeResult; + try + { + writeResult = RvtWriter.SaveAs(source, copy); + } + catch (RvtWriteRefusedException ex) when (ex.Kind == "relative-reference-relocation") + { + Assert.False(File.Exists(copy)); + stagedSource = Fresh("rvtwriter-staged-saveas" + ext); + File.Copy(source, stagedSource); + writeResult = RvtWriter.SaveAs(stagedSource, copy); + } + Assert.Equal(caps.FileKind, writeResult.FileKind); Assert.Equal(caps.EffectiveRelease, writeResult.EffectiveRelease); - var expectedStreams = StreamHashes(source); + var expectedStreams = StreamHashes(stagedSource ?? source); var actualStreams = StreamHashes(copy); Assert.Equal( expectedStreams.Select(s => (s.Key, s.Value.Length, s.Value.Sha256)), actualStreams.Select(s => (s.Key, s.Value.Length, s.Value.Sha256))); - var srcDoc = RvtDocument.Open(source); + var srcDoc = RvtDocument.Open(stagedSource ?? source); var dstDoc = RvtDocument.Open(copy); Assert.Equal(srcDoc.Elements.Count, dstDoc.Elements.Count); @@ -130,6 +143,7 @@ public void SaveAs_OnSupportedSampleFiles_PreservesStreamsIdentitiesRelationship finally { File.Delete(copy); + if (stagedSource is not null) File.Delete(stagedSource); } } @@ -199,29 +213,43 @@ public void TargetedRewrite_OnSampleFiles_VerifiesChecksumsAndPreservesModelGrap string ext = Path.GetExtension(source); string rewritten = Fresh("rvtwriter-rewrite" + ext); + string? stagedSource = null; try { var pagedEdits = globalPtPayload is not null ? new[] { new RvtPagedStreamEdit("Global/PartitionTable", globalPtPayload) } : Array.Empty(); - var res = RvtWriter.Rewrite(source, rewritten, new RvtWriteRequest + var request = new RvtWriteRequest { PagedStreamEdits = pagedEdits, PartitionRecordEdits = [ new RvtPartitionRecordEdit(targetPart.Value, 102, targetOwner, targetBody), ], - }); + }; + + RvtWriteResult res; + try + { + res = RvtWriter.Rewrite(source, rewritten, request); + } + catch (RvtWriteRefusedException ex) when (ex.Kind == "relative-reference-relocation") + { + Assert.False(File.Exists(rewritten)); + stagedSource = Fresh("rvtwriter-staged-rewrite" + ext); + File.Copy(source, stagedSource); + res = RvtWriter.Rewrite(stagedSource, rewritten, request); + } Assert.Equal(1, res.RewrittenRecordCount); - var srcCensus = CoverageCensus.Read(source); + var srcCensus = CoverageCensus.Read(stagedSource ?? source); var dstCensus = CoverageCensus.Read(rewritten); Assert.Equal(0, dstCensus.Totals.ChecksumMismatches); Assert.Equal(srcCensus.Totals.Items, dstCensus.Totals.Items); - var srcDoc = RvtDocument.Open(source); + var srcDoc = RvtDocument.Open(stagedSource ?? source); var dstDoc = RvtDocument.Open(rewritten); Assert.Equal(srcDoc.Elements.Count, dstDoc.Elements.Count); Assert.Equal( @@ -232,6 +260,7 @@ public void TargetedRewrite_OnSampleFiles_VerifiesChecksumsAndPreservesModelGrap finally { File.Delete(rewritten); + if (stagedSource is not null) File.Delete(stagedSource); } } diff --git a/tests/RvtIO.Tests/RvtWriterTests.cs b/tests/RvtIO.Tests/RvtWriterTests.cs index a0605bf..40ff2a0 100644 --- a/tests/RvtIO.Tests/RvtWriterTests.cs +++ b/tests/RvtIO.Tests/RvtWriterTests.cs @@ -63,6 +63,7 @@ internal static byte[] BuildSchemaBytesForRelease(int release, bool includeFrami writer.Top(new Cls("SignatureMarker", new NoRef(), 1, [], [])); // markerId + 3 // Sample domain class with one int32 field (type 0x05) so WideBlockWalker walks it cleanly! writer.Top(new Cls("SyntheticItem", new NoRef(), 2, [new Fld("m_code", 0x05)], ["00112233445566778899aabbccddeeff"])); + writer.Top(new Cls("RvtLinkSymbol", new NoRef(), 1, [new Fld("m_code", 0x05)], ["112233445566778899aabbccddeeff00"])); } while (writer.Placed.Count < totalClasses) @@ -107,9 +108,16 @@ void WriteLengthPrefixedUtf16(string text) return ms.ToArray(); } - internal static byte[] BuildTransmissionDataBytes(string linkedPath) + internal static byte[] BuildTransmissionDataBytes( + string linkedPath, + string pathType = "Relative", + string loadState = "Loaded", + string? extraReferenceXml = null) { - string xml = $"""501Revit Link{linkedPath}RelativeLoaded"""; + string escapedPath = System.Security.SecurityElement.Escape(linkedPath) ?? ""; + string escapedType = System.Security.SecurityElement.Escape(pathType) ?? ""; + string escapedState = System.Security.SecurityElement.Escape(loadState) ?? ""; + string xml = $"""501Revit Link{escapedPath}{escapedType}{escapedState}{extraReferenceXml ?? ""}"""; byte[] utf16 = Encoding.Unicode.GetBytes(xml); byte[] data = new byte[4 + utf16.Length]; BinaryPrimitives.WriteInt32LittleEndian(data, xml.Length); @@ -133,6 +141,10 @@ internal sealed class SyntheticFileOptions public bool IncludeFramingMarkers { get; init; } = true; public bool IncludePartitions { get; init; } = true; public string? LinkedPath { get; init; } = @"links\Arch_Link.rvt"; + public string LinkedPathType { get; init; } = "Relative"; + public string LinkedLoadState { get; init; } = "Loaded"; + public byte[]? RawTransmissionDataBytes { get; init; } + public bool IncludeRevitLinkRecord { get; init; } } internal string CreateSyntheticRevitFile(SyntheticFileOptions? options = null) @@ -178,10 +190,15 @@ internal static string CreateSyntheticRevitFile(string path, SyntheticFileOption pi.Write([1, 2, 3, 4, 5, 6, 7, 8]); } - if (opt.LinkedPath is not null) + if (opt.RawTransmissionDataBytes is not null) { using var td = root.CreateStream("TransmissionData"); - td.Write(BuildTransmissionDataBytes(opt.LinkedPath)); + td.Write(opt.RawTransmissionDataBytes); + } + else if (opt.LinkedPath is not null) + { + using var td = root.CreateStream("TransmissionData"); + td.Write(BuildTransmissionDataBytes(opt.LinkedPath, opt.LinkedPathType, opt.LinkedLoadState)); } using (var custom = root.CreateStream("CustomOpaqueVendorStream")) @@ -225,13 +242,21 @@ internal static string CreateSyntheticRevitFile(string path, SyntheticFileOption BinaryPrimitives.WriteInt32LittleEndian(code100, 100); byte[] unknownOpaqueBody = [0x99, 0x88, 0x77, 0x66, 0x55]; + var projectRecords = new List<(ulong Owner, ushort ClassTag, byte[] Payload)> + { + (101UL, syntheticItemTag, code100), + (202UL, 65000, unknownOpaqueBody), // unknown class tag + opaque payload + }; + if (opt.IncludeRevitLinkRecord) + { + ushort linkTag = (ushort)parsedSchema.Classes.Single(c => c.Name == "RvtLinkSymbol").Id; + projectRecords.Add((501UL, linkTag, code100)); + } + var projectBlock = PartitionStreamEditorTests.MakeStandardBlock( idWidth, keyed: false, - [ - (101UL, syntheticItemTag, code100), - (202UL, 65000, unknownOpaqueBody), // unknown class tag + opaque payload - ]); + projectRecords.ToArray()); var familyBlock = PartitionStreamEditorTests.MakeStandardBlock( idWidth, keyed: true, @@ -753,4 +778,204 @@ public void Rewrite_RefusesValidButIncompatibleReplacementSchema_AndWritesNoOutp Assert.Equal(sourceSha, FileSha256(source)); Assert.Equal(new[] { source }, Directory.EnumerateFileSystemEntries(_folder).Order(StringComparer.Ordinal)); } + + private static string ResolveExternalReferenceTarget(string hostPath) + { + var doc = RvtDocument.Open(hostPath); + Assert.NotNull(doc.TransmissionData); + var extRef = Assert.Single(doc.TransmissionData!.ExternalReferences); + Assert.False(string.IsNullOrWhiteSpace(extRef.Path)); + string normalized = extRef.Path! + .Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + string hostDir = Path.GetDirectoryName(Path.GetFullPath(hostPath))!; + return Path.GetFullPath(Path.Combine(hostDir, normalized)); + } + + [Fact] + public void SaveAs_AndRewrite_WithValidRelativeLink_PreserveTargetInSameDirectory_AndRefuseDifferentDirectory() + { + string linksDir = Path.Combine(_folder, "links"); + string inputDir = Path.Combine(_folder, "input"); + string nestedDir = Path.Combine(_folder, "output", "nested"); + Directory.CreateDirectory(linksDir); + Directory.CreateDirectory(inputDir); + Directory.CreateDirectory(nestedDir); + + string linkedFile = CreateSyntheticRevitFile( + Path.Combine(linksDir, "linked.rvt"), + new SyntheticFileOptions + { + FileName = "linked.rvt", + SchemaRelease = 2022, + SavedRelease = 2022, + LinkedPath = null, + }); + string linkedSha = FileSha256(linkedFile); + + string hostFile = CreateSyntheticRevitFile( + Path.Combine(inputDir, "host.rvt"), + new SyntheticFileOptions + { + FileName = "host.rvt", + SchemaRelease = 2022, + SavedRelease = 2022, + LinkedPath = @"..\links\linked.rvt", + LinkedPathType = "Relative", + LinkedLoadState = "Loaded", + IncludeRevitLinkRecord = true, + }); + string hostSha = FileSha256(hostFile); + + // Precondition: the relative reference in input/host.rvt resolves to the real linked.rvt file on disk. + string initialResolved = ResolveExternalReferenceTarget(hostFile); + Assert.True(File.Exists(initialResolved)); + Assert.True(ExportReport.SameFile(linkedFile, initialResolved)); + + byte[] schemaBytes = BuildSchemaBytesForRelease(2022); + ushort itemTag = (ushort)TfSchemaReader.Parse(schemaBytes).Classes.Single(c => c.Name == "SyntheticItem").Id; + byte[] updatedItemBody = new byte[6]; + BinaryPrimitives.WriteUInt16LittleEndian(updatedItemBody.AsSpan(0, 2), itemTag); + BinaryPrimitives.WriteInt32LittleEndian(updatedItemBody.AsSpan(2, 4), 777); + + var rewriteRequest = new RvtWriteRequest + { + PagedStreamEdits = [new RvtPagedStreamEdit("Global/Latest", new byte[] { 42, 43, 44, 45 })], + PartitionRecordEdits = [new RvtPartitionRecordEdit(1, 102, 101UL, updatedItemBody)], + }; + + // 1. Same-directory destinations preserve the resolved link target on disk for SaveAs, RvtDocument.SaveAs, and Rewrite. + string sameSaveAs = Path.Combine(inputDir, "host_saveas.rvt"); + var saveAsRes = RvtWriter.SaveAs(hostFile, sameSaveAs); + Assert.Equal(0, saveAsRes.ReplacedStreamCount); + string sameSaveAsResolved = ResolveExternalReferenceTarget(sameSaveAs); + Assert.True(File.Exists(sameSaveAsResolved)); + Assert.True(ExportReport.SameFile(linkedFile, sameSaveAsResolved)); + + string sameDocSaveAs = Path.Combine(inputDir, "host_doc_saveas.rvt"); + RvtDocument.Open(hostFile).SaveAs(sameDocSaveAs); + string sameDocResolved = ResolveExternalReferenceTarget(sameDocSaveAs); + Assert.True(File.Exists(sameDocResolved)); + Assert.True(ExportReport.SameFile(linkedFile, sameDocResolved)); + + string sameRewrite = Path.Combine(inputDir, "host_rewrite.rvt"); + var rewriteRes = RvtWriter.Rewrite(hostFile, sameRewrite, rewriteRequest); + Assert.Equal(1, rewriteRes.RewrittenRecordCount); + string sameRewriteResolved = ResolveExternalReferenceTarget(sameRewrite); + Assert.True(File.Exists(sameRewriteResolved)); + Assert.True(ExportReport.SameFile(linkedFile, sameRewriteResolved)); + + // 2. Different-directory destinations would break ../links/linked.rvt resolution and must be refused before publishing output. + string brokenCandidateTarget = Path.GetFullPath(Path.Combine(nestedDir, "..", "links", "linked.rvt")); + Assert.False(File.Exists(brokenCandidateTarget)); + Assert.False(ExportReport.SameFile(linkedFile, brokenCandidateTarget)); + + string nestedSaveAs = Path.Combine(nestedDir, "host_saveas.rvt"); + var exSaveAs = Assert.Throws(() => RvtWriter.SaveAs(hostFile, nestedSaveAs)); + Assert.Equal("relative-reference-relocation", exSaveAs.Kind); + + string nestedDocSaveAs = Path.Combine(nestedDir, "host_doc_saveas.rvt"); + var exDocSaveAs = Assert.Throws(() => RvtDocument.Open(hostFile).SaveAs(nestedDocSaveAs)); + Assert.Equal("relative-reference-relocation", exDocSaveAs.Kind); + + string nestedRewrite = Path.Combine(nestedDir, "host_rewrite.rvt"); + var exRewrite = Assert.Throws(() => RvtWriter.Rewrite(hostFile, nestedRewrite, rewriteRequest)); + Assert.Equal("relative-reference-relocation", exRewrite.Kind); + + Assert.Empty(Directory.EnumerateFileSystemEntries(nestedDir)); + Assert.Equal(hostSha, FileSha256(hostFile)); + Assert.Equal(linkedSha, FileSha256(linkedFile)); + } + + [Fact] + public void SaveAs_AndRewrite_ToDifferentDirectory_SucceedForNonRelativeLinks_AndRefuseCorruptOrStrippedLinkMetadata() + { + string linksDir = Path.Combine(_folder, "links"); + string inputDir = Path.Combine(_folder, "input"); + string nestedDir = Path.Combine(_folder, "output", "nested"); + Directory.CreateDirectory(linksDir); + Directory.CreateDirectory(inputDir); + Directory.CreateDirectory(nestedDir); + + string linkedFile = CreateSyntheticRevitFile( + Path.Combine(linksDir, "linked.rvt"), + new SyntheticFileOptions + { + FileName = "linked.rvt", + SchemaRelease = 2025, + SavedRelease = 2025, + LinkedPath = null, + }); + string fullLinked = Path.GetFullPath(linkedFile); + + // 1. Absolute + Content external references remain valid across directories. + const string keynoteXml = "502Keynote TableKeynotes.txtContentLoaded"; + string absHost = CreateSyntheticRevitFile( + Path.Combine(inputDir, "host_abs.rvt"), + new SyntheticFileOptions + { + FileName = "host_abs.rvt", + SchemaRelease = 2025, + SavedRelease = 2025, + RawTransmissionDataBytes = BuildTransmissionDataBytes(fullLinked, "Absolute", "Loaded", keynoteXml), + IncludeRevitLinkRecord = true, + }); + + string nestedAbsSaveAs = Path.Combine(nestedDir, "host_abs_saveas.rvt"); + RvtWriter.SaveAs(absHost, nestedAbsSaveAs); + var reopenedAbs = RvtDocument.Open(nestedAbsSaveAs); + Assert.NotNull(reopenedAbs.TransmissionData); + Assert.Equal(2, reopenedAbs.TransmissionData!.ExternalReferences.Count); + string resolvedAbs = Path.GetFullPath(reopenedAbs.TransmissionData.ExternalReferences[0].Path!); + Assert.True(File.Exists(resolvedAbs)); + Assert.True(ExportReport.SameFile(linkedFile, resolvedAbs)); + + string nestedAbsRewrite = Path.Combine(nestedDir, "host_abs_rewrite.rvt"); + RvtWriter.Rewrite(absHost, nestedAbsRewrite, new RvtWriteRequest + { + PagedStreamEdits = [new RvtPagedStreamEdit("Global/Latest", new byte[] { 1, 2, 3, 4 })], + }); + Assert.True(File.Exists(nestedAbsRewrite)); + + // 2. Malformed TransmissionData succeeds in the same directory (verbatim copy) and refuses cross-directory relocation. + string corruptTdHost = CreateSyntheticRevitFile( + Path.Combine(inputDir, "host_corrupt_td.rvt"), + new SyntheticFileOptions + { + FileName = "host_corrupt_td.rvt", + SchemaRelease = 2025, + SavedRelease = 2025, + RawTransmissionDataBytes = [0x05, 0x00, 0x00, 0x00, 0x3C, 0x00], // truncated UTF-16 XML + }); + + string sameCorruptCopy = Path.Combine(inputDir, "host_corrupt_td_copy.rvt"); + RvtWriter.SaveAs(corruptTdHost, sameCorruptCopy); + Assert.True(File.Exists(sameCorruptCopy)); + + string nestedCorruptCopy = Path.Combine(nestedDir, "host_corrupt_td.rvt"); + var exCorrupt = Assert.Throws(() => RvtWriter.SaveAs(corruptTdHost, nestedCorruptCopy)); + Assert.Equal("unreadable-transmission-data", exCorrupt.Kind); + Assert.False(File.Exists(nestedCorruptCopy)); + + // 3. RvtLinkSymbol record in Partitions/1 with stripped TransmissionData refuses cross-directory relocation. + string strippedTdHost = CreateSyntheticRevitFile( + Path.Combine(inputDir, "host_stripped_td.rvt"), + new SyntheticFileOptions + { + FileName = "host_stripped_td.rvt", + SchemaRelease = 2025, + SavedRelease = 2025, + LinkedPath = null, + IncludeRevitLinkRecord = true, + }); + + string sameStrippedCopy = Path.Combine(inputDir, "host_stripped_td_copy.rvt"); + RvtWriter.SaveAs(strippedTdHost, sameStrippedCopy); + Assert.True(File.Exists(sameStrippedCopy)); + + string nestedStrippedCopy = Path.Combine(nestedDir, "host_stripped_td.rvt"); + var exStripped = Assert.Throws(() => RvtWriter.SaveAs(strippedTdHost, nestedStrippedCopy)); + Assert.Equal("missing-metadata", exStripped.Kind); + Assert.False(File.Exists(nestedStrippedCopy)); + } } diff --git a/tests/RvtIO.Tests/SaveAsCommandTests.cs b/tests/RvtIO.Tests/SaveAsCommandTests.cs index ca9c8e7..7600e94 100644 --- a/tests/RvtIO.Tests/SaveAsCommandTests.cs +++ b/tests/RvtIO.Tests/SaveAsCommandTests.cs @@ -150,6 +150,32 @@ public void Check_AndWrite_OnRefusedFile_ReturnExitFive_WriteNoOutput_AndRecordR Assert.False(srcNode.TryGetProperty("workshared", out _)); Assert.False(srcNode.TryGetProperty("cloudModel", out _)); } + + // Cross-directory write with a relative external reference -> refused (exit 5) with relative-reference-relocation + string nestedDir = Path.Combine(_folder, "nested_out"); + Directory.CreateDirectory(nestedDir); + string linkedHost = RvtWriterTests.CreateSyntheticRevitFile( + P("linked_host_2025.rvt"), + new RvtWriterTests.SyntheticFileOptions + { + FileName = "linked_host_2025.rvt", + SchemaRelease = 2025, + SavedRelease = 2025, + LinkedPath = @"links\linked.rvt", + LinkedPathType = "Relative", + }); + string relocatedOut = Path.Combine(nestedDir, "linked_host_2025.rvt"); + string relocReport = P("relocated_refused.json"); + var (relocExit, relocOut, relocErr) = Run(linkedHost, relocatedOut, "--report", relocReport); + Assert.Equal(SaveAsCommand.Refused, relocExit); + Assert.Equal("", relocOut); + Assert.Contains("Nothing was written (exit code 5)", relocErr); + Assert.False(File.Exists(relocatedOut)); + using (var json = JsonDocument.Parse(File.ReadAllText(relocReport))) + { + Assert.Equal("refused", json.RootElement.GetProperty("status").GetString()); + Assert.Equal("relative-reference-relocation", json.RootElement.GetProperty("kind").GetString()); + } } [Fact] From aa53fc035141c7ca1ebe1d97d0d0404df4639675 Mon Sep 17 00:00:00 2001 From: mie Date: Fri, 13 Nov 2026 04:20:55 +0300 Subject: [PATCH 4/4] Recognise the native relative path type and require a reference for every link before relocation --- docs/test-fixtures.md | 4 +- docs/writing.md | 4 +- src/RvtIO/Writing/RvtWriter.cs | 63 +++++++++++++++++--------- tests/RvtIO.Tests/RvtWriterTests.cs | 68 +++++++++++++++++++++++++++-- 4 files changed, 112 insertions(+), 27 deletions(-) diff --git a/docs/test-fixtures.md b/docs/test-fixtures.md index 1496837..155f8e8 100644 --- a/docs/test-fixtures.md +++ b/docs/test-fixtures.md @@ -49,8 +49,8 @@ folder (`TestFiles.Scratch`); no test uses a fixed absolute path. ## What runs without the private files -With no private file at all, 10483 of the 10929 test cases run and pass on net8.0 and 446 are skipped (net10.0: 10487 of -10933, 446 skipped): 440 need private files (listed below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`) +With no private file at all, 10486 of the 10932 test cases run and pass on net8.0 and 446 are skipped (net10.0: 10490 of +10936, 446 skipped): 440 need private files (listed below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`) and 6 need other inputs (two need symbolic links, three need `RVTIO_FREEFORM_FULL`, `RVTIO_CYL_FULL` or `RVTIO_MESH_FULL`, one is a known gap). The tests that run use synthetic data built in the tests and the small fixtures committed under `tests/RvtIO.Tests/Fixtures`. With the full private fixture folder the diff --git a/docs/writing.md b/docs/writing.md index 24e3353..4409974 100644 --- a/docs/writing.md +++ b/docs/writing.md @@ -78,8 +78,8 @@ When rewriting data, `RvtWriter` enforces the following invariants: | `unknown-file-kind` | Container has neither `PartAtom` nor `ProjectInformation` and its path extension is not `.rvt`/`.rte`/`.rfa`/`.rft`. | | `cloud-model` | `BasicFileInfo` marks a single-user or workshared cloud model (`IsSingleUserCloudModel: True` or cloud central path). | | `workshared-model` | `BasicFileInfo` reports worksharing enabled. | -| `missing-metadata` | `BasicFileInfo` is missing, unreadable, or lacks a recognised `Worksharing`, `IsSingleUserCloudModel`, or saving-release field, or `TransmissionData` is missing when relocating a file with `RvtLinkSymbol` records. | -| `relative-reference-relocation` | Saving to a different directory would change the resolved target of a relative external file reference (or an unverified `PointCloudType` path). | +| `missing-metadata` | `BasicFileInfo` is missing, unreadable, or lacks a recognised `Worksharing`, `IsSingleUserCloudModel`, or saving-release field, or, when relocating a file with `RvtLinkSymbol` records, `TransmissionData` is missing or lists no external reference for one of them. | +| `relative-reference-relocation` | Saving to a different directory would change the resolved target of a relative external file reference (path type `Relative` or `Relative to Central Model`, as Revit writes it, or no path type with a relative path), a reference has a path type other than these, `Absolute`, `Server`, `Content` and `Library`, or the file holds an unverified `PointCloudType` path. | | `unreadable-transmission-data` | `TransmissionData` cannot be parsed cleanly when saving to a different directory. | | `corrupt-page-parity` | A `Formats/*`, `Global/*`, or `Partitions/*` stream fails Hamming page parity verification. | | `missing-schema` | `Formats/Latest` is missing, unreadable, or cannot be parsed. | diff --git a/src/RvtIO/Writing/RvtWriter.cs b/src/RvtIO/Writing/RvtWriter.cs index 66c67eb..334ee78 100644 --- a/src/RvtIO/Writing/RvtWriter.cs +++ b/src/RvtIO/Writing/RvtWriter.cs @@ -247,7 +247,7 @@ private sealed record Inspection( RvtWriteCapabilities Capabilities, PartitionFraming.Tags? FramingTags, IReadOnlyDictionary CanonicalStreamPaths, - bool HasPartitionRevitLinks, + IReadOnlySet PartitionRevitLinkIds, bool HasPartitionPointClouds); private static RvtWriteCapabilities InspectContainer(string containerPath, string? pathHint = null) @@ -384,7 +384,7 @@ private static Inspection InspectRoot(RootStorage root, string sourcePath) RvtOperationSupport saveAsSupport; RvtOperationSupport pagedSupport; RvtOperationSupport partitionSupport; - bool hasPartitionRevitLinks = false; + IReadOnlySet partitionRevitLinkIds = new HashSet(); bool hasPartitionPointClouds = false; if (baseFailure is { } refusal) @@ -405,7 +405,7 @@ private static Inspection InspectRoot(RootStorage root, string sourcePath) idWidth, rvtLinkClassTag, pointCloudClassTag, - out hasPartitionRevitLinks, + out partitionRevitLinkIds, out hasPartitionPointClouds); } @@ -434,7 +434,7 @@ private static Inspection InspectRoot(RootStorage root, string sourcePath) capabilities, framingTags, canonicalPaths, - hasPartitionRevitLinks, + partitionRevitLinkIds, hasPartitionPointClouds); } @@ -516,10 +516,11 @@ private static RvtOperationSupport EvaluatePartitionEligibility( int? idWidth, ushort? rvtLinkClassTag, ushort? pointCloudClassTag, - out bool hasPartitionRevitLinks, + out IReadOnlySet partitionRevitLinkIds, out bool hasPartitionPointClouds) { - hasPartitionRevitLinks = false; + var revitLinkIds = new HashSet(); + partitionRevitLinkIds = revitLinkIds; hasPartitionPointClouds = false; if (framingTags is null) @@ -531,7 +532,6 @@ private static RvtOperationSupport EvaluatePartitionEligibility( var adler = new Adler32(); ulong terminator = idWidth.Value == 8 ? ulong.MaxValue : uint.MaxValue; - bool sawRevitLink = false; bool sawPointCloud = false; foreach (var entry in partitionEntries) { @@ -546,7 +546,7 @@ private static RvtOperationSupport EvaluatePartitionEligibility( if (record.Block == 0 && record.Channel == 102 && record.Body.Length >= 2) { ushort classTag = BinaryPrimitives.ReadUInt16LittleEndian(record.Body.Span); - if (rvtLinkClassTag == classTag) sawRevitLink = true; + if (rvtLinkClassTag == classTag) revitLinkIds.Add(record.Owner); if (pointCloudClassTag == classTag) sawPointCloud = true; } if (record.Channel == 101) return; @@ -560,7 +560,6 @@ private static RvtOperationSupport EvaluatePartitionEligibility( } }, out string? reason, out _); - hasPartitionRevitLinks = sawRevitLink; hasPartitionPointClouds = sawPointCloud; if (!ok) @@ -609,7 +608,7 @@ private static void ValidateExternalReferenceRelocation( if (!inspection.CanonicalStreamPaths.TryGetValue("TransmissionData", out string? tdPath)) { - if (inspection.HasPartitionRevitLinks && !sameDirectory) + if (inspection.PartitionRevitLinkIds.Count > 0 && !sameDirectory) { throw new RvtWriteRefusedException( "missing-metadata", @@ -630,6 +629,22 @@ private static void ValidateExternalReferenceRelocation( return; } + if (!sameDirectory) + { + // every RvtLinkSymbol needs its own reference entry: without one the target of the link cannot be checked + var referencedIds = new HashSet(); + foreach (var entry in references) + if (ulong.TryParse(entry.ElementId?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out ulong id)) + referencedIds.Add(id); + ulong[] unlisted = inspection.PartitionRevitLinkIds.Where(id => !referencedIds.Contains(id)).Order().ToArray(); + if (unlisted.Length > 0) + { + throw new RvtWriteRefusedException( + "missing-metadata", + $"TransmissionData lists no external reference for RvtLinkSymbol element(s) {string.Join(", ", unlisted)} in Partitions/, so their link targets cannot be verified across directories."); + } + } + foreach (var entry in references) { CheckRelativeReferenceTarget( @@ -668,22 +683,30 @@ private static void CheckRelativeReferenceTarget( return; string? trimmedType = pathType?.Trim(); - bool isExplicitRelative = string.Equals(trimmedType, "Relative", StringComparison.OrdinalIgnoreCase); - bool isExplicitNonRelative = !string.IsNullOrEmpty(trimmedType) - && (string.Equals(trimmedType, "Absolute", StringComparison.OrdinalIgnoreCase) - || string.Equals(trimmedType, "Server", StringComparison.OrdinalIgnoreCase) - || string.Equals(trimmedType, "Content", StringComparison.OrdinalIgnoreCase) - || string.Equals(trimmedType, "Library", StringComparison.OrdinalIgnoreCase)); - - bool isImplicitRelative = !isExplicitNonRelative - && string.IsNullOrEmpty(trimmedType) + string elementSuffix = string.IsNullOrWhiteSpace(elementId) ? "" : $" (element {elementId.Trim()})"; + // Revit 2022 writes "Relative to Central Model" for a link whose Revit API path type is Relative + bool isExplicitRelative = string.Equals(trimmedType, "Relative", StringComparison.OrdinalIgnoreCase) + || string.Equals(trimmedType, "Relative to Central Model", StringComparison.OrdinalIgnoreCase); + bool isExplicitNonRelative = string.Equals(trimmedType, "Absolute", StringComparison.OrdinalIgnoreCase) + || string.Equals(trimmedType, "Server", StringComparison.OrdinalIgnoreCase) + || string.Equals(trimmedType, "Content", StringComparison.OrdinalIgnoreCase) + || string.Equals(trimmedType, "Library", StringComparison.OrdinalIgnoreCase); + + if (!string.IsNullOrEmpty(trimmedType) && !isExplicitRelative && !isExplicitNonRelative) + { + if (sameDirectory) return; + throw new RvtWriteRefusedException( + "relative-reference-relocation", + $"Saving to a different directory cannot verify external reference{elementSuffix} with the unrecognised path type '{trimmedType}'."); + } + + bool isImplicitRelative = string.IsNullOrEmpty(trimmedType) && !string.IsNullOrWhiteSpace(rawPath) && !IsRootedOrUriLikePath(rawPath); if (!isExplicitRelative && !isImplicitRelative) return; - string elementSuffix = string.IsNullOrWhiteSpace(elementId) ? "" : $" (element {elementId.Trim()})"; if (string.IsNullOrWhiteSpace(rawPath)) { if (!sameDirectory) diff --git a/tests/RvtIO.Tests/RvtWriterTests.cs b/tests/RvtIO.Tests/RvtWriterTests.cs index 40ff2a0..6444dfc 100644 --- a/tests/RvtIO.Tests/RvtWriterTests.cs +++ b/tests/RvtIO.Tests/RvtWriterTests.cs @@ -792,8 +792,11 @@ private static string ResolveExternalReferenceTarget(string hostPath) return Path.GetFullPath(Path.Combine(hostDir, normalized)); } - [Fact] - public void SaveAs_AndRewrite_WithValidRelativeLink_PreserveTargetInSameDirectory_AndRefuseDifferentDirectory() + // "Relative to Central Model" is what Revit 2022 writes for a relative link + [Theory] + [InlineData("Relative to Central Model")] + [InlineData("Relative")] + public void SaveAs_AndRewrite_WithValidRelativeLink_PreserveTargetInSameDirectory_AndRefuseDifferentDirectory(string pathType) { string linksDir = Path.Combine(_folder, "links"); string inputDir = Path.Combine(_folder, "input"); @@ -821,7 +824,7 @@ public void SaveAs_AndRewrite_WithValidRelativeLink_PreserveTargetInSameDirector SchemaRelease = 2022, SavedRelease = 2022, LinkedPath = @"..\links\linked.rvt", - LinkedPathType = "Relative", + LinkedPathType = pathType, LinkedLoadState = "Loaded", IncludeRevitLinkRecord = true, }); @@ -978,4 +981,63 @@ public void SaveAs_AndRewrite_ToDifferentDirectory_SucceedForNonRelativeLinks_An Assert.Equal("missing-metadata", exStripped.Kind); Assert.False(File.Exists(nestedStrippedCopy)); } + + [Fact] + public void SaveAs_ToDifferentDirectory_RefusesALinkThatTransmissionDataDoesNotList() + { + string inputDir = Path.Combine(_folder, "input"); + string nestedDir = Path.Combine(_folder, "output", "nested"); + Directory.CreateDirectory(inputDir); + Directory.CreateDirectory(nestedDir); + const string keynoteXml = "502Keynote TableAbsoluteNot Found"; + byte[] Xml(string body) + { + string xml = $"{body}"; + byte[] data = new byte[4 + xml.Length * 2]; + BinaryPrimitives.WriteInt32LittleEndian(data, xml.Length); + Encoding.Unicode.GetBytes(xml).CopyTo(data.AsSpan(4)); + return data; + } + + // a valid but empty TransmissionData, and one that lists only another reference, while Partitions/1 holds RvtLinkSymbol 501 + foreach (var (name, td) in new[] { ("empty", Xml("")), ("other", Xml(keynoteXml)) }) + { + string host = CreateSyntheticRevitFile( + Path.Combine(inputDir, $"host_{name}.rvt"), + new SyntheticFileOptions { SchemaRelease = 2022, SavedRelease = 2022, RawTransmissionDataBytes = td, IncludeRevitLinkRecord = true }); + + RvtWriter.SaveAs(host, Path.Combine(inputDir, $"host_{name}_copy.rvt")); + string nested = Path.Combine(nestedDir, $"host_{name}.rvt"); + var ex = Assert.Throws(() => RvtWriter.SaveAs(host, nested)); + Assert.Equal("missing-metadata", ex.Kind); + Assert.Contains("501", ex.Message); + Assert.False(File.Exists(nested)); + } + } + + [Fact] + public void SaveAs_ToDifferentDirectory_RefusesAnUnrecognisedPathType() + { + string inputDir = Path.Combine(_folder, "input"); + string nestedDir = Path.Combine(_folder, "output", "nested"); + Directory.CreateDirectory(inputDir); + Directory.CreateDirectory(nestedDir); + string host = CreateSyntheticRevitFile( + Path.Combine(inputDir, "host.rvt"), + new SyntheticFileOptions + { + SchemaRelease = 2022, + SavedRelease = 2022, + LinkedPath = @"..\links\linked.rvt", + LinkedPathType = "Relative to Somewhere Else", + IncludeRevitLinkRecord = true, + }); + + RvtWriter.SaveAs(host, Path.Combine(inputDir, "host_copy.rvt")); + string nested = Path.Combine(nestedDir, "host.rvt"); + var ex = Assert.Throws(() => RvtWriter.SaveAs(host, nested)); + Assert.Equal("relative-reference-relocation", ex.Kind); + Assert.Contains("Relative to Somewhere Else", ex.Message); + Assert.False(File.Exists(nested)); + } }