diff --git a/.release-notes/protect-ignore-file-size.md b/.release-notes/protect-ignore-file-size.md new file mode 100644 index 0000000000..6293584654 --- /dev/null +++ b/.release-notes/protect-ignore-file-size.md @@ -0,0 +1,3 @@ +## Protect pony-lint against oversize ignore files + +pony-lint now rejects `.gitignore` and `.ignore` files larger than 64 KB. With hierarchical ignore loading, each directory in a project can have its own ignore files — an unexpectedly large file could cause excessive memory consumption. Ignore files that exceed the limit or that cannot be opened produce a `lint/ignore-error` diagnostic with exit code 2. diff --git a/tools/pony-lint/ignore_matcher.pony b/tools/pony-lint/ignore_matcher.pony index 8ddbfd7870..d6c863c3c3 100644 --- a/tools/pony-lint/ignore_matcher.pony +++ b/tools/pony-lint/ignore_matcher.pony @@ -46,6 +46,7 @@ class ref IgnoreMatcher let _root: String val let _in_git_repo: Bool let _rules: Array[(IgnorePattern val, String val)] + let _errors: Array[(String val, String val)] new ref create(file_auth: FileAuth, root: (String val | None)) => """ @@ -63,6 +64,7 @@ class ref IgnoreMatcher _in_git_repo = false end _rules = Array[(IgnorePattern val, String val)] + _errors = Array[(String val, String val)] fun ref load_directory(dir_path: String val) => """ @@ -75,15 +77,47 @@ class ref IgnoreMatcher end _load_file(Path.join(dir_path, ".ignore"), dir_path) + fun errors(): this->Array[(String val, String val)] => + """ + Return errors accumulated during `load_directory` calls. Each entry is + a `(message, file_path)` tuple. Call `clear_errors` after draining. + """ + _errors + + fun ref clear_errors() => + """ + Remove all accumulated errors. Call after draining `errors()`. + """ + _errors.clear() + fun ref _load_file(file_path: String val, base_dir: String val) => """ Parse all lines from an ignore file and append the resulting rules. + + Rejects files that cannot be opened or are larger than 64 KB to prevent + unexpected memory consumption, especially with hierarchical ignore + loading where each directory can have its own `.gitignore` and `.ignore` + files. """ let fp = FilePath(_file_auth, file_path) if not fp.exists() then return end let file = File.open(fp) - if not file.valid() then return end - let content: String val = file.read_string(file.size()) + if not file.valid() then + _errors.push(( + "could not open ignore file: " + file_path, + file_path)) + return + end + let size = file.size() + if size > _max_ignore_file_size() then + file.dispose() + _errors.push(( + "ignore file too large (" + size.string() + " bytes, max " + + _max_ignore_file_size().string() + "): " + file_path, + file_path)) + return + end + let content: String val = file.read_string(size) file.dispose() // Normalize line endings and parse let clean_content: String val = @@ -168,3 +202,5 @@ class ref IgnoreMatcher else false end + + fun _max_ignore_file_size(): USize => 65_536 diff --git a/tools/pony-lint/linter.pony b/tools/pony-lint/linter.pony index f8ca9247e8..43deb416a9 100644 --- a/tools/pony-lint/linter.pony +++ b/tools/pony-lint/linter.pony @@ -480,6 +480,17 @@ class val Linter end end + // Surface errors from intermediate ignore file loading + for (msg, path) in matcher.errors().values() do + config_errors.push(Diagnostic( + "lint/ignore-error", + msg, + try Path.rel(_cwd, path)? else path end, + 0, + 0)) + end + matcher.clear_errors() + // Pre-load subdirectory configs from hierarchy root through intermediate // directories to each target. Uses _root_dir (config hierarchy root), // not the git root. @@ -674,6 +685,17 @@ class ref _FileCollector is WalkHandler // Load ignore files for this directory _matcher.load_directory(dir_path.path) + // Surface errors from ignore file loading + for (msg, path) in _matcher.errors().values() do + _config_errors.push(Diagnostic( + "lint/ignore-error", + msg, + try Path.rel(_cwd, path)? else path end, + 0, + 0)) + end + _matcher.clear_errors() + // Load subdirectory config if present _load_config(dir_path.path) diff --git a/tools/pony-lint/test/_test_ignore.pony b/tools/pony-lint/test/_test_ignore.pony index f73479a1a9..4a648e9afa 100644 --- a/tools/pony-lint/test/_test_ignore.pony +++ b/tools/pony-lint/test/_test_ignore.pony @@ -424,6 +424,51 @@ class \nodoc\ _TestIgnoreMatcherHierarchical is UnitTest h.fail("could not create temp directory") end +class \nodoc\ _TestIgnoreMatcherOversizedFile is UnitTest + """Oversized ignore file produces an error and loads no rules.""" + fun name(): String => "IgnoreMatcher: oversized file -> error" + + fun apply(h: TestHelper) => + let auth = h.env.root + try + let tmp = FilePath.mkdtemp(FileAuth(auth), "ignore-test")? + let git_dir = + FilePath(FileAuth(auth), Path.join(tmp.path, ".git")) + git_dir.mkdir() + // Write a .gitignore that exceeds the 64 KB limit + let gitignore_path = + Path.join(tmp.path, ".gitignore") + let gitignore = + File(FilePath(FileAuth(auth), gitignore_path)) + let content = recover val String(65_537) .> append("x" * 65_537) end + gitignore.print(content) + gitignore.dispose() + + let matcher = lint.IgnoreMatcher(FileAuth(auth), tmp.path) + matcher.load_directory(tmp.path) + + // Should not have loaded any rules + h.assert_false( + matcher.is_ignored( + Path.join(tmp.path, "anything"), "anything", false)) + + // Should have recorded an error + let errs = matcher.errors() + h.assert_eq[USize](1, errs.size()) + try + (let msg, _) = errs(0)? + h.assert_true(msg.contains("too large")) + else + h.fail("could not read error") + end + + FilePath(FileAuth(auth), gitignore_path).remove() + git_dir.remove() + tmp.remove() + else + h.fail("could not create temp directory") + end + class \nodoc\ _TestIgnoreMatcherAnchoredPattern is UnitTest """Anchored patterns match against the relative path from base_dir.""" fun name(): String => "IgnoreMatcher: anchored pattern" diff --git a/tools/pony-lint/test/main.pony b/tools/pony-lint/test/main.pony index de50329fa2..0d412bc5a5 100644 --- a/tools/pony-lint/test/main.pony +++ b/tools/pony-lint/test/main.pony @@ -169,6 +169,7 @@ actor \nodoc\ Main is TestList test(_TestIgnoreMatcherNonGitIgnoresGitignore) test(_TestIgnoreMatcherHierarchical) test(_TestIgnoreMatcherAnchoredPattern) + test(_TestIgnoreMatcherOversizedFile) // Linter tests test(_TestLinterSingleFile)