diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f485525..c3a9ade 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-latest] + os: [windows-latest, ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 @@ -31,14 +31,3 @@ jobs: name: packages-${{ matrix.os }} path: artifacts/*.nupkg if-no-files-found: error - - build-linux: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - dotnet-version: | - 8.0.x - - run: dotnet build src/RvtIO.Cli/RvtIO.Cli.csproj -c Release diff --git a/CHANGELOG.md b/CHANGELOG.md index 87b09a2..b62832f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,8 @@ API between minor releases. - IFC export, current profile: the room of a family instance that stores none is looked up at one test point per instance instead of at the centre of its box. An instance of a family with a room calculation point is tested at that point, placed with the instance, whatever its host. Otherwise a wall-hosted instance is tested one foot outside the side face of its wall that it faces (the face with the higher handle, the lower one when the instance is flipped across the wall), a work-plane-based instance one foot from its placed origin along its own Z axis, an instance of a hosted family on a floor or a ceiling one foot from its placed origin against its own Z axis (along it when the instance is flipped in Y), and any other instance at the centre of the box of its own geometry (the placed box of its symbol when it has none), moved up to half a foot towards its level. Only the rooms of the phase the instance was created in are considered (the phase id as the room record stores it when the room has no validated phase, 0 included), and the first room by id whose vertical extent and solid hold the point wins; a point on the boundary is inside. A room stored with the instance is kept, as before. Measured against the reference by the container of the products on nine sample files: 376 containers now agree (239, 31, 31, 29, 16, 14, 12, 3 and 1), and no slot regressed in eight of the files. In the ninth, 10 instances (4 building element proxies and 6 furnishing elements) moved from the storey to a space where the reference keeps the storey. They were then compared with the room Revit itself reports: the 4 proxies have no host and Revit gives them that room (the reference moves their test point to a level elevation counted from the project base point, which is 100 mm off in that model), so they stay in the space; the 6 furnishing elements are hosted by a floor and Revit gives them no room, which the floor rule above now follows. With the floor rule that file has 6 more containers that agree with the reference and 1 that no longer does (a floor-hosted furnishing element the reference puts in a space and Revit does not); the IFC of five other of the nine files does not change, the remaining three were not exported again. Three more rules come from the same comparison with Revit. Of the 504 ceiling-hosted fixtures of that model Revit gives the room under the ceiling to the 396 that are not flipped and no room to the 108 that are flipped in Y, which the ceiling rule above follows; the reference puts all of them in a space. Two furnishing elements whose symbol stores a box wider than its geometry are in the room that holds the centre of the geometry, where the centre of the stored box lies 1.4 mm outside it; the reference keeps them in the storey. An instance of a family with a room calculation point is in the room that holds that point, for every kind of host: the point is read from the symbol and agrees with the one Revit reports for the 453 instances of that model that have one, and 2 level-based fixtures that stood in a space are in the storey, as in Revit. These three rules change 112 containers of that file (108, 2 and 2), which now differ from the reference; the IFC of five other files and the JSON output of four do not change. Against the room Revit reports for the family instances of that model in its Revit 2022 version, measured again on the current main branch, the container agrees for 1408 of 1530 contained instances (1274 on the main branch, 1291 for the reference); in four synthetic files made for the floor, the mirrored, the calculation point and the boundary cases for 80 of 80, 8 of 8, 32 of 32 and 76 of 78. Not followed: Revit counts a point 0.05 mm outside the plan boundary of a room as inside (the 2 of 78). An instance hosted by an arc wall gets no wall-side point and is tested as an instance without a host. The JSON output of all measured files is unchanged; the IFC of a tenth, synthetic file is identical to before. The legacy profile was not measured. - Adds `rvtio coverage [--report report.json] [--top n]`, which counts how many records of the partition streams of one file the class schema of that file walks to their last byte, per block kind (project or loaded family), channel and class, with the reason and the field at which a walk stops, and lists every stream of the file with its kind and whether it frames or decodes. The count is strict: no byte scan, no resynchronisation, no recovery; a partition stream whose framing does not hold adds no record and is named in the report. It writes nothing but the JSON report (`rvtio-coverage/1`). Exit code 0 when the file was measured, 1 for a wrong command line, 2 when the file cannot be read. See [the coverage report](docs/coverage-report.md). +- Adds `--ifc-timestamp ` (an ISO-8601 timestamp or Unix epoch seconds; `IfcExporter.Timestamp` in the library) to write a fixed UTC timestamp into the STEP `FILE_NAME` header so repeated IFC exports of the same input are byte-for-byte identical, and adds CI-runnable corruption and fuzz tests over synthetic `.rvt` containers and inner streams with execution-time and memory-allocation bounds alongside full Windows, Linux and macOS CI test matrices. (Closes #39) + ### Changed - Reading: in a file without its own class schema (Revit 2020 and 2022) whose partition streams are read by their framing, the root object of each element, from which the reader takes the class of the element and the stair and railing facts it keeps per root, is the channel-102 record of the element in the project's own block, in its current copy. Before, the stream walk counted every block it met for the id, including copies of the id in the documents of loaded families, and missed roots it did not reach; an element with two counted roots or none had no class. On 23 such files 59 elements in 7 files now get their class (a schedule, a view, a wall, a rebar, a sketch plane and others): in the compact JSON they carry the name of their class and their usual properties instead of an empty name, and the export report names their class instead of `unknown`; the IFC of all 23 files is unchanged. Refs #47. @@ -66,6 +68,7 @@ API between minor releases. ### Fixed +- Geometry meshing: scales the pole Jacobian degeneracy threshold in `RevolvedMesher.NormalAt` by the profile tangent magnitude so 1-ULP trigonometric differences at sphere poles fall through to the pole limit normal on Linux and macOS as on Windows instead of leaving duplicate pole vertices unmerged. (Refs #39) - IFC export, current profile: a project whose length format is "metres and centimetres" declares metres, and a project in US survey feet declares feet; both were written in millimetres before. The IFC export of Revit itself declares the same for these two formats. In a file of the Revit 2020 layout US survey feet are now read as a length format too (not checked on such a file). A project in any other format (centimetres, decimetres, inches) is still written in millimetres, with every length scaled to them, and the diagnostics now say that its stored unit has no unit family of its own instead of presenting millimetres as that unit. The README and the API reference name the rule and the default. Measured with Revit 2022 on one model under ten length formats, declared unit and the formats that give it: metres (metres; metres and centimetres), centimetres (centimetres), millimetres (millimetres), feet (feet; feet and fractional inches; US survey feet; decimetres), inches (inches; fractional inches). Four small files saved by Revit 2022 in metres and centimetres, US survey feet, centimetres and inches export as described. (Closes #36) - Export report: `export.skippedByClass` did not list every element the IFC export left out. Three exits of the product loop dropped a product without an entry: a product without bounds (`product-without-bounds`), one with invalid bounds (`invalid-bounds`) and one whose box is under 1 cm in every direction (`bounds-under-one-centimetre`). A curtain wall or roof that is written after its parts and has no written part is now listed as `container-without-written-parts`, and the elements the export leaves out before that loop as `non-primary-design-option` and `outside-export-phase`. The list of reasons in docs/export-report.md gains these six and the four the exporter already wrote without naming them there (`export-to-ifc-flag-no`, `reveal-sweep`, `product-without-body`, `wall-without-solid`); a test keeps the list complete, and the documentation says that levels, rooms and the site topography are listed although their spatial entity is written. The IFC output does not change; the counts `considered` and `skipped` of a report grow by the elements that were not listed: on two samples measured, 1 product without bounds in one and 1137 elements of non-primary design options in the other, with the same `exported` count as before. New tests start the command line tool as a process on an input that is no compound file, on an empty one and on a compound file cut in half, and check exit code 2 and the failed report; exit code 3 for a reader limit is still tested in the library only. (Closes #37) - A `Global` or `Formats` stream whose gzip member has a corrupt deflate header is treated as a stream that does not decode: a class table damaged that way is reported as unreadable input (exit code 2 of `downgrade` and `coverage`) instead of ending in an unhandled exception. diff --git a/README.md b/README.md index 87064df..d398491 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Add the library to a .NET project: dotnet add package RvtIO ``` -The library targets .NET 8 and .NET 10. It uses managed code without native dependencies or Windows-only APIs and supports Windows, Linux and macOS. Tests currently run on Windows. +The library targets .NET 8 and .NET 10. It uses managed code without native dependencies or Windows-only APIs and is tested on Windows, Linux and macOS. ## Command line @@ -79,7 +79,7 @@ rvtio project.rvt project.ifc --ifc-opening-legacy-axes `--ifc-opening-host-axes`, the opt-in of the earlier release for the new reading, is still accepted and does nothing. In the library the option is `IfcExporter.OpeningQuantitiesSwapped`; `IfcExporter.OpeningQuantitiesInHostAxes` is kept and is the opposite of it. -Use `--ifc-revit-parameter-groups` for one property set per parameter group. A file saved in several increments can hold an element in more than one partition stream; the tool keeps only its current copy (every copy is kept when the file's increment tables cannot be decoded). Use `--keep-all-copies` to decode every copy instead (the earlier behavior; `--resolve-current-copies` is still accepted and does nothing). Run `rvtio --help` for all options. Unknown options exit with code 1; options starting with `--dev-` are not a stable interface. +Use `--ifc-timestamp ` (an ISO-8601 timestamp or Unix epoch seconds; `IfcExporter.Timestamp` in the library) to write a fixed UTC timestamp into the STEP `FILE_NAME` header so repeated exports of the same input are byte-for-byte identical. Use `--ifc-revit-parameter-groups` for one property set per parameter group. A file saved in several increments can hold an element in more than one partition stream; the tool keeps only its current copy (every copy is kept when the file's increment tables cannot be decoded). Use `--keep-all-copies` to decode every copy instead (the earlier behavior; `--resolve-current-copies` is still accepted and does nothing). Run `rvtio --help` for all options. Unknown options exit with code 1; options starting with `--dev-` are not a stable interface. ### Exit codes and the export report diff --git a/docs/public-api.md b/docs/public-api.md index d2e1319..ef7d4fd 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -36,7 +36,7 @@ extension and label helpers carry the ids and names stored in the file format. | Type | Use | |---|---| -| `IfcExporter` | Writes IFC from a `RevitFileInfo` (`Export`). Properties: `Profile`, `BaseQuantities`, `OpeningQuantitiesSwapped` (true writes the legacy Width and Height of door and window openings; default false), `OpeningQuantitiesInHostAxes` (the opposite of it, kept for compatibility), `ExportPhaseId`, `ExportAllPhases`, `RoomExportPhaseId`, `ExportRoomsOfAllPhases`, `RevitParameterGroups`, `SkipLog`, `ExportedProducts`, `DiagnosticOutput`. | +| `IfcExporter` | Writes IFC from a `RevitFileInfo` (`Export`). Properties: `Profile`, `BaseQuantities`, `OpeningQuantitiesSwapped` (true writes the legacy Width and Height of door and window openings; default false), `OpeningQuantitiesInHostAxes` (the opposite of it, kept for compatibility), `ExportPhaseId`, `ExportAllPhases`, `RoomExportPhaseId`, `ExportRoomsOfAllPhases`, `RevitParameterGroups`, `Timestamp`, `SkipLog`, `ExportedProducts`, `DiagnosticOutput`. | | `IfcProfile` | `Legacy` or `Current` (the default) output profile. | | `IfcUnitFamily` | The length unit family an export of the current profile declares, by the length format of the project: `Metre` for metres and for metres and centimetres, `Foot` for feet, feet and fractional inches and US survey feet, `Millimetre` for millimetres, for every other format and for an unreadable one. The legacy profile always declares millimetres. | | `CompactJsonExporter` | `Export(rvtPath, outputPath, readerOptions, options)` reads a file and writes the compact JSON. | diff --git a/src/RvtIO.Cli/ExportCommand.cs b/src/RvtIO.Cli/ExportCommand.cs index a780626..08afc0a 100644 --- a/src/RvtIO.Cli/ExportCommand.cs +++ b/src/RvtIO.Cli/ExportCommand.cs @@ -11,12 +11,12 @@ internal static partial class Program /// The values of the command line that decide the export. private sealed record ExportArguments(string[] Args, string RvtPath, string OutputPath, string? ExternalDumpPath, string? ExternalDumpClassPrefix, IfcProfile Profile, bool IsIfcOutput, string? ExportPhaseOption, string? RoomPhaseOption, bool IncludeIfcRevitParameters, bool RevitParameterGroups, bool BaseQuantities, - string JsonClassPrefix, bool CompactJson, bool OpeningLegacyAxes); + string JsonClassPrefix, bool CompactJson, bool OpeningLegacyAxes, DateTimeOffset? IfcTimestamp); // The export: reads the file and writes the IFC, the compact JSON or the full JSON dump. private static int Export(ExportArguments arguments) { - var (args, rvtPath, outputPath, externalDumpPath, externalDumpClassPrefix, profile, isIfcOutput, exportPhaseOption, roomPhaseOption, includeIfcRevitParameters, revitParameterGroups, baseQuantities, jsonClassPrefix, compactJson, openingLegacyAxes) = arguments; + var (args, rvtPath, outputPath, externalDumpPath, externalDumpClassPrefix, profile, isIfcOutput, exportPhaseOption, roomPhaseOption, includeIfcRevitParameters, revitParameterGroups, baseQuantities, jsonClassPrefix, compactJson, openingLegacyAxes, ifcTimestamp) = arguments; WarnIfRevitLocalization(args); var options = new RvtReaderOptions { @@ -114,7 +114,7 @@ private static int Export(ExportArguments arguments) RvtIO.Parsing.ValidatedElementNames.Apply(rvtInfo, reader.LastParsingState ?? throw new InvalidOperationException("The reader kept no parsing state.")); var skipLog = new SkipLog(); - var exporter = new IfcExporter(Console.Error, includeIfcRevitParameters) { SkipLog = skipLog, ExportPhaseId = exportPhase, ExportAllPhases = exportPhaseOption == "all", RoomExportPhaseId = roomPhase, ExportRoomsOfAllPhases = roomPhaseOption == "all", RevitParameterGroups = revitParameterGroups, Profile = profile, BaseQuantities = baseQuantities, OpeningQuantitiesSwapped = openingLegacyAxes }; + var exporter = new IfcExporter(Console.Error, includeIfcRevitParameters) { SkipLog = skipLog, ExportPhaseId = exportPhase, ExportAllPhases = exportPhaseOption == "all", RoomExportPhaseId = roomPhase, ExportRoomsOfAllPhases = roomPhaseOption == "all", RevitParameterGroups = revitParameterGroups, Profile = profile, BaseQuantities = baseQuantities, OpeningQuantitiesSwapped = openingLegacyAxes, Timestamp = ifcTimestamp }; var swExport = System.Diagnostics.Stopwatch.StartNew(); exporter.Export(rvtInfo, outputPath); Console.Error.WriteLine($"Timing: export {swExport.Elapsed.TotalSeconds:F1}s"); diff --git a/src/RvtIO.Cli/IfcTimestampOption.cs b/src/RvtIO.Cli/IfcTimestampOption.cs new file mode 100644 index 0000000..5e185e6 --- /dev/null +++ b/src/RvtIO.Cli/IfcTimestampOption.cs @@ -0,0 +1,74 @@ +using System.Globalization; + +namespace RvtIO.Cli; + +/// The --ifc-timestamp <timestamp> option: fixes the UTC timestamp written into the STEP FILE_NAME header so +/// repeated IFC exports of the same input are byte-for-byte identical. +internal static class IfcTimestampOption +{ + public const string Name = "--ifc-timestamp"; + + private static readonly string[] ExactFormats = + [ + "yyyy-MM-dd'T'HH:mm:ssK", + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd HH:mm:ssK", + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd", + ]; + + /// Reads --ifc-timestamp from . Without the option is null. + /// Accepts ISO-8601 timestamps (UTC when no offset is given) or non-negative Unix epoch seconds. Returns false with + /// set when the option has no value, is given twice or cannot be parsed. + public static bool TryParse(string[] args, out DateTimeOffset? timestamp, out string? error) + { + timestamp = null; + error = null; + bool seen = false; + for (int i = 0; i < args.Length; i++) + { + if (args[i] != Name) continue; + if (seen || i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal)) + { + error = $"{Name} requires one ISO-8601 timestamp (for example 2026-01-15T12:30:45Z) or Unix epoch seconds."; + timestamp = null; + return false; + } + seen = true; + string raw = args[++i]; + if (!TryParseValue(raw, out var parsed)) + { + error = $"{Name} requires one ISO-8601 timestamp (for example 2026-01-15T12:30:45Z) or Unix epoch seconds."; + timestamp = null; + return false; + } + timestamp = parsed; + } + return true; + } + + private static bool TryParseValue(string raw, out DateTimeOffset result) + { + if (string.IsNullOrWhiteSpace(raw)) + { + result = default; + return false; + } + if (long.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out long epochSeconds)) + { + try + { + result = DateTimeOffset.FromUnixTimeSeconds(epochSeconds); + return true; + } + catch (ArgumentOutOfRangeException) + { + result = default; + return false; + } + } + return DateTimeOffset.TryParseExact(raw, ExactFormats, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out result); + } +} diff --git a/src/RvtIO.Cli/Program.cs b/src/RvtIO.Cli/Program.cs index 34cbe26..48d7589 100644 --- a/src/RvtIO.Cli/Program.cs +++ b/src/RvtIO.Cli/Program.cs @@ -29,6 +29,7 @@ internal static partial class Program --ifc-room-phase says otherwise, rooms of other phases are left out. Default: the last phase for the current profile, every phase for the legacy profile (IFC only) --ifc-room-phase rooms to export: the last phase, every phase or one native phase id (IFC only) + --ifc-timestamp UTC timestamp for the STEP FILE_NAME header (ISO-8601 or Unix epoch seconds; IFC only) --revit-localization deprecated, ignored: the system family names are built in --r22-layout read the Revit 2022 layout for a JSON export too (an IFC export does it by default) --no-r22-layout keep the 64-bit schema path alone in an IFC export @@ -89,7 +90,7 @@ Developer options (measurement and comparison dumps, not a stable interface): // Options that take one value, and options that take two; every other option starting with "--" is a switch. private static readonly HashSet OneValueOptions = [ - "--ifc-profile", "--ifc-phase", "--ifc-room-phase", "--json-class-prefix", "--revit-localization", "--inspect", "--diag-dump", "--find-str", + "--ifc-profile", "--ifc-phase", "--ifc-room-phase", RvtIO.Cli.IfcTimestampOption.Name, "--json-class-prefix", "--revit-localization", "--inspect", "--diag-dump", "--find-str", "--sketch-owner-dump", "--dev-external-dump", "--dev-external-dump-prefix", "--dev-candidates", "--dev-trace", "--dev-facts", "--dev-fact-names", "--dev-user-params", "--dev-collisions", "--dev-solids", "--dev-native", "--class-census", "--episode-check", "--test-decoders", "--dump-chunks", "--block-dump", "--search-global", "--hex-search", "--chunk-dump", "--block-scan", "--rec-dump", @@ -195,6 +196,11 @@ private static int Main(string[] args) Console.Error.WriteLine(jsonClassPrefixError); return 1; } + if (!RvtIO.Cli.IfcTimestampOption.TryParse(args, out DateTimeOffset? ifcTimestamp, out string? ifcTimestampError)) + { + Console.Error.WriteLine(ifcTimestampError); + return 1; + } // A JSON output is the compact data export (the properties collection); --json-full writes the full legacy dump // (every parameter, geometry, all fields). --compact-json is kept as the explicit spelling of the default. bool jsonFull = args.Contains("--json-full"); @@ -220,6 +226,11 @@ private static int Main(string[] args) Console.Error.WriteLine("--ifc-room-phase requires IFC output."); return 1; } + if (ifcTimestamp != null && !isIfcOutput) + { + Console.Error.WriteLine("--ifc-timestamp requires IFC output."); + return 1; + } for (int i = 0; i < args.Length - 1; i++) { if (args[i] == "--dev-external-dump") externalDumpPath = args[i + 1]; @@ -286,7 +297,7 @@ private static int Main(string[] args) if (listCategoryOutPath != null) return ListCategory(rvtPath, listCategoryId, listCategoryOutPath); return Export(new ExportArguments(args, rvtPath, outputPath, externalDumpPath, externalDumpClassPrefix, profile, isIfcOutput, - exportPhaseOption, roomPhaseOption, includeIfcRevitParameters, revitParameterGroups, baseQuantities, jsonClassPrefix, compactJson, openingLegacyAxes)); + exportPhaseOption, roomPhaseOption, includeIfcRevitParameters, revitParameterGroups, baseQuantities, jsonClassPrefix, compactJson, openingLegacyAxes, ifcTimestamp)); } /// The value after the last occurrence of , null when the option is absent or ends the arguments. diff --git a/src/RvtIO/Geometry/Meshing/RevolvedMesher.cs b/src/RvtIO/Geometry/Meshing/RevolvedMesher.cs index a6a536b..f022261 100644 --- a/src/RvtIO/Geometry/Meshing/RevolvedMesher.cs +++ b/src/RvtIO/Geometry/Meshing/RevolvedMesher.cs @@ -401,7 +401,7 @@ public int Add(Pt p, double[] pos, double[] n) /// Unit surface normal (sign is irrelevant, only differences are compared), the limit value at a pole. private static double[] NormalAt(RevolvedEval s, double[] env, double u, double v) { - double[] Jac(double uu, double vv) + (double[] Cross, double PuNorm) Jac(double uu, double vv) { double h = 1e-6 * Math.Max(Math.Abs(env[1] - env[0]), 1e-12); var a = s.Pt(uu + h, vv); var b = s.Pt(uu - h, vv); @@ -409,14 +409,14 @@ double[] Jac(double uu, double vv) double hv = 1e-6 * Math.Max(Math.Abs(env[3] - env[2]), 1e-12); var c = s.Pt(uu, vv + hv); var d = s.Pt(uu, vv - hv); var pv = new[] { (c[0] - d[0]) / (2 * hv), (c[1] - d[1]) / (2 * hv), (c[2] - d[2]) / (2 * hv) }; - return Cross(pu, pv); + return (Cross(pu, pv), Norm(pu[0], pu[1], pu[2])); } - var j = Jac(u, v); + var (j, puNorm) = Jac(u, v); double n = Norm(j[0], j[1], j[2]); - if (n > 1e-9) return new[] { j[0] / n, j[1] / n, j[2] / n }; + if (n > 1e-7 * Math.Max(puNorm, 1.0)) return new[] { j[0] / n, j[1] / n, j[2] / n }; double dd = 1e-4 * (env[1] - env[0]); double uu2 = Math.Abs(u - env[0]) < Math.Abs(u - env[1]) ? u + dd : u - dd; - j = Jac(uu2, v); + (j, _) = Jac(uu2, v); double m = Math.Max(Norm(j[0], j[1], j[2]), 1e-300); return new[] { j[0] / m, j[1] / m, j[2] / m }; } diff --git a/src/RvtIO/Ifc/IfcExporter.cs b/src/RvtIO/Ifc/IfcExporter.cs index 8321800..7d5f93e 100644 --- a/src/RvtIO/Ifc/IfcExporter.cs +++ b/src/RvtIO/Ifc/IfcExporter.cs @@ -333,6 +333,9 @@ private void ValidateRoomExportPhase(RevitFileInfo info) /// Receives the elements the export leaves out, with the class and the reason (see ). Null to collect nothing. public SkipLog? SkipLog { get; init; } + /// UTC timestamp written into the STEP FILE_NAME header (yyyy-MM-ddTHH:mm:ss). Null (the default) uses the current UTC time. + public DateTimeOffset? Timestamp { get; init; } + /// Products written by the last export, as counted in the "IFC export:" diagnostic line. public int ExportedProducts { get; private set; } @@ -6644,7 +6647,7 @@ void AddWallConnection(int relating, int related, string relatingType, string re } if (CurrentProfile) ApplyCurrentOpeningOrder(w, openingOrderKeys); w.RewriteArguments(IfcLengthUnit.Converter(_unitFamily, CurrentProfile)); - w.WriteTo(output, headerFileName, "IFC2X3", desc, appName, headerOrganization); + w.WriteTo(output, headerFileName, "IFC2X3", desc, appName, headerOrganization, Timestamp); ExportedProducts = exportedCount; _diag?.WriteLine($"IFC export: {exportedCount} elements exported, {skippedCount} skipped (faces:{withFaces}, bbox:{withBbox}, no-geom:{noGeom})"); diff --git a/src/RvtIO/Ifc/StepWriter.cs b/src/RvtIO/Ifc/StepWriter.cs index b4be962..b255519 100644 --- a/src/RvtIO/Ifc/StepWriter.cs +++ b/src/RvtIO/Ifc/StepWriter.cs @@ -94,9 +94,9 @@ public void AddWithId(int id, string type, string args) /// Write complete IFC STEP file to stream. public void WriteTo(TextWriter w, string fileName, string schema, string description, string application, - string organization = "") + string organization = "", DateTimeOffset? timestamp = null) { - var now = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture); + var now = (timestamp?.UtcDateTime ?? DateTime.UtcNow).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture); w.WriteLine("ISO-10303-21;"); w.WriteLine("HEADER;"); diff --git a/tests/RvtIO.Tests/CliExitCodeTests.cs b/tests/RvtIO.Tests/CliExitCodeTests.cs index f91cd85..18c7164 100644 --- a/tests/RvtIO.Tests/CliExitCodeTests.cs +++ b/tests/RvtIO.Tests/CliExitCodeTests.cs @@ -9,9 +9,15 @@ namespace RvtIO.Tests; /// The tool is started as a process of its own, from the copy the test project builds next to the tests. public class CliExitCodeTests { + internal static string DotnetExecutable => + Environment.ProcessPath is { } processPath + && string.Equals(Path.GetFileNameWithoutExtension(processPath), "dotnet", StringComparison.OrdinalIgnoreCase) + ? processPath + : "dotnet"; + private static int Run(params string[] arguments) { - var start = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + var start = new ProcessStartInfo(DotnetExecutable) { 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)!; diff --git a/tests/RvtIO.Tests/CorruptionFuzzTests.cs b/tests/RvtIO.Tests/CorruptionFuzzTests.cs new file mode 100644 index 0000000..7c2e8db --- /dev/null +++ b/tests/RvtIO.Tests/CorruptionFuzzTests.cs @@ -0,0 +1,278 @@ +using System.Diagnostics; +using OpenMcdf; +using RvtIO.Parsing; +using RvtIO.Writing; +using Xunit; + +namespace RvtIO.Tests; + +/// CI-runnable corruption and fuzz tests over synthetic .rvt compound files and inner streams, enforcing bounded +/// execution time and bounded memory allocations without private fixtures. +[Collection("ElemTableStaticMaps")] +public sealed class CorruptionFuzzTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "rvtio-fuzz-" + Guid.NewGuid().ToString("N")); + + public CorruptionFuzzTests() => Directory.CreateDirectory(_directory); + + public void Dispose() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + + [Fact] + public void SyntheticContainerTruncations_AreRejectedByVerifyAndReaderWithinBounds() + { + byte[] validFile = BuildValidSyntheticRvtBytes(); + Assert.True(validFile.Length > 4096); + + int[] cutPoints = + [ + 0, 1, 7, 8, 64, 511, 512, 513, 1024, 1536, 2048, + validFile.Length / 4, validFile.Length / 2, (validFile.Length * 3) / 4, + validFile.Length - 512, validFile.Length - 1, + ]; + + string targetPath = Path.Combine(_directory, "truncated.rvt"); + var totalWatch = Stopwatch.StartNew(); + long baselineHeap = GC.GetTotalMemory(forceFullCollection: true); + + foreach (int cut in cutPoints.Where(c => c >= 0 && c < validFile.Length).Distinct()) + { + File.WriteAllBytes(targetPath, validFile[..cut]); + + long allocBefore = GC.GetTotalAllocatedBytes(precise: false); + var iterWatch = Stopwatch.StartNew(); + + Exception? verifyEx = Record.Exception(() => RvtContainerCheck.Verify(targetPath)); + if (verifyEx != null) + { + Assert.True(verifyEx is RvtInputException or RvtLimitException, + $"Unexpected exception from Verify at cut={cut}: {verifyEx.GetType().FullName}"); + } + else + { + // OpenMcdf V3 allocates a 128-entry trailing FAT sector at the end of a small file, so only cuts in + // trailing unreferenced FAT entries can still satisfy every declared stream length. + Assert.True(cut > validFile.Length / 2, $"Expected Verify to reject cut={cut} (length={validFile.Length})."); + } + + try + { + _ = new RvtFileReader().Read(targetPath); + } + catch (Exception readEx) + { + Assert.True(readEx is RvtInputException or RvtLimitException, + $"Unexpected exception from Read at cut={cut}: {readEx.GetType().FullName}"); + } + + long allocDelta = GC.GetTotalAllocatedBytes(precise: false) - allocBefore; + Assert.True(iterWatch.ElapsedMilliseconds < 5000, $"Truncation cut={cut} took {iterWatch.ElapsedMilliseconds} ms."); + Assert.True(allocDelta < 64L * 1024 * 1024, $"Truncation cut={cut} allocated {allocDelta} bytes."); + } + + long retainedGrowth = GC.GetTotalMemory(forceFullCollection: true) - baselineHeap; + Assert.True(retainedGrowth < 64L * 1024 * 1024, $"Retained heap grew by {retainedGrowth} bytes."); + Assert.True(totalWatch.ElapsedMilliseconds < 20_000, $"Container truncation suite took {totalWatch.ElapsedMilliseconds} ms."); + } + + [Fact] + public void SyntheticContainerBitFlipsZeroRangesAndMaxUintOverwrites_StayWithinTimeAndMemoryBounds() + { + byte[] validFile = BuildValidSyntheticRvtBytes(); + string targetPath = Path.Combine(_directory, "mutated-container.rvt"); + var random = new Random(39_2026); + var totalWatch = Stopwatch.StartNew(); + long baselineHeap = GC.GetTotalMemory(forceFullCollection: true); + + for (int i = 0; i < 120; i++) + { + byte[] mutated = (byte[])validFile.Clone(); + switch (i % 4) + { + case 0: + // Corrupt CFB header / DIFAT / first sector + int headerPos = random.Next(0, Math.Min(512, mutated.Length)); + mutated[headerPos] ^= (byte)(1 + random.Next(255)); + break; + case 1: + // Zero out a contiguous range anywhere in the container + int start = random.Next(0, mutated.Length); + int len = random.Next(1, Math.Min(1024, mutated.Length - start + 1)); + Array.Clear(mutated, start, len); + break; + case 2: + // Overwrite 4 bytes with 0xFFFFFFFF (uint.MaxValue / CFB ENDOFCHAIN / runaway count) + int maxPos = random.Next(0, mutated.Length - 4); + BitConverter.GetBytes(uint.MaxValue).CopyTo(mutated, maxPos); + break; + default: + // Multi-byte random bit flips across FAT, directory, and stream sectors + for (int k = 0; k < 6; k++) + mutated[random.Next(mutated.Length)] ^= (byte)(1 + random.Next(255)); + break; + } + + File.WriteAllBytes(targetPath, mutated); + long allocBefore = GC.GetTotalAllocatedBytes(precise: false); + var iterWatch = Stopwatch.StartNew(); + + var diagnostics = new RvtReadDiagnostics(); + try + { + _ = new RvtFileReader(new RvtReaderOptions { Diagnostics = diagnostics }).Read(targetPath); + } + catch (Exception ex) + { + Assert.False(ex is OutOfMemoryException, $"Iteration {i} threw OutOfMemoryException."); + Assert.True(ex is RvtInputException or RvtLimitException, + $"Iteration {i} threw unexpected {ex.GetType().FullName}: {ex.Message}"); + } + + long allocDelta = GC.GetTotalAllocatedBytes(precise: false) - allocBefore; + Assert.True(iterWatch.ElapsedMilliseconds < 5000, $"Iteration {i} took {iterWatch.ElapsedMilliseconds} ms."); + Assert.True(allocDelta < 128L * 1024 * 1024, $"Iteration {i} allocated {allocDelta} bytes."); + } + + long retainedGrowth = GC.GetTotalMemory(forceFullCollection: true) - baselineHeap; + Assert.True(retainedGrowth < 64L * 1024 * 1024, $"Retained heap grew by {retainedGrowth} bytes."); + Assert.True(totalWatch.ElapsedMilliseconds < 30_000, $"Container fuzz suite took {totalWatch.ElapsedMilliseconds} ms."); + } + + [Fact] + public void SyntheticInnerStreamMutationsAndRunawayLengths_StayWithinTimeAndMemoryBounds() + { + var (elemTableStream, historyStream, formatsStream, partition1Stream, partition2Stream) = BuildValidStreams(); + string targetPath = Path.Combine(_directory, "mutated-streams.rvt"); + var random = new Random(9001); + var totalWatch = Stopwatch.StartNew(); + long baselineHeap = GC.GetTotalMemory(forceFullCollection: true); + + for (int i = 0; i < 80; i++) + { + byte[] et = (byte[])elemTableStream.Clone(); + byte[] hist = (byte[])historyStream.Clone(); + byte[] fmt = (byte[])formatsStream.Clone(); + byte[] p1 = (byte[])partition1Stream.Clone(); + byte[] p2 = (byte[])partition2Stream.Clone(); + + byte[][] targets = [et, hist, fmt, p1, p2]; + int slot = i % targets.Length; + targets[slot] = MutateStream(targets[slot], i / targets.Length, random); + + CoverageCensusTests.WriteFile(targetPath, + ("Global/ElemTable", targets[0]), + ("Global/History", targets[1]), + ("Formats/Latest", targets[2]), + ("Partitions/1", targets[3]), + ("Partitions/2", targets[4])); + + long allocBefore = GC.GetTotalAllocatedBytes(precise: false); + var iterWatch = Stopwatch.StartNew(); + var diagnostics = new RvtReadDiagnostics(); + + try + { + var info = new RvtFileReader(new RvtReaderOptions { Diagnostics = diagnostics }).Read(targetPath); + Assert.NotNull(info); + } + catch (Exception ex) + { + Assert.False(ex is OutOfMemoryException, $"Inner-stream iteration {i} threw OutOfMemoryException."); + Assert.True(ex is RvtInputException or RvtLimitException, + $"Inner-stream iteration {i} threw unexpected {ex.GetType().FullName}: {ex.Message}"); + } + + long allocDelta = GC.GetTotalAllocatedBytes(precise: false) - allocBefore; + Assert.True(iterWatch.ElapsedMilliseconds < 5000, $"Inner-stream iteration {i} took {iterWatch.ElapsedMilliseconds} ms."); + Assert.True(allocDelta < 128L * 1024 * 1024, $"Inner-stream iteration {i} allocated {allocDelta} bytes."); + } + + long retainedGrowth = GC.GetTotalMemory(forceFullCollection: true) - baselineHeap; + Assert.True(retainedGrowth < 64L * 1024 * 1024, $"Retained heap grew by {retainedGrowth} bytes."); + Assert.True(totalWatch.ElapsedMilliseconds < 30_000, $"Inner-stream fuzz suite took {totalWatch.ElapsedMilliseconds} ms."); + } + + private static byte[] MutateStream(byte[] source, int mode, Random random) + { + if (source.Length == 0) return source; + byte[] copy = (byte[])source.Clone(); + switch (mode % 5) + { + case 0: + return copy[..random.Next(0, copy.Length)]; + case 1: + for (int k = 0; k < 5; k++) + copy[random.Next(copy.Length)] ^= (byte)(1 + random.Next(255)); + return copy; + case 2: + if (copy.Length >= 4) + BitConverter.GetBytes(uint.MaxValue).CopyTo(copy, random.Next(0, copy.Length - 3)); + return copy; + case 3: + int clearPos = random.Next(0, copy.Length); + Array.Clear(copy, clearPos, random.Next(1, copy.Length - clearPos + 1)); + return copy; + default: + // Corrupt payload inside a valid PageCodec + Gzip wrapper with runaway 0xFF counts + byte[] inner = new byte[128]; + random.NextBytes(inner); + if (inner.Length >= 8) + { + BitConverter.GetBytes((ushort)1312).CopyTo(inner, 0); + BitConverter.GetBytes(int.MaxValue).CopyTo(inner, 2); + } + return PageCodec.EncodePadded(GzipMember.Write(inner)); + } + } + + private byte[] BuildValidSyntheticRvtBytes() + { + var (et, hist, fmt, p1, p2) = BuildValidStreams(); + string path = Path.Combine(_directory, "seed.rvt"); + CoverageCensusTests.WriteFile(path, + ("Global/ElemTable", et), + ("Global/History", hist), + ("Formats/Latest", fmt), + ("Partitions/1", p1), + ("Partitions/2", p2)); + return File.ReadAllBytes(path); + } + + private static (byte[] ElemTable, byte[] History, byte[] Formats, byte[] Partition1, byte[] Partition2) BuildValidStreams() + { + using var elemTable = new MemoryStream(); + using (var writer = new BinaryWriter(elemTable)) + { + writer.Write((ushort)1312); + writer.Write(3); + for (int i = 0; i < 3; i++) + { + writer.Write(1000 + i); + writer.Write(100 + i); + writer.Write(64 + i); + writer.Write(523); + writer.Write(999); + writer.Write(7 + i); + writer.Write(42 + i); + } + } + + byte[] p1 = CoverageCensusTests.Partition( + CoverageCensusTests.Block(false, + CoverageCensusTests.R(1000, CoverageCensusTests.Body("Plain", 5, 0, 0, 0)), + CoverageCensusTests.R(1001, CoverageCensusTests.Body("Store", 9, 0, 0, 0)))); + byte[] p2 = CoverageCensusTests.Partition( + CoverageCensusTests.Block(true, + CoverageCensusTests.R(1002, CoverageCensusTests.Body("Listed", 2, 0, 0, 0, 7, 0, 0, 0, 8, 0, 0, 0)))); + + return ( + PageCodec.EncodePadded(GzipMember.Write(elemTable.ToArray())), + PageCodec.EncodePadded(GzipMember.Write(new byte[64])), + PageCodec.EncodePadded(GzipMember.Write(CoverageCensusTests.TableBytes)), + p1, + p2); + } +} diff --git a/tests/RvtIO.Tests/ExportReportTests.cs b/tests/RvtIO.Tests/ExportReportTests.cs index 6735518..046336d 100644 --- a/tests/RvtIO.Tests/ExportReportTests.cs +++ b/tests/RvtIO.Tests/ExportReportTests.cs @@ -167,7 +167,8 @@ public void SameFileSeesAHardLinkOnWindows() [Fact] public void RealPathKeepsAPathWithoutLinksAndTheComponentsThatDoNotExist() { - string folder = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "rvt-realpath-" + Guid.NewGuid().ToString("N"), "a", "b.json")); + string tempRoot = ExportReport.RealPath(Path.GetFullPath(Path.GetTempPath())); + string folder = Path.Combine(tempRoot, "rvt-realpath-" + Guid.NewGuid().ToString("N"), "a", "b.json"); Assert.Equal(folder, ExportReport.RealPath(folder)); string root = Path.GetPathRoot(folder)!; Assert.Equal(root, ExportReport.RealPath(root)); diff --git a/tests/RvtIO.Tests/IfcDeterminismTests.cs b/tests/RvtIO.Tests/IfcDeterminismTests.cs new file mode 100644 index 0000000..f6e1e8c --- /dev/null +++ b/tests/RvtIO.Tests/IfcDeterminismTests.cs @@ -0,0 +1,324 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using RvtIO.Cli; +using RvtIO.Ifc; +using RvtIO.Model; +using RvtIO.Writing; +using Xunit; + +namespace RvtIO.Tests; + +/// Fixed-timestamp IFC header ( and --ifc-timestamp) and byte-for-byte +/// deterministic IFC exports across repeated runs. +[Collection("ElemTableStaticMaps")] +public class IfcDeterminismTests +{ + private static readonly DateTimeOffset FixedUtc = new(2026, 1, 15, 12, 30, 45, TimeSpan.Zero); + + [Fact] + public void IfcTimestampOption_WithoutOption_ReturnsNull() + { + Assert.True(IfcTimestampOption.TryParse(["project.rvt", "project.ifc"], out var timestamp, out string? error)); + Assert.Null(timestamp); + Assert.Null(error); + } + + [Theory] + [InlineData("2026-01-15T12:30:45Z", "2026-01-15T12:30:45")] + [InlineData("2026-01-15T14:30:45+02:00", "2026-01-15T12:30:45")] + [InlineData("2026-01-15T12:30:45", "2026-01-15T12:30:45")] + [InlineData("2026-01-15 12:30:45Z", "2026-01-15T12:30:45")] + [InlineData("2026-01-15", "2026-01-15T00:00:00")] + [InlineData("1768480245", "2026-01-15T12:30:45")] + public void IfcTimestampOption_ValidFormats_NormalizeToUtc(string input, string expectedUtcText) + { + Assert.True(IfcTimestampOption.TryParse(["project.rvt", "project.ifc", "--ifc-timestamp", input], out var timestamp, out string? error)); + Assert.Null(error); + Assert.NotNull(timestamp); + Assert.Equal(expectedUtcText, timestamp.Value.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture)); + } + + public static IEnumerable InvalidTimestampArguments() + { + yield return [new[] { "project.rvt", "project.ifc", "--ifc-timestamp" }]; + yield return [new[] { "project.rvt", "project.ifc", "--ifc-timestamp", "--ifc-revit-parameters" }]; + yield return [new[] { "project.rvt", "project.ifc", "--ifc-timestamp", "not-a-date" }]; + yield return [new[] { "project.rvt", "project.ifc", "--ifc-timestamp", "2026-01-15T12:30:45Z", "--ifc-timestamp", "2026-01-15T12:30:45Z" }]; + } + + [Theory] + [MemberData(nameof(InvalidTimestampArguments))] + public void IfcTimestampOption_InvalidOrDuplicateInput_FailsWithMessage(string[] args) + { + Assert.False(IfcTimestampOption.TryParse(args, out var timestamp, out string? error)); + Assert.Null(timestamp); + Assert.NotNull(error); + Assert.Contains("--ifc-timestamp", error); + } + + [Theory] + [InlineData(IfcProfile.Current)] + [InlineData(IfcProfile.Legacy)] + public void RepeatedExportsWithFixedTimestamp_AreByteForByteIdentical(IfcProfile profile) + { + string directory = Path.Combine(Path.GetTempPath(), "rvtio-ifc-det-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string firstPath = Path.Combine(directory, "project.ifc"); + string secondDir = Path.Combine(directory, "second"); + Directory.CreateDirectory(secondDir); + string secondPath = Path.Combine(secondDir, "project.ifc"); + + var exporter1 = new IfcExporter(includeRevitPropertySets: true) + { + Profile = profile, + RevitParameterGroups = true, + Timestamp = FixedUtc, + }; + var exporter2 = new IfcExporter(includeRevitPropertySets: true) + { + Profile = profile, + RevitParameterGroups = true, + Timestamp = new DateTimeOffset(2026, 1, 15, 14, 30, 45, TimeSpan.FromHours(2)), + }; + + exporter1.Export(BuildMultiElementModel(), firstPath); + exporter2.Export(BuildMultiElementModel(), secondPath); + + byte[] firstBytes = File.ReadAllBytes(firstPath); + byte[] secondBytes = File.ReadAllBytes(secondPath); + Assert.Equal(firstBytes, secondBytes); + + string text = Encoding.ASCII.GetString(firstBytes); + Assert.Contains("FILE_NAME('output.ifc','2026-01-15T12:30:45',", text); + } + finally { Directory.Delete(directory, recursive: true); } + } + + [Fact] + public void CliExportWithIfcTimestamp_ProducesByteIdenticalOutputAcrossRunsAndRejectsJsonOutput() + { + string directory = Path.Combine(Path.GetTempPath(), "rvtio-cli-det-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string rvt = Path.Combine(directory, "synthetic.rvt"); + WriteSyntheticRvt(rvt); + + string jsonOut = Path.Combine(directory, "out.json"); + var (jsonExit, jsonErr) = RunCli(rvt, jsonOut, "--ifc-timestamp", "2026-01-15T12:30:45Z"); + Assert.Equal(RvtExitCodes.Usage, jsonExit); + Assert.Contains("--ifc-timestamp requires IFC output.", jsonErr); + Assert.False(File.Exists(jsonOut)); + + string dirA = Path.Combine(directory, "a"); + string dirB = Path.Combine(directory, "b"); + Directory.CreateDirectory(dirA); + Directory.CreateDirectory(dirB); + string ifcA = Path.Combine(dirA, "export.ifc"); + string ifcB = Path.Combine(dirB, "export.ifc"); + + var (exitA, _) = RunCli(rvt, ifcA, "--ifc-timestamp", "2026-01-15T12:30:45Z"); + var (exitB, _) = RunCli(rvt, ifcB, "--ifc-timestamp", "2026-01-15T14:30:45+02:00"); + Assert.Equal(RvtExitCodes.Success, exitA); + Assert.Equal(RvtExitCodes.Success, exitB); + + byte[] bytesA = File.ReadAllBytes(ifcA); + byte[] bytesB = File.ReadAllBytes(ifcB); + Assert.Equal(bytesA, bytesB); + Assert.Contains("'2026-01-15T12:30:45'", Encoding.ASCII.GetString(bytesA)); + } + finally { Directory.Delete(directory, recursive: true); } + } + + [SkippableFact] + public void RealFixtureRepeatedExportWithFixedTimestamp_IsByteForByteIdentical() + { + string path = TestFiles.Find("TESTFILE.rvt"); + string directory = Path.Combine(Path.GetTempPath(), "rvtio-fixture-det-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string dirA = Path.Combine(directory, "a"); + string dirB = Path.Combine(directory, "b"); + Directory.CreateDirectory(dirA); + Directory.CreateDirectory(dirB); + string outA = Path.Combine(dirA, "model.ifc"); + string outB = Path.Combine(dirB, "model.ifc"); + + var infoA = new RvtFileReader(new RvtReaderOptions { R22Layout = true, SystemFamilyNameLocale = "ENU", SeparateFacesWithDistinctOpenings = true }).Read(path); + var infoB = new RvtFileReader(new RvtReaderOptions { R22Layout = true, SystemFamilyNameLocale = "ENU", SeparateFacesWithDistinctOpenings = true }).Read(path); + + new IfcExporter { Profile = IfcProfile.Current, Timestamp = FixedUtc }.Export(infoA, outA); + new IfcExporter { Profile = IfcProfile.Current, Timestamp = FixedUtc }.Export(infoB, outB); + + Assert.Equal(File.ReadAllBytes(outA), File.ReadAllBytes(outB)); + } + finally { Directory.Delete(directory, recursive: true); } + } + + private static (int ExitCode, string StdErr) RunCli(params string[] arguments) + { + var start = new ProcessStartInfo(CliExitCodeTests.DotnetExecutable) + { + 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 stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + Assert.True(process.WaitForExit(120_000), "The tool did not end within two minutes."); + Task.WaitAll(stdout, stderr); + return (process.ExitCode, stderr.Result); + } + + internal static void WriteSyntheticRvt(string path) + { + using var elemTable = new MemoryStream(); + using (var writer = new BinaryWriter(elemTable)) + { + writer.Write((ushort)1312); + writer.Write(2); + writer.Write(1001); writer.Write(100); writer.Write(64); + writer.Write(523); writer.Write(999); writer.Write(7); writer.Write(42); + writer.Write(1002); writer.Write(101); writer.Write(65); + writer.Write(523); writer.Write(999); writer.Write(8); writer.Write(43); + } + byte[] partition1 = CoverageCensusTests.Partition( + CoverageCensusTests.Block(false, + CoverageCensusTests.R(1001, CoverageCensusTests.Body("Plain", 5, 0, 0, 0)), + CoverageCensusTests.R(1002, CoverageCensusTests.Body("Store", 9, 0, 0, 0)))); + byte[] partition2 = CoverageCensusTests.Partition( + CoverageCensusTests.Block(true, + CoverageCensusTests.R(1002, CoverageCensusTests.Body("Listed", 2, 0, 0, 0, 11, 0, 0, 0, 22, 0, 0, 0)))); + CoverageCensusTests.WriteFile(path, + ("Global/ElemTable", PageCodec.EncodePadded(GzipMember.Write(elemTable.ToArray()))), + ("Global/History", PageCodec.EncodePadded(GzipMember.Write(new byte[64]))), + ("Formats/Latest", PageCodec.EncodePadded(GzipMember.Write(CoverageCensusTests.TableBytes))), + ("Partitions/1", partition1), + ("Partitions/2", partition2)); + } + + private static RevitFileInfo BuildMultiElementModel() + { + const int phaseCreatedId = -1012100; + const int elemTypeId = -1002050; + const int levelParamId = -1001952; + + var material = new Element + { + Id = 700, + IsDiscovered = true, + CategoryId = -2000700, + Category = "OST_Materials", + Name = "Concrete C30", + HasShadingAppearance = true, + }; + + var level0 = new Element + { + Id = 10, + IsDiscovered = true, + CategoryId = -2000240, + Category = "OST_Levels", + Name = "Level 0", + Parameters = + [ + new Parameter { Id = -1007102, Definition = new Definition { Id = -1007102, Name = "LEVEL_ELEV" }, StorageType = StorageType.Double, Value = 0.0 }, + new Parameter { Id = -1007103, Definition = new Definition { Id = -1007103, Name = "LEVEL_IS_BUILDING_STORY" }, StorageType = StorageType.Integer, Value = 1 }, + ], + }; + + var wallType = new Element + { + Id = 500, + IsDiscovered = true, + CategoryId = -2000011, + Category = "OST_Walls", + Name = "Exterior 300", + FamilyName = "Basic Wall", + SerializedBasicWallTypeFunction = 1, + Layers = + [ + new MaterialLayer { LayerIndex = 0, Function = "Structure", ThicknessMm = 300.0, MaterialId = 700, MaterialName = "Concrete C30" }, + ], + }; + + var doorType = new Element + { + Id = 510, + IsDiscovered = true, + CategoryId = -2000023, + Category = "OST_Doors", + Name = "Single 900x2100", + FamilyName = "Single-Flush", + Parameters = + [ + new Parameter { Id = -1001301, Definition = new Definition { Id = -1001301, Name = "DOOR_WIDTH" }, StorageType = StorageType.Double, Value = 2.952755905511811 }, + new Parameter { Id = -1001300, Definition = new Definition { Id = -1001300, Name = "DOOR_HEIGHT" }, StorageType = StorageType.Double, Value = 6.889763779527559 }, + ], + }; + + var wallA = PrismElement(100, -2000011, "OST_Walls", "Basic Wall:Exterior 300:100", 500, 10, 0, 20, 1, 10, 700, "W-01"); + var wallB = PrismElement(101, -2000011, "OST_Walls", "Basic Wall:Exterior 300:101", 500, 10, 25, 20, 1, 10, 700, "W-02"); + var door = PrismElement(200, -2000023, "OST_Doors", "Single-Flush:Single 900x2100:200", 510, 10, 5, 3, 1, 7, 700, "D-01"); + door.SuperInstanceId = 100; + door.NativeMasterSymbolId = 510; + + static Element PrismElement(int id, int categoryId, string category, string name, int typeId, int levelId, + double x0, double dx, double dy, double dz, int renderStyleId, string mark) + { + double[][] pts = + [ + [x0, 0, 0], [x0 + dx, 0, 0], [x0 + dx, dy, 0], [x0, dy, 0], + [x0, 0, dz], [x0 + dx, 0, dz], [x0 + dx, dy, dz], [x0, dy, dz], + ]; + int[][] faces = + [ + [0, 3, 2, 1], [4, 5, 6, 7], + [0, 1, 5, 4], [1, 2, 6, 5], + [2, 3, 7, 6], [3, 0, 4, 7], + ]; + return new Element + { + Id = id, + IsDiscovered = true, + CategoryId = categoryId, + Category = category, + Name = name, + References = [3, levelId, typeId], + BoundingBox = [x0, 0, 0, x0 + dx, dy, dz], + Geometry = new GeometryData + { + BoundingBox = [x0, 0, 0, x0 + dx, dy, dz], + Faces = faces.Select(idx => new GeoFace + { + RenderStyleId = renderStyleId, + OuterVertices = idx.Select(k => pts[k]).ToList(), + }).ToList(), + BrepSolids = [new GeoBrepSolid { IsClosed = true, FaceIndices = [0, 1, 2, 3, 4, 5] }], + }, + Parameters = + [ + new Parameter { Id = phaseCreatedId, StorageType = StorageType.ElementId, Value = 3 }, + new Parameter { Id = elemTypeId, Definition = new Definition { Id = elemTypeId, Name = "ELEM_TYPE_PARAM" }, StorageType = StorageType.ElementId, Value = typeId }, + new Parameter { Id = levelParamId, Definition = new Definition { Id = levelParamId, Name = "FAMILY_LEVEL_PARAM" }, StorageType = StorageType.ElementId, Value = levelId }, + new Parameter { Id = -1001012, Definition = new Definition { Id = -1001012, Name = "ALL_MODEL_MARK" }, StorageType = StorageType.String, Value = mark }, + ], + }; + } + + var info = new RevitFileInfo + { + SerializedProjectPhaseIds = [3], + SerializedDefaultExportPhaseId = 3, + }; + info.Elements.AddRange([material, level0, wallType, doorType, wallA, wallB, door]); + return info; + } +} diff --git a/tests/RvtIO.Tests/PartitionFramingFuzzTests.cs b/tests/RvtIO.Tests/PartitionFramingFuzzTests.cs index 44e4d91..8f1f6c7 100644 --- a/tests/RvtIO.Tests/PartitionFramingFuzzTests.cs +++ b/tests/RvtIO.Tests/PartitionFramingFuzzTests.cs @@ -31,11 +31,74 @@ public void MutatedAndTruncatedStreamsAreRejectedWithoutAnException() default: BitConverter.GetBytes(uint.MaxValue).CopyTo(data, random.Next(0, data.Length - 4)); break; } var census = RecordRouting.Begin(schema, out _)!; + long allocBefore = GC.GetAllocatedBytesForCurrentThread(); var watch = System.Diagnostics.Stopwatch.StartNew(); bool accepted = RecordRouting.Collect(census, 1, data, out string? failure); + long allocDelta = GC.GetAllocatedBytesForCurrentThread() - allocBefore; Assert.True(watch.ElapsedMilliseconds < 5000); + Assert.True(allocDelta < 64L * 1024 * 1024, $"Iteration {i} allocated {allocDelta} bytes."); Assert.True(accepted || failure != null); } } } + + [Fact] + public void SyntheticPartitionStreams_MutatedAndTruncatedAreRejectedWithinTimeAndMemoryBounds() + { + var writer = new SchemaStreamEditorTests.Writer(); + writer.Top(new SchemaStreamEditorTests.Cls("Identifier", new SchemaStreamEditorTests.NoRef(), 1, [new SchemaStreamEditorTests.Fld("m_id", 4)], [])); + for (int id = 13; id < 816; id++) writer.Top(new SchemaStreamEditorTests.Cls("Filler" + id, new SchemaStreamEditorTests.NoRef(), 1, [], [])); + foreach (string name in new[] { "ContentKey", "ContentMarker", "SegmentMarker", "SegmentCheckback", "SignatureMarker" }) + writer.Top(new SchemaStreamEditorTests.Cls(name, new SchemaStreamEditorTests.NoRef(), 1, [], [])); + var schema = FormatsSchema.TryParse(writer.Build())!; + Assert.NotNull(RecordRouting.Begin(schema, out _)); + + byte[] raw = CoverageCensusTests.Partition( + CoverageCensusTests.Block(false, + CoverageCensusTests.R(1001, CoverageCensusTests.Body("Plain", 5, 0, 0, 0)), + CoverageCensusTests.R(1002, CoverageCensusTests.Body("Listed", 3, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0))), + CoverageCensusTests.Block(true, + CoverageCensusTests.R(2001, CoverageCensusTests.Body("Store", 9, 0, 0, 0)), + CoverageCensusTests.R(2002, CoverageCensusTests.Body("Empty")))); + + var random = new Random(20260115); + var totalWatch = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < 600; i++) + { + byte[] data = (byte[])raw.Clone(); + switch (i % 5) + { + case 0: + data = data[..random.Next(0, data.Length + 1)]; + break; + case 1: + for (int k = 0; k <= random.Next(6); k++) + data[random.Next(data.Length)] ^= (byte)(1 + random.Next(255)); + break; + case 2: + if (data.Length >= 4) + BitConverter.GetBytes(random.Next()).CopyTo(data, random.Next(0, data.Length - 3)); + break; + case 3: + if (data.Length >= 4) + BitConverter.GetBytes(uint.MaxValue).CopyTo(data, random.Next(0, data.Length - 3)); + break; + default: + int zeroStart = random.Next(0, data.Length); + int zeroLen = random.Next(0, data.Length - zeroStart + 1); + Array.Clear(data, zeroStart, zeroLen); + break; + } + + var census = RecordRouting.Begin(schema, out _)!; + long allocBefore = GC.GetAllocatedBytesForCurrentThread(); + var watch = System.Diagnostics.Stopwatch.StartNew(); + bool accepted = RecordRouting.Collect(census, 1, data, out string? failure); + long allocDelta = GC.GetAllocatedBytesForCurrentThread() - allocBefore; + Assert.True(watch.ElapsedMilliseconds < 2000, $"Iteration {i} took {watch.ElapsedMilliseconds} ms."); + Assert.True(allocDelta < 32L * 1024 * 1024, $"Iteration {i} allocated {allocDelta} bytes."); + Assert.True(accepted || failure != null); + } + Assert.True(totalWatch.ElapsedMilliseconds < 15_000, $"Total fuzz run took {totalWatch.ElapsedMilliseconds} ms."); + } } diff --git a/tests/RvtIO.Tests/RvtIO.Tests.csproj b/tests/RvtIO.Tests/RvtIO.Tests.csproj index c9b32cd..7ffda9c 100644 --- a/tests/RvtIO.Tests/RvtIO.Tests.csproj +++ b/tests/RvtIO.Tests/RvtIO.Tests.csproj @@ -30,6 +30,7 @@ +