Skip to content

Commit 791a5dc

Browse files
committed
transplants: warn on zero-byte binary file additions (bug 1709608)
Surface zero-byte binary file additions to the landing user as a warning that must be acknowledged before landing. Bug 1709608 has recurred multiple times since 2021: when Lando lands using a Phabricator diff whose `creationMethod` is `commit` (the auto-generated diff Phabricator creates from a landed commit), binary file payloads have been stripped, so the file lands as 0 bytes. Most recently this hit bug 2034984 (where both the test and reference image in a reftest landed as 0 bytes, causing the comparison to silently pass and removing test coverage with no signal) and bug 2005089 (Android sample-app launcher icons landed as 0 bytes). The 2023-12 fix in #364 only covered the uplift creation path; non-uplift relands remain vulnerable. As an interim safety net, add a warning that inspects the rendered patch and surfaces any zero-byte binary additions. A follow-up will add a targeted blocker that uses `differential.querydiffs` to detect the regression case (binary file went from non-zero to zero between diff versions on the same revision) and block automatically, without bothering authors of legitimate empty-binary fixtures. The new `PreventEmptyBinaryCheck` is written as a neutral detection primitive (no warn/block opinion in its message) so the follow-up blocker can reuse it unchanged.
1 parent 5e2572b commit 791a5dc

4 files changed

Lines changed: 174 additions & 0 deletions

File tree

src/lando/api/legacy/transplants.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
from lando.main.support import LegacyAPIException
4949
from lando.utils.landing_checks import (
5050
DiffAssessor,
51+
PreventEmptyBinaryCheck,
5152
PreventNSPRNSSCheck,
5253
PreventSubmodulesCheck,
5354
PreventSymlinksCheck,
@@ -536,6 +537,28 @@ def warning_multiple_authors(
536537
return f"Revision has multiple authors: {', '.join(author_usernames)}."
537538

538539

540+
@RevisionWarningCheck("Adds zero-byte binary files (bug 1709608).")
541+
def warning_empty_binary_files(
542+
revision: dict, diff: dict, stack_state: StackAssessmentState
543+
) -> str | None:
544+
"""Warn when the active diff adds binary files with zero bytes.
545+
546+
Symptom of bug 1709608: diffs sourced from a landed commit have their
547+
binary payloads stripped by Phabricator, so a re-land using such a diff
548+
silently produces empty binary files in the tree. This warning surfaces
549+
the situation; the targeted blocker (later) catches the regression case
550+
automatically. Implemented as a warning so legitimately-empty binary
551+
fixtures (rare but real -- e.g. `empty.xpi`, `0_sized_file`) can still
552+
be landed by acknowledging the warning.
553+
"""
554+
diff_id = PhabricatorClient.expect(diff, "id")
555+
parsed_diff = stack_state.parsed_diffs[diff_id]
556+
557+
diff_assessor = DiffAssessor(parsed_diff=parsed_diff)
558+
if issues := diff_assessor.run_diff_checks([PreventEmptyBinaryCheck]):
559+
return issues[0]
560+
561+
539562
def blocker_user_no_auth0_email(
540563
stack_state: StackAssessmentState,
541564
) -> str | None:
@@ -899,6 +922,7 @@ def blocker_try_task_config(
899922
warning_wip_commit_message,
900923
warning_unresolved_comments,
901924
warning_multiple_authors,
925+
warning_empty_binary_files,
902926
]
903927

904928

src/lando/main/models/repo.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ class HooksChoices(models.TextChoices):
111111
"BugReferencesCheck",
112112
"Prevent commit messages referencing non-public bugs from try.",
113113
)
114+
PreventEmptyBinaryCheck = (
115+
"PreventEmptyBinaryCheck",
116+
"Detect diffs that add a binary file with zero bytes.",
117+
)
114118

115119
@property
116120
def path(self) -> str:

src/lando/utils/landing_checks.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,58 @@ def result(self) -> str | None:
336336
)
337337

338338

339+
@dataclass
340+
class PreventEmptyBinaryCheck(PatchCheck):
341+
"""Detect diffs that add a binary file with zero bytes.
342+
343+
Symptom of bug 1709608: when Lando lands using a Phabricator diff whose
344+
`creationMethod` is `commit`, binary file payloads have been stripped
345+
and the file would land as 0 bytes. The fix that landed for bug 1709608
346+
only covers the uplift creation path; this check defends every landing
347+
path by inspecting the rendered patch directly, so it is root-cause-
348+
agnostic.
349+
350+
The check produces a list of offending filenames; callers decide whether
351+
to surface this as a warning or a blocker.
352+
"""
353+
354+
@override
355+
@classmethod
356+
def name(cls) -> str:
357+
return "PreventEmptyBinaryCheck"
358+
359+
@override
360+
@classmethod
361+
def description(cls) -> str:
362+
return "Detect diffs that add a binary file with zero bytes."
363+
364+
empty_binary_files: list[str] = field(default_factory=list)
365+
366+
def next_diff(self, diff: dict):
367+
# Only flag *additions* of binary files. Modifications and deletions
368+
# legitimately have zero or partial-binary payloads.
369+
if not diff.get("binary") or diff.get("deleted") or not diff.get("new"):
370+
return
371+
372+
# `binary_hunk_size` from `rs_parsepatch` is e.g. [("literal", 87)] or
373+
# [("literal", N), ("delta", M)]. A pure-add of an empty file shows up
374+
# as a single ("literal", 0) hunk; sum across hunks defensively.
375+
total = sum(size for _kind, size in diff.get("binary_hunk_size") or [])
376+
if total == 0:
377+
self.empty_binary_files.append(diff["filename"])
378+
379+
def result(self) -> str | None:
380+
if self.empty_binary_files:
381+
return (
382+
"Revision adds zero-byte binary files "
383+
f"({wrap_filenames(self.empty_binary_files)}). This usually "
384+
"means the active diff was sourced from a landed commit and "
385+
"its binary payloads were stripped by Phabricator. Re-submit "
386+
"the revision with `moz-phab` to restore the binary content. "
387+
"See bug 1709608."
388+
)
389+
390+
339391
@dataclass
340392
class PreventSymlinksCheck(PatchCheck):
341393
"""Check for symlinks introduced in the diff."""

