Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Fix overwriting a list repository addressed by numeric index in config command - #13025

Open
ousamabenyounes wants to merge 4 commits into
composer:mainfrom
ousamabenyounes:fix/issue-12623
Open

Fix overwriting a list repository addressed by numeric index in config command#13025
ousamabenyounes wants to merge 4 commits into
composer:mainfrom
ousamabenyounes:fix/issue-12623

Conversation

@ousamabenyounes

Copy link
Copy Markdown

Fixes #12623

Problem

composer config repositories.N <type> <url>, where N is a numeric index into a repositories list, must overwrite the existing repository at position N. Since the repo command refactor it no longer does: the old entry is kept and a duplicate is added instead.

$ cat composer.json
{
    "repositories": [
        { "type": "path", "url": "../" },
        { "type": "composer", "url": "https://other.test" }
    ]
}

$ composer config repositories.0 composer https://replaced.test
# before this patch, index 0 is NOT replaced — a duplicate is prepended:
{
    "repositories": [
        { "type": "composer", "url": "https://replaced.test" },
        { "type": "path", "url": "../" },
        { "type": "composer", "url": "https://other.test" }
    ]
}

Root cause

JsonManipulator::doRemoveRepository() (called by addRepository()/insertRepository()/removeRepository() before writing the new entry) matched repositories only by their name property. A numeric key like 0 never matches a name, 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-format repositories are 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):

1) Composer\Test\Json\JsonManipulatorTest::testAddRepositoryByNumericIndexOverwritesListEntry
Failed asserting that two strings are equal.
--- Expected  (index 0 replaced)
+++ Actual    (old entry kept, duplicate added)

FAILURES! Tests: 1, Assertions: 2, Failures: 1.

2) Composer\Test\Command\ConfigCommandTest::testConfigUpdates with data set
   "overwrite a repository addressed by numeric index in a list"
Failed asserting that two arrays are identical.
FAILURES! Tests: 75, Assertions: 150, Failures: 1.

GREEN — with the fix:

Composer\Test\Json\JsonManipulatorTest
OK (1 test, 2 assertions)

Composer\Test\Command\ConfigCommandTest
OK (95 tests, 194 assertions)

Full related suites pass (JsonManipulatorTest 125/125, ConfigCommandTest 95/95, RepositoryCommandTest 20/20, JsonConfigSourceTest 45/45) and PHPStan level 8 is clean.

…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.
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.
@Seldaek Seldaek added this to the 2.10 milestone Aug 24, 2026
@Seldaek
Seldaek requested a review from glaubinix August 24, 2026 09:52
return false;
}

$indexToInsert = null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 1

On 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I believe this is broken both in JsonConfigSource::insertRepository and JsonManipulator::insertRepository

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.

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.

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.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

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.

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.

Comment thread doc/03-cli.md Outdated
Comment on lines +904 to +906
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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

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.

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 with The 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']],
],
];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think the PcreException was there before but somehow the code changes here make it possible/easier to reach the code path?

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.

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().

@Seldaek Seldaek Aug 25, 2026

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.

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.
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

API Surface Changes

If any of the additions below are not intended as public API, mark them with @internal in the docblock.

New API Surface

Methods

@Seldaek
Seldaek requested a review from glaubinix August 25, 2026 11:43
));
}

$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>');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nice! Only downside is that we now have three methods (this one, findRepositoryIndex, findRepositoryKey) which all do more or less the same thing

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.

5f4852f unifies that, ish.. It kinda replaced one with another one, I am not even sure anymore :D

@Seldaek Seldaek modified the milestones: 2.10, 2.11 Aug 26, 2026
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.
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.

composer config repositories.X now works differently if X is numeric

3 participants