Files
iofficeai--officecli/src/officecli/Core/CompoundFile.cs
T
wehub-resource-sync 8cb1f9f479
Publish SDK (PyPI) / publish (push) Has been cancelled
Publish SDK (npm) / publish (@aionui/officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (windows-latest) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/officecli-sdk) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/sdk) (push) Has been cancelled
Publish SDK (npm) / publish (officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (macos-latest) (push) Has been cancelled
SDK smoke / smoke (ubuntu-latest) (push) Has been cancelled
Skill parity / diff (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:09:29 +08:00

389 lines
18 KiB
C#

// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Buffers.Binary;
using System.Text;
namespace OfficeCli.Core;
/// <summary>
/// Self-contained reader/writer for the Microsoft Compound File Binary
/// (CFB / OLE structured storage, the <c>D0 CF 11 E0</c> container) format,
/// scoped to exactly what OLE embedding needs: a single named stream inside
/// the root storage.
///
/// This replaces the former third-party OpenMcdf dependency so the OLE
/// wrap/unwrap path is fully owned in-tree. The implementation follows
/// [MS-CFB]:
///
/// <list type="bullet">
/// <item>Writer emits a V3 container (512-byte sectors). Streams smaller
/// than the 4096-byte mini-stream cutoff are stored in the mini stream
/// via the mini FAT; larger streams go in regular FAT sectors. Both
/// paths are implemented because embedded payloads vary in size and a
/// spec-compliant reader (real Office, the former OpenMcdf) decides which
/// region to read from purely off the recorded stream size.</item>
/// <item>Reader parses V3 <i>and</i> V4 containers (sector size taken from
/// the header sector shift) defensively, since the bytes it unwraps may
/// originate from any tool. Every offset is bounds-checked and chain
/// traversal is iteration-capped; malformed input yields <c>null</c>
/// rather than throwing, so callers fall back to the raw bytes.</item>
/// </list>
/// </summary>
internal static class CompoundFile
{
private const uint FREESECT = 0xFFFFFFFF;
private const uint ENDOFCHAIN = 0xFFFFFFFE;
private const uint FATSECT = 0xFFFFFFFD;
private const uint NOSTREAM = 0xFFFFFFFF;
private const int MiniSectorSize = 64;
private const int MiniStreamCutoff = 4096;
private const int DirEntrySize = 128;
private const int HeaderDifatCount = 109; // FAT sector slots stored in the header
private static readonly byte[] Magic =
{ 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 };
// ==================== Writer ====================
/// <summary>
/// Build a minimal V3 CFB byte array whose root storage contains a single
/// stream named <paramref name="streamName"/> holding <paramref name="data"/>.
/// </summary>
public static byte[] WriteSingleStream(string streamName, byte[] data)
{
if (streamName == null) throw new ArgumentNullException(nameof(streamName));
data ??= Array.Empty<byte>();
const int sectorSize = 512;
var sectors = new List<byte[]>(); // regular sector payloads, id == index
var fat = new List<uint>(); // FAT entry per sector (parallel to sectors)
// Append a fresh chain of regular sectors holding blob; return start id.
int WriteChain(byte[] blob)
{
int n = Math.Max(1, (blob.Length + sectorSize - 1) / sectorSize);
int start = sectors.Count;
for (int i = 0; i < n; i++)
{
var s = new byte[sectorSize];
int off = i * sectorSize;
int len = Math.Min(sectorSize, blob.Length - off);
if (len > 0) Array.Copy(blob, off, s, 0, len);
sectors.Add(s);
fat.Add(0);
}
for (int i = 0; i < n; i++)
fat[start + i] = i == n - 1 ? ENDOFCHAIN : (uint)(start + i + 1);
return start;
}
uint rootStart = ENDOFCHAIN;
long rootSize = 0;
uint streamStart;
uint firstMiniFat = ENDOFCHAIN;
uint numMiniFat = 0;
if (data.Length == 0)
{
streamStart = ENDOFCHAIN;
}
else if (data.Length < MiniStreamCutoff)
{
// Small stream → mini stream + mini FAT.
int numMini = (data.Length + MiniSectorSize - 1) / MiniSectorSize;
int miniBytes = numMini * MiniSectorSize;
var miniStream = new byte[miniBytes];
Array.Copy(data, miniStream, data.Length);
rootStart = (uint)WriteChain(miniStream);
rootSize = miniBytes;
int miniFatSectors = (numMini + 127) / 128;
var miniFatBlob = new byte[miniFatSectors * sectorSize];
// Default every slot to FREESECT, then lay down the 0→1→…→END chain.
for (int i = 0; i < miniFatBlob.Length; i += 4)
BinaryPrimitives.WriteUInt32LittleEndian(miniFatBlob.AsSpan(i), FREESECT);
for (int i = 0; i < numMini; i++)
BinaryPrimitives.WriteUInt32LittleEndian(
miniFatBlob.AsSpan(i * 4),
i == numMini - 1 ? ENDOFCHAIN : (uint)(i + 1));
firstMiniFat = (uint)WriteChain(miniFatBlob);
numMiniFat = (uint)miniFatSectors;
streamStart = 0; // first mini-sector index
}
else
{
// Large stream → regular FAT sectors.
streamStart = (uint)WriteChain(data);
}
// Directory: Root Entry + the stream entry, padded to a 512-byte sector.
var dir = new byte[4 * DirEntrySize];
WriteDirEntry(dir, 0 * DirEntrySize, "Root Entry", objType: 5,
left: NOSTREAM, right: NOSTREAM, child: 1, start: rootStart, size: rootSize);
WriteDirEntry(dir, 1 * DirEntrySize, streamName, objType: 2,
left: NOSTREAM, right: NOSTREAM, child: NOSTREAM,
start: streamStart, size: data.Length);
// Entries 2 and 3 stay zeroed (objType 0 = unallocated).
int dirStart = WriteChain(dir);
// Allocate FAT sectors last: they must also be represented in the FAT.
int dataSectors = sectors.Count;
int numFat = Math.Max(1, (dataSectors + 126) / 127); // ceil(dataSectors/127)
// Adding numFat sectors may push us over another FAT-sector boundary.
while (dataSectors + numFat > numFat * 128) numFat++;
if (numFat > HeaderDifatCount)
throw new NotSupportedException(
"OLE payload too large to wrap (would need DIFAT sectors).");
var fatSectorIds = new int[numFat];
for (int i = 0; i < numFat; i++)
{
fatSectorIds[i] = sectors.Count;
sectors.Add(new byte[sectorSize]);
fat.Add(FATSECT);
}
// Serialize the FAT array across the FAT sectors (tail = FREESECT).
var fatBytes = new byte[numFat * sectorSize];
for (int i = 0; i < fatBytes.Length; i += 4)
BinaryPrimitives.WriteUInt32LittleEndian(fatBytes.AsSpan(i), FREESECT);
for (int s = 0; s < fat.Count; s++)
BinaryPrimitives.WriteUInt32LittleEndian(fatBytes.AsSpan(s * 4), fat[s]);
for (int i = 0; i < numFat; i++)
Array.Copy(fatBytes, i * sectorSize, sectors[fatSectorIds[i]], 0, sectorSize);
// Header (512 bytes) + every sector in id order.
var header = new byte[sectorSize];
Array.Copy(Magic, header, Magic.Length);
// CLSID (8..23) stays zero.
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(24), 0x003E); // minor version
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(26), 0x0003); // major version (V3)
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(28), 0xFFFE); // byte order
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(30), 0x0009); // sector shift → 512
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(32), 0x0006); // mini sector shift → 64
// reserved (34..39) zero, numDirSectors (40) = 0 for V3.
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(44), (uint)numFat);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(48), (uint)dirStart);
// transaction signature (52) zero.
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(56), MiniStreamCutoff);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(60), firstMiniFat);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(64), numMiniFat);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(68), ENDOFCHAIN); // first DIFAT sector
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(72), 0); // num DIFAT sectors
// DIFAT array at 76: FAT sector ids, rest FREESECT.
for (int i = 0; i < HeaderDifatCount; i++)
BinaryPrimitives.WriteUInt32LittleEndian(
header.AsSpan(76 + i * 4),
i < numFat ? (uint)fatSectorIds[i] : FREESECT);
var output = new byte[sectorSize + sectors.Count * sectorSize];
Array.Copy(header, 0, output, 0, sectorSize);
for (int i = 0; i < sectors.Count; i++)
Array.Copy(sectors[i], 0, output, sectorSize + i * sectorSize, sectorSize);
return output;
}
private static void WriteDirEntry(byte[] buf, int offset, string name,
byte objType, uint left, uint right, uint child, uint start, long size)
{
var nameBytes = Encoding.Unicode.GetBytes(name);
int copyLen = Math.Min(nameBytes.Length, 62); // 31 UTF-16 chars + null = 64 bytes
Array.Copy(nameBytes, 0, buf, offset, copyLen);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(offset + 64), (ushort)(copyLen + 2));
buf[offset + 66] = objType;
buf[offset + 67] = 1; // colorFlag: black
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(offset + 68), left);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(offset + 72), right);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(offset + 76), child);
// CLSID (80..95), stateBits (96..99), timestamps (100..115) stay zero.
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(offset + 116), start);
BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(offset + 120), (ulong)size);
}
// ==================== Reader ====================
/// <summary>
/// Extract the stream named <paramref name="streamName"/> from a CFB byte
/// array. Returns the stream bytes, or <c>null</c> if the input is not a
/// valid CFB, the stream is absent, or any structural inconsistency is hit.
/// </summary>
public static byte[]? ReadStream(byte[] cfb, string streamName)
{
try
{
if (cfb == null || cfb.Length < 512) return null;
for (int i = 0; i < Magic.Length; i++)
if (cfb[i] != Magic[i]) return null;
int sectorShift = BinaryPrimitives.ReadUInt16LittleEndian(cfb.AsSpan(30));
if (sectorShift < 7 || sectorShift > 20) return null;
int sectorSize = 1 << sectorShift;
if (sectorSize < 128) return null;
uint numFat = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(44));
uint firstDir = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(48));
uint miniCutoff = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(56));
uint firstMiniFat = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(60));
uint firstDifat = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(68));
uint numDifat = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(72));
int totalSectors = (cfb.Length - sectorSize) / sectorSize;
if (totalSectors <= 0) return null;
int SectorOffset(uint id) => sectorSize + (int)id * sectorSize;
bool ValidSector(uint id) => id < (uint)totalSectors;
// Gather FAT sector ids: header DIFAT (109) then any DIFAT sectors.
var fatSectorIds = new List<uint>();
for (int i = 0; i < HeaderDifatCount && fatSectorIds.Count < numFat; i++)
{
uint id = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(76 + i * 4));
if (id == FREESECT) break;
fatSectorIds.Add(id);
}
uint difatCur = firstDifat;
int difatGuard = 0;
int perDifat = sectorSize / 4 - 1;
while (difatCur != ENDOFCHAIN && difatCur != FREESECT &&
fatSectorIds.Count < numFat && difatGuard++ <= totalSectors + 1)
{
if (!ValidSector(difatCur)) return null;
int baseOff = SectorOffset(difatCur);
for (int i = 0; i < perDifat && fatSectorIds.Count < numFat; i++)
{
uint id = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(baseOff + i * 4));
if (id == FREESECT) continue;
fatSectorIds.Add(id);
}
difatCur = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(baseOff + perDifat * 4));
}
if (numDifat > 0) { /* followed above; count is advisory */ }
// Build the full FAT array.
int fatEntries = fatSectorIds.Count * (sectorSize / 4);
var fat = new uint[fatEntries];
int w = 0;
foreach (uint fid in fatSectorIds)
{
if (!ValidSector(fid)) return null;
int baseOff = SectorOffset(fid);
for (int i = 0; i < sectorSize / 4; i++)
fat[w++] = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(baseOff + i * 4));
}
// Follow a FAT chain; cap iterations to guard against cycles.
List<uint>? Chain(uint start)
{
var ids = new List<uint>();
uint cur = start;
int guard = 0;
while (cur != ENDOFCHAIN && cur != FREESECT)
{
if (cur >= (uint)fat.Length || !ValidSector(cur)) return null;
if (guard++ > totalSectors + 1) return null;
ids.Add(cur);
cur = fat[cur];
}
return ids;
}
byte[]? ReadRegular(uint start, long size)
{
var ids = Chain(start);
if (ids == null) return null;
var buf = new byte[ids.Count * sectorSize];
for (int i = 0; i < ids.Count; i++)
Array.Copy(cfb, SectorOffset(ids[i]), buf, i * sectorSize, sectorSize);
if (size < 0 || size > buf.Length) return buf;
var outBuf = new byte[size];
Array.Copy(buf, outBuf, (int)size);
return outBuf;
}
// Parse the directory and locate Root Entry + the target stream.
var dirChain = Chain(firstDir);
if (dirChain == null) return null;
var target = Encoding.Unicode.GetBytes(streamName);
uint rootStart = ENDOFCHAIN;
long rootSize = 0;
bool foundStream = false;
uint streamStart = ENDOFCHAIN;
long streamSize = 0;
foreach (uint dsec in dirChain)
{
int baseOff = SectorOffset(dsec);
for (int e = 0; e + DirEntrySize <= sectorSize; e += DirEntrySize)
{
int off = baseOff + e;
byte objType = cfb[off + 66];
if (objType == 5) // root
{
rootStart = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(off + 116));
rootSize = (long)BinaryPrimitives.ReadUInt64LittleEndian(cfb.AsSpan(off + 120));
}
else if (objType == 2 && !foundStream) // stream
{
int nameLen = BinaryPrimitives.ReadUInt16LittleEndian(cfb.AsSpan(off + 64));
nameLen = Math.Clamp(nameLen, 0, 64);
int cmpLen = nameLen >= 2 ? nameLen - 2 : 0; // drop null terminator
if (cmpLen == target.Length && cfb.AsSpan(off, cmpLen).SequenceEqual(target))
{
streamStart = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(off + 116));
streamSize = (long)BinaryPrimitives.ReadUInt64LittleEndian(cfb.AsSpan(off + 120));
foundStream = true;
}
}
}
}
if (!foundStream) return null;
if (streamSize == 0) return Array.Empty<byte>();
// Large stream: read straight from the regular FAT.
if (miniCutoff == 0 || streamSize >= miniCutoff)
return ReadRegular(streamStart, streamSize);
// Small stream: read from the mini stream via the mini FAT.
byte[]? miniStream = ReadRegular(rootStart, rootSize);
if (miniStream == null) return null;
var miniFatChain = Chain(firstMiniFat);
if (miniFatChain == null) return null;
int miniFatEntries = miniFatChain.Count * (sectorSize / 4);
var miniFat = new uint[miniFatEntries];
int mw = 0;
foreach (uint mfid in miniFatChain)
{
int baseOff = SectorOffset(mfid);
for (int i = 0; i < sectorSize / 4; i++)
miniFat[mw++] = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(baseOff + i * 4));
}
var result = new byte[streamSize];
int written = 0;
uint miniCur = streamStart;
int miniGuard = 0;
int totalMini = miniStream.Length / MiniSectorSize;
while (miniCur != ENDOFCHAIN && miniCur != FREESECT && written < streamSize)
{
if (miniCur >= (uint)miniFat.Length || miniCur >= (uint)totalMini) return null;
if (miniGuard++ > totalMini + 1) return null;
int take = (int)Math.Min(MiniSectorSize, streamSize - written);
Array.Copy(miniStream, (int)miniCur * MiniSectorSize, result, written, take);
written += take;
miniCur = miniFat[miniCur];
}
return written == streamSize ? result : null;
}
catch
{
return null;
}
}
}