src/lando/utils/tests/test_landing_checks.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
LandingChecks,
1717
PatchCollectionAssessor,
1818
PreventDotGithubCheck,
19+
PreventEmptyBinaryCheck,
1920
PreventHgDirectoryCheck,
2021
PreventNSPRNSSCheck,
2122
PreventSignedCommitsCheck,
@@ -646,6 +647,99 @@ def test_check_prevent_submodules():
646647
), "Check should prevent revisions from introducing submodules."
647648

648649

650+
# Patches exercising the binary file paths in `rs_parsepatch`. These reflect
651+
# the GIT binary patch format Phabricator emits via `differential.getrawdiff`.
652+
GIT_DIFF_EMPTY_BINARY_ADD = """\
653+
diff --git a/empty.png b/empty.png
654+
new file mode 100644
655+
GIT binary patch
656+
literal 0
657+
HcmV?d00001
658+
659+
"""
660+
661+
GIT_DIFF_NONEMPTY_BINARY_ADD = """\
662+
diff --git a/real.png b/real.png
663+
new file mode 100644
664+
GIT binary patch
665+
literal 87
666+
zcmZ?wbhEHbWMp7uXkdSr1_lE>3=9k!85kJ?7+e9j7+M3l85kIz?VMfFP&3pMz`(EJ
667+
o&%nUd&(O%n&CtNI%g_nQz5(_qzaP=zsBtDUMI;Cj1_p+T0VKi-RR910
668+
669+
"""
670+
671+
GIT_DIFF_BINARY_DELETE = """\
672+
diff --git a/old.png b/old.png
673+
deleted file mode 100644
674+
GIT binary patch
675+
literal 0
676+
HcmV?d00001
677+
678+
"""
679+
680+
681+
def test_check_prevent_empty_binary_text_only():
682+
"""Text-only diffs are unaffected."""
683+
parsed_diff = rs_parsepatch.get_diffs(
684+
GIT_DIFF_FILENAME_TEMPLATE.format(filename="src/foo.cpp")
685+
)
686+
check = PreventEmptyBinaryCheck()
687+
for diff in parsed_diff:
688+
check.next_diff(diff)
689+
assert check.result() is None, "Text-only diffs must not trigger the check."
690+
691+
692+
def test_check_prevent_empty_binary_nonempty_addition():
693+
"""Adding a non-empty binary file is allowed."""
694+
parsed_diff = rs_parsepatch.get_diffs(GIT_DIFF_NONEMPTY_BINARY_ADD)
695+
check = PreventEmptyBinaryCheck()
696+
for diff in parsed_diff:
697+
check.next_diff(diff)
698+
assert check.result() is None, (
699+
"Adding a binary file with content must not trigger the check."
700+
)
701+
702+
703+
def test_check_prevent_empty_binary_deletion():
704+
"""Deleting a binary file is allowed even though its hunk is zero-sized."""
705+
parsed_diff = rs_parsepatch.get_diffs(GIT_DIFF_BINARY_DELETE)
706+
check = PreventEmptyBinaryCheck()
707+
for diff in parsed_diff:
708+
check.next_diff(diff)
709+
assert check.result() is None, "Deleting a binary file must not trigger the check."
710+
711+
712+
def test_check_prevent_empty_binary_addition():
713+
"""Adding a zero-byte binary file is the bug 1709608 signature."""
714+
parsed_diff = rs_parsepatch.get_diffs(GIT_DIFF_EMPTY_BINARY_ADD)
715+
check = PreventEmptyBinaryCheck()
716+
for diff in parsed_diff:
717+
check.next_diff(diff)
718+
result = check.result()
719+
assert result is not None, "Adding a zero-byte binary file must trigger the check."
720+
assert "empty.png" in result
721+
assert "1709608" in result
722+
723+
724+
def test_check_prevent_empty_binary_mixed():
725+
"""In a mixed diff, only the empty addition is reported."""
726+
parsed_diff = rs_parsepatch.get_diffs(
727+
GIT_DIFF_EMPTY_BINARY_ADD
728+
+ GIT_DIFF_NONEMPTY_BINARY_ADD
729+
+ GIT_DIFF_FILENAME_TEMPLATE.format(filename="src/bar.cpp")
730+
)
731+
check = PreventEmptyBinaryCheck()
732+
for diff in parsed_diff:
733+
check.next_diff(diff)
734+
result = check.result()
735+
assert result is not None
736+
assert "empty.png" in result
737+
assert "real.png" not in result, (
738+
"Non-empty binary additions must not appear in the report."
739+
)
740+
assert "src/bar.cpp" not in result, "Text additions must not appear in the report."
741+
742+
649743
def test_check_bug_references_public_bugs():
650744
patch_helper = HgPatchHelper.from_string_io(
651745
io.StringIO(

0 commit comments

Comments
 (0)