Skip to content

Commit dc3b57f

Browse files
committed
fix: check rejects symlink/special/unreadable fragments before any read; exactly one Unreleased header enforced (PR #806 review round-3 P1s)
1 parent 71541ad commit dc3b57f

2 files changed

Lines changed: 84 additions & 8 deletions

File tree

.claude/scripts/changelog_compile.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,26 @@ def _valid_fragment_name(name):
125125
return True
126126

127127

128+
_UNRELEASED_HEADER_RE = re.compile(r"^## \[Unreleased\]\s*$", flags=re.MULTILINE)
129+
130+
131+
def _unreleased_headers(changelog_text):
132+
"""All '## [Unreleased]' header matches, in file order."""
133+
return list(_UNRELEASED_HEADER_RE.finditer(changelog_text))
134+
135+
128136
def _unreleased_slice(changelog_text):
129-
"""Return (start, end) character offsets of the Unreleased section body
130-
(after the header line, up to the next '## ' line), or None."""
131-
m = re.search(r"^## \[Unreleased\]\s*$", changelog_text, flags=re.MULTILINE)
132-
if not m:
137+
"""Return (start, end) character offsets of the FIRST Unreleased section
138+
body (after the header line, up to the next '## ' line), or None.
139+
140+
Callers must separately enforce that exactly one Unreleased header
141+
exists (run_check does) — a duplicate header would otherwise hide its
142+
body from this slice.
143+
"""
144+
headers = _unreleased_headers(changelog_text)
145+
if not headers:
133146
return None
147+
m = headers[0]
134148
nl = changelog_text.find("\n", m.start())
135149
if nl == -1:
136150
# Header is the last line with no trailing newline: empty body.
@@ -165,19 +179,39 @@ def run_check(root):
165179
"YYYYMMDD-<kebab-slug>.md with a real calendar date"
166180
)
167181
continue
182+
# lstat-level guard BEFORE any read: a symlink's content is
183+
# mutable out-of-band, and a FIFO/device would hang or crash the
184+
# read; neither can be certified.
185+
if p.is_symlink() or not p.is_file():
186+
findings.append(f"changelog.d/{p.name}: not a regular file (symlink or special)")
187+
continue
188+
try:
189+
text = p.read_text()
190+
except (OSError, UnicodeDecodeError) as exc:
191+
findings.append(f"changelog.d/{p.name}: unreadable ({exc})")
192+
continue
168193
fragments.append(p)
169-
_, errors = _parse_fragment(p.read_text())
194+
_, errors = _parse_fragment(text)
170195
findings.extend(f"changelog.d/{p.name}: {e}" for e in errors)
171196

172197
changelog = root / "CHANGELOG.md"
173198
if not changelog.is_file():
174199
findings.append("CHANGELOG.md is missing")
175200
else:
176-
sl = _unreleased_slice(changelog.read_text())
177-
if sl is None:
201+
changelog_text = changelog.read_text()
202+
headers = _unreleased_headers(changelog_text)
203+
if not headers:
178204
findings.append("CHANGELOG.md: '## [Unreleased]' header is missing")
205+
elif len(headers) > 1:
206+
findings.append(
207+
f"CHANGELOG.md: {len(headers)} '## [Unreleased]' headers found "
208+
"— exactly one is allowed (a duplicate section would hide "
209+
"direct edits from the pointer-only guard)"
210+
)
179211
else:
180-
body = changelog.read_text()[sl[0] : sl[1]]
212+
sl = _unreleased_slice(changelog_text)
213+
assert sl is not None
214+
body = changelog_text[sl[0] : sl[1]]
181215
nonblank = [ln for ln in body.splitlines() if ln.strip()]
182216
if nonblank != [POINTER_COMMENT]:
183217
findings.append(

tests/test_changelog_fragments.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,36 @@ def test_missing_unreleased_header_fails(self, mod, tmp_path):
228228
root = make_repo(tmp_path, changelog=MINIMAL_CHANGELOG.replace("## [Unreleased]\n", ""))
229229
assert any("Unreleased" in f for f in check_findings(mod, root))
230230

231+
def test_check_rejects_symlink_fragment_before_reading(self, mod, tmp_path):
232+
# A symlink at a fragment path must be a finding at check level (CI),
233+
# not just at compile time — its target is mutable out-of-band.
234+
root = make_repo(tmp_path)
235+
target = root / "real-content.md"
236+
target.write_text(GOOD_FRAGMENT)
237+
(root / "changelog.d" / "20260830-x.md").symlink_to(target)
238+
assert any("not a regular file" in f for f in check_findings(mod, root))
239+
240+
def test_check_rejects_dangling_symlink_without_crashing(self, mod, tmp_path):
241+
root = make_repo(tmp_path)
242+
(root / "changelog.d" / "20260830-x.md").symlink_to(root / "does-not-exist.md")
243+
assert any("not a regular file" in f for f in check_findings(mod, root))
244+
245+
def test_check_rejects_undecodable_fragment(self, mod, tmp_path):
246+
root = make_repo(tmp_path)
247+
(root / "changelog.d" / "20260830-x.md").write_bytes(b"### Fixed\n- \xff\xfe junk\n")
248+
assert any("unreadable" in f for f in check_findings(mod, root))
249+
250+
def test_duplicate_unreleased_headers_fail(self, mod, tmp_path):
251+
# A second '## [Unreleased]' section could carry direct bullets that
252+
# the first-match slice never inspects; exactly one header is allowed.
253+
changelog = MINIMAL_CHANGELOG.replace(
254+
"## [1.2.0] - 2026-01-15\n",
255+
"## [Unreleased]\n\n### Added\n- smuggled direct bullet\n\n"
256+
"## [1.2.0] - 2026-01-15\n",
257+
)
258+
root = make_repo(tmp_path, changelog=changelog)
259+
assert any("exactly one is allowed" in f for f in check_findings(mod, root))
260+
231261
def test_eof_only_unreleased_header_is_finding_not_traceback(self, mod, tmp_path):
232262
# File ending exactly at the header with no trailing newline must
233263
# produce a validation finding, not a ValueError.
@@ -351,6 +381,18 @@ def test_exit4_rejects_noncanonical_header_date(self, mod, tmp_path):
351381
root = make_repo(tmp_path, changelog=changelog)
352382
assert run_compile(mod, root, version="1.2.0") == 1
353383

384+
def test_compile_refuses_duplicate_unreleased_headers(self, mod, tmp_path):
385+
# check runs as compile's first step, so a duplicate Unreleased
386+
# section blocks compilation before any insertion or deletion.
387+
changelog = MINIMAL_CHANGELOG.replace(
388+
"## [1.2.0] - 2026-01-15\n",
389+
"## [Unreleased]\n\n### Added\n- smuggled direct bullet\n\n"
390+
"## [1.2.0] - 2026-01-15\n",
391+
)
392+
root = make_repo(tmp_path, changelog=changelog, fragments={"20260830-x.md": GOOD_FRAGMENT})
393+
assert run_compile(mod, root) == 1
394+
assert (root / "changelog.d" / "20260830-x.md").exists()
395+
354396
def test_exit4_target_header_at_eof_is_error_not_traceback(self, mod, tmp_path):
355397
# An existing target header ending the file with no trailing newline
356398
# must produce the empty-section error, not a ValueError (the same

0 commit comments

Comments
 (0)