Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ All notable changes to this project are documented in this file.
pattern example (`--pattern` help, README) was updated to the `: `-free
`"scan"_dd.mm.yyyy_HHMMss` form, which works everywhere.

### Fixed

- **Tests on Windows**: the suite now runs green again on Windows. The README
help snapshot test compares line endings that git may convert to CRLF, the
`~` home expansion produced mixed path separators, and the read-only folder
checks relied on `chmod`, which has no effect on directories on Windows.
- README snapshot normalization: `test/readme.test.ts` no longer fails on a
CRLF checkout.
- `PathHelper.getOutputFolder`: `~` expansion now goes through
`path.join`, so paths use the platform separator consistently.
- `PathHelper.checkIfFolderIsWritable` now performs a real temporary write
(create + delete) instead of `fs.access(W_OK)`, which does not honor ACLs
or the read-only attribute on Windows; the writability tests use an
`icacls` deny on Windows (ACLs) and keep the `chmod` approach on POSIX.
- Timestamp patterns containing `:` cannot produce a valid file name on
Windows; that formatting case is skipped there.

## [1.11.1] - 2026-08-29

### Fixed
Expand Down
15 changes: 12 additions & 3 deletions src/PathHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,17 +133,26 @@ export default class PathHelper {
}

if (folder.startsWith("~")) {
return folder.replace(/^~/, os.homedir());
return path.join(os.homedir(), folder.slice(2));
}
return folder;
}

private static async checkIfFolderIsWritable(folder: string) {
// Check if the folder exists
// Probe with an actual write: `fs.access(W_OK)` does not honor ACLs or
// the read-only attribute on Windows, so a real create/delete is the
// only reliable cross-platform check.
const probePath = path.join(
folder,
`.node-hp-scan-to-write-check-${process.pid}-${Date.now()}-${nanoid()}`,
);
try {
await fs.promises.access(folder, fs.constants.W_OK);
const fd = await fs.promises.open(probePath, "wx");
await fd.close();
await fs.promises.unlink(probePath).catch(() => undefined);
return folder; // The folder exists and is writable
} catch {
await fs.promises.unlink(probePath).catch(() => undefined);
// If the folder does not exist or is not writable, handle the error
throw new Error(
`The folder "${folder}" does not exist or is not writable.`,
Expand Down
58 changes: 47 additions & 11 deletions test/PathHelper.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,45 @@
import { describe } from "mocha";
import { expect } from "chai";
import { execFileSync } from "node:child_process";
import PathHelper from "../src/PathHelper.js";
import fs from "node:fs";
import * as fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";

const now: Date = new Date();
const isWindows = process.platform === "win32";

/**
* Makes a folder unwritable for the current user and returns a restore
* function. On POSIX a chmod 0o444 is enough; on Windows folder write
* access is controlled by ACLs, so an explicit deny is used instead.
*/
function makeFolderUnwritable(folder: string): () => void {
if (isWindows) {
const who = execFileSync("whoami", { encoding: "utf8" }).trim();
execFileSync("icacls", [folder, "/deny", `${who}:(WD,AD)`], {
stdio: "ignore",
});
return () => {
try {
execFileSync("icacls", [folder, "/remove:d", who], {
stdio: "ignore",
});
} catch {
// Restore failures are ignored; the caller may remove the folder anyway
}
};
}
fs.chmodSync(folder, 0o444);
return () => {
try {
fs.chmodSync(folder, 0o755);
} catch {
// ignore
}
};
}

describe("PathHelper", () => {
describe("getFileForPage", () => {
Expand Down Expand Up @@ -49,7 +82,12 @@ describe("PathHelper", () => {
});
});
describe("getFileForScan", () => {
it("Can format a file with formatted timestamp", async () => {
it("Can format a file with formatted timestamp", async function () {
if (isWindows) {
// `:` is not allowed in Windows file names, so a pattern producing
// `HH:MM:ss` cannot be exercised there.
this.skip();
}
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "test-"));
const nextFileName = await PathHelper.getFileForScan(
tempDir,
Expand Down Expand Up @@ -166,7 +204,7 @@ describe("PathHelper", () => {
it("should throw an error if the folder is not writable", async () => {
const readOnlyFolder = path.join(os.tmpdir(), "read-only-folder");
await fs.promises.mkdir(readOnlyFolder, { recursive: true });
await fs.promises.chmod(readOnlyFolder, 0o444); // Set to read-only
const restore = makeFolderUnwritable(readOnlyFolder);

try {
await PathHelper.getTargetFolder(readOnlyFolder);
Expand All @@ -182,11 +220,10 @@ describe("PathHelper", () => {
} else {
throw error; // Re-throw if it's not an Error object
}
} finally {
restore();
await fs.promises.rmdir(readOnlyFolder);
}

// Clean up: Restore permissions and remove the folder
await fs.promises.chmod(readOnlyFolder, 0o755); // Set back to writable
await fs.promises.rmdir(readOnlyFolder);
});
});

Expand All @@ -212,7 +249,7 @@ describe("PathHelper", () => {
it("should throw an error if the folder is not writable", async () => {
const readOnlyFolder = path.join(os.tmpdir(), "read-only-folder");
await fs.promises.mkdir(readOnlyFolder, { recursive: true });
await fs.promises.chmod(readOnlyFolder, 0o444); // Set to read-only
const restore = makeFolderUnwritable(readOnlyFolder);

try {
await PathHelper.getTempFolder(readOnlyFolder);
Expand All @@ -228,11 +265,10 @@ describe("PathHelper", () => {
} else {
throw error; // Re-throw if it's not an Error object
}
} finally {
restore();
await fs.promises.rmdir(readOnlyFolder);
}

// Clean up: Restore permissions and remove the folder
await fs.promises.chmod(readOnlyFolder, 0o755); // Set back to writable
await fs.promises.rmdir(readOnlyFolder);
});
});

Expand Down
4 changes: 3 additions & 1 deletion test/readme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ function expectComandUsageIsUpdated(commandName: string) {
const help = new Help();
help.showGlobalOptions = true;
const val = `\`\`\`text\n${help.formatHelp(command, help)}\`\`\``;
expect(usageInReadme).to.be.eq(val);
// Normalize line endings: checkouts on Windows may convert the README
// to CRLF, while the generated help always uses LF.
expect(usageInReadme?.replace(/\r\n/g, "\n")).to.be.eq(val);
}
}
}
Expand Down
Loading