@ctrl-spc/cs 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { isAbsolute } from 'node:path';
3
+ import { inflateSync } from 'node:zlib';
4
+ export const MAX_SCREENSHOT_BYTES = 20 * 1024 * 1024;
5
+ const MAX_DECODED_SCREENSHOT_BYTES = 256 * 1024 * 1024;
6
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
7
+ const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
8
+ let crc = value;
9
+ for (let bit = 0; bit < 8; bit += 1) {
10
+ crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
11
+ }
12
+ return crc >>> 0;
13
+ });
14
+ function crc32(bytes) {
15
+ let crc = 0xffffffff;
16
+ for (const byte of bytes) {
17
+ crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
18
+ }
19
+ return (crc ^ 0xffffffff) >>> 0;
20
+ }
21
+ /**
22
+ * Read and validate the local screenshot before any hosted request is made.
23
+ *
24
+ * This validates the PNG framing and decoded scanline envelope without
25
+ * rewriting the user's original bytes. Interlaced images are rejected because
26
+ * browser and mobile screenshot tools produce standard non-interlaced PNGs,
27
+ * and validating Adam7 safely would add complexity without helping Phase 1.
28
+ */
29
+ export async function readPngScreenshot(localPath) {
30
+ if (!localPath || !isAbsolute(localPath)) {
31
+ throw new Error('path must be an absolute local path');
32
+ }
33
+ let stat;
34
+ try {
35
+ stat = await lstat(localPath);
36
+ }
37
+ catch (err) {
38
+ const code = err.code;
39
+ if (code === 'ENOENT')
40
+ throw new Error(`the PNG was not found at ${localPath}`);
41
+ throw new Error(`the PNG could not be inspected at ${localPath}: ${err.message}`);
42
+ }
43
+ if (!stat.isFile()) {
44
+ throw new Error(`the path is not a regular file: ${localPath}`);
45
+ }
46
+ if (stat.size === 0) {
47
+ throw new Error('the PNG is empty');
48
+ }
49
+ if (stat.size > MAX_SCREENSHOT_BYTES) {
50
+ throw new Error(`the PNG is larger than the ${MAX_SCREENSHOT_BYTES / (1024 * 1024)} MB limit`);
51
+ }
52
+ let bytes;
53
+ try {
54
+ bytes = await readFile(localPath);
55
+ }
56
+ catch (err) {
57
+ throw new Error(`the PNG could not be read at ${localPath}: ${err.message}`);
58
+ }
59
+ if (bytes.length !== stat.size) {
60
+ throw new Error('the PNG changed while it was being read; capture it again and retry');
61
+ }
62
+ if (bytes.length < PNG_SIGNATURE.length || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
63
+ throw new Error('the file is not a readable PNG (invalid PNG signature)');
64
+ }
65
+ let offset = 8;
66
+ let width = 0;
67
+ let height = 0;
68
+ let sawHeader = false;
69
+ let sawImageData = false;
70
+ let sawEnd = false;
71
+ let sawPalette = false;
72
+ let endedImageData = false;
73
+ let bitDepth = 0;
74
+ let colorType = 0;
75
+ const imageDataChunks = [];
76
+ while (offset < bytes.length) {
77
+ if (bytes.length - offset < 12) {
78
+ throw new Error('the file is not a readable PNG (truncated chunk header)');
79
+ }
80
+ const length = bytes.readUInt32BE(offset);
81
+ const chunkEnd = offset + 12 + length;
82
+ if (chunkEnd > bytes.length || chunkEnd < offset) {
83
+ throw new Error('the file is not a readable PNG (truncated chunk data)');
84
+ }
85
+ const typeBytes = bytes.subarray(offset + 4, offset + 8);
86
+ const type = typeBytes.toString('ascii');
87
+ if (!/^[A-Za-z]{4}$/.test(type)) {
88
+ throw new Error('the file is not a readable PNG (invalid chunk type)');
89
+ }
90
+ const expectedCrc = bytes.readUInt32BE(offset + 8 + length);
91
+ const actualCrc = crc32(bytes.subarray(offset + 4, offset + 8 + length));
92
+ if (actualCrc !== expectedCrc) {
93
+ throw new Error(`the file is not a readable PNG (${type} chunk CRC mismatch)`);
94
+ }
95
+ if (!sawHeader) {
96
+ if (type !== 'IHDR' || length !== 13) {
97
+ throw new Error('the file is not a readable PNG (missing the required IHDR header)');
98
+ }
99
+ width = bytes.readUInt32BE(offset + 8);
100
+ height = bytes.readUInt32BE(offset + 12);
101
+ bitDepth = bytes[offset + 16];
102
+ colorType = bytes[offset + 17];
103
+ const compression = bytes[offset + 18];
104
+ const filter = bytes[offset + 19];
105
+ const interlace = bytes[offset + 20];
106
+ const legalDepths = {
107
+ 0: [1, 2, 4, 8, 16],
108
+ 2: [8, 16],
109
+ 3: [1, 2, 4, 8],
110
+ 4: [8, 16],
111
+ 6: [8, 16],
112
+ };
113
+ if (!legalDepths[colorType]?.includes(bitDepth)) {
114
+ throw new Error('the file is not a readable PNG (unsupported IHDR color type or bit depth)');
115
+ }
116
+ if (compression !== 0 || filter !== 0) {
117
+ throw new Error('the file is not a readable PNG (unsupported compression or filter method)');
118
+ }
119
+ if (interlace !== 0) {
120
+ throw new Error('the file is not a readable PNG (interlaced PNGs are not supported)');
121
+ }
122
+ sawHeader = true;
123
+ }
124
+ else if (type === 'IHDR') {
125
+ throw new Error('the file is not a readable PNG (duplicate IHDR header)');
126
+ }
127
+ if (type === 'PLTE') {
128
+ if (sawImageData || length === 0 || length % 3 !== 0 || length > 768) {
129
+ throw new Error('the file is not a readable PNG (invalid PLTE chunk)');
130
+ }
131
+ sawPalette = true;
132
+ }
133
+ if (type === 'IDAT') {
134
+ if (endedImageData) {
135
+ throw new Error('the file is not a readable PNG (IDAT chunks must be consecutive)');
136
+ }
137
+ sawImageData = true;
138
+ imageDataChunks.push(bytes.subarray(offset + 8, offset + 8 + length));
139
+ }
140
+ else if (type === 'IEND') {
141
+ if (length !== 0) {
142
+ throw new Error('the file is not a readable PNG (invalid IEND chunk)');
143
+ }
144
+ sawEnd = true;
145
+ offset = chunkEnd;
146
+ break;
147
+ }
148
+ else if (sawImageData) {
149
+ endedImageData = true;
150
+ }
151
+ if (/^[A-Z]/.test(type) && !['IHDR', 'PLTE', 'IDAT', 'IEND'].includes(type)) {
152
+ throw new Error(`the file is not a readable PNG (unsupported critical chunk ${type})`);
153
+ }
154
+ offset = chunkEnd;
155
+ }
156
+ if (!sawImageData) {
157
+ throw new Error('the file is not a readable PNG (missing image data)');
158
+ }
159
+ if (!sawEnd) {
160
+ throw new Error('the file is not a readable PNG (missing IEND chunk)');
161
+ }
162
+ if (offset !== bytes.length) {
163
+ throw new Error('the file is not a readable PNG (trailing data after IEND)');
164
+ }
165
+ if (width < 1 || height < 1) {
166
+ throw new Error('the file is not a readable PNG (width and height must be positive)');
167
+ }
168
+ if (colorType === 3 && !sawPalette) {
169
+ throw new Error('the file is not a readable PNG (indexed color requires a PLTE chunk)');
170
+ }
171
+ const channels = colorType === 0 || colorType === 3 ? 1 : colorType === 2 ? 3 : colorType === 4 ? 2 : 4;
172
+ const rowBytes = Math.ceil((width * channels * bitDepth) / 8);
173
+ const scanlineBytes = rowBytes + 1;
174
+ const expectedDecodedBytes = scanlineBytes * height;
175
+ if (!Number.isSafeInteger(expectedDecodedBytes) || expectedDecodedBytes > MAX_DECODED_SCREENSHOT_BYTES) {
176
+ throw new Error('the PNG expands beyond the 256 MB decoded screenshot limit');
177
+ }
178
+ let decoded;
179
+ try {
180
+ decoded = inflateSync(Buffer.concat(imageDataChunks), {
181
+ maxOutputLength: expectedDecodedBytes + 1,
182
+ });
183
+ }
184
+ catch (err) {
185
+ throw new Error(`the file is not a readable PNG (invalid compressed image data: ${err.message})`);
186
+ }
187
+ if (decoded.length !== expectedDecodedBytes) {
188
+ throw new Error(`the file is not a readable PNG (decoded scanline size is ${decoded.length}, expected ${expectedDecodedBytes})`);
189
+ }
190
+ for (let row = 0; row < height; row += 1) {
191
+ if (decoded[row * scanlineBytes] > 4) {
192
+ throw new Error(`the file is not a readable PNG (invalid filter byte on scanline ${row + 1})`);
193
+ }
194
+ }
195
+ return { bytes, width, height, sizeBytes: bytes.length };
196
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
5
5
  "engines": {
6
6
  "node": ">=22"