diff --git a/.github/workflows/sp11-driver-audit.yml b/.github/workflows/sp11-driver-audit.yml new file mode 100644 index 00000000000000..da5000912658f2 --- /dev/null +++ b/.github/workflows/sp11-driver-audit.yml @@ -0,0 +1,24 @@ +name: SP11 driver audit tools + +on: + pull_request: + paths: + - tools/sp11-driver-audit/** + - .github/workflows/sp11-driver-audit.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + parser-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '22' + - name: Test bounded parsers and command-line behavior + run: node --test tools/sp11-driver-audit/*.test.mjs diff --git a/tools/sp11-driver-audit/README.md b/tools/sp11-driver-audit/README.md new file mode 100644 index 00000000000000..cf8aa89bce3e03 --- /dev/null +++ b/tools/sp11-driver-audit/README.md @@ -0,0 +1,181 @@ +# Surface Pro 11 driver research + +This 7.3 integration imports research tools and historical validation notes. +The notes retain their original 7.2 source identities and evidence limits; they +do not describe a new build or installation of the current kernel. Native +OV13858 and VD55 camera code is tracked separately in +[PR #73](https://github.com/ooaklee/linux_ms_dev_kit-sp11/pull/73). + +These independently written Linux development tools inventory an extracted +Surface driver package and decode selected camera resource metadata. They are +the first source deliverable in the SP11 parity work. They do not install +drivers, execute Windows programs, access hardware, generate device trees, or +establish that a device is present. + +The intended production result is native Linux kernel and userspace source +that builds and runs without Windows or the MSI. Windows package inspection is +an optional research step, outside that production dependency chain. Device +firmware is a separate dependency whose Linux availability and device-specific +authentication must be established. + +## Run + +Node.js 22 or newer is sufficient for these tools; no npm dependencies are +required. Use an extracted package you already have: + +```sh +node tools/sp11-driver-audit/inventory.mjs /path/to/SurfaceUpdate > inventory.json +node tools/sp11-driver-audit/aeob.mjs --input /path/to/CAMS_RES_MSHW0491.bin +node tools/sp11-driver-audit/camera-config.mjs \ + --kind prld --input /path/to/CAMP_PRLD_MSHW0495.bin +node tools/sp11-driver-audit/camera-config.mjs \ + --kind pcfg --input /path/to/CAMP_PCFG_MSHW0495.bin +node tools/sp11-driver-audit/camera-config.mjs \ + --kind scfg --input /path/to/SCFG_REAR_MSHW0491.bin +node tools/sp11-driver-audit/camera-power.mjs \ + --input /path/to/CAMS_RES_MSHW0491.bin +node tools/sp11-driver-audit/windows-resources.mjs \ + --format registry-utf16-arm64 --input /path/to/camera-platform.reg +node tools/sp11-driver-audit/chromatix.mjs \ + --input /path/to/com.surface.sensormodule.rfc_ov13858.bin +node --test tools/sp11-driver-audit/*.test.mjs +``` + +The inventory includes SHA-256 identities, file sizes, PE architecture, INF +versions, manufacturer-declared model bindings, service declarations and source +file declarations. Model identities are package declarations; subsequent IDs +in a model row are compatible IDs, not additional proof of installed hardware. +Service declarations include sections that might never be selected. Broken +string declarations remain unresolved and produce warnings. Child symbolic +links are skipped. INF path strings are never opened as commands or files. + +The AeoB reader checks the ACPI V1 output-buffer structure: lengths, top-level +counts, nested boundaries and the minimum four-byte argument data union. This +does not align every payload to four bytes. Documented four-byte integers and +observed eight-byte integers retain full precision. Buffers and unknown tags +retain a digest and byte range without guessed device semantics. Unsupported +layouts fail explicitly. Generated JSON is research data and should remain +outside the kernel source tree. + +The camera interpreter uses the [recorded field specification](camera-fields.md). +It preserves both platform configuration banks, raw values and unknown tail +fields. It does not select a board, turn orientation codes into angles, or +translate Windows resource indices into Linux GPIO or CCI numbers. + +The power interpreter names the verified TLMM GPIO fields, type-one PMIC +voltage/enable/mode votes and resource-delay units. Unknown mechanisms, +component subtrees and extension fields remain opaque. Wrapper metadata is +stored once, with offset references from resources, so long device labels are +not repeated for every operation. Configured sequence order is not measured +electrical timing, and a GPIO level or voltage is not proof of a sensor pin's +function or ownership. + +The Windows resource reader explicitly selects the observed ARM64 packed +layout; it does not autodetect architectures or evaluate firmware. It accepts +bounded UTF-16LE registry exports containing typed `hex(8)` lists, or a raw +list with `--format cm-arm64`. Unknown layouts, variable-length resource +types, MSI interrupts and ambiguous/duplicate registry value names fail +explicitly. Registry output can contain device-instance identifiers: retain +it privately. Nothing is imported into the registry or applied to hardware. + +The Chromatix reader validates the observed 3.0.1 container, CRC32, section +extents, named descriptors, register-sequence references and resource budgets. +It does not execute operations or emit Linux driver code. Default output +contains identities, offsets, counts and digests, not register values or raw +payloads. Explicit `--include-registers` and `--include-payloads` options are +for local research; keep their output outside the source tree. Unknown +operation codes must not be interpreted as production policy. + +## Initial measured coverage + +The April 2026 package named +`SurfacePro11_ARM_Win11_26100_26.041.12746.0` contains 1,126 files and 205 INF +files. The inventory retains bindings from every INF, with two malformed vendor +string declarations reported as warnings. Static PE headers identify 190 ARM64 +and 17 I386 objects; the package also contains DSP and firmware objects in other +formats. + +All 72 files beginning with `AeoB` follow the checked ACPI argument-storage +layout, containing 47,008 records with maximum nesting depth six. Two short +arguments require the minimum data union; the earlier narrow reader rejected +`qcadc/ADC1.bin` because it missed this documented storage rule. Structural +decoding does not establish the meaning of ADC resources or coverage of every +possible argument encoding. + +The new power interpreter accepts all 13 inspected camera-resource files; +uninterpreted component subtrees remain opaque rather than being treated as +covered policies. The rear file exposes three GPIO, eight PMIC-vote and three +delay records. A saved MSHW0495 camera-platform allocation independently +matches the two CCI memory ranges in Linux. Its four byte-identical export +copies constitute one observation, not four hardware trials; allocation data +is not itself a translated WDF resource trace. See the field specification +for the combined evidence and its limits. + +The sensor-container reader validates the inspected IMX681, OV13858 and three +OV02C10 containers. The front module has 3,035 named descriptors and 915 register +rows; the rear has 2,117 descriptors and 632 rows. Some TPG and IR records use +unsupported semantics and fail explicitly. Front sequences were compared with +public IMX681 reference tables; rear output sizes agree with output-size +registers. Neither check establishes CSI timing or successful rear capture. + +## Implementation tracks + +| Track | Existing Linux position | Next implementation evidence | +| --- | --- | --- | +| Front IMX681 | Native driver and OLED graph; prior preview and indicator tests | Compare verified power, lane and stream/error lifecycle with this package | +| Rear OV13858 | Native OF power-management foundation under development; no Denali graph | Verified board resources, module-specific PHY/modes, actuator and indicator ownership | +| IR VD55G0/VD55G1 | Native family-driver port isolated from the tested carrier; G0 illumination rejected | Actual module identity, board transport, sensor-only capture and separate illumination qualification | +| Touch/pen | Native transport and userspace exist; fresh-boot autoload repair is a separate series | Preserve repair and investigate actual lifecycle gaps | +| Suspend | Several device and SoC resource owners are involved | Decode power policies and compare failed Linux transitions with measured Windows behaviour | +| Sensors | Surface SAM support is distinct from the Qualcomm sensor hub | Transport, exact board configuration, orientation and calibration | +| USB4 | Router/retimer groundwork exists | Resource ownership, sideband and working firmware/ring lifecycle | +| Audio | Working protected kernel and userspace path; an ALSA update replaced the selector | Preserve sound while completing persistent userspace selection and scoped compatibility | +| Bluetooth factory identity | Existing native firmware-property consumer; separate bounded provider source candidate | Authenticate factory source, harden provider selection and qualify the installed boot path | + +Hardware identity comes from the actual board, firmware tables and device +identification. The bundle includes optional docks, modems, SSDs, panels and +camera assemblies; membership in the package does not justify enabling them. + +The [native OV13858 series](ov13858-native.md) is separate from these research +tools. It uses Linux resource interfaces, leaves existing sensor tables intact +and does not load the inspected configuration files. Its board-enablement and +hardware-qualification gates remain explicit. + +The [source-only camera bundle guide](native-source-bundles.md) describes +the independent OV13858 and VD55G0 patch sets, their native upstream/vendor +provenance and the source, object and module validation workflow. They are +foundations for qualification, not installable camera fixes. In particular, a +native IR sensor driver is not equivalent to safely enabling an illuminator. + +The [native Bluetooth provisioning source track](native-bluetooth-provisioning.md) +records a separate source-only dtbloader candidate, its exact native build and +synthetic coverage, and the provider/boot-chain gates that remain. It does not +add a kernel raw-partition reader or change the running device's factory identity. + +The [dated USB4 R5 validation supplement](native-usb4-r5-validation.md) records +the source-only firmware-parser correction, later native module/MODPOST proof, +and event-channel requirements identified by static analysis. It does not +qualify USB4 activation or provide modules for the running v26 kernel. + +The subsequent [USB4 resource-bounds correction](native-usb4-resource-bounds.md) +adds validation of the existing frontend's MMIO extents, source-linked host +tests and a fresh ARM64 object check. Its source-only companion patch does not +inherit R5's module proof or establish a working firmware event channel. + +The research checkout began at the older integration revision +`2cbd1ec3e2da385e7bd91fd65c63ba5a8fb5b865`. These tools are independent of that +kernel revision and are fork research infrastructure. Hardware changes must +use an explicitly selected carrier: the tested v8 source is +`8ba2b75713feb5c72bbc11dd8cce4048e23cdeea`, with the subsequent touch autoload +repair tracked in fork PR #69. Upstream `jg/ubuntu-qcom-x1e-7.2.y` had advanced +to 7.2.9 at `d84d9b29dffe2199bf295824f82099ee58997ce7` when inspected on +15 October 2026. That newer integration requires separate qualification. + +## Source and evidence + +Tests use synthetic fixtures and run without the driver package. The source is +GPL-2.0-only. No Windows executable, firmware payload, recovered pseudocode or +private device capture is included. Keep locally generated evidence separate +from the original source changes and preserve the input hashes for every +behavioural finding. Analysis tools are not themselves a working camera or a +proof of full Linux hardware parity. diff --git a/tools/sp11-driver-audit/aeob.mjs b/tools/sp11-driver-audit/aeob.mjs new file mode 100644 index 00000000000000..185d5f6a51c88d --- /dev/null +++ b/tools/sp11-driver-audit/aeob.mjs @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: GPL-2.0-only +// Original reader for observed ACPI_EVAL_OUTPUT_BUFFER_V1 files. No execution. +// https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/acpiioct/ns-acpiioct-_acpi_method_argument_v1 +import { createHash } from 'node:crypto'; +import { open } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const AEOB_LIMITS = Object.freeze({ + maxBytes: 16 * 1024 * 1024, + maxNodes: 100_000, + maxDepth: 32, +}); + +export class AeobDecodeError extends Error { + constructor(code, message, offset = 0) { + super(message); + this.name = 'AeobDecodeError'; + this.code = code; + this.offset = offset; + } +} + +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +function limitsFor(options) { + const limits = { ...AEOB_LIMITS }; + for (const [key, value] of Object.entries(options)) { + if (!Object.hasOwn(AEOB_LIMITS, key)) throw new TypeError(`Unknown decoder option: ${key}`); + if (!Number.isSafeInteger(value) || value < 1 || value > AEOB_LIMITS[key]) { + throw new RangeError(`${key} must be an integer from 1 to ${AEOB_LIMITS[key]}`); + } + limits[key] = value; + } + return limits; +} + +/** + * Decode structure only. ACPI_METHOD_ARGUMENT_V1 has a four-byte minimum data + * union, even for shorter strings or buffers; this is not four-byte alignment + * of longer payloads. The bundle also uses eight-byte integer payloads, which + * are preserved in full alongside the documented four-byte ULONG form. + * Header count is checked against parsed top-level records. Unknown tags stay + * opaque; incompatible layouts fail rather than guessing alternate counts. + * Integer values do not imply GPIOs, physical addresses, rates or policy. + */ +export function decodeAeob(input, options = {}) { + if (!(input instanceof Uint8Array)) throw new TypeError('Input must be bytes'); + const limits = limitsFor(options); + if (input.byteLength > limits.maxBytes) { + throw new AeobDecodeError('BYTE_LIMIT', 'Input exceeds the decoder byte limit'); + } + // Work on a stable bounded copy even when the caller supplied mutable bytes. + const bytes = Buffer.from(input); + const fail = (code, message, offset) => { + throw new AeobDecodeError(code, message, offset); + }; + if (bytes.length < 12) fail('SHORT_HEADER', 'AeoB header requires 12 bytes', 0); + if (!bytes.subarray(0, 4).equals(Buffer.from('AeoB', 'ascii'))) { + fail('BAD_MAGIC', 'Input does not have AeoB magic', 0); + } + const declaredBytes = bytes.readUInt32LE(4); + const declaredTopLevelCount = bytes.readUInt32LE(8); + if (declaredBytes !== bytes.length) { + fail('LENGTH_MISMATCH', 'Declared file length differs from actual length', 4); + } + if (declaredTopLevelCount > limits.maxNodes) { + fail('NODE_LIMIT', 'Declared top-level count exceeds the node limit', 8); + } + let nodeCount = 0; + let maxDepthSeen = 0; + const unknownKinds = new Set(); + + function records(start, end, depth) { + if (depth > limits.maxDepth) { + fail('DEPTH_LIMIT', 'Nested records exceed the depth limit', start); + } + maxDepthSeen = Math.max(maxDepthSeen, depth); + const result = []; + let offset = start; + while (offset < end) { + if (end - offset < 4) { + fail('SHORT_RECORD_HEADER', 'Incomplete TLV header in its container', offset); + } + if (++nodeCount > limits.maxNodes) { + fail('NODE_LIMIT', 'Parsed records exceed the node limit', offset); + } + const kind = bytes.readUInt16LE(offset); + const payloadBytes = bytes.readUInt16LE(offset + 2); + const payloadOffset = offset + 4; + const payloadEnd = payloadOffset + payloadBytes; + const recordEnd = payloadOffset + Math.max(4, payloadBytes); + if (recordEnd > end) { + fail('RECORD_OVERRUN', 'Argument storage extends past its container', offset); + } + const node = { offset, kind, payloadOffset, payloadBytes }; + if (payloadBytes < 4) node.paddingBytes = 4 - payloadBytes; + const payload = bytes.subarray(payloadOffset, payloadEnd); + if (kind === 0) { + if (payloadBytes !== 4 && payloadBytes !== 8) { + fail('INTEGER_LENGTH', 'Integer requires a 4- or 8-byte payload', offset); + } + node.encoding = payloadBytes === 8 ? 'uint64-le' : 'uint32-le'; + node.value = payloadBytes === 8 + ? payload.readBigUInt64LE().toString(10) + : payload.readUInt32LE().toString(10); + } else if (kind === 1) { + if (payloadBytes === 0 || payload[payloadBytes - 1] !== 0) { + fail('STRING_TERMINATOR', 'Observed string kind requires a terminal NUL', offset); + } + for (let i = 0; i < payloadBytes - 1; i++) { + if (payload[i] === 0 || payload[i] > 0x7f) { + fail('STRING_ENCODING', 'String is not single-NUL-terminated ASCII', payloadOffset + i); + } + } + node.encoding = 'ascii-nul'; + node.value = payload.subarray(0, -1).toString('ascii'); + } else if (kind === 3) { + node.encoding = 'nested-tlv'; + node.children = records(payloadOffset, payloadEnd, depth + 1); + } else { + node.encoding = kind === 2 ? 'buffer' : 'opaque'; + node.sha256 = digest(payload); + if (kind !== 2) unknownKinds.add(kind); + } + result.push(node); + offset = recordEnd; + } + return result; + } + + const decoded = records(12, bytes.length, 0); + if (decoded.length !== declaredTopLevelCount) { + fail('COUNT_MISMATCH', 'Header count differs from parsed top-level record count', 8); + } + return { + schema: 'sp11-acpi-output-observed-v2', + interpretation: 'ACPI output structure only; device field meanings remain unverified', + sha256: digest(bytes), + bytes: bytes.length, + header: { magic: 'AeoB', declaredBytes, declaredTopLevelCount }, + stats: { + nodeCount, + maxDepth: maxDepthSeen, + unknownKinds: [...unknownKinds].sort((a, b) => a - b), + }, + records: decoded, + }; +} + +export async function readAeobBytes(path) { + const handle = await open(path, 'r'); + try { + const stat = await handle.stat(); + if (!stat.isFile()) throw new TypeError('Input must be a regular file'); + if (stat.size > AEOB_LIMITS.maxBytes) { + throw new AeobDecodeError('BYTE_LIMIT', 'Input exceeds the decoder byte limit'); + } + // One extra byte detects a file that grew after stat without unbounded read. + const bytes = Buffer.alloc(stat.size + 1); + let offset = 0; + while (offset < bytes.length) { + const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset !== stat.size) { + throw new AeobDecodeError('FILE_CHANGED', 'Input length changed while reading'); + } + return bytes.subarray(0, offset); + } finally { + await handle.close(); + } +} + +async function main(args) { + if (args.length !== 2 || args[0] !== '--input' || !args[1]) { + throw new TypeError('Usage: node tools/sp11-driver-audit/aeob.mjs --input FILE'); + } + const decoded = decodeAeob(await readAeobBytes(args[1])); + process.stdout.write(`${JSON.stringify(decoded, null, 2)}\n`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + main(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${JSON.stringify({ + error: error.code ?? error.name, + message: error.message, + ...(error instanceof AeobDecodeError ? { offset: error.offset } : {}), + })}\n`); + process.exitCode = 1; + }); +} diff --git a/tools/sp11-driver-audit/aeob.test.mjs b/tools/sp11-driver-audit/aeob.test.mjs new file mode 100644 index 00000000000000..7dd5680cc4fb0e --- /dev/null +++ b/tools/sp11-driver-audit/aeob.test.mjs @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: GPL-2.0-only +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { AEOB_LIMITS, AeobDecodeError, decodeAeob } from './aeob.mjs'; + +// Synthetic fixtures are independently generated, not copied driver payloads. +function tlv(kind, payload, paddingValue = 0) { + const header = Buffer.alloc(4); + header.writeUInt16LE(kind, 0); + header.writeUInt16LE(payload.length, 2); + return Buffer.concat([header, payload, Buffer.alloc(Math.max(0, 4 - payload.length), paddingValue)]); +} + +function integer(value) { + const payload = Buffer.alloc(8); + payload.writeBigUInt64LE(value); + return tlv(0, payload); +} + +function container(children) { return tlv(3, Buffer.concat(children)); } + +function file(records, count = records.length) { + const body = Buffer.concat(records); + const header = Buffer.alloc(12); + header.write('AeoB', 0, 'ascii'); + header.writeUInt32LE(body.length + 12, 4); + header.writeUInt32LE(count, 8); + return Buffer.concat([header, body]); +} + +function rejects(code, bytes, options) { + assert.throws(() => decodeAeob(bytes, options), (error) => { + assert.ok(error instanceof AeobDecodeError); + assert.equal(error.code, code); + assert.ok(Number.isInteger(error.offset)); + return true; + }); +} + +test('nested structure preserves precise unsigned 64-bit values and offsets', () => { + const bytes = file([container([ + integer(18_446_744_073_709_551_615n), + tlv(1, Buffer.from('reference\0', 'ascii')), + ])]); + const result = decodeAeob(bytes); + assert.equal(result.header.declaredTopLevelCount, 1); + assert.deepEqual(result.stats, { nodeCount: 3, maxDepth: 1, unknownKinds: [] }); + assert.equal(result.records[0].offset, 12); + assert.equal(result.records[0].children[0].payloadOffset, 20); + assert.equal(result.records[0].children[0].value, '18446744073709551615'); + assert.equal(result.records[0].children[1].value, 'reference'); + assert.equal(result.sha256, createHash('sha256').update(bytes).digest('hex')); +}); + +test('empty structure and empty terminated string are valid', () => { + assert.deepEqual(decodeAeob(file([])).records, []); + assert.equal(decodeAeob(file([tlv(1, Buffer.from([0]))])).records[0].value, ''); + assert.deepEqual(decodeAeob(file([container([])])).records[0].children, []); +}); + +test('unknown kind remains opaque with length, offsets and digest only', () => { + const payload = Buffer.from([0xff, 0, 7, 4]); + const result = decodeAeob(file([tlv(17, payload)])); + assert.deepEqual(result.stats.unknownKinds, [17]); + assert.deepEqual(result.records[0], { + offset: 12, kind: 17, payloadOffset: 16, payloadBytes: 4, + encoding: 'opaque', sha256: createHash('sha256').update(payload).digest('hex'), + }); + assert.equal('value' in result.records[0], false); +}); + +test('short arguments consume a minimum four-byte data union, not alignment', () => { + const payload = Buffer.from([0x11, 0x22]); + const result = decodeAeob(file([container([ + tlv(2, payload, 0xa5), + tlv(1, Buffer.from('a\0')), + tlv(1, Buffer.from('four\0')), + integer(9n), + ])])); + const children = result.records[0].children; + assert.equal(children[0].encoding, 'buffer'); + assert.equal(children[0].paddingBytes, 2); + assert.equal(children[0].sha256, createHash('sha256').update(payload).digest('hex')); + assert.deepEqual(result.stats.unknownKinds, []); + assert.deepEqual(children.map((item) => item.offset), [16, 24, 32, 41]); + assert.equal(children[3].value, '9'); + // Padding belongs to the data union, but may not run into a sibling. + const truncated = Buffer.from([2, 0, 2, 0, 0x11, 0x22]); + rejects('RECORD_OVERRUN', file([truncated])); + rejects('RECORD_OVERRUN', file([container([truncated]), integer(1n)])); +}); + +test('documented ULONG and observed uint64 integer forms preserve precision', () => { + const payload = Buffer.alloc(4); + payload.writeUInt32LE(0xffff_ffff); + const result = decodeAeob(file([tlv(0, payload)])); + assert.equal(result.records[0].encoding, 'uint32-le'); + assert.equal(result.records[0].value, '4294967295'); +}); + +test('header, byte limits and declared counts are enforced', () => { + rejects('SHORT_HEADER', Buffer.alloc(11)); + const magic = file([]); magic[0] = 0; + rejects('BAD_MAGIC', magic); + const length = file([]); length.writeUInt32LE(13, 4); + rejects('LENGTH_MISMATCH', length); + rejects('COUNT_MISMATCH', file([integer(1n)], 0)); + rejects('COUNT_MISMATCH', file([container([integer(1n)])], 2)); + rejects('NODE_LIMIT', file([], AEOB_LIMITS.maxNodes + 1)); + rejects('BYTE_LIMIT', file([integer(1n)]), { maxBytes: 20 }); +}); + +test('container length cannot consume siblings or partial headers', () => { + rejects('SHORT_RECORD_HEADER', file([Buffer.from([0, 0, 8])])); + const overrun = Buffer.from([0, 0, 8, 0, 1]); + rejects('RECORD_OVERRUN', file([overrun])); + rejects('SHORT_RECORD_HEADER', file([container([Buffer.from([0, 0])])])); + rejects('RECORD_OVERRUN', file([container([overrun]), integer(1n)])); + // Bytes beyond the minimum data union are not silently treated as padding. + rejects('SHORT_RECORD_HEADER', file([container([ + tlv(2, Buffer.from([0x11, 0x22])), Buffer.from([0, 0]), + ])])); +}); + +test('observed integer and string encodings reject unsupported forms', () => { + rejects('INTEGER_LENGTH', file([tlv(0, Buffer.alloc(7))])); + rejects('STRING_TERMINATOR', file([tlv(1, Buffer.alloc(0))])); + rejects('STRING_TERMINATOR', file([tlv(1, Buffer.from('no nul'))])); + rejects('STRING_ENCODING', file([tlv(1, Buffer.from([65, 0, 66, 0]))])); + rejects('STRING_ENCODING', file([tlv(1, Buffer.from([0xc3, 0xa9, 0]))])); +}); + +test('recursive depth and total record counts are independently bounded', () => { + const nested = file([container([container([integer(1n)])])]); + rejects('DEPTH_LIMIT', nested, { maxDepth: 1 }); + rejects('NODE_LIMIT', nested, { maxNodes: 2 }); + assert.equal(decodeAeob(nested, { maxDepth: 2, maxNodes: 3 }).stats.nodeCount, 3); + for (const options of [{ maxBytes: 0 }, { maxNodes: 1.2 }, { maxDepth: 33 }]) { + assert.throws(() => decodeAeob(file([]), options), RangeError); + } + assert.throws(() => decodeAeob(file([]), { guessedPadding: true }), TypeError); + assert.throws(() => decodeAeob(file([]), { toString: 1 }), TypeError); + assert.throws(() => decodeAeob('AeoB'), TypeError); +}); + +test('CLI emits JSON on success and a bounded diagnostic on failure', async () => { + const temporary = await mkdtemp(join(tmpdir(), 'sp11-aeob-test-')); + try { + const input = join(temporary, 'synthetic.bin'); + await writeFile(input, file([integer(42n)])); + const cli = fileURLToPath(new URL('./aeob.mjs', import.meta.url)); + const ok = spawnSync(process.execPath, [cli, '--input', input], { encoding: 'utf8' }); + assert.equal(ok.status, 0, ok.stderr); + assert.equal(JSON.parse(ok.stdout).records[0].value, '42'); + assert.equal(ok.stderr, ''); + await writeFile(input, Buffer.alloc(4)); + const bad = spawnSync(process.execPath, [cli, '--input', input], { encoding: 'utf8' }); + assert.equal(bad.status, 1); + assert.equal(bad.stdout, ''); + assert.equal(JSON.parse(bad.stderr).error, 'SHORT_HEADER'); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +}); diff --git a/tools/sp11-driver-audit/camera-config.mjs b/tools/sp11-driver-audit/camera-config.mjs new file mode 100644 index 00000000000000..e534c215df54a6 --- /dev/null +++ b/tools/sp11-driver-audit/camera-config.mjs @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-2.0-only +// Original metadata interpretation from the recorded field specification. +// These are Windows resource indices, not Linux GPIOs or device-tree values. +import {decodeAeob,readAeobBytes} from './aeob.mjs'; +import {fileURLToPath} from 'node:url'; +import {resolve} from 'node:path'; + +function words(input) { + const decoded=decodeAeob(input); + if(decoded.records.length!==1||decoded.records[0].kind!==3)throw new Error('Expected one observed camera tuple'); + const children=decoded.records[0].children; + if(children.some(x=>x.kind!==0||BigInt(x.value)>0xffffffffn))throw new Error('Expected 32-bit camera fields in integer records'); + return {sha256:decoded.sha256,words:children.map(x=>({value:Number(x.value),offset:x.payloadOffset}))}; +} + +export function decodeSensorConfiguration(input) { + const decoded=decodeAeob(input); + if(decoded.records.length!==1||decoded.records[0].kind!==3)throw new Error('Expected one observed sensor tuple'); + const fields=decoded.records[0].children; + if(fields.length!==5||fields[0].kind!==1||fields[1].kind!==1|| + fields.slice(2).some(x=>x.kind!==0||BigInt(x.value)>0xffffffffn)) { + throw new Error('Observed sensor configuration requires two strings and three 32-bit fields'); + } + const [bus,probe,tail]=fields.slice(2).map(x=>Number(x.value)); + const frequencyCode=(bus>>>16)&3; + const registerAddressTypeCode=(bus>>>18)&3; + const registerDataTypeCode=(bus>>>20)&3; + return { + schema:'sp11-camera-scfg-observed-v1',sourceSHA256:decoded.sha256, + names:{sensorModule:fields[0].value,tuning:fields[1].value}, + bus:{offset:fields[2].payloadOffset,packed:`0x${bus.toString(16).padStart(8,'0')}`, + windowsSlaveAddress:bus&0xffff,observedInterfaceAddressByte:bus&0xff, + observedCciSlaveId:(bus>>>1)&0x7f,frequencyCode, + observedFrequencySetting:[100,400,1000,400][frequencyCode], + registerAddressTypeCode,registerDataTypeCode, + observedAddressType:[1,2,3,1][registerAddressTypeCode], + observedDataType:[1,2,3,1][registerDataTypeCode], + uninterpretedUpperBits:bus>>>22}, + probe:{offset:fields[3].payloadOffset,register:probe&0xffff,expectedValue:probe>>>16}, + uninterpretedTail:{offset:fields[4].payloadOffset,value:tail}, + limits:['Names are labels only; no referenced file is opened.', + 'The raw Windows field is not a Linux address; the observed platform HAL shifts its interface byte right one and masks seven bits.', + 'Frequency setting units and CCI bus mapping are not established by this consumer.', + 'Enum code three uses a logged Windows fallback; it does not establish hardware capability.', + 'Observed type two uses a two-byte register address and a big-endian two-byte probe value.', + 'These probe fields do not establish power sequencing, mode tables or device-tree wiring.'], + }; +} + +export function decodePrivacyConfiguration(input) { + const source=words(input); + if(source.words.length!==6)throw new Error('Observed privacy configuration requires six descriptors'); + const descriptors=source.words.map(({value,offset},index)=>{ + const mechanismCode=(value>>>8)&3; + const record={index,offset,packed:`0x${value.toString(16).padStart(8,'0')}`,sensorSlots:Array.from({length:8},(_,i)=>i).filter(i=>(value&(1<>>10)&7; + Object.assign(record,{mechanism:'gpio',resourceIndex,activeLow:Boolean(value&(1<<13)),executionPath:resourceIndex<6?'resource-hub-write':'log-only-selector'}); + } + else if(mechanismCode===3)Object.assign(record,{mechanism:'component-pstate',componentIndex:(value>>>10)&7}); + else if(mechanismCode===2)Object.assign(record,{mechanism:'pmic',opaqueSelectors:{low:(value>>>10)&7,high:(value>>>13)&7,resource:(value>>>16)&0x3ff}}); + else record.mechanism='inactive-or-unsupported'; + return record; + }); + return {schema:'sp11-camera-prld-observed-v1',sourceSHA256:source.sha256,descriptors,limits:['Sensor slots require independent identity mapping.','Resource-hub indices are not physical pin numbers.','Polarity describes a Windows GPIO operation; no Linux GPIO mapping is generated.','No hardware accessed or configured.']}; +} + +export function decodePlatformConfiguration(input) { + const source=words(input); + if(source.words.length<9)throw new Error('Observed platform configuration requires flags and eight sensor words'); + const flags=source.words[0].value; + const sensorWords=source.words.slice(1,9).map(({value,offset},index)=>({ + index,bank:Math.floor(index/4),slot:index%4,offset,packed:`0x${value.toString(16).padStart(8,'0')}`, + orientationCode:value&15,directionCode:(value>>>4)&15, + flashPresent:Boolean(value&(1<<8)),flashIndex:(value>>>9)&7, + flashCciTimerIndex:(value>>>12)&3,triggerType:(value>>>14)&3, + windowsI2CMaster:(value>>>16)&15,csiPhyIndex:(value>>>20)&15, + faceAuthentication:Boolean(value&(1<<24)),shutterType:(value>>>25)&1, + })); + return {schema:'sp11-camera-pcfg-observed-v1',sourceSHA256:source.sha256,flags:{packed:`0x${flags.toString(16)}`,sensorSlotMask:flags&15,uninterpretedBits4To7:(flags>>>4)&15,hasPositionOffsets:Boolean(flags&(1<<8)),hasPrivacyShutter:Boolean(flags&(1<<9))},sensorWords,uninterpretedTail:source.words.slice(9),limits:['Four logical slots share one presence mask; two alternative word banks are preserved.','Choosing a bank requires independently verified platform selection.','Orientation codes are not converted to degrees.','Windows I2C master indices are not automatically Linux CCI bus numbers.','No device-tree or runtime configuration is generated.']}; +} + +if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)) { + const args=process.argv.slice(2); + try { + const decoders={prld:decodePrivacyConfiguration,pcfg:decodePlatformConfiguration,scfg:decodeSensorConfiguration}; + if(args.length!==4||args[0]!=='--kind'||!Object.hasOwn(decoders,args[1])||args[2]!=='--input')throw new Error('Usage: node camera-config.mjs --kind prld|pcfg|scfg --input FILE'); + const input=await readAeobBytes(args[3]); + console.log(JSON.stringify(decoders[args[1]](input),null,2)); + }catch(error){console.error(JSON.stringify({error:error.code??error.name,message:error.message}));process.exitCode=1;} +} diff --git a/tools/sp11-driver-audit/camera-config.test.mjs b/tools/sp11-driver-audit/camera-config.test.mjs new file mode 100644 index 00000000000000..bea109000b611d --- /dev/null +++ b/tools/sp11-driver-audit/camera-config.test.mjs @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-2.0-only +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {decodePrivacyConfiguration,decodePlatformConfiguration,decodeSensorConfiguration} from './camera-config.mjs'; + +function fixture(values) { + const records=values.map(v=>{const bytes=Buffer.alloc(12);bytes.writeUInt16LE(0);bytes.writeUInt16LE(8,2);bytes.writeBigUInt64LE(BigInt(v),4);return bytes;}); + const header=Buffer.alloc(16);header.write('AeoB');header.writeUInt32LE(16+12*values.length,4);header.writeUInt32LE(1,8);header.writeUInt16LE(3,12);header.writeUInt16LE(12*values.length,14); + return Buffer.concat([header,...records]); +} + +test('privacy descriptors separate sensor membership, mechanism and resource index',()=>{ + const decoded=decodePrivacyConfiguration(fixture([0x106,0x501,0,0,0,0])); + assert.deepEqual(decoded.descriptors[0].sensorSlots,[1,2]); + assert.equal(decoded.descriptors[0].resourceIndex,0); + assert.equal(decoded.descriptors[1].resourceIndex,1); + assert.equal(decoded.descriptors[1].mechanism,'gpio'); + assert.equal(decoded.descriptors[1].executionPath,'resource-hub-write'); + assert.equal('physicalGPIO' in decoded.descriptors[1],false); +}); +test('privacy polarity and component selection are distinct fields',()=>{ + const result=decodePrivacyConfiguration(fixture([(1<<13)|(5<<10)|(1<<8)|1,(4<<10)|(3<<8)|2,0,0,0,0])); + assert.equal(result.descriptors[0].activeLow,true);assert.equal(result.descriptors[0].resourceIndex,5); + assert.equal(result.descriptors[1].componentIndex,4);assert.equal('activeLow' in result.descriptors[1],false); +}); +test('platform fields preserve both banks without inventing camera identity',()=>{ + const decoded=decodePlatformConfiguration(fixture([0x107,0x110000,0x230010,0x1000110,0,0,0,0,0,123])); + assert.equal(decoded.flags.sensorSlotMask,7);assert.equal(decoded.flags.hasPositionOffsets,true); + assert.equal(decoded.sensorWords[0].windowsI2CMaster,1);assert.equal(decoded.sensorWords[0].csiPhyIndex,1); + assert.equal(decoded.sensorWords[1].windowsI2CMaster,3);assert.equal(decoded.sensorWords[1].csiPhyIndex,2); + assert.equal(decoded.sensorWords[2].faceAuthentication,true);assert.equal(decoded.sensorWords[4].bank,1); + assert.equal('sensorName' in decoded.sensorWords[0],false);assert.equal(decoded.uninterpretedTail[0].value,123); +}); +test('unsupported tuple sizes and large fields cannot be silently truncated',()=>{ + assert.throws(()=>decodePrivacyConfiguration(fixture([1])),/six/); + assert.throws(()=>decodePlatformConfiguration(fixture([1])),/eight/); + assert.throws(()=>decodePrivacyConfiguration(fixture([0x100000000n,0,0,0,0,0])),/32-bit/); +}); +test('platform flags describe four logical slots and retain other bits',()=>{ + const decoded=decodePlatformConfiguration(fixture([0x1f7,0,0,0,0,0,0,0,0])); + assert.equal(decoded.flags.sensorSlotMask,7); + assert.equal(decoded.flags.uninterpretedBits4To7,15); +}); +test('GPIO selectors six and seven are logged rather than written',()=>{ + const decoded=decodePrivacyConfiguration(fixture([(6<<10)|0x101,(7<<10)|0x101,0,0,0,0])); + assert.equal(decoded.descriptors[0].resourceIndex,6); + assert.equal(decoded.descriptors[1].resourceIndex,7); + assert.equal(decoded.descriptors[0].executionPath,'log-only-selector'); + assert.equal(decoded.descriptors[1].executionPath,'log-only-selector'); +}); +test('slot bit four is opaque and timer bits remain separate from trigger type',()=>{ + const decoded=decodePlatformConfiguration(fixture([0x10,(2<<12)|(3<<14),0,0,0,0,0,0,0])); + assert.equal(decoded.flags.sensorSlotMask,0); + assert.equal(decoded.flags.uninterpretedBits4To7,1); + assert.equal(decoded.sensorWords[0].flashCciTimerIndex,2); + assert.equal(decoded.sensorWords[0].triggerType,3); +}); + +function sensorFixture(values=[0x00150020,0x12345678,0],names=['sensor-label','tuning-label']) { + const numberRecords=fixture(values).subarray(16); + const strings=names.map(name=>{ + const payload=Buffer.from(`${name}\0`,'ascii'); + const bytes=Buffer.alloc(4+Math.max(4,payload.length)); + bytes.writeUInt16LE(1);bytes.writeUInt16LE(payload.length,2);payload.copy(bytes,4); + return bytes; + }); + const children=Buffer.concat([...strings,numberRecords]); + const header=Buffer.alloc(16);header.write('AeoB'); + header.writeUInt32LE(16+children.length,4);header.writeUInt32LE(1,8); + header.writeUInt16LE(3,12);header.writeUInt16LE(children.length,14); + return Buffer.concat([header,children]); +} + +test('sensor probe fields separate Windows address, enum codes and identity',()=>{ + const decoded=decodeSensorConfiguration(sensorFixture()); + assert.equal(decoded.bus.windowsSlaveAddress,0x20); + assert.equal(decoded.bus.observedCciSlaveId,0x10); + assert.equal(decoded.bus.frequencyCode,1); + assert.equal(decoded.bus.observedFrequencySetting,400); + assert.equal(decoded.bus.observedAddressType,2); + assert.equal(decoded.bus.observedDataType,2); + assert.equal(decoded.probe.register,0x5678); + assert.equal(decoded.probe.expectedValue,0x1234); + assert.equal('linuxAddress' in decoded.bus,false); + assert.equal('frequencyHz' in decoded.bus,false); + assert.equal(decoded.names.sensorModule,'sensor-label'); +}); + +test('sensor labels stay inert and opaque fields and enum fallback remain visible',()=>{ + const decoded=decodeSensorConfiguration(sensorFixture([0xfff00023,0xffffffff,7],['../missing','a'])); + assert.equal(decoded.names.sensorModule,'../missing'); + assert.equal(decoded.bus.windowsSlaveAddress,0x23); + assert.equal(decoded.bus.registerDataTypeCode,3); + assert.equal(decoded.bus.observedDataType,1); + assert.equal(decoded.bus.uninterpretedUpperBits,0x3ff); + assert.equal(decoded.uninterpretedTail.value,7); + const wide=decodeSensorConfiguration(sensorFixture([0x00151234,0,0])); + assert.equal(wide.bus.windowsSlaveAddress,0x1234); + assert.equal(wide.bus.observedInterfaceAddressByte,0x34); + assert.equal(wide.bus.observedCciSlaveId,0x1a); +}); + +test('sensor interpreter rejects unverified extensions, missing labels and wide fields',()=>{ + assert.throws(()=>decodeSensorConfiguration(fixture([1,2,3])),/two strings/); + assert.throws(()=>decodeSensorConfiguration(sensorFixture([1,2])),/two strings/); + assert.throws(()=>decodeSensorConfiguration(sensorFixture([1,2,3,4])),/two strings/); + assert.throws(()=>decodeSensorConfiguration(sensorFixture([0x100000000n,2,3])),/32-bit/); + assert.throws(()=>decodeSensorConfiguration(sensorFixture([1,2,3],['one'])),/two strings/); +}); diff --git a/tools/sp11-driver-audit/camera-fields.md b/tools/sp11-driver-audit/camera-fields.md new file mode 100644 index 00000000000000..1a3dfc0a106e46 --- /dev/null +++ b/tools/sp11-driver-audit/camera-fields.md @@ -0,0 +1,315 @@ +# Camera field observations + +These field definitions come from static analysis of the shipped ARM64 +`qccamplatform8380.sys`, SHA-256 +`836714ec41f92f45af363d7bf3c9b9cfc2cccdbd19a1c57f355b471068648049`. +REA 6.5.0 with Ghidra 12.1.4 imported the PE image at `0x140000000`. +Function boundaries, string references, recovered control flow and ARM64 +instructions were inspected locally. The original inputs and recovered code +remain local; this document records interoperable data-field observations. + +## PRLD + +The routine at `0x140001858`, referenced by the privacy-configuration log +string, consumes six numeric fields from the first nested tuple. Its ARM64 +instructions at `0x140001a18` through `0x140001ad4` confirm the bit extraction. +The execution path at `0x140001b38` distinguishes GPIO, PMIC and component +P-state operations and maintains aggregate usage counters. + +| Bits | Observed role | Limits | +| --- | --- | --- | +| 0–7 | Membership in eight sensor slots | Mapping slots to physical sensors requires separate evidence | +| 8–9 | Mechanism: 1 GPIO, 2 PMIC, 3 component P-state | Zero is inactive or unsupported | +| 10–12 | GPIO selector or component index | GPIO selectors 0–5 use Resource Hub; 6–7 only log in the observed path | +| 13 | GPIO output inversion | Applies to the GPIO mechanism | +| 10–15, 16–25 | PMIC selector fields | Their physical meanings are not established | + +For the observed MSHW0495 file, `0x106` selects slots 1 and 2 through GPIO +resource index 0; `0x501` selects slot 0 through GPIO resource index 1. Thus the +decimal value 1281 is a packed descriptor. Neither value is a physical GPIO. +The mapping from these resource indices to the board's GPIO resources remains +separate from this format decoding. + +## PCFG + +The routine at `0x140002ef0` reads a flag field and eight sensor words, then +handles four logical sensor slots. Flag bits 0–3 form their presence mask; +bits 4–7 remain uninterpreted. It selects the second group of four words when +another callback returns `0x27b`; the interpreter preserves both banks. This +observation does not identify the callback's platform ID namespace. + +The ARM64 bit extractions at `0x140003238` through `0x1400032a4` and the adjacent +field-specific log arguments support these names: + +| Bits in each sensor word | Observed field | +| --- | --- | +| 0–3 | Orientation enumeration | +| 4–7 | Direction enumeration | +| 8 | Flash presence | +| 9–11 | Flash index | +| 12–13 | Flash/CCI timer index | +| 14–15 | Trigger type | +| 16–19 | Windows I2C master index | +| 20–23 | CSIPHY index | +| 24 | Face-authentication flag | +| 25 | Shutter type | + +Flag bit 8 indicates position-offset data and bit 9 indicates privacy-shutter +data. Tail words remain uninterpreted in the source tool. Orientation values +are not degrees. An I2C index alone does not identify a Linux bus, and neither +CSI lane layout nor link frequency follows from these fields. + +The MSHW0495 bank-zero words begin `0x00110000`, `0x00230010`, `0x01000110`. +The second word's CSIPHY 2 agrees with the existing front-camera graph. The +combined CCI evidence below now maps logical master 3 to Linux `cci1_i2c1` +on the captured platform. The PHY match alone does not prove the identities +of every sensor slot or justify enabling the rear or IR hardware. + +## SCFG + +The inspected front and rear sensor configuration files each contain two +string labels and three numeric words. REA inspected the front consumer's +getter at `0x140001b48`, probe at `0x140009f18` and read helper at `0x14000ad68`; +the rear equivalents are `0x140005188`, `0x14000d8a0` and `0x14000e6c8`. +The front consumer SHA-256 is +`80a8e4a1ef8f0dacfbc2e8c6919cb269993057ffd3133c2ef7016ff742e46f03`; +the rear consumer SHA-256 is +`b7d7a278c5e7b92ebf35f870a7e06cbad670ffb35bfaf40106e27b09bf33fabb`. + +| First numeric word | Observed role | +| --- | --- | +| Bits 0–15 | Raw Windows slave-address field | +| Bits 16–17 | Frequency code: 0/1/2 map to 100/400/1000; 3 falls back to 400 | +| Bits 18–19 | Register-address type: 0/1/2 map to 1/2/3; 3 falls back to 1 | +| Bits 20–21 | Register-data type: same enumeration mapping | +| Bits 22–31 | Uninterpreted | + +The second word contains the probe register in bits 0–15 and expected value +in bits 16–31. The third word remains opaque. Observed type two uses a two-byte +register address and combines two returned bytes high first. The numerical +frequency-setting units are not established by this consumer. Enum fallback +is observed Windows behavior, not an endorsement of unsupported hardware. + +Rear `0xd855300b` means a probe of register `0x300b` for bytes `d8 55`, agreeing +with the native OV13858 driver's three-byte `00 d8 55` check at `0x300a`. +Front `0x0aff0004` instead describes a probe at `0x0004` for `0aff`; it is not +the existing native IMX681 model-ID check at `0x0016` for `0681` and does not +justify replacing that check. Both inspected files contain raw address `0x20`. +Neither consumer establishes seven-bit normalization or Linux CCI bus mapping. +The 16-bit stored field is copied into a byte in the inspected interface; +both the stored field and that byte are reported without normalizing either. +The tool retains names as inert labels and opens no referenced paths. + +### Platform CCI conversion + +Further analysis resolves the address conversion outside those consumers. +In the platform binary identified at the start of this document, command +builder `0x14000f838` extracts bits 1–7 of the raw interface byte; instruction +`0x14000f85c` is the corresponding `ubfx`. It places the result in the CCI +SET_PARAM address field. Native `drivers/i2c/busses/i2c-qcom-cci.c` puts its +seven-bit Linux address into the same field without that shift. Consequently +raw `0x20` corresponds to CCI/Linux address `0x10`, not Linux address `0x20`. +The interpreter reports that observed conversion alongside both raw fields. + +Controller routing is a separate step. Initialization at +`0x14000a988` splits a nonnegative logical master index into controller +instance `index / 2` and master `index % 2`. However, `0x14003b850` obtains +controller bases dynamically from translated firmware resource ordinals two +and three. The split alone does not prove their Linux identities. + +### Captured controller resources + +A saved `ACPI\QCOM0C32` / MSHW0495 registry export supplies the missing +allocation addresses. Its SHA-256 is +`a46ebcfc358440fc30c196f9dc0c14b028f06a197bd6964bcd93b374b592f061`. +`BootConfig` and `AllocConfig` contain the same 200-byte resource list, +SHA-256 `f340bd8f69c8e52ce6bfcf82e6912eeea896c0c4a86ba405d5c75815bca9f857`. +Four byte-identical copies are one observation. + +The observed list has one full descriptor, version/revision 1/1 and nine +20-byte partial descriptors. Absolute ordinal 2 is memory at `0x0ac15000`, +length `0x1000`; ordinal 3 is memory at `0x0ac16000`, length `0x1000`. +Those match `cci0` and `cci1` in `hamoa.dtsi`. IRQ vectors 492 and 303 also +agree with the respective Linux GIC SPI 460 and 271 plus the architectural +32 offset. Combining that capture with the independently verified ordinal +consumer and index split gives this controller map: + +| Logical master | Captured controller memory | Linux bus | +| --- | --- | --- | +| 0 | `0x0ac15000` | `cci0_i2c0` | +| 1 | `0x0ac15000` | `cci0_i2c1` | +| 2 | `0x0ac16000` | `cci1_i2c0` | +| 3 | `0x0ac16000` | `cci1_i2c1` | + +These are captured registry allocations, not an evaluated `_CRS` or a direct +WDF translated-list dump. Do not present them as either, infer physical sensor +presence from them, or assume a different board revision has identical wiring. +Controller mapping alone is not board enablement. + +The bounded reader follows the documented +[resource-list structure](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ns-wdm-_cm_resource_list) +and [partial-descriptor fields](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ns-wdm-_cm_partial_resource_descriptor), +with an explicit observed-layout selection. It never applies resources or +turns connection IDs into Linux GPIO numbers. + +### Sensor slots and configured routes + +Separate instruction-level inspection closes the PCFG consumer association; +this is not inferred from privacy-indicator labels. Each sensor getter asks +the platform for a `0x388`-byte table with command `0x232083`. Rear getter +`0x140005188` copies the first slot at table offset `0x24`. Front getter +`0x140001b48` copies offset `0x90` and records index one; auxiliary getter +`0x140001300` copies offset `0xfc` and records index two. These offsets match +the parser's `0x6c`-byte slot stride. The inspected auxiliary driver SHA-256 is +`e5b6b064f39cf239ab07e22ca2434e3c08c93b691dc7d27cebb90861226efa75`. + +Combining the MSHW0495 bank-zero values, those consumers, the controller map +and each SCFG address gives these configured routes: + +| Sensor consumer | PCFG slot | Logical master | Linux bus/address | CSIPHY index | +| --- | --- | --- | --- | --- | +| Rear | 0 | 1 | `cci0_i2c1`, `0x10` | 1 | +| Front | 1 | 3 | `cci1_i2c1`, `0x10` | 2 | +| Auxiliary | 2 | 0 | `cci0_i2c0`, `0x60` | 0 | + +The auxiliary address is not the front/rear address. Its SCFG file, SHA-256 +`1c848ac4ddbe12948cae4affee421e3a457cac61f98dacb30043841bf0bd1bff`, +contains raw `0xc0`, with expected probe bytes `30 47` at register zero. +That is consistent with the first two little-endian bytes of native VD55G0 +ID `0x53354730`, but not a substitute for a physical full-ID/revision check. +The alternate PCFG bank is zero in this file; the effective bank and actual +board revision still need confirmation. No graph, rail ownership or sensor +presence is established merely by publishing these configured routes. + +## Rear mode and PHY request + +The rear module's serialized mode fields were traced through the actual +deserializer and request producer in `QcDeviceMFT8380.dll`, SHA-256 +`c241b7fbb2ec54e439752a1ea7ad25da10ca740012a54bd0e7a87ea94a141c35`. +The chain from `0x180881218` through `0x18087eab0` and `0x18087d020` +expands each 252-byte serialized mode into a `0x248`-byte runtime record. +The deserializer and downstream consumers use the following proven mappings: + +| Field | Serialized mode offset | Runtime offset | +| --- | --- | --- | +| Line length | `0x24` | `0x44` | +| Frame length | `0x28` | `0x48` | +| Output pixel clock | `0x34` | `0x54` | +| Maximum frame rate | `0x40` | `0x60` | +| Lane count | `0x50` | `0x74` | +| Requested settle time | `0x5c` | `0x84` | +| D-PHY/C-PHY phase | `0x60` | `0x88` | + +All three inspected rear modes describe four-lane D-PHY, RAW10, 100 ns +requested settle time and 474,240,000 output pixels/s. The producer applies +a runtime percentage overhead before calculating per-lane bit rate. Only +at zero overhead would the nominal D-PHY request be 592,800,000 Hz: +`474240000 * 10 / 4 / 2`. The effective overhead is unresolved. This is a +metadata-derived receiver request, not verified sensor PLL timing or a new +Linux link-frequency mode. + +The published `vtPixelClock` is synthesized from line length, frame length +and maximum FPS in this path. It is not the output pixel clock. The listed +frame length is 3214, while the last listed sensor writes encode 3208; +dynamic frame-duration/exposure programming remains untraced. Do not derive +Linux exposure/blanking controls by conflating these clock domains. + +`qccammipicsi8380.sys`, SHA-256 +`033f5b1431ad4c76a12ac3b7f0a5be42e460a03bcff40d249511b3034786d407`, +separates D-PHY from C-PHY and consumes the bit-rate and nanosecond fields. +The configured `laneAssign = 0x3210` is unpacked as `0,1,2,3`. Native +instructions show that forwarding even though decompiler output omits its +local array; decompiled indirect-call arguments alone were insufficient. + +The four-lane Windows PHY mask `0xd5` agrees with Linux's X1E PHY table. +Linux's gen2 enable path produces that mask from receiver data positions +`0,1,2,3`, copying the receiver endpoint positions directly. Sensor-local +`1,2,3,4` numbering and clock lane zero are a different endpoint contract: +do not blindly copy them onto the CAMSS receiver or reinterpret the fixed +hardware clock-enable bit as a DT clock-lane number. Physical lane mapping, +polarities, sensor PLL and runtime capture still require qualification. + +## Power-resource delays + +Static analysis of `qcpep8380.sys`, SHA-256 +`e5bdeb8c0fb8823990f7520a19c00807711cf909f91f8d67db78ffb18c1b2277`, +establishes milliseconds for the normal runtime `DELAY` resource path: + +1. The provider registered at `0x140089880` handles the interface queried by + the camera platform; `0x14003de98` validates the AeoB signature. +2. Type-nine parsing at `0x140414680` copies the first integer unscaled into + resource data at offset `0x18`. +3. Conversion at `0x14040aa74` and construction at `0x1404265f0` preserve it + into runtime-node offset `0x20`. +4. Execution at `0x140128170` converts it to negative `value * 10000` relative + 100-nanosecond ticks for the sleep path, or `value * 1000` microseconds for + the busy-wait path. + +Thus the front and rear resource values 1 and 10 represent 1 ms and 10 ms. +Elapsed time can already satisfy a delay, and the crashdump path only logs its +completion; this is not a claim that every path always sleeps. The platform's +fixed 20-microsecond polling and named sensor-mode delay fields are separate. +These observations do not establish supply mapping, regulator ownership or +safe Linux sequencing by themselves. + +## GPIO and regulator resource fields + +Further instruction-level PEP analysis establishes the mandatory six-field +`TLMMGPIO` payload. Parsing at `0x1404100e0`, conversion at `0x14040a2f0` +and construction at `0x14041fe70` preserve these fields into the runtime +node consumed by `0x14011d290`: + +| Field | Observed meaning | +| --- | --- | +| 0 | Physical TLMM GPIO number | +| 1 | Raw output level, not a logical polarity flag | +| 2 | Function/mux encoding | +| 3 | Output-enable encoding | +| 4 | Pull encoding | +| 5 | Drive-strength encoding | + +Instructions `0x1402082e4..0x14020833c` clear or set output bit 1 at the +GPIO's I/O register. The rear tuples select GPIO110, physical low then high, +and low during power-down. They select GPIO mux, output enabled, no pull +and a drive encoding equivalent to native 2 mA. There is no inversion flag +in these six fields. This does not independently prove that the net is +OV13858 reset rather than another module enable. + +For observed `PMICVREGVOTE` input type 1, `0x140440af0` selects internal +type zero and `0x140412cb0` reads voltage, software-enable, mode and a fourth +vote field. The fourth field and optional extensions remain uninterpreted. +The native service callback chain reaches `0x1402271c0`, which emits voltage +divided by 1,000, enable and mode into the same RPMh fields used by Linux. +The resource-name table maps the rear votes to `ldom6` at 1.8 V, `ldom1` +at 1.2 V, `ldom5` at 2.8 V and `ldob16` at 2.9 V. Off tuples remove that +consumer's voltage and enable votes; shared physical rails may remain on. + +The configured rear order holds GPIO110 low, votes the first three rails, +delays 1 ms, votes the 2.9 V rail, enables MCLK1 at 19.2 MHz, drives GPIO110 +high and delays 10 ms. Power-down configures low, delays 1 ms, stops MCLK1 +and withdraws the rail votes in reverse order. This is configured order, +not measured electrical timing or exclusive ownership. In particular, +voltage does not prove sensor pin roles or that `ldob16` powers autofocus. + +Linux TLMM pinctrl/GPIO and RPMh regulator providers already express these +operations. A production driver must use those frameworks, not a Windows +resource interpreter or a raw-MMIO compatibility layer. Native +`regulator_bulk_enable()` dispatches asynchronously: array order must not be +mistaken for serialization. Any required sensor-specific order needs a +reviewed native sequence and tracked error unwind after pin/rail ownership +and timing are established. + +References: [ACPI output-buffer layout](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/acpiioct/ns-acpiioct-_acpi_eval_output_buffer_v1), +[argument storage](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/acpiioct/ns-acpiioct-_acpi_method_argument_v1), +[relative sleep units](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-kedelayexecutionthread), +[stall units](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-kestallexecutionprocessor). + +## Linux boundary + +The production driver must use native Linux firmware-node properties and +driver frameworks. It must not load these Windows configuration containers or +require the MSI at build or runtime. Recovered resource requirements inform +reviewed native configuration; unresolved physical mappings remain explicit +research tasks. Sensor identification, power ownership, error unwinding, +stream lifetime and indicator sharing require their own implementation and +tests. diff --git a/tools/sp11-driver-audit/camera-power.mjs b/tools/sp11-driver-audit/camera-power.mjs new file mode 100644 index 00000000000000..a83ca56ee30c34 --- /dev/null +++ b/tools/sp11-driver-audit/camera-power.mjs @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: GPL-2.0-only +// Research-only, original decoder of statically observed resource fields. +// This never executes a driver or generates Linux hardware configuration. +import {decodeAeob, readAeobBytes} from './aeob.mjs'; +import {resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +export const POWER_FIELD_EVIDENCE = Object.freeze({ + driver: 'qcpep8380.sys', + sha256: 'e5bdeb8c0fb8823990f7520a19c00807711cf909f91f8d67db78ffb18c1b2277', + provider: 'REA 6.5.0 / Ghidra 12.1.4', + tlmm: Object.freeze({ + parser: '0x1404100e0', + conversion: '0x14040a2f0', + construction: '0x14041fe70', + executor: '0x14011d290', + physicalLevelInstructions: '0x1402082e4..0x14020833c', + nativePinLayoutBlob: '8d2b8246170b4dc4cdc63f16ada344f3b9c9bec1', + nativePinConfigBlob: 'fd0add4e71da14d6e74b0517e1015926c6c15d1b', + }), + pmic: Object.freeze({ + parser: '0x140412cb0', + inputVoteTypeConversion: '0x140440af0', + executor: '0x140126b90', + rpmhConversion: '0x1402271c0', + voltageEnableModeInstructions: '0x140227a04..0x140227a30', + nativeRegulatorBlob: '73856e3df337add151ce1bb9ce9ce4b9a836384c', + }), + delay: Object.freeze({ + parser: '0x140414680', + executor: '0x140128170', + sleepConversionInstructions: '0x140033704..0x140033710', + stallConversionInstructions: '0x140128c5c..0x140128c7c', + }), +}); + +class PowerDecodeError extends Error { + constructor(message, offset) { + super(message); + this.name = 'PowerDecodeError'; + this.code = 'POWER_FIELDS'; + this.offset = offset; + } +} + +function fail(message, node) { + throw new PowerDecodeError(message, node.offset); +} + +function uint32(node, name) { + if (!node || node.kind !== 0 || BigInt(node.value) > 0xffffffffn) { + fail(`${name} requires a 32-bit unsigned integer`, node ?? {offset: 0}); + } + return Number(node.value); +} + +function payload(tuple, minimum) { + const fields = tuple.children; + if (fields.length !== 2 || fields[1].kind !== 3 || fields[1].children.length < minimum) { + fail(`${fields[0].value} requires a nested payload with at least ${minimum} fields`, tuple); + } + return fields[1].children; +} + +function opaque(tuple, reason, context) { + return {offset: tuple.offset, interpretation: 'opaque', reason, contextOffsets: context, raw: tuple}; +} + +function decodeResource(tuple, context) { + const mechanism = tuple.children[0].value; + if (mechanism === 'TLMMGPIO') { + const fields = payload(tuple, 6); + const values = fields.slice(0, 6).map((field, index) => uint32(field, `TLMMGPIO field ${index}`)); + const [gpioNumber, rawOutputLevel, functionCode, outputEnableCode, pullCode, driveCode] = values; + // Do not silently emulate the executor's bit masks on unsupported values. + if (gpioNumber > 1023 || rawOutputLevel > 1 || functionCode > 15 || + outputEnableCode > 1 || pullCode > 3 || driveCode > 7) { + return opaque(tuple, 'TLMMGPIO values outside the interpreted native field ranges', context); + } + return { + mechanism, offset: tuple.offset, contextOffsets: context, interpretation: 'observed-fields', + gpioNumber, rawOutputLevel, functionCode, outputEnableCode, pullCode, driveCode, + equivalentDriveStrengthMilliamp: (driveCode + 1) * 2, + fields: fields.slice(0, 6), opaqueTail: fields.slice(6), + }; + } + if (mechanism === 'PMICVREGVOTE') { + const fields = payload(tuple, 2); + if (fields[0].kind !== 1) fail('PMICVREGVOTE resource name requires a string', fields[0]); + const voteTypeCode = uint32(fields[1], 'PMICVREGVOTE vote type'); + if (voteTypeCode !== 1) return opaque(tuple, 'Uninterpreted PMICVREGVOTE vote type', context); + if (fields.length < 6) fail('PMICVREGVOTE type 1 requires six payload fields', tuple); + const [voltageMicrovolts, softwareEnableVote, hardwareModeCode] = fields.slice(2, 5) + .map((field, index) => uint32(field, `PMICVREGVOTE field ${index + 2}`)); + uint32(fields[5], 'PMICVREGVOTE opaque fourth vote field'); + if (softwareEnableVote > 1) { + return opaque(tuple, 'Uninterpreted non-boolean software enable vote', context); + } + return { + mechanism, offset: tuple.offset, contextOffsets: context, interpretation: 'observed-fields', + resourceName: fields[0].value, voteTypeCode, observedInternalVoteType: 0, + voltageMicrovolts, softwareEnableVote, hardwareModeCode, + opaqueFourthVoteField: fields[5], opaqueTail: fields.slice(6), + fields: fields.slice(0, 5), + }; + } + if (mechanism === 'DELAY') { + const fields = payload(tuple, 1); + return { + mechanism, offset: tuple.offset, contextOffsets: context, interpretation: 'observed-fields', + configuredMilliseconds: uint32(fields[0], 'DELAY duration'), + durationField: fields[0], opaqueTail: fields.slice(1), + }; + } + return opaque(tuple, 'Uninterpreted resource mechanism or container', context); +} + +export function decodeCameraPower(input) { + // Bounds, recursion depth, integers, TLV lengths, counts and string encoding + // are checked by the shared structural reader before field interpretation. + const source = decodeAeob(input); + if (!source.records.length || source.records.some(node => node.kind !== 3)) { + throw new PowerDecodeError('Expected one or more camera power tuples', 12); + } + const resources = []; + const wrappers = []; + function walk(tuple, context) { + const fields = tuple.children; + if (!fields.length || fields[0].kind !== 1) { + resources.push(opaque(tuple, 'Tuple does not start with a string label', context)); + return; + } + const tag = fields[0].value; + if (tag === 'DEVICE' || tag === 'DSTATE') { + // Both observed DEVICE wrappers (with or without a numeric identifier) + // retain labels only; no platform identity or runtime policy is selected. + const prefix = tag === 'DEVICE' && fields[1]?.kind === 0 ? 3 : 2; + if (fields.length < prefix || + (tag === 'DEVICE' ? fields[prefix - 1].kind !== 1 : fields[1].kind !== 0) || + fields.slice(prefix).some(node => node.kind !== 3)) { + fail(`${tag} wrapper has an unsupported field layout`, tuple); + } + if (tag === 'DSTATE' || prefix === 3) uint32(fields[1], `${tag} ordinal`); + wrappers.push({tag, offset: tuple.offset, parentOffset: context.at(-1) ?? null, + fields: fields.slice(1, prefix)}); + const nextContext = [...context, tuple.offset]; + for (const child of fields.slice(prefix)) walk(child, nextContext); + return; + } + resources.push(decodeResource(tuple, context)); + } + for (const record of source.records) walk(record, []); + return { + schema: 'sp11-camera-power-observed-v1', sourceSHA256: source.sha256, + evidence: POWER_FIELD_EVIDENCE, wrappers, resources, + limits: [ + 'Research only: no executable, referenced path, device or configuration is opened or applied.', + 'Tuple order is configured order, not measured timing or proof of exclusive resource ownership.', + 'Raw GPIO levels do not establish reset identity or a Linux active-low flag.', + 'Rail names are inert labels; voltage does not establish sensor-core or autofocus wiring.', + 'Enable and voltage are consumer votes; a zero vote does not prove a shared rail is electrically off.', + 'DELAY units apply only to this observed resource path, not other sensor delay fields.', + 'Unknown mechanisms, vote types and extensions remain opaque; no Linux device-tree policy is generated.', + 'Native regulator_bulk_enable is asynchronous; its array order does not reproduce a serialized resource list.', + ], + }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const args = process.argv.slice(2); + if (args.length !== 2 || args[0] !== '--input' || !args[1]) { + throw new TypeError('Usage: node camera-power.mjs --input FILE'); + } + console.log(JSON.stringify(decodeCameraPower(await readAeobBytes(args[1])), null, 2)); + } catch (error) { + console.error(JSON.stringify({error: error.code ?? error.name, message: error.message, + ...(error.offset === undefined ? {} : {offset: error.offset})})); + process.exitCode = 1; + } +} diff --git a/tools/sp11-driver-audit/camera-power.test.mjs b/tools/sp11-driver-audit/camera-power.test.mjs new file mode 100644 index 00000000000000..655bd2383bd01f --- /dev/null +++ b/tools/sp11-driver-audit/camera-power.test.mjs @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-2.0-only +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {decodeCameraPower, POWER_FIELD_EVIDENCE} from './camera-power.mjs'; + +function record(kind, payload) { + const bytes = Buffer.alloc(4 + Math.max(4, payload.length)); + bytes.writeUInt16LE(kind); + bytes.writeUInt16LE(payload.length, 2); + payload.copy(bytes, 4); + return bytes; +} +const integer = value => { + const bytes = Buffer.alloc(8); + bytes.writeBigUInt64LE(BigInt(value)); + return record(0, bytes); +}; +const string = value => record(1, Buffer.from(`${value}\0`, 'ascii')); +const tuple = children => record(3, Buffer.concat(children)); +const resource = (name, fields) => tuple([string(name), tuple(fields)]); +function fixture(records) { + const payload = Buffer.concat(records); + const header = Buffer.alloc(12); + header.write('AeoB'); + header.writeUInt32LE(12 + payload.length, 4); + header.writeUInt32LE(records.length, 8); + return Buffer.concat([header, payload]); +} +const gpio = values => resource('TLMMGPIO', values.map(integer)); +const vote = (name, values) => resource('PMICVREGVOTE', [string(name), ...values.map(integer)]); +const decodeOne = record => decodeCameraPower(fixture([record])).resources[0]; + +test('six TLMM fields retain raw low/high levels without inventing reset polarity', () => { + const decoded = decodeCameraPower(fixture([gpio([110, 0, 0, 1, 0, 0]), gpio([110, 1, 0, 1, 0, 0])])); + assert.deepEqual(decoded.resources.map(item => item.rawOutputLevel), [0, 1]); + const first = decoded.resources[0]; + assert.equal(first.gpioNumber, 110); + assert.equal(first.outputEnableCode, 1); + assert.equal(first.functionCode, 0); + assert.equal(first.pullCode, 0); + assert.equal(first.equivalentDriveStrengthMilliamp, 2); + assert.equal('activeLow' in first, false); + assert.equal('resetGPIO' in first, false); + assert.equal(first.fields[0].payloadOffset, first.fields[0].offset + 4); +}); + +test('TLMM bitfield boundaries are explicit; unsupported values stay opaque, not masked', () => { + const valid = decodeOne(gpio([1023, 1, 15, 1, 3, 7])); + assert.equal(valid.gpioNumber, 1023); + assert.equal(valid.equivalentDriveStrengthMilliamp, 16); + for (const values of [[1024, 0, 0, 1, 0, 0], [110, 2, 0, 1, 0, 0], + [110, 0, 16, 1, 0, 0], [110, 0, 0, 2, 0, 0], [110, 0, 0, 1, 4, 0], [110, 0, 0, 1, 0, 8]]) { + const unsupported = decodeOne(gpio(values)); + assert.equal(unsupported.interpretation, 'opaque'); + assert.deepEqual(unsupported.raw.children[1].children.map(item => Number(item.value)), values); + } +}); + +test('TLMM extension fields and strings remain opaque rather than introducing inversion', () => { + const decoded = decodeOne(resource('TLMMGPIO', [...[110, 0, 0, 1, 0, 0].map(integer), + string('EGPIO_ENABLE'), integer(0xffffffffffffffffn)])); + assert.equal(decoded.opaqueTail[0].value, 'EGPIO_ENABLE'); + assert.equal(decoded.opaqueTail[1].value, '18446744073709551615'); + assert.equal('activeLow' in decoded, false); +}); + +test('type-one PMIC votes expose only verified voltage, enable and mode fields', () => { + const on = decodeOne(vote('PPP_RESOURCE_ID_LDO6_M', [1, 1800000, 1, 7, 0])); + assert.equal(on.observedInternalVoteType, 0); + assert.equal(on.voltageMicrovolts, 1800000); + assert.equal(on.softwareEnableVote, 1); + assert.equal(on.hardwareModeCode, 7); + assert.equal(on.opaqueFourthVoteField.value, '0'); + assert.equal('supplyName' in on, false); + assert.equal('actuator' in on, false); + const off = decodeOne(vote('../not-opened', [1, 0, 0, 7, 0])); + assert.equal(off.resourceName, '../not-opened'); + assert.equal(off.voltageMicrovolts, 0); + assert.equal(off.softwareEnableVote, 0); +}); + +test('unverified PMIC vote types and non-boolean enable values are not guessed', () => { + const other = decodeOne(resource('PMICVREGVOTE', [string('resource'), integer(12), string('unknown')])); + assert.equal(other.interpretation, 'opaque'); + assert.equal(other.raw.children[1].children[2].value, 'unknown'); + const enable = decodeOne(vote('PPP_RESOURCE_ID_LDO16_B', [1, 2900000, 2, 7, 0])); + assert.equal(enable.interpretation, 'opaque'); + assert.equal('voltageMicrovolts' in enable, false); +}); + +test('PMIC extensions remain opaque and no name is resolved to a file or board consumer', () => { + const item = decodeOne(resource('PMICVREGVOTE', [string('PPP_RESOURCE_ID_LDO16_B'), + ...[1, 2900000, 1, 7, 0].map(integer), string('SUPPRESSIBLE')])); + assert.equal(item.opaqueTail[0].value, 'SUPPRESSIBLE'); + assert.equal('autofocusSupply' in item, false); + assert.equal('client' in item, false); +}); + +test('resource delays are milliseconds, with optional metadata preserved verbatim', () => { + const result = decodeOne(resource('DELAY', [integer(10), integer(123)])); + assert.equal(result.configuredMilliseconds, 10); + assert.equal(result.opaqueTail[0].value, '123'); + assert.equal('microseconds' in result, false); + assert.equal(decodeOne(resource('DELAY', [integer(0)])).configuredMilliseconds, 0); +}); + +test('DEVICE/DSTATE wrappers retain context and configured list order without Linux conversion', () => { + const input = fixture([tuple([string('DEVICE'), integer(133), string('\\_SB.CAMS'), + tuple([string('COMPONENT'), integer(0)]), + tuple([string('DSTATE'), integer(0), gpio([110, 0, 0, 1, 0, 0]), + resource('DELAY', [integer(1)]), gpio([110, 1, 0, 1, 0, 0])]), + tuple([string('DSTATE'), integer(3), gpio([110, 0, 0, 1, 0, 0])])])]); + const result = decodeCameraPower(input); + assert.equal(result.resources[0].interpretation, 'opaque'); + assert.deepEqual(result.resources.slice(1).map(item => item.mechanism), ['TLMMGPIO', 'DELAY', 'TLMMGPIO', 'TLMMGPIO']); + const wrapper = offset => result.wrappers.find(item => item.offset === offset); + assert.equal(wrapper(result.resources[1].contextOffsets[0]).fields[1].value, '\\_SB.CAMS'); + assert.equal(wrapper(result.resources[1].contextOffsets[1]).fields[0].value, '0'); + assert.equal(wrapper(result.resources.at(-1).contextOffsets[1]).fields[0].value, '3'); + assert.ok(result.resources[1].offset < result.resources[2].offset); +}); + +test('wrapper labels are emitted once, not amplified across every child resource', () => { + const label = 'long-inert-device-label-'.repeat(1000); + const records = Array.from({length: 50}, () => resource('DELAY', [integer(1)])); + const input = fixture([tuple([string('DEVICE'), string(label), tuple([string('DSTATE'), integer(0), ...records])])]); + const result = decodeCameraPower(input); + assert.equal(result.resources.length, 50); + assert.equal(result.wrappers.length, 2); + assert.equal(JSON.stringify(result).split(label).length - 1, 1); + assert.ok(result.resources.every(item => item.contextOffsets.every(offset => Number.isSafeInteger(offset)))); + assert.equal(result.wrappers[1].parentOffset, result.wrappers[0].offset); +}); + +test('unknown mechanisms and unlabeled tuples remain opaque including their numeric widths', () => { + const unknown = decodeOne(resource('FUTURE_POWER', [integer(0xffffffffffffffffn)])); + assert.equal(unknown.interpretation, 'opaque'); + assert.equal(unknown.raw.children[1].children[0].value, '18446744073709551615'); + const unlabeled = decodeOne(tuple([integer(7)])); + assert.equal(unlabeled.interpretation, 'opaque'); +}); + +test('known malformed layouts, wrong types and over-wide interpreted fields fail explicitly', () => { + for (const bad of [gpio([110]), resource('TLMMGPIO', [string('110'), ...[0, 0, 1, 0, 0].map(integer)]), + gpio([0x100000000n, 0, 0, 1, 0, 0]), vote('rail', [1, 1800000]), + vote('rail', [1, 0x100000000n, 1, 7, 0]), resource('DELAY', []), + resource('DELAY', [integer(0x100000000n)]), + tuple([string('DEVICE'), integer(1), integer(2)]), + tuple([string('DSTATE'), string('0')]), + tuple([string('TLMMGPIO'), tuple([...[110, 0, 0, 1, 0, 0].map(integer)]), integer(9)])]) { + assert.throws(() => decodeCameraPower(fixture([bad])), error => error.code === 'POWER_FIELDS'); + } +}); + +test('shared structural limits reject hostile size, count and recursion before interpretation', () => { + assert.throws(() => decodeCameraPower(Buffer.alloc(16 * 1024 * 1024 + 1)), error => error.code === 'BYTE_LIMIT'); + const wrongCount = fixture([gpio([110, 0, 0, 1, 0, 0])]); + wrongCount.writeUInt32LE(2, 8); + assert.throws(() => decodeCameraPower(wrongCount), error => error.code === 'COUNT_MISMATCH'); + let deep = integer(0); + for (let i = 0; i < 34; i++) deep = tuple([deep]); + assert.throws(() => decodeCameraPower(fixture([deep])), error => error.code === 'DEPTH_LIMIT'); + assert.throws(() => decodeCameraPower(fixture([])), /power tuples/); + assert.throws(() => decodeCameraPower(fixture([integer(1)])), /power tuples/); +}); + +test('evidence metadata pins the actual driver and instruction-based field observations', () => { + const result = decodeCameraPower(fixture([resource('DELAY', [integer(1)])])); + assert.equal(result.evidence.sha256, POWER_FIELD_EVIDENCE.sha256); + assert.match(result.evidence.tlmm.physicalLevelInstructions, /^0x/); + assert.match(result.sourceSHA256, /^[a-f0-9]{64}$/); + assert.equal(Object.isFrozen(POWER_FIELD_EVIDENCE), true); +}); diff --git a/tools/sp11-driver-audit/chromatix.mjs b/tools/sp11-driver-audit/chromatix.mjs new file mode 100644 index 00000000000000..f4cd4ed848a95c --- /dev/null +++ b/tools/sp11-driver-audit/chromatix.mjs @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: GPL-2.0-only +// Independently authored structural reader for observed Chromatix 3.0.1 files. +// No vendor parser, driver code, binary payload or register table is embedded. +import { createHash } from 'node:crypto'; +import { open } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const CHROMATIX_LIMITS = Object.freeze({ + maxBytes: 16 * 1024 * 1024, + maxNodes: 100_000, + maxRegisterRows: 100_000, + maxTotalPayloadBytes: 32 * 1024 * 1024, + maxResolvedValues: 1_000_000, + maxResolvedBytes: 32 * 1024 * 1024, +}); + +export class ChromatixDecodeError extends Error { + constructor(code, message, offset = 0) { + super(message); + this.name = 'ChromatixDecodeError'; + this.code = code; + this.offset = offset; + } +} + +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const CRC_TABLE = Array.from({ length: 256 }, (_, value) => { + for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0); + return value >>> 0; +}); + +export function crc32(bytes) { + let value = 0xffffffff; + for (const byte of bytes) value = (value >>> 8) ^ CRC_TABLE[(value ^ byte) & 0xff]; + return (value ^ 0xffffffff) >>> 0; +} + +function optionsFor(options) { + const result = { ...CHROMATIX_LIMITS, includeRegisters: false, includePayloads: false }; + for (const [key, value] of Object.entries(options)) { + if (key === 'includeRegisters' || key === 'includePayloads') { + if (typeof value !== 'boolean') throw new TypeError(`${key} must be boolean`); + } else if (Object.hasOwn(CHROMATIX_LIMITS, key)) { + if (!Number.isSafeInteger(value) || value < 1 || value > CHROMATIX_LIMITS[key]) { + throw new RangeError(`${key} must be from 1 to ${CHROMATIX_LIMITS[key]}`); + } + } else { + throw new TypeError(`Unknown decoder option: ${key}`); + } + result[key] = value; + } + return result; +} + +/** + * Structure corroborated against the front IMX681 and rear OV13858 modules: + * fixed header; section 0 with 56-byte named descriptors; section 1 with their + * referenced bytes; trailing CRC32. Descriptor fields whose meaning is unknown + * remain opaque. Register rows are supported only when their named references + * and lengths agree with the observed 40-byte layout. Front mode 1/5 ordered + * pairs were independently compared with the GPL-2.0 reference data in + * https://github.com/karsies-wq/sp11-imx681-linux/tree/b08f76f40b8d7b715bd4da6aef484f86142cc147/sensor-data + * That comparison corroborates serialization, not electrical timing, hardware + * routing or successful capture. + */ +export function decodeChromatix(input, options = {}) { + if (!(input instanceof Uint8Array)) throw new TypeError('Input must be bytes'); + const config = optionsFor(options); + const fail = (code, message, offset = 0) => { + throw new ChromatixDecodeError(code, message, offset); + }; + if (input.byteLength > config.maxBytes) fail('BYTE_LIMIT', 'Input exceeds the byte limit'); + const bytes = Buffer.from(input); + if (bytes.length < 172) fail('SHORT_HEADER', 'Observed header requires at least 172 bytes'); + + function ascii(offset, length, label) { + const data = bytes.subarray(offset, offset + length); + const end = data.indexOf(0); + if (end < 0 || data.subarray(end).some((byte) => byte !== 0) || + data.subarray(0, end).some((byte) => byte < 0x20 || byte > 0x7e)) { + fail('ASCII_FIELD', `${label} must be NUL-terminated ASCII with zero padding`, offset); + } + return data.subarray(0, end).toString('ascii'); + } + + if (ascii(0, 28, 'Signature') !== 'QTI Chromatix Header') { + fail('BAD_MAGIC', 'Input does not have the observed Chromatix signature'); + } + const endOffset = bytes.readUInt32LE(28); + if (endOffset !== bytes.length - 4) { + fail('LENGTH_MISMATCH', 'Declared end excludes exactly one trailing CRC32', 28); + } + const version = { + major: bytes.readUInt16LE(32), + minor: bytes.readUInt16LE(34), + patch: bytes.readUInt32LE(36), + }; + if (version.major !== 3 || version.minor !== 0 || version.patch !== 1) { + fail('UNSUPPORTED_VERSION', 'Only the observed Chromatix 3.0.1 layout is supported', 32); + } + const storedCrc32 = bytes.readUInt32LE(endOffset); + const computedCrc32 = crc32(bytes.subarray(0, endOffset)); + if (storedCrc32 !== computedCrc32) fail('CRC_MISMATCH', 'Trailing CRC32 does not match', endOffset); + const parser = ascii(40, 48, 'Parser identification'); + const moduleName = ascii(88, 64, 'Module name'); + const tableOffset = bytes.readUInt32LE(160); + const sectionCount = bytes.readUInt32LE(164); + if (sectionCount !== 3 || tableOffset < 168 || tableOffset + sectionCount * 12 > endOffset) { + fail('SECTION_TABLE', 'Expected three complete section descriptors after the header', 160); + } + const sections = []; + const bySection = new Map(); + for (let index = 0; index < sectionCount; index++) { + const descriptorOffset = tableOffset + index * 12; + const id = bytes.readUInt32LE(descriptorOffset); + const offset = bytes.readUInt32LE(descriptorOffset + 4); + const length = bytes.readUInt32LE(descriptorOffset + 8); + if (bySection.has(id) || id > 2) fail('SECTION_ID', 'Duplicate or unsupported section ID', descriptorOffset); + if (offset < tableOffset + sectionCount * 12 || offset + length > endOffset) { + fail('SECTION_BOUNDS', 'Section lies outside the file data region', descriptorOffset + 4); + } + const section = { id, descriptorOffset, offset, bytes: length }; + sections.push(section); + bySection.set(id, section); + } + const orderedSections = [...sections].sort((a, b) => a.offset - b.offset); + for (let i = 1; i < orderedSections.length; i++) { + if (orderedSections[i].offset < orderedSections[i - 1].offset + orderedSections[i - 1].bytes) { + fail('SECTION_OVERLAP', 'Section regions overlap', orderedSections[i].descriptorOffset); + } + } + for (const section of sections) { + section.sha256 = hash(bytes.subarray(section.offset, section.offset + section.bytes)); + } + const descriptors = bySection.get(0); + const payloads = bySection.get(1); + if (descriptors.bytes % 56 !== 0) { + fail('DESCRIPTOR_LENGTH', 'Descriptor section length is not a multiple of 56', descriptors.offset); + } + const descriptorCount = descriptors.bytes / 56; + if (descriptorCount > config.maxNodes) fail('NODE_LIMIT', 'Descriptor count exceeds the limit', descriptors.offset); + + const nodes = []; + const byId = new Map(); + let totalPayloadBytes = 0; + for (let index = 0; index < descriptorCount; index++) { + const descriptorOffset = descriptors.offset + index * 56; + const id = bytes.readUInt32LE(descriptorOffset); + if (id === 0 || byId.has(id)) fail('NODE_ID', 'Descriptor IDs must be nonzero and unique', descriptorOffset); + const name = ascii(descriptorOffset + 4, 32, 'Descriptor name'); + const payloadRelativeOffset = bytes.readUInt32LE(descriptorOffset + 48); + const length = bytes.readUInt32LE(descriptorOffset + 52); + if (payloadRelativeOffset + length > payloads.bytes) { + fail('PAYLOAD_BOUNDS', 'Named descriptor payload exceeds its section', descriptorOffset + 48); + } + totalPayloadBytes += length; + if (totalPayloadBytes > config.maxTotalPayloadBytes) { + fail('PAYLOAD_LIMIT', 'Aggregate referenced payload length exceeds the limit', descriptorOffset); + } + const payloadOffset = payloads.offset + payloadRelativeOffset; + const node = { + id, name, descriptorOffset, + opaqueDescriptorWords: [36, 40, 44].map((offset) => bytes.readUInt32LE(descriptorOffset + offset)), + payloadOffset, payloadBytes: length, + sha256: hash(bytes.subarray(payloadOffset, payloadOffset + length)), + }; + if (config.includePayloads) { + node.payloadHex = bytes.subarray(payloadOffset, payloadOffset + length).toString('hex'); + } + nodes.push(node); + byId.set(id, node); + } + + let resolvedValueCount = 0; + let resolvedPayloadBytes = 0; + function referencedWords(id, expectedName, count, sourceOffset, wordBytes = 4) { + const node = byId.get(id); + if (!node || node.name !== expectedName || count > config.maxRegisterRows || node.payloadBytes !== count * wordBytes) { + fail('NAMED_REFERENCE', `Reference must identify ${expectedName} with count-matched ${wordBytes}-byte data`, sourceOffset); + } + // Charge every use, including repeated references to shared payloads. Bounds + // on unique nodes alone do not bound resolved arrays or their digest work. + resolvedValueCount += count; + resolvedPayloadBytes += count * wordBytes; + if (resolvedValueCount > config.maxResolvedValues || resolvedPayloadBytes > config.maxResolvedBytes) { + fail('RESOLUTION_LIMIT', 'Aggregate resolved reference budget exceeded', sourceOffset); + } + return { + nodeId: id, payloadOffset: node.payloadOffset, payloadBytes: node.payloadBytes, + sha256: node.sha256, + values: Array.from({ length: count }, (_, index) => bytes.readUIntLE(node.payloadOffset + index * wordBytes, wordBytes)), + }; + } + + const registerSequences = []; + const sequenceById = new Map(); + let registerRowCount = 0; + for (const node of nodes.filter((entry) => entry.name === 'regSetting')) { + if (node.payloadBytes % 40 !== 0) { + fail('REGISTER_ROW_LENGTH', 'Observed regSetting rows require 40 bytes each', node.payloadOffset); + } + const rowCount = node.payloadBytes / 40; + registerRowCount += rowCount; + if (registerRowCount > config.maxRegisterRows) { + fail('REGISTER_LIMIT', 'Register row count exceeds the limit', node.payloadOffset); + } + const rows = []; + const widths = new Map(); + const operations = new Map(); + const resolvedDigest = createHash('sha256'); + for (let index = 0; index < rowCount; index++) { + const offset = node.payloadOffset + index * 40; + const words = Array.from({ length: 10 }, (_, item) => bytes.readUInt32LE(offset + item * 4)); + const [slaveCount, slaveReference, registerAddress, dataCount, dataReference, + registerAddressBytes, registerDataBytes, operation, delayCount, delayReference] = words; + // Nonempty slaveAddr descriptors in the EEPROM records contain uint16s. + const slave = referencedWords(slaveReference, 'slaveAddr', slaveCount, offset + 4, 2); + const data = referencedWords(dataReference, 'registerData', dataCount, offset + 16); + const delay = referencedWords(delayReference, 'delayUs', delayCount, offset + 36); + if (![0, 1, 2, 3, 4].includes(registerAddressBytes) || ![0, 1, 2, 3, 4].includes(registerDataBytes)) { + fail('REGISTER_WIDTH', 'Register width code lies outside the observed 0..4 range', offset + 20); + } + // Zero-width records occur in TPG/auxiliary placeholders; retain the code + // without treating it as a usable register width. Nonzero operation codes + // remain opaque: their registerData may hold counts rather than values. + if ((registerAddressBytes > 0 && registerAddress >= 2 ** (8 * registerAddressBytes)) || + (operation === 0 && registerDataBytes > 0 && + data.values.some((value) => value >= 2 ** (8 * registerDataBytes)))) { + fail('REGISTER_VALUE_WIDTH', 'Register address or data exceeds its declared width', offset + 8); + } + const row = { + offset, registerAddress, registerAddressBytes, registerDataBytes, + widthSpecified: registerAddressBytes > 0 && registerDataBytes > 0, + operationCode: operation, + slaveReference: slave.nodeId, slaveAddresses: slave.values, + dataReference: data.nodeId, data: data.values, + delayReference: delay.nodeId, delayUs: delay.values, + }; + // Every payload reference contributes, including data outside the row span. + resolvedDigest.update(`${JSON.stringify(row)}\n`); + if (config.includeRegisters) rows.push(row); + const widthKey = `${registerAddressBytes}/${registerDataBytes}`; + widths.set(widthKey, (widths.get(widthKey) ?? 0) + 1); + operations.set(operation, (operations.get(operation) ?? 0) + 1); + } + const sequence = { + nodeId: node.id, payloadOffset: node.payloadOffset, payloadBytes: node.payloadBytes, + rowCount, rowSpanSha256: node.sha256, + resolvedRowsSha256: resolvedDigest.digest('hex'), + widths: [...widths].map(([bytes, rows]) => ({ addressDataBytes: bytes, rows })), + operationCodes: [...operations].map(([code, rows]) => ({ code, rows })), + ...(config.includeRegisters ? { rows } : {}), + }; + registerSequences.push(sequence); + sequenceById.set(node.id, sequence); + } + + const registerOwners = []; + for (const node of nodes.filter((entry) => entry.name === 'resSettings' || entry.name === 'initSetting')) { + if (node.payloadBytes !== 8) { + fail('REGISTER_OWNER_LENGTH', 'Observed register owner requires count and reference', node.payloadOffset); + } + const count = bytes.readUInt32LE(node.payloadOffset); + const referenceId = bytes.readUInt32LE(node.payloadOffset + 4); + const sequence = sequenceById.get(referenceId); + if (!sequence || sequence.rowCount !== count) { + fail('REGISTER_OWNER_REFERENCE', 'Register owner count/reference does not match its sequence', node.payloadOffset); + } + registerOwners.push({ nodeId: node.id, name: node.name, payloadOffset: node.payloadOffset, count, referenceId }); + } + + return { + schema: 'sp11-chromatix-observed-3.0.1-v1', + interpretation: 'structure and named references only; table agreement does not verify hardware behavior', + bytes: bytes.length, sha256: hash(bytes), + header: { + version, parser, moduleName, endOffset, + crc32: storedCrc32.toString(16).padStart(8, '0'), crc32Verified: true, + opaqueWords: [bytes.readUInt32LE(152), bytes.readUInt32LE(156)], + }, + stats: { + descriptorCount, totalPayloadBytes, registerSequences: registerSequences.length, + registerRowCount, resolvedValueCount, resolvedPayloadBytes, + }, + sections, nodes, registerSequences, registerOwners, + emitted: { registerValues: config.includeRegisters, payloadBytes: config.includePayloads }, + }; +} + +async function main(args) { + const options = {}; + let input; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--input' && !input && args[i + 1]) input = args[++i]; + else if (args[i] === '--include-registers' && !options.includeRegisters) options.includeRegisters = true; + else if (args[i] === '--include-payloads' && !options.includePayloads) options.includePayloads = true; + else throw new TypeError('Usage: chromatix.mjs --input FILE [--include-registers] [--include-payloads]'); + } + if (!input) throw new TypeError('An --input file is required'); + const handle = await open(input, 'r'); + let bytes; + try { + const stat = await handle.stat(); + if (!stat.isFile()) throw new TypeError('Input must be a regular file'); + if (stat.size > CHROMATIX_LIMITS.maxBytes) { + throw new ChromatixDecodeError('BYTE_LIMIT', 'Input exceeds the byte limit'); + } + bytes = Buffer.alloc(stat.size + 1); + let offset = 0; + while (offset < bytes.length) { + const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset !== stat.size) throw new ChromatixDecodeError('FILE_CHANGED', 'Input length changed while reading'); + bytes = bytes.subarray(0, offset); + } finally { + await handle.close(); + } + process.stdout.write(`${JSON.stringify(decodeChromatix(bytes, options), null, 2)}\n`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + main(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${JSON.stringify({ + error: error.code ?? error.name, message: error.message, + ...(error instanceof ChromatixDecodeError ? { offset: error.offset } : {}), + })}\n`); + process.exitCode = 1; + }); +} diff --git a/tools/sp11-driver-audit/chromatix.test.mjs b/tools/sp11-driver-audit/chromatix.test.mjs new file mode 100644 index 00000000000000..08dc657235dc6c --- /dev/null +++ b/tools/sp11-driver-audit/chromatix.test.mjs @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: GPL-2.0-only +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { crc32, decodeChromatix, ChromatixDecodeError } from './chromatix.mjs'; + +// All fixtures are independently constructed, not vendor binary excerpts. +const u32 = (...values) => { + const bytes = Buffer.alloc(values.length * 4); + values.forEach((value, index) => bytes.writeUInt32LE(value, index * 4)); + return bytes; +}; +const finish = (bytes) => { + bytes.writeUInt32LE(crc32(bytes.subarray(0, -4)), bytes.length - 4); + return bytes; +}; + +function fixture({ repeatRows = 1, dataValues = [0x56] } = {}) { + const definitions = [ + ['resSettings', u32(repeatRows, 2)], + ['regSetting', Buffer.concat(Array.from({ length: repeatRows }, () => + u32(0, 3, 0x1234, dataValues.length, 4, 2, 1, 0, 1, 5)))], + ['slaveAddr', Buffer.alloc(0)], + ['registerData', u32(...dataValues)], + ['delayUs', u32(7)], + ['uninterpreted', u32(0xffffffff)], + ]; + const table = 168; + const metadata = table + 36; + const metadataBytes = definitions.length * 56; + const data = metadata + metadataBytes; + const dataBytes = definitions.reduce((sum, item) => sum + item[1].length, 0); + const footer = data + dataBytes; + const bytes = Buffer.alloc(footer + 20 + 4); + bytes.write('QTI Chromatix Header', 0, 'ascii'); + bytes.writeUInt32LE(bytes.length - 4, 28); + bytes.writeUInt16LE(3, 32); bytes.writeUInt32LE(1, 36); + bytes.write('Parameter Parser V3.4.0 (synthetic)', 40, 'ascii'); + bytes.write('synthetic.example', 88, 'ascii'); + bytes.writeUInt32LE(1, 152); + bytes.writeUInt32LE(table, 160); bytes.writeUInt32LE(3, 164); + u32(0, metadata, metadataBytes, 1, data, dataBytes, 2, footer, 20).copy(bytes, table); + let relative = 0; + definitions.forEach(([name, payload], index) => { + const offset = metadata + index * 56; + bytes.writeUInt32LE(index + 1, offset); + bytes.write(name, offset + 4, 'ascii'); + bytes.writeUInt32LE(0xffffffff, offset + 44); + bytes.writeUInt32LE(relative, offset + 48); + bytes.writeUInt32LE(payload.length, offset + 52); + payload.copy(bytes, data + relative); + relative += payload.length; + }); + return { bytes: finish(bytes), metadata, data, footer }; +} + +function rejects(code, bytes, options) { + assert.throws(() => decodeChromatix(bytes, options), (error) => { + assert.ok(error instanceof ChromatixDecodeError); + assert.equal(error.code, code); + return true; + }); +} + +test('CRC32 has its standard independent check value', () => { + assert.equal(crc32(Buffer.from('123456789')), 0xcbf43926); +}); + +test('default summaries validate references without emitting registers or payloads', () => { + const result = decodeChromatix(fixture().bytes); + assert.equal(result.stats.descriptorCount, 6); + assert.equal(result.stats.registerRowCount, 1); + assert.equal(result.header.crc32Verified, true); + assert.equal(result.nodes[0].name, 'resSettings'); + assert.equal(result.nodes[5].opaqueDescriptorWords[2], 0xffffffff); + assert.equal('payloadHex' in result.nodes[3], false); + assert.equal('rows' in result.registerSequences[0], false); + assert.deepEqual(result.registerOwners.map((owner) => [owner.count, owner.referenceId]), [[1, 2]]); +}); + +test('explicit private-research options expose precise named values and bytes', () => { + const result = decodeChromatix(fixture().bytes, { includeRegisters: true, includePayloads: true }); + const row = result.registerSequences[0].rows[0]; + assert.equal(row.registerAddress, 0x1234); + assert.deepEqual(row.data, [0x56]); + assert.deepEqual(row.slaveAddresses, []); + assert.deepEqual(row.delayUs, [7]); + assert.equal(row.operationCode, 0); + assert.equal(result.nodes[3].payloadHex, '56000000'); + assert.deepEqual(result.emitted, { registerValues: true, payloadBytes: true }); +}); + +test('header signature, lengths, version and CRC are enforced', () => { + rejects('SHORT_HEADER', Buffer.alloc(171)); + const { bytes } = fixture(); + const magic = Buffer.from(bytes); magic[0] = 88; + rejects('BAD_MAGIC', magic); + const length = Buffer.from(bytes); length.writeUInt32LE(bytes.length, 28); + rejects('LENGTH_MISMATCH', length); + const version = Buffer.from(bytes); version.writeUInt16LE(5, 32); + rejects('UNSUPPORTED_VERSION', version); + const crc = Buffer.from(bytes); crc[crc.length - 1] ^= 1; + rejects('CRC_MISMATCH', crc); +}); + +test('section tables reject overlap, overflow, missing IDs and non-descriptor extents', () => { + const { bytes, metadata } = fixture(); + const overlap = Buffer.from(bytes); overlap.writeUInt32LE(metadata, 184); + rejects('SECTION_OVERLAP', finish(overlap)); + const overflow = Buffer.from(bytes); overflow.writeUInt32LE(0xffffffff, 176); + rejects('SECTION_BOUNDS', finish(overflow)); + const duplicate = Buffer.from(bytes); duplicate.writeUInt32LE(0, 180); + rejects('SECTION_ID', finish(duplicate)); + const count = Buffer.from(bytes); count.writeUInt32LE(0xffffffff, 164); + rejects('SECTION_TABLE', finish(count)); + const extent = Buffer.from(bytes); extent.writeUInt32LE(55, 176); + rejects('DESCRIPTOR_LENGTH', finish(extent)); +}); + +test('named descriptors enforce ASCII, unique IDs and payload bounds', () => { + const { bytes, metadata } = fixture(); + const name = Buffer.from(bytes); name[metadata + 4] = 0xff; + rejects('ASCII_FIELD', finish(name)); + const duplicate = Buffer.from(bytes); duplicate.writeUInt32LE(1, metadata + 56); + rejects('NODE_ID', finish(duplicate)); + const overflow = Buffer.from(bytes); overflow.writeUInt32LE(0xffffffff, metadata + 48); + rejects('PAYLOAD_BOUNDS', finish(overflow)); +}); + +test('register payload references, owner counts and value widths are validated', () => { + const { bytes, metadata, data } = fixture(); + const missing = Buffer.from(bytes); missing.writeUInt32LE(99, data + 8 + 16); + rejects('NAMED_REFERENCE', finish(missing)); + const wrongName = Buffer.from(bytes); wrongName.writeUInt32LE(5, data + 8 + 16); + rejects('NAMED_REFERENCE', finish(wrongName)); + const oversizedCount = Buffer.from(bytes); oversizedCount.writeUInt32LE(0xffffffff, data + 8 + 12); + rejects('NAMED_REFERENCE', finish(oversizedCount)); + const owner = Buffer.from(bytes); owner.writeUInt32LE(2, data); + rejects('REGISTER_OWNER_REFERENCE', finish(owner)); + const width = Buffer.from(bytes); width.writeUInt32LE(5, data + 8 + 20); + rejects('REGISTER_WIDTH', finish(width)); + const value = Buffer.from(bytes); value.writeUInt32LE(0x100, data + 8 + 40); + rejects('REGISTER_VALUE_WIDTH', finish(value)); + const size = Buffer.from(bytes); size.writeUInt32LE(39, metadata + 56 + 52); + rejects('REGISTER_ROW_LENGTH', finish(size)); +}); + +test('zero width and unknown operations remain opaque instead of executable policy', () => { + const { bytes, data } = fixture(); + const placeholder = Buffer.from(bytes); + placeholder.writeUInt32LE(0, data + 8 + 20); + placeholder.writeUInt32LE(0, data + 8 + 24); + const row = decodeChromatix(finish(placeholder), { includeRegisters: true }).registerSequences[0].rows[0]; + assert.equal(row.widthSpecified, false); + const opaqueOperation = Buffer.from(bytes); + opaqueOperation.writeUInt32LE(77, data + 8 + 28); + opaqueOperation.writeUInt32LE(0xffffffff, data + 8 + 40); + const unknown = decodeChromatix(finish(opaqueOperation), { includeRegisters: true }).registerSequences[0].rows[0]; + assert.equal(unknown.operationCode, 77); + assert.deepEqual(unknown.data, [0xffffffff]); +}); + +test('resource and option bounds cannot be expanded or bypassed', () => { + const { bytes } = fixture(); + rejects('BYTE_LIMIT', bytes, { maxBytes: 10 }); + rejects('NODE_LIMIT', bytes, { maxNodes: 5 }); + rejects('PAYLOAD_LIMIT', bytes, { maxTotalPayloadBytes: 1 }); + assert.throws(() => decodeChromatix(bytes, { includeRegisters: 'yes' }), TypeError); + assert.throws(() => decodeChromatix(bytes, { maxNodes: 100001 }), RangeError); + assert.throws(() => decodeChromatix(bytes, { toString: 1 }), TypeError); +}); + +test('reused payload references consume the aggregate resolution budget every time', () => { + const { bytes } = fixture({ repeatRows: 3, dataValues: Array(100).fill(0x56) }); + // Only one 400-byte data node exists, but three rows resolve it repeatedly. + const result = decodeChromatix(bytes); + assert.equal(result.stats.resolvedValueCount, 303); + assert.equal(result.stats.resolvedPayloadBytes, 1212); + rejects('RESOLUTION_LIMIT', bytes, { maxResolvedValues: 150 }); + rejects('RESOLUTION_LIMIT', bytes, { maxResolvedBytes: 600 }); + rejects('RESOLUTION_LIMIT', bytes, { maxResolvedValues: 150, includeRegisters: true }); + assert.throws(() => decodeChromatix(bytes, { maxResolvedValues: 1_000_001 }), RangeError); +}); + +test('CLI default and opt-in output follow the same disclosure boundaries', async () => { + const temporary = await mkdtemp(join(tmpdir(), 'sp11-chromatix-test-')); + try { + const input = join(temporary, 'synthetic.bin'); + await writeFile(input, fixture().bytes); + const cli = fileURLToPath(new URL('./chromatix.mjs', import.meta.url)); + for (const flags of [[], ['--include-registers']]) { + const run = spawnSync(process.execPath, [cli, '--input', input, ...flags], { encoding: 'utf8' }); + assert.equal(run.status, 0, run.stderr); + const decoded = JSON.parse(run.stdout); + assert.equal('rows' in decoded.registerSequences[0], flags.length > 0); + } + await writeFile(input, Buffer.alloc(4)); + const bad = spawnSync(process.execPath, [cli, '--input', input], { encoding: 'utf8' }); + assert.equal(bad.status, 1); + assert.equal(bad.stdout, ''); + assert.equal(JSON.parse(bad.stderr).error, 'SHORT_HEADER'); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +}); diff --git a/tools/sp11-driver-audit/inventory.mjs b/tools/sp11-driver-audit/inventory.mjs new file mode 100644 index 00000000000000..1cf806f71dd797 --- /dev/null +++ b/tools/sp11-driver-audit/inventory.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: GPL-2.0-only +// Independently written static metadata inventory; never executes an input. +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import {fileURLToPath} from 'node:url'; + +export function splitFields(text) { + const fields=[];let field='',quoted=false; + for(let i=0;i16*1024*1024)throw new Error('INF exceeds size bound'); + const {text,encoding}=decodeInf(bytes),sections=new Map();let section=null,continued=''; + const lines=text.split(/\r?\n/); + if(lines.length>200000)throw new Error('INF exceeds line bound'); + for(let i=0;i{ + let current=value; + for(let i=0;i<8;i++){const next=current.replace(/%([^%]+)%/g,(whole,key)=>strings.get(key.toLowerCase())??whole);if(next===current)return next;current=next;} + return current; + }; + const version=Object.fromEntries((sections.get('version')||[]).filter(x=>x.key!==null).map(x=>{const values=splitFields(x.value).map(expand);return [x.key.toLowerCase(),values.length===1?values[0]:values];})); + const declared=new Set(),missing=[]; + for(const row of sections.get('manufacturer')||[]) { + const [base,...decorations]=splitFields(expand(row.value)); + for(const name of decorations.length?decorations.map(d=>`${base}.${d}`):[base]) { + const key=name.toLowerCase();if(sections.has(key))declared.add(key);else missing.push(name); + } + } + const models=[]; + for(const name of declared)for(const row of sections.get(name)) { + if(row.key===null)continue; + const [installSection,...identities]=splitFields(expand(row.value)); + // INF model positions define identities; GUID and software enumerators are + // valid too. A guessed bus-name grammar would silently lose whole drivers. + const [hardwareID,...compatibleIDs]=identities; + if(hardwareID)models.push({section:name,line:row.line,description:expand(row.key).replace(/^"|"$/g,''),installSection,hardwareID,compatibleIDs:compatibleIDs.filter(x=>x.length>0)}); + } + const services=[],serviceBinaries=[],sourceFiles=[]; + for(const [name,rows] of sections)for(const row of rows) { + if(row.key?.toLowerCase()==='addservice')services.push({section:name,line:row.line,fields:splitFields(expand(row.value))}); + if(row.key?.toLowerCase()==='servicebinary')serviceBinaries.push({section:name,line:row.line,value:expand(splitFields(row.value)[0])}); + if(/^sourcedisksfiles(?:\.|$)/.test(name)&&row.key!==null)sourceFiles.push({section:name,line:row.line,name:expand(row.key)}); + } + return {encoding,version,models,missingDeclaredModelSections:missing,services,serviceBinaries,sourceFiles,warnings,limits:['Declared package bindings only; not evidence of installed hardware.','AddReg and executable driver semantics are not inferred.','Malformed string declarations are reported and left unresolved.']}; +} + +export function inspectPEHeader(bytes) { + if(bytes.length<64||bytes.toString('ascii',0,2)!=='MZ')return null; + const offset=bytes.readUInt32LE(0x3c); + if(offset>bytes.length-24||bytes.toString('ascii',offset,offset+4)!=='PE\0\0')return {format:'MZ',status:'PE header unavailable or invalid'}; + const machine=bytes.readUInt16LE(offset+4),names={0xaa64:'ARM64',0x8664:'AMD64',0x14c:'I386',0xa641:'ARM64EC',0xa64e:'ARM64X'}; + return {format:'PE',machine:`0x${machine.toString(16)}`,architecture:names[machine]??'unknown',sections:bytes.readUInt16LE(offset+6)}; +} + +async function digest(file) { + const hash=crypto.createHash('sha256');for await(const chunk of fs.createReadStream(file))hash.update(chunk);return hash.digest('hex'); +} + +export async function inventory(input) { + const root=fs.realpathSync(input);if(!fs.statSync(root).isDirectory())throw new Error('Input must be an extracted directory'); + const files=[],skipped=[]; + async function walk(dir) { + const entries=fs.readdirSync(dir,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name,'en')); + for(const entry of entries) { + const absolute=path.join(dir,entry.name),relative=path.relative(root,absolute).split(path.sep).join('/'); + if(entry.isSymbolicLink()){skipped.push({path:relative,reason:'symbolic link'});continue;} + if(entry.isDirectory()){await walk(absolute);continue;} + if(!entry.isFile()){skipped.push({path:relative,reason:'not a regular file'});continue;} + const stat=fs.statSync(absolute),record={path:relative,size:stat.size,sha256:await digest(absolute)}; + if(/\.inf$/i.test(entry.name)){try{record.inf=parseInf(fs.readFileSync(absolute));}catch(e){record.infError=e.message;}} + const handle=fs.openSync(absolute,'r');try{const header=Buffer.alloc(Math.min(stat.size,65536));fs.readSync(handle,header,0,header.length,0);const pe=inspectPEHeader(header);if(pe)record.native=pe;if(header.toString('ascii',0,4)==='AeoB')record.container='AeoB';}finally{fs.closeSync(handle);} + files.push(record); + } + } + await walk(root); + const byExtension={},byArchitecture={};for(const file of files){const ext=path.extname(file.path).toLowerCase()||'(none)';byExtension[ext]=(byExtension[ext]||0)+1;if(file.native?.architecture)byArchitecture[file.native.architecture]=(byArchitecture[file.native.architecture]||0)+1;} + return {schemaVersion:1,inputLabel:path.basename(root),summary:{files:files.length,bytes:files.reduce((n,f)=>n+f.size,0),infFiles:files.filter(f=>f.inf).length,infErrors:files.filter(f=>f.infError).length,aeobFiles:files.filter(f=>f.container==='AeoB').length,byExtension,byArchitecture},files,skipped,interpretation:'Static package inventory. No input executed, no files installed, and no hardware support claim.'}; +} + +if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) { + const args=process.argv.slice(2); + if(args.length!==1){console.error('Usage: node inventory.mjs /path/to/extracted/SurfaceUpdate > inventory.json');process.exitCode=64;} + else try{console.log(JSON.stringify(await inventory(args[0]),null,2));}catch(e){console.error(e.message);process.exitCode=1;} +} diff --git a/tools/sp11-driver-audit/inventory.test.mjs b/tools/sp11-driver-audit/inventory.test.mjs new file mode 100644 index 00000000000000..9d0d11a4252e08 --- /dev/null +++ b/tools/sp11-driver-audit/inventory.test.mjs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-2.0-only +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {decodeInf,parseInf,splitFields,inspectPEHeader} from './inventory.mjs'; + +test('quoted commas, escaped quotes and semicolons survive field parsing',()=>{ + assert.deepEqual(splitFields('a,"b,c","d""e",f'),['a','b,c','d"e','f']); + assert.throws(()=>splitFields('"oops'),/Unterminated/); +}); +test('only manufacturer-referenced model sections define device bindings',()=>{ + const inf=Buffer.from('[Version]\nClass=Camera\nProvider=%Vendor%\n[Manufacturer]\n%Vendor%=Models,NTarm64.10.0\n[Models.NTarm64.10.0]\n%Description%=Sensor,ACPI\\SONY0681,ACPI\\SONY0681&SUBSYS_MSHW0490 ;comment\n[Unreferenced]\nignored=Fake,ACPI\\FAKE0001\n[Sensor.Services]\nAddService=Camera,2,Service\n[Service]\nServiceBinary=%12%\\sensor.sys\n[Strings]\nVendor="Microsoft; vendor"\nDescription="Front, camera"\n'); + const parsed=parseInf(inf); + assert.equal(parsed.version.provider,'Microsoft; vendor'); + assert.equal(parsed.models.length,1); + assert.equal(parsed.models[0].description,'Front, camera'); + assert.equal(parsed.models[0].hardwareID,'ACPI\\SONY0681'); + assert.deepEqual(parsed.models[0].compatibleIDs,['ACPI\\SONY0681&SUBSYS_MSHW0490']); + assert.equal(parsed.serviceBinaries[0].value,'%12%\\sensor.sys'); +}); +test('UTF16 and continued INF rows retain the complete device identity',()=>{ + const input=Buffer.concat([Buffer.from([255,254]),Buffer.from('[Manufacturer]\r\nM=Models,NTarm64\r\n[Models.NTarm64]\r\nD=Install,\\\r\n ACPI\\DEVICE01\r\n','utf16le')]); + assert.equal(decodeInf(input).encoding,'UTF-16LE'); + assert.equal(parseInf(input).models[0].hardwareID,'ACPI\\DEVICE01'); + assert.throws(()=>decodeInf(Buffer.from([255,254,65])),/Odd UTF-16LE/); +}); +test('missing decorated sections are explicit instead of assuming support',()=>{ + const parsed=parseInf(Buffer.from('[Manufacturer]\nM=Models,NTarm64\n[Models]\nD=Install,ACPI\\DEVICE01\n')); + assert.equal(parsed.models.length,0);assert.deepEqual(parsed.missingDeclaredModelSections,['Models.NTarm64']); +}); +test('DriverVer preserves both date and package version',()=>{ + assert.deepEqual(parseInf(Buffer.from('[Version]\nDriverVer=04/09/2026,1.2.3.4\n')).version.driverver,['04/09/2026','1.2.3.4']); +}); +test('GUID enumerators and software identities are retained by model position',()=>{ + const parsed=parseInf(Buffer.from('[Manufacturer]\nM=Models\n[Models]\nD=Install,{4047F442-B3B0-4517-8C6F-9EEA770022B1}\\SurfacePowerStateDriver,SWC\\Component\n')); + assert.equal(parsed.models[0].hardwareID,'{4047F442-B3B0-4517-8C6F-9EEA770022B1}\\SurfacePowerStateDriver'); + assert.deepEqual(parsed.models[0].compatibleIDs,['SWC\\Component']); +}); +test('malformed unused strings retain evidence without losing other bindings',()=>{ + const parsed=parseInf(Buffer.from('[Manufacturer]\nM=Models\n[Models]\nD=Install,ACPI\\DEVICE01\n[Strings]\nbad="missing terminator\n')); + assert.equal(parsed.models.length,1);assert.equal(parsed.warnings.length,1); + assert.equal(parsed.warnings[0].line,6); +}); +test('PE architecture comes from bounded headers, not extension or package title',()=>{ + assert.equal(inspectPEHeader(Buffer.from('not a PE')),null); + const bytes=Buffer.alloc(128);bytes.write('MZ');bytes.writeUInt32LE(64,0x3c);bytes.write('PE\0\0',64);bytes.writeUInt16LE(0xaa64,68);bytes.writeUInt16LE(4,70); + assert.equal(inspectPEHeader(bytes).architecture,'ARM64'); + bytes.writeUInt32LE(999999,0x3c);assert.equal(inspectPEHeader(bytes).status,'PE header unavailable or invalid'); +}); diff --git a/tools/sp11-driver-audit/native-bluetooth-provisioning.md b/tools/sp11-driver-audit/native-bluetooth-provisioning.md new file mode 100644 index 00000000000000..4fec1090b9b15c --- /dev/null +++ b/tools/sp11-driver-audit/native-bluetooth-provisioning.md @@ -0,0 +1,263 @@ +# Native Bluetooth factory-provisioning source track + +This is a separate native firmware-provider research track, not a new kernel +partition driver, a Windows compatibility layer or an installation procedure. +The current candidate has not been executed in EFI or on real factory storage. + +## Existing Linux boundary + +The kernel already has a firmware-node `local-bd-address` consumer in +`net/bluetooth/hci_sync.c`. Qualcomm's driver also has an explicit +`qcom,local-bd-address-broken` byte-order quirk. Those native interfaces provide +an integration boundary; their presence does not prove the current board's +property, byte order or installed provider is correct. Do not add or remove +that quirk by guesswork, invent a replacement factory address or start scanning +raw storage from a new Linux driver to work around unverified boot integration. + +## Current complete native source candidate + +The source-only `sp11-native-dpp-source-20261015-r3` bundle contains the complete +five-commit series over [dtbloader](https://github.com/TravMurav/dtbloader) at +`b1bf23e88b779ac323fbcbe547cf3ba939433df3`. It adds sized DTB inputs and staged +publication (`3489cd54a96f1a1e9397894bef101da220eb3e10`) to the four commits +listed below. The resulting tree is +`86123f3325150775539e3ba3ae16f6a69b873b76`. Apply it to that original base, +not on top of the earlier archives. + +The revision-3 archive SHA-256 is +`dea11979cfa914cf12614d37b257dd4cb925f74bd98d62e5c37354fcb98346ab`. +Its 90,397 bytes contain 38 text files: five patch emails, 29 exact source and +licence snapshots, and four metadata files. Two deterministic packs matched +byte-for-byte. Independent fresh Linux extraction checked all 37 checksum +entries and compared all 38 extracted files against the reviewed staging. + +Independent Linux replay of all five emails reproduced the exact tree and +preserved raw author identities/dates, complete messages, parsed trailers and +stable patch IDs. All six public test targets passed after replay. Repository +integrity also passed with the complete 117-commit base ancestry; initial +offline integrity checks lacked that ancestry, and their failures are retained +separately from the accepted result. Replay committer metadata may differ. + +This is buildable research source, not an installable kernel or a qualified +EFI provider. The bundle contains no compiled image, Windows payload, private +capture or provisioning record. Earlier archives remain unchanged. + +## Historical revision-2 reader and matcher series + +The source-only `sp11-native-dpp-source-20261015-r2` bundle applies four focused +commits to [dtbloader](https://github.com/TravMurav/dtbloader) at +`b1bf23e88b779ac323fbcbe547cf3ba939433df3`: + +1. `587ded14d0f8ada9324bd325bac9faef31696c8f`: isolate the known native RWFS + fields in a bounded, allocation-free reader; check media extents/identity, + exact payload lengths and read budgets; remove raw provisioning debug output. +2. `397ee3ca597084d91f1be5c82fe10129e03f43a4`: constrain Bluetooth vendor matching + to `QCOM` through the existing Surface Pro 11 descriptor, preserving other + boards' unconstrained vendor policy and the existing WLAN/byte-reversal path. +3. `0d88509c82a3752fa3b9afb982b344612f1dcc31`: honor the native matcher's + already declared hardware-ID priority order without changing descriptors. +4. `3ba7a4bdfb92bf9869ab3783ccbe06a036dc86ce`: add portable actual-function + board-matcher tests alongside the existing bounded-reader tests. + +Revision 2 is the complete four-patch series from the same base, not an +incremental patch for the original two-patch archive. That earlier archive +remains an immutable historical revision. Replaying the four emails onto the +full exact base on Linux reproduced tree +`a91c60a401d726276d56e51d3e9a971e3c083c48`, with all 15 review snapshots and +original author identities, exact dates, messages, trailers and code patch IDs +preserved. Replay committer metadata need not match the original commits. + +The revision-2 archive SHA-256 is +`c6910c329bb9edc553c8ec6eb62d01a847e92eac272596009ddd35a6dada1a57`. +It contains only 23 text files, including 15 source/licence snapshots and four +patch emails. Deterministic packing, exact member-byte comparison and fresh +Linux extraction verified all 22 checksums. Private evidence and compiled +results are not distributed in the archive. + +The reader interprets only the licensed native implementation's known fields. +Unknown versions, secondary magic, region ownership and nonzero row states +remain opaque. The first zero-present row ends traversal; duplicates are +rejected within that visited prefix. Row/byte limits are implementation policy, +not assertions about factory-format maxima. No proprietary format header, +Windows binary, recovered pseudocode or real provisioning record is included. +The original BSD-3-Clause licence and contributors' attribution are retained. + +## Historical revision-2 checks + +The actual standalone core passed 4,691 synthetic calls and 300,856 assertions +per optimized and sanitizer host run. The actual EFI adapter/Surface wrapper, +included with fake EFI/FDT boundaries, passed 87 scenarios and 2,295 assertions +per run. These include partial reads, failure-zero output, changed media, +vendor scoping, byte reversal and the exact row-cap/sentinel boundary. + +The complete native project compiled and linked with the exact recorded +GNU-EFI/libfdt/SHA-1 submodules and Clang/LLD 18.1.3. All 85 expected objects +were independently checked as ARM64 COFF; the PE32+ image has the EFI +boot-service-driver subsystem and no import/delay-import tables. Project +sources used `-Wall -Werror`, with no warnings in the complete build log. +This build is freestanding native EFI: `aarch64-windows` selects COFF/ABI, not +a Windows runtime. The unsigned research image is not distributed in the bundle. + +OpenCode's completed GLM 5.1 review found no new actionable defects in the +supplied production source and recommended source-only status. Its requested +tests were mapped to actual coverage; review imprecision was qualified rather +than used as authority. No source review or compilation proves device parity. + +## Follow-up: declared board-selection priority + +The separate native commit `0d88509c82a3752fa3b9afb982b344612f1dcc31` +corrects a pre-existing board matcher issue: use `hwids[priority[i]]`, not +`hwids[i]`. The original loop checked IDs 0 through 8, ignoring the declared +specificity order and omitting IDs 9 through 11. The priority array, device +descriptors, additional match predicates, registration-order ties and +successful-match cache are unchanged. The correction and its portable tests +are included in revision 2, not retroactively added to the original archive. + +The exact function passed 191 synthetic scenarios per optimized and +AddressSanitizer/UndefinedBehaviorSanitizer run on both macOS and native ARM64 +Linux. Reversing only the indexed expression reproduced the same 75 expected +failures per run. Cases cover every declared priority pair in both registry +orders, sole identities, rejected predicates, ties, missing identities and +cache/error retry behavior. Actual project headers are used, but EFI services, +SMBIOS population and registry boundaries are synthetic; these tests do not +exercise the COFF linker registry or identify a real board. + +The public `make -C tests check` and `sanitize` targets reproduce both suites; +select the installed C compiler with `CC`, such as `CC=clang-18`. Python 3 is +needed for test extraction/orchestration only, not the production provider. + +The corrected source also completed the full 85-object native ARM64 EFI +compile/link without warnings. OpenCode recommended the focused correction +and these source-level gates, not deployment of the unqualified provider. + +## Versioned protocol gate + +The inspected dtbloader header and +[Stubble's pinned header](https://github.com/ubuntu/stubble/blob/08218605b5ac014f7064ef7129e84ba7ec582dca/include/proto/dt-fixup.h) +agree on the legacy `e617d64c-fe08-46da-f4dc-bbd5870c7300` interface with four +arguments, including flags. Stubble's official main still had that exact +revision when checked on 15 October 2026. This source agreement does not identify +the provider or consumer installed on a particular device. + +The [published protocol specification](https://github.com/U-Boot-EFI/EFI_DT_FIXUP_PROTOCOL/blob/c98cfac7b4daed69bb097a579981b877b765569a/README.rst) +records an incompatible January 2026 change: GUID +`60ed6ba9-dfef-4799-ac7b-75e0f833456c`, three arguments and no flags/reservation +operation. The protocol revision value alone does not distinguish these ABIs. +Do not change only the GUID, cast between signatures or add speculative memory +reservation behavior. Pin the actual installed caller/provider pair before any +separate migration decision. Current source checks preserve the legacy ABI. + +## Remaining deployment gates + +### Revision-3 sized inputs and staged publication + +Native commit `3489cd54a96f1a1e9397894bef101da220eb3e10` adds the sized +input/staging prerequisite over the four-commit source above. It is included +in revision 3, not retroactively added to revision 2 or a firmware release. + +The file path now checks a bounded metadata query and exact read, validates +against the bytes actually read and closes file/volume handles. A shared +helper stages the complete existing callback on a private bounded tree, then +validates, packs and validates again before copying the packed extent back. +Failures leave the original buffer unchanged under the documented accessible, +exclusively owned memory/callback contract. Successful calls preserve the +pointer and unused tail. This is not concurrency-atomic or a whole-firmware/ +NVRAM transaction, and does not change factory-data or board-selection policy. + +The separate native ADR records the 2 MiB AArch64 boot/workspace ceiling, +existing 1 MiB local allocation, legacy protocol ABI/headroom negotiation and +successfully published ACPI-page ownership. Zeroing the local allocation before +reading makes pre-fixup normalized-hash padding deterministic; it does not +introduce authentication or change confirmation/NVRAM policy. Metadata uses a +bounded pool, avoiding a large stack frame and an unavailable stack-probe +helper without adding a Windows runtime dependency. + +All six public targets (`check`, `sanitize`, `check-fdt`, `sanitize-fdt`, +`check-io`, `sanitize-io`) passed on ARM64 macOS and native ARM64 Linux. The +optional FDT suite exercises 95 scenarios/795 assertions with real libfdt; +the file-helper suite exercises 2,802 calls with real GNU-EFI types and fake +allocation/file callbacks. Neither target fetches dependencies. Six and eight +malformed source-extraction controls respectively fail closed. The original +standalone core/matcher suites still pass with unchanged test dependencies. + +Separate private actual-source fixtures additionally cover loader lifecycle +with 52 scenarios and the historical partial-mutation negative control. These +do not qualify actual filesystem opening, SHA/NVRAM authorization or factory +provisioning. The full native 86-object ARM64 COFF link passed without warnings +from a pre-commit snapshot whose compiled inputs match the final commit; +retained source/object/image bytes were independently audited. The historical +87-scenario adapter fixture predates the utility-header change; it was not +rerun against revision 3 and is not a whole-revision regression claim. OpenCode's +direct GLM 5.1 supplied-source review found no introduced blocker. Its review +and these tests are not proof of firmware ABI, deployment or hardware parity. + +### Subsequent file-opening source candidate + +Local native commit `1c7b8f75bf6be7efceeb7aea4b8e32fa0b0cf553`, tree +`f5acac88077dcc3c7da01bb64cc4a6632dc68eb5`, is a separate follow-up over +revision 3. It is not contained in that immutable source archive. + +`GetVolume()` and `FileOpen()` now return explicit native status and a separate +owned handle. Only exact successful opens publish a non-NULL output. Native +errors propagate; unexpected warnings and success without a required interface +or handle fail closed. Failed-service output pointers are not trusted or closed +as though they were owned. Borrowed protocol interfaces remain borrowed. + +The six-path DTB search continues only on a file-open `EFI_NOT_FOUND`, retaining +the original order. Device/access/media failures stop immediately instead of +falling through to a lower-priority file or protocol-only success. Only genuine +exhausted-path absence is optional; lookup, metadata, read and close errors +remain fatal even when their status is also `EFI_NOT_FOUND`. Root-close failure +overrides optional absence. Existing cleanup precedence, normalized-hash order, +staged publication and the legacy protocol interface remain unchanged. + +All eight public targets passed on ARM64 macOS and native ARM64 Linux, including +optimized and sanitizer variants. The helper suite now has 2,969 calls; the new +actual-source loader suite has 142 scenarios. It compiles actual GNU-EFI UTF-16 +functions and libfdt rather than rewriting the search or tree algorithms. +Eighteen loader extraction controls and two behavioral mutants reject malformed +boundaries and reintroduced error masking. The full current-commit 86-object +native COFF compile/link passed without warnings. OpenCode's direct GLM 5.1 +production-source review found no introduced blocker; tests and dependencies +were outside that review's scope and were checked separately. + +The new native ADR `0002-explicit-file-opening-status.md` supersedes only the +opening-status limitation in ADR 0001, leaving the earlier record unchanged. +Real EFI services, handle release, SHA/NVRAM authorization and installed +caller/provider behavior remain unqualified. No EFI image was executed or +installed; these shared-loader changes also need non-SP11 firmware testing. + +### Still open + +The existing GPT-name comparison and broad/ambiguous partition selection still +need hardening. A stable media ID does not authenticate storage or rule out +in-place mutation. WLAN/Bluetooth success remains coupled. In revision 2 the +two FDT updates are sequential and a failed second update leaves the first +applied. Revision 3 isolates those updates in a private tree; +it does not change the callback's address-source or compatible-node policy. +Valid existing addresses, exact compatible-node selection, actual factory +record semantics and the installed Stubble/provider path need independent +qualification. The host adapter tests intentionally reproduce the partial-FDT +failure in the original callback; the revision-3 staged-entry tests verify its +failure-isolation boundary. + +The sole current GPT-name caller passes the fixed literal `DPP`; the pinned +native comparison reads at most four units of its 36-unit media name. A current +GPT-name overread has not been demonstrated. Generic API hardening must not be +reported as an observed exploit. Separately, the native board matcher declares +a hardware-ID priority array; the follow-up above corrects its indexed lookup. +Actual SMBIOS generation and device selection still require qualification +before provider deployment. + +Revision 3 establishes complete successful reads and validation against +supplied byte extents before staged mutation. A header's `totalsize` still +does not prove pointer accessibility. Its `GetVolume()` and `FileOpen()` still +collapse native errors into missing handles; the missing-file flag separates +failed-open control flow from post-open I/O errors only. The subsequent source +candidate above corrects that classification, not real filesystem/service +qualification. Neither revision-2 nor revision-3 archives contain that new +candidate; revision 2 also lacks revision-3 staged publication. + +No raw-storage capture, provider invocation, boot-order/Secure Boot change or +hardware radio test follows from these source checks. Those require separately +scoped decisions after the installed boot path and record ownership are known. diff --git a/tools/sp11-driver-audit/native-camera-validation.md b/tools/sp11-driver-audit/native-camera-validation.md new file mode 100644 index 00000000000000..87fdd0155b13f2 --- /dev/null +++ b/tools/sp11-driver-audit/native-camera-validation.md @@ -0,0 +1,207 @@ +# Native camera validation scope + +These source-only foundations use the tested-v8 carrier +`8ba2b75713feb5c72bbc11dd8cce4048e23cdeea`. The source bundles include no Debian +packages, boot images, Windows payloads, EFI applications, private captures or +signing keys. No board nodes or shipping configuration are enabled. + +## Previous revision: complete kernel and module link + +The previous bundle, SHA-256 +`ec319fe60543a555c2a34212477ade671ef23765d43e0f8addd526cb3d8fc405`, +contained 2 OV13858 and 21 IR patches. Its IR source ended at +`14bb8959fc37812a23ed55af73a2eb273acdb043`, driver SHA-256 +`ee61374030f80665fbbba0497b46010f222c2f97118505be4909d8ffa60bdc5c`. + +- A native ARM64 GCC 13.3.0/binutils 2.42 build completed with + `make ARCH=arm64 W=1 -j4 vmlinux modules`. +- `vmlinux` and all 7,817 modules listed in `modules.order` were present and + identified as little-endian AArch64 ELF files. The modified drivers had no + compiler warnings or errors. Warnings elsewhere in the carrier are + classified in the subsequent-build comparison below; this was not a + warning-free whole-kernel build. +- The effective configuration was unchanged by `olddefconfig`, SHA-256 + `bccba597adeb6475571f813c278ca02e6a478c2134f862cf64e1319ada10bdf7`. + It enabled both sensor drivers as modules for validation and disabled debug + information; it was not an identical release configuration/toolchain. +- Its `vmlinux` SHA-256 was + `83a4abe6b3a00e17c270c6820d88c99e441b1fe196816246cfc656106bf8d31c`. + +That complete build did **not** contain the HDR correction added in revision 2 +or the three corrections added in revision 3. + +## Revision 2: HDR correction + +Revision 2 retains all 23 earlier patch emails byte-for-byte and appends IR +commit `5c961dcbcb4e7742124e402800317f693634ddb4`, for 24 patches total. +That revision's IR driver SHA-256 is +`086ec2feff305a159d93a283c0e303fd04108c798e6c4252d34bfe5e45c9b5b1`. +The other three driver/binding snapshots are unchanged. + +An independent replay applied all 24 unmodified emails with `git am` to the +complete original v8 commit and tree on a fresh Linux filesystem, not a +synthetic small-file fixture. All author names/emails, exact author dates and +timezones, complete messages, ordered trailers and code patch IDs were +preserved. The resulting tree was +`b1cd6a5c0b39d1e443177f7ef0d91fdbcf3a4720`, with all four snapshots matching +the manifest and only those paths plus the declared `MAINTAINERS` addition +changed from v8. Index/worktree, whitespace and connectivity checks were clean. + +The correction keeps the existing 446-line maximum-register setting +when an HDR-capable model is in HDR subtraction mode and automatic/manual +exposure changes. Other modes retain their prior frame-derived maximum. +Manual register values, register order, frame timing, GPIO policy and the +VD55G0 illumination gate are unchanged. + +- Independent tests extracted the actual source functions, rather than a + rewritten model. The corrected source passed 779/779 cases on both host + AddressSanitizer/UndefinedBehaviorSanitizer and native ARM64 Linux. + The unchanged pre-fix source failed 176/779 HDR-sensitive cases on both. +- Cases included G0 with no HDR control, G1/G4 HDR and non-HDR operation, + control-change flag combinations, live automatic/manual transitions and + register-resolution/write failures. Stub tests do not exercise actual + hardware, firmware, V4L2 runtime locking or PM/CCI services. +- A native ARM64 `W=1` object and external test-module link passed without + warnings or errors against the previous build's read-only prepared kernel, + configuration and `Module.symvers`. No full kernel rebuild was repeated. +- The focused object SHA-256 was + `ecdf9dd3d1ab39b32abba7c64876d6bafb6c11e8ba61d01458581ac2e6576be7`; + the focused test-module SHA-256 was + `e624a4dfc7fa39486b30362b4517815860b4c09546a0d57ce8fa0eb4eaeffbc6`. + Those artifacts are not supplied as standalone installation binaries. +- The focused diff passed strict checkpatch and whitespace checks. + +## Current revision: format, control lifecycle and G0 exposure writes + +Revision 3 retains all 24 revision-2 patch emails byte-for-byte and appends: + +- `62ff85ae8cc10d44040b293bea3cf887eff65d09`: reject active format changes + while the core still owns a stream, including after a failed stop. TRY + negotiation remains available. The exact source passed 209 synthetic cases + per host-sanitizer/native-ARM64 platform; these model the surrounding core. +- `ef08c6a681e2cf61feb5aa5189bf51368401b6f0`: initialize the handler before + choosing the shared state lock, copy initial state before control + construction, check sticky constructor errors and release state before its + handler. Stream callbacks use caller-locked grab helpers. The exact source + passed 298 synthetic lifecycle cases per platform. Separate real-mutex + cases distinguished the corrected helpers from a deadlocking intermediate; + they do not constitute full-kernel lockdep or real V4L2 allocator tests. +- `ff99e470cc8ca665580cd26bd072145d853d1ab5`: omit the newly added G0 + maximum-register write and remove its mapping, restoring the native ST G0 + control-write pattern. Mode selection, manual exposure/gains, software + ranges and G1/G4/VD65G4 register traces remain unchanged. + +The current IR driver SHA-256 is +`600064057065a8a7a98951fa016b22dda842c634bf3832a985f2583eb488e1a0`. +The other three source snapshots and six licence/copying files are unchanged. +All 27 emails replayed onto the complete original v8 tree on a Linux filesystem, +with author identities, exact dates, messages, ordered trailers and stable +code patch IDs preserved. Only the four snapshots and declared `MAINTAINERS` +addition differ from the carrier. The manifest records the resulting tree. + +The final G0 correction passed 1,059/1,059 exact-function cases on both host +AddressSanitizer/UndefinedBehaviorSanitizer and native ARM64 GCC 13.3. The +unchanged parent failed 76 intended G0-only assertions, not 76 demonstrated +physical defects. All 896 success-matrix traces differ only by deleting the +G0 `0x096c` operation; all 912 non-G0 cases, including injected failures, are +unchanged. The harness uses actual CCI encodings, maps, resolver and write/ +control functions, but a stub bus and projected control/model structures. +It proves traces and first-error handling, not register acceptance or AE +efficacy. Missing G0 mapping returns `-EOPNOTSUPP` without a CCI transaction. + +A focused native ARM64 `W=1` object/module link for this exact final source +passed without warnings or errors against the read-only prepared kernel, +configuration and `Module.symvers`. The object SHA-256 is +`17859f8fb91b595577ce28fc002fcbbb31f3f51227ccf4555e828be33a5600ab`; +the test-module SHA-256 is +`ad15bc5252eadba578b53c8e2f2c18f0c94b72a9ae2a0be54c9e18b311d8d8cf`. +At bundle creation, revision 3 had only the focused build above; the later +complete build is recorded below. These test artifacts are not standalone +installation modules. The final diff passed strict +checkpatch and whitespace checks; OpenCode GLM 5.1 found no source-level +blocker in the bounded correction. + +## Revision 3: subsequent complete kernel and module link + +The exact 27-patch replay, tree +`aa48347a793826819475a078eee05759fadc7f26`, subsequently completed a clean +native ARM64 GCC 13.3.0/binutils 2.42 build with +`make ARCH=arm64 W=1 -j4 vmlinux modules`. The same validation configuration +remained unchanged by `olddefconfig`. This includes all four source corrections +added after the earlier complete build, not just the original 23 patches. + +Independent read-only checks verified `vmlinux` and all 7,817 ordered modules +as ELF64 little-endian AArch64 files, with unique canonical paths, no object/ +module aliases and recorded hashes. The frozen build wrapper had returned a +post-build verification error because it expected `.ko` entries in +`modules.order`. The kernel's own `scripts/Makefile.modfinal` instead maps its +listed `.o` entries to `.ko`. A separate corrected checker verified every +mapped output; neither the build nor its original failed report was replaced. + +Both full logs contain 1,307 source-positioned compiler warnings and 2,467 raw +warning-colon lines, including diagnostics without source positions. Neither +modified sensor appears in those warning lines. The ten category counts match. +Review of all added/removed signatures found only typographic versus ASCII +apostrophes; complete warning multisets match after that exact normalization. +This is explicit review of a presentation difference, not a warning-free build +or an assertion that existing carrier warnings are harmless. + +The complete-build artifact SHA-256 values are: + +- `vmlinux`: `f73d23291d20eca9c5305b61a06923274089364aacd37b5639c53028a8a748ed`. +- `ov13858.ko`: `bb4f5551712351a307e9b5d87bae02d9f22b4e77297a9945676ecd4add35fb69`. +- `vd55g1.ko`: `902d482c62721a8552f8f042adf35602a5417452b441da881e766f72bfa47abd`. + +The existing source archive remains immutable. These later checks qualify +compilation/linking of its exact source, not Debian packaging, binding or +`MAINTAINERS` semantics, release-toolchain equivalence, installation, capture, +firmware behavior or physical hardware safety. Generated signing material was +not read, hashed or exported by the audit. + +## Scoped binding and example checks + +The same exact two binding snapshots subsequently passed direct document +validation, YAML lint and the kernel's binding-style checker using dtschema +2026.9. Each direct check exited successfully without diagnostics. The selected +kernel `dt_binding_check` target generated both example DTBs and all three check +stamps without changing source. + +Separate checks confirmed both schema IDs were present in the processed schema +and inspected JSON validation diagnostics, not just command exit status. In +this tool version, `dt-validate` returns zero even for validation errors, and +the kernel example recipe masks that exit with `true`. + +Four synthetic examples passed: the original OV13858 and G1 examples, plus G0 +without LEDs using 600 MHz and the 375 MHz schema-minimum link frequencies. +Five controls were +rejected by the intended schemas: missing `reg` on either original sensor, +OV13858 at the unsupported 592.8 MHz link frequency, G0 with `st,leds`, and G0 +below 375 MHz. These checks cover the stated example constraints, not every possible +tree, driver behavior or physical clock/sensor configuration. + +Whole-tree schema aggregation also reported an unrelated ath12k parsing error, +duplicate EC-compatible schemas and a jobserver warning. These remain recorded; +the check is not described as a clean validation of every carrier binding. +No board graph, camera operation, electrical or optical behavior was qualified. + +## Remaining boundaries + +No Denali rear/IR graph, physical sensor identity, calibration, runtime +capture, failure recovery, system sleep or existing G1/G4 hardware regression +has been qualified. G0 nonempty `st,leds` is rejected before power resources +are used. Post-firmware GPIO input programming does not prove reset/boot pin +states or physical no-emission, electrical or optical safety. + +ST's pinned native G0 source, with the same cut1 firmware 2.11 and cut2 0.5 +payloads, never directly writes `0x096c`. Public documentation describes that +register as a manual maximum in a standby-configured group; this is not proof +of an AUTO ceiling or a firmware-specific live-write exception. The current +source restores the vendor-native absence of that host operation, not full +vendor-driver equivalence or physical exposure-range validation. + +Live VBLANK changes continue to update context frame length and software +exposure ranges. Register latching, full manual range, G1-family live +static-register writes, partial-write recovery and LED context behavior +require separate source/sensor review. No speculative startup-only maximum +write or group-hold transaction is added. None of these changes qualifies +illumination. diff --git a/tools/sp11-driver-audit/native-source-bundles.md b/tools/sp11-driver-audit/native-source-bundles.md new file mode 100644 index 00000000000000..7d3e8b40958e9c --- /dev/null +++ b/tools/sp11-driver-audit/native-source-bundles.md @@ -0,0 +1,156 @@ +# Native camera source foundations + +These source-only foundations are development material, not a qualified +kernel release or a ready-to-install camera fix. They do not enable rear or +IR camera nodes on Denali, change a boot default, or replace the working +front-camera, audio or touch configuration. + +The production code uses native Linux I2C/CCI, V4L2, firmware-node, regulator, +clock and GPIO interfaces. Building it does not require Windows, the MSI, +the research readers or any Windows driver/configuration file. The VD55G0 +driver includes sensor firmware patches published in ST's GPL-2.0 Linux +reference driver; Windows independence does not mean firmware independence. +This does not resolve firmware sourcing for every other SP11 subsystem. + +## Contents and provenance + +- `patches/ov13858/`: the OV13858 binding and native OF power-management + commits, with existing sensor register tables retained. +- `patches/vd55g0/`: the native VD55G1-family prerequisite/VD55G0 series and + separate local corrections. Original contributors' authorship and sign-offs + are retained; the port does not import the newer media-core pad API or + unrelated Intel IPU/x86 platform changes. +- `source/`: exact driver and binding snapshots for review. They belong in a + full kernel tree, not a stand-alone Windows compatibility module or DKMS + shim. +- `MANIFEST.json` and `SHA256SUMS`: exact carrier, source revisions, source + hashes and patch inventory. The manifest distinguishes source checks from + physical qualification. Licence texts accompany the source. + +The carrier is the tested v8 source revision +`8ba2b75713feb5c72bbc11dd8cce4048e23cdeea` from +[the kernel fork](https://github.com/ooaklee/linux_ms_dev_kit-sp11). +That does not make these new changes hardware-tested. Later carrier updates, +including the separate touch-autoload repair, require their own integration +and regression checks. Fork research tools and CI are not part of the native +driver patch sets. + +Revision 3 retains revision 2's 24 patch emails unchanged and appends three +IR-driver corrections: reject active format changes while streaming, establish +the shared state/control lock before publication and use its caller-locked +control helpers, and restore VD55G0's native exposure-control write sequence. +The bundle contains 2 OV13858 and 25 IR patches. Earlier archives remain +separately identified, immutable historical revisions. + +VD55G0 source is based on +[Peter Marshall's native series](https://github.com/petm5/linux/commit/4a04accd5f688ac4fdca1ad4d3959ef17825085c), +the existing ST driver, and +[Jizhou Tong's register-width correction](https://patchwork.linuxtv.org/project/linux-media/patch/010601a1332f51fe-f5c0bb2d-cf7f-4e99-9feb-1e8f65057945-000000@ap-northeast-1.amazonses.com/). +The two embedded VD55G0 firmware payloads were compared byte-for-byte with +[ST's pinned Linux reference](https://github.com/STMicroelectronics/vd55g0-linux-driver/blob/a05627b0f6d8775aa54b6fa306e91f425f2cbf9e/vd55g0_patches.h). + +## Apply and compile for source review + +Use a separate full kernel checkout at the manifest's carrier revision. +Verify `SHA256SUMS` inside the extracted bundle before applying either +independent patch set. From the kernel checkout: + +```sh +git switch -c sp11-native-camera-research +git am /path/to/source-bundle/patches/ov13858/*.patch +git am /path/to/source-bundle/patches/vd55g0/*.patch +``` + +For an ARM64 Linux source/object check, with the normal kernel build +dependencies installed: + +```sh +mkdir -p ../sp11-native-camera-build +python3 debian/scripts/misc/annotations \ + --file debian.qcom-x1e/config/annotations \ + --arch arm64 --flavour qcom-x1e --export \ + > ../sp11-native-camera-build/.config +scripts/config --file ../sp11-native-camera-build/.config \ + --module VIDEO_OV13858 --module VIDEO_VD55G1 +make O=../sp11-native-camera-build ARCH=arm64 olddefconfig +make O=../sp11-native-camera-build ARCH=arm64 W=1 -j4 \ + drivers/media/i2c/ov13858.o drivers/media/i2c/vd55g1.o +``` + +`olddefconfig` adapts to the available compiler/tools; review its configuration +delta rather than calling it an identical release configuration. Cross builds +also require an appropriate `CROSS_COMPILE`. These example object commands do +not produce an installable package or validate CSI capture. Do not use the +resulting objects with `insmod`. + +## Source validation, not hardware qualification + +A complete ARM64 `W=1` build of `vmlinux` and all 7,817 ordered modules passed +for the **previous 23-patch revision**, whose IR source ends at +`14bb8959fc37812a23ed55af73a2eb273acdb043`. Its configuration was unchanged +by `olddefconfig`; it was a validation configuration, not an identical release +toolchain/configuration. Warnings elsewhere in the carrier remain documented. +That full build does not include revision 2's HDR correction or revision 3's +three corrections. + +The **current 27-patch revision** ends at IR commit +`ff99e470cc8ca665580cd26bd072145d853d1ab5`. Its exact source subsequently +completed a clean full ARM64 `W=1` build of `vmlinux` and all 7,817 ordered +modules, independently checked against the unchanged validation configuration +and source pins. No modified-driver warnings were found. Existing carrier +warning counts/categories match the earlier log; differing signatures were +individually reconciled as quotation-mark presentation only. A post-build +checker correction follows the kernel's actual `.o`-to-`.ko` ordering contract; +the original failed checker report and immutable archive remain preserved. + +The exact driver also passed a focused ARM64 `W=1` object/module link against +the prepared kernel and 1,059 synthetic register-trace/error cases on each of +host sanitizers and native ARM64 Linux. +The G0 correction removes only the added maximum-register operation; all +tested G1/G4/VD65G4 traces, including HDR subtraction's 446-line value, remain +unchanged. Separate checks at the preceding format and lifecycle commits +cover active-state mutation, allocation failures and shared-lock ownership. +These are source-level tests, not sensor or full V4L2/PM/CCI-core tests. + +All 27 patch emails applied with `git am` to the complete original v8 +commit/tree on a Linux filesystem, preserving author identities, exact dates, +commit messages and ordered trailers. The resulting four snapshots matched; +only those paths and the declared `MAINTAINERS` addition differ from v8. + +See [the validation summary](native-camera-validation.md) and the bundle's +manifest for exact source, configuration and artifact hashes. The exact binding +snapshots also passed scoped document/lint/style checks and four positive/five +negative synthetic example checks; unrelated carrier schema diagnostics remain +recorded. These are not physical board or exhaustive schema-semantic tests. +No revision +is an installable kernel release or evidence of camera, suspend or +optical-safety qualification. + +## Deliberate qualification gates + +OV13858 still needs verified reset-net and sensor pin-to-rail ownership, +module/actuator calibration handling, sensor/receiver lane mapping and +timing, followed by capture/lifecycle tests. See +[the OV13858 qualification notes](ov13858-native.md). + +VD55G0 illumination is deliberately unsupported in this foundation: +`st,leds` is rejected for that sensor before resources are powered. The +driver does not guess GPIO1 wiring or manufacture a flash control. Sensor +GPIOs are configured as inputs after firmware reaches standby and before +streaming. This is a software policy, not proof of undocumented reset +defaults, absence of emitted IR, or electrical/eye safety. + +IR board identity, actual I2C routing/address, supplies/reset, CSI graph, +capture timing, calibration and sensor-only runtime behaviour remain to be +qualified. Illuminator wiring, first-frame and live-control exposure limits, +current control and independent safety requirements need a separate review +before illumination support can be added. G0's `0x096c` is described as a +manual maximum, not a proven automatic-exposure ceiling. The current source +does not directly access it, matching ST's native driver for the same firmware +versions. This does not establish the full physical exposure range. Live +VBLANK changes update context frame length and software exposure ranges; +firmware-specific maximum/latching rules and the G1-family live static-register +policy still need qualification. No guessed group-hold or startup-only maximum +write has been added. Full capture, runtime failure recovery, suspend/resume +and physical VD55G1/G4 regression compatibility remain unproven. No device +installation instructions are supplied for this unqualified foundation. diff --git a/tools/sp11-driver-audit/native-usb4-r5-validation.md b/tools/sp11-driver-audit/native-usb4-r5-validation.md new file mode 100644 index 00000000000000..dc2fe11a5efeb7 --- /dev/null +++ b/tools/sp11-driver-audit/native-usb4-r5-validation.md @@ -0,0 +1,132 @@ +# Native USB4 source R5 — validation supplement, 17 October 2026 + +This is experimental source validation, **not a working USB4 release**. Do not +load or activate the Qualcomm host-router frontend. Its explicit DT/sysfs +activation paths remain reachable; the prohibition is operational, not a +compile-time interlock. This work does not change the accepted v25/v26 kernels. + +This dated note supplements the immutable R5 source archive. The archive's +README correctly records the object-only evidence available when it was +created; the subsequent module checks below do not rewrite that history. + +## Source identity and delivery + +The complete base is `e02a1a495958175c63c65643c4cc8ab80647330f` from +[the kernel fork](https://github.com/ooaklee/linux_ms_dev_kit-sp11). +The local corrected tip is `bae6318b004835cfcbf60fff75e80f1902d5c315`, tree +`5c525eaf0627d3cd1af9d8e93bac684932765c8f`. The local tip is not claimed to be +publicly retrievable. Seven included email patches reconstruct its six changed +files from the exact base. This is not a standalone or DKMS driver. + +The source-only archive is `sp11-native-usb4-source-20261017-r5.tar.gz`, +88,024 bytes, SHA-256: + +```text +e24c557cd5bc3f02288141ee2309f1b1066ca881630f60b0b59cf62cebfca94b +``` + +It contains six complete changed source files, seven email patches, both base +licence texts, a README, manifest and checksum list. The first six emails and +licences remain byte-identical to R4. A minimal six-file replay preserved author +metadata, raw dates, complete messages and stable patch IDs for all seven commits +and matched the final snapshots. That is not a full-tree replay claim. An +independent in-memory archive reconstruction matched every byte. + +No Windows executable, firmware payload, recovered pseudocode, captured table, +private log, compiled module or signing key is included. Compiling this +experimental source does not require Windows or the MSI. Firmware sourcing, +authenticity and the physical activation protocol remain unresolved requirements. + +## Correction and completed checks + +R5 validates the entire cold-load firmware container before that branch halts +the UC or writes payloads. It checks complete headers and payloads using +remaining-byte arithmetic, retains destination/count limits, rejects empty or +trailing incomplete input and unaligned destinations, and reads little-endian +words without assuming input alignment. Record order and overlaps are retained. +Earlier resource preparation and register reads are outside this guarantee. + +The exact parser/bring-up functions passed 90 host cases and 130,436 assertions +in each optimized and ASan/UBSan mode, with ten named inverse controls rejected. +Full ordered reference comparison covers aligned inputs; other byte alignments +have payload/counter oracles. One observed sample passed the pure validator in +memory without MMIO. These do not authenticate firmware or define all vendor +formats. + +The later native ARM64 check freshly compiled `qcom_usb4_hr.o` with GCC 13.3.0, +binutils 2.42 and `W=1`, ran normal in-tree MODPOST, and linked both unsigned +modules. No new-module diagnostics were captured. OpenCode reviewed the +prepared procedure; the completed evidence was separately inspected and audited. + +The configuration was unchanged `ubuntu_x1e_defconfig`, SHA-256: + +```text +a3cdca096dabe73dc9c2b060722d1744733e42bf808a7e0c3edd3dca4363acec +``` + +This is the prototype release **`7.2.0`**, not the accepted v26 `7.2.5` ABI. +Built-in objects were reused, not freshly rebuilt: all 4,196 object hashes and +current GENKSYMS command hashes were checked before/after. The original baseline +did not individually pin those command bytes. All 15,895 regenerated five-field +symbol rows matched its captured table after MODPOST; that captured table was +never imported or copied as a build input. The original built-in pass's 1,148 +diagnostics remain historical evidence, not a warning-free whole-kernel claim. + +Both final AArch64 ELF modules passed basic and extended version checks, strong +import/provider resolution, namespace/GPL checks and dependency comparison. +`nhi_remove`'s final export metadata matches CRC `0x8aa954cf`, GPL flag 1 and +empty namespace. This narrow check does not establish relocation correctness or +module loadability. + +- `qcom_usb4_hr.ko`: 61,144 bytes, 69 versioned imports, dependency `thunderbolt`. + Compared with R4, only `_dev_err` was removed; no imports were added. +- `thunderbolt.ko`: 868,344 bytes, 234 versioned imports; unchanged module bytes. + +No signing, installation, loading, device activation or hardware test occurred. +The temporary private key in the original build cache was neither opened nor +copied into the new output. The modules remain private validation artifacts, +not an installation bundle. + +## Reproduce a build only + +Obtain a disposable complete checkout of the exact base and apply all seven +archive patches in order, following its checksum/application instructions. +On native ARM64 Linux with the normal kernel prerequisites: + +```sh +make O=../usb4-r5-check ARCH=arm64 ubuntu_x1e_defconfig +sha256sum ../usb4-r5-check/.config +make O=../usb4-r5-check ARCH=arm64 W=1 -j2 vmlinux_o +make O=../usb4-r5-check ARCH=arm64 W=1 V=1 -j2 \ + drivers/thunderbolt/thunderbolt.ko drivers/thunderbolt/qcom_usb4_hr.ko +``` + +Compare the configuration digest with the one above before continuing. A +different compiler or prerequisite set can change it; stop and review that +delta instead of claiming identical validation. A fresh built-in build can +generate default temporary signing material: keep it private and out of any +archive. Do not import another kernel's `Module.symvers` or use suppression +flags to bypass module checks. These commands do not install the results, and +the results must not be loaded into an unrelated installed kernel. + +## Next functional work + +Static investigation establishes a missing event-channel contract, not a safe +register sequence to copy. A running UC does not by itself establish a valid +event-buffer location, bounded layout or interrupt readiness. Native work must +validate resource-relative offsets, entry/header extents, producer/consumer +indices and lifetimes, including a fresh-instance warm start. + +The inspected Windows path can publish event state and continue after its +positive timeout result. Linux should retain negative timeout failure instead +of adopting that behavior. The inspected PCIe-interrupt selector is mutable +software policy, not board identity; native support must not depend on Windows +registry values. Decompiler output alone incorrectly suggested a constant +selector, which the actual instructions and output-pointer calls contradict. + +Physical resource/MMIO ownership, IRQ acknowledgement and masking, event-memory +coherency, DMA/work quiescence, powered dependencies, stop/idle/restart and +recovery still require implementation and qualification. The inherited arming +sequence still contains unaligned word accesses; guessed address substitutions +are not a justified repair. Suspend/resume, working USB4 and full Windows/Linux +feature parity remain unproven. diff --git a/tools/sp11-driver-audit/native-usb4-resource-bounds.md b/tools/sp11-driver-audit/native-usb4-resource-bounds.md new file mode 100644 index 00000000000000..bd14cbb5299efb --- /dev/null +++ b/tools/sp11-driver-audit/native-usb4-resource-bounds.md @@ -0,0 +1,121 @@ +# Native USB4 resource bounds — 17 October 2026 + +This is a source-only correction to the experimental Qualcomm host-router +frontend, **not working or hardware-qualified USB4 support**. Do not load or +activate it. Existing explicit DT/sysfs activation paths remain reachable; +this warning is not a compile-time interlock. Accepted v25/v26 sources and the +Surface installation are unchanged. + +## Correction and source identity + +The local source commit is `7620268bebf4c7984691707e32effbd0778b25bf`, following +R5 commit `bae6318b004835cfcbf60fff75e80f1902d5c315`. Only +`drivers/thunderbolt/qcom_usb4_hr.c` changes. Its SHA-256 is: + +```text +a505501e30dfe5b22c7cf3bfd2fa5f1d57d327055a302b6c81d281ec369c8ae8 +``` + +The firmware parser limits destinations to 56 KiB, but the old probe accepted +a successfully mapped UC RAM resource smaller than that. Other register and +diagnostic accesses also assumed minimum extents. Probe now validates every +named range before mapping any of them or entering power/PHY/firmware setup. +Missing, reversed, inclusive-size-wrapped and undersized ranges fail. +Mapping errors retain their error codes and device-managed cleanup. + +The nonzero minimum extents cover the existing frontend accesses: + +| Named resource | Minimum bytes | +| --- | ---: | +| `router` | `0x20ac` | +| `router_config` | `0x1c` | +| `port_group` | `0x68` | +| `sideband` | `0x40` | +| `uc_ram` | `0xe000` | +| `uc_per` | `0x1004` | +| `usbap_config` | `0x18` | +| `nhi` | `0x40000` | + +The NHI value conservatively retains the complete described Qualcomm aperture; +it is not a derived minimum for every NHI implementation. Unused named ranges +remain required with a representable nonzero size. Larger UC RAM resources +do not enlarge the firmware parser's fixed destination limit. The complete +firmware validator and bring-up functions remain byte-identical to R5. + +Logical bounds do not prove physical ownership, powered access or alignment +safety. In particular, this does not repair or authorize the inherited +optional unaligned interrupt-arming accesses or raw PHY/mailbox experiments. + +## Validation actually completed + +The exact production resource declaration, table and mapping helper were +extracted into a host fixture. Each optimized and ASan/UBSan run passed 97 +named cases and 6,703 assertions. Tests cover names/order, exact and short +extents, larger resources, missing and reversed ranges, full-width wrap, +mapping failures and validation before any map. Removing the minimum-size +guard failed the independent UC RAM extent oracle in both modes. + +The actual probe's early error-return ordering was checked statically, not +executed as a complete platform lifecycle. The fixture does not simulate +physical MMIO or device-managed cleanup. Earlier firmware tests were not +rerun or presented as new whole-driver coverage. + +A fresh native ARM64 `W=1` object compilation passed with GCC 13.3.0 and +binutils 2.42, using unchanged `ubuntu_x1e_defconfig`: + +```text +a3cdca096dabe73dc9c2b060722d1744733e42bf808a7e0c3edd3dca4363acec +``` + +The 56,832-byte AArch64 ELF relocatable object has SHA-256: + +```text +d36db78b50f6222fb36aaad21283ff880c0e73d2befb7c12b479b3db42ae905e +``` + +The compiler ran non-root with fresh output and read-only source. The full +source carrier and six overlays were checked before/after; no compiler +diagnostics were captured. OpenCode reviewed the source and prepared procedure; +completed artifacts received a separate read-only audit. + +This is the prototype **7.2.0** configuration, not the installed v26 7.2.5 ABI. +The new object adds `devm_ioremap_resource` and +`platform_get_resource_byname`, replacing +`devm_platform_ioremap_resource_byname`. These are undefined object symbols, +not newly verified module imports. R5's later +[module validation](native-usb4-r5-validation.md) does not apply automatically +to this changed object. New MODPOST, module linking, packaging, loadability +and hardware qualification remain outstanding. + +## Source-only companion delivery + +The immutable R5 archive remains unchanged. Apply the companion email patch +after its seven patches in a disposable complete checkout of the exact base: + +`sp11-native-usb4-resource-bounds-20261017.patch` — 4,834 bytes, SHA-256: + +```text +b211dd75be346927bb0bc3b9ab2e64f86d7ef5f75f28d15efb50d19effd8b0cd +``` + +A matching `.patch.sha256` sidecar is supplied. The patch was applied to a +fresh minimal repository containing all six exact R5 snapshots. All six final +files, author/raw date/complete message and stable patch ID matched the new +source. That is a minimal source replay, not a full-tree replay. The local +commit is not claimed publicly retrievable, and this note does not publish +the patch or archive remotely. + +This is not a standalone or DKMS driver. Use the complete exact kernel base +and matching build prerequisites; no Windows files are needed for compilation. +No Windows binaries, firmware, recovered pseudocode, private captures, +compiled modules or signing keys are part of the companion patch. Firmware +sourcing/authenticity remains a separate unresolved dependency. + +## Next functional requirement + +Static analysis now distinguishes the configuration command from event-ring +initialization and traces notification scheduling. The observed counter lock +does not establish that firmware ring entries remain stable while consumed. +Linux still needs a verified producer/consumer and acknowledgement contract, +resource ownership, safe warm-start reconstruction and physical stop/idle. +Do not infer USB4, dock, standby or complete feature parity from these checks. diff --git a/tools/sp11-driver-audit/ov13858-native.md b/tools/sp11-driver-audit/ov13858-native.md new file mode 100644 index 00000000000000..1cfb5161ebcdd2 --- /dev/null +++ b/tools/sp11-driver-audit/ov13858-native.md @@ -0,0 +1,103 @@ +# Native OV13858 foundation + +This development series extends the existing Linux OV13858 sensor driver. +It requires no Windows installation, MSI, vendor configuration container, +Windows executable or recovered register table to build or run. It is not yet +a working rear-camera configuration for ARM Surface Pro 11. + +## Scope + +The generic `ovti,ov13858` binding supplies a native firmware-node interface: +one 19.2 MHz clock, `dovdd`, `avdd` and `dvdd` supplies, an optional reset GPIO +and a CSI-2 endpoint. Existing ACPI systems retain their power-domain resource +ownership and do not acquire the new DT supplies or GPIO. + +The driver retains its existing register tables. The endpoint selects the +existing 270 MHz modes, the existing 540 MHz mode, or both. Mode enumeration, +format selection and controls use that selected set. Four D-PHY data lanes +in order, clock lane zero, normal polarities and continuous clock are required; +unsupported configurations are rejected before power resources are used. + +Runtime PM is enabled before asynchronous subdevice registration, with a +reference held through publication. Unknown supply state after enable, +disable or unwind failure latches a fatal power fault. Reset remains asserted, +the enabled clock is stopped once, and further register I/O or stream enable +is rejected. This is containment, not automatic electrical recovery: a reprobe +alone cannot establish that a failed regulator provider or physical rail has +recovered. + +## Source provenance + +Power sequencing follows native Linux regulator, clock, GPIO and V4L2 APIs. +The 1 ms reset hold and 5 ms post-release recovery use the precedent in +[Sergey Lebedev's Linux-media v3 proposal](https://lore.kernel.org/linux-media/20260831130312.26296-1-lsa.uz@pm.me/). +That work concerns Intel Surface Pro 11 for Business; these times are not +measurements on ARM Denali. Its contribution is referenced rather than +attributing this implementation or a new sign-off to that author. + +Separate static research establishes milliseconds for the Windows resource +`DELAY` values, including 1 and 10 ms in the rear configuration. This does not +prove that the generic sensor timing covers the entire Denali module. Do not +silently substitute either sequence for verified board/module requirements. + +## Before Denali enablement + +No Denali DTS node is enabled by this series. Resolve and review: + +- Current-board and effective-PCFG-bank confirmation. Static consumer + tracing now associates the rear with slot zero; the saved MSHW0495 + allocation, HAL and SCFG together describe `cci0_i2c1` at `0x10`, CSIPHY1. + This is not a fresh translated-resource or physical capture trace. +- Reset-net and sensor pin-to-rail ownership. Static analysis establishes + physical GPIO110 low/high levels and the `ldom6`/`ldom1`/`ldom5`/`ldob16` + voltage votes, but does not prove that GPIO110 is reset or that the + separately sequenced 2.9 V rail belongs to an actuator. +- Physical sensor-to-receiver lane assignment, polarities, sensor PLL and + module-specific timing. The verified Windows producer's nominal 592.8 MHz + request assumes zero overhead, whose effective setting is unresolved; + it is not a new validated Linux mode. Metadata and listed frame-length + writes still disagree, and output and exposure clocks are distinct. +- Actuator/OTP calibration ownership and indicator sharing with the front + and IR cameras, using native Linux interfaces. + +The [resource field notes](camera-fields.md) distinguish configured serial +ordering from the generic driver's asynchronous bulk regulator enable. The +5 ms generic post-reset delay also differs from the module's configured +10 ms. Neither difference is a measured failure or a verified sensor-wide +requirement; do not change generic ACPI behaviour or add a guessed fourth +sensor supply to reproduce an unverified board sequence. + +Runtime qualification must cover chip identification, test-pattern/capture +layout, start/stop/reopen, fault and probe-deferral paths, indicator release, +idle power, system sleep, and existing ACPI users. Source compilation and +binding validation are not evidence that rear capture works. + +## Source checks completed + +The source is split into binding commit `82d8210aa474` and native driver commit +`135b49d9a108`. The existing driver blob was identical in this research base, +the tested v8 carrier and the inspected jglathe 7.2.9 tree before these edits. + +- ARM64 GCC 13.3 `W=1` driver-object builds passed with ACPI enabled, ACPI + disabled, and runtime PM disabled in a minimal configuration. +- Targeted binding/example validation passed, with positive frequency-subset + fixtures and negative lane, polarity, clock and frequency fixtures. +- Strict checkpatch reported zero errors, warnings or checks. +- Seven host logic scenarios using the exact extracted power, register and + stream-guard functions passed AddressSanitizer and UndefinedBehaviorSanitizer. + These use API stubs, not the real PM core, regulators or hardware. +- An OpenCode GLM 5.1 final source review found no source-level blocker after + its initial findings were checked against this tree and the genuine unknown + supply-state failure was contained. +- Both focused commits applied cleanly to an exact tested-v8 source export. + Its exported `qcom-x1e` configuration already selected OV13858 as a module; + the ARM64 `W=1` driver-object build passed against those carrier headers, + with no Windows package present in the build environment. + +The unchanged OV13858 snapshot was also included in the previous 23-patch +revision's complete ARM64 kernel/module link. Revisions 2 and 3 add separately +tested IR-driver corrections without changing this sensor snapshot; see +[the validation summary](native-camera-validation.md) for that distinction. +These checks do not qualify a combined hardware release. Integration with later +carrier changes and physical OF/ACPI regression tests remain required before a +release claim. diff --git a/tools/sp11-driver-audit/windows-resources.mjs b/tools/sp11-driver-audit/windows-resources.mjs new file mode 100644 index 00000000000000..c2e47b603c49f6 --- /dev/null +++ b/tools/sp11-driver-audit/windows-resources.mjs @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: GPL-2.0-only +// Original, offline reader for an explicitly selected observed ARM64 layout. +// Structure definitions, not implementation code, were consulted: +// https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ns-wdm-_cm_resource_list +// https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ns-wdm-_cm_partial_resource_descriptor +// +// Evidence note: four identical QCOM0C32/MSHW0495 REG exports constitute ONE +// observation. File SHA256 a46ebcfc358440fc30c196f9dc0c14b028f06a197bd6964bcd93b374b592f061; +// BootConfig == AllocConfig, 200 bytes, resource SHA256 +// f340bd8f69c8e52ce6bfcf82e6912eeea896c0c4a86ba405d5c75815bca9f857. +// Ordinals 2/3 contain 0xac15000/0xac16000, both length 0x1000; these match +// Linux cci0/cci1. IRQ vectors 492/303 additionally match GIC SPI 460/271 +32. +// These are registry allocations, NOT an evaluated _CRS or direct WDF +// translated-resource capture, and do not independently prove sensor routing. +import { createHash } from 'node:crypto'; +import { open } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const WINDOWS_RESOURCE_LIMITS = Object.freeze({ + maxBytes: 1024 * 1024, + maxResources: 4096, + maxValues: 64, + maxLines: 16384, +}); + +export class WindowsResourceDecodeError extends Error { + constructor(code, message, offset = 0) { + super(message); + this.name = 'WindowsResourceDecodeError'; + this.code = code; + this.offset = offset; + } +} + +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const fail = (code, message, offset = 0) => { + throw new WindowsResourceDecodeError(code, message, offset); +}; + +function limitsFor(options) { + const limits = { ...WINDOWS_RESOURCE_LIMITS }; + for (const [key, value] of Object.entries(options)) { + if (!Object.hasOwn(limits, key)) throw new TypeError(`Unknown decoder option: ${key}`); + if (!Number.isSafeInteger(value) || value < 1 || value > limits[key]) { + throw new RangeError(`${key} must be an integer from 1 to ${limits[key]}`); + } + limits[key] = value; + } + return limits; +} + +function boundedCopy(input, limits) { + if (!(input instanceof Uint8Array)) throw new TypeError('Input must be bytes'); + if (input.byteLength > limits.maxBytes) fail('BYTE_LIMIT', 'Input exceeds the byte limit'); + return Buffer.from(input); +} + +const layoutLimits = [ + 'Caller-selected observed ARM64 layout: 20-byte header and 20-byte partial descriptors; architecture is not autodetected.', + 'Only one full resource list, version 1/revision 1, memory, line-interrupt and connection descriptors are supported.', + 'Unknown resource types, message-signaled interrupts and variable DeviceSpecificData are rejected.', + 'Registry allocation data does not establish whether an address is raw or translated; no _CRS evaluation or WDF trace is performed.', + 'Resource ordinals and connection IDs do not identify sensor routing, Linux GPIOs, I2C channels or hardware policy.', +]; + +/** No MMIO, driver execution, address translation, or automatic Linux mapping. */ +export function decodeCmResourceListArm64(input, options = {}) { + const limits = limitsFor(options); + const bytes = boundedCopy(input, limits); + if (bytes.length < 20) fail('SHORT_HEADER', 'CM_RESOURCE_LIST needs a 20-byte header'); + const fullResourceCount = bytes.readUInt32LE(0); + const version = bytes.readUInt16LE(12); + const revision = bytes.readUInt16LE(14); + const count = bytes.readUInt32LE(16); + if (fullResourceCount !== 1) fail('FULL_LIST_COUNT', 'Only one full resource list is supported'); + if (version !== 1 || revision !== 1) { + fail('UNSUPPORTED_VERSION', 'Only observed version 1/revision 1 is supported', 12); + } + if (count > limits.maxResources) fail('RESOURCE_LIMIT', 'Resource count exceeds the limit', 16); + if (bytes.length !== 20 + count * 20) { + fail('EXTENT_MISMATCH', 'Input extent differs from the selected fixed-descriptor layout', 16); + } + const resources = []; + for (let ordinal = 0; ordinal < count; ordinal++) { + const offset = 20 + ordinal * 20; + const type = bytes[offset]; + const flags = bytes.readUInt16LE(offset + 2); + const resource = { ordinal, offset, type, shareDisposition: bytes[offset + 1], flags }; + if (type === 3) { + Object.assign(resource, { + kind: 'memory', base: `0x${bytes.readBigUInt64LE(offset + 4).toString(16)}`, + length: bytes.readUInt32LE(offset + 12), + opaqueUnionTail: bytes.readUInt32LE(offset + 16), + }); + } else if (type === 2) { + // Only the observed line-interrupt flags 0/1 are interpreted. MSI has a + // different raw union; do not accidentally label its words as vectors. + if (flags & ~1) fail('UNSUPPORTED_INTERRUPT_FLAGS', 'Only line-interrupt flags 0/1 are supported', offset + 2); + Object.assign(resource, { + kind: 'interrupt', levelOrGroupWord: bytes.readUInt32LE(offset + 4), + vector: bytes.readUInt32LE(offset + 8), + affinity: bytes.readBigUInt64LE(offset + 12).toString(10), + }); + } else if (type === 0x84) { + Object.assign(resource, { + kind: 'connection', connectionClass: bytes[offset + 4], + connectionType: bytes[offset + 5], reserved: bytes.readUInt16LE(offset + 6), + connectionId: bytes.readBigUInt64LE(offset + 8).toString(10), + opaqueUnionTail: bytes.readUInt32LE(offset + 16), + }); + } else { + fail('UNSUPPORTED_RESOURCE_TYPE', `Resource type ${type} is outside the supported fixed layout`, offset); + } + resources.push(resource); + } + return { + schema: 'windows-cm-resource-list-observed-arm64-v1', sha256: digest(bytes), bytes: bytes.length, + header: { + fullResourceCount, interfaceTypeRaw: bytes.readUInt32LE(4), busNumber: bytes.readUInt32LE(8), + version, revision, count, + }, + resources, limits: [...layoutLimits], + }; +} + +/** Strict typed hex(8) extraction only, not a general REG importer/evaluator. */ +export function decodeRegistryResources(input, options = {}) { + const limits = limitsFor(options); + const bytes = boundedCopy(input, limits); + if (bytes.length < 2 || bytes[0] !== 0xff || bytes[1] !== 0xfe || bytes.length % 2) { + fail('REG_ENCODING', 'Expected BOM-prefixed UTF-16LE with an even byte extent'); + } + let text; + try { text = new TextDecoder('utf-16le', { fatal: true }).decode(bytes.subarray(2)); } + catch { fail('REG_ENCODING', 'Invalid UTF-16LE registry text'); } + if (text.includes('\0') || /\r(?!\n)/.test(text)) fail('REG_ENCODING', 'Unexpected NUL or bare CR in registry text'); + let lineCount = 1; + for (const ch of text) if (ch === '\n' && ++lineCount > limits.maxLines) { + fail('LINE_LIMIT', 'Registry line count exceeds the limit'); + } + const lines = text.split(/\r?\n/); + if (lines[0] !== 'Windows Registry Editor Version 5.00') fail('REG_HEADER', 'Unsupported registry text header'); + const entries = []; + const identities = new Set(); + let section = null; + let totalResources = 0; + for (let line = 1; line < lines.length; line++) { + const current = lines[line].trim(); + if (!current || current.startsWith(';')) continue; + if (current.startsWith('[')) { + const match = /^\[([^\]\r\n]+)\]$/.exec(current); + if (!match || match[1].startsWith('-')) fail('REG_SECTION', 'Unsupported registry section syntax'); + section = match[1]; + continue; + } + if (!current.startsWith('"') && !current.startsWith('@')) continue; + // Track every recognizable value, including ignored types, to avoid + // presenting an earlier/later hex(8) overwritten by X="..." or hex(2). + // This deliberately rejects escaped names rather than guessing aliases. + const assignment = /^(?:"([^"\\\r\n]{0,256})"|(@))=(.*)$/.exec(current); + if (!assignment || !section) fail('REG_VALUE_SYNTAX', 'Values require a section and an unescaped quoted/default name'); + const name = assignment[2] ? '' : assignment[1]; + const identity = `${section.toLowerCase()}\0${name.toLowerCase()}`; + if (identities.has(identity)) fail('DUPLICATE_VALUE', 'Repeated resource value has ambiguous registry semantics'); + identities.add(identity); + // Unrelated value contents are not decoded or re-emitted. Text mentioning + // hex(8) inside a quoted string is not a typed resource assignment. + const valueText = assignment[3]; + if (!/^hex\(8\)/i.test(valueText)) continue; + const match = /^hex\(8\):(.*)$/i.exec(valueText); + if (!match) fail('REG_VALUE_SYNTAX', 'Typed hex(8) resource value requires a colon'); + if (entries.length >= limits.maxValues) fail('VALUE_LIMIT', 'Resource value count exceeds the limit'); + const parts = []; + let fragment = match[1].trim(); + while (fragment.endsWith('\\')) { + if (!fragment.endsWith(',\\')) fail('REG_CONTINUATION', 'Hex continuation must follow a comma'); + parts.push(fragment.slice(0, -1)); + if (++line >= lines.length) fail('REG_CONTINUATION', 'Unfinished resource value continuation'); + if (!/^[ \t]+/.test(lines[line])) fail('REG_CONTINUATION', 'Continuation must be an indented hex line'); + fragment = lines[line].trim(); + } + parts.push(fragment); + const body = parts.join(''); + if (!/^[ \t]*[0-9a-f]{2}(?:[ \t]*,[ \t]*[0-9a-f]{2})*[ \t]*$/i.test(body)) { + fail('REG_HEX', 'Resource value must contain comma-separated two-digit hex bytes'); + } + const tokens = body.split(','); + // Check extent before allocating the decoded value or constructing nodes. + if (tokens.length > 20 + limits.maxResources * 20) fail('RESOURCE_LIMIT', 'Resource value extent exceeds the limit'); + const payload = Buffer.from(tokens.map((token) => Number.parseInt(token.trim(), 16))); + if (payload.length >= 20 && totalResources + payload.readUInt32LE(16) > limits.maxResources) { + fail('RESOURCE_LIMIT', 'Aggregate registry resource count exceeds the limit', 16); + } + const resourceList = decodeCmResourceListArm64(payload, limits); + totalResources += resourceList.header.count; + entries.push({ section, name, resourceList }); + } + if (!entries.length) fail('NO_RESOURCE_VALUES', 'No typed hex(8) resource values found'); + return { + schema: 'windows-registry-resources-utf16-observed-arm64-v1', + sha256: digest(bytes), bytes: bytes.length, + stats: { values: entries.length, resources: totalResources, distinctResourcePayloads: new Set(entries.map((entry) => entry.resourceList.sha256)).size }, + entries, + limits: [...layoutLimits, 'Only typed hex(8) contents are extracted; unrelated value contents are ignored, and nothing is imported.', + 'All repeated value identities and escaped/ambiguous value names are conservatively rejected, including unrelated value types.', + 'Identical payloads/copies are not independent hardware observations.'], + }; +} + +export async function readWindowsResourceBytes(path) { + const handle = await open(path, 'r'); + try { + const stat = await handle.stat(); + if (!stat.isFile()) throw new TypeError('Input must be a regular file'); + if (stat.size > WINDOWS_RESOURCE_LIMITS.maxBytes) fail('BYTE_LIMIT', 'Input exceeds the byte limit'); + const bytes = Buffer.alloc(stat.size + 1); + let offset = 0; + while (offset < bytes.length) { + const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, null); + if (!bytesRead) break; + offset += bytesRead; + } + if (offset !== stat.size) fail('FILE_CHANGED', 'Input length changed while reading'); + return bytes.subarray(0, offset); + } finally { await handle.close(); } +} + +async function main(args) { + const decoders = { 'registry-utf16-arm64': decodeRegistryResources, 'cm-arm64': decodeCmResourceListArm64 }; + if (args.length !== 4 || args[0] !== '--format' || !Object.hasOwn(decoders, args[1]) || args[2] !== '--input' || !args[3]) { + throw new TypeError('Usage: node windows-resources.mjs --format registry-utf16-arm64|cm-arm64 --input FILE'); + } + process.stdout.write(`${JSON.stringify(decoders[args[1]](await readWindowsResourceBytes(args[3])), null, 2)}\n`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + main(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${JSON.stringify({ error: error.code ?? error.name, message: error.message, + ...(error instanceof WindowsResourceDecodeError ? { offset: error.offset } : {}) })}\n`); + process.exitCode = 1; + }); +} diff --git a/tools/sp11-driver-audit/windows-resources.test.mjs b/tools/sp11-driver-audit/windows-resources.test.mjs new file mode 100644 index 00000000000000..e1c66988bb099c --- /dev/null +++ b/tools/sp11-driver-audit/windows-resources.test.mjs @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: GPL-2.0-only +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { WINDOWS_RESOURCE_LIMITS, WindowsResourceDecodeError, decodeCmResourceListArm64, decodeRegistryResources } from './windows-resources.mjs'; + +// All bytes are independently generated synthetic fixtures, not OEM captures. +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); +function descriptor(type, flags = 0) { + const bytes = Buffer.alloc(20); + bytes[0] = type; + bytes[1] = 1; + bytes.writeUInt16LE(flags, 2); + return bytes; +} +function memory(base = 0x123456789abcdef0n, length = 0x2000) { + const bytes = descriptor(3); + bytes.writeBigUInt64LE(base, 4); + bytes.writeUInt32LE(length, 12); + bytes.writeUInt32LE(0xabcdef01, 16); + return bytes; +} +function interrupt(flags = 1) { + const bytes = descriptor(2, flags); + bytes.writeUInt32LE(0x10203, 4); + bytes.writeUInt32LE(711, 8); + bytes.writeBigUInt64LE(0xffffffffffffffffn, 12); + return bytes; +} +function connection() { + const bytes = descriptor(0x84); + bytes[4] = 1; + bytes[5] = 2; + bytes.writeUInt16LE(0xabcd, 6); + bytes.writeBigUInt64LE(0xfedcba9876543210n, 8); + bytes.writeUInt32LE(0x76543210, 16); + return bytes; +} +function resourceList(resources = []) { + const header = Buffer.alloc(20); + header.writeUInt32LE(1, 0); + header.writeUInt32LE(15, 4); + header.writeUInt32LE(7, 8); + header.writeUInt16LE(1, 12); + header.writeUInt16LE(1, 14); + header.writeUInt32LE(resources.length, 16); + return Buffer.concat([header, ...resources]); +} +const hex = (bytes) => [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join(','); +function registry(body, eol = '\r\n') { + return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(['Windows Registry Editor Version 5.00', '', ...body].join(eol), 'utf16le')]); +} +function rejects(code, fn) { + assert.throws(fn, (error) => { + assert.ok(error instanceof WindowsResourceDecodeError); + assert.equal(error.code, code); + assert.ok(Number.isInteger(error.offset)); + return true; + }); +} + +test('observed ARM64 structure preserves ordinals, offsets, full-width fields and opaque tails', () => { + const bytes = resourceList([memory(), interrupt(), connection()]); + const result = decodeCmResourceListArm64(new Uint8Array(bytes)); + assert.equal(result.sha256, hash(bytes)); + assert.deepEqual(result.header, { fullResourceCount: 1, interfaceTypeRaw: 15, busNumber: 7, version: 1, revision: 1, count: 3 }); + assert.deepEqual(result.resources.map((resource) => [resource.ordinal, resource.offset, resource.kind]), [[0, 20, 'memory'], [1, 40, 'interrupt'], [2, 60, 'connection']]); + assert.equal(result.resources[0].base, '0x123456789abcdef0'); + assert.equal(result.resources[0].length, 0x2000); + assert.equal(result.resources[0].opaqueUnionTail, 0xabcdef01); + assert.equal(result.resources[1].levelOrGroupWord, 0x10203); + assert.equal(result.resources[1].vector, 711); + assert.equal(result.resources[1].affinity, '18446744073709551615'); + assert.equal(result.resources[2].connectionId, '18364758544493064720'); + assert.equal(result.resources[2].reserved, 0xabcd); + assert.equal(result.resources[2].opaqueUnionTail, 0x76543210); + assert.deepEqual(decodeCmResourceListArm64(resourceList()).resources, []); +}); + +test('strict counts, exact extent and version reject truncation, alternate packing and trailing data', () => { + rejects('SHORT_HEADER', () => decodeCmResourceListArm64(Buffer.alloc(19))); + for (const count of [0, 2]) { + const bytes = resourceList(); bytes.writeUInt32LE(count, 0); + rejects('FULL_LIST_COUNT', () => decodeCmResourceListArm64(bytes)); + } + for (const offset of [12, 14]) { + const bytes = resourceList(); bytes.writeUInt16LE(2, offset); + rejects('UNSUPPORTED_VERSION', () => decodeCmResourceListArm64(bytes)); + } + const bytes = resourceList([memory()]); + rejects('EXTENT_MISMATCH', () => decodeCmResourceListArm64(bytes.subarray(0, -1))); + rejects('EXTENT_MISMATCH', () => decodeCmResourceListArm64(Buffer.concat([bytes, Buffer.alloc(1)]))); + rejects('EXTENT_MISMATCH', () => decodeCmResourceListArm64(Buffer.concat([bytes.subarray(0, 20), Buffer.alloc(16)]))); + const tooMany = resourceList(); tooMany.writeUInt32LE(0xffffffff, 16); + rejects('RESOURCE_LIMIT', () => decodeCmResourceListArm64(tooMany)); +}); + +test('unknown, variable-length and MSI resource interpretations fail closed', () => { + for (const type of [0, 1, 5, 7, 0x80, 0xff]) { + rejects('UNSUPPORTED_RESOURCE_TYPE', () => decodeCmResourceListArm64(resourceList([descriptor(type)]))); + } + for (const flags of [2, 3, 0x20, 0x8000]) { + rejects('UNSUPPORTED_INTERRUPT_FLAGS', () => decodeCmResourceListArm64(resourceList([interrupt(flags)]))); + } + assert.equal(decodeCmResourceListArm64(resourceList([interrupt(0)])).resources[0].flags, 0); +}); + +test('typed UTF-16 REG extraction handles continuations and counts identical payloads once', () => { + const bytes = resourceList([memory(), interrupt()]); + const tokens = hex(bytes).split(','); + const input = registry(['[HKEY_LOCAL_MACHINE\\SYNTHETIC\\Device]', '"Ignored"=hex(2):not-read', + '"Literal"="text containing =hex(8):not a resource"', + `"BootConfig"=hex(8):${tokens.slice(0, 23).join(',')},\\`, + ` ${tokens.slice(23).join(',')}`, `"AllocConfig"=hex(8):${hex(bytes)}`, '; synthetic comment']); + const result = decodeRegistryResources(input); + assert.equal(result.sha256, hash(input)); + assert.deepEqual(result.stats, { values: 2, resources: 4, distinctResourcePayloads: 1 }); + assert.deepEqual(result.entries.map((entry) => entry.name), ['BootConfig', 'AllocConfig']); + assert.equal(result.entries[0].section, 'HKEY_LOCAL_MACHINE\\SYNTHETIC\\Device'); + assert.equal(result.entries[0].resourceList.sha256, hash(bytes)); + assert.equal(decodeRegistryResources(registry(['[Synthetic]', `"X"=HEX(8):${hex(bytes)}`], '\n')).entries.length, 1); +}); + +test('REG encoding is UTF-16LE only, fatal for invalid surrogates, NUL, odd extent and wrong header', () => { + const good = registry(['[Synthetic]', `"X"=hex(8):${hex(resourceList())}`]); + rejects('REG_ENCODING', () => decodeRegistryResources(good.subarray(2))); + rejects('REG_ENCODING', () => decodeRegistryResources(good.subarray(0, -1))); + rejects('REG_ENCODING', () => decodeRegistryResources(Buffer.from([0xfe, 0xff, 0, 0]))); + rejects('REG_ENCODING', () => decodeRegistryResources(Buffer.from([0xff, 0xfe, 0, 0xd8]))); + rejects('REG_ENCODING', () => decodeRegistryResources(registry(['\0']))); + rejects('REG_ENCODING', () => decodeRegistryResources(registry(['bare\rreturn']))); + const wrongHeader = Buffer.from(good); wrongHeader.writeUInt16LE(0x58, 2); + rejects('REG_HEADER', () => decodeRegistryResources(wrongHeader)); +}); + +test('malformed hex, continuation and duplicate effective registry values are rejected', () => { + for (const body of ['', '0', '000', '00,', ',00', '00,,00', '0 1', 'zz', '00,0g']) { + rejects('REG_HEX', () => decodeRegistryResources(registry(['[Synthetic]', `"X"=hex(8):${body}`]))); + } + rejects('REG_CONTINUATION', () => decodeRegistryResources(registry(['[Synthetic]', '"X"=hex(8):00,\\']))); + rejects('REG_CONTINUATION', () => decodeRegistryResources(registry(['[Synthetic]', '"X"=hex(8):00,\\', '[Other]']))); + rejects('REG_CONTINUATION', () => decodeRegistryResources(registry(['[Synthetic]', '"X"=hex(8):0\\', ' 1']))); + rejects('REG_HEX', () => decodeRegistryResources(registry(['[Synthetic]', '"X"=hex(8):00,\\', ' "NotHex"=1']))); + rejects('REG_VALUE_SYNTAX', () => decodeRegistryResources(registry([`"X"=hex(8):${hex(resourceList())}`]))); + rejects('REG_VALUE_SYNTAX', () => decodeRegistryResources(registry(['[Synthetic]', '"Escaped\\Name"=hex(8):00']))); + rejects('REG_SECTION', () => decodeRegistryResources(registry(['[-Synthetic]']))); + rejects('REG_SECTION', () => decodeRegistryResources(registry(['[Unclosed']))); + rejects('NO_RESOURCE_VALUES', () => decodeRegistryResources(registry(['[Synthetic]', '"X"="ignored"']))); + const payload = hex(resourceList()); + rejects('DUPLICATE_VALUE', () => decodeRegistryResources(registry(['[Synthetic]', `"BootConfig"=hex(8):${payload}`, '[sYNTHETIC]', `"bootconfig"=hex(8):${payload}`]))); + assert.equal(decodeRegistryResources(registry(['[A]', `"X"=hex(8):${payload}`, '[B]', `"X"=hex(8):${payload}`])).entries.length, 2); +}); + +test('ignored value types cannot silently override a resource in either order', () => { + const typed = `"X"=hex(8):${hex(resourceList([memory()]))}`; + for (const unrelated of ['"x"="string"', '"x"=hex(2):00,00', '"x"=-']) { + for (const assignments of [[typed, unrelated], [unrelated, typed]]) { + rejects('DUPLICATE_VALUE', () => decodeRegistryResources(registry(['[Synthetic]', ...assignments]))); + } + } + rejects('DUPLICATE_VALUE', () => decodeRegistryResources(registry(['[Synthetic]', '@="default"', `""=hex(8):${hex(resourceList())}`]))); + for (const escaped of ['"\\X"="ignored"', '"X\\\\"="ignored"', '"X\\"Y"="ignored"']) { + for (const assignments of [[typed, escaped], [escaped, typed]]) { + rejects('REG_VALUE_SYNTAX', () => decodeRegistryResources(registry(['[Synthetic]', ...assignments]))); + } + } +}); + +test('bounds include aggregate descriptors across repeated lists and overrides only lower limits', () => { + const bytes = resourceList([memory(), memory()]); + rejects('RESOURCE_LIMIT', () => decodeCmResourceListArm64(bytes, { maxResources: 1 })); + rejects('BYTE_LIMIT', () => decodeCmResourceListArm64(bytes, { maxBytes: bytes.length - 1 })); + const input = registry(['[Synthetic]', `"A"=hex(8):${hex(bytes)}`, `"B"=hex(8):${hex(bytes)}`]); + rejects('RESOURCE_LIMIT', () => decodeRegistryResources(input, { maxResources: 3 })); + rejects('VALUE_LIMIT', () => decodeRegistryResources(input, { maxValues: 1 })); + rejects('LINE_LIMIT', () => decodeRegistryResources(input, { maxLines: 2 })); + rejects('BYTE_LIMIT', () => decodeRegistryResources(input, { maxBytes: input.length - 1 })); + for (const [name, maximum] of Object.entries(WINDOWS_RESOURCE_LIMITS)) { + for (const value of [0, -1, 1.1, maximum + 1, NaN]) { + assert.throws(() => decodeCmResourceListArm64(bytes, { [name]: value }), RangeError); + } + } + assert.throws(() => decodeRegistryResources(input, { unknown: 1 }), TypeError); + assert.throws(() => decodeCmResourceListArm64('not bytes'), TypeError); +}); + +test('CLI explicitly selects its layout, reads only, and reports machine-readable failures', async () => { + const dir = await mkdtemp(join(tmpdir(), 'windows-resources-test-')); + const cli = fileURLToPath(new URL('./windows-resources.mjs', import.meta.url)); + try { + const file = join(dir, 'synthetic.reg'); + await writeFile(file, registry(['[Synthetic]', `"X"=hex(8):${hex(resourceList([connection()]))}`])); + const result = spawnSync(process.execPath, [cli, '--format', 'registry-utf16-arm64', '--input', file], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).entries[0].resourceList.resources[0].kind, 'connection'); + const missingFormat = spawnSync(process.execPath, [cli, '--input', file], { encoding: 'utf8' }); + assert.notEqual(missingFormat.status, 0); + assert.equal(JSON.parse(missingFormat.stderr).error, 'TypeError'); + const binary = join(dir, 'synthetic.bin'); + await writeFile(binary, resourceList([memory()])); + const direct = spawnSync(process.execPath, [cli, '--format', 'cm-arm64', '--input', binary], { encoding: 'utf8' }); + assert.equal(direct.status, 0, direct.stderr); + assert.equal(JSON.parse(direct.stdout).resources[0].kind, 'memory'); + await writeFile(binary, Buffer.alloc(2)); + const invalid = spawnSync(process.execPath, [cli, '--format', 'cm-arm64', '--input', binary], { encoding: 'utf8' }); + assert.notEqual(invalid.status, 0); + assert.equal(JSON.parse(invalid.stderr).error, 'SHORT_HEADER'); + const directory = spawnSync(process.execPath, [cli, '--format', 'cm-arm64', '--input', dir], { encoding: 'utf8' }); + assert.notEqual(directory.status, 0); + assert.equal(JSON.parse(directory.stderr).error, 'TypeError'); + } finally { await rm(dir, { recursive: true }); } +});