Skip to content

feat(git): [#2175] accept declared symbolic links in the maintainer merge tool - #2184

Merged
josecelano merged 18 commits into
torrust:developfrom
da2ce7:2175-merge-tool-symlink-exceptions
Sep 10, 2026
Merged

josecelano merged 18 commits into
torrust:developfrom
da2ce7:2175-merge-tool-symlink-exceptions

Conversation

@da2ce7

@da2ce7 da2ce7 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Closes #2175

Summary

The vendored maintainer merge tool refuses a merge whose tree contains any symbolic link, with no way to admit a link a repository carries on purpose. That refusal currently blocks every merge in torrust/torrust-index, whose tree carries one deliberate link (.dockerignore pointing at .containerignore, so Docker and Podman share a single ignore list), and it will block any Torrust repository that ever declares one.

This adds an explicit, auditable exception: a repository declares the links it accepts in a JSON file it commits, the tool reads that declaration out of the merged tree it has just produced at the tree path the invocation names, and it exempts exactly the declared links whose target matches byte for byte. Everything else refuses exactly as before. The check also widens from the merged tip to every commit the merge introduces, judged against that one final declaration.

The mechanism is opt-in per invocation, and the declaration's name and location are the caller's, not the tool's. The tool holds no default path and knows no file name: it reads the tree path --symlinks gives it, so a run without the argument reads nothing and behaves exactly as the tool did before. .symlinks.json at the repository root is this repository's convention, stated in exactly one place, the unconditional --symlinks .symlinks.json in merge-pull-request.sh; a repository adopting the workflow may keep its declaration anywhere in its tree and name it in its own wrapper. This repository's tree carries no symbolic link and this PR adds no declaration file, so no tracker merge changes behavior.

What changed, per file

  • contrib/dev-tools/git/github-merge.py — new --symlinks <tree path> argument with no default value, validated as a repository-relative tree path (an absolute value or one containing .. is a usage error, exit 2). The tip-only refusal is replaced by a check over every commit in pull/<n>/base..pull/<n>/head plus the local merge commit, each listed with git ls-tree --full-tree -r -z <commit> under the same mode mask as before, so the paths compared are the tree's own bytes rather than git's rendering of them, and every path and target comparison runs on bytes. The declaration is read once, from the merge commit, with git show <merge commit>:<path>. New helpers: get_symlink_entries, read_symlink_declaration, symlink_declaration_path_error, symlink_target_is_confined, check_symlinks; get_symlink_files is replaced by the first of these.
  • contrib/dev-tools/git/merge-pull-request.sh — passes --symlinks .symlinks.json before the positional arguments of its final exec python3, unconditionally and with no filesystem check.
  • contrib/dev-tools/git/README-github-merge.md — new "Declared Symbolic Links" section with the declaration format and the full rule set; the provenance section rewritten to the single permanent statement; the known-upstream-issues section re-checked and its line references corrected; the coverage boundary updated for the new suite.
  • contrib/dev-tools/git/tests/test-github-merge-symlinks.py — new suite, 23 tests, covering the tool's own behaviour, including targets and paths outside ASCII, a target that is not valid UTF-8, and control characters in reported values.
  • contrib/dev-tools/git/tests/test-merge-pull-request.sh — the delegation assertion updated for the new argument list, plus a case asserting the path is delegated with no such file in the working tree.
  • .github/skills/dev/git-workflow/merge-pull-request/SKILL.md — a "Symbolic Links in the Merge" section for maintainers.
  • docs/issues/open/2175-merge-tool-symlink-exceptions/ — the specification brought up to date, and a new verification.md holding the evidence.

How the architectural decisions are honoured

AD1 — the tool is maintained here, not tracked against upstream. The refusal lives inside github-merge.py, before the tree hash is taken, and no wrapper argument reaches it, so the feature is implemented by editing the tool directly. The README now carries exactly one provenance statement: the file came from the Bitcoin Core developers' github-merge.py (MIT, retained in github-merge-COPYING), with SHA-256 e390eb014131f3183a2cba642134974a6b09b19a65322d17dd7c81cf4ffbaad2, in commit 833a4160e5753d54cde47bcc4bed25df7a04c0f6 (2026-07-23). The byte-identity requirement, the per-change re-hashing, and the local-change policy are gone; git history records what changed after arrival more precisely than a hash could. The known upstream defects stay recorded rather than fixed, now because each is a separate defect with its own review rather than because a policy forbids touching the file.

AD2 — no default path inside the tool. --symlinks has default=None, so an omitted argument is distinguishable from any path and disables declaration processing entirely: no file is read, nothing is exempted, and no accepted-link or stale-entry output appears, even when the merged tree carries a .symlinks.json that would have declared the link. The wrapper states the repository's path once. Scenario M8 in verification.md demonstrates exactly this.

Validation

All runs on the project build server at idle scheduling priority; the first four re-run at the review-round tip, the pre-push gate from the original tip 80562d1.

Check Exit Wall
python3 contrib/dev-tools/git/tests/test-github-merge-symlinks.py (23 tests) 0 8.2 s
bash contrib/dev-tools/git/tests/test-merge-pull-request.sh 0 1.2 s
linter all 0 17.8 s
TORRUST_GIT_HOOKS_LOG_DIR=.tmp ./contrib/dev-tools/git/hooks/pre-commit.sh (6 of 6 steps) 0 33.3 s
TORRUST_GIT_HOOKS_LOG_DIR=.tmp ./contrib/dev-tools/git/hooks/pre-push.sh (4 of 4 steps) 0 185.7 s

Both suites leave the working tree clean, which the merge workflow depends on since it refuses to start on a dirty tree.

The eight manual scenarios of the specification were executed against fixture repositories and their output is recorded in docs/issues/open/2175-merge-tool-symlink-exceptions/verification.md: a declared link is accepted and printed with its reason before the signing prompt (M1); an undeclared link refuses with exit 4, naming each carrying commit (M2); a declared absolute target and a declared .. target both refuse (M3); a missing declaration file, reached through the wrapper, reproduces today's refusal with no error of its own (M4); a declaration present only in the working directory or only on the base branch exempts nothing (M5); a link carried only by an intermediate commit refuses and names that commit, which a tip-only check would have missed (M6); a removal pull request refuses when it drops the entry and passes with a stale-entry report when it retains it (M7); and an omitted argument refuses a declared link and prints no accepted or stale output (M8).

Notes for review

  • The specification's stale-entry rule contradicted its own AC7: an entry is now stale when its path is not a symbolic link in the merged result, which is the only reading under which the retained entry of a removal pull request is both the thing that admits the pre-deletion commit's link and reported as stale. The correction is in the spec, the README rule list, and the tool, and the reasoning is in the spec's progress log.
  • Where the specification was silent, the smallest reading was taken and recorded in the progress log: merge commits inside the range are walked, the commit-naming refusal message is used unconditionally, an unparsable declaration exempts nothing and reports why, an unusable --symlinks value is an ordinary usage error, and the namespace field is documented but not enforced.
  • The sibling repository torrust/torrust-index adds its own .symlinks.json and its own wrapper argument once this lands; mirroring is deliberately out of scope here.

The merge tool refuses every symbolic link a merge introduces, and offers no
way to admit one a repository carries on purpose. Describe the declaration
file that admits them, .symlinks.json in the repository root, together with
the rules the tool applies to it: which commits are checked, that the
declaration is read from the final merged tree alone, that matching runs from
the trees to the file and is byte-exact, that absolute and escaping targets
are never accepted whatever the file says, and that the mechanism only runs
when an invocation names a declaration path.

Documenting the format before the tool implements it keeps the format
reviewable on its own and gives the implementation a written contract to
satisfy.
The merge tool refused every symbolic link in the merged tip, so a repository
that carries a link on purpose could not reach signing at all and had no way
to say which link it accepts. Add an optional --symlinks argument naming a
declaration inside the merged tree, and check the links against it.

The declaration is read from the local merge commit the tool has just
created, never from the working directory or from a branch, so the links and
the statement that admits them come from one reviewed tree and no state in
the maintainer's environment can change the verdict. The check now walks
every commit the merge introduces rather than the tip alone, because a link
that appears mid-range and disappears before the tip still resolves on every
checkout of the commit that carries it; each refusal names that commit. The
final declaration judges the whole range, so no commit is admitted by a
statement the merge result has withdrawn.

Matching runs from the trees to the declaration and is byte-exact, an
absolute or escaping target is refused whatever the declaration says, and
every admitted link is printed with its reason before the maintainer signs.
The argument carries no default: a run without it reads no declaration and
behaves as the tool did before, so an exemption is always visible in the
invocation that grants it.
…apper

The merge tool holds no default declaration path, so a run that does not name
one refuses every symbolic link the merge introduces. Pass the tracker's path
from the wrapper, which is where repository-specific wiring already lives, so
the exemption is stated once and is visible in the invocation that grants it.

The value names a location in the merged tree the tool has yet to create, not
a file in the maintainer's working directory, so the wrapper deliberately
performs no existence check: it cannot observe that tree, and an absent
declaration is not an error in the tool either.
The wrapper suite asserted the exact argument list the wrapper delegates, so
naming the declaration path broke that assertion; update it and add a case
showing the path is passed with no file of that name in the working tree,
which is the tracker's own situation and the reason the wrapper cannot check
for one.

The tool's own behavior needs a repository to observe, so add a suite that
builds one per case, together with the bare upstream that publishes the pull
request to it. A URL rewrite sends the tool's fetch to that upstream, so the
real fetch and merge paths run with no network and no GitHub API, and a
driver replaces the three API calls with fixture data and answers the signing
prompt with a refusal, which makes a run that passes the check exit 1 and a
run the check refuses exit 4.

The cases cover what the declaration decides: a declared link is admitted, an
undeclared one, a different target, an escaping target and an absolute target
each refuse; a declaration exempts nothing when it is only in the working
directory, only on the target branch, unreadable, or when the invocation
names none at all; a link that only an intermediate commit carries refuses
and names that commit; and a removal keeps or drops the entry with the two
outcomes the rules describe.
… policy

The vendoring record asked for byte-identity with the origin copy and a new
provenance hash after every local change, and the known-issues section
justified leaving real defects unfixed by that requirement. The copy is not
tracking its origin: its consumers are Torrust repositories whose needs
Bitcoin Core does not share, and the first of those needs, a declaration for
the symbolic links a repository accepts, cannot be met from the wrapper at
all because the refusal lives inside the tool.

Record the provenance the way a reader uses it: one permanent statement of
where the file came from, under what license, and what it looked like on
arrival. Everything after arrival is what git history is for, recorded more
precisely than a hash in a README. Re-check the known upstream issues on that
footing: they stay recorded because each is a separate defect with its own
review, not because a policy forbids fixing them, and their line references
are corrected for the file as it now stands.

Also name the two test suites the tool now has in the coverage boundary.
…ners

The merge workflow now admits declared symbolic links, and a maintainer meets
that behavior mid-merge unless the skill describes it first. State what the
merge output shows, that an undeclared link stops the run before there is
anything to sign, and that the commit named in the message may be an
intermediate one rather than the tip.

The two properties that surprise a reader are worth naming explicitly: the
declaration is read from the merged result rather than from the working copy,
so a local file grants nothing, and removing a declared link takes two
changes, so a stale-entry report on a removal pull request is the expected
outcome rather than a defect.
…tion

Add the issue-local verification record with the automatic checks, the
manual scenarios M1-M8, and the acceptance evidence, and bring the
specification up to date with what was implemented: task statuses, workflow
checkpoints, scenario and acceptance tables, and progress-log entries for the
readings the specification left open and how each was settled.

Correct the stale-entry rule while doing so. Defining a stale entry as one
matching no symbolic link in any checked commit contradicts AC7 and the
removal rule that follows it, because the entry a removal pull request
retains does match the link in its own pre-deletion commit and must still be
reported as stale. An entry is stale when its path is not a symbolic link in
the merged result, which is the reading both statements share and the one the
tool implements.
Copilot AI lite review requested due to automatic review settings September 9, 2026 10:30
@da2ce7
da2ce7 requested a review from a team as a code owner September 9, 2026 10:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Adds an opt-in, auditable mechanism for allowing specific symbolic links during maintainer merges by introducing a symlink declaration file read from the merged result and validating symlinks across the entire commit range introduced by a PR.

Changes:

  • Add --symlinks <tree path> to github-merge.py, read/parse a declaration from the merge commit, and check symlinks across pull/<n>/base..pull/<n>/head plus the merge commit.
  • Update merge-pull-request.sh to always delegate --symlinks .symlinks.json (tree path, no working-tree existence check).
  • Add comprehensive tests and documentation/spec updates, including manual verification evidence.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
contrib/dev-tools/git/github-merge.py Implements declaration parsing and commit-range symlink enforcement behind --symlinks.
contrib/dev-tools/git/merge-pull-request.sh Delegates the declaration tree path to the vendored tool unconditionally.
contrib/dev-tools/git/tests/test-github-merge-symlinks.py Adds deterministic integration-style tests for the tool’s symlink behavior.
contrib/dev-tools/git/tests/test-merge-pull-request.sh Updates wrapper delegation assertions and adds a no-working-tree-file delegation case.
contrib/dev-tools/git/README-github-merge.md Documents the declaration format, rule set, and coverage boundary updates.
.github/skills/dev/git-workflow/merge-pull-request/SKILL.md Updates maintainer guidance for symlink behavior and tests.
docs/issues/open/2175-merge-tool-symlink-exceptions/ISSUE.md Marks tasks/AC as done and updates the spec/progress log.
docs/issues/open/2175-merge-tool-symlink-exceptions/verification.md Adds recorded evidence for automated and manual verification scenarios.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread contrib/dev-tools/git/github-merge.py Outdated
Comment thread contrib/dev-tools/git/github-merge.py Outdated
Comment thread contrib/dev-tools/git/github-merge.py Outdated
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.97%. Comparing base (696a173) to head (4bff469).
⚠️ Report is 65 commits behind head on develop.

Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #2184      +/-   ##
===========================================
+ Coverage    84.66%   84.97%   +0.31%     
===========================================
  Files          351      351              
  Lines        29657    30211     +554     
  Branches     29657    30211     +554     
===========================================
+ Hits         25108    25671     +563     
+ Misses        4197     4168      -29     
- Partials       352      372      +20     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@josecelano josecelano self-assigned this Sep 9, 2026

@josecelano josecelano left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: process the Copilot review threads

The three Copilot suggestions must be addressed before this PR can merge. Please process every Copilot review thread strictly according to the repository process-copilot-suggestions workflow: assess each suggestion against the code and applicable requirements, make the minimal correction where warranted (or record a substantiated no-action decision), reply to each thread with the rationale, resolve it individually, and update the required tracker record.

The open findings concern:

  1. per-symlink subprocess overhead during commit-range validation;
  2. errors="replace" undermining the specified byte-exact target comparison; and
  3. unescaped path or target values permitting misleading terminal diagnostics.

Please run the prescribed validation after addressing them and report the result.

@da2ce7

da2ce7 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

The location and name of '.symlinks.json' is a repo convention. The tool itself has no notion of the actual location and name of the symlinks manifest. - That the file is accepted is defined by the namespace the tool encodes. Will correct issue and pull request to make this fact explicit.

A path, a link target, and a declaration reason are all chosen by whoever wrote the commit, and a value may legally carry a newline or a terminal escape. Interpolated into the report as it stands, such a value forges lines the tool never wrote: a target ending in a newline and the text of a refusal prints an error for a file that was never refused, and the maintainer reads it moments before signing.

Paths and targets are now quoted and escaped for display, and the reason passes through the sanitizer the tool already applies to text it did not write. An ordinary path or target renders exactly as before, so the documented message shapes are unchanged; the escaping is a rendering only and never reaches the comparison that decides which links are admitted.
The rules say a declared target must equal the link's content byte for byte, and the check decided the match on a decoded rendering instead. The link blob was decoded with a replacing decoder, so every byte that is not valid UTF-8 became the same replacement character: distinct targets collapsed onto one string, and a declaration naming that string admitted a link whose content it does not describe. The path was decoded from a listing git renders rather than from the tree, which quotes control characters always and bytes outside ASCII under the default configuration, so a declaration naming a path outside ASCII could not match the link it describes.

Paths and targets are now read and compared as the bytes the tree carries, and the declaration's strings are encoded once to the bytes they stand for. A target that is not valid UTF-8 has no declaration it can equal and is refused, two links whose contents differ can no longer match one entry, and a path outside ASCII matches the entry that names it. Listing with '-z' is what makes the path available at all rather than its rendering.
The range walk read a link's content from the object store once per commit that carried it, so the cost of the check grew with commits times links even though a link usually keeps the same content across a whole pull request. Each read is its own process, and process startup dominates a read this small.

Identical content is one blob whatever the path or the commit, so the walk now carries the contents it has already read and asks the object store only for the ones it has not. Keyed by object id, the reuse cannot answer for a link whose content changed: a changed target is a different blob and is read, which is what the existing case for a target changing inside the range asserts.

Measured over a fifty-commit range: with one link per commit, 262 ms to 143 ms; with twenty, 2778 ms to 188 ms. What is left is one tree listing per commit, which the check cannot avoid, because whether a commit carries a link is a property of its own tree.
…nvention

The tool holds no default declaration path, and the documents said so where they discussed the argument while still describing the file as a root file at a fixed name where a reader first meets it. That reads as though `.symlinks.json` at the repository root were part of the mechanism, and it is not: the tool reads whatever tree path `--symlinks` names and attaches no meaning to the name, while this repository's choice of path is stated once, in the wrapper's argument.

The objective, the declaration-format section, and the merge skill now say that where they describe the file, so a repository adopting the workflow reads the same fact wherever it starts.
Records the round: the three review threads, what each was decided to be and why, the commit that carries each fix, the measurements behind accepting one suggestion in part and declining the batch reader it proposed, and the reply that states each decision on the thread itself.
@da2ce7

da2ce7 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

The three Copilot threads are processed per process-copilot-suggestions: each has a reply with the decision and its evidence, each is resolved individually, and the tracker record is at docs/copilot-pr-reviews/pr-2184-copilot-suggestions.md (commit 82cbdb4).

Decisions, in the order they were handled:

  • Byte-for-byte target comparison (errors='replace') — action, a4e6a02: paths and targets are now compared as the tree's bytes, the listing uses ls-tree -z so paths are the tree's own rather than git's C-quoted rendering, and a target that is not valid UTF-8 has no declaration it can equal. Three tests added.
  • Control characters in reported values — action, a0b42f3: every printed path, target, and reason is escaped at the print sites only; comparisons are unchanged. Two tests added.
  • Subprocess cost of the range walk — action in part, ba60e69: each distinct link content is read once across the range (measured over 50 commits: 262 ms to 143 ms at one link per commit, 2778 ms to 188 ms at twenty). The long-lived cat-file --batch reader is declined on those measurements; the reasoning is in the thread.

A fourth commit, a0f69c7, makes explicit in the README, the merge skill, and the specification that the declaration's name and location are a repository convention stated once in merge-pull-request.sh, not something the tool knows; the PR body and the issue body say the same now.

Validation at 82cbdb4 on the build host: test-github-merge-symlinks.py 23 tests pass (8.2 s), test-merge-pull-request.sh passes (1.2 s), linter all exit 0 (18.1 s), contrib/dev-tools/git/hooks/pre-commit.sh 6 of 6 steps exit 0 (32.8 s). The vendored file's provenance statement is unchanged.

@da2ce7
da2ce7 requested a review from josecelano September 9, 2026 15:21

@josecelano josecelano left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: validate the declaration format contract

contrib/dev-tools/git/github-merge.py currently accepts an arbitrary JSON object containing a symlinks array. It does not validate the required manifest namespace (com.torrust.repository.symlinks) or its supported version before constructing exemptions.

This contradicts the documented format in contrib/dev-tools/git/README-github-merge.md and the accepted contract in issue #2175. It means a foreign or malformed manifest could authorize symbolic links.

Please validate the exact namespace and supported version before reading declarations, and fail closed (admit no exemptions) whenever either field is missing or unsupported. Add focused negative tests covering missing, incorrect, and unsupported namespace/version values, then run the relevant test suites and prescribed validation gate.

The declaration's header states which format a document belongs to and which revision of that format it was written to, and the check read neither. Any JSON object carrying a 'symlinks' array was read as a declaration of this format, so a document written to some other format, or to a revision whose rules this tool has not been taught, could authorize a symbolic link on the strength of a field name it happens to share.

Both header fields are now checked before a single entry is read: 'namespace' must be exactly 'com.torrust.repository.symlinks' and 'version' exactly [1, 0, 0]. A document that omits either field, or carries another value for it, exempts nothing and takes the path a declaration that cannot be read already takes, so the report names the field that disagreed and every link refuses exactly as it does with no declaration at all.

The comparison is exact and type-strict. Python compares True to 1 and 1 to 1.0 as equal, while JSON's true, 1 and 1.0 are three different documents, so a comparison resting on equality alone would read [true, 0, 0] as the supported version. An unsupported version fails closed rather than being read under the rules of the version this tool does know, which leaves a later revision of the format to teach the tool its version rather than pass unread. The values a message reports come out of a tree, so each is rendered as the JSON it came from, which escapes every control character and cannot forge a line of the report.
Seven cases fix the header's meaning: a declaration missing its namespace, one naming another format, one whose namespace is that string inside an array, a declaration missing its version, one naming an unsupported version, one whose version is a string rather than an integer triple, and one whose version carries a boolean where an integer is documented. That last case is the one a comparison resting on Python equality alone would admit.

Each commits a symbolic link together with an entry that would admit it, so the header is the only thing between the link and an exemption, and each asserts both halves of the behaviour: the link is refused with the error naming the commit that carries it, and the report says which field disagreed. Asserting the warning alone would pass against a tool that warned and exempted anyway.

The declaration builder takes the header fields as arguments defaulting to the documented values, with a sentinel for a field the document does not carry, so a case about the header builds exactly the document it is about and the accepting case keeps building the documented one.
The format section showed the namespace and the version without saying they are read, and the rules covered only a declaration that cannot be parsed, so a reader could not tell whether a document of another format was one of those. Both documents now carry the rule where a reader meets the fields: the header is checked before any entry, the values are matched as the JSON values they are, and a document that omits either field or carries another value exempts nothing.

The specification's progress log records the reversal of the earlier reading, which held the namespace documented but not enforced because no rule made the tool validate it, together with why an unsupported version has to fail closed.
@da2ce7

da2ce7 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Thank you, you are right: the tool accepted any JSON object carrying a symlinks array, so a document written to another format, or to a revision of this one the tool has not been taught, could authorize a link on the strength of a field name it happens to share. The header is now validated before a single entry is read.

What is validated. namespace must be exactly com.torrust.repository.symlinks and version exactly [1, 0, 0], the values the README's "Declaration format" section and the specification both document. Both are checked in read_symlink_declaration before the symlinks array is looked at.

How it fails closed. A missing field, a wrong value, and a wrong-typed value all return the same (declared={}, problem=...) shape a malformed declaration already returned, so they take the path that was already there: WARNING: Ignoring the symlink declaration '.symlinks.json' because <field disagreement>. No symbolic link is exempted., and every link then refuses with ERROR: File '<path>' was a symlink in commit <hash> and exit code 4, exactly as with no declaration at all. Nothing is exempted on the way through, so no path exists by which an unrecognized header widens what a merge admits.

The message names the field, the value found, and the value expected. Values out of a tree are chosen by whoever wrote the commit, so each is rendered as the JSON it came from, which escapes every control character and everything outside ASCII and cannot forge a line of the report.

Two readings the documents left open, recorded in the specification's progress log:

  • An unsupported version fails closed rather than being read. The documented version is the single value [1, 0, 0], and neither document states a compatibility policy. Admitting an unknown revision would mean reading a document under rules it was not written to, which is the fail-open direction on a check that decides what a merge admits, so a later revision of the format has to teach the tool its version.
  • The comparison is type-strict. Python compares True to 1 and 1 to 1.0 as equal, while JSON's true, 1 and 1.0 are three different documents, so a comparison resting on equality alone would read [true, 0, 0] as the supported version. There is a test for exactly that.

Commits

  • 1c844194 fix(git): validate the symlink declaration namespace and version
  • 4bea6cfc test(git): cover the symbolic-link declaration header
  • 8ff85d5b docs(git): state that the declaration header is validated first

Tests. Seven focused cases in DeclarationHeaderTest, each committing a symbolic link together with an entry that would admit it, so the header is the only thing between the link and an exemption. Each asserts both halves: the link is refused with the error naming the commit that carries it, and the report says which field disagreed; asserting the warning alone would pass against a tool that warned and exempted anyway.

  • it_should_ignore_a_declaration_that_carries_no_namespace
  • it_should_ignore_a_declaration_belonging_to_another_format
  • it_should_ignore_a_declaration_whose_namespace_is_not_a_string
  • it_should_ignore_a_declaration_that_carries_no_version
  • it_should_ignore_a_declaration_of_an_unsupported_version
  • it_should_ignore_a_declaration_whose_version_is_not_an_integer_triple
  • it_should_ignore_a_declaration_whose_version_carries_a_boolean

The accepting case is unchanged: it_should_accept_a_declared_symbolic_link builds its declaration from the documented header, so the same values these cases vary are the ones that still admit a declared link.

Documentation. The vendoring README's rules now state that the header is checked before any entry and that a mismatch exempts nothing, and the format section says both fields are required and read exactly. The issue #2175 specification carries the matching rule, and its progress log records the reversal of the earlier reading that held the namespace documented but not enforced.

Validation, all on the branch tip:

  • test-github-merge-symlinks.py: 30 tests, OK, 10.1s
  • test-merge-pull-request.sh: all wrapper tests passed, 1.2s
  • linter all: exit 0, 53.0s
  • pre-commit.sh: all six steps PASS, 67.8s
  • Manual scenarios M1-M8 for this issue: unchanged verdicts, 3.6s. M1 still accepts the declared link and M7b still passes with the stale-entry report, so the header check did not narrow the accepting paths.

@da2ce7
da2ce7 requested a review from josecelano September 9, 2026 16:36
@josecelano

Copy link
Copy Markdown
Member

Thanks — the namespace/version validation and associated tests address the prior finding. One remaining blocker from the focused re-review: Python json.loads() accepts non-standard constants such as NaN, Infinity, and -Infinity by default. That permits a declaration which is not valid JSON to be processed, contrary to the documented fail-closed rule for invalid declarations.

Please reject these constants explicitly (for example, via a rejecting parse_constant handler) and add negative tests covering them. After the focused tests and required validation pass, I will re-review for approval.

A declaration is read with Python's JSON reader, which accepts NaN, Infinity and -Infinity although JSON defines none of them. A document carrying one of those was therefore read here and would be refused by every conforming reader elsewhere, so a file that is not valid JSON could authorize a symbolic link, and the rule that a declaration which cannot be read exempts nothing never applied to it.

The reader is now told to refuse all three. The refusal is a ValueError, which is what invalid JSON already raises at this call, so the document takes the one path a broken declaration already takes: nothing is declared, every link refuses exactly as it does with no declaration at all, and the report names the constant that was found rather than a second failure surface. Position in the document is not a distinction the reader can make, so a constant in the header, in a top-level field, or inside an entry all fail the same way and are caught before a single entry is read.

The two remaining reads of JSON in this tool parse GitHub API responses rather than repository content. They are a different trust class: the response is not the input that removes a refusal, it is discarded on any exception already, and no value it carries can exempt a link, so they are left as they are.
Ten cases fix what a declaration may spell. Nine commit a symbolic link together with an entry that would admit it, under a document whose only fault is NaN, Infinity or -Infinity, each of the three constants placed in the version triple, in a top-level field, and inside an entry. The tenth is the same document with no constant in it, admitting that same link, so each of the nine refusals is the constant rather than the document those cases vary.

The two positions outside the header are the ones that decide the behaviour: nothing else in those documents disagrees, so a reader that admitted the constant would exempt the link. The version triple covers the third position and shows the refusal comes before any rule of this format is applied, since the report names the constant rather than the field the header check would otherwise have refused.

Each case asserts both halves: the link is refused with the error naming the commit that carries it, and the report carries the whole warning line naming the constant that was found. Asserting the warning alone would pass against a tool that warned and exempted anyway. The documents are written as text rather than serialized from Python values, because a constant JSON does not define is a violation of the grammar rather than a value a serializer would be asked for, and what a case is about is exactly the bytes its commit carries.
The rules said a declaration that cannot be read exempts nothing, and left the reader's own latitude unstated, so a reader could not tell that a document spelling NaN, Infinity or -Infinity was one of those rather than a document those rules had nothing to say about. Both documents now carry the rule beside the header rule it precedes: the document is read as JSON and nothing wider, the three constants JSON does not define are refused wherever they appear, and such a document exempts nothing and reports the constant it carried.

The specification's progress log records why the gap existed, that the reader the tool is built on accepts all three by default, and why the tool's two remaining reads of JSON, which parse GitHub API responses, are a different trust class and are unchanged.
@da2ce7

da2ce7 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — fixed at 4bff469.

The declaration parse now passes a parse_constant handler that raises ValueError naming the constant it was given, so NaN, Infinity and -Infinity are rejected wherever in the document they occur. The failure is routed through the existing malformed-declaration path rather than a new one: read_symlink_declaration already catches (UnicodeDecodeError, ValueError) at that call and returns the "cannot be read" message, so such a document produces

WARNING: Ignoring the symlink declaration '.symlinks.json' because it is not valid JSON (it carries the non-standard constant NaN). No symbolic link is exempted.

and every link then refuses with the usual ERROR: File ... was a symlink in commit ... and exit code 4 — the same surface and the same exit behaviour as any other invalid JSON. Because the refusal happens in the reader, it lands before the header check and before any entry is read, so position in the document is not a distinction.

Ten new tests in DeclarationConstantTest (suite total 30 -> 40, all green):

  • it_should_ignore_a_declaration_whose_version_carries_nan / _infinity / _negative_infinity
  • it_should_ignore_a_declaration_carrying_nan_in_a_top_level_field / infinity_ / negative_infinity_
  • it_should_ignore_a_declaration_carrying_nan_inside_an_entry / infinity_ / negative_infinity_
  • it_should_accept_the_same_declaration_with_no_constant_in_it

Each of the nine commits the link together with an entry that would admit it, so the constant is the only thing between the link and an exemption, and each asserts both the refusal naming the carrying commit and the full warning line — a tool that warned and exempted anyway would fail. The two positions outside the header are the ones that decide the behaviour: nothing else in those documents disagrees, so a reader admitting the constant would exempt the link. The tenth is the same document without a constant, still accepted, which fixes that the nine refusals are the constant rather than the template.

The tool's two other JSON reads are in retrieve_json, parsing GitHub API responses. They are a different trust class and are unchanged: the declaration is repository content that removes a refusal, while an API response supplies pull request metadata that cannot exempt anything, is already sanitized, and is discarded on any exception (except Exception returns None, which the caller treats as unavailable). The reasoning is recorded in the specification's progress log.

Gates at 4bff469e: py_compile 0.1s; symlink suite 40 tests OK 13.1s; merge-workflow wrapper suite OK 1.1s; linter all all linters passed 18.4s; pre-commit hook 6/6 PASS 33.2s; working tree clean. Documentation updated in README-github-merge.md and the specification's rules and progress log.

@josecelano josecelano left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final re-review complete. The declaration parser now rejects non-standard JSON constants fail-closed, the focused coverage verifies those paths, and the implementation and documentation align with issue #2175. Approved.

@josecelano

Copy link
Copy Markdown
Member

ACK 4bff469

@josecelano
josecelano merged commit 89d4514 into torrust:develop Sep 10, 2026
18 checks passed
@da2ce7
da2ce7 deleted the 2175-merge-tool-symlink-exceptions branch September 10, 2026 09:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explicit Symlink Exceptions for the Vendored Maintainer Merge Tool

3 participants