Skip to content

fix: harden EOL converter against data loss and add test suite - #12

Open
TheJavaGuy wants to merge 5 commits into
jurosh:mainfrom
TheJavaGuy:harden-eol-converter
Open

TheJavaGuy wants to merge 5 commits into
jurosh:mainfrom
TheJavaGuy:harden-eol-converter

Conversation

@TheJavaGuy

Copy link
Copy Markdown
Contributor

Summary

Rewrites the converter to fix 14 confirmed defects, several of which destroyed data (binary and UTF-16 files rewritten with U+FFFD, absolute globs writing to the wrong path, symlinks written through, non-atomic writes). Adds a full test suite with coverage thresholds and a CI workflow.

Changes

Converter (src/index.js)

  • Files must pass a lossless UTF-8 round-trip and contain no NUL byte; binary, UTF-16, symlinks and files above 256 MiB are skipped
  • Strict <lf|crlf|warmup> <glob> argument parsing; unknown modes no longer silently mean LF, warmup can no longer be bypassed
  • One absolute path is used for both read and write
  • Atomic writes via temp file plus rename, with file mode preserved explicitly so the umask cannot alter it
  • Unchanged files are not rewritten, so mtime stays intact
  • **/node_modules/** and **/.git/** are always ignored
  • Exit code 1 if any file failed, 2 on usage error or no match
  • Functions exported for testing, with JSDoc

Tests and CI

  • node:test suite covering argument parsing, UTF-8 detection, atomic write, the full run loop and the CLI process; coverage is 100% with 80% thresholds enforced
  • GitHub Actions workflow on Node 22 and 24, Ubuntu and Windows

Packaging and docs

  • engines.node raised to >=22.8.0 (required by the test runner flags; older lines are end-of-life)
  • SPDX license id Apache-2.0, files field so only src ships
  • README documents the mandatory mode, behaviour and exit codes; .gitattributes forces LF

Files Changed

File Change
src/index.js Modified
test/index.test.js Added
.github/workflows/test.yml Added
package.json Modified
README.md Modified
.gitattributes Added

Testing

npm test passes locally on Node 24.20.0: 35 tests, 100% line, branch and function coverage. Removing the chmod fix makes the two mode-preservation tests fail, confirming they catch the regression. Windows CI job is untested locally.

Related Issues

None

Rewrite src/index.js to fix 14 confirmed defects, each reproduced
against glob 9.3.5 and eol 0.9.1 on Node 24:

 1. Binary files destroyed: every file was decoded as UTF-8 and written
    back, so invalid bytes became U+FFFD (a PNG header 89 50 4E 47 came
    out as EF BF BD 50 4E 47). Now files must pass a lossless UTF-8
    round-trip and contain no NUL byte, otherwise they are skipped.
 2. UTF-16 files destroyed: BOM FF FE became two U+FFFD and 0D 00 0A 00
    became 0A 00 0A 00. Fixed by the same round-trip check.
 3. Absolute glob read one file and overwrote another: read used
    path.join(cwd, file) while write used the bare file name, so the
    pattern /x/a.txt read <cwd>/x/a.txt and wrote into /x/a.txt. Now one
    absolute path from glob({ absolute: true }) is used for both.
 4. No arguments converted the tool's own source: argv[length-1] was
    index.js and the mode fell through to LF. Now exactly two
    arguments are required.
 5. Any unknown mode silently meant LF (typo "crfl" performed a real
    conversion). Mode is now validated against lf|crlf|warmup.
 6. Warmup was bypassed when combined with crlf ("warmup crlf x" ran a
    real CRLF conversion) because only argv[length-2] was inspected.
    Positional parsing is replaced by strict <mode> <glob>.
 7. Exit code was always 0: no match, read errors and write errors
    (EACCES, ENOSPC) only printed, and a rejected run() was unhandled.
    Now usage/no-match errors exit 2 and any per-file failure exits 1.
 8. Non-atomic writes: writeFileSync truncated in place, so a crash,
    SIGINT or full disk left a zero-length or partial file. Content is
    now written to a temp file (flag wx) and renamed over the target.
 9. Symlinks were written through: glob returned symlinked files and
    writeFileSync followed them to any location on disk. lstat now
    rejects anything that is not a regular file.
10. Lone CR was rewritten: eol.lf("a\rb") returns "a\nb". Behaviour is
    kept but now documented in README as intentional normalization.
11. Unchanged files were rewritten anyway, bumping mtime on every match
    and triggering rebuilds. Output is now compared to input first.
12. No ignore list: node_modules was traversed and rewritten. Default
    ignore now covers **/node_modules/** and **/.git/**.
13. Whole-file string decode threw on files above the V8 string limit
    and was only warned about. An explicit 256 MiB size cap now skips
    such files with a message.
14. Minor: unnecessary .bind(eol), output wrongly called the glob a
    "regex", no engines field although glob 9 needs Node 16+, and no
    .gitattributes so a CRLF checkout of index.js broke the shebang.
    All four addressed.

Also adds a per-run summary (changed/unchanged/skipped/failed) and
updates README for the now-mandatory mode argument and the new
skip/ignore/exit-code behaviour.
- writeAtomic now chmods the temp file so the umask cannot alter the
  preserved permission bits
- add node:test suite with 80% coverage thresholds (currently 100%)
- export parseArgs, isUtf8Text, writeAtomic and run for testing; add JSDoc
- add GitHub Actions workflow for Node 22 and 24 on Ubuntu and Windows
- package.json: engines >=22.8.0, SPDX license id, files field
@TheJavaGuy

Copy link
Copy Markdown
Contributor Author

@jurosh I know this PR turned out to be quite large. If you're uncomfortable with merging it I can split it into several small, focused ones.

@TheJavaGuy

Copy link
Copy Markdown
Contributor Author

@jurosh Second try 🙏

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Conversion can corrupt ambiguous binary or UTF-16 files, write through symlinked directories, lose access metadata, or delete pre-existing temporary files.

8 open issues
What changed in this PR

This PR hardens the line-ending converter against data loss and adds automated tests and cross-platform CI.

Changes:

  • Reworks conversion with strict arguments, UTF-8 validation, file filtering, atomic writes, and explicit exit codes.
  • Adds unit and CLI tests with coverage thresholds, plus Ubuntu and Windows CI on Node 22 and 24.
  • Updates usage documentation, package metadata, and repository line-ending settings.
File Description
test/​index.test.js Adds tests for validation, conversion, file handling, and CLI behavior.
src/​index.js Reworks conversion safeguards, atomic writes, reporting, and testable exports.
README.md Documents mandatory modes, requirements, behavior, and exit codes.
package.json Adds the test command, Node requirement, package file filtering, and SPDX license identifier.
.github/​workflows/​test.yml Adds a Node 22/24 test matrix on Ubuntu and Windows.
.gitattributes Sets LF endings for files Git treats as text.

Review effort: Balanced

Comment thread src/index.js
Comment on lines +32 to +33
if (buf.includes(0)) return false
return Buffer.from(buf.toString('utf8'), 'utf8').equals(buf)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed the README overpromised. No content-only check can rule this out: any UTF-16 file with an ASCII character or line break contains a NUL and is skipped, so only NUL-free data that is also valid UTF-8 slips through. Fixed the README in 3e4027c to state the limit and recommend targeting text files with the glob.

Comment thread src/index.js Outdated
Comment on lines +47 to +49
fs.writeFileSync(tmp, content, { mode, flag: 'wx' })
fs.chmodSync(tmp, mode & 0o7777)
fs.renameSync(tmp, file)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3e4027c: replaced temp file + rename with an in-place rewrite (open r+, write, ftruncate). The inode is kept, so owner, group, mode, ACLs and xattrs are preserved.

Comment thread src/index.js
Comment on lines +46 to 53
try {
fs.writeFileSync(tmp, content, { mode, flag: 'wx' })
fs.chmodSync(tmp, mode & 0o7777)
fs.renameSync(tmp, file)
} catch (err) {
try { fs.unlinkSync(tmp) } catch { /* nothing to clean */ }
throw err
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3e4027c: the temp file is gone entirely (files are rewritten in place), so there is no cleanup path left.

