@chestnutlabs/gcode-containers 0.1.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.
- package/README.md +16 -0
- package/dist/gcode-3mf.d.ts +45 -0
- package/dist/gcode-3mf.d.ts.map +1 -0
- package/dist/gcode-3mf.js +169 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/zip.d.ts +70 -0
- package/dist/zip.d.ts.map +1 -0
- package/dist/zip.js +283 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# @chestnutlabs/gcode-containers
|
|
2
|
+
|
|
3
|
+
Safe, bounded, **zero-dependency container extraction** for sliced G-code archives —
|
|
4
|
+
`.gcode.3mf` (sliced-plate export) and plain ZIP — for the Chestnut Labs G-code Preview stack
|
|
5
|
+
(design: DD-005, security review DD-005 §7.3).
|
|
6
|
+
|
|
7
|
+
- In-memory ZIP reader with strict resource limits (entry counts, sizes, name policies)
|
|
8
|
+
- `.gcode.3mf` plate discovery and selection (multi-plate via the wire `plate` option)
|
|
9
|
+
- Hardened against adversarial archives — a manifest-tracked adversarial corpus rides in CI, and
|
|
10
|
+
coverage-guided fuzzing over `readDirectory`/`streamEntry` is part of the release gate
|
|
11
|
+
|
|
12
|
+
This package is bundled into the parser's default *batteries* worker — most applications never
|
|
13
|
+
import it directly. Import it to build a custom worker or to consume containers outside the
|
|
14
|
+
parse pipeline.
|
|
15
|
+
|
|
16
|
+
Part of [Chestnut Labs G-code Preview](../../README.md) · MIT
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.gcode.3mf` container adapter (DD-005 §4.4, as amended) — the Bambu
|
|
3
|
+
* Studio / OrcaSlicer sliced-project ZIP: per-plate G-code payloads under
|
|
4
|
+
* `Metadata/plate_*.gcode` plus machine/filament configuration.
|
|
5
|
+
*
|
|
6
|
+
* Lifecycle (amendment 3): `open()` is DISCOVERY ONLY (central directory +
|
|
7
|
+
* bounded metadata — no payload inflate); the consumer selects a plate;
|
|
8
|
+
* `openPlate(i)` streams exactly one payload with incremental caps and
|
|
9
|
+
* end-of-stream CRC verification.
|
|
10
|
+
*/
|
|
11
|
+
import type { MachineGeometry, FilamentInfo } from '@chestnutlabs/toolpath-core';
|
|
12
|
+
import { type ContainerLimits } from './zip.js';
|
|
13
|
+
export interface PlateEntry {
|
|
14
|
+
index: number;
|
|
15
|
+
name: string;
|
|
16
|
+
estimatedBytes: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ContainerMetadata {
|
|
19
|
+
machine?: MachineGeometry;
|
|
20
|
+
filaments?: FilamentInfo[];
|
|
21
|
+
/** Whitelisted key/value settings (bounded) — also feeds dialect detection. */
|
|
22
|
+
raw: Record<string, string>;
|
|
23
|
+
warnings: {
|
|
24
|
+
code: string;
|
|
25
|
+
message: string;
|
|
26
|
+
}[];
|
|
27
|
+
}
|
|
28
|
+
export interface StreamLike {
|
|
29
|
+
getReader(): {
|
|
30
|
+
read(): Promise<{
|
|
31
|
+
done: boolean;
|
|
32
|
+
value?: Uint8Array;
|
|
33
|
+
}>;
|
|
34
|
+
releaseLock?(): void;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export interface OpenedContainer {
|
|
38
|
+
id: 'gcode-3mf';
|
|
39
|
+
plates: PlateEntry[];
|
|
40
|
+
metadata: ContainerMetadata;
|
|
41
|
+
openPlate(index: number): StreamLike;
|
|
42
|
+
}
|
|
43
|
+
export declare function sniffGcode3mf(prefix: Uint8Array, name?: string): boolean;
|
|
44
|
+
export declare function openGcode3mf(bytes: Uint8Array, limits?: ContainerLimits): Promise<OpenedContainer>;
|
|
45
|
+
//# sourceMappingURL=gcode-3mf.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gcode-3mf.d.ts","sourceRoot":"","sources":["../src/gcode-3mf.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAU,MAAM,6BAA6B,CAAC;AACzF,OAAO,EASL,KAAK,eAAe,EAErB,MAAM,UAAU,CAAC;AAElB,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B,SAAS,CAAC,EAAE,YAAY,EAAE,CAAC;IAC3B,+EAA+E;IAC/E,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC/C;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,IAAI;QAAE,IAAI,IAAI,OAAO,CAAC;YAAE,IAAI,EAAE,OAAO,CAAC;YAAC,KAAK,CAAC,EAAE,UAAU,CAAA;SAAE,CAAC,CAAC;QAAC,WAAW,CAAC,IAAI,IAAI,CAAA;KAAE,CAAC;CAC/F;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,WAAW,CAAC;IAChB,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAAC;CACtC;AAgBD,wBAAgB,aAAa,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAKxE;AAqDD,wBAAsB,YAAY,CAChC,KAAK,EAAE,UAAU,EACjB,MAAM,GAAE,eAA0C,GACjD,OAAO,CAAC,eAAe,CAAC,CA2G1B"}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { ContainerError, DEFAULT_CONTAINER_LIMITS, canonicalName, crc32, crc32Final, extractEntry, readDirectory, streamEntry } from './zip.js';
|
|
2
|
+
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04];
|
|
3
|
+
const PLATE_RE = /^metadata\/plate_(\d+)\.gcode$/;
|
|
4
|
+
/** Settings keys copied into metadata.raw — a whitelist, never the whole config. */
|
|
5
|
+
const RAW_WHITELIST = [
|
|
6
|
+
'printer_model',
|
|
7
|
+
'printer_settings_id',
|
|
8
|
+
'print_settings_id',
|
|
9
|
+
'printable_height',
|
|
10
|
+
'nozzle_diameter',
|
|
11
|
+
'filament_type',
|
|
12
|
+
'filament_colour',
|
|
13
|
+
'version'
|
|
14
|
+
];
|
|
15
|
+
export function sniffGcode3mf(prefix, name) {
|
|
16
|
+
const magic = prefix.length >= 4 && ZIP_MAGIC.every((b, i) => prefix[i] === b);
|
|
17
|
+
if (!magic)
|
|
18
|
+
return false;
|
|
19
|
+
// Extension hint refines but never overrides the magic (a .zip named .gcode is still not a container).
|
|
20
|
+
return name === undefined || /\.(gcode\.)?3mf$/i.test(name) || true;
|
|
21
|
+
}
|
|
22
|
+
/** Bambu/Orca `printable_area`: ["0x0","256x0","256x256","0x256"] or "0x0,256x0,..." */
|
|
23
|
+
function parsePrintableArea(value) {
|
|
24
|
+
const parts = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : null;
|
|
25
|
+
if (parts === null || parts.length < 3 || parts.length > 64)
|
|
26
|
+
return null;
|
|
27
|
+
const points = [];
|
|
28
|
+
for (const p of parts) {
|
|
29
|
+
if (typeof p !== 'string')
|
|
30
|
+
return null;
|
|
31
|
+
const m = /^\s*(-?\d+(?:\.\d+)?)x(-?\d+(?:\.\d+)?)\s*$/.exec(p);
|
|
32
|
+
if (m === null)
|
|
33
|
+
return null;
|
|
34
|
+
points.push({ x: parseFloat(m[1]), y: parseFloat(m[2]) });
|
|
35
|
+
}
|
|
36
|
+
return points;
|
|
37
|
+
}
|
|
38
|
+
function machineFromSettings(settings) {
|
|
39
|
+
const points = parsePrintableArea(settings['printable_area']);
|
|
40
|
+
if (points === null)
|
|
41
|
+
return undefined;
|
|
42
|
+
const xs = points.map((p) => p.x);
|
|
43
|
+
const ys = points.map((p) => p.y);
|
|
44
|
+
const min = { x: Math.min(...xs), y: Math.min(...ys) };
|
|
45
|
+
const max = { x: Math.max(...xs), y: Math.max(...ys) };
|
|
46
|
+
const axisAlignedRect = points.length === 4 && points.every((p) => (p.x === min.x || p.x === max.x) && (p.y === min.y || p.y === max.y));
|
|
47
|
+
const height = Number(settings['printable_height']);
|
|
48
|
+
const printerName = typeof settings['printer_model'] === 'string'
|
|
49
|
+
? settings['printer_model']
|
|
50
|
+
: typeof settings['printer_settings_id'] === 'string'
|
|
51
|
+
? settings['printer_settings_id']
|
|
52
|
+
: undefined;
|
|
53
|
+
return {
|
|
54
|
+
bed: axisAlignedRect ? { kind: 'rect', min, max } : { kind: 'polygon', points },
|
|
55
|
+
origin: { x: 0, y: 0 },
|
|
56
|
+
heightMm: Number.isFinite(height) ? height : undefined,
|
|
57
|
+
printerName,
|
|
58
|
+
confidence: 'known', // read from the project's own machine config
|
|
59
|
+
source: { adapterId: 'gcode-3mf', evidence: 'Metadata/project_settings.config printable_area' }
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function filamentsFromSettings(settings) {
|
|
63
|
+
const types = settings['filament_type'];
|
|
64
|
+
if (!Array.isArray(types))
|
|
65
|
+
return undefined;
|
|
66
|
+
const colours = Array.isArray(settings['filament_colour']) ? settings['filament_colour'] : [];
|
|
67
|
+
return types.slice(0, 64).map((t, i) => ({
|
|
68
|
+
slot: i,
|
|
69
|
+
type: typeof t === 'string' ? t : undefined,
|
|
70
|
+
color: typeof colours[i] === 'string' ? colours[i] : undefined
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
export async function openGcode3mf(bytes, limits = DEFAULT_CONTAINER_LIMITS) {
|
|
74
|
+
const dir = readDirectory(bytes, limits);
|
|
75
|
+
const warnings = [...dir.warnings];
|
|
76
|
+
const plates = [];
|
|
77
|
+
for (const entry of dir.entries) {
|
|
78
|
+
const m = PLATE_RE.exec(entry.name);
|
|
79
|
+
if (m !== null) {
|
|
80
|
+
plates.push({ index: plates.length, name: entry.rawName, estimatedBytes: entry.uncompressedSize, entry });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
plates.sort((a, b) => parseInt(PLATE_RE.exec(a.name.toLowerCase())?.[1] ?? '0') -
|
|
84
|
+
parseInt(PLATE_RE.exec(b.name.toLowerCase())?.[1] ?? '0'));
|
|
85
|
+
plates.forEach((p, i) => (p.index = i));
|
|
86
|
+
if (plates.length === 0) {
|
|
87
|
+
// Distinguish the common user mistake: a MODEL/PROJECT 3MF (meshes, no
|
|
88
|
+
// sliced G-code) vs a genuinely empty/unrecognized archive. Slicing is a
|
|
89
|
+
// slicer's job — tell the user what to export instead.
|
|
90
|
+
const isModelProject = dir.entries.some((e) => e.name === '3d/3dmodel.model');
|
|
91
|
+
throw new ContainerError('E_CONTAINER_NO_PAYLOAD', isModelProject
|
|
92
|
+
? 'this is a model/project 3MF (3D meshes, no sliced G-code). The viewer displays sliced output — ' +
|
|
93
|
+
'in Bambu Studio/OrcaSlicer, slice the plate and use "Export plate sliced file" (.gcode.3mf)'
|
|
94
|
+
: 'no Metadata/plate_*.gcode entries found');
|
|
95
|
+
}
|
|
96
|
+
// Amendment 3: a duplicate of a SELECTED PAYLOAD name is an error, not a warning.
|
|
97
|
+
const payloadDup = warnings.find((w) => w.code === 'container-duplicate-entry' && plates.some((p) => w.message.includes(`'${canonicalName(p.name)}'`)));
|
|
98
|
+
if (payloadDup !== undefined) {
|
|
99
|
+
throw new ContainerError('E_CONTAINER_DUPLICATE', payloadDup.message);
|
|
100
|
+
}
|
|
101
|
+
// Bounded metadata extraction (discovery-time; small entries only).
|
|
102
|
+
const raw = {};
|
|
103
|
+
let machine;
|
|
104
|
+
let filaments;
|
|
105
|
+
const settingsEntry = dir.entries.find((e) => e.name === 'metadata/project_settings.config');
|
|
106
|
+
if (settingsEntry !== undefined) {
|
|
107
|
+
if (settingsEntry.uncompressedSize > limits.maxMetadataBytes) {
|
|
108
|
+
warnings.push({
|
|
109
|
+
code: 'metadata-truncated',
|
|
110
|
+
message: 'project_settings.config exceeds maxMetadataBytes; skipped'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
try {
|
|
115
|
+
const text = new TextDecoder().decode(await extractEntry(bytes, settingsEntry, limits.maxMetadataBytes));
|
|
116
|
+
const settings = JSON.parse(text);
|
|
117
|
+
machine = machineFromSettings(settings);
|
|
118
|
+
filaments = filamentsFromSettings(settings);
|
|
119
|
+
for (const key of RAW_WHITELIST) {
|
|
120
|
+
const v = settings[key];
|
|
121
|
+
if (typeof v === 'string' || typeof v === 'number')
|
|
122
|
+
raw[key] = String(v).slice(0, 4096);
|
|
123
|
+
else if (Array.isArray(v))
|
|
124
|
+
raw[key] = v.slice(0, 64).join(',').slice(0, 4096);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
warnings.push({
|
|
129
|
+
code: 'container-metadata-invalid',
|
|
130
|
+
message: `project_settings.config unreadable: ${err instanceof Error ? err.message : err}`
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
id: 'gcode-3mf',
|
|
137
|
+
plates: plates.map(({ index, name, estimatedBytes }) => ({ index, name, estimatedBytes })),
|
|
138
|
+
metadata: { machine, filaments, raw, warnings },
|
|
139
|
+
openPlate(index) {
|
|
140
|
+
const plate = plates[index];
|
|
141
|
+
if (plate === undefined) {
|
|
142
|
+
throw new ContainerError('E_CONTAINER_NO_PAYLOAD', `plate ${index} does not exist (${plates.length} plates)`);
|
|
143
|
+
}
|
|
144
|
+
// Stream with incremental caps; verify CRC + size at end-of-stream.
|
|
145
|
+
const gen = streamEntry(bytes, plate.entry, limits.maxExpandedBytesPerEntry);
|
|
146
|
+
let crc = 0xffffffff;
|
|
147
|
+
let total = 0;
|
|
148
|
+
return {
|
|
149
|
+
getReader: () => ({
|
|
150
|
+
read: async () => {
|
|
151
|
+
const { done, value } = await gen.next();
|
|
152
|
+
if (done) {
|
|
153
|
+
if (crc32Final(crc) !== plate.entry.crc32) {
|
|
154
|
+
throw new ContainerError('E_CONTAINER_CRC', `CRC mismatch for '${plate.entry.name}'`);
|
|
155
|
+
}
|
|
156
|
+
if (total !== plate.entry.uncompressedSize) {
|
|
157
|
+
throw new ContainerError('E_CONTAINER_HEADER_MISMATCH', `expanded size of '${plate.entry.name}' differs from headers`);
|
|
158
|
+
}
|
|
159
|
+
return { done: true };
|
|
160
|
+
}
|
|
161
|
+
crc = crc32(value, crc);
|
|
162
|
+
total += value.byteLength;
|
|
163
|
+
return { done: false, value: value };
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @chestnutlabs/gcode-containers — safe container extraction (DD-005 §4.4/§7).
|
|
3
|
+
*
|
|
4
|
+
* Phase 2 (#74): the hardened in-memory ZIP reader and the `.gcode.3mf`
|
|
5
|
+
* adapter. In-memory only, zero dependencies, never writes files, never
|
|
6
|
+
* fetches. Depends only on @chestnutlabs/toolpath-core; the parser consumes
|
|
7
|
+
* it through its structural ContainerAdapterLike type (worker entries compose).
|
|
8
|
+
*/
|
|
9
|
+
export { ContainerError, DEFAULT_CONTAINER_LIMITS, canonicalName, crc32, crc32Final, readDirectory, locatePayload, extractEntry, streamEntry } from './zip.js';
|
|
10
|
+
export type { ContainerLimits, ZipEntry, ZipDirectory } from './zip.js';
|
|
11
|
+
export { openGcode3mf, sniffGcode3mf } from './gcode-3mf.js';
|
|
12
|
+
export type { OpenedContainer, PlateEntry, ContainerMetadata, StreamLike } from './gcode-3mf.js';
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,cAAc,EACd,wBAAwB,EACxB,aAAa,EACb,KAAK,EACL,UAAU,EACV,aAAa,EACb,aAAa,EACb,YAAY,EACZ,WAAW,EACZ,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,eAAe,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC7D,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @chestnutlabs/gcode-containers — safe container extraction (DD-005 §4.4/§7).
|
|
3
|
+
*
|
|
4
|
+
* Phase 2 (#74): the hardened in-memory ZIP reader and the `.gcode.3mf`
|
|
5
|
+
* adapter. In-memory only, zero dependencies, never writes files, never
|
|
6
|
+
* fetches. Depends only on @chestnutlabs/toolpath-core; the parser consumes
|
|
7
|
+
* it through its structural ContainerAdapterLike type (worker entries compose).
|
|
8
|
+
*/
|
|
9
|
+
export { ContainerError, DEFAULT_CONTAINER_LIMITS, canonicalName, crc32, crc32Final, readDirectory, locatePayload, extractEntry, streamEntry } from './zip.js';
|
|
10
|
+
export { openGcode3mf, sniffGcode3mf } from './gcode-3mf.js';
|
package/dist/zip.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, hostile-input-hardened ZIP reader (DD-005 §4.4/§7, as amended).
|
|
3
|
+
*
|
|
4
|
+
* In-memory only — never touches a filesystem. Zero dependencies: inflate uses
|
|
5
|
+
* the platform `DecompressionStream('deflate-raw')` (browsers, workers, and the
|
|
6
|
+
* pinned Node 22+ runtime; NOT Node 18). Enforced per amendment 3:
|
|
7
|
+
* - per-entry CRC32 verification (streaming accumulation)
|
|
8
|
+
* - central/local header agreement (name, sizes, method)
|
|
9
|
+
* - encrypted-entry rejection
|
|
10
|
+
* - duplicate canonical-name handling
|
|
11
|
+
* - incremental expanded-byte caps (a lying header cannot balloon memory)
|
|
12
|
+
* Zip64 is detected and rejected with a structured error (v1 scope; .gcode.3mf
|
|
13
|
+
* payloads are far below 4 GB).
|
|
14
|
+
*/
|
|
15
|
+
export declare class ContainerError extends Error {
|
|
16
|
+
readonly code: string;
|
|
17
|
+
constructor(code: string, message: string);
|
|
18
|
+
}
|
|
19
|
+
export interface ContainerLimits {
|
|
20
|
+
maxEntries: number;
|
|
21
|
+
maxEntryNameBytes: number;
|
|
22
|
+
maxExpandedBytesPerEntry: number;
|
|
23
|
+
maxExpandedBytesTotal: number;
|
|
24
|
+
maxMetadataBytes: number;
|
|
25
|
+
}
|
|
26
|
+
/** §7.2 defaults (provisional; ratified by the E4 benchmark phase). */
|
|
27
|
+
export declare const DEFAULT_CONTAINER_LIMITS: ContainerLimits;
|
|
28
|
+
export interface ZipEntry {
|
|
29
|
+
/** Canonical name: forward slashes, no traversal — see canonicalName(). */
|
|
30
|
+
name: string;
|
|
31
|
+
rawName: string;
|
|
32
|
+
method: number;
|
|
33
|
+
compressedSize: number;
|
|
34
|
+
uncompressedSize: number;
|
|
35
|
+
crc32: number;
|
|
36
|
+
localHeaderOffset: number;
|
|
37
|
+
flags: number;
|
|
38
|
+
}
|
|
39
|
+
export declare function crc32(bytes: Uint8Array, seed?: number): number;
|
|
40
|
+
export declare const crc32Final: (c: number) => number;
|
|
41
|
+
/**
|
|
42
|
+
* Canonicalize an entry name for duplicate detection and selection sanity:
|
|
43
|
+
* backslashes → slashes, case-folded, collapsed slashes. Returns null for
|
|
44
|
+
* HOSTILE names (traversal segments, absolute paths, drive letters, NUL).
|
|
45
|
+
*/
|
|
46
|
+
export declare function canonicalName(rawName: string): string | null;
|
|
47
|
+
export interface ZipDirectory {
|
|
48
|
+
/** Central-directory entries in CD order, hostile names excluded (warned). */
|
|
49
|
+
entries: ZipEntry[];
|
|
50
|
+
warnings: {
|
|
51
|
+
code: string;
|
|
52
|
+
message: string;
|
|
53
|
+
}[];
|
|
54
|
+
}
|
|
55
|
+
/** Parse the central directory (discovery only — no payload inflate). */
|
|
56
|
+
export declare function readDirectory(bytes: Uint8Array, limits: ContainerLimits): ZipDirectory;
|
|
57
|
+
/** Validate central/local header agreement and return the payload data offset. */
|
|
58
|
+
export declare function locatePayload(bytes: Uint8Array, entry: ZipEntry): number;
|
|
59
|
+
/**
|
|
60
|
+
* Extract one entry fully in memory with incremental caps + streaming CRC.
|
|
61
|
+
* `cap` bounds the expanded size REGARDLESS of what headers claim.
|
|
62
|
+
*/
|
|
63
|
+
export declare function extractEntry(bytes: Uint8Array, entry: ZipEntry, cap: number): Promise<Uint8Array>;
|
|
64
|
+
/**
|
|
65
|
+
* Stream an entry's expanded bytes with an incremental cap. CRC verification
|
|
66
|
+
* for the streaming path is the CALLER's job via crc32/crc32Final (the plate
|
|
67
|
+
* pipeline verifies at end-of-stream); extractEntry() verifies internally.
|
|
68
|
+
*/
|
|
69
|
+
export declare function streamEntry(bytes: Uint8Array, entry: ZipEntry, cap: number): AsyncGenerator<Uint8Array>;
|
|
70
|
+
//# sourceMappingURL=zip.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zip.d.ts","sourceRoot":"","sources":["../src/zip.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,qBAAa,cAAe,SAAQ,KAAK;aAErB,IAAI,EAAE,MAAM;gBAAZ,IAAI,EAAE,MAAM,EAC5B,OAAO,EAAE,MAAM;CAKlB;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,wBAAwB,EAAE,MAAM,CAAC;IACjC,qBAAqB,EAAE,MAAM,CAAC;IAC9B,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED,uEAAuE;AACvE,eAAO,MAAM,wBAAwB,EAAE,eAMtC,CAAC;AAEF,MAAM,WAAW,QAAQ;IACvB,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,MAAM,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,iBAAiB,EAAE,MAAM,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC;CACf;AAkBD,wBAAgB,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,SAAa,GAAG,MAAM,CAMlE;AAED,eAAO,MAAM,UAAU,GAAI,GAAG,MAAM,KAAG,MAAgC,CAAC;AAExE;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ5D;AASD,MAAM,WAAW,YAAY;IAC3B,8EAA8E;IAC9E,OAAO,EAAE,QAAQ,EAAE,CAAC;IACpB,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC/C;AAED,yEAAyE;AACzE,wBAAgB,aAAa,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,eAAe,GAAG,YAAY,CA8EtF;AAED,kFAAkF;AAClF,wBAAgB,aAAa,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,GAAG,MAAM,CAwCxE;AAID;;;GAGG;AACH,wBAAsB,YAAY,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAsBvG;AAED;;;;GAIG;AACH,wBAAuB,WAAW,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,cAAc,CAAC,UAAU,CAAC,CAiE9G"}
|
package/dist/zip.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, hostile-input-hardened ZIP reader (DD-005 §4.4/§7, as amended).
|
|
3
|
+
*
|
|
4
|
+
* In-memory only — never touches a filesystem. Zero dependencies: inflate uses
|
|
5
|
+
* the platform `DecompressionStream('deflate-raw')` (browsers, workers, and the
|
|
6
|
+
* pinned Node 22+ runtime; NOT Node 18). Enforced per amendment 3:
|
|
7
|
+
* - per-entry CRC32 verification (streaming accumulation)
|
|
8
|
+
* - central/local header agreement (name, sizes, method)
|
|
9
|
+
* - encrypted-entry rejection
|
|
10
|
+
* - duplicate canonical-name handling
|
|
11
|
+
* - incremental expanded-byte caps (a lying header cannot balloon memory)
|
|
12
|
+
* Zip64 is detected and rejected with a structured error (v1 scope; .gcode.3mf
|
|
13
|
+
* payloads are far below 4 GB).
|
|
14
|
+
*/
|
|
15
|
+
export class ContainerError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.name = 'ContainerError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** §7.2 defaults (provisional; ratified by the E4 benchmark phase). */
|
|
24
|
+
export const DEFAULT_CONTAINER_LIMITS = {
|
|
25
|
+
maxEntries: 4096,
|
|
26
|
+
maxEntryNameBytes: 1024,
|
|
27
|
+
maxExpandedBytesPerEntry: 512 * 1024 * 1024,
|
|
28
|
+
maxExpandedBytesTotal: 1024 * 1024 * 1024,
|
|
29
|
+
maxMetadataBytes: 8 * 1024 * 1024
|
|
30
|
+
};
|
|
31
|
+
const EOCD_SIG = 0x06054b50;
|
|
32
|
+
const CD_SIG = 0x02014b50;
|
|
33
|
+
const LOCAL_SIG = 0x04034b50;
|
|
34
|
+
const ZIP64_EOCD_SIG = 0x06064b50;
|
|
35
|
+
/** CRC32 (IEEE) with the standard table. */
|
|
36
|
+
const CRC_TABLE = (() => {
|
|
37
|
+
const t = new Uint32Array(256);
|
|
38
|
+
for (let n = 0; n < 256; n++) {
|
|
39
|
+
let c = n;
|
|
40
|
+
for (let k = 0; k < 8; k++)
|
|
41
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
42
|
+
t[n] = c >>> 0;
|
|
43
|
+
}
|
|
44
|
+
return t;
|
|
45
|
+
})();
|
|
46
|
+
export function crc32(bytes, seed = 0xffffffff) {
|
|
47
|
+
let c = seed;
|
|
48
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
49
|
+
c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
50
|
+
}
|
|
51
|
+
return c >>> 0;
|
|
52
|
+
}
|
|
53
|
+
export const crc32Final = (c) => (c ^ 0xffffffff) >>> 0;
|
|
54
|
+
/**
|
|
55
|
+
* Canonicalize an entry name for duplicate detection and selection sanity:
|
|
56
|
+
* backslashes → slashes, case-folded, collapsed slashes. Returns null for
|
|
57
|
+
* HOSTILE names (traversal segments, absolute paths, drive letters, NUL).
|
|
58
|
+
*/
|
|
59
|
+
export function canonicalName(rawName) {
|
|
60
|
+
if (rawName.includes('\0'))
|
|
61
|
+
return null;
|
|
62
|
+
const slashed = rawName.replace(/\\/g, '/');
|
|
63
|
+
if (/^[a-zA-Z]:/.test(slashed) || slashed.startsWith('/'))
|
|
64
|
+
return null;
|
|
65
|
+
const parts = slashed.split('/').filter((p) => p.length > 0);
|
|
66
|
+
if (parts.some((p) => p === '..' || p === '.'))
|
|
67
|
+
return null;
|
|
68
|
+
if (parts.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
return parts.join('/').toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
function u16(b, o) {
|
|
73
|
+
return b[o] | (b[o + 1] << 8);
|
|
74
|
+
}
|
|
75
|
+
function u32(b, o) {
|
|
76
|
+
return (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0;
|
|
77
|
+
}
|
|
78
|
+
/** Parse the central directory (discovery only — no payload inflate). */
|
|
79
|
+
export function readDirectory(bytes, limits) {
|
|
80
|
+
// Locate EOCD: scan backward over the last 64KB+22 for the signature.
|
|
81
|
+
const scanStart = Math.max(0, bytes.length - (64 * 1024 + 22));
|
|
82
|
+
let eocd = -1;
|
|
83
|
+
for (let i = bytes.length - 22; i >= scanStart; i--) {
|
|
84
|
+
if (u32(bytes, i) === EOCD_SIG) {
|
|
85
|
+
eocd = i;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (eocd === -1)
|
|
90
|
+
throw new ContainerError('E_CONTAINER_FORMAT', 'not a ZIP archive (no end-of-central-directory)');
|
|
91
|
+
const totalEntries = u16(bytes, eocd + 10);
|
|
92
|
+
const cdSize = u32(bytes, eocd + 12);
|
|
93
|
+
const cdOffset = u32(bytes, eocd + 16);
|
|
94
|
+
if (totalEntries === 0xffff || cdOffset === 0xffffffff || cdSize === 0xffffffff) {
|
|
95
|
+
throw new ContainerError('E_CONTAINER_ZIP64', 'zip64 archives are not supported (v1 scope)');
|
|
96
|
+
}
|
|
97
|
+
// Explicit zip64 EOCD present anywhere near the tail → reject rather than misparse.
|
|
98
|
+
for (let i = Math.max(0, eocd - 76); i < eocd; i++) {
|
|
99
|
+
if (u32(bytes, i) === ZIP64_EOCD_SIG) {
|
|
100
|
+
throw new ContainerError('E_CONTAINER_ZIP64', 'zip64 archives are not supported (v1 scope)');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (totalEntries > limits.maxEntries) {
|
|
104
|
+
throw new ContainerError('E_CONTAINER_LIMIT_ENTRIES', `archive has ${totalEntries} entries (> ${limits.maxEntries})`);
|
|
105
|
+
}
|
|
106
|
+
if (cdOffset + cdSize > bytes.length) {
|
|
107
|
+
throw new ContainerError('E_CONTAINER_FORMAT', 'central directory extends past the archive');
|
|
108
|
+
}
|
|
109
|
+
const warnings = [];
|
|
110
|
+
const entries = [];
|
|
111
|
+
const seen = new Map();
|
|
112
|
+
let o = cdOffset;
|
|
113
|
+
const dec = new TextDecoder();
|
|
114
|
+
for (let n = 0; n < totalEntries; n++) {
|
|
115
|
+
if (o + 46 > bytes.length || u32(bytes, o) !== CD_SIG) {
|
|
116
|
+
throw new ContainerError('E_CONTAINER_FORMAT', `malformed central directory at entry ${n}`);
|
|
117
|
+
}
|
|
118
|
+
const flags = u16(bytes, o + 8);
|
|
119
|
+
const method = u16(bytes, o + 10);
|
|
120
|
+
const crc = u32(bytes, o + 16);
|
|
121
|
+
const compressedSize = u32(bytes, o + 20);
|
|
122
|
+
const uncompressedSize = u32(bytes, o + 24);
|
|
123
|
+
const nameLen = u16(bytes, o + 28);
|
|
124
|
+
const extraLen = u16(bytes, o + 30);
|
|
125
|
+
const commentLen = u16(bytes, o + 32);
|
|
126
|
+
const localHeaderOffset = u32(bytes, o + 42);
|
|
127
|
+
if (nameLen > limits.maxEntryNameBytes) {
|
|
128
|
+
throw new ContainerError('E_CONTAINER_LIMIT_NAME', `entry name of ${nameLen} B (> ${limits.maxEntryNameBytes})`);
|
|
129
|
+
}
|
|
130
|
+
if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localHeaderOffset === 0xffffffff) {
|
|
131
|
+
throw new ContainerError('E_CONTAINER_ZIP64', 'zip64 entry fields are not supported (v1 scope)');
|
|
132
|
+
}
|
|
133
|
+
const rawName = dec.decode(bytes.subarray(o + 46, o + 46 + nameLen));
|
|
134
|
+
o += 46 + nameLen + extraLen + commentLen;
|
|
135
|
+
if (rawName.endsWith('/'))
|
|
136
|
+
continue; // directory entries carry no payload
|
|
137
|
+
const name = canonicalName(rawName);
|
|
138
|
+
if (name === null) {
|
|
139
|
+
warnings.push({
|
|
140
|
+
code: 'container-entry-hostile-name',
|
|
141
|
+
message: `entry name rejected: ${JSON.stringify(rawName)}`
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const dup = seen.get(name);
|
|
146
|
+
if (dup !== undefined) {
|
|
147
|
+
warnings.push({ code: 'container-duplicate-entry', message: `duplicate canonical name '${name}' (first wins)` });
|
|
148
|
+
continue; // amendment 3: first central-directory occurrence wins for metadata; payload dups error at selection
|
|
149
|
+
}
|
|
150
|
+
seen.set(name, entries.length);
|
|
151
|
+
entries.push({ name, rawName, method, compressedSize, uncompressedSize, crc32: crc, localHeaderOffset, flags });
|
|
152
|
+
}
|
|
153
|
+
return { entries, warnings };
|
|
154
|
+
}
|
|
155
|
+
/** Validate central/local header agreement and return the payload data offset. */
|
|
156
|
+
export function locatePayload(bytes, entry) {
|
|
157
|
+
const o = entry.localHeaderOffset;
|
|
158
|
+
if (o + 30 > bytes.length || u32(bytes, o) !== LOCAL_SIG) {
|
|
159
|
+
throw new ContainerError('E_CONTAINER_FORMAT', `bad local header for '${entry.name}'`);
|
|
160
|
+
}
|
|
161
|
+
const flags = u16(bytes, o + 6);
|
|
162
|
+
if ((flags & 0x1) !== 0 || (entry.flags & 0x1) !== 0) {
|
|
163
|
+
throw new ContainerError('E_CONTAINER_ENCRYPTED', `entry '${entry.name}' is encrypted — no decryption path exists`);
|
|
164
|
+
}
|
|
165
|
+
const method = u16(bytes, o + 8);
|
|
166
|
+
const nameLen = u16(bytes, o + 26);
|
|
167
|
+
const extraLen = u16(bytes, o + 28);
|
|
168
|
+
const localName = new TextDecoder().decode(bytes.subarray(o + 30, o + 30 + nameLen));
|
|
169
|
+
if (method !== entry.method || localName !== entry.rawName) {
|
|
170
|
+
throw new ContainerError('E_CONTAINER_HEADER_MISMATCH', `central/local header disagreement for '${entry.name}' — treating the entry as hostile`);
|
|
171
|
+
}
|
|
172
|
+
// When the local header carries sizes (no data-descriptor flag), they must agree too.
|
|
173
|
+
if ((flags & 0x8) === 0) {
|
|
174
|
+
const localCrc = u32(bytes, o + 14);
|
|
175
|
+
const localCompressed = u32(bytes, o + 18);
|
|
176
|
+
const localUncompressed = u32(bytes, o + 22);
|
|
177
|
+
if (localCrc !== entry.crc32 ||
|
|
178
|
+
localCompressed !== entry.compressedSize ||
|
|
179
|
+
localUncompressed !== entry.uncompressedSize) {
|
|
180
|
+
throw new ContainerError('E_CONTAINER_HEADER_MISMATCH', `central/local size/CRC disagreement for '${entry.name}' — treating the entry as hostile`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const dataStart = o + 30 + nameLen + extraLen;
|
|
184
|
+
if (dataStart + entry.compressedSize > bytes.length) {
|
|
185
|
+
throw new ContainerError('E_CONTAINER_FORMAT', `entry '${entry.name}' payload extends past the archive`);
|
|
186
|
+
}
|
|
187
|
+
return dataStart;
|
|
188
|
+
}
|
|
189
|
+
const INFLATE_CHUNK = 256 * 1024;
|
|
190
|
+
/**
|
|
191
|
+
* Extract one entry fully in memory with incremental caps + streaming CRC.
|
|
192
|
+
* `cap` bounds the expanded size REGARDLESS of what headers claim.
|
|
193
|
+
*/
|
|
194
|
+
export async function extractEntry(bytes, entry, cap) {
|
|
195
|
+
const out = [];
|
|
196
|
+
let total = 0;
|
|
197
|
+
let crc = 0xffffffff;
|
|
198
|
+
for await (const chunk of streamEntry(bytes, entry, cap)) {
|
|
199
|
+
out.push(chunk);
|
|
200
|
+
total += chunk.byteLength;
|
|
201
|
+
crc = crc32(chunk, crc);
|
|
202
|
+
}
|
|
203
|
+
if (crc32Final(crc) !== entry.crc32) {
|
|
204
|
+
throw new ContainerError('E_CONTAINER_CRC', `CRC mismatch for '${entry.name}'`);
|
|
205
|
+
}
|
|
206
|
+
if (total !== entry.uncompressedSize) {
|
|
207
|
+
throw new ContainerError('E_CONTAINER_HEADER_MISMATCH', `expanded size of '${entry.name}' differs from headers`);
|
|
208
|
+
}
|
|
209
|
+
const joined = new Uint8Array(total);
|
|
210
|
+
let off = 0;
|
|
211
|
+
for (const c of out) {
|
|
212
|
+
joined.set(c, off);
|
|
213
|
+
off += c.byteLength;
|
|
214
|
+
}
|
|
215
|
+
return joined;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Stream an entry's expanded bytes with an incremental cap. CRC verification
|
|
219
|
+
* for the streaming path is the CALLER's job via crc32/crc32Final (the plate
|
|
220
|
+
* pipeline verifies at end-of-stream); extractEntry() verifies internally.
|
|
221
|
+
*/
|
|
222
|
+
export async function* streamEntry(bytes, entry, cap) {
|
|
223
|
+
const dataStart = locatePayload(bytes, entry);
|
|
224
|
+
const compressed = bytes.subarray(dataStart, dataStart + entry.compressedSize);
|
|
225
|
+
if (entry.uncompressedSize > cap) {
|
|
226
|
+
throw new ContainerError('E_CONTAINER_LIMIT_EXPANDED', `entry '${entry.name}' claims ${entry.uncompressedSize} B (> cap ${cap})`);
|
|
227
|
+
}
|
|
228
|
+
let produced = 0;
|
|
229
|
+
const guard = (chunk) => {
|
|
230
|
+
produced += chunk.byteLength;
|
|
231
|
+
if (produced > cap) {
|
|
232
|
+
throw new ContainerError('E_CONTAINER_LIMIT_EXPANDED', `entry '${entry.name}' expanded past ${cap} B — headers lied; extraction stopped`);
|
|
233
|
+
}
|
|
234
|
+
return chunk;
|
|
235
|
+
};
|
|
236
|
+
if (entry.method === 0) {
|
|
237
|
+
if (compressed.byteLength !== entry.uncompressedSize) {
|
|
238
|
+
throw new ContainerError('E_CONTAINER_HEADER_MISMATCH', `stored entry '${entry.name}' size disagreement`);
|
|
239
|
+
}
|
|
240
|
+
for (let o = 0; o < compressed.byteLength; o += INFLATE_CHUNK) {
|
|
241
|
+
yield guard(compressed.subarray(o, Math.min(o + INFLATE_CHUNK, compressed.byteLength)));
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (entry.method !== 8) {
|
|
246
|
+
throw new ContainerError('E_CONTAINER_METHOD', `entry '${entry.name}' uses unsupported method ${entry.method}`);
|
|
247
|
+
}
|
|
248
|
+
const ds = new DecompressionStream('deflate-raw');
|
|
249
|
+
const writer = ds.writable.getWriter();
|
|
250
|
+
const reader = ds.readable.getReader();
|
|
251
|
+
// The writer side must never become an unobserved rejection: on corrupt
|
|
252
|
+
// deflate data the stream errors BOTH ends, and an unawaited writer promise
|
|
253
|
+
// crashes the process as an unhandled rejection even though the reader path
|
|
254
|
+
// converts its copy of the error (found by the #131 fuzzer — see the
|
|
255
|
+
// fuzz-regressions corpus). Capture instead of floating.
|
|
256
|
+
let writeError;
|
|
257
|
+
const writeAll = (async () => {
|
|
258
|
+
for (let o = 0; o < compressed.byteLength; o += INFLATE_CHUNK) {
|
|
259
|
+
await writer.write(compressed.subarray(o, Math.min(o + INFLATE_CHUNK, compressed.byteLength)));
|
|
260
|
+
}
|
|
261
|
+
await writer.close();
|
|
262
|
+
})().catch((e) => {
|
|
263
|
+
writeError = e;
|
|
264
|
+
});
|
|
265
|
+
try {
|
|
266
|
+
for (;;) {
|
|
267
|
+
const { done, value } = await reader.read();
|
|
268
|
+
if (done)
|
|
269
|
+
break;
|
|
270
|
+
if (value !== undefined && value.byteLength > 0)
|
|
271
|
+
yield guard(value);
|
|
272
|
+
}
|
|
273
|
+
await writeAll;
|
|
274
|
+
if (writeError !== undefined)
|
|
275
|
+
throw writeError;
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
await writeAll; // settled by construction; never rejects
|
|
279
|
+
if (err instanceof ContainerError)
|
|
280
|
+
throw err;
|
|
281
|
+
throw new ContainerError('E_CONTAINER_INFLATE', `inflate failed for '${entry.name}': ${err instanceof Error ? err.message : err}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chestnutlabs/gcode-containers",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Safe, bounded, in-memory container extraction for sliced G-code (.gcode.3mf) \u2014 DD-005 \u00a74.4/\u00a77. Never writes files, never fetches, zero dependencies.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"gcode",
|
|
7
|
+
"3d-printing",
|
|
8
|
+
"toolpath",
|
|
9
|
+
"cnc",
|
|
10
|
+
"chestnutlabs",
|
|
11
|
+
"3mf",
|
|
12
|
+
"zip",
|
|
13
|
+
"container",
|
|
14
|
+
"gcode-3mf"
|
|
15
|
+
],
|
|
16
|
+
"author": "Chestnut Labs",
|
|
17
|
+
"homepage": "https://github.com/ChestnutLabs/gcode-preview#readme",
|
|
18
|
+
"bugs": "https://github.com/ChestnutLabs/gcode-preview/issues",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/ChestnutLabs/gcode-preview.git",
|
|
22
|
+
"directory": "packages/gcode-containers"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"main": "dist/index.js",
|
|
30
|
+
"types": "dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"import": "./dist/index.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.json",
|
|
42
|
+
"test": "vitest run"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@chestnutlabs/toolpath-core": "0.1.0"
|
|
46
|
+
}
|
|
47
|
+
}
|