diff --git a/src/get-default-printer/get-default-printer.spec.ts b/src/get-default-printer/get-default-printer.spec.ts index 7740cf1..1bc80fb 100644 --- a/src/get-default-printer/get-default-printer.spec.ts +++ b/src/get-default-printer/get-default-printer.spec.ts @@ -1,5 +1,5 @@ import getDefaultPrinter from "./get-default-printer"; -import execAsync from "../utils/exec-async"; +import { execFileAsync } from "../utils/exec-async"; import { Printer } from "../types"; jest.mock("../utils/exec-async"); @@ -20,11 +20,11 @@ Interface: /etc/cups/ppd/Virtual_PDF_Printer.ppd afterEach(() => { // restore the original implementation. - execAsync.mockRestore(); + execFileAsync.mockRestore(); }); it("returns the system default printer", async () => { - execAsync + execFileAsync .mockImplementationOnce(() => Promise.resolve({ stdout: defaultPrinterStdout }) ) @@ -41,11 +41,11 @@ it("returns the system default printer", async () => { }; await expect(getDefaultPrinter()).resolves.toEqual(expected); - await expect(execAsync).toBeCalledWith("lpstat -d"); + await expect(execFileAsync).toBeCalledWith("lpstat", ["-d"]); }); it("returns null when the default printer is not defined", async () => { - execAsync.mockImplementation(() => + execFileAsync.mockImplementation(() => Promise.resolve({ stdout: "no system default destination" }) ); @@ -53,6 +53,6 @@ it("returns null when the default printer is not defined", async () => { }); it("fails with an error", async () => { - execAsync.mockImplementation(() => Promise.reject("error")); + execFileAsync.mockImplementation(() => Promise.reject("error")); await expect(getDefaultPrinter()).rejects.toMatch("error"); }); diff --git a/src/get-default-printer/get-default-printer.ts b/src/get-default-printer/get-default-printer.ts index 1b218f8..fbf1773 100644 --- a/src/get-default-printer/get-default-printer.ts +++ b/src/get-default-printer/get-default-printer.ts @@ -1,10 +1,10 @@ import { Printer } from "../types"; -import execAsync from "../utils/exec-async"; +import { execFileAsync } from "../utils/exec-async"; import parsePrinterAttribute from "../utils/parse-printer-attribute"; export default async function getDefaultPrinter(): Promise { try { - const { stdout } = await execAsync("lpstat -d"); + const { stdout } = await execFileAsync("lpstat", ["-d"]); const printer = getPrinterName(stdout); if (!printer) return null; return await getPrinterData(printer); @@ -19,7 +19,7 @@ function getPrinterName(output: string): string { } async function getPrinterData(printer: string): Promise { - const { stdout } = await execAsync(`lpstat -lp ${printer}`); + const { stdout } = await execFileAsync("lpstat", ["-lp", printer]); return { printer, status: stdout.split(/.*is\s(\w+)\..*/gm)[1], diff --git a/src/get-printers/get-printers.spec.ts b/src/get-printers/get-printers.spec.ts index d9b7ba9..4281d03 100644 --- a/src/get-printers/get-printers.spec.ts +++ b/src/get-printers/get-printers.spec.ts @@ -1,5 +1,5 @@ import getPrinters from "./get-printers"; -import execAsync from "../utils/exec-async"; +import { execFileAsync } from "../utils/exec-async"; import { Printer } from "../types"; jest.mock("../utils/exec-async"); @@ -31,11 +31,11 @@ After fault: continue afterEach(() => { // restore the original implementation. - execAsync.mockRestore(); + execFileAsync.mockRestore(); }); it("return a list of available printers", async () => { - execAsync.mockImplementation(() => Promise.resolve({ stdout })); + execFileAsync.mockImplementation(() => Promise.resolve({ stdout })); const expected: Printer[] = [ { @@ -58,7 +58,7 @@ it("return a list of available printers", async () => { }); it("return an empty list when there are no printers installed.", async () => { - execAsync.mockImplementation(() => + execFileAsync.mockImplementation(() => Promise.resolve({ stdout: "lpstat: No destinations added." }) ); @@ -66,6 +66,6 @@ it("return an empty list when there are no printers installed.", async () => { }); it("fails with an error", async () => { - execAsync.mockImplementation(() => Promise.reject("error")); + execFileAsync.mockImplementation(() => Promise.reject("error")); await expect(getPrinters()).rejects.toMatch("error"); }); diff --git a/src/get-printers/get-printers.ts b/src/get-printers/get-printers.ts index 5ad8ef3..c0401d3 100644 --- a/src/get-printers/get-printers.ts +++ b/src/get-printers/get-printers.ts @@ -1,10 +1,10 @@ import { Printer } from "../types"; -import execAsync from "../utils/exec-async"; +import { execFileAsync } from "../utils/exec-async"; import parsePrinterAttribute from "../utils/parse-printer-attribute"; export default async function getPrinters(): Promise { try { - const { stdout } = await execAsync("lpstat -lp"); + const { stdout } = await execFileAsync("lpstat", ["-lp"]); const isThereAnyPrinter = stdout.match("printer"); if (!isThereAnyPrinter) return []; diff --git a/src/print/print.spec.ts b/src/print/print.spec.ts index c0089d5..fd6c24c 100644 --- a/src/print/print.spec.ts +++ b/src/print/print.spec.ts @@ -1,5 +1,5 @@ import { existsSync } from "fs"; -import execAsync from "../utils/exec-async"; +import { execFileAsync } from "../utils/exec-async"; import print from "./print"; jest.mock("fs"); @@ -9,7 +9,7 @@ jest.mock("../utils/exec-async"); beforeEach(() => { // override the implementations existsSync.mockImplementation(() => true); - execAsync.mockImplementation(() => + execFileAsync.mockImplementation(() => Promise.resolve({ stdout: "request id is myDummyPrinter-15 (1 file(s))" }) ); }); @@ -17,7 +17,7 @@ beforeEach(() => { afterEach(() => { // restore the original implementations existsSync.mockRestore(); - execAsync.mockRestore(); + execFileAsync.mockRestore(); }); it("throws when no file is specified.", async () => { @@ -35,7 +35,7 @@ it("sends the PDF file to the default printer", async () => { await print(filename); - expect(execAsync).toHaveBeenCalledWith(`lp '${filename}'`); + expect(execFileAsync).toHaveBeenCalledWith("lp", [filename]); }); it("sends PDF file to the specific printer", async () => { @@ -44,7 +44,7 @@ it("sends PDF file to the specific printer", async () => { await print(filename, printer); - expect(execAsync).toHaveBeenCalledWith(`lp '${filename}' -d ${printer}`); + expect(execFileAsync).toHaveBeenCalledWith("lp", [filename, "-d", printer]); }); it("allows to pass other print options", async () => { @@ -54,9 +54,14 @@ it("allows to pass other print options", async () => { await print(filename, printer, options); - expect(execAsync).toHaveBeenCalledWith( - `lp '${filename}' -d ${printer} -o landscape -o fit-to-page -o media=A4` - ); + expect(execFileAsync).toHaveBeenCalledWith("lp", [ + filename, + "-d", + printer, + "-o landscape", + "-o fit-to-page", + "-o media=A4", + ]); }); it("allows to pass options but omit the printer name", async () => { @@ -65,9 +70,12 @@ it("allows to pass options but omit the printer name", async () => { await print(filename, undefined, options); - expect(execAsync).toHaveBeenCalledWith( - `lp '${filename}' -o landscape -o fit-to-page -o media=A4` - ); + expect(execFileAsync).toHaveBeenCalledWith("lp", [ + filename, + "-o landscape", + "-o fit-to-page", + "-o media=A4", + ]); }); it("throws if options passed not as an array", async () => { diff --git a/src/print/print.ts b/src/print/print.ts index 18d6860..382702a 100644 --- a/src/print/print.ts +++ b/src/print/print.ts @@ -1,6 +1,6 @@ import fs from "fs"; import { ExecResponse } from "../types"; -import execAsync from "../utils/exec-async"; +import { execFileAsync } from "../utils/exec-async"; export default async function print( file: string, @@ -10,7 +10,7 @@ export default async function print( if (!file) throw "No file specified"; if (!fs.existsSync(file)) throw "No such file"; - const args = [`'${file}'`]; + const args = [file]; if (printer) { args.push("-d", printer); @@ -19,8 +19,8 @@ export default async function print( if (options) { if (!Array.isArray(options)) throw "options should be an array"; - options.forEach((arg) => args.push(arg)); + args.push(...options); } - return execAsync(`lp ${args.join(" ")}`); + return execFileAsync("lp", args); } diff --git a/src/utils/exec-async.ts b/src/utils/exec-async.ts index e5be78d..ca0accd 100644 --- a/src/utils/exec-async.ts +++ b/src/utils/exec-async.ts @@ -1,25 +1,37 @@ "use strict"; import { ExecResponse } from "../types"; -import { exec } from "child_process"; +import { execFile } from "child_process"; -export default function execAsync(cmd: string): Promise { - return new Promise((resolve, reject) => { - exec(cmd, { - // The output from lp and lpstat is parsed assuming the language is English. - // LANG=C sets the language and the SOFTWARE variable is necessary - // on MacOS due to a detail in Apple's CUPS implementation - // (see https://unix.stackexchange.com/a/33836) - env: { - SOFTWARE: "", - LANG: "C" - } - }, (err, stdout, stderr) => { - if (err) { - reject(err); - } else { - resolve({stdout, stderr}); - } - }); - }); +export function execFileAsync( + cmd: string, + args: string[] = [] +): Promise { + return new Promise((resolve, reject) => { + execFile( + cmd, + args, + { + // The output from lp and lpstat is parsed assuming the language is English. + // LANG=C sets the language and the SOFTWARE variable is necessary + // on MacOS due to a detail in Apple's CUPS implementation + // (see https://unix.stackexchange.com/a/33836) + env: { + SOFTWARE: "", + LANG: "C", + }, + // shell MUST be set to false. + // Otherwise any input containing shell metacharacters may be used to trigger arbitrary command execution. + // See https://nodejs.org/api/child_process.html#child_processexecfilefile-args-options-callback + shell: false, + }, + (err, stdout, stderr) => { + if (err) { + reject(err); + } else { + resolve({ stdout, stderr }); + } + } + ); + }); } diff --git a/src/utils/parse-response.spec.ts b/src/utils/parse-response.spec.ts index 55ed9ae..1abad68 100644 --- a/src/utils/parse-response.spec.ts +++ b/src/utils/parse-response.spec.ts @@ -1,34 +1,40 @@ -import execAsync from '../utils/exec-async'; -import { getRequestId, default as isPrintComplete } from './parse-response'; +import { execFileAsync } from "../utils/exec-async"; +import { getRequestId, default as isPrintComplete } from "./parse-response"; -jest.mock('../utils/exec-async'); -jest.mock('../get-default-printer/get-default-printer'); +jest.mock("../utils/exec-async"); +jest.mock("../get-default-printer/get-default-printer"); const queuedStdout = `lp0-39 username 15360 Mon 12 Jun 2023 21:09:48`; -describe('getRequestId', () => { - it('returns the job id', async () => { - const response = { stdout: 'request id is myDummyPrinter-15 (1 file(s))', stderr: null }; - const expected = 'myDummyPrinter-15'; +describe("getRequestId", () => { + it("returns the job id", async () => { + const response = { + stdout: "request id is myDummyPrinter-15 (1 file(s))", + stderr: null, + }; + const expected = "myDummyPrinter-15"; expect(getRequestId(response)).toEqual(expected); }); - it('returns -1 on weird input', async () => { - const response = { stdout: 'printer is offline or something/manually passing stuff', stderr: null }; + it("returns -1 on weird input", async () => { + const response = { + stdout: "printer is offline or something/manually passing stuff", + stderr: null, + }; const expected = null; expect(getRequestId(response)).toEqual(expected); }); - it('returns -1 when response is empty', async () => { - const response = { stdout: '', stderr: null }; + it("returns -1 when response is empty", async () => { + const response = { stdout: "", stderr: null }; const expected = null; expect(getRequestId(response)).toEqual(expected); }); - it('returns -1 when response is null', async () => { + it("returns -1 when response is null", async () => { const response = { stdout: null, stderr: null }; const expected = null; @@ -36,37 +42,48 @@ describe('getRequestId', () => { }); }); -describe('isPrintComplete', () => { +describe("isPrintComplete", () => { beforeEach(() => { - execAsync.mockImplementationOnce(() => Promise.resolve({ stdout: queuedStdout })); + execFileAsync.mockImplementationOnce(() => + Promise.resolve({ stdout: queuedStdout }) + ); }); afterEach(() => { // restore the original implementation. - execAsync.mockRestore(); + execFileAsync.mockRestore(); }); - it('job is still on the queue', async () => { - const printResponse = { stdout: 'request id is lp0-39 (1 file(s))', stderr: null }; + it("job is still on the queue", async () => { + const printResponse = { + stdout: "request id is lp0-39 (1 file(s))", + stderr: null, + }; const result = isPrintComplete(printResponse); await expect(result).resolves.toEqual(false); - expect(execAsync).toBeCalledWith(`lpstat -o lp0`); + expect(execFileAsync).toBeCalledWith("lpstat", ["-o", "lp0"]); }); - it('job is not on the queue', async () => { - const printResponse = { stdout: 'request id is lp0-12 (1 file(s))', stderr: null }; + it("job is not on the queue", async () => { + const printResponse = { + stdout: "request id is lp0-12 (1 file(s))", + stderr: null, + }; const result = isPrintComplete(printResponse); await expect(result).resolves.toEqual(true); }); - it('nothing on the queue', async () => { - const printResponse = { stdout: 'request id is lp0-39 (1 file(s))', stderr: null }; - execAsync.mockRestore(); - execAsync.mockImplementationOnce(() => Promise.resolve({ stdout: '' })); + it("nothing on the queue", async () => { + const printResponse = { + stdout: "request id is lp0-39 (1 file(s))", + stderr: null, + }; + execFileAsync.mockRestore(); + execFileAsync.mockImplementationOnce(() => Promise.resolve({ stdout: "" })); const result = isPrintComplete(printResponse); @@ -74,7 +91,10 @@ describe('isPrintComplete', () => { }); it("getJobId didn't work", async () => { - const printResponse = { stdout: 'printer is offline or something/manually passing stuff', stderr: null }; + const printResponse = { + stdout: "printer is offline or something/manually passing stuff", + stderr: null, + }; const result = isPrintComplete(printResponse); diff --git a/src/utils/parse-response.ts b/src/utils/parse-response.ts index 9932a34..bdff75b 100644 --- a/src/utils/parse-response.ts +++ b/src/utils/parse-response.ts @@ -1,5 +1,5 @@ import { ExecResponse } from "../types"; -import execAsync from "./exec-async"; +import { execFileAsync } from "./exec-async"; async function isPrintComplete(printResponse: ExecResponse) { const requestId = getRequestId(printResponse); @@ -7,13 +7,13 @@ async function isPrintComplete(printResponse: ExecResponse) { return false; } - const args = new Array(); + const args: string[] = []; const { printer } = splitRequestId(requestId); if (printer) { args.push("-o", printer); } - const { stdout } = await execAsync(`lpstat ${args.join(" ")}`); + const { stdout } = await execFileAsync("lpstat", args); if (!stdout) { return true;