#nullable enable
// Copyright (c) 2026 Siegfried Pammer
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace ICSharpCode.Decompiler.Util
{
///
/// Reader for the !AvaloniaResources manifest resource that the Avalonia UI framework
/// embeds in compiled assemblies. The blob packs every Avalonia resource (compiled XAML,
/// images, ...) behind a single index, much like a .resources file packs a managed
/// resource set.
///
///
/// Layout (all integers little-endian):
///
/// - Int32 indexLength: byte length of the index section that follows.
/// - Index section: Int32 version (must be 2), Int32 entryCount, then per entry a
/// 7-bit length-prefixed UTF-8 path, Int32 offset and Int32 size.
/// - Data section: concatenated file bytes; each entry's offset is relative to the
/// start of this section (i.e. to indexLength + 4).
///
///
public sealed class AvaloniaResourcesFile : IEnumerable>
{
///
/// Name of the manifest resource that holds the packed Avalonia resources.
///
public const string ResourceName = "!AvaloniaResources";
// Index format version written by Avalonia's AvaloniaResourcesIndexReaderWriter. Version 1
// was a legacy XML index that this reader does not support.
const int BinaryCurrentVersion = 2;
readonly List> entries = new();
///
/// Returns if is the manifest
/// resource name Avalonia uses for its packed resources.
///
public static bool IsAvaloniaResourcesEntry(string resourceName)
=> resourceName == ResourceName;
///
/// Parses the !AvaloniaResources blob read from .
///
/// The blob is truncated, uses an unsupported
/// index version, or contains an entry that points outside the data section.
public AvaloniaResourcesFile(Stream stream)
{
if (stream == null)
throw new ArgumentNullException(nameof(stream));
byte[] blob;
using (var buffer = new MemoryStream())
{
stream.CopyTo(buffer);
blob = buffer.ToArray();
}
try
{
Parse(blob);
}
catch (EndOfStreamException ex)
{
throw new BadImageFormatException("Truncated !AvaloniaResources index.", ex);
}
}
void Parse(byte[] blob)
{
using var reader = new BinaryReader(new MemoryStream(blob), Encoding.UTF8);
int indexLength = reader.ReadInt32();
// The data section begins right after the 4-byte length prefix and the index.
long baseOffset = 4L + indexLength;
if (indexLength < 0 || baseOffset > blob.Length)
throw new BadImageFormatException("Invalid !AvaloniaResources index length.");
int version = reader.ReadInt32();
if (version != BinaryCurrentVersion)
throw new BadImageFormatException($"Unsupported !AvaloniaResources index version {version}.");
int count = reader.ReadInt32();
if (count < 0)
throw new BadImageFormatException("Invalid !AvaloniaResources entry count.");
// The count is attacker-controlled, so it must not be trusted as an allocation size: an
// honest entry occupies at least a 1-byte path length prefix plus two Int32s, so the index
// section cannot hold more than this many entries. A lying count is left to the per-entry
// spill guard below instead of forcing a huge pre-allocation.
const int MinEntrySize = 1 + 4 + 4;
long maxEntries = (baseOffset - reader.BaseStream.Position) / MinEntrySize;
entries.Capacity = (int)Math.Min(count, Math.Max(0, maxEntries));
// The build task deduplicates identical files, so many entries can legitimately alias one
// (offset, size) range in the data section; those share a single buffer here rather than
// each copying the bytes. Distinct ranges in an honest index are non-overlapping and thus
// cannot total more than the data section they occupy, so the sum of copied bytes is
// bounded by the data section length. Rejecting anything larger stops a crafted index from
// amplifying a small file into a huge allocation via overlapping, per-entry-copied slices.
long dataSectionLength = blob.Length - baseOffset;
long copiedBytes = 0;
var buffersByRange = new Dictionary();
for (int i = 0; i < count; i++)
{
string path = GetPathRooted(reader.ReadString());
int offset = reader.ReadInt32();
int size = reader.ReadInt32();
// Every byte of every entry must come from the index section: an entry that runs
// past the declared index end would otherwise silently consume data-section bytes.
// This also rejects a count claiming more entries than the index holds.
if (reader.BaseStream.Position > baseOffset)
throw new BadImageFormatException("Malformed !AvaloniaResources index.");
if (offset < 0 || size < 0 || baseOffset + offset + size > blob.Length)
throw new BadImageFormatException($"!AvaloniaResources entry '{path}' points outside the data section.");
long rangeKey = ((long)offset << 32) | (uint)size;
if (!buffersByRange.TryGetValue(rangeKey, out var data))
{
copiedBytes += size;
if (copiedBytes > dataSectionLength)
throw new BadImageFormatException("!AvaloniaResources index references more data than the data section contains.");
data = new byte[size];
Buffer.BlockCopy(blob, (int)baseOffset + offset, data, 0, size);
buffersByRange[rangeKey] = data;
}
entries.Add(new KeyValuePair(path, data));
}
}
// Avalonia's AssetLoader keys resources by a path with a leading slash; a path stored
// without one is rooted on read so the keys match what Avalonia itself resolves.
static string GetPathRooted(string path)
=> path.Length == 0 || path[0] != '/' ? "/" + path : path;
public IEnumerator> GetEnumerator() => entries.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => entries.GetEnumerator();
}
}