diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4a36204 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2750294..af9b1ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,12 +5,25 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: - validate: - runs-on: ubuntu-latest + test: + name: ${{ matrix.os }} / Node ${{ matrix.node-version }} + runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - node-version: [22, 24] + include: + - os: ubuntu-latest + node-version: 22 + - os: windows-latest + node-version: 22 + - os: macos-latest + node-version: 22 + - os: ubuntu-latest + node-version: 24 steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 @@ -19,5 +32,5 @@ jobs: cache: npm - run: npm ci - run: npm run check - - run: npm test + - run: npm run test:coverage - run: npm audit --audit-level=high diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4950896 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,26 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "23 4 * * 1" + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: CodeQL / JavaScript-TypeScript + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + - uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + - uses: github/codeql-action/autobuild@v3 + - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9b958c..6d00ae6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,10 +5,10 @@ on: tags: ["v*"] permissions: - contents: read + contents: write jobs: - verify: + release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -18,13 +18,43 @@ jobs: cache: npm - run: npm ci - run: npm run check - - run: npm test + - run: npm run test:coverage - run: npm audit --audit-level=high - - name: Verify tag matches package version + - name: Verify metadata, license, package contents, and tag + env: + RELEASE_TAG: ${{ github.ref_name }} + run: npm run verify:package + - name: Extract matching changelog notes + run: npm run release:notes > release-notes.md + - name: Build package artifact + id: pack shell: bash run: | - expected="v$(node -p "require('./package.json').version")" - test "$GITHUB_REF_NAME" = "$expected" || { - echo "Tag $GITHUB_REF_NAME does not match package version $expected" + npm pack --json > pack-result.json + package_file="$(node -e "console.log(require('./pack-result.json')[0].filename)")" + echo "package_file=$package_file" >> "$GITHUB_OUTPUT" + - name: Preserve package as workflow artifact + uses: actions/upload-artifact@v4 + with: + name: npm-package-${{ github.ref_name }} + path: ${{ steps.pack.outputs.package_file }} + if-no-files-found: error + retention-days: 30 + - name: Refuse to replace an existing release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $GITHUB_REF_NAME already exists; immutable releases are never replaced." exit 1 - } + fi + - name: Create GitHub release and attach package + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release create "$GITHUB_REF_NAME" + "${{ steps.pack.outputs.package_file }}" + --repo "$GITHUB_REPOSITORY" + --verify-tag + --title "$GITHUB_REF_NAME" + --notes-file release-notes.md diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..de902a8 --- /dev/null +++ b/.npmignore @@ -0,0 +1,6 @@ +dist/test/ +test/ +.env +.env.* +auth.json +credentials* diff --git a/CHANGELOG.md b/CHANGELOG.md index 73e4ec2..e8d5dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,22 @@ ### Added -- product, architecture, contributor, examples, and performance documentation; -- broader node:test coverage for anchor parsing and edit operations; -- reproducible hash benchmark and test coverage command; -- CI and tag/version release verification with dependency auditing. +- product, architecture, contributor, examples, performance, operations, branch-protection, security, and release documentation; +- focused coverage for anchor parsing, classified failures, file kinds, atomic filesystem edits, and cross-platform link/newline behavior; +- reproducible hash benchmark and enforced coverage thresholds; +- Linux, Windows, macOS, Node 22, and Node 24 CI coverage; +- package provenance/license verification and immutable GitHub release artifact creation; +- weekly Dependabot and CodeQL scanning workflows. + +### Changed + +- filesystem edits now classify paths before decoding and use same-directory atomic replacement with mode, BOM, newline, and cleanup guarantees; +- package contents exclude compiled tests and include complete repository provenance metadata. + +### Security + +- unsafe binary, image, special-file, symlink, null-byte, and lossy UTF-8 rewrites are rejected before writing; +- security reporting, sensitive-diagnostic handling, dependency review, and recovery responsibilities are documented. ## 0.1.0 diff --git a/LICENSE b/LICENSE index d1e1072..1f8772f 100644 --- a/LICENSE +++ b/LICENSE @@ -1 +1,21 @@ MIT License + +Copyright (c) 2026 T50 Systems + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index fb44242..bc79d0a 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ Edit `lines` must contain literal file content. Remove copied `LINE#HASH:` prefi Use the filesystem adapter, which detects and preserves newline style. Include a CRLF regression test for adapter changes. +The adapter refuses directories, symbolic links, special files, images, null-byte/binary data, and invalid UTF-8 that would decode with replacement characters. Successful edits use a same-directory temporary file and atomic replacement, preserve UTF-8 BOMs and existing permission bits, and clean up temporary files after success or handled failure. Atomic replacement intentionally breaks the edited path out of a hard-link set; other hard links continue to reference the unchanged original inode. + ## Development ```bash @@ -98,18 +100,26 @@ npm run test:coverage npm run benchmark ``` +### Supported matrix + +CI runs Node.js 22 on Ubuntu, Windows, and macOS, plus the Node.js 24 compatibility job on Ubuntu. Capability-sensitive symlink and permission assertions report a specific diagnostic when the host cannot provide that feature; unrelated filesystem and CRLF assertions continue to run. The thresholded coverage command enforces at least 85% line coverage and 75% branch coverage. + ## Documentation - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — components, control flow, and invariants. +- [`docs/BRANCH_PROTECTION.md`](docs/BRANCH_PROTECTION.md) — required review/check policy and read-only verification. +- [`docs/OPERATIONS.md`](docs/OPERATIONS.md) — classified errors, filesystem recovery, and safe escalation. - [`docs/EXAMPLES.md`](docs/EXAMPLES.md) — parsing, editing, recovery, and adapter examples. - [`docs/PERFORMANCE.md`](docs/PERFORMANCE.md) — reproducible hash baseline. - [`docs/PRODUCT.md`](docs/PRODUCT.md) — vision and success metrics. +- [`docs/RELEASING.md`](docs/RELEASING.md) — package verification, immutable tags, release creation, and recovery. - [`CONTRIBUTING.md`](CONTRIBUTING.md) — contributor workflow. - [`CHANGELOG.md`](CHANGELOG.md) — release history. +- [`SECURITY.md`](SECURITY.md) — supported versions, private reporting, trust boundaries, and security maintenance. ## Release workflow -Update `package.json` and `CHANGELOG.md`, merge validated changes, and create a matching `vX.Y.Z` tag. The release workflow verifies build/check/tests, dependency audit, and tag/version consistency. +Update `package.json` and add a matching version section to `CHANGELOG.md`, merge validated changes, and create a new immutable `vX.Y.Z` tag. The release workflow enforces coverage and audit gates, verifies provenance/license/package contents and tag/version/changelog consistency, builds an npm-format tarball, and creates one GitHub release with the tarball attached. See [`docs/RELEASING.md`](docs/RELEASING.md). ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b5a9d11 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security policy + +## Supported versions + +| Version | Supported | +|---|---| +| 0.1.x | Yes | +| Earlier or unreleased snapshots | No | + +Security fixes are released as new immutable patch versions. Support may move to the newest minor release after an announcement in the changelog and GitHub release notes. + +## Private reporting + +Use [GitHub private vulnerability reporting](https://github.com/T50-Systems/pi-anchor-edit-core/security/advisories/new). Do not open a public issue for a suspected vulnerability. If GitHub does not present a private report form, contact a T50 Systems maintainer privately through the organization’s established contact channel and include only enough metadata to arrange a secure transfer; do not fall back to a public issue. + +Never include credentials, access tokens, secrets, customer data, private source files, complete edited file contents, or stale-anchor diagnostic excerpts in a public issue, discussion, pull request, benchmark, or log. A safe initial report states the affected version, operating system, Node version, error prefix, and a minimal synthetic reproduction. Maintainers will request sensitive evidence through the private advisory. + +We aim to acknowledge a private report within 3 business days, provide an initial severity/triage decision within 7 business days, and send status updates at least every 14 days until remediation or closure. Coordinated disclosure timing is agreed with the reporter after a fix and supported release are ready. + +## Trust boundary + +`pi-anchor-edit-core` reads and mutates caller-selected local paths. The caller is responsible for authorization, path selection, backups, and preventing untrusted users from choosing sensitive targets. The filesystem adapter rejects symbolic links, special files, directories, images, binary/null-byte content, and invalid UTF-8 rewrites; it does not create a sandbox or establish that a path is safe to edit. + +Anchor diagnostics can quote nearby file content in `>>> LINE#HASH:content` retry lines. Treat all diagnostics as potentially sensitive. Redact or replace them with synthetic examples before sharing, and never send raw diagnostics to telemetry by default. + +See [`docs/OPERATIONS.md`](docs/OPERATIONS.md) for safe recovery actions and [`docs/RELEASING.md`](docs/RELEASING.md) for immutable release recovery. + +## Dependency and scanning maintenance + +Dependabot checks npm and GitHub Actions dependencies weekly. CodeQL scans pushes, pull requests, and a weekly schedule. The maintainer responsible for the next release reviews high-severity `npm audit`, Dependabot, and code-scanning findings at least weekly and before every release. Security-related dependency updates use the normal reviewed pull-request and required-check path; emergency fixes do not bypass validation. diff --git a/dist/src/file-kind.d.ts b/dist/src/file-kind.d.ts index b1884fc..9de46af 100644 --- a/dist/src/file-kind.d.ts +++ b/dist/src/file-kind.d.ts @@ -1,5 +1,7 @@ export type LoadedFile = { kind: 'directory'; +} | { + kind: 'symlink'; } | { kind: 'image'; mimeType: string; diff --git a/dist/src/file-kind.js b/dist/src/file-kind.js index 00f7bd7..3432080 100644 --- a/dist/src/file-kind.js +++ b/dist/src/file-kind.js @@ -1,4 +1,4 @@ -import { open as fsOpen, stat as fsStat } from 'node:fs/promises'; +import { lstat as fsLstat, open as fsOpen } from 'node:fs/promises'; import { fileTypeFromBuffer } from 'file-type'; const IMAGE_MIME_TYPES = new Set([ 'image/jpeg', @@ -19,7 +19,10 @@ function hasNullByte(buffer) { return buffer.includes(0); } export async function loadFileKindAndText(filePath) { - const pathStat = await fsStat(filePath); + const pathStat = await fsLstat(filePath); + if (pathStat.isSymbolicLink()) { + return { kind: 'symlink' }; + } if (pathStat.isDirectory()) { return { kind: 'directory' }; } @@ -44,7 +47,8 @@ export async function loadFileKindAndText(filePath) { if (hasNullByte(sample)) { return { kind: 'binary', description: 'null bytes detected' }; } - const decoder = new TextDecoder('utf-8'); + // Preserve a UTF-8 BOM as content so a read/edit round trip is byte-safe. + const decoder = new TextDecoder('utf-8', { ignoreBOM: true }); const fatalDecoder = new TextDecoder('utf-8', { fatal: true }); let hadUtf8DecodeErrors = false; const noteUtf8DecodeErrors = (chunk) => { diff --git a/dist/src/filesystem-client.d.ts b/dist/src/filesystem-client.d.ts index e363b2b..ad98244 100644 --- a/dist/src/filesystem-client.d.ts +++ b/dist/src/filesystem-client.d.ts @@ -1,5 +1,7 @@ import type { EditParams, PiClient, ReadParams } from './types.js'; export declare class FilesystemPiClient implements PiClient { + protected replaceTemporaryFile(temporaryPath: string, destinationPath: string): Promise; + private atomicWrite; read({ path, offset, limit }: ReadParams): Promise; edit({ path, edits }: EditParams): Promise; } diff --git a/dist/src/filesystem-client.js b/dist/src/filesystem-client.js index 8d16c11..ca112c2 100644 --- a/dist/src/filesystem-client.js +++ b/dist/src/filesystem-client.js @@ -1,6 +1,8 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; -import { formatAnchors, makeAnchor, parseAnchorLine } from './anchors.js'; +import { chmod, mkdir, open, rename, rm, stat } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { basename, dirname, join } from 'node:path'; +import { formatAnchors } from './anchors.js'; +import { loadFileKindAndText } from './file-kind.js'; import { applyHashlineEdits, resolveEditAnchors } from './hashline.js'; import { detectLineEnding, normalizeToLF, restoreLineEndings } from './text.js'; function splitLines(text) { @@ -8,111 +10,76 @@ function splitLines(text) { } async function loadText(path) { try { - return await readFile(path, 'utf8'); + const loaded = await loadFileKindAndText(path); + switch (loaded.kind) { + case 'text': + if (loaded.hadUtf8DecodeErrors) { + throw new Error(`[E_DECODE_LOSS] Refusing to rewrite ${path}: invalid UTF-8 would be replaced.`); + } + return { text: loaded.text, mode: (await stat(path)).mode & 0o7777 }; + case 'directory': + throw new Error(`[E_UNSUPPORTED_FILE] Refusing to read directory: ${path}`); + case 'symlink': + throw new Error(`[E_UNSUPPORTED_FILE] Refusing to follow symbolic link: ${path}`); + case 'image': + throw new Error(`[E_BINARY_FILE] Refusing to read image (${loaded.mimeType}): ${path}`); + case 'binary': + throw new Error(`[E_BINARY_FILE] Refusing to read binary file (${loaded.description}): ${path}`); + } } catch (error) { if (error.code === 'ENOENT') { - return ''; + return { text: '' }; } throw error; } } -function ensureAnchorMatches(lines, anchorRaw) { - const anchor = parseAnchorLine(anchorRaw); - if (!anchor) { - return { ok: false, message: `[E_INVALID_PATCH] Invalid anchor: ${anchorRaw}` }; - } - const index = anchor.lineNumber - 1; - const currentLine = lines[index]; - if (currentLine === undefined) { - return { - ok: false, - message: `[E_STALE_ANCHOR] Anchor no longer exists.\n>>> ${makeAnchor(Math.max(anchor.lineNumber, 1), '').raw}`, - }; - } - const currentAnchor = makeAnchor(anchor.lineNumber, currentLine); - if (currentAnchor.raw !== anchorRaw) { - return { - ok: false, - message: `[E_STALE_ANCHOR] Anchor changed.\n>>> ${currentAnchor.raw}`, - }; - } - return { ok: true, index }; -} -function applyReplaceText(text, oldText, newText) { - const parts = text.split(oldText); - if (parts.length !== 2) { - return '[E_INVALID_PATCH] replace_text requires one unique exact occurrence'; - } - return parts.join(newText); -} -function applySimpleFallback(lines, edit) { - const next = [...lines]; - const payload = edit.lines ?? []; - if (edit.op === 'append' && !edit.pos) - return [...next, ...payload]; - if (edit.op === 'prepend' && !edit.pos) - return [...payload, ...next]; - if (!edit.pos) - return `[E_INVALID_PATCH] ${edit.op} requires pos unless appending/prepending at file boundary`; - const start = ensureAnchorMatches(next, edit.pos); - if (!start.ok) - return start.message; - if (edit.op === 'append') { - next.splice(start.index + 1, 0, ...payload); - return next; - } - if (edit.op === 'prepend') { - next.splice(start.index, 0, ...payload); - return next; +export class FilesystemPiClient { + async replaceTemporaryFile(temporaryPath, destinationPath) { + await rename(temporaryPath, destinationPath); } - if (edit.end) { - const end = ensureAnchorMatches(next, edit.end); - if (!end.ok) - return end.message; - next.splice(start.index, end.index - start.index + 1, ...payload); - return next; + async atomicWrite(path, content, mode) { + const parent = dirname(path); + await mkdir(parent, { recursive: true }); + const temporaryPath = join(parent, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`); + let handle; + try { + handle = await open(temporaryPath, 'wx', mode ?? 0o666); + await handle.writeFile(content, 'utf8'); + await handle.sync(); + await handle.close(); + handle = undefined; + if (mode !== undefined) + await chmod(temporaryPath, mode); + await this.replaceTemporaryFile(temporaryPath, path); + } + finally { + await handle?.close().catch(() => undefined); + await rm(temporaryPath, { force: true }).catch(() => undefined); + } } - next.splice(start.index, 1, ...payload); - return next; -} -export class FilesystemPiClient { async read({ path, offset = 1, limit = 2000 }) { - const content = await loadText(path); + const { text: content } = await loadText(path); const normalized = normalizeToLF(content); const lines = splitLines(normalized); const slice = lines.slice(offset - 1, offset - 1 + limit); return formatAnchors(slice, offset); } async edit({ path, edits }) { - const raw = await loadText(path); + const { text: raw, mode } = await loadText(path); const ending = detectLineEnding(raw); let normalized = normalizeToLF(raw); - for (const edit of edits) { - if (edit.op === 'replace_text') { - const replaced = applyReplaceText(normalized, edit.oldText, edit.newText); - if (replaced.startsWith('[E_INVALID_PATCH]')) - return replaced; - normalized = replaced; - continue; - } - try { - const result = applyHashlineEdits(normalized, resolveEditAnchors([edit])); - normalized = result.content; - } - catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.startsWith('[E_STALE_ANCHOR]') || message.startsWith('[E_BAD_REF]') || message.startsWith('[E_RANGE_OOB]') || message.startsWith('[E_BAD_OP]') || message.startsWith('[E_EDIT_CONFLICT]') || message.startsWith('[E_NO_MATCH]') || message.startsWith('[E_MULTI_MATCH]') || message.startsWith('[E_WOULD_EMPTY]') || message.startsWith('[E_INVALID_PATCH]')) { - return message; - } - const fallback = applySimpleFallback(splitLines(normalized), edit); - if (typeof fallback === 'string') - return fallback; - normalized = fallback.join('\n'); - } + try { + const result = applyHashlineEdits(normalized, resolveEditAnchors(edits)); + normalized = result.content; + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith('[E_')) + return message; + throw error; } - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, restoreLineEndings(normalized, ending), 'utf8'); + await this.atomicWrite(path, restoreLineEndings(normalized, ending), mode); return formatAnchors(splitLines(normalized)); } } diff --git a/dist/test/filesystem-client.test.d.ts b/dist/test/filesystem-client.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/test/filesystem-client.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/test/filesystem-client.test.js b/dist/test/filesystem-client.test.js new file mode 100644 index 0000000..d7d6c43 --- /dev/null +++ b/dist/test/filesystem-client.test.js @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { chmod, link, lstat, mkdtemp, readFile, readdir, stat, symlink, writeFile, } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { FilesystemPiClient, loadFileKindAndText } from '../src/index.js'; +async function fixture(name = 'file.txt') { + const dir = await mkdtemp(join(tmpdir(), 'pi-anchor-edit-core-')); + return { dir, path: join(dir, name) }; +} +function secondAnchor(preview) { + const anchor = preview.split('\n')[1]; + assert.ok(anchor, 'expected a second anchor'); + return anchor; +} +class FailingReplacementClient extends FilesystemPiClient { + async replaceTemporaryFile() { + throw new Error('simulated replacement failure'); + } +} +test('classifies empty and ordinary UTF-8 text', async () => { + const { path } = await fixture(); + await writeFile(path, ''); + assert.deepEqual(await loadFileKindAndText(path), { kind: 'text', text: '' }); + await writeFile(path, 'plain text'); + assert.deepEqual(await loadFileKindAndText(path), { kind: 'text', text: 'plain text' }); +}); +test('classifies directories, images, null bytes, and invalid UTF-8', async () => { + const { dir, path } = await fixture(); + assert.deepEqual(await loadFileKindAndText(dir), { kind: 'directory' }); + await writeFile(path, Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex')); + assert.deepEqual(await loadFileKindAndText(path), { kind: 'image', mimeType: 'image/png' }); + await writeFile(path, Buffer.from([0x61, 0, 0x62])); + assert.deepEqual(await loadFileKindAndText(path), { kind: 'binary', description: 'null bytes detected' }); + await writeFile(path, Buffer.from([0x61, 0xc3, 0x28])); + const decoded = await loadFileKindAndText(path); + assert.equal(decoded.kind, 'text'); + assert.equal(decoded.kind === 'text' && decoded.hadUtf8DecodeErrors, true); +}); +test('rejects unsafe classifications without writing', async () => { + const client = new FilesystemPiClient(); + const { dir, path } = await fixture(); + await assert.rejects(() => client.read({ path: dir }), /E_UNSUPPORTED_FILE/); + for (const bytes of [Buffer.from([0x61, 0, 0x62]), Buffer.from([0x61, 0xc3, 0x28])]) { + await writeFile(path, bytes); + await assert.rejects(() => client.edit({ path, edits: [{ op: 'prepend', lines: ['unsafe'] }] }), /E_(?:BINARY_FILE|DECODE_LOSS)/); + assert.deepEqual(await readFile(path), bytes); + } +}); +test('creates a missing text file atomically', async () => { + const client = new FilesystemPiClient(); + const { path } = await fixture('nested/new.txt'); + assert.equal(await client.read({ path }), ''); + await client.edit({ path, edits: [{ op: 'prepend', lines: ['created'] }] }); + assert.equal(await readFile(path, 'utf8'), 'created'); +}); +test('preserves CRLF, UTF-8 BOM, and an existing permission mode', async (t) => { + const client = new FilesystemPiClient(); + const { path } = await fixture(); + await writeFile(path, '\uFEFFone\r\ntwo'); + const canAssertMode = process.platform !== 'win32'; + if (canAssertMode) + await chmod(path, 0o640); + const preview = await client.read({ path }); + await client.edit({ path, edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }] }); + assert.equal(await readFile(path, 'utf8'), '\uFEFFone\r\npatched'); + if (canAssertMode) { + assert.equal((await stat(path)).mode & 0o777, 0o640); + } + else { + t.diagnostic('permission-bit assertion unavailable on Windows; CRLF/BOM assertions still ran'); + } +}); +test('replacement failure leaves the original intact and removes the temporary file', async () => { + const client = new FailingReplacementClient(); + const { dir, path } = await fixture(); + await writeFile(path, 'one\ntwo'); + const preview = await client.read({ path }); + await assert.rejects(() => client.edit({ path, edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }] }), /simulated replacement failure/); + assert.equal(await readFile(path, 'utf8'), 'one\ntwo'); + assert.deepEqual((await readdir(dir)).filter((entry) => entry.includes('.tmp')), []); +}); +test('successful replacement leaves no temporary file', async () => { + const client = new FilesystemPiClient(); + const { dir, path } = await fixture(); + await writeFile(path, 'one\ntwo'); + const preview = await client.read({ path }); + await client.edit({ path, edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }] }); + assert.deepEqual((await readdir(dir)).filter((entry) => entry.includes('.tmp')), []); +}); +test('filesystem client supports every edit operation and returns classified failures', async () => { + const client = new FilesystemPiClient(); + const { path } = await fixture(); + await writeFile(path, 'one\ntwo\nthree'); + const page = await client.read({ path, offset: 2, limit: 1 }); + assert.match(page, /^2#/); + let preview = await client.read({ path }); + await client.edit({ path, edits: [{ op: 'append', pos: preview.split('\n')[0], lines: ['after-one'] }] }); + preview = await client.read({ path }); + await client.edit({ path, edits: [{ op: 'prepend', pos: preview.split('\n')[3], lines: ['before-three'] }] }); + await client.edit({ path, edits: [{ op: 'replace_text', oldText: 'after-one', newText: 'replaced' }] }); + assert.equal(await readFile(path, 'utf8'), 'one\nreplaced\ntwo\nbefore-three\nthree'); + assert.match(await client.edit({ path, edits: [{ op: 'replace_text', oldText: 'missing', newText: 'x' }] }), /^\[E_NO_MATCH\]/); + assert.match(await client.edit({ path, edits: [{ op: 'replace', pos: '1#ZZ:stale', lines: ['x'] }] }), /^\[E_STALE_ANCHOR\]/); +}); +test('filesystem client rejects detected images without writing', async () => { + const client = new FilesystemPiClient(); + const { path } = await fixture(); + const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'); + await writeFile(path, png); + await assert.rejects(() => client.read({ path }), /E_BINARY_FILE.*image\/png/); + assert.deepEqual(await readFile(path), png); +}); +test('symbolic links are rejected when the platform permits creating one', async (t) => { + const client = new FilesystemPiClient(); + const { dir, path: target } = await fixture('target.txt'); + const symbolicPath = join(dir, 'symbolic.txt'); + await writeFile(target, 'target'); + try { + await symlink(target, symbolicPath, 'file'); + } + catch (error) { + const code = error.code; + if (code === 'EPERM' || code === 'EACCES' || code === 'ENOSYS') { + t.skip(`symbolic-link capability unavailable: ${code}`); + return; + } + throw error; + } + assert.deepEqual(await loadFileKindAndText(symbolicPath), { kind: 'symlink' }); + await assert.rejects(() => client.read({ path: symbolicPath }), /E_UNSUPPORTED_FILE.*symbolic link/); + assert.equal(await readFile(target, 'utf8'), 'target'); +}); +test('atomic editing breaks only the selected hard link', async (t) => { + const client = new FilesystemPiClient(); + const { dir, path } = await fixture(); + const alias = join(dir, 'alias.txt'); + await writeFile(path, 'one\ntwo'); + try { + await link(path, alias); + } + catch (error) { + const code = error.code; + if (code === 'EPERM' || code === 'EACCES' || code === 'ENOSYS' || code === 'EXDEV') { + t.skip(`hard-link capability unavailable: ${code}`); + return; + } + throw error; + } + const before = await lstat(path); + assert.equal(before.nlink >= 2, true); + const preview = await client.read({ path }); + await client.edit({ path, edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }] }); + assert.equal(await readFile(path, 'utf8'), 'one\npatched'); + assert.equal(await readFile(alias, 'utf8'), 'one\ntwo'); + assert.notEqual((await lstat(path)).ino, (await lstat(alias)).ino); + assert.equal(basename(path), 'file.txt'); +}); diff --git a/dist/test/hashline-errors.test.d.ts b/dist/test/hashline-errors.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/test/hashline-errors.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/test/hashline-errors.test.js b/dist/test/hashline-errors.test.js new file mode 100644 index 0000000..f7a335e --- /dev/null +++ b/dist/test/hashline-errors.test.js @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyHashlineEdits, computeLineHash, detectLineEnding, normalizeToLF, resolveEditAnchors, restoreLineEndings, stripBom, } from '../src/index.js'; +function ref(line, content) { + return `${line}#${computeLineHash(line, content)}:${content}`; +} +function apply(content, edits) { + return applyHashlineEdits(content, resolveEditAnchors(edits)); +} +test('replace supports single lines and ranges and rejects invalid ranges', () => { + assert.equal(apply('a\nb\nc', [{ op: 'replace', pos: ref(2, 'b'), lines: ['B'] }]).content, 'a\nB\nc'); + assert.equal(apply('a\nb\nc', [{ op: 'replace', pos: ref(1, 'a'), end: ref(2, 'b'), lines: ['AB'] }]).content, 'AB\nc'); + assert.throws(() => apply('a\nb', [{ op: 'replace', pos: ref(2, 'b'), end: ref(1, 'a'), lines: ['x'] }]), /E_BAD_OP/); + assert.throws(() => apply('a', [{ op: 'replace', pos: `2#${computeLineHash(2, 'x')}`, lines: ['x'] }]), /E_RANGE_OOB/); +}); +test('append and prepend support anchored and boundary forms with failures', () => { + assert.equal(apply('a', [{ op: 'append', lines: ['z'] }]).content, 'a\nz'); + assert.equal(apply('a', [{ op: 'prepend', lines: ['z'] }]).content, 'z\na'); + assert.equal(apply('a\nb', [{ op: 'append', pos: ref(1, 'a'), lines: ['x'] }]).content, 'a\nx\nb'); + assert.equal(apply('a\nb', [{ op: 'prepend', pos: ref(2, 'b'), lines: ['x'] }]).content, 'a\nx\nb'); + assert.throws(() => apply('a', [{ op: 'append', lines: [] }]), /E_BAD_OP/); + assert.throws(() => apply('a', [{ op: 'prepend', lines: [] }]), /E_BAD_OP/); +}); +test('replace_text classifies no-match, multi-match, and empty search failures', () => { + assert.equal(apply('alpha beta', [{ op: 'replace_text', oldText: 'beta', newText: 'gamma' }]).content, 'alpha gamma'); + assert.throws(() => apply('alpha', [{ op: 'replace_text', oldText: 'missing', newText: 'x' }]), /E_NO_MATCH/); + assert.throws(() => apply('alpha alpha', [{ op: 'replace_text', oldText: 'alpha', newText: 'x' }]), /E_MULTI_MATCH/); + assert.throws(() => apply('alpha', [{ op: 'replace_text', oldText: '', newText: 'x' }]), /E_BAD_OP/); +}); +test('rejects malformed anchors, stale anchors, conflicts, unsafe payloads, and emptying', () => { + assert.throws(() => resolveEditAnchors([{ op: 'replace', pos: '1', lines: ['x'] }]), /E_BAD_REF/); + assert.throws(() => apply('a', [{ op: 'replace', pos: '1#ZZ:a', lines: ['x'] }]), /E_STALE_ANCHOR/); + assert.throws(() => apply('a\nb', [ + { op: 'replace', pos: ref(1, 'a'), end: ref(2, 'b'), lines: ['x'] }, + { op: 'replace', pos: ref(2, 'b'), lines: ['y'] }, + ]), /E_EDIT_CONFLICT/); + assert.throws(() => resolveEditAnchors([{ op: 'replace', pos: ref(1, 'a'), lines: ['1#ZZ:copied'] }]), /E_INVALID_PATCH/); + assert.throws(() => apply('a', [{ op: 'replace', pos: ref(1, 'a'), lines: [] }]), /E_WOULD_EMPTY/); +}); +test('validates operation shapes and anchor hash syntax', () => { + assert.throws(() => resolveEditAnchors([{ op: 'unknown' }]), /E_BAD_OP/); + assert.throws(() => resolveEditAnchors([{ op: 'replace', pos: '1#A', lines: ['x'] }]), /E_BAD_REF/); + assert.throws(() => resolveEditAnchors([{ op: 'replace', pos: '0#ZZ', lines: ['x'] }]), /E_BAD_REF/); + assert.throws(() => resolveEditAnchors([{ op: 'replace', pos: '1#12', lines: ['x'] }]), /E_BAD_REF/); + assert.throws(() => resolveEditAnchors([{ op: 'append', end: ref(1, 'a'), lines: ['x'] }]), /E_BAD_OP/); +}); +test('text helpers preserve their documented normalization behavior', () => { + assert.equal(detectLineEnding('a\r\nb'), '\r\n'); + assert.equal(detectLineEnding('a\nb'), '\n'); + assert.equal(normalizeToLF('a\r\nb\rc'), 'a\nb\nc'); + assert.equal(restoreLineEndings('a\nb', '\r\n'), 'a\r\nb'); + assert.deepEqual(stripBom('\uFEFFtext'), { bom: '\uFEFF', text: 'text' }); + assert.deepEqual(stripBom('text'), { bom: '', text: 'text' }); +}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 08bf5e4..44ffd3a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,7 +18,7 @@ 3. The core parses references and verifies current line hashes/text hints. 4. Operations are validated for overlap, uniqueness, and invalid rendered prefixes. 5. The engine produces new content or throws a classified actionable error. -6. The filesystem adapter preserves newline behavior and writes the result. +6. The filesystem adapter classifies the path before decoding, preserves newline/BOM/mode behavior, and atomically replaces the selected directory entry. ## Invariants @@ -27,3 +27,6 @@ - Edit payloads contain literal file content, not rendered `LINE#HASH:` prefixes. - Pure transformation code remains independent from filesystem access. - Public exports flow through `src/index.ts` and compiled `dist` declarations. +- Symbolic links are rejected rather than followed. Atomic replacement of a hard-linked path changes only that path; sibling links retain the prior inode and content. +- Binary, image, special-file, null-byte, and decode-loss inputs are rejected before any write. +- Temporary files are created beside the destination so replacement stays on the same filesystem, and are removed after success or handled failure. diff --git a/docs/BRANCH_PROTECTION.md b/docs/BRANCH_PROTECTION.md new file mode 100644 index 0000000..caf5e11 --- /dev/null +++ b/docs/BRANCH_PROTECTION.md @@ -0,0 +1,31 @@ +# Main branch protection policy + +Repository administrators should configure `main` with all of the following controls: + +- require a pull request before merging, including administrators; +- require at least one approving review and dismissal of stale approvals after new commits; +- require conversation resolution and branches to be up to date before merge; +- require these exact stable CI contexts: + - `ubuntu-latest / Node 22` + - `windows-latest / Node 22` + - `macos-latest / Node 22` + - `ubuntu-latest / Node 24` +- prohibit force pushes and branch deletion; and +- apply enforcement to administrators. + +A failing or missing required check blocks merge. After changing workflow job names, first observe the new contexts on a pull request, then update protection in a reviewed administrative change so protection never silently points at nonexistent checks. + +## Read-only verification + +Administrators can inspect the effective configuration without changing it: + +```bash +gh api repos/T50-Systems/pi-anchor-edit-core/branches/main/protection \ + --jq '{required_status_checks,required_pull_request_reviews,enforce_admins,allow_force_pushes,allow_deletions}' +``` + +Verification passes only when pull-request reviews are non-null, `strict` is true, required contexts are non-empty and exactly match the supported CI jobs, `enforce_admins.enabled` is true, and force-push/deletion flags are false. + +## Emergency and release procedure + +Urgency does not authorize a direct push or skipped validation. Prepare a narrowly scoped branch, obtain the required review, run every required check, and merge the up-to-date pull request. Releases are created from the validated `main` commit under [`RELEASING.md`](RELEASING.md). If GitHub Actions is unavailable, wait for service recovery or document an explicit owner-approved temporary ruleset change in a public audit trail; restore and verify the policy before any further merge. Never move an existing release tag. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..1712c64 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,41 @@ +# Operations and recovery + +## Before editing + +Authorize the caller-selected path, keep a recoverable copy when content is important, read immediately before editing, and use only anchors from that read. Do not place secrets or private file content in bug reports. Use synthetic fixtures when reproducing failures. + +The filesystem adapter performs same-directory temporary-file replacement. A handled failure removes its temporary file; the original path remains unchanged when replacement fails. Symbolic links are rejected. Editing one name in a hard-link set replaces only that directory entry, so sibling hard links retain the old inode and content. + +## Classified errors + +| Error prefix | Meaning | Safe caller action | +|---|---|---| +| `[E_STALE_ANCHOR]` | Observed content changed. The diagnostic may contain nearby source lines. | Keep the diagnostic private, re-read the file, copy the current `>>>` anchors verbatim, reassess intent, and retry once with current anchors. | +| `[E_BAD_REF]` | An anchor is malformed, out of format, or has an invalid hash. | Re-read and copy the complete `LINE#HASH:content` value. Never construct or repair an anchor manually. | +| `[E_RANGE_OOB]` | An anchor points outside the current file. | Re-read, verify that the intended range still exists, and create a new request. | +| `[E_BAD_OP]` | The operation or its required fields are invalid. | Correct the request shape; do not retry unchanged. | +| `[E_EDIT_CONFLICT]` | Edits in one request overlap or otherwise conflict. | Merge the intent into one non-overlapping edit or split it into sequential read/edit cycles. | +| `[E_NO_MATCH]` | `replace_text` found no exact match. | Re-read and use a narrower current exact value or anchored edit. | +| `[E_MULTI_MATCH]` | `replace_text` is ambiguous or overlaps. | Use anchored edits or provide an exact value that occurs once. | +| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file. | Stop and require an explicit, separately reviewed whole-file deletion/write path if emptying is intended. | +| `[E_INVALID_PATCH]` | Payload content includes rendered anchors/diff markers or otherwise violates literal-content rules. | Remove display prefixes and submit only literal replacement lines. | +| `[E_BINARY_FILE]` | The classifier detected an image, binary MIME type, or null bytes. | Do not edit with this library. Select a format-aware binary tool and preserve the original. | +| `[E_DECODE_LOSS]` | UTF-8 decoding would replace invalid bytes. | Do not rewrite. Determine the real encoding and use an encoding-aware conversion with an explicit backup. | +| `[E_UNSUPPORTED_FILE]` | The path is a directory, symbolic link, or unsupported special file. | Resolve and authorize a regular-file path explicitly; do not weaken the classifier or follow the link implicitly. | + +`Operation aborted` means the supplied abort signal was already cancelled; leave content unchanged, determine whether the caller still wants the operation, then re-read before retrying. + +## Filesystem failures + +Node filesystem errors such as `EACCES`, `EPERM`, `EROFS`, `ENOSPC`, `EMFILE`, and unexpected `ENOENT` are propagated. Do not elevate privileges automatically. Confirm directory authorization and available space, preserve the original, inspect the same directory for a `.filename...tmp` residue after an unhandled process termination, and remove a residue only after confirming no live process owns it. A missing path is treated as empty for an intentional prepend/append creation; callers must distinguish intentional creation from a misspelled path. + +## Escalation data + +A safe report contains: + +- package version, Node version, operating system, and filesystem type; +- the error prefix or Node error code; +- whether the destination existed and whether link capability was involved; and +- a minimal synthetic fixture with no proprietary text. + +Never attach real credentials, tokens, private paths, customer data, complete file content, or unredacted stale-anchor output. Report suspected security impact only through the private route in [`SECURITY.md`](../SECURITY.md). diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..9467381 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,25 @@ +# Release and recovery + +## Preconditions + +A release is built only from a reviewed commit already merged to protected `main`. Before proposing a tag: + +1. move the release notes from `[Unreleased]` to a level-two section whose heading exactly matches the `package.json` version (for example, `## 0.2.0` or `## [0.2.0]`); +2. run `npm ci`, `npm run check`, `npm run test:coverage`, `npm audit --audit-level=high`, and `npm run verify:package`; +3. confirm the required Ubuntu, Windows, macOS, and Node 24 compatibility checks passed on the merge commit; and +4. create an annotated `vX.Y.Z` tag that exactly matches the package version, then push that new tag once. + +The tag workflow repeats validation, extracts notes from the matching changelog section, creates the npm-format tarball from the tagged commit, retains it as a workflow artifact, and creates one GitHub release with that tarball attached. It does not publish to the npm registry. + +## Immutable tags and releases + +Never move, force-push, delete, or reuse a released tag. The workflow refuses to overwrite an existing GitHub release. If a tag was created from the wrong commit but has not been pushed, delete the local tag and recreate it. Once pushed, treat the tag as immutable and prepare a new patch version instead. + +## Recovery + +- **Validation fails before release creation:** correct the source through a new reviewed pull request, increment the package version, add a matching changelog section, and create a new tag. +- **Artifact upload fails:** rerun the failed workflow job for the same immutable commit only if no GitHub release was created. If state is uncertain, inspect the Actions run and `gh release view vX.Y.Z` before retrying. +- **Release creation fails after artifact upload:** do not rewrite the tag. Correct permissions or transient service problems and rerun against the same tagged commit after confirming no release exists. +- **A defective release is already public:** preserve the release and tag as evidence, mark the release notes as affected if necessary, and ship a corrected patch version. Do not silently replace its attached tarball. + +Emergency changes follow the same pull-request and validation path. Administrators must not bypass required checks; an urgent correction is a narrowly scoped reviewed pull request followed by a new immutable patch release. diff --git a/package-lock.json b/package-lock.json index c102756..16e3aa0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,9 @@ "@types/node": "^24.3.0", "@types/xxhashjs": "^0.2.4", "typescript": "^5.9.2" + }, + "engines": { + "node": ">=22" } }, "node_modules/@borewit/text-codec": { diff --git a/package.json b/package.json index b946391..93b08e8 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,23 @@ "name": "pi-anchor-edit-core", "version": "0.1.0", "description": "Shared anchor-based edit core for Pi packages", + "keywords": [ + "pi", + "anchor-editing", + "hashline", + "atomic-write" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/T50-Systems/pi-anchor-edit-core.git" + }, + "homepage": "https://github.com/T50-Systems/pi-anchor-edit-core#readme", + "bugs": { + "url": "https://github.com/T50-Systems/pi-anchor-edit-core/issues" + }, + "engines": { + "node": ">=22" + }, "type": "module", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -12,7 +29,7 @@ } }, "files": [ - "dist", + "dist/src", "docs", "README.md", "CONTRIBUTING.md", @@ -23,8 +40,10 @@ "build": "tsc -p tsconfig.json", "check": "tsc -p tsconfig.json --noEmit", "test": "node --test dist/test/**/*.test.js", - "test:coverage": "npm run build && node --experimental-test-coverage --test dist/test/**/*.test.js", + "test:coverage": "npm run build && node --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=75 --test dist/test/**/*.test.js", "benchmark": "npm run build && node benchmarks/core.mjs", + "verify:package": "npm run build && node scripts/verify-package.mjs", + "release:notes": "node scripts/extract-release-notes.mjs", "pretest": "npm run build" }, "dependencies": { diff --git a/scripts/extract-release-notes.mjs b/scripts/extract-release-notes.mjs new file mode 100644 index 0000000..f62a5ba --- /dev/null +++ b/scripts/extract-release-notes.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); +const changelog = (await readFile(new URL('../CHANGELOG.md', import.meta.url), 'utf8')).replaceAll('\r\n', '\n'); +const version = packageJson.version; +const lines = changelog.split('\n'); +const headingIndex = lines.findIndex((line) => line === `## ${version}` || line === `## [${version}]`); +assert.notEqual(headingIndex, -1, `CHANGELOG.md must contain a level-two section for ${version}`); +const nextHeadingOffset = lines.slice(headingIndex + 1).findIndex((line) => line.startsWith('## ')); +const end = nextHeadingOffset === -1 ? lines.length : headingIndex + 1 + nextHeadingOffset; +const notes = lines.slice(headingIndex + 1, end).join('\n').trim(); +assert.ok(notes.length > 0, `CHANGELOG.md section ${version} must contain release notes`); +process.stdout.write(`${notes}\n`); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..5adf326 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; + +const canonicalLicense = `MIT License + +Copyright (c) 2026 T50 Systems + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +`; + +const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); +const license = await readFile(new URL('../LICENSE', import.meta.url), 'utf8'); +assert.equal(license.replaceAll('\r\n', '\n'), canonicalLicense, 'LICENSE must match the canonical MIT text and copyright line'); + +assert.equal(packageJson.license, 'MIT'); +assert.deepEqual(packageJson.repository, { + type: 'git', + url: 'git+https://github.com/T50-Systems/pi-anchor-edit-core.git', +}); +assert.equal(packageJson.homepage, 'https://github.com/T50-Systems/pi-anchor-edit-core#readme'); +assert.deepEqual(packageJson.bugs, { url: 'https://github.com/T50-Systems/pi-anchor-edit-core/issues' }); +assert.ok(Array.isArray(packageJson.keywords) && packageJson.keywords.length >= 3, 'package keywords are required'); +assert.equal(packageJson.engines?.node, '>=22'); + +const releaseTag = process.env.RELEASE_TAG; +if (releaseTag !== undefined) { + assert.equal(releaseTag, `v${packageJson.version}`, `tag ${releaseTag} must match package version v${packageJson.version}`); +} + +const npmCli = process.env.npm_execpath; +assert.ok(npmCli, 'npm_execpath is required; run verification through npm run verify:package'); +const packed = spawnSync(process.execPath, [npmCli, 'pack', '--dry-run', '--json'], { + cwd: new URL('..', import.meta.url), + encoding: 'utf8', +}); +if (packed.status !== 0) { + throw new Error(`npm pack --dry-run failed:\n${packed.stderr || packed.stdout}`); +} +const report = JSON.parse(packed.stdout); +const files = new Set(report[0]?.files?.map(({ path }) => path)); +for (const required of ['LICENSE', 'README.md', 'package.json', 'dist/src/index.js', 'dist/src/index.d.ts']) { + assert.ok(files.has(required), `package tarball is missing ${required}`); +} +for (const path of files) { + assert.ok(!path.startsWith('test/') && !path.startsWith('dist/test/'), `package tarball must exclude tests: ${path}`); + assert.ok(!/(^|\/)(?:\.env|auth\.json|credentials?)(?:\.|$)/i.test(path), `package tarball contains sensitive local file: ${path}`); +} + +console.log(`Verified package metadata, canonical MIT license, and ${files.size} packed files.`); diff --git a/src/file-kind.ts b/src/file-kind.ts index 93f1144..dd2e823 100644 --- a/src/file-kind.ts +++ b/src/file-kind.ts @@ -1,4 +1,4 @@ -import { open as fsOpen, stat as fsStat } from 'node:fs/promises'; +import { lstat as fsLstat, open as fsOpen } from 'node:fs/promises'; import { fileTypeFromBuffer } from 'file-type'; const IMAGE_MIME_TYPES = new Set([ @@ -22,6 +22,7 @@ const FILE_TYPE_SNIFF_BYTES = 8192; export type LoadedFile = | { kind: 'directory' } + | { kind: 'symlink' } | { kind: 'image'; mimeType: string } | { kind: 'text'; text: string; hadUtf8DecodeErrors?: true } | { kind: 'binary'; description: string }; @@ -31,7 +32,10 @@ function hasNullByte(buffer: Uint8Array): boolean { } export async function loadFileKindAndText(filePath: string): Promise { - const pathStat = await fsStat(filePath); + const pathStat = await fsLstat(filePath); + if (pathStat.isSymbolicLink()) { + return { kind: 'symlink' }; + } if (pathStat.isDirectory()) { return { kind: 'directory' }; } @@ -60,7 +64,8 @@ export async function loadFileKindAndText(filePath: string): Promise return { kind: 'binary', description: 'null bytes detected' }; } - const decoder = new TextDecoder('utf-8'); + // Preserve a UTF-8 BOM as content so a read/edit round trip is byte-safe. + const decoder = new TextDecoder('utf-8', { ignoreBOM: true }); const fatalDecoder = new TextDecoder('utf-8', { fatal: true }); let hadUtf8DecodeErrors = false; const noteUtf8DecodeErrors = (chunk?: Uint8Array): void => { diff --git a/src/filesystem-client.ts b/src/filesystem-client.ts index b5ca0e6..875d770 100644 --- a/src/filesystem-client.ts +++ b/src/filesystem-client.ts @@ -1,94 +1,72 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; -import { formatAnchors, makeAnchor, parseAnchorLine } from './anchors.js'; +import { chmod, mkdir, open, rename, rm, stat } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { basename, dirname, join } from 'node:path'; +import { formatAnchors } from './anchors.js'; +import { loadFileKindAndText } from './file-kind.js'; import { applyHashlineEdits, resolveEditAnchors, type HashlineToolEdit } from './hashline.js'; import { detectLineEnding, normalizeToLF, restoreLineEndings } from './text.js'; -import type { EditParams, PiClient, ReadParams, ReplaceLikeEditOp } from './types.js'; +import type { EditParams, PiClient, ReadParams } from './types.js'; function splitLines(text: string): string[] { return text.length === 0 ? [] : text.split(/\r?\n/); } -async function loadText(path: string): Promise { +type LoadedText = { text: string; mode?: number }; + +async function loadText(path: string): Promise { try { - return await readFile(path, 'utf8'); + const loaded = await loadFileKindAndText(path); + switch (loaded.kind) { + case 'text': + if (loaded.hadUtf8DecodeErrors) { + throw new Error(`[E_DECODE_LOSS] Refusing to rewrite ${path}: invalid UTF-8 would be replaced.`); + } + return { text: loaded.text, mode: (await stat(path)).mode & 0o7777 }; + case 'directory': + throw new Error(`[E_UNSUPPORTED_FILE] Refusing to read directory: ${path}`); + case 'symlink': + throw new Error(`[E_UNSUPPORTED_FILE] Refusing to follow symbolic link: ${path}`); + case 'image': + throw new Error(`[E_BINARY_FILE] Refusing to read image (${loaded.mimeType}): ${path}`); + case 'binary': + throw new Error(`[E_BINARY_FILE] Refusing to read binary file (${loaded.description}): ${path}`); + } } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return ''; + return { text: '' }; } throw error; } } -function ensureAnchorMatches(lines: string[], anchorRaw: string): { ok: true; index: number } | { ok: false; message: string } { - const anchor = parseAnchorLine(anchorRaw); - if (!anchor) { - return { ok: false, message: `[E_INVALID_PATCH] Invalid anchor: ${anchorRaw}` }; - } - - const index = anchor.lineNumber - 1; - const currentLine = lines[index]; - if (currentLine === undefined) { - return { - ok: false, - message: `[E_STALE_ANCHOR] Anchor no longer exists.\n>>> ${makeAnchor(Math.max(anchor.lineNumber, 1), '').raw}`, - }; - } - - const currentAnchor = makeAnchor(anchor.lineNumber, currentLine); - if (currentAnchor.raw !== anchorRaw) { - return { - ok: false, - message: `[E_STALE_ANCHOR] Anchor changed.\n>>> ${currentAnchor.raw}`, - }; - } - - return { ok: true, index }; -} - -function applyReplaceText(text: string, oldText: string, newText: string): string { - const parts = text.split(oldText); - if (parts.length !== 2) { - return '[E_INVALID_PATCH] replace_text requires one unique exact occurrence'; - } - return parts.join(newText); -} - -function applySimpleFallback(lines: string[], edit: ReplaceLikeEditOp): string[] | string { - const next = [...lines]; - const payload = edit.lines ?? []; - - if (edit.op === 'append' && !edit.pos) return [...next, ...payload]; - if (edit.op === 'prepend' && !edit.pos) return [...payload, ...next]; - if (!edit.pos) return `[E_INVALID_PATCH] ${edit.op} requires pos unless appending/prepending at file boundary`; - const start = ensureAnchorMatches(next, edit.pos); - if (!start.ok) return start.message; - - if (edit.op === 'append') { - next.splice(start.index + 1, 0, ...payload); - return next; - } - - if (edit.op === 'prepend') { - next.splice(start.index, 0, ...payload); - return next; +export class FilesystemPiClient implements PiClient { + protected async replaceTemporaryFile(temporaryPath: string, destinationPath: string): Promise { + await rename(temporaryPath, destinationPath); } - if (edit.end) { - const end = ensureAnchorMatches(next, edit.end); - if (!end.ok) return end.message; - next.splice(start.index, end.index - start.index + 1, ...payload); - return next; + private async atomicWrite(path: string, content: string, mode?: number): Promise { + const parent = dirname(path); + await mkdir(parent, { recursive: true }); + const temporaryPath = join(parent, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`); + let handle: Awaited> | undefined; + + try { + handle = await open(temporaryPath, 'wx', mode ?? 0o666); + await handle.writeFile(content, 'utf8'); + await handle.sync(); + await handle.close(); + handle = undefined; + if (mode !== undefined) await chmod(temporaryPath, mode); + await this.replaceTemporaryFile(temporaryPath, path); + } finally { + await handle?.close().catch(() => undefined); + await rm(temporaryPath, { force: true }).catch(() => undefined); + } } - next.splice(start.index, 1, ...payload); - return next; -} - -export class FilesystemPiClient implements PiClient { async read({ path, offset = 1, limit = 2000 }: ReadParams): Promise { - const content = await loadText(path); + const { text: content } = await loadText(path); const normalized = normalizeToLF(content); const lines = splitLines(normalized); const slice = lines.slice(offset - 1, offset - 1 + limit); @@ -96,35 +74,23 @@ export class FilesystemPiClient implements PiClient { } async edit({ path, edits }: EditParams): Promise { - const raw = await loadText(path); + const { text: raw, mode } = await loadText(path); const ending = detectLineEnding(raw); let normalized = normalizeToLF(raw); - for (const edit of edits) { - if (edit.op === 'replace_text') { - const replaced = applyReplaceText(normalized, edit.oldText, edit.newText); - if (replaced.startsWith('[E_INVALID_PATCH]')) return replaced; - normalized = replaced; - continue; - } - - try { - const result = applyHashlineEdits(normalized, resolveEditAnchors([edit as HashlineToolEdit])); - normalized = result.content; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.startsWith('[E_STALE_ANCHOR]') || message.startsWith('[E_BAD_REF]') || message.startsWith('[E_RANGE_OOB]') || message.startsWith('[E_BAD_OP]') || message.startsWith('[E_EDIT_CONFLICT]') || message.startsWith('[E_NO_MATCH]') || message.startsWith('[E_MULTI_MATCH]') || message.startsWith('[E_WOULD_EMPTY]') || message.startsWith('[E_INVALID_PATCH]')) { - return message; - } - - const fallback = applySimpleFallback(splitLines(normalized), edit); - if (typeof fallback === 'string') return fallback; - normalized = fallback.join('\n'); - } + try { + const result = applyHashlineEdits( + normalized, + resolveEditAnchors(edits as HashlineToolEdit[]), + ); + normalized = result.content; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith('[E_')) return message; + throw error; } - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, restoreLineEndings(normalized, ending), 'utf8'); + await this.atomicWrite(path, restoreLineEndings(normalized, ending), mode); return formatAnchors(splitLines(normalized)); } } diff --git a/test/filesystem-client.test.ts b/test/filesystem-client.test.ts new file mode 100644 index 0000000..55038ac --- /dev/null +++ b/test/filesystem-client.test.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + chmod, + link, + lstat, + mkdtemp, + readFile, + readdir, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { FilesystemPiClient, loadFileKindAndText } from '../src/index.js'; + +async function fixture(name = 'file.txt'): Promise<{ dir: string; path: string }> { + const dir = await mkdtemp(join(tmpdir(), 'pi-anchor-edit-core-')); + return { dir, path: join(dir, name) }; +} + +function secondAnchor(preview: string): string { + const anchor = preview.split('\n')[1]; + assert.ok(anchor, 'expected a second anchor'); + return anchor; +} + +class FailingReplacementClient extends FilesystemPiClient { + protected override async replaceTemporaryFile(): Promise { + throw new Error('simulated replacement failure'); + } +} + +test('classifies empty and ordinary UTF-8 text', async () => { + const { path } = await fixture(); + await writeFile(path, ''); + assert.deepEqual(await loadFileKindAndText(path), { kind: 'text', text: '' }); + await writeFile(path, 'plain text'); + assert.deepEqual(await loadFileKindAndText(path), { kind: 'text', text: 'plain text' }); +}); + +test('classifies directories, images, null bytes, and invalid UTF-8', async () => { + const { dir, path } = await fixture(); + assert.deepEqual(await loadFileKindAndText(dir), { kind: 'directory' }); + + await writeFile(path, Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex')); + assert.deepEqual(await loadFileKindAndText(path), { kind: 'image', mimeType: 'image/png' }); + + await writeFile(path, Buffer.from([0x61, 0, 0x62])); + assert.deepEqual(await loadFileKindAndText(path), { kind: 'binary', description: 'null bytes detected' }); + + await writeFile(path, Buffer.from([0x61, 0xc3, 0x28])); + const decoded = await loadFileKindAndText(path); + assert.equal(decoded.kind, 'text'); + assert.equal(decoded.kind === 'text' && decoded.hadUtf8DecodeErrors, true); +}); + +test('rejects unsafe classifications without writing', async () => { + const client = new FilesystemPiClient(); + const { dir, path } = await fixture(); + await assert.rejects(() => client.read({ path: dir }), /E_UNSUPPORTED_FILE/); + + for (const bytes of [Buffer.from([0x61, 0, 0x62]), Buffer.from([0x61, 0xc3, 0x28])]) { + await writeFile(path, bytes); + await assert.rejects( + () => client.edit({ path, edits: [{ op: 'prepend', lines: ['unsafe'] }] }), + /E_(?:BINARY_FILE|DECODE_LOSS)/, + ); + assert.deepEqual(await readFile(path), bytes); + } +}); + +test('creates a missing text file atomically', async () => { + const client = new FilesystemPiClient(); + const { path } = await fixture('nested/new.txt'); + assert.equal(await client.read({ path }), ''); + await client.edit({ path, edits: [{ op: 'prepend', lines: ['created'] }] }); + assert.equal(await readFile(path, 'utf8'), 'created'); +}); + +test('preserves CRLF, UTF-8 BOM, and an existing permission mode', async (t) => { + const client = new FilesystemPiClient(); + const { path } = await fixture(); + await writeFile(path, '\uFEFFone\r\ntwo'); + const canAssertMode = process.platform !== 'win32'; + if (canAssertMode) await chmod(path, 0o640); + + const preview = await client.read({ path }); + await client.edit({ path, edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }] }); + + assert.equal(await readFile(path, 'utf8'), '\uFEFFone\r\npatched'); + if (canAssertMode) { + assert.equal((await stat(path)).mode & 0o777, 0o640); + } else { + t.diagnostic('permission-bit assertion unavailable on Windows; CRLF/BOM assertions still ran'); + } +}); + +test('replacement failure leaves the original intact and removes the temporary file', async () => { + const client = new FailingReplacementClient(); + const { dir, path } = await fixture(); + await writeFile(path, 'one\ntwo'); + const preview = await client.read({ path }); + + await assert.rejects( + () => client.edit({ path, edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }] }), + /simulated replacement failure/, + ); + + assert.equal(await readFile(path, 'utf8'), 'one\ntwo'); + assert.deepEqual((await readdir(dir)).filter((entry) => entry.includes('.tmp')), []); +}); + +test('successful replacement leaves no temporary file', async () => { + const client = new FilesystemPiClient(); + const { dir, path } = await fixture(); + await writeFile(path, 'one\ntwo'); + const preview = await client.read({ path }); + await client.edit({ path, edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }] }); + assert.deepEqual((await readdir(dir)).filter((entry) => entry.includes('.tmp')), []); +}); + +test('filesystem client supports every edit operation and returns classified failures', async () => { + const client = new FilesystemPiClient(); + const { path } = await fixture(); + await writeFile(path, 'one\ntwo\nthree'); + + const page = await client.read({ path, offset: 2, limit: 1 }); + assert.match(page, /^2#/); + + let preview = await client.read({ path }); + await client.edit({ path, edits: [{ op: 'append', pos: preview.split('\n')[0], lines: ['after-one'] }] }); + preview = await client.read({ path }); + await client.edit({ path, edits: [{ op: 'prepend', pos: preview.split('\n')[3], lines: ['before-three'] }] }); + await client.edit({ path, edits: [{ op: 'replace_text', oldText: 'after-one', newText: 'replaced' }] }); + assert.equal(await readFile(path, 'utf8'), 'one\nreplaced\ntwo\nbefore-three\nthree'); + + assert.match( + await client.edit({ path, edits: [{ op: 'replace_text', oldText: 'missing', newText: 'x' }] }), + /^\[E_NO_MATCH\]/, + ); + assert.match( + await client.edit({ path, edits: [{ op: 'replace', pos: '1#ZZ:stale', lines: ['x'] }] }), + /^\[E_STALE_ANCHOR\]/, + ); +}); + +test('filesystem client rejects detected images without writing', async () => { + const client = new FilesystemPiClient(); + const { path } = await fixture(); + const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'); + await writeFile(path, png); + await assert.rejects(() => client.read({ path }), /E_BINARY_FILE.*image\/png/); + assert.deepEqual(await readFile(path), png); +}); + +test('symbolic links are rejected when the platform permits creating one', async (t) => { + const client = new FilesystemPiClient(); + const { dir, path: target } = await fixture('target.txt'); + const symbolicPath = join(dir, 'symbolic.txt'); + await writeFile(target, 'target'); + try { + await symlink(target, symbolicPath, 'file'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES' || code === 'ENOSYS') { + t.skip(`symbolic-link capability unavailable: ${code}`); + return; + } + throw error; + } + + assert.deepEqual(await loadFileKindAndText(symbolicPath), { kind: 'symlink' }); + await assert.rejects(() => client.read({ path: symbolicPath }), /E_UNSUPPORTED_FILE.*symbolic link/); + assert.equal(await readFile(target, 'utf8'), 'target'); +}); + +test('atomic editing breaks only the selected hard link', async (t) => { + const client = new FilesystemPiClient(); + const { dir, path } = await fixture(); + const alias = join(dir, 'alias.txt'); + await writeFile(path, 'one\ntwo'); + try { + await link(path, alias); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES' || code === 'ENOSYS' || code === 'EXDEV') { + t.skip(`hard-link capability unavailable: ${code}`); + return; + } + throw error; + } + + const before = await lstat(path); + assert.equal(before.nlink >= 2, true); + const preview = await client.read({ path }); + await client.edit({ path, edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }] }); + + assert.equal(await readFile(path, 'utf8'), 'one\npatched'); + assert.equal(await readFile(alias, 'utf8'), 'one\ntwo'); + assert.notEqual((await lstat(path)).ino, (await lstat(alias)).ino); + assert.equal(basename(path), 'file.txt'); +}); diff --git a/test/hashline-errors.test.ts b/test/hashline-errors.test.ts new file mode 100644 index 0000000..de53207 --- /dev/null +++ b/test/hashline-errors.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyHashlineEdits, + computeLineHash, + detectLineEnding, + normalizeToLF, + resolveEditAnchors, + restoreLineEndings, + stripBom, +} from '../src/index.js'; +import type { HashlineToolEdit } from '../src/hashline.js'; + +function ref(line: number, content: string): string { + return `${line}#${computeLineHash(line, content)}:${content}`; +} + +function apply(content: string, edits: HashlineToolEdit[]) { + return applyHashlineEdits(content, resolveEditAnchors(edits)); +} + +test('replace supports single lines and ranges and rejects invalid ranges', () => { + assert.equal(apply('a\nb\nc', [{ op: 'replace', pos: ref(2, 'b'), lines: ['B'] }]).content, 'a\nB\nc'); + assert.equal( + apply('a\nb\nc', [{ op: 'replace', pos: ref(1, 'a'), end: ref(2, 'b'), lines: ['AB'] }]).content, + 'AB\nc', + ); + assert.throws( + () => apply('a\nb', [{ op: 'replace', pos: ref(2, 'b'), end: ref(1, 'a'), lines: ['x'] }]), + /E_BAD_OP/, + ); + assert.throws(() => apply('a', [{ op: 'replace', pos: `2#${computeLineHash(2, 'x')}`, lines: ['x'] }]), /E_RANGE_OOB/); +}); + +test('append and prepend support anchored and boundary forms with failures', () => { + assert.equal(apply('a', [{ op: 'append', lines: ['z'] }]).content, 'a\nz'); + assert.equal(apply('a', [{ op: 'prepend', lines: ['z'] }]).content, 'z\na'); + assert.equal(apply('a\nb', [{ op: 'append', pos: ref(1, 'a'), lines: ['x'] }]).content, 'a\nx\nb'); + assert.equal(apply('a\nb', [{ op: 'prepend', pos: ref(2, 'b'), lines: ['x'] }]).content, 'a\nx\nb'); + assert.throws(() => apply('a', [{ op: 'append', lines: [] }]), /E_BAD_OP/); + assert.throws(() => apply('a', [{ op: 'prepend', lines: [] }]), /E_BAD_OP/); +}); + +test('replace_text classifies no-match, multi-match, and empty search failures', () => { + assert.equal(apply('alpha beta', [{ op: 'replace_text', oldText: 'beta', newText: 'gamma' }]).content, 'alpha gamma'); + assert.throws(() => apply('alpha', [{ op: 'replace_text', oldText: 'missing', newText: 'x' }]), /E_NO_MATCH/); + assert.throws(() => apply('alpha alpha', [{ op: 'replace_text', oldText: 'alpha', newText: 'x' }]), /E_MULTI_MATCH/); + assert.throws(() => apply('alpha', [{ op: 'replace_text', oldText: '', newText: 'x' }]), /E_BAD_OP/); +}); + +test('rejects malformed anchors, stale anchors, conflicts, unsafe payloads, and emptying', () => { + assert.throws(() => resolveEditAnchors([{ op: 'replace', pos: '1', lines: ['x'] }]), /E_BAD_REF/); + assert.throws(() => apply('a', [{ op: 'replace', pos: '1#ZZ:a', lines: ['x'] }]), /E_STALE_ANCHOR/); + assert.throws( + () => apply('a\nb', [ + { op: 'replace', pos: ref(1, 'a'), end: ref(2, 'b'), lines: ['x'] }, + { op: 'replace', pos: ref(2, 'b'), lines: ['y'] }, + ]), + /E_EDIT_CONFLICT/, + ); + assert.throws(() => resolveEditAnchors([{ op: 'replace', pos: ref(1, 'a'), lines: ['1#ZZ:copied'] }]), /E_INVALID_PATCH/); + assert.throws(() => apply('a', [{ op: 'replace', pos: ref(1, 'a'), lines: [] }]), /E_WOULD_EMPTY/); +}); + +test('validates operation shapes and anchor hash syntax', () => { + assert.throws(() => resolveEditAnchors([{ op: 'unknown' }]), /E_BAD_OP/); + assert.throws(() => resolveEditAnchors([{ op: 'replace', pos: '1#A', lines: ['x'] }]), /E_BAD_REF/); + assert.throws(() => resolveEditAnchors([{ op: 'replace', pos: '0#ZZ', lines: ['x'] }]), /E_BAD_REF/); + assert.throws(() => resolveEditAnchors([{ op: 'replace', pos: '1#12', lines: ['x'] }]), /E_BAD_REF/); + assert.throws( + () => resolveEditAnchors([{ op: 'append', end: ref(1, 'a'), lines: ['x'] }]), + /E_BAD_OP/, + ); +}); + +test('text helpers preserve their documented normalization behavior', () => { + assert.equal(detectLineEnding('a\r\nb'), '\r\n'); + assert.equal(detectLineEnding('a\nb'), '\n'); + assert.equal(normalizeToLF('a\r\nb\rc'), 'a\nb\nc'); + assert.equal(restoreLineEndings('a\nb', '\r\n'), 'a\r\nb'); + assert.deepEqual(stripBom('\uFEFFtext'), { bom: '\uFEFF', text: 'text' }); + assert.deepEqual(stripBom('text'), { bom: '', text: 'text' }); +});