From 064dffe5008a5c5614efa59bc2da5e3b1ad7ea32 Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sun, 8 Nov 2026 14:55:10 -0800 Subject: [PATCH] fix(cli): read files saved by Windows PowerShell's redirect `trustshell sign-receipt body.json > r.json`, the command demo prints, wrote UTF-16 in Windows PowerShell 5.1, and verify-receipt then called the file "not valid JSON". Found by running the same commands in each shell from a clean install. Every file a person hands the CLI now decodes the same way, including claims files. Co-Authored-By: Claude Opus 5.5 --- dist/cli/index.js | 17 ++++++++--------- dist/lib/decode-text.d.ts | 6 ++++++ dist/lib/decode-text.js | 18 ++++++++++++++++++ src/cli/index.ts | 17 ++++++++--------- src/lib/decode-text.ts | 12 ++++++++++++ tests/decode-text.test.ts | 18 ++++++++++++++++++ 6 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 dist/lib/decode-text.d.ts create mode 100644 dist/lib/decode-text.js create mode 100644 src/lib/decode-text.ts create mode 100644 tests/decode-text.test.ts diff --git a/dist/cli/index.js b/dist/cli/index.js index c7551c61..c2598273 100644 --- a/dist/cli/index.js +++ b/dist/cli/index.js @@ -90,6 +90,7 @@ const run_1 = require("../sandbox/run"); const guardrails_1 = require("../lib/guardrails"); const api_url_1 = require("../lib/api-url"); const install_key_1 = require("../lib/install-key"); +const decode_text_1 = require("../lib/decode-text"); const owner_receipt_1 = require("../lib/owner-receipt"); /** Exit codes — a small, stable contract so CI scripts can branch on them. */ exports.EXIT = { @@ -1373,7 +1374,7 @@ async function run(args, client, io = realIO) { const file = args.operand; let raw; try { - raw = (0, node_fs_1.readFileSync)(file, 'utf8'); + raw = (0, decode_text_1.decodeText)((0, node_fs_1.readFileSync)(file)); } catch (e) { io.err(`verify-receipt: cannot read ${file}: ${e.message}`); @@ -1399,7 +1400,7 @@ async function run(args, client, io = realIO) { let envelope = obj && typeof obj === 'object' ? obj['six_claims'] : undefined; if (args.envelope) { try { - envelope = JSON.parse((0, node_fs_1.readFileSync)(args.envelope, 'utf8')); + envelope = JSON.parse((0, decode_text_1.decodeText)((0, node_fs_1.readFileSync)(args.envelope))); } catch (e) { io.err(`verify-receipt: cannot read --envelope ${args.envelope}: ${e.message}`); @@ -1426,7 +1427,7 @@ async function run(args, client, io = realIO) { const file = args.operand; let raw; try { - raw = (0, node_fs_1.readFileSync)(file === '-' ? 0 : file, 'utf8'); // fd 0 = stdin + raw = (0, decode_text_1.decodeText)((0, node_fs_1.readFileSync)(file === '-' ? 0 : file)); // fd 0 = stdin } catch (e) { io.err(`sign-receipt: cannot read ${file === '-' ? 'stdin' : file}: ${e.message}`); @@ -1683,7 +1684,7 @@ async function run(args, client, io = realIO) { return exports.EXIT.USAGE; } try { - evidence = JSON.parse(fs.readFileSync(args.evidence, 'utf8')); + evidence = JSON.parse((0, decode_text_1.decodeText)(fs.readFileSync(args.evidence))); } catch { io.err(`report: evidence file is not valid JSON: ${args.evidence}`); @@ -1731,11 +1732,9 @@ function readClaimsFile(path, fs = require('node:fs')) { throw new Error(`--claims-file: no such file: ${path}`); if (st.size > action_evidence_1.MAX_CLAIMS_FILE_BYTES) throw new Error(`--claims-file is ${st.size} bytes; the limit is ${action_evidence_1.MAX_CLAIMS_FILE_BYTES}`); - const text = String(fs.readFileSync(path, 'utf8')); - // One leading byte-order mark is a file-encoding marker, not text: Windows PowerShell 5.1 writes it - // for `Out-File` and `Set-Content -Encoding utf8`, and kept it would make line 1 non-ASCII and - // block every verdict (XC1 re-check 5). Only that one character is dropped; any other stays. - return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + // Windows PowerShell 5.1 writes a byte-order mark for `Out-File` and `Set-Content -Encoding utf8`, and UTF-16 for + // `>`. A mark kept in the text would make line 1 non-ASCII and block every verdict (XC1 re-check 5). + return (0, decode_text_1.decodeText)(fs.readFileSync(path)); } /** * `trustshell check --claims ""` (or `--claims-file `). GitHub only, exactly like the URL form: the diff --git a/dist/lib/decode-text.d.ts b/dist/lib/decode-text.d.ts new file mode 100644 index 00000000..6d37845d --- /dev/null +++ b/dist/lib/decode-text.d.ts @@ -0,0 +1,6 @@ +/** + * Text a person saved on any platform. Windows PowerShell 5.1's `>` writes UTF-16 with a byte-order mark, so + * `trustshell sign-receipt body.json > r.json`, the documented command, produced a file `verify-receipt` called + * "not valid JSON". A byte-order mark says how the bytes are encoded and is never part of the text. + */ +export declare function decodeText(raw: Buffer | string): string; diff --git a/dist/lib/decode-text.js b/dist/lib/decode-text.js new file mode 100644 index 00000000..64a73254 --- /dev/null +++ b/dist/lib/decode-text.js @@ -0,0 +1,18 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.decodeText = decodeText; +/** + * Text a person saved on any platform. Windows PowerShell 5.1's `>` writes UTF-16 with a byte-order mark, so + * `trustshell sign-receipt body.json > r.json`, the documented command, produced a file `verify-receipt` called + * "not valid JSON". A byte-order mark says how the bytes are encoded and is never part of the text. + */ +function decodeText(raw) { + if (typeof raw === 'string') + return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw; + if (raw[0] === 0xff && raw[1] === 0xfe) + return raw.subarray(2).toString('utf16le'); + if (raw[0] === 0xfe && raw[1] === 0xff) + return Buffer.from(raw.subarray(2)).swap16().toString('utf16le'); + const text = raw.toString('utf8'); + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index afc46654..7560d94d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -78,6 +78,7 @@ import { sandboxCli } from '../sandbox/run'; import { GUARD_RECORD_SCHEMA, trustshellHome } from '../lib/guardrails'; import { ApiUrlError } from '../lib/api-url'; import { signingKey } from '../lib/install-key'; +import { decodeText } from '../lib/decode-text'; import { signOwnerReceipt, installKeyRootHex, type SealableReceipt } from '../lib/owner-receipt'; /** Exit codes — a small, stable contract so CI scripts can branch on them. */ @@ -1454,7 +1455,7 @@ export async function run( const file = args.operand as string; let raw: string; try { - raw = readTextFile(file, 'utf8'); + raw = decodeText(readTextFile(file)); } catch (e) { io.err(`verify-receipt: cannot read ${file}: ${(e as Error).message}`); return EXIT.USAGE; @@ -1477,7 +1478,7 @@ export async function run( let envelope: unknown = obj && typeof obj === 'object' ? obj['six_claims'] : undefined; if (args.envelope) { try { - envelope = JSON.parse(readTextFile(args.envelope, 'utf8')); + envelope = JSON.parse(decodeText(readTextFile(args.envelope))); } catch (e) { io.err(`verify-receipt: cannot read --envelope ${args.envelope}: ${(e as Error).message}`); return EXIT.USAGE; @@ -1502,7 +1503,7 @@ export async function run( const file = args.operand as string; let raw: string; try { - raw = readTextFile(file === '-' ? 0 : file, 'utf8'); // fd 0 = stdin + raw = decodeText(readTextFile(file === '-' ? 0 : file)); // fd 0 = stdin } catch (e) { io.err(`sign-receipt: cannot read ${file === '-' ? 'stdin' : file}: ${(e as Error).message}`); return EXIT.USAGE; @@ -1746,7 +1747,7 @@ export async function run( return EXIT.USAGE; } try { - evidence = JSON.parse(fs.readFileSync(args.evidence, 'utf8')) as EvidenceDoc; + evidence = JSON.parse(decodeText(fs.readFileSync(args.evidence))) as EvidenceDoc; } catch { io.err(`report: evidence file is not valid JSON: ${args.evidence}`); return EXIT.USAGE; @@ -1796,11 +1797,9 @@ export function readClaimsFile( } if (!st?.isFile()) throw new Error(`--claims-file: no such file: ${path}`); if (st.size > MAX_CLAIMS_FILE_BYTES) throw new Error(`--claims-file is ${st.size} bytes; the limit is ${MAX_CLAIMS_FILE_BYTES}`); - const text = String(fs.readFileSync(path, 'utf8')); - // One leading byte-order mark is a file-encoding marker, not text: Windows PowerShell 5.1 writes it - // for `Out-File` and `Set-Content -Encoding utf8`, and kept it would make line 1 non-ASCII and - // block every verdict (XC1 re-check 5). Only that one character is dropped; any other stays. - return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + // Windows PowerShell 5.1 writes a byte-order mark for `Out-File` and `Set-Content -Encoding utf8`, and UTF-16 for + // `>`. A mark kept in the text would make line 1 non-ASCII and block every verdict (XC1 re-check 5). + return decodeText(fs.readFileSync(path)); } /** diff --git a/src/lib/decode-text.ts b/src/lib/decode-text.ts new file mode 100644 index 00000000..f1a9b096 --- /dev/null +++ b/src/lib/decode-text.ts @@ -0,0 +1,12 @@ +/** + * Text a person saved on any platform. Windows PowerShell 5.1's `>` writes UTF-16 with a byte-order mark, so + * `trustshell sign-receipt body.json > r.json`, the documented command, produced a file `verify-receipt` called + * "not valid JSON". A byte-order mark says how the bytes are encoded and is never part of the text. + */ +export function decodeText(raw: Buffer | string): string { + if (typeof raw === 'string') return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw; + if (raw[0] === 0xff && raw[1] === 0xfe) return raw.subarray(2).toString('utf16le'); + if (raw[0] === 0xfe && raw[1] === 0xff) return Buffer.from(raw.subarray(2)).swap16().toString('utf16le'); + const text = raw.toString('utf8'); + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} diff --git a/tests/decode-text.test.ts b/tests/decode-text.test.ts new file mode 100644 index 00000000..812ff685 --- /dev/null +++ b/tests/decode-text.test.ts @@ -0,0 +1,18 @@ +import { decodeText } from '../src/lib/decode-text'; + +describe('decodeText', () => { + const text = '{"a":"é"}\n'; + it('reads what Windows PowerShell 5.1 > writes', () => { + expect(decodeText(Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text, 'utf16le')]))).toBe(text); + }); + it('reads UTF-16 big-endian', () => { + expect(decodeText(Buffer.concat([Buffer.from([0xfe, 0xff]), Buffer.from(text, 'utf16le').swap16()]))).toBe(text); + }); + it('drops one UTF-8 byte-order mark and keeps the rest', () => { + expect(decodeText(Buffer.from(`${text}`, 'utf8'))).toBe(text); + expect(decodeText(`${text}`)).toBe(`${text}`); + }); + it('leaves plain UTF-8 alone', () => { + expect(decodeText(Buffer.from(text, 'utf8'))).toBe(text); + }); +});