Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/DeterministicIoPackaging/DeterministicPackage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ public static partial class DeterministicPackage
public static DateTime StableDate { get; } = new(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
public static DateTimeOffset StableDateOffset { get; } = new(StableDate);

static PatcherSet CreatePatchers()
static PatcherSet CreatePatchers(IReadOnlyCollection<string> partNames)
{
var workbookRelsPatcher = new WorkbookRelationshipPatcher();
var documentRelsPatcher = new DocumentRelationshipPatcher();
Expand All @@ -14,7 +14,7 @@ static PatcherSet CreatePatchers()
var pptxRelsPatcher = new PptxRelationshipPatcher();
IReadOnlyList<IPatcher> patchers =
[
new ContentTypesPatcher(),
new ContentTypesPatcher(partNames),
new RelationshipPatcher(),
sheetRelsPatcher,
new SheetPatcher(sheetRelsPatcher),
Expand Down
27 changes: 25 additions & 2 deletions src/DeterministicIoPackaging/DeterministicPackage_Convert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ public static partial class DeterministicPackage
// and patched in place with no extra copy.
public static MemoryStream Convert(Stream source)
{
var patchers = CreatePatchers();
var target = new MemoryStream();
using (var sourceArchive = ReadArchive(source))
using (var targetArchive = CreateArchive(target))
{
// Part names must be known before patching begins so the
// ContentTypesPatcher can canonicalize the content-type map against
// every part in the package, not just the ones the input map lists.
var patchers = CreatePatchers(CollectPartNames(sourceArchive));
foreach (var sourceEntry in sourceArchive.OrderedEntries())
{
DuplicateEntry(sourceEntry, targetArchive, patchers);
Expand All @@ -27,11 +30,11 @@ public static MemoryStream Convert(Stream source)

public static async Task<MemoryStream> ConvertAsync(Stream source, Cancel token = default)
{
var patchers = CreatePatchers();
var target = new MemoryStream();
using (var sourceArchive = ReadArchive(source))
using (var targetArchive = CreateArchive(target))
{
var patchers = CreatePatchers(CollectPartNames(sourceArchive));
foreach (var sourceEntry in sourceArchive.OrderedEntries())
{
await DuplicateEntryAsync(sourceEntry, targetArchive, patchers, token);
Expand All @@ -43,6 +46,26 @@ public static async Task<MemoryStream> ConvertAsync(Stream source, Cancel token
return target;
}

// Every part in the package, as leading-slash PartName values, for the
// ContentTypesPatcher. [Content_Types].xml is not itself a part, and skipped
// entries are dropped from the output, so neither belongs in the map.
static IReadOnlyCollection<string> CollectPartNames(Archive archive)
{
var partNames = new List<string>();
foreach (var entry in archive.Entries)
{
if (entry.FullName is "[Content_Types].xml" ||
IsSkippedEntry(entry))
{
continue;
}

partNames.Add(ContentTypesPatcher.ToPartName(entry.FullName));
}

return partNames;
}

// ZIP local file header signature ("PK\x03\x04").
// Used to detect nested ZIP packages (e.g. xlsx/docx/pptx embedded inside
// word/embeddings/, ppt/embeddings/, xl/embeddings/) so they can be
Expand Down
195 changes: 189 additions & 6 deletions src/DeterministicIoPackaging/Patching/ContentTypesPatcher.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,42 @@
class ContentTypesPatcher : IExactMatchPatcher
// Canonicalizes [Content_Types].xml so it is byte-identical run-to-run and
// producer-independent.
//
// The OPC content-type map lets a part's content type be declared two equivalent
// ways: a <Default Extension="..."> that applies to every part with that
// extension, or a per-part <Override PartName="...">. For an extension whose
// parts carry several different content types (e.g. "xml", shared by workbook /
// worksheet / styles / sharedStrings / core-properties), the producer must pick
// ONE content type to be the extension's Default and emit Overrides for the rest.
//
// System.IO.Packaging (used by DocumentFormat.OpenXml's Clone, and by producers
// such as Aspose.Cells) makes that pick via internal collection ordering, so
// which content type wins the "xml" Default — and therefore which parts become
// Overrides — is not stable across producers, SDK versions, or runtimes. Merely
// sorting the entries (the previous behavior here) preserves whatever split the
// input happened to use, so the non-determinism survives.
//
// This patcher removes the ambiguity by recomputing the split from scratch:
// 1. Determine every part's effective content type from the input map
// (its Override if present, otherwise the Default for its extension).
// 2. For each extension, choose a canonical Default deterministically:
// the content type shared by the MOST parts of that extension, with the
// lexicographically-smallest (Ordinal) content type breaking ties.
// 3. Emit an <Override> for every part whose effective content type differs
// from its extension's chosen Default.
// 4. Sort Defaults by Extension and Overrides by PartName (both Ordinal).
//
// The rewrite is OPC-preserving: every part still resolves to exactly the content
// type it had in the input. It needs the full set of part names — not just what
// the input map lists — because changing an extension's Default turns previously
// implicit parts into ones that must now be spelled out as Overrides. Those names
// come from the package's zip entries, supplied by DeterministicPackage.Convert.
class ContentTypesPatcher(IReadOnlyCollection<string> partNames) :
IExactMatchPatcher
{
static XNamespace ns = "http://schemas.openxmlformats.org/package/2006/content-types";
static XName defaultName = ns + "Default";
static XName overrideName = ns + "Override";

public string ExactMatch => "[Content_Types].xml";

public bool IsMatch(Entry entry) =>
Expand All @@ -8,12 +45,158 @@ public bool IsMatch(Entry entry) =>
public void PatchXml(XDocument xml, string entryName)
{
var root = xml.Root!;
var elements = root.Elements()
.OrderBy(_ => _.Name.LocalName)
.ThenBy(_ => (string?)_.Attribute("Extension") ?? "")
.ThenBy(_ => (string?)_.Attribute("PartName") ?? "")

var defaults = ReadDefaults(root);
var overrides = ReadOverrides(root);

// Group each part by its extension, tracking its effective content type,
// so every extension can be assigned a single canonical Default below.
var byExtension = new Dictionary<string, List<PartContentType>>(StringComparer.Ordinal);
foreach (var partName in partNames)
{
var extension = Extension(partName);
if (extension.Length == 0)
{
continue;
}

var contentType = EffectiveContentType(partName, extension, defaults, overrides);
if (contentType == null)
{
// No Override and no Default covers this extension. The part has no
// declared content type in the input; leave the map untouched for it.
continue;
}

if (!byExtension.TryGetValue(extension, out var parts))
{
parts = [];
byExtension.Add(extension, parts);
}

parts.Add(new(partName, contentType));
}

var newDefaults = new List<XElement>();
var newOverrides = new List<XElement>();
foreach (var (extension, parts) in byExtension)
{
var canonicalDefault = ChooseDefault(parts);
newDefaults.Add(
new(defaultName,
new XAttribute("Extension", extension),
new XAttribute("ContentType", canonicalDefault)));

foreach (var part in parts)
{
if (!string.Equals(part.ContentType, canonicalDefault, StringComparison.Ordinal))
{
newOverrides.Add(
new(overrideName,
new XAttribute("PartName", part.PartName),
new XAttribute("ContentType", part.ContentType)));
}
}
}

var ordered = newDefaults
.OrderBy(_ => (string) _.Attribute("Extension")!, StringComparer.Ordinal)
.Concat(
newOverrides
.OrderBy(_ => (string) _.Attribute("PartName")!, StringComparer.Ordinal))
.ToList();

root.ReplaceAll(elements);
root.ReplaceAll(ordered);
}

// The content type shared by the most parts wins the extension's Default;
// the lexicographically-smallest content type (Ordinal) breaks ties. Both
// criteria are total and input-order-independent, so the choice is stable
// regardless of how the producer ordered its parts.
static string ChooseDefault(List<PartContentType> parts) =>
parts
.GroupBy(_ => _.ContentType, StringComparer.Ordinal)
.OrderByDescending(_ => _.Count())
.ThenBy(_ => _.Key, StringComparer.Ordinal)
.First()
.Key;

static string? EffectiveContentType(
string partName,
string extension,
Dictionary<string, string> defaults,
Dictionary<string, string> overrides)
{
if (overrides.TryGetValue(partName, out var overridden))
{
return overridden;
}

if (defaults.TryGetValue(extension, out var byDefault))
{
return byDefault;
}

return null;
}

static Dictionary<string, string> ReadDefaults(XElement root)
{
// Extensions are matched case-insensitively per the OPC spec; normalize to
// lower-case so a part's extension always finds its Default.
var defaults = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var element in root.Elements(defaultName))
{
var extension = (string?) element.Attribute("Extension");
var contentType = (string?) element.Attribute("ContentType");
if (extension != null && contentType != null)
{
defaults[extension.ToLowerInvariant()] = contentType;
}
}

return defaults;
}

static Dictionary<string, string> ReadOverrides(XElement root)
{
var overrides = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var element in root.Elements(overrideName))
{
var partName = (string?) element.Attribute("PartName");
var contentType = (string?) element.Attribute("ContentType");
if (partName != null && contentType != null)
{
overrides[partName] = contentType;
}
}

return overrides;
}

// Part names in the content-type map are absolute ("/xl/workbook.xml"); the
// zip entry names are relative ("xl/workbook.xml"). Normalize entry names to
// the leading-slash form so they line up with the map's PartName values.
internal static string ToPartName(string entryFullName) =>
entryFullName.StartsWith('/') ? entryFullName : "/" + entryFullName;

static string Extension(string partName)
{
var lastDot = partName.LastIndexOf('.');
if (lastDot < 0 || lastDot == partName.Length - 1)
{
return "";
}

// A dot must be in the final segment to be an extension.
var lastSlash = partName.LastIndexOf('/');
if (lastDot < lastSlash)
{
return "";
}

return partName.Substring(lastDot + 1).ToLowerInvariant();
}

readonly record struct PartContentType(string PartName, string ContentType);
}
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project>
<PropertyGroup>
<NoWarn>CS1591;CS0649;CA1416;NU1608;NU1109;NU1510</NoWarn>
<Version>0.29.0</Version>
<Version>0.30.0</Version>
<LangVersion>preview</LangVersion>
<AssemblyVersion>1.0.0</AssemblyVersion>
<Description>Modify System.IO.Packaging (https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging) files to ensure they are deterministic. Helpful for testing, build reproducibility, security verification, and ensuring package integrity across different build environments.</Description>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />
<Default Extension="xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" />
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" />
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" />
</Types>
97 changes: 97 additions & 0 deletions src/Tests/Patching/ContentTypesPatcherTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
[TestFixture]
public class ContentTypesPatcherTests
{
const string variantDefaultIsWorkbook =
"""
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />
<Default Extension="xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" />
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" />
<Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" />
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" />
</Types>
""";

// Same package, but the producer chose a different content type for the "xml"
// Default and moved the workbook to an Override. Semantically identical.
const string variantDefaultIsWorksheet =
"""
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />
<Default Extension="xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" />
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" />
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" />
</Types>
""";

static string[] partNames =
[
"/_rels/.rels",
"/xl/workbook.xml",
"/xl/worksheets/sheet1.xml",
"/xl/worksheets/sheet2.xml",
"/xl/styles.xml"
];

static string Patch(string xml) =>
PatchHelper
.Patch(new ContentTypesPatcher(partNames), xml, "[Content_Types].xml")
.ToString();

// The two equivalent producer splits must canonicalize to identical output.
[Test]
public void SplitChoiceIsCanonicalized() =>
Assert.That(Patch(variantDefaultIsWorksheet), Is.EqualTo(Patch(variantDefaultIsWorkbook)));

// The most-common content type for an extension becomes its Default; here two
// worksheets vs one each of workbook/styles, so worksheet+xml wins the "xml"
// Default and the two worksheet parts carry no Override.
[Test]
public Task MostCommonBecomesDefault() =>
Verify(Patch(variantDefaultIsWorkbook));

// Every part still resolves to the content type it had in the input: the
// rewrite must be OPC-preserving, only relocating declarations between
// Default and Override.
[Test]
public void ContentTypesArePreserved()
{
var patched = XDocument.Parse(Patch(variantDefaultIsWorkbook));

var expected = new Dictionary<string, string>
{
["/_rels/.rels"] = "application/vnd.openxmlformats-package.relationships+xml",
["/xl/workbook.xml"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml",
["/xl/worksheets/sheet1.xml"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml",
["/xl/worksheets/sheet2.xml"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml",
["/xl/styles.xml"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"
};

foreach (var (partName, contentType) in expected)
{
Assert.That(Resolve(patched, partName), Is.EqualTo(contentType),
$"'{partName}' resolved to the wrong content type");
}
}

// Resolves a part's content type the way an OPC reader would: an Override for
// that exact part wins, otherwise the Default for its extension.
static string? Resolve(XDocument contentTypes, string partName)
{
XNamespace ns = "http://schemas.openxmlformats.org/package/2006/content-types";
var root = contentTypes.Root!;

var over = root.Elements(ns + "Override")
.FirstOrDefault(_ => (string?) _.Attribute("PartName") == partName);
if (over != null)
{
return (string?) over.Attribute("ContentType");
}

var extension = partName[(partName.LastIndexOf('.') + 1)..];
var def = root.Elements(ns + "Default")
.FirstOrDefault(_ =>
string.Equals((string?) _.Attribute("Extension"), extension, StringComparison.OrdinalIgnoreCase));
return (string?) def?.Attribute("ContentType");
}
}
Binary file modified src/Tests/Tests.AbsPath.DotNet.verified.xlsx
Binary file not shown.
Binary file modified src/Tests/Tests.AbsPath.Net.verified.xlsx
Binary file not shown.
Loading
Loading