Fix overwriting a list repository addressed by numeric index in config command - #13025
Fix overwriting a list repository addressed by numeric index in config command#13025ousamabenyounes wants to merge 4 commits into
Conversation
…g command `composer config repositories.N <type> <url>` must overwrite the existing repository at position N when repositories are stored as a JSON list. Since the repo command refactor, JsonManipulator::doRemoveRepository matched only by repository name, so a numeric key never removed the existing entry and a duplicate was added instead of replacing it (issue composer#12623). Match a numeric name against the list position so the entry at that index is removed before the new one is written back.
f9ba7b3 to
b094b54
Compare
doRemoveRepository was the only write path reading a numeric name as a list position, and it removed the entry while addRepository put the replacement back at the front (or at the back with --append) instead of in place. Resolving the name once in findRepositoryKey and reusing it from addRepository, removeRepository, insertRepository and setRepositoryUrl fixes that, and makes `repo set-url 0`, `repo add x --before 1` and numeric keys in the object format work as well. Names still take precedence over positions, and ctype_digit replaces is_numeric so that 0.9 and 1e1 are names rather than aliases for index 0 and 10. Also fixes the uniqueness filter in JsonConfigSource, whose || chain was always true, so the whole-file fallback never removed anything.
| return false; | ||
| } | ||
|
|
||
| $indexToInsert = null; |
There was a problem hiding this comment.
There is a behaviour change here which looks wrong
Have the composer.json
{
"name": "acme/package",
"repositories": [
{
"name": "a",
"type": "path",
"url": "../a"
},
{
"name": "b",
"type": "path",
"url": "../b"
},
{
"name": "c",
"type": "composer",
"url": "https://c.test"
}
]
}Run command
bin/composer repo add 0 vcs https://moved.test --before 1On main this fails with
In JsonConfigSource.php line 172:
The referenced repository "1" does not exist.
In this branch this rewrites the composer.json to
{
"name": "acme/package",
"repositories": [
{
"name": "b",
"type": "path",
"url": "../b"
},
{
"type": "vcs",
"url": "https://moved.test"
},
{
"name": "c",
"type": "composer",
"url": "https://c.test"
}
]
}Which I am not sure is correct
There was a problem hiding this comment.
I believe this is broken both in JsonConfigSource::insertRepository and JsonManipulator::insertRepository
There was a problem hiding this comment.
Good catch, that was wrong. insertRepository() was resolving the new repository's name to a position of its own, so 0 deleted the entry at position 0. Since --before/--after already state the position, the name argument there only ever names the new repository — it is now resolved by name only (findRepositoryKey($name, false) / findRepositoryIndex($repos, $name, false)), so nothing gets removed.
Your exact reproduction is now a test, and with named repositories it errors instead — see the reply on the docs thread for the rule.
There was a problem hiding this comment.
Correct, it was broken in both. Fixed in JsonManipulator::insertRepository() and in the JsonConfigSource::insertRepository() fallback, which now takes the same $allowIndex flag.
While adding your set-url by position case I found the JsonConfigSource::setRepositoryUrl() fallback had the same class of bug — it compared $name === $index, string against int, so it never matched anything in a list. That one is fixed too.
| @@ -159,20 +159,32 @@ private function sortPackages(array &$packages = []): void | |||
| */ | |||
| public function addRepository(string $name, $config, bool $append = true): bool | |||
There was a problem hiding this comment.
This test case here changes the behaviour
public function testAddRepositoryByNumericKeyInObjectFormatKeepsItsPosition(): void
{
$manipulator = new JsonManipulator('{
"repositories": {
"foo": {
"type": "composer",
"url": "https://foo.test"
},
"1": {
"type": "path",
"url": "../one"
},
"bar": {
"type": "composer",
"url": "https://bar.test"
}
}
}');
self::assertTrue($manipulator->addRepository('1', ['type' => 'composer', 'url' => 'https://replaced.test'], false));
self::assertSame(
['https://replaced.test', 'https://foo.test', '../one', 'https://bar.test'],
array_column(JsonFile::parseJson($manipulator->getContents())['repositories'], 'url')
);
}In your branch this overwrites an existing entry and afterwards the repositories section only has three entries. No matter whether append is set to true/false
There was a problem hiding this comment.
This one is intentional, and I would like to keep it. In the object format a numeric key is a name, so "1" should be replaced rather than duplicated — the reason it was not is the same int-vs-string bug as elsewhere: casting a stdClass to array yields int keys, so $repositoryIndex === $name never matched. #12623 is the list-format symptom of it; this is the object-format one, and the reporter's own target JSON in that issue uses "0"/"1" keys.
Your case is now a test (testAddRepositoryByNumericKeyInObjectFormatReplacesTheNamedEntry), asserting the three-entry result. Note it does not warn or throw: this resolves by name, not by position, so the new rule does not apply to it.
| Repositories which have no `name` can be addressed by their position in the list instead, which is | ||
| what `composer repo list` shows in brackets. The repository at that position is then replaced in | ||
| place rather than a new one being added: |
There was a problem hiding this comment.
This is only true if there are no Composer repositories configured in the global composer.json. If there are for instance two entries in the global composer.json then these have index 0, 1 in the brackets and the entries in the local composer.json start with index 2
There was a problem hiding this comment.
You are right, and it is worse than that — the numbers are not offset, they are reordered. Config::merge() reverses, re-keys on collision, then reverses back, so with two repositories in the global config.json and three locally, repo list prints:
[4] path ../l0
[3] path ../l1
[2] path ../l2
[0] composer https://g1.test
[1] composer https://g2.test
while config repositories.0 still targets ../l0, the first entry of the local file.
That killed the justification for the feature, so the rule is now:
- a repository is addressed by its name
- a numeric key falls back to the position in the file being modified, and warns that this is unreliable
- if the entry at that position has a
name, it is refused withThe repository at position 1 is named "b", address it by that name instead.
The check lives in one place (BaseConfigCommand::validateRepositoryKey()) for both the config and repo commands, and applies to whichever argument does the addressing — which for --before/--after is the ordering option, not the new name. The docs no longer claim the number is what repo list shows.
One inconsistency I deliberately left: repo get-url N still resolves against the merged config, so it can disagree with repo set-url N. That is pre-existing on the read side and we kept this change to writes only.
| }, | ||
| [['name' => 'foo', 'type' => 'vcs', 'url' => 'https://foo.test'], ['type' => 'path', 'url' => '../new']], | ||
| ], | ||
| ]; |
There was a problem hiding this comment.
When I add the test case below
'set-url by position' => [
static function (JsonConfigSource $source): void {
$source->setRepositoryUrl('1', 'https://updated.test');
},
[['name' => 'foo', 'type' => 'vcs', 'url' => 'https://foo.test'], ['type' => 'path', 'url' => 'https://updated.test']],
],
Then I run into
Composer\Pcre\PcreException : preg_match(): failed executing "{(?(DEFINE)
(?<number> -? (?= [1-9]|0(?!\d) ) \d++ (?:\.\d++)? (?:[eE] [+-]?+ \d++)? )
(?<boolean> true | false | null )
(?<string> " (?:[^"\\]*+ | \\ ["\\bfnrt\/] | \\ u [0-9A-Fa-f]{4} )* " )
(?<array> \[ (?: (?&json) \s*+ (?: , (?&json) \s*+ )*+ )?+ \s*+ \] )
(?<pair> \s*+ (?&string) \s*+ : (?&json) \s*+ )
(?<object> \{ (?: (?&pair) (?: , (?&pair) )*+ )?+ \s*+ \} )
(?<json> \s*+ (?: (?&number) | (?&boolean) | (?&string) | (?&array) | (?&object) ) )
)^(?P<start>\s*\{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?"repositories"\s*:\s*\[\s*((?&json)\s*+,\s*+){1})(?P<repository>(?&object))(?P<end>.*)}sx": Backtrack limit exhausted
There was a problem hiding this comment.
I think the PcreException was there before but somehow the code changes here make it possible/easier to reach the code path?
There was a problem hiding this comment.
Reproduced — thanks, that was a real gap. setRepositoryUrl() was the only one of the six regex methods without a PREG_BACKTRACK_LIMIT_ERROR guard, so it threw instead of degrading to the whole-file fallback. It now catches it like the others.
The fallback it degrades to was also broken ($name === $index, string vs int, never matched in a list), so your case would have silently done nothing even with the guard. Both are fixed and your set-url by position case is in provideRepositoryFallback().
There was a problem hiding this comment.
Your reading is right: pre-existing, but this branch made it reachable. On main setRepositoryUrl('1', ...) never resolved a numeric name at all, so it returned early and the regex was never built. Now that it resolves, the unguarded Preg::isMatch() is hit — which is exactly what your test surfaced.
Positions are a poor address: they shift as repositories are added or removed, and they do not match the numbers `composer repo list` prints once the global config defines repositories of its own -- with two global and three local repositories the local ones show up as [4] [3] [2] while `repositories.0` still means the first entry of the file. So keep one rule: a repository is addressed by its name, a numeric key falls back to a position in the file being edited, and that fallback warns. When the entry at that position has a name, refuse it and say which name to use instead. This is checked once in BaseConfigCommand for both the config and repo commands, and applies to whichever argument does the addressing, which for --before/--after is the ordering option rather than the name of the new repository. That also fixes `repo add 0 vcs URL --before 1` deleting the repository at position 0, as insertRepository resolved the new repository's name to a position of its own. setRepositoryUrl was the only regex method without a PREG_BACKTRACK_LIMIT_ERROR guard, so it threw instead of falling back to the whole-file rewrite, and that fallback could not match by position.
API Surface ChangesIf any of the additions below are not intended as public API, mark them with New API SurfaceMethods
|
| )); | ||
| } | ||
|
|
||
| $this->getIO()->writeError('<warning>Addressing a repository by its position ("'.$name.'") is unreliable as positions shift when repositories are added or removed, and do not match what "composer repo list" shows when the global config defines repositories. Give the repository a "name" and use that instead.</warning>'); |
There was a problem hiding this comment.
Nice! Only downside is that we now have three methods (this one, findRepositoryIndex, findRepositoryKey) which all do more or less the same thing
There was a problem hiding this comment.
5f4852f unifies that, ish.. It kinda replaced one with another one, I am not even sure anymore :D
There were three near-identical lookups (JsonManipulator::findRepositoryKey,
JsonConfigSource::findRepositoryIndex and the loop in BaseConfigCommand), and the drift between
them is what made the setRepositoryUrl fallback compare a string name against an int index and
never match. So keep one: findRepositoryKey is now public static and takes the decoded
repositories, and the other two call it. It has to accept a \stdClass rather than only an array,
because casting an object with numeric keys yields int keys, which is what made
{"repositories": {"0": {}}} indistinguishable from a list.
A numeric name is ambiguous with a position, so a repository called "1" can silently resolve to
the second entry of the list when no such name exists. Refuse to write one -- only reachable
through a user supplied JSON definition, as the commands never inject a numeric name -- and warn
about the ones already out there in `composer validate`. The schema stays permissive so existing
files keep installing. Numeric keys in the object format are untouched, a key there is a name.
Fixes #12623
Problem
composer config repositories.N <type> <url>, whereNis a numeric index into arepositorieslist, must overwrite the existing repository at positionN. Since therepocommand refactor it no longer does: the old entry is kept and a duplicate is added instead.Root cause
JsonManipulator::doRemoveRepository()(called byaddRepository()/insertRepository()/removeRepository()before writing the new entry) matched repositories only by theirnameproperty. A numeric key like0never matches aname, so nothing is removed and the new entry is simply added. Reading by numeric index already works (config repositories.0); only writing regressed.Fix
Match a numeric name against the list position so the entry at that index is removed before the new one is written back. The branch is guarded with
!$isAssoc && is_numeric($name), so named repositories and object-formatrepositoriesare unaffected.Test verification (RED → GREEN)
Two tests were added (unit + functional). Both fail before the production change and pass after.
RED — with only the tests applied on top of the current base (production fix reverted):
GREEN — with the fix:
Full related suites pass (
JsonManipulatorTest125/125,ConfigCommandTest95/95,RepositoryCommandTest20/20,JsonConfigSourceTest45/45) and PHPStan level 8 is clean.