diff --git a/CHANGELOG.md b/CHANGELOG.md index b16c5b7..932261b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,7 @@ This project uses a Keep a Changelog-style structure. Until a tagged release is - Additional WCAG requirement IDs used by `jp-public-web` are accepted by screening validation. - Formal claim blockers are separated from auxiliary screening candidates. - Public report visibility (`report --visibility public`) now withholds every target host that the Web network guard refuses, and records the reason in the redaction manifest. This adds IPv4-mapped, NAT64, 6to4 and site-local IPv6 forms, `192.88.99.0/24`, and `*.localhost` / `*.localdomain` names. Earlier builds kept some of these hosts; regenerate public reports made with them and complete publication review again. +- Registering or validating artifact paths that differ only in letter case is now rejected as a duplicate on macOS as well as Windows, because the default macOS filesystem is case-insensitive. Symlink, junction and containment checks are unchanged. A case-sensitive macOS volume can report a false duplicate; that is deliberate. ## Compatibility notes diff --git a/release-files.json b/release-files.json index a7247ab..13cd3d6 100644 --- a/release-files.json +++ b/release-files.json @@ -346,6 +346,7 @@ "tests/agent-role-contracts.test.mjs", "tests/architecture-doc.test.mjs", "tests/artifact-authoring.test.mjs", + "tests/artifact-path-identity.test.mjs", "tests/assessment-output-contract.test.mjs", "tests/assessment-review-provenance.test.mjs", "tests/attestation-verifier.test.mjs", diff --git a/shared/skill/scripts/lib/audit-run-validation.mjs b/shared/skill/scripts/lib/audit-run-validation.mjs index 53d47e1..8c49a4a 100644 --- a/shared/skill/scripts/lib/audit-run-validation.mjs +++ b/shared/skill/scripts/lib/audit-run-validation.mjs @@ -10,7 +10,7 @@ import { validateParticipantBindings, collectParticipantEvidence } from "./parti import { validateDeclaredChangeBindings, collectDeclaredChangeEvidence } from "./declared-change.mjs"; import { collectFixExecutionEvidence } from "./fix-execution-evidence.mjs"; import { targetBindingErrors } from "./run-targets.mjs"; -import { pathKey, parseJsonBytes, readStableFile, assertStableFile, resolveInside } from "./safe-file-io.mjs"; +import { artifactPathKey, parseJsonBytes, readStableFile, assertStableFile, resolveInside } from "./safe-file-io.mjs"; import { defaultSkillRoot, loadAuditResources } from "./audit-resources.mjs"; import { auditRunRegistryCompatibility, auditRunEnvelopeCompatibility } from "./orchestration-contracts.mjs"; import { artifactRootFor, validateArtifact, registeredArtifactPath, canonicalPermissions, remediationPermissionError, validateRegisteredArtifactEntries, validateArtifactEnvelopeSemantics, validateHumanQueueBindings, validateScreeningProfileBindings, validateDeclaredHumanBindings, validateRemediationBindings } from "./audit-artifact-validation.mjs"; @@ -158,7 +158,7 @@ export function validateAuditRun(run, { skillRoot = defaultSkillRoot, runFile, r if (!artifactRoot) continue; try { const file = registeredArtifactPath(artifactRoot, entry); - const canonicalPath = pathKey(file); + const canonicalPath = artifactPathKey(file); if (canonicalArtifactPaths.has(canonicalPath)) errors.push(`Duplicate canonical artifact path: ${String(entry?.path)}.`); canonicalArtifactPaths.add(canonicalPath); const snapshot = readArtifactFile(file, { label: `registered artifact ${String(entry?.artifact_id)}` }); diff --git a/shared/skill/scripts/lib/audit-run.mjs b/shared/skill/scripts/lib/audit-run.mjs index 4bfd722..78138ba 100644 --- a/shared/skill/scripts/lib/audit-run.mjs +++ b/shared/skill/scripts/lib/audit-run.mjs @@ -16,7 +16,7 @@ import { networkPolicyErrors } from "./network-policy.mjs"; import { afterChangeRun } from "./declared-change.mjs"; import { collectFixExecutionEvidence } from "./fix-execution-evidence.mjs"; import { targetInventoryErrors, targetBindingErrors, checkLocalRunTargets, consumeRunTargetCheck, checkRunTargets } from "./run-targets.mjs"; -import { pathKey, isWithinPath, hasTraversal, inspectRealComponents, parseJsonBytes, readStableFile, assertStableFile, resolveInside } from "./safe-file-io.mjs"; +import { artifactPathKey, isWithinPath, hasTraversal, inspectRealComponents, parseJsonBytes, readStableFile, assertStableFile, resolveInside } from "./safe-file-io.mjs"; import { defaultSkillRoot, loadAuditResources } from "./audit-resources.mjs"; import { compareText } from "./text-order.mjs"; import { roleFor, validateArtifact, registeredArtifactPath, canonicalPermissions, remediationPermissionError, validateFixHandoffBinding, validateChangeRecordAuthorizationBinding, assertCurrentOperationalRun } from "./audit-artifact-validation.mjs"; @@ -173,7 +173,7 @@ export function registerArtifact(run, artifact, options = {}) { if (relativeSegments.includes(".fix-consumption") || relativeSegments.some((segment) => segment.includes("pending-change-record"))) { throw new Error(`Internal fixer runtime evidence is not registerable as a completed artifact: ${relativePath}`); } - if (run.artifacts.some((entry) => pathKey(registeredArtifactPath(validation.artifactRoot, entry)) === pathKey(artifactPath))) { + if (run.artifacts.some((entry) => artifactPathKey(registeredArtifactPath(validation.artifactRoot, entry)) === artifactPathKey(artifactPath))) { throw new Error(`Duplicate artifact path: ${relativePath}`); } const snapshot = readStableFile(artifactPath, { label: "artifact file" }); diff --git a/shared/skill/scripts/lib/safe-file-io.mjs b/shared/skill/scripts/lib/safe-file-io.mjs index b4e7dae..3bb6a30 100644 --- a/shared/skill/scripts/lib/safe-file-io.mjs +++ b/shared/skill/scripts/lib/safe-file-io.mjs @@ -11,6 +11,11 @@ export function pathKey(value) { return process.platform === "win32" ? normalized.toLowerCase() : normalized; } +export function artifactPathKey(value, platform = process.platform) { + const normalized = path.normalize(path.resolve(value)); + return platform === "win32" || platform === "darwin" ? normalized.toLowerCase() : normalized; +} + export function samePath(left, right) { return pathKey(left) === pathKey(right); } diff --git a/tests/artifact-path-identity.test.mjs b/tests/artifact-path-identity.test.mjs new file mode 100644 index 0000000..cb7f3db --- /dev/null +++ b/tests/artifact-path-identity.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { artifactPathKey, pathKey, samePath } from "../codex/skills/information-accessibility-practice/scripts/lib/safe-file-io.mjs"; + +for (const platform of ["darwin", "win32", "linux"]) { + test(`artifact path identity ${platform === "linux" ? "preserves" : "folds"} case on ${platform}`, () => { + const upper = artifactPathKey("artifacts/Report.json", platform); + const lower = artifactPathKey("artifacts/report.json", platform); + if (platform === "linux") assert.notEqual(upper, lower); + else assert.equal(upper, lower); + assert.equal(upper, artifactPathKey(path.resolve("artifacts/Report.json"), platform)); + }); +} + +test("artifact path identity defaults to the current platform", () => { + assert.equal(artifactPathKey("artifacts/Report.json"), artifactPathKey("artifacts/Report.json", process.platform)); +}); + +test("path safety comparisons preserve case on darwin", () => { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); + try { + Object.defineProperty(process, "platform", { value: "darwin" }); + assert.notEqual(pathKey("artifacts/Report.json"), pathKey("artifacts/report.json")); + assert.equal(samePath("artifacts/Report.json", "artifacts/report.json"), false); + assert.equal(samePath("artifacts/Report.json", path.resolve("artifacts/Report.json")), true); + assert.equal(artifactPathKey("artifacts/Report.json"), artifactPathKey("artifacts/report.json")); + } finally { + Object.defineProperty(process, "platform", descriptor); + } +});