Public: anyone can read this; the crew can change it.

An app prints PWG raster through the print route, because the owner's printer… #11954

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11953-an-app-prints-pwg-raster-through-the-pri → main 3 commits fork

  • Merged. scout/11953-an-app-prints-pwg-raster-through-the-pri was merged into main as 67e3a6b.

Review

Sign in to review this change.
7 files changed +364 −17
docs/features/devices.md +1 −1
@@ -12,7 +12,7 @@
1212 ## Printing from an app
1313
1414 - On a device's row ("Allowed apps") the owner grants it to an app by slug, and revokes it again (`device.granted` / `device.revoked` events). No grant, no print. In the terminal's Devices screen, `a` allows the device under the cursor to an app (type its slug) and `r` revokes one; the same actions and events as the page.
15 - An app prints with `POST $FORGE_API_URL/devices/<id>/print`, `Authorization: Bearer $FORGE_TOKEN`: the app process's own token, body the PDF (`content-type: application/pdf`). The forge knows the app from `app_token`, which only the runtime writes when it mints that token; a person's token named like an app is not one. The reply is `{"job":{"id","state","reasons"}}` from the printer's IPP Print-Job answer.
15 - An app prints with `POST $FORGE_API_URL/devices/<id>/print`, `Authorization: Bearer $FORGE_TOKEN`: the app process's own token, body the PDF (`content-type: application/pdf`). The forge knows the app from `app_token`, which only the runtime writes when it mints that token; a person's token named like an app is not one. A printer that takes only PWG raster is printed to with `content-type: image/pwg-raster` (up to 80 MB, 20 pages; sent as document-format `image/pwg-raster`). Before anything leaves for the LAN the forge checks the `RaS2` sync word, walks every page header (1796 bytes) and its PackBits lines, and takes the page count from them; a truncated, mis-synced, odd-format or 21-page file is 422. Then it asks the printer (Get-Printer-Attributes) for `pwg-raster-document-resolution-supported` and `-type-supported` and refuses a page those lists rule out with 422 (`raster_resolution_refused`, `raster_type_refused`); a printer that lists nothing is not second-guessed. Width and height are not checked against the printer (IPP has no attribute for them). The body is buffered, as the PDF is. The reply is `{"job":{"id","state","reasons"}}` from the printer's IPP Print-Job answer.
1616 - Checked before anything leaves for the LAN: at most 10 MB and 20 pages, `%PDF-` header and `%%EOF` trailer, otherwise 422. Pages are counted from the plain page tree; a PDF whose tree is in a compressed object stream answers 422 `pages_unknown`. Not granted, or not a declared device: 403. Printer silent or refusing: 502.
1717 - The route takes a device number only; the job goes to the declared address, so an app reaches no other LAN host through it. Its cell's own egress is unchanged (`src/apps/egress.ts` still opens only granted `host:port`s).
1818 - Each print appends `device.printed` (device, app, pages).
src/contract/openapi/routes/postApiV1DevicesIdPrint.ts +6 −1
@@ -3,7 +3,7 @@ import type { Route } from "../shared.ts";
33 export const route: Route = {
44 method: "post",
55 path: "/api/v1/devices/{id}/print",
6 summary: "Print the PDF in the request body on a device the app was granted (the app's own token); answers 200 with the job",
6 summary: "Print the PDF (application/pdf) or PWG raster (image/pwg-raster, up to 80 MB, 20 pages) in the request body on a device the app was granted (the app's own token); answers 200 with the job",
77 auth: true,
88 role: "member",
99 refusals: {
@@ -13,6 +13,11 @@ export const route: Route = {
1313 not_pdf: "422: the body is not a PDF",
1414 too_many_pages: "422: the PDF has more pages than the grant allows",
1515 pages_unknown: "422: the page count could not be read",
16 not_raster: "422: the body does not begin with the PWG raster sync word RaS2",
17 raster_truncated: "422: a PWG raster page header or its lines are cut short",
18 raster_unsupported: "422: a PWG raster page has a format the forge cannot read",
19 raster_resolution_refused: "422: the printer's pwg-raster-document-resolution-supported does not list the page's resolution",
20 raster_type_refused: "422: the printer's pwg-raster-document-type-supported does not list the page's type",
1621 printer_unreachable: "502: the printer did not answer",
1722 printer_refused: "502: the printer refused the job",
1823 unauthorized: "no token",
src/devices/ipp.ts +38 −5
@@ -32,6 +32,8 @@ const WANTED = [
3232 "marker-colors",
3333 ];
3434
35 const RASTER_WANTED = ["pwg-raster-document-resolution-supported", "pwg-raster-document-type-supported"];
36
3537 const RESPONSE_MAX = 256 * 1024;
3638
3739 export type IppValue = string | number | boolean | Uint8Array;
@@ -46,7 +48,7 @@ function attr(tag: number, name: string, value: string): number[] {
4648 }
4749
4850 /** The request body for Get-Printer-Attributes on `printerUri`. */
49 export function encodeGetPrinterAttributes(printerUri: string, requestId = 1): Uint8Array {
51 export function encodeGetPrinterAttributes(printerUri: string, requestId = 1, wanted: readonly string[] = WANTED): Uint8Array {
5052 const out: number[] = [
5153 1, 1, // version 1.1
5254 OP_GET_PRINTER_ATTRIBUTES >> 8, OP_GET_PRINTER_ATTRIBUTES & 255,
@@ -56,7 +58,7 @@ export function encodeGetPrinterAttributes(printerUri: string, requestId = 1): U
5658 ...attr(TAG.language, "attributes-natural-language", "en"),
5759 ...attr(TAG.uri, "printer-uri", printerUri),
5860 ];
59 WANTED.forEach((k, i) => out.push(...attr(TAG.keyword, i === 0 ? "requested-attributes" : "", k)));
61 wanted.forEach((k, i) => out.push(...attr(TAG.keyword, i === 0 ? "requested-attributes" : "", k)));
6062 out.push(TAG.end);
6163 return Uint8Array.from(out);
6264 }
@@ -166,6 +168,32 @@ export async function probePrinter(url: string, timeoutMs = 2500): Promise<Probe
166168 return { ok: true, report: reportOf(answer.response.attributes) };
167169 }
168170
171 export interface RasterSupport {
172 /** [x, y] dpi pairs the printer lists, or undefined when it lists none. */
173 readonly resolutions: [number, number][] | undefined;
174 readonly types: string[] | undefined;
175 }
176
177 /**
178 * What the printer says about PWG raster: `pwg-raster-document-resolution-supported`
179 * and `-type-supported`.
180 */
181 export async function probeRasterSupport(url: string, timeoutMs = 2500): Promise<{ ok: true; support: RasterSupport } | { ok: false; error: "unreachable" | "refused" | "bad_answer" }> {
182 const answer = await exchange(url, encodeGetPrinterAttributes(url, 1, RASTER_WANTED), timeoutMs);
183 if (!answer.ok) return answer;
184 const a = answer.response.attributes;
185 const resolutions: [number, number][] = [];
186 for (const v of a.get("pwg-raster-document-resolution-supported") ?? []) {
187 // resolution value: x (int32), y (int32), units (1 byte; 3 = dots per inch)
188 if (v instanceof Uint8Array && v.length === 9 && v[8] === 3) {
189 const d = new DataView(v.buffer, v.byteOffset, 9);
190 resolutions.push([d.getInt32(0), d.getInt32(4)]);
191 }
192 }
193 const types = str(a, "pwg-raster-document-type-supported");
194 return { ok: true, support: { resolutions: resolutions.length > 0 ? resolutions : undefined, types: types.length > 0 ? types : undefined } };
195 }
196
169197 type Exchange =
170198 | { readonly ok: true; readonly response: IppResponse }
171199 | { readonly ok: false; readonly error: "unreachable" | "refused" | "bad_answer" };
@@ -200,7 +228,7 @@ async function exchange(url: string, body: Uint8Array, timeoutMs: number): Promi
200228 }
201229
202230 /** The request body for Print-Job: the document follows the attributes. */
203 export function encodePrintJob(printerUri: string, pdf: Uint8Array, jobName: string, requestId = 1): Uint8Array {
231 export function encodePrintJob(printerUri: string, pdf: Uint8Array, jobName: string, requestId = 1, format = "application/pdf"): Uint8Array {
204232 const head: number[] = [
205233 1, 1,
206234 OP_PRINT_JOB >> 8, OP_PRINT_JOB & 255,
@@ -211,7 +239,7 @@ export function encodePrintJob(printerUri: string, pdf: Uint8Array, jobName: str
211239 ...attr(TAG.uri, "printer-uri", printerUri),
212240 ...attr(TAG.nameWithoutLanguage, "requesting-user-name", "forge"),
213241 ...attr(TAG.nameWithoutLanguage, "job-name", jobName),
214 ...attr(TAG.mimeType, "document-format", "application/pdf"),
242 ...attr(TAG.mimeType, "document-format", format),
215243 TAG.end,
216244 ];
217245 const out = new Uint8Array(head.length + pdf.length);
@@ -243,7 +271,12 @@ export type PrintResult =
243271 * takes a long document needs longer than a probe does.
244272 */
245273 export async function printPdf(url: string, pdf: Uint8Array, jobName: string, timeoutMs = 30_000): Promise<PrintResult> {
246 const answer = await exchange(url, encodePrintJob(url, pdf, jobName), timeoutMs);
274 return printDocument(url, pdf, jobName, "application/pdf", timeoutMs);
275 }
276
277 /** The same Print-Job with another document-format (PWG raster is `image/pwg-raster`). */
278 export async function printDocument(url: string, doc: Uint8Array, jobName: string, format: string, timeoutMs = 30_000): Promise<PrintResult> {
279 const answer = await exchange(url, encodePrintJob(url, doc, jobName, 1, format), timeoutMs);
247280 if (!answer.ok) return answer;
248281 const a = answer.response.attributes;
249282 const id = a.get("job-id")?.[0];
src/devices/print.ts +37 −7
@@ -11,7 +11,8 @@ import { getDevice, type Device } from "../core/devices.ts";
1111 import { dailyCap, logJob, printedToday, type Outcome } from "../core/print_log.ts";
1212 import { RateLimiter } from "../core/ratelimit.ts";
1313 import { err, ok, type Result } from "../kernel/result.ts";
14 import { printPdf, type PrintJob } from "./ipp.ts";
14 import { printDocument, printPdf, probeRasterSupport, type PrintJob } from "./ipp.ts";
15 import { PWG_MAX_BYTES, readPwg, unsupportedBy, type PwgError } from "./pwg.ts";
1516
1617 export const PDF_MAX_BYTES = 10 * 1024 * 1024;
1718 export const PDF_MAX_PAGES = 20;
@@ -20,16 +21,21 @@ export const PDF_MAX_PAGES = 20;
2021 export const PRINTS_PER_MINUTE = 10;
2122 export const PRINTS_AT_ONCE = 2;
2223
23 export type PrintError = "forbidden" | "rate_limited" | "daily_cap" | "not_pdf" | "too_large" | "too_many_pages" | "pages_unknown" | "printer_unreachable" | "printer_refused";
24 export type PrintError = "forbidden" | "rate_limited" | "daily_cap" | "not_pdf" | "too_large" | "too_many_pages" | "pages_unknown" | "printer_unreachable" | "printer_refused" | PwgError | "raster_resolution_refused" | "raster_type_refused";
2425
2526 export const PRINT_WORDS: Record<PrintError, string> = {
2627 forbidden: "This app is not granted that printer.",
2728 rate_limited: "This app is printing too fast; wait a little.",
2829 daily_cap: "This app has used its prints for today on this printer; it can print again tomorrow.",
2930 not_pdf: "The body must be a PDF (application/pdf).",
30 too_large: "The PDF is over 10 MB.",
31 too_many_pages: "The PDF is over 20 pages.",
31 too_large: "The document is over its size limit (10 MB for a PDF, 80 MB for PWG raster).",
32 too_many_pages: "The document is over 20 pages.",
3233 pages_unknown: "The page count could not be read; save the PDF without compressed object streams.",
34 not_raster: "The body must be PWG raster (image/pwg-raster): it does not begin with the RaS2 sync word.",
35 raster_truncated: "The PWG raster is cut short: a page header or its lines end before they should.",
36 raster_unsupported: "The PWG raster has a page this forge cannot read: it takes 8 or 16 bits of sgray, srgb or adobe-rgb, with consistent header sizes.",
37 raster_resolution_refused: "The printer says it does not take PWG raster at this resolution.",
38 raster_type_refused: "The printer says it does not take PWG raster in this colour space and depth.",
3339 printer_unreachable: "The printer did not answer.",
3440 printer_refused: "The printer did not accept the job.",
3541 };
@@ -68,6 +74,13 @@ export function checkPdf(pdf: Uint8Array): Result<number, PrintError> {
6874 return ok(pages);
6975 }
7076
77 /** The body is PWG raster within the limits: its page count, or why not. */
78 export function checkRaster(raster: Uint8Array): Result<number, PrintError> {
79 if (raster.length > PWG_MAX_BYTES) return err("too_large");
80 const pages = readPwg(raster);
81 return pages.ok ? ok(pages.value.length) : pages;
82 }
83
7184 const perMinute = new RateLimiter({ perMinute: PRINTS_PER_MINUTE, adminPerMinute: PRINTS_PER_MINUTE, enabled: true });
7285 const inFlight = new Map<string, number>();
7386
@@ -114,16 +127,33 @@ export function permitPrint(app: App, secret: string, deviceId: number): Result<
114127 }
115128
116129 /** Print `pdf` for a permitted app; the document is checked before any byte leaves for the LAN. */
117 export async function printFor(app: App, permit: Permit, pdf: Uint8Array): Promise<Result<PrintJob, PrintError>> {
130 export async function printFor(app: App, permit: Permit, pdf: Uint8Array, kind: "pdf" | "raster" = "pdf"): Promise<Result<PrintJob, PrintError>> {
118131 try {
119132 const { caller, device } = permit;
120 const checked = checkPdf(pdf);
133 const checked = kind === "raster" ? checkRaster(pdf) : checkPdf(pdf);
121134 const log = (pages: number, outcome: Outcome): void => logJob(app.db, { deviceId: device.id, appId: caller.id, pages, outcome });
122135 if (!checked.ok) {
123136 log(0, "refused_document");
124137 return checked;
125138 }
126 const sent = await printPdf(device.url, pdf, `${caller.slug} (forge)`);
139 if (kind === "raster") {
140 // Only now does anything touch the LAN: ask what the printer takes of what the pages need.
141 const asked = await probeRasterSupport(device.url);
142 if (!asked.ok) {
143 log(0, "failed");
144 return err(asked.error === "unreachable" ? "printer_unreachable" : "printer_refused");
145 }
146 const pages = readPwg(pdf);
147 for (const page of pages.ok ? pages.value : []) {
148 const why = unsupportedBy(page, asked.support.resolutions, asked.support.types);
149 if (why !== undefined) {
150 log(0, "refused_document");
151 return err(why);
152 }
153 }
154 }
155 const jobName = `${caller.slug} (forge)`;
156 const sent = kind === "raster" ? await printDocument(device.url, pdf, jobName, "image/pwg-raster") : await printPdf(device.url, pdf, jobName);
127157 log(checked.value, sent.ok ? "printed" : "failed");
128158 releasePermit(permit);
129159 if (sent.ok) {
src/devices/pwg.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * PWG raster (PWG 5102.4) read just far enough to vouch for it: the `RaS2`
3 * sync word, then per page a 1796-byte header and PackBits-coded lines. Every
4 * page is walked, because the only way to find the next header is to decode
5 * this page's lines; nothing is sent to a printer that this walk refuses.
6 * Plain bytes in, plain data out.
7 */
8
9 import { err, ok, type Result } from "../kernel/result.ts";
10
11 export const PWG_MAX_BYTES = 80 * 1024 * 1024;
12 export const PWG_MAX_PAGES = 20;
13 export const PWG_HEADER_BYTES = 1796;
14 /** Sides and line length no real page reaches; keeps a forged header from making a long walk. */
15 const MAX_SIDE = 40_000;
16
17 export interface PwgPage {
18 readonly width: number;
19 readonly height: number;
20 /** Dots per inch, across and down. */
21 readonly dpi: readonly [number, number];
22 /** The IPP keyword for the pixel format, e.g. `sgray_8`. */
23 readonly type: string;
24 }
25
26 export type PwgError = "not_raster" | "raster_truncated" | "too_many_pages" | "raster_unsupported";
27
28 /** cupsColorSpace values PWG raster allows, with their channel counts and keyword stems. */
29 const SPACES: Record<number, { channels: number; stem: string }> = {
30 18: { channels: 1, stem: "sgray" },
31 19: { channels: 3, stem: "srgb" },
32 20: { channels: 3, stem: "adobe-rgb" },
33 };
34
35 /** The pages of a PWG raster document, or why it is refused. */
36 export function readPwg(bytes: Uint8Array): Result<PwgPage[], PwgError> {
37 if (bytes.length < 4 || String.fromCharCode(bytes[0]!, bytes[1]!, bytes[2]!, bytes[3]!) !== "RaS2") return err("not_raster");
38 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
39 const pages: PwgPage[] = [];
40 let at = 4;
41 while (at < bytes.length) {
42 if (pages.length === PWG_MAX_PAGES) return err("too_many_pages");
43 if (at + PWG_HEADER_BYTES > bytes.length) return err("raster_truncated");
44 const h = at;
45 const dpi: [number, number] = [view.getUint32(h + 276), view.getUint32(h + 280)];
46 const width = view.getUint32(h + 372);
47 const height = view.getUint32(h + 376);
48 const bitsPerColor = view.getUint32(h + 384);
49 const bitsPerPixel = view.getUint32(h + 388);
50 const bytesPerLine = view.getUint32(h + 392);
51 const space = SPACES[view.getUint32(h + 400)];
52 if (!space || (bitsPerColor !== 8 && bitsPerColor !== 16) || bitsPerPixel !== bitsPerColor * space.channels) return err("raster_unsupported");
53 if (width < 1 || height < 1 || width > MAX_SIDE || height > MAX_SIDE || dpi[0] < 1 || dpi[1] < 1) return err("raster_unsupported");
54 if (bytesPerLine !== Math.ceil((width * bitsPerPixel) / 8)) return err("raster_unsupported");
55 at += PWG_HEADER_BYTES;
56 const pixel = bitsPerPixel / 8;
57 let rows = 0;
58 while (rows < height) {
59 if (at >= bytes.length) return err("raster_truncated");
60 const repeat = bytes[at++]! + 1;
61 let line = 0;
62 while (line < bytesPerLine) {
63 if (at >= bytes.length) return err("raster_truncated");
64 const c = bytes[at++]!;
65 if (c === 128) break; // the rest of the line is white
66 const run = c < 128 ? c + 1 : 257 - c;
67 line += run * pixel;
68 at += c < 128 ? pixel : run * pixel;
69 if (line > bytesPerLine) return err("raster_unsupported");
70 }
71 if (at > bytes.length) return err("raster_truncated");
72 rows += repeat;
73 if (rows > height) return err("raster_unsupported");
74 }
75 pages.push({ width, height, dpi, type: `${space.stem}_${bitsPerColor}` });
76 }
77 if (pages.length === 0) return err("raster_truncated");
78 return ok(pages);
79 }
80
81 /**
82 * Whether the printer's answers (`pwg-raster-document-resolution-supported`,
83 * `pwg-raster-document-type-supported`) rule a page out; undefined when they
84 * allow it or the printer does not say.
85 */
86 export function unsupportedBy(
87 page: PwgPage,
88 resolutions: readonly [number, number][] | undefined,
89 types: readonly string[] | undefined,
90 ): "raster_resolution_refused" | "raster_type_refused" | undefined {
91 if (resolutions && resolutions.length > 0 && !resolutions.some(([x, y]) => x === page.dpi[0] && y === page.dpi[1])) return "raster_resolution_refused";
92 if (types && types.length > 0 && !types.includes(page.type)) return "raster_type_refused";
93 return undefined;
94 }
src/http/api/print.ts +11 −3
@@ -1,10 +1,11 @@
11 /**
22 * `POST /api/v1/devices/:id/print` — an app prints a PDF on a device its
33 * owner granted it. The credential is the app's own token; the body is the
4 * PDF. The route takes a device number and nothing that names an address.
4 * PDF, or PWG raster when the content-type says `image/pwg-raster`. The route takes a device number and nothing that names an address.
55 */
66
77 import { PRINT_WORDS, PDF_MAX_BYTES, permitPrint, printFor, releasePermit, type PrintError } from "../../devices/print.ts";
8 import { PWG_MAX_BYTES } from "../../devices/pwg.ts";
89 import type { Handler } from "../context.ts";
910 import { readBounded } from "../limits.ts";
1011 import { apiError, json, tryAgain, unauthorized, untilNextDay, WINDOW } from "../respond.ts";
@@ -21,6 +22,11 @@ const STATUS: Record<PrintError, number> = {
2122 pages_unknown: 422,
2223 printer_unreachable: 502,
2324 printer_refused: 502,
25 not_raster: 422,
26 raster_truncated: 422,
27 raster_unsupported: 422,
28 raster_resolution_refused: 422,
29 raster_type_refused: 422,
2430 };
2531
2632 /** A print refusal; the two 429s say when: the minute's window, or the next UTC day for the daily cap. */
@@ -39,9 +45,11 @@ export function printRoutes(router: Router<Handler>): void {
3945 const permit = permitPrint(ctx.app, secret, id);
4046 if (!permit.ok) return printRefusal(permit.error);
4147 // The permit holds a place until printFor, or here when the body never gets that far.
48 const raster = (ctx.request.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase() === "image/pwg-raster";
49 const cap = raster ? PWG_MAX_BYTES : PDF_MAX_BYTES;
4250 let pdf: Awaited<ReturnType<typeof readBounded>>;
4351 try {
44 pdf = await readBounded(ctx.request, PDF_MAX_BYTES);
52 pdf = await readBounded(ctx.request, cap);
4553 } catch (e) {
4654 releasePermit(permit.value);
4755 throw e;
@@ -50,7 +58,7 @@ export function printRoutes(router: Router<Handler>): void {
5058 releasePermit(permit.value);
5159 return apiError("too_large", 422, PRINT_WORDS.too_large);
5260 }
53 const done = await printFor(ctx.app, permit.value, pdf);
61 const done = await printFor(ctx.app, permit.value, pdf, raster ? "raster" : "pdf");
5462 if (!done.ok) return printRefusal(done.error);
5563 return json({ job: { id: done.value.id, state: done.value.state, reasons: done.value.reasons } });
5664 });
test/print_raster.test.ts added +177
@@ -0,0 +1,177 @@
1 /**
2 * An app prints PWG raster through the print route: checked before the LAN,
3 * then sent as image/pwg-raster.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { mintAppToken } from "../src/apps/token.ts";
11 import { grantDevice } from "../src/core/device_grants.ts";
12 import { declareDevice } from "../src/core/devices.ts";
13 import { getUserByName } from "../src/core/users.ts";
14 import { readPwg } from "../src/devices/pwg.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { adminCookie } from "./helpers/admin.ts";
17
18 const enc = new TextEncoder();
19 const attr = (tag: number, name: string, value: string | number | number[]) => {
20 const n = enc.encode(name);
21 const v = typeof value === "number" ? [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255] : typeof value === "string" ? [...enc.encode(value)] : value;
22 return [tag, n.length >> 8, n.length & 255, ...n, v.length >> 8, v.length & 255, ...v];
23 };
24 const be = (n: number) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255];
25
26 /**
27 * A PWG page: 1796-byte header, then `height` identical white lines as one
28 * repeated PackBits line.
29 */
30 function page(width = 64, height = 32, dpi = 600, space = 18, bits = 8): Uint8Array {
31 const h = new Uint8Array(1796);
32 const v = new DataView(h.buffer);
33 v.setUint32(276, dpi); v.setUint32(280, dpi);
34 v.setUint32(372, width); v.setUint32(376, height);
35 v.setUint32(384, bits);
36 const channels = space === 18 ? 1 : 3;
37 v.setUint32(388, bits * channels);
38 const line = Math.ceil((width * bits * channels) / 8);
39 v.setUint32(392, line);
40 v.setUint32(400, space);
41 const pixel = (bits * channels) / 8;
42 const data: number[] = [height - 1];
43 let left = width;
44 while (left > 0) {
45 const run = Math.min(left, 128);
46 data.push(run - 1, ...new Array(pixel).fill(0xff));
47 left -= run;
48 }
49 return Uint8Array.from([...h, ...data]);
50 }
51 const pwg = (...pages: Uint8Array[]) => Uint8Array.from([...enc.encode("RaS2"), ...pages.flatMap((p) => [...p])]);
52
53 let received: Uint8Array[] = [];
54 let capabilities: number[] = [];
55 const printer = Bun.serve({
56 port: 0,
57 hostname: "127.0.0.1",
58 async fetch(req) {
59 const body = new Uint8Array(await req.arrayBuffer());
60 received.push(body);
61 const getAttributes = body[3] === 0x0b;
62 return new Response(
63 Uint8Array.from([
64 1, 1, 0, 0, 0, 0, 0, 1,
65 0x01, ...attr(0x47, "attributes-charset", "utf-8"),
66 0x02, ...(getAttributes ? capabilities : [...attr(0x21, "job-id", 7), ...attr(0x23, "job-state", 9)]),
67 0x03,
68 ]),
69 { headers: { "content-type": "application/ipp" } },
70 );
71 },
72 });
73
74 let dataDir = "", granted = "", deviceId = 0;
75 let app: App, servers: Servers;
76 const prior = process.env.FORGE_DEVICES_ALLOW_LOOPBACK;
77
78 beforeAll(async () => {
79 process.env.FORGE_DEVICES_ALLOW_LOOPBACK = "1";
80 dataDir = await mkdtemp(join(tmpdir(), "forge-pwg-"));
81 app = createApp({ dataDir, port: 0 });
82 servers = await serve(app);
83 await adminCookie(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
84 const owner = getUserByName(app.db, "alice")!;
85 app.db.run("PRAGMA foreign_keys = OFF");
86 app.db.run("INSERT INTO app (id, repo_id, slug, access) VALUES ('a1', 'repo-a1', 'paper', 'private')");
87 app.db.run("PRAGMA foreign_keys = ON");
88 const d = declareDevice(app.db, { name: "Brother", kind: "printer", url: `ipp://127.0.0.1:${printer.port}/ipp/print` });
89 if (!d.ok) throw new Error("declare");
90 deviceId = d.value.id;
91 expect(grantDevice(app.db, deviceId, "paper", owner.id).ok).toBe(true);
92 granted = mintAppToken(app.db, owner.id, "paper", "production").token;
93 });
94 afterAll(async () => {
95 if (prior === undefined) delete process.env.FORGE_DEVICES_ALLOW_LOOPBACK;
96 else process.env.FORGE_DEVICES_ALLOW_LOOPBACK = prior;
97 printer.stop(true);
98 servers.stop();
99 app.close();
100 await rm(dataDir, { recursive: true, force: true });
101 });
102
103 const print = (body: Uint8Array) =>
104 fetch(`${servers.url}/api/v1/devices/${deviceId}/print`, {
105 method: "POST",
106 headers: { connection: "close", "content-type": "image/pwg-raster", authorization: `Bearer ${granted}` },
107 body,
108 });
109
110 const includes = (hay: Uint8Array, needle: Uint8Array): boolean => Buffer.from(hay).includes(Buffer.from(needle));
111
112 test("a two-page 600 dpi sgray_8 file reaches the printer as image/pwg-raster, byte for byte", async () => {
113 capabilities = [];
114 received = [];
115 const doc = pwg(page(), page());
116 const parsed = readPwg(doc);
117 expect(parsed.ok && parsed.value.map((p) => [p.dpi[0], p.type])).toEqual([[600, "sgray_8"], [600, "sgray_8"]]);
118 const res = await print(doc);
119 expect(res.status).toBe(200);
120 expect(await res.json()).toEqual({ job: { id: 7, state: "completed", reasons: [] } });
121 const job = received.find((r) => r[3] === 0x02)!;
122 expect(Buffer.from(job).toString("latin1")).toContain("image/pwg-raster");
123 expect(Buffer.from(job).toString("latin1")).not.toContain("application/pdf");
124 expect(job.length).toBeGreaterThan(doc.length);
125 expect(includes(job, doc)).toBe(true);
126 const row = app.db.query("SELECT pages, outcome FROM print_job ORDER BY rowid DESC LIMIT 1").get();
127 expect(row).toEqual({ pages: 2, outcome: "printed" });
128 });
129
130 test("a truncated header, a wrong sync word and 21 pages are 422 and nothing reaches the device", async () => {
131 received = [];
132 const good = pwg(page());
133 expect((await print(good.subarray(0, 500))).status).toBe(422);
134 expect((await print(good.subarray(0, good.length - 3))).status).toBe(422);
135 const wrong = good.slice();
136 wrong.set(enc.encode("RaS3"));
137 const r = await print(wrong);
138 expect(r.status).toBe(422);
139 expect(((await r.json()) as { error: string }).error).toBe("not_raster");
140 const many = await print(pwg(...Array.from({ length: 21 }, () => page(8, 2))));
141 expect(many.status).toBe(422);
142 expect(((await many.json()) as { error: string }).error).toBe("too_many_pages");
143 expect(received.length).toBe(0);
144 expect((await print(pwg(...Array.from({ length: 20 }, () => page(8, 2))))).status).toBe(200);
145 });
146
147 test("a page the printer says it cannot take is refused with a sentence, before the job is sent", async () => {
148 capabilities = [
149 ...attr(0x32, "pwg-raster-document-resolution-supported", [...be(300), ...be(300), 3]),
150 ...attr(0x44, "pwg-raster-document-type-supported", "srgb_8"),
151 ];
152 received = [];
153 const r = await print(pwg(page()));
154 expect(r.status).toBe(422);
155 expect(((await r.json()) as { error: string }).error).toBe("raster_resolution_refused");
156 capabilities = [
157 ...attr(0x32, "pwg-raster-document-resolution-supported", [...be(600), ...be(600), 3]),
158 ...attr(0x44, "pwg-raster-document-type-supported", "srgb_8"),
159 ];
160 const t = await print(pwg(page()));
161 expect(t.status).toBe(422);
162 expect(((await t.json()) as { error: string }).error).toBe("raster_type_refused");
163 expect(received.every((x) => x[3] === 0x0b)).toBe(true);
164 capabilities = [
165 ...attr(0x32, "pwg-raster-document-resolution-supported", [...be(600), ...be(600), 3]),
166 ...attr(0x44, "pwg-raster-document-type-supported", "sgray_8"),
167 ];
168 expect((await print(pwg(page()))).status).toBe(200);
169 });
170
171 test("PackBits runs that overrun a line or the page are refused", () => {
172 const p = page(64, 32);
173 const bad = p.slice();
174 bad[1796] = 40; // line repeat past the page height
175 expect(readPwg(pwg(bad)).ok).toBe(false);
176 expect(readPwg(pwg(page(64, 32, 600, 19, 8))).ok).toBe(true); // srgb_8
177 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs