Skip to content

Commit bba0f76

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 bba0f76

6 files changed

Lines changed: 280 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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Generated by Django 6.0.4 on 2026-05-13 12:00
2+
3+
import django.contrib.postgres.fields
4+
from django.db import migrations, models
5+
6+
import lando.main.models.repo
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("main", "0052_alter_profile_options"),
13+
]
14+
15+
operations = [
16+
migrations.AlterField(
17+
model_name="repo",
18+
name="hooks",
19+
field=django.contrib.postgres.fields.ArrayField(
20+
base_field=models.CharField(
21+
choices=[
22+
(
23+
"PreventSymlinksCheck",
24+
"Check for symlinks introduced in the diff.",
25+
),
26+
(
27+
"TryTaskConfigCheck",
28+
"Check for `try_task_config.json` introduced in the diff.",
29+
),
30+
(
31+
"PreventDotGithubCheck",
32+
"Prevent changes to GitHub workflows directory.",
33+
),
34+
(
35+
"PreventHgDirectoryCheck",
36+
"Prevent patches from modifying .hg/ directory.",
37+
),
38+
(
39+
"PreventNSPRNSSCheck",
40+
"Prevent changes to vendored NSPR directories.",
41+
),
42+
(
43+
"PreventSignedCommitsCheck",
44+
"Prevent patches from introducing signed commits.",
45+
),
46+
(
47+
"PreventSubmodulesCheck",
48+
"Prevent introduction of Git submodules into the repository.",
49+
),
50+
(
51+
"CommitMessagesCheck",
52+
"Check the format of the passed commit message for issues.",
53+
),
54+
(
55+
"WPTSyncCheck",
56+
"Check the WPTSync bot is only pushing changes to relevant subset of the tree.",
57+
),
58+
(
59+
"BugReferencesCheck",
60+
"Prevent commit messages referencing non-public bugs from try.",
61+
),
62+
(
63+
"PreventEmptyBinaryCheck",
64+
"Detect diffs that add a binary file with zero bytes.",
65+
),
66+
],
67+
max_length=255,
68+
),
69+
blank=True,
70+
default=lando.main.models.repo.get_default_hooks,
71+
null=True,
72+
),
73+
),
74+
]

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/management/commands/migrate_data.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,35 @@ def migrate_1_bug1971103_add_firefox_required_automation_permissions(
9797
self.stdout.write(f"{repo.name} ", ending="")
9898

9999
self.stdout.write("done.")
100+
101+
def migrate_2_bug1709608_enable_prevent_empty_binary_hook(
102+
self, ask_confirm: bool = True
103+
):
104+
"""
105+
Enable the `PreventEmptyBinaryCheck` hook for all Phabricator repos.
106+
107+
See bug 1709608: a Phabricator diff whose `creationMethod` is `commit`
108+
can land binary files as zero bytes. The hook defends every landing
109+
path, but it only fires when enabled on a repo.
110+
"""
111+
hook = Repo.HooksChoices.PreventEmptyBinaryCheck.value
112+
phab_repos = Repo.objects.filter(is_phabricator_repo=True).exclude(
113+
hooks__contains=[hook]
114+
)
115+
if not phab_repos:
116+
self.stdout.write(
117+
f"No Phabricator repo found without the {hook} hook."
118+
)
119+
raise SystemExit()
120+
121+
repo_names = ", ".join(repo.name for repo in phab_repos)
122+
self._get_confirmation(
123+
ask_confirm, f"Enabling {hook} hook for: ", repo_names
124+
)
125+
126+
for repo in phab_repos:
127+
repo.hooks = (repo.hooks or []) + [hook]
128+
repo.save()
129+
self.stdout.write(f"{repo.name} ", ending="")
130+
131+
self.stdout.write("done.")

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)