Skip to content

Fix CFB v3 OLE writer header and DIFAT boundaries #4811 - #4983

Open
n3crosis wants to merge 1 commit into
PHPOffice:masterfrom
n3crosis:fix/ole-cfb-root-conformance
Open

Fix CFB v3 OLE writer header and DIFAT boundaries #4811#4983
n3crosis wants to merge 1 commit into
PHPOffice:masterfrom
n3crosis:fix/ole-cfb-root-conformance

Conversation

@n3crosis

@n3crosis n3crosis commented Sep 4, 2026

Copy link
Copy Markdown

This is:

  • a bugfix
  • a new feature
  • refactoring
  • additional unit tests

Checklist:

  • Changes are covered by unit tests
    • Changes are covered by existing unit tests
    • New unit tests have been added
  • Code style is respected
  • Commit message explains why the change is made (see https://github.com/erlang/otp/wiki/Writing-good-commit-messages)
  • CHANGELOG.md contains a short summary of the change and a link to the pull request if applicable
  • Documentation is updated as necessary (no public documentation change is needed)

Why this change is needed?

The legacy XLS writer creates a CFB/OLE version-3 container through Shared/OLE/PPS/Root. This PR corrects the following CFB conformance defects:

  1. The writer emitted header minor version 0x003B; it now emits 0x003E. MS-CFB specifies 0x003E as the version-3/4 minor version value. MS-CFB §2.2, Compound File Header

  2. The header always declared the CFB v3 512-byte-sector and 64-byte mini-sector layout, while the serializer could internally choose different power-of-two sizes. The writer is now explicitly limited to the v3 512/64-byte profile, which matches the header and the required version-3 sector shifts. MS-CFB §2.2, Compound File Header

  3. Exactly 109 FAT-sector locations fit in the header DIFAT, but the writer created an inconsistent extended-DIFAT header at that boundary: it declared zero DIFAT sectors while setting a first DIFAT sector. This is the issue reported in 当 SAT扇区数正好等于109时,生成的 xls 文件有问题 #4811. The writer now records ENDOFCHAIN and a zero DIFAT count at the 109-FAT boundary. MS-CFB §2.5, DIFAT Sectors

  4. Extended-DIFAT capacity was calculated as 128 FAT locations per DIFAT sector. Each DIFAT sector actually has 127 FAT-sector locations and one next-DIFAT-sector pointer. The writer now correctly creates two linked DIFAT sectors for 237 FAT sectors. MS-CFB §2.5, DIFAT Sectors

  5. The v3 writer could serialize an individual stream or aggregate root mini-stream larger than 2 GiB, even though the v3 stream-size field requires the upper 32 bits to be zero. It now rejects these layouts before writing output, and also rejects unrepresentable regular-sector counts. MS-CFB §2.6.1, Compound File Directory Entry

Defect-to-code mapping

  1. Non-canonical v3 header and mismatched sector profile: the writer previously wrote 0x003B while allowing its internal sector sizes to be rounded to arbitrary powers of two. It now writes the canonical minor version and uses named fixed-profile constants.

    - $this->bigBlockSize = (int) (2 ** ((isset($this->bigBlockSize)) ? self::adjust2($this->bigBlockSize) : 9));
    - $this->smallBlockSize = (int) (2 ** ((isset($this->smallBlockSize)) ? self::adjust2($this->smallBlockSize) : 6));
    + $this->bigBlockSize = self::BIG_BLOCK_SIZE;
    + $this->smallBlockSize = self::SMALL_BLOCK_SIZE;
    
    - . pack('v', 0x3B)
    + . pack('v', 0x3E)
  2. Malformed 109-FAT header in 当 SAT扇区数正好等于109时,生成的 xls 文件有问题 #4811: the old strict comparison treated 109 FAT sectors as requiring extended DIFAT metadata. The inclusive comparison keeps all 109 locations in the header and writes ENDOFCHAIN with a zero DIFAT count.

    - if ($iBdCnt < $i1stBdL) {
    + if ($iBdCnt <= $i1stBdL) {
        fwrite($FILE, pack('V', -2) . pack('V', 0));
    }
  3. Incorrect two-DIFAT boundary: a 512-byte DIFAT sector contains 128 DWORDs. Its first 127 DWORDs identify FAT sectors; its final DWORD points to the next DIFAT sector. The header identifies the first 109 FAT sectors, so one DIFAT sector can identify only 109 + 127 = 236 FAT sectors. A 237-FAT file therefore needs two DIFAT sectors. The old * $iBlCnt calculation treated all 128 DWORDs as FAT locations and incorrectly declared one DIFAT sector sufficient; * ($iBlCnt - 1) reserves the chain-pointer DWORD. The identical correction in saveBbd() ensures the emitted DIFAT chain matches the header count.

    // saveHeader(): decide how many DIFAT sectors the header declares.
    - if ($iBdCnt <= ($iBdExL * $iBlCnt + $i1stBdL)) {
    + if ($iBdCnt <= ($iBdExL * ($iBlCnt - 1) + $i1stBdL)) {
          break;
      }
    
    // saveBbd(): use the same 127-entry capacity while writing the chain.
    - if ($iBdCnt <= ($iBdExL * $iBbCnt + $i1stBdL)) {
    + if ($iBdCnt <= ($iBdExL * ($iBbCnt - 1) + $i1stBdL)) {
          break;
      }
  4. Invalid oversized v3 stream layouts: each file stream and the aggregate root mini-stream are checked before serialization. The maximum is inclusive at 0x80000000, as required by CFB v3.

    private const MAX_VERSION_3_STREAM_SIZE = 0x80000000;
    
    $this->assertVersion3StreamSize($raList[$i]->Size);
    $this->assertVersion3MiniStreamSize((int) $iSBcnt);
    
    if ($size > self::MAX_VERSION_3_STREAM_SIZE) {
        throw new Exception('OLE version-3 streams cannot exceed 2 GiB.');
    }
  5. Unrepresentable sector IDs: after calculating the complete layout, the writer rejects a result whose regular-sector count exceeds the CFB maximum.

    private const MAX_REGULAR_SECTOR_COUNT = 0xFFFFFFFB;
    
    if ($iAllW + $iBdCnt > self::MAX_REGULAR_SECTOR_COUNT) {
        throw new Exception('OLE version-3 output exceeds the maximum sector count.');
    }

New OLEPpsRootTest coverage verifies:

  • CFB v3 header minor version and sector shifts.
  • 4095-byte mini-stream and 4096-byte regular-stream boundaries, with OLE reader round-trips.
  • 109 FAT sectors (header DIFAT only), 110 FAT sectors (one DIFAT sector), and 237 FAT sectors (two linked DIFAT sectors).
  • Individual-stream and aggregate-mini-stream v3 size-limit rejection.
  • A minimal legacy XLS writer/read-back integration path.

OLEPpsRootTest includes a temporary test-local getDataByName() helper for the mini-stream reader round-trip. It can be removed once OLE::getDataByName() is available on the target branch.

Validation completed:

vendor/bin/phpunit --configuration phpunit.xml.dist \
  tests/PhpSpreadsheetTests/Shared/OLETest.php \
  tests/PhpSpreadsheetTests/Shared/OLEPhpunit10Test.php \
  tests/PhpSpreadsheetTests/Shared/OLEPpsRootTest.php

15 tests, 173 assertions

vendor/bin/phpcs src/PhpSpreadsheet/Shared/OLE/PPS/Root.php tests/PhpSpreadsheetTests/Shared/OLEPpsRootTest.php --report=full

vendor/bin/phpstan analyse --memory-limit=2048M src/PhpSpreadsheet/Shared/OLE/PPS/Root.php tests/PhpSpreadsheetTests/Shared/OLEPpsRootTest.php

Manual verification for #4811

From the repository root, generate the reported 63,000-row fixture with a sufficiently large PHP memory limit:

php -d memory_limit=2048M -r '
require "vendor/autoload.php";

$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$sheet = $spreadsheet->setActiveSheetIndex(0);
$value = str_repeat("A", 512);

for ($row = 1; $row <= 63000; ++$row) {
    foreach (range("A", "H") as $column) {
        $sheet->setCellValue($column . $row, $value);
    }
}

(new \PhpOffice\PhpSpreadsheet\Writer\Xls($spreadsheet))->save("phpspreadsheet-issue4811-repro.xls");
$spreadsheet->disconnectWorksheets();
'

Inspect offsets 44, 68, and 72 in the generated CFB header. The expected values are 109 FAT sectors, 0xFFFFFFFE (ENDOFCHAIN) as the first DIFAT sector, and 0 DIFAT sectors. The generated file can then be opened in Excel to verify that it no longer shows the repair dialog described in #4811.

The legacy XLS writer emitted a non-canonical CFB v3 header and malformed DIFAT metadata when exactly 109 FAT sectors were needed. It also miscounted DIFAT capacity beyond that boundary.

Use the fixed CFB v3 sector profile, write minor version 0x003E, correct DIFAT allocation, and reject oversized v3 stream layouts. Add boundary and round-trip coverage.
@n3crosis
n3crosis force-pushed the fix/ole-cfb-root-conformance branch from b94f86d to cd2298f Compare September 4, 2026 13:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant