fix(snapshot): copy notification records as bytes, not through a locale decode - #8184
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of All the load-bearing claims check out: the raise propagates to a caught, audited failure at both callers ( Design-Verdict: PASS Root-cause fix at the right seam: binary framing via the shared reader that was already reserved for this caller, with an honest raise-not-warn failure posture. Suggestions
[DESIGN-REVIEWED] 576a636 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of First-Principles-Verdict: PASS A documented-deferred conversion done at cause level — text mode itself removed, using the reader built for it, with zero unfixed siblings. What this change shipsIntent: stop the snapshot notification merge losing or corrupting records by copying them as bytes instead of through a locale decode. FIX.
Checks run: [FIRST-PRINCIPLES-REVIEWED] 576a636 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/snapshot.py:2402 -- “Only an unhashable False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe candidate keys on preserved-not-introduced behavior. The predecessor keyed dedup on The candidate also fails input-reachability: notification No findings. [OPUS-REVIEWED] 576a636 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
534b123 to
d9ddc60
Compare
|
Confirmed and fixed in Reading the callers rather than the function: Both handlers now Everything the fix guarantees is unchanged: a destination-scan failure is still a true no-op (the destination is not opened for append until that scan completes), and a source-scan failure still leaves its prefix in place with a re-run adding no duplicate, because Two mutations pin it -- turning either |
d9ddc60 to
73dd0b6
Compare
|
Confirmed and fixed in The part worth recording is why nothing local caught it. isort, black, flake8, mypy and every repo gate pass on a doubled print. And all thirteen of my own assertions spell the check as There is now a count assertion rather than a membership one, Also confirmed on the same head: Thank you for verifying the description's claims individually rather than the summary -- the cap-alias sibling count and the |
73dd0b6 to
c9a763c
Compare
|
Confirmed and fixed in The old key was
One declared rider from the same fix, because widening to any hashable Same head: 86 / 40 / 51 across |
c9a763c to
b525540
Compare
|
Confirmed and fixed in Your finding is right and it was my own rider that caused it. I added So the rider traded an exotic collision for a common one. An integer-versus-float timestamp is an ordinary serializer artefact; a boolean timestamp is not. Your suggested fix -- one numeric tag for integers and floats, booleans kept distinct -- would work. I did not take it, because it keeps my addition and adds a taxonomy to compensate for it. Instead the addition is deleted: the That leaves the
Same head: 86 / 40 / 51 across |
b525540 to
76c294c
Compare
76c294c to
21806b4
Compare
|
Confirmed and fixed in The live file holds a crash-truncated row and the source holds a record with a bare carriage return: The source splits into two pieces at the carriage return, because this reader's boundaries are the universal-newline set. The FIRST piece strips to exactly the truncated live row, so it was skipped as a duplicate; the second was appended. Result: The source's carriage return is gone, the file gained a line that parses as neither, and The root cause is the general one rather than the carriage return: an unparseable row was keyed on its stripped CONTENT, and content is not identity. Two different records whose stripped bytes coincide collapse, and the loser is deleted. So the fix is your prescription generalised -- a record with no usable Declared cost, stated rather than discovered: re-running a merge re-appends a One mutation in this round did NOT redden, and diagnosing it removed dead code instead of leaving a false positive in the harness. Writing the skip test as Same head: 89 / 40 / 51 across |
|
Both items dispositioned. Verified each against The Filed rather than folded in, for the reason you already accepted: it is pre-existing behaviour in a different component, and the honest fix needs a judgement this PR should not make. Whether a refused cron merge should abort the WHOLE import the way notifications now does is a real design call, because The Declining for now is a sequencing judgement, not a disagreement. This head is currently zero-red with the GPT lane still in flight on it, and a push re-rolls five edit-triggered workflows including that lane, which is the one non-deterministic gate here. Trading a settled board for the removal of one constant is a bad exchange while a verdict is pending. If GPT returns a legitimate finding on this head, the subtraction goes in that push, where it costs nothing. If GPT comes back clean, I will leave it and say so, because at that point the only thing a push buys is risk. Recorded here rather than in the description so this comment re-fires nothing. |
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
UX Review (Fable 5) — ⏭️ skippedRevision |
7ab712f to
e5ffe85
Compare
e5ffe85 to
9de3a94
Compare
9de3a94 to
b83d2b3
Compare
…le decode The notification merge appends records from a snapshot into the live notifications.jsonl verbatim, but both handles were text mode. That is a locale decode followed by a locale encode with universal-newline translation on top, and neither half is byte-exact. Measured on main, one wrong mode produced six outcomes. Two need no bad encoding at all: a record terminated CRLF lost its carriage return, and a valid-UTF-8 record containing a bare carriage return was split by universal newlines so both halves failed json.loads, the except swallowed both, and the merge printed "Notifications imported: 0" and returned success with the record gone for good. The rest are locale-dependent: the decode happens at `for line in f`, OUTSIDE the try, so UnicodeDecodeError -- a ValueError -- escaped `except (ValueError, TypeError)` because the iterator raised it rather than json.loads. On a UTF-8 host that aborted the restore with a traceback; under a single-byte locale the decode succeeded, the encode put the same bytes back, and the live file stopped being valid UTF-8. Its loader then returns NO rows for the whole file and the next rewrite persists that empty view. Both reads now go through jsonl_util.strict_raw_records on binary handles, which is the reader that module was written for and says so. Records come back with their terminators, undecoded and byte-exact. Encoding is enforced by decoding as a VALIDATION step whose result is used only for the dedupe key, while what gets appended is always the original bytes: validating by decoding and writing the decoded form back is the round trip being removed. The destination scan was the same defect and is converted too. The issue's own criterion "destination-scan failure must be a true no-op" names it, though its prose does not: an invalid record already in the live file aborted the merge before the copy loop was reached. A ts-less record is keyed on its RAW BYTES, and only a record that fails to parse gets no key at all. That split is load-bearing. Stripping is what the predecessor did and what deleted bytes: it makes two DISTINCT byte sequences share a key, so a source fragment ending in a carriage return strips to a crash-truncated live row that contains none, and the fragment is skipped as a duplicate while its tail is appended. Unstripped bytes cannot do that, because byte-equal records ARE the same record and collapsing them loses nothing. Withholding a key from an unparseable record covers the rest: the fragments a split record produces are exactly the unparseable ones, which makes "a fragment is never mistaken for a record already present" structural rather than a property of whichever collision one happens to think of. Both branches are mutation-verified separately, and the kind tag keeps the families apart -- json.loads cannot yield a value whose type is named "raw". The raw key is the bytes that LAND, not the bytes that arrive. The append terminates an unterminated record, so keying the arriving form made a second import compare a source row's unterminated bytes against the terminated row the first import itself wrote, miss, and append a duplicate -- reproduced as two copies after two imports with "Notifications imported: 1" printed both times. Normalising uses the same endswith(_TERMINATORS) predicate as that write, so the key and the bytes cannot drift. The DIRECTION is load-bearing and only one of the two is safe. Adding the terminator the writer adds is deterministic and merges only records that land identically; normalising the other way with rstrip would be line.strip() under a new name, mapping X\r and X onto one key -- the fragment and crash-truncated-row pair this change exists to keep apart. Found by mutation: substituting rstrip left the suite green, so a test now pins the direction and not just the presence. An unhashable ts -- a list or dict -- also takes the raw key rather than going unkeyed, because it parses and so is not in the fragment class, and because byte-equal records are the same record whichever way the ts was unusable. Stated here as intent rather than left as a surprise. The refusal this merge raises now has somewhere to land. `restore_main` already wraps the merge in a try whose stated purpose is that "a traceback would read like a crash and bury the one sentence saying what to do about it", and `UnreadableRecord` was simply missing from its arms -- so an over-cap or invalid-UTF-8 notification record crashed `kirocrew restore` instead of refusing it. Added as one more arm alongside `PinnedPathRefusal` and `UnsafeComponentRoot`, audited under the event name a declined restore already uses. Deliberately narrower than the `(OSError, UnreadableRecord)` tuple the merge itself catches: an OSError at that boundary could come from any copy in the restore, and labelling one of those a refused notification record would be a wrong message rather than a missing one. The other caller needs nothing. `portability.apply_import_zip` reaches the dashboard import handler, which already wraps it in `except Exception` with `logger.exception` plus an audited denial -- which is what the raise was FOR, so that path reports a refusal rather than an `ok: true` with records missing. The remaining surface is filed rather than fixed here, because it spans two owners outside this file and needs a product decision: an unparseable row still appends on every merge, by the design above, and dashboard/state.py's trim keeps the newest rows while deliberately retaining unparseable ones, so a crafted snapshot can push older genuine notifications out. That vector is pre-existing -- main appends N rows for N distinct rows exactly as this branch does -- and it is tracked in #8313. Also settled, each with a test that fails without it: the dedupe key is well-defined and hashable for every record shape, so a non-object record no longer raises AttributeError off .get and a list or dict ts no longer raises TypeError on set insert; an unterminated final record gains a terminator on either side, so two records cannot glue into one line that parses as neither; the posture is abort, never skip, because the output feeds a durable append and a skipped record is a deleted one; and the per-record cap arrives with the reader, which removes the last handle iteration from snapshot.py and therefore removes it from _DEFERRED_READERS. An explicit encoding= would NOT have fixed this. newline= is a separate axis and the carriage-return losses fire on a pure UTF-8 host with fully valid UTF-8 input. Binary mode closes both at once. Abort means RAISE, not warn. apply_import_zip has no try around the merge, appends "notifications (merged)" unconditionally, and the dashboard handler answers ok: True with a SEL outcome="ok" -- and none of those see stdout. A printed warning alone would therefore report an import that left records behind as one that finished, which is the same defect class this change closes. The print stays so a CLI operator reads the reason before the traceback. This is deliberately unlike _merge_crons, which warns and returns: a refused cron merge writes nothing and skips one component, while this one may already have appended a prefix. Out of scope, filed as #8181: both callers fall back to shutil.copy2 when the live file does not exist. That copy is already byte-exact, so leaving text mode does not touch it; its harm is that it faithfully delivers bytes the loader then refuses, which needs a validation pass that does not exist today rather than a pipeline conversion. Closes #7771 Refs #6345 Refs #8181
b83d2b3 to
576a636
Compare
NicholasRBowers
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (4 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix with a clear root cause — snapshot notification merge copied records through a locale decode instead of byte-exact, and the dedupe key stripped/collided on non-byte-equal records; now validates UTF-8 and dedupes on kind-tagged keys with raw-bytes fallback.
1. What is the problem?
snapshot._merge_notificationsappends the snapshot's notification records into the livenotifications.jsonlverbatim. Both handles were text mode:So a verbatim byte copy was running through a locale decode followed by a locale encode, with universal-newline translation on top. Neither half is byte-exact, and the issue's own framing -- an "encoding-validation gap" -- names a symptom rather than the defect.
Read this before prescribing
encoding="utf-8"The obvious remedy is an explicit
encoding=on bothopen()calls. It does not fix this. Two of the measured failures are locale-INDEPENDENT and fire on a pure UTF-8 host with fully valid UTF-8 input, becausenewline=is a separate axis fromencoding=. Measured on2d75835f3, UTF-8 locale,PYTHONUTF8=1:A valid record is silently and permanently lost. A record containing a bare carriage return:
Universal newlines split the record in two, both halves then failed
json.loads, andexcept (ValueError, TypeError): passswallowed both. No encoding was involved.A valid record loses a byte. A record terminated with a carriage return plus line feed:
The remaining failures are locale-dependent, and the locale decides which one you get.
for line in fdecodes OUTSIDE thetry, soUnicodeDecodeError-- aValueErrorsubclass -- escapedexcept (ValueError, TypeError)because the ITERATOR raised it, notjson.loads. The escaping traceback's innermost frame is<frozen codecs>:322.On the single-byte-locale row: latin-1 and cp1252 are byte-BIJECTIONS on decode-then-encode. All 256 byte values were checked; zero differ for either codec, and cp1252 rejects exactly five (
0x81 0x8D 0x8F 0x90 0x9D) and round-trips the other 251 identically. So that path does not garble the bytes -- it faithfully delivers invalid UTF-8 into a file whose reader demands UTF-8, which is the harm.2. Why this issue matters to the user
The live file's loader decodes the whole file inside one
trywhoseexcept Exceptionreturns[], so one bad byte costs every record, not the bad row. Measured withKIROCREW_HOMEon a temp dir:Any delete, acknowledge or clear triggers that rewrite. So the user's outcome is a restore that reported success, then total loss of notification history the next time they dismiss a notification. And the two locale-independent failures lose records on the default configuration with no bad bytes anywhere.
3. How our fix solves it
Symptom to root cause: records were lost or garbled -> because the appended bytes were not the source bytes -> because a verbatim copy was being decoded, re-encoded and newline-translated -> because both handles were text mode. So the fix is to stop being in text mode.
Both reads now go through
jsonl_util.strict_raw_recordson BINARY handles. That module's own docstring names this caller and says it was left unconverted pending exactly this contract, andstrict_raw_recordssays "use where the record must survive byte-for-byte: a reader that copies records into another file cannot go through a lossy decode and re-encode". Records come back with their terminators, undecoded and byte-exact.The write-side contract the issue asked for, stated up front and now on the function itself:
AttributeErroroff.get, and a list or dicttsno longer raisesTypeErroron set insert. Atsis used whenever it is truthy AND hashable, under a kind tag deliberately coarser than its Python type, because two equalities are in play at once:True == 1and they hash equal, so an untagged key DELETES ats: truerow as a duplicate of ats: 1row;1 == 1.0and they hash equal too, so tagging withtype(ts).__name__SPLITS an integer-versus-float spelling of one row and persists a duplicate. One tag covers both --intandfloatshare"num"so they deduplicate exactly as the predecessor's bare-value key did, whileboolis its own tag and is tested FIRST because it is a subclass ofint. A record with NO usabletsyields no key and is never deduplicated: the predecessor fell back toline.strip(), which is content used as identity, and for a row that does not parse that is not identity -- two different records whose stripped bytes coincide collapse and the loser is deleted.apply_import_zipappendsnotifications (merged)to its summary unconditionally and the dashboard handler answersok: Truewith a SELoutcome="ok", and neither sees stdout, so a printed warning alone would tell an API caller that an import which left records behind had finished. The print stays so a CLI operator reads the reason before the traceback. Both scans are no-ops on refusal: the destination is not opened for append until its scan completes, and the ENTIRE source is validated before it is opened, because an identity-less row cannot be deduplicated so any prefix left by an aborted copy is one a retry would append again. A failure DURING the copy is the residual case -- the source changed between the two passes -- and only there does a prefix survive, since rolling it back would be a second unvalidated write.The destination scan is converted too, and that is not scope creep. The issue's own acceptance criterion "destination-scan failure must be a true no-op" is about that site, even though neither the issue body nor its triage comment names it. It had the same defect: an invalid record already in the LIVE file aborted the merge with a traceback before the copy loop was reached.
The bound is in scope for the same reason it could not be left out.
snapshot.pyheld exactly twofor <var> in <handle>:loops and both were in this function, so converting them removes the file's last instance of the shape -- at which pointtest_the_scanner_actually_detects_the_patternrequires it to leave_DEFERRED_READERS, since that test asserts every excused file still contains the shape. And the byte-exactness fix requires a binary framing reader, which isstrict_raw_records, whosecapis a defaulted keyword.Refs #6345is here so that issue's owner sees a part landed; this does not claim to close it.4. What tests we did
Twenty-one new tests in
test/test_snapshot.py::TestNotificationMergeWriteSideContract, one per contract property. Every fixture is real BYTES written to a real file: a synthesizedUnicodeDecodeErrorwould route the merge down a healthy path and prove nothing, because the whole defect was that the decode happened atfor line in f, outside thetry, so the real failure never took the branch a fake exception takes.Mutation-verified one enforcement SITE at a time, twenty-four mutations, each reverted with the file asserted restored byte-for-byte:
Failed: DID NOT RAISE UndecodableRecordFailed: DID NOT RAISE UndecodableRecordFailed: DID NOT RAISE UndecodableRecordFailed: DID NOT RAISE UndecodableRecordFailed: DID NOT RAISE UnreadableRecordAt index 42 diff: b'{' != b'\n'-- the records gluedFailed: the merge aborted ... AttributeError("'list' object has no attribute 'get'")tsFailed: ... TypeError("unhashable type: 'list'")Failed: DID NOT RAISE OversizedRecordtsstill deduplicatesAssertionError: a numeric ts stopped deduplicatingtsdeduplicateAssertionError: an int and an equal float ts stopped deduplicatingts: truedoes not collide withts: 1AssertionError: a boolean ts collided with 1boolis not swallowed by the numeric armAssertionError: a boolean ts collided with 1AssertionError: a record fragment was skipped as a duplicateAssertionError: the declared re-appendexisting.addguards keepNoneout of the seen-setAssertionError: an identity-less row was droppedAssertionError: first: a prefix was appendedAssertionError: first: a prefix was appendedassert 2 == 1, with both copies in the messageAssertionError: a raw escape reached the terminalAssertionError: a raw escape reached the terminalEvery red is on pytest's assertion channel --
AssertionError, orFailedfromDID NOT RAISEor from a_merge_must_not_aborthelper that names the property instead of surfacing an incidental exception.Two of those deserve calling out because they are not obvious. The success-line mutation exists because a restructure re-emitted that
printand no linter, formatter or type checker sees a doubled print -- and every other assertion here spells the check"Notifications imported:" in out, which is structurally blind to duplication whatever suffix it carries, so the test counts instead of testing membership. And the pre-validation pass has two mutations because removing the pass and keeping the pass while not calling the key function fail identically:strict_raw_recordsdoes not decode, so draining the reader proves nothing about encoding.Suites, one named file per invocation, serially (
-n 0), withPYTHONPATHpinned to the worktree so main's installed copy could not be the thing under test:test/test_snapshot.py-- 92 passed (71 pre-existing, including the two pre-existing notification-merge tests, which pass unchanged)test/test_jsonl_util.py-- 40 passed, including the audit scanner withsnapshot.pyremoved from the excused settest/test_portability.py-- 51 passed (the second caller of this function)Gates:
isort,flake8,mypy,scripts/check_black_formatting.py,scripts/check_subprocess_encoding.py,scripts/check_lockdown_before_publish.py,scripts/check_testpaths_coverage.py,scripts/check_loop_bound_locks.py-- all clean. Mergeability computed rather than polled:git merge-tree --write-tree kirocrew/main HEADexits 0, and main's delta since the merge-base overlaps none of these four files.5. Any other suggestions on the work
A bundle chooses its own inner root, so the two SOURCE-path prints escape it.
_safe_nameexists in this module for exactly that -- its docstring names archive root directories, and nineteen sites already use it -- and the prints this change adds were bypassing it, so a crafted root could move the cursor and overwrite lines right above the prompt where the operator decides whether to trust the restore. Both source prints now wrap the path. The destination print deliberately does not: that path is the live data home, chosen by the operator, not a name out of an archive.The exception text is deliberately left unwrapped, and the invariant is stated on the code because it is what makes the wrapper unnecessary rather than forgotten: both types the arm catches already render an embedded path with repr-style escaping --
OSError.__str__for its filename, andjsonl_utilvia{path!r}. Measured: a control character in a directory name reaches neither exception'sstr()raw. Wrapping it as well was over-delivery, and no mutation of it could redden.The
blockedlabel is stale and can be dropped. It was applied becausestrict_raw_recordsand_DEFERRED_READERSdid not exist on main. Both shipped in #7651, merged 2026-09-02 as4c288169029ddea1f0522d90243fe39e029cfa7c, and both were read on main before this change was written.One correction to the issue's triage comment, because it bears on the remedy. That comment identifies the silent path as one where "the destination gets different bytes than the source had". Measured, that is not the mechanism: under a single-byte locale the copy is byte-faithful, and the bytes that really do change change from universal-newline translation, which is locale-independent. Its headline conclusion -- that the non-byte-exact round trip is the root defect rather than an encoding-validation gap -- is right, and stronger than its own reasoning.
Two residues, both filed rather than folded in. #8181: both callers fall back to
shutil.copy2when the live file does not exist, which is byte-exact and validates nothing, so it delivers the same total-loss outcome on every locale including UTF-8 on a fresh install. Not in this PR becausecopy2never decoded anything, so leaving text mode does not touch it -- closing it means ADDING a validation pass rather than converting a pipeline. #8217:_merge_cronswarns and returns on three refusal paths whileportability.pyappendscrons (merged)unconditionally, so a refused cron merge is reported as merged -- the same summary-honesty defect this PR fixes for notifications, in the neighbouring component, and its fix needs a judgement about whether one refused component should abort a multi-component restore.One inherited quirk, declared rather than silently carried.
ts: trueandts: 1are equal and hash equal in Python; the kind tag separates them, but the underlying identity is the language's and is inherited from the code being replaced. It is not a regression this PR introduces and not among the acceptance criteria.Trailer:
Closes #7771rather than a bareRefs, because all four acceptance criteria are met by this diff and both residues have their own tracked owners.Scope: one finding acknowledged and tracked rather than fixed here
A review round raised that identity-less notification rows can evict live history. The
finding is real and half of it is fixed in this change: a
ts-less row that PARSES is nowdeduplicated on its raw unstripped bytes, which restores the predecessor's idempotence for a
re-run without restoring the deletion it caused. Stripping made two distinct byte sequences
share a key; raw bytes cannot, because byte-equal records are the same record.
The remaining half is deliberately not fixed here, and is tracked in #8313:
correct, not a shortcoming:
jsonl_utilframes on the universal-newline set, so a recordcontaining a bare carriage return is split into fragments, and those fragments are exactly
the unparseable records. Giving them a content key lets one collide with a crash-truncated
row and be deleted. The docstring records that framing on newline alone was considered and
rejected for the mirror-image reason, so this is a reviewed tradeoff rather than an
oversight.
dashboard/state.py's trimkeeps the newest rows while deliberately retaining unparseable ones -- which is what turns
an append into an eviction. That is a second owner's file.
change, not a bug fix:
mainimports them, and refusing would make one bad row cost anentire snapshot restore under this change's validate-whole-source-first posture.
The vector is pre-existing:
mainkeys ats-less row byline.strip(), which collapsesonly identical rows, so it appends N rows for N distinct rows exactly as this branch does.
Nothing here is a regression from this change.
Two reviewers, the same lines, different questions
_notification_key's raw-key return was examined twice with opposite outcomes, and bothreadings are honest. Recording it so nobody has to reconstruct why.
Opus 4.8 considered the append's terminator handling and dropped it. Its question was
whether adding a terminator to an unterminated final record HARMS the destination, and the
answer is no: terminating it is correct hygiene, the operation is idempotent, no record is
lost, and nothing becomes unloadable.
GPT 5.6 asked a different question -- whether the added terminator changes the row's
DEDUPLICATION KEY -- and found that it does. An unterminated parsed row without
tswaskeyed on its arriving bytes while the append wrote it with
\nappended, so a second importcompared the source's unterminated bytes against the terminated row the first import itself
wrote, missed, and appended a second copy.
Reproduced before the fix: two copies after two imports, with
Notifications imported: 1printed both times. Fixed by normalizing the raw key to the bytes that LAND rather than the
bytes that arrive, using the same predicate as the write so the two cannot drift.
The direction of that normalization is load-bearing. Adding the terminator the writer
adds is deterministic and merges only records that land identically. Normalizing the other
way --
rstrip-- would be the predecessor'sline.strip()under a new name, mappingX\rand
Xonto one key, which is exactly the fragment / crash-truncated-row pair this changeexists to keep apart. A mutation test pins the direction, not merely the presence, because
substituting
rstripleft the whole suite green until that test was added.The general form worth keeping: an earlier "no findings" is not evidence of absence. It is
evidence about the question that reviewer asked.
Pattern harvest
Rule candidate: review-prompt
Pattern: a loop that iterates a file handle in TEXT mode while its body copies the line into another file. The decode sits on the
forstatement, so it is outside anytryin the body, andUnicodeDecodeErrorbeing aValueErrormakes anexcept ValueErrorlook like it covers a case it cannot reach. Independently, text mode applies universal-newline translation, so the copy is not byte-exact even when every byte is valid UTF-8. Two questions worth asking of any such loop: does anexcept ValueErrorin the body actually catch the decode, and is the handle text mode when the contract is a verbatim copy.Second candidate, from this PR's own review history: when a change adds a discriminator to a dedupe key, ask what the discriminator JOINS as well as what it SPLITS. Two revisions here each fixed one equality class and broke the other, because
True == 1and1 == 1.0both hold and hash equal.Not a semgrep rule: the shape is easy to match but the judgement -- whether a site's contract is verbatim-copy or consume -- is not, so a syntactic rule would be mostly false positives on consumer reads.
Closes #7771
Refs #6345
Refs #8181
Refs #8217