Comment thread src/index.js
Comment on lines +77 to +78
const st = fs.lstatSync(file)
if (!st.isFile()) { stats.skipped++; console.log(`SKIP not a regular file: ${file}`); continue }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly agree. Verified with glob 9.3.5: ** does not descend into symlinked directories, so this only happens when the user names the symlinked directory explicitly in the pattern, which is an explicit request. Documented the behaviour in the README in 3e4027c rather than adding a realpath check that would also reject legitimate setups (e.g. a symlinked project directory).

Comment thread src/index.js Outdated
Comment on lines +44 to +45
const tmp = path.join(path.dirname(file),
`.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3e4027c: no temp file name anymore, files are rewritten in place.

Comment thread src/index.js Outdated
*/
async function run(argv = process.argv.slice(2), cwd = process.cwd()) {
const { mode, pattern } = parseArgs(argv)
const convert = mode === 'crlf' ? eol.crlf : eol.lf

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3e4027c: warmup takes an optional target, eolConverter warmup crlf "<glob>" previews a CRLF conversion; warmup <glob> previews LF (the default). Covered by a test.

Comment thread src/index.js
} catch (error) {
console.warn(error)
const st = fs.lstatSync(file)
if (!st.isFile()) { stats.skipped++; console.log(`SKIP not a regular file: ${file}`); continue }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3e4027c: in-place rewrite keeps the inode, so all hard links see the new content. Added a test.

Comment thread src/index.js Outdated
if (!isUtf8Text(input)) { stats.skipped++; console.log(`SKIP not UTF-8 text: ${file}`); continue }
const output = Buffer.from(convert(input.toString('utf8')), 'utf8')
if (output.equals(input)) { stats.unchanged++; continue }
if (!dryRun) writeAtomic(file, output, st.mode)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3e4027c: open(r+) fails with EACCES on read-only files, which are reported as FAIL and left untouched (same as before this PR). Added a test.

@jurosh

jurosh commented Nov 3, 2026

Copy link
Copy Markdown
Owner

@TheJavaGuy Hey, can you take a look at reported items ?

Also please add back default to LF (not making this as mandatory) so library can be used also without that extra param:

# Defaults to LF
eolConverter "**/*.js"

# Extra param to enforce format...
eolConverter lf "**/*.js"
  • those 2 should have same output

- Make the target optional; a lone glob converts to LF as before
- Allow warmup to take a target: warmup [lf|crlf] <glob>
- Overwrite files in place instead of temp file + rename so owner,
  ACLs, xattrs and hard links survive and read-only files are refused
- Document encoding-detection and symlinked-directory limits
@TheJavaGuy

Copy link
Copy Markdown
Contributor Author

@jurosh Thanks for the review. Addressed in 3e4027c:

  • LF is the default again: eolConverter "**/*.js" and eolConverter lf "**/*.js" produce the same output (tests cover both).
  • All Copilot items are answered in their threads. The main change is that files are now rewritten in place instead of temp file + rename, which fixes the ownership/ACL, hard-link, read-only, temp-cleanup and long-filename items at once. Trade-off: writes are no longer atomic (same as before this PR), noted in the README.
  • Warmup can now preview CRLF: eolConverter warmup crlf "**/*.js".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants