Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ under one policy. Do not add a restore path that skips it:
- Versions: what the site ran (`previousVersions()`: the lock while it still lists them, else
`core/custom/composer.installed.json`, written by every restore and by `package:installrequire`),
pinned by `pins()`; a package the release ships or the core requires keeps the release's version.
A branch from git or hg is pinned at its commit (`dev-main#<commit>`); a branch whose commit is not
known on both sides counts as a change, so it needs consent.
- Arguments: `updateArguments()`, `--with-dependencies` (`--minimal-changes` from Composer 2.7), never
`--with-all-dependencies`, which moves the core's own packages too.
- `plan()` dry run first (`planInstall()`, a `composer install --dry-run`, when nothing needs restoring:
Expand Down
2 changes: 1 addition & 1 deletion assets/modules/store/installer/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
$_lang = array();
$_params = array();
$lang = evo()->config['manager_language'];
if (file_exists($modulePath . '/lang/' . $lang . '.inc.php')){
if (\EvolutionCMS\Support\ManagerLanguage::isName($lang) && file_exists($modulePath . '/lang/' . $lang . '.inc.php')){
include_once($modulePath . '/lang/' . $lang . '.inc.php');
} else {
include_once($modulePath . '/lang/en.inc.php');
Expand Down
2 changes: 1 addition & 1 deletion assets/plugins/extrascheck/CheckOutdated.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ public function makeConfigButton($title)
public function setLang($lexicon = [])
{
$lang = $this->modx->getConfig('manager_language');
if (file_exists( __DIR__ . '/lang/'.$lang.'/core.inc.php')){
if (\EvolutionCMS\Support\ManagerLanguage::isName($lang) && file_exists( __DIR__ . '/lang/'.$lang.'/core.inc.php')){
include_once(__DIR__ . '/lang/'.$lang.'/core.inc.php');
} else {
include_once(__DIR__ . '/lang/english/core.inc.php');
Expand Down
5 changes: 3 additions & 2 deletions assets/plugins/updater/plugin.updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -852,8 +852,9 @@ function poll() {
$_lang = [];
$plugin_path = EVO_BASE_PATH . "assets/plugins/updater/";
include($plugin_path . 'lang/en.php');
if (file_exists($plugin_path . 'lang/' . $modx->config['manager_language'] . '.php')) {
include($plugin_path . 'lang/' . $modx->config['manager_language'] . '.php');
$language = $modx->config['manager_language'];
if (\EvolutionCMS\Support\ManagerLanguage::isName($language) && file_exists($plugin_path . 'lang/' . $language . '.php')) {
include($plugin_path . 'lang/' . $language . '.php');
}

$e = &$modx->Event;
Expand Down
19 changes: 19 additions & 0 deletions core/src/Core.php
Original file line number Diff line number Diff line change
Expand Up @@ -6800,6 +6800,7 @@ public function getSettings()
$this->config = $this->configCompatibility();

$this->loadConfig();
$this->dropManagerLanguagePath();

// Apply the system timezone before user settings and OnLoadSettings hooks.
$siteTimezone = Support\SiteTimezone::apply(
Expand All @@ -6819,6 +6820,19 @@ public function getSettings()
$this->syncManagerTheme();
}

/**
* manager_language names a file to include, here and in packages that read it as it
* stands, and a stored value may be anything: only a locale name gets into the
* configuration. A deleted locale passes, it reads as English where it is used.
*/
private function dropManagerLanguagePath(): void
{
$language = $this->config['manager_language'] ?? '';
if ($language !== '' && !Support\ManagerLanguage::isName($language)) {
$this->config['manager_language'] = Support\ManagerLanguage::DEFAULT;
}
}

/**
* Realign the already resolved ManagerTheme with the current manager_theme and
* manager_language. A different theme needs a new instance (the theme name drives the
Expand Down Expand Up @@ -6906,6 +6920,11 @@ public function getUserSettings()
}
}

// The same rule as dropManagerLanguagePath(); the system language stays.
if (isset($usrSettings['manager_language']) && !Support\ManagerLanguage::isName($usrSettings['manager_language'])) {
unset($usrSettings['manager_language']);
}

if (empty($usrSettings)) {
return [];
}
Expand Down
2 changes: 1 addition & 1 deletion core/src/Legacy/ErrorHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public function includeLang($context = 'common')
// @phpstan-ignore-next-line includeOnce.fileNotFound
include_once($lang_path . 'en/global.php');
$manager_language = $modx->getConfig('manager_language');
if (is_file($lang_path.$manager_language."/global.php")) {
if (\EvolutionCMS\Support\ManagerLanguage::isName($manager_language) && is_file($lang_path.$manager_language."/global.php")) {
include_once($lang_path.$manager_language."/global.php");
}

Expand Down
46 changes: 35 additions & 11 deletions core/src/Services/CustomComposerPackages.php
Original file line number Diff line number Diff line change
Expand Up @@ -666,14 +666,16 @@ public static function unexpectedChanges(array $operations, array $pins, array $
{
$custom = array_fill_keys(array_map('strtolower', $packages), true);
$sameVersion = static function (string $a, string $b): bool {
// A branch is "dev-main 18f1a03" in one source and "dev-main" in another: a reference
// on one side only says nothing, two different ones mean the branch moved.
[$versionA, $referenceA] = array_pad(explode(' ', strtolower(ltrim(trim($a), 'vV')), 2), 2, '');
[$versionB, $referenceB] = array_pad(explode(' ', strtolower(ltrim(trim($b), 'vV')), 2), 2, '');

return $versionA === $versionB
&& ($referenceA === '' || $referenceB === ''
|| str_starts_with($referenceA, $referenceB) || str_starts_with($referenceB, $referenceA));
// A branch is "dev-main#18f1a03..." as a pin and "dev-main 18f1a03" as Composer prints it.
// A commit on one side only cannot show the branch stayed where it was: that needs consent.
$parts = static fn (string $version): array => array_pad(preg_split('/[ #]/', strtolower(ltrim(trim($version), 'vV')), 2), 2, '');
[$versionA, $referenceA] = $parts($a);
[$versionB, $referenceB] = $parts($b);
if ($versionA !== $versionB || ($referenceA === '') !== ($referenceB === '')) {
return false;
}

return $referenceA === '' || str_starts_with($referenceA, $referenceB) || str_starts_with($referenceB, $referenceA);
};

$changes = [];
Expand Down Expand Up @@ -717,7 +719,7 @@ private static function satisfies(string $version, string $constraint): bool
/**
* Packages of core/composer.lock with their versions and requirements.
*
* @return array<string, array{version: string, require: list<string>}>
* @return array<string, array{version: string, pin: string, require: list<string>}>
*/
private function lockPackages(): array
{
Expand All @@ -734,6 +736,7 @@ private function lockPackages(): array
if ($name !== '') {
$packages[$name] = [
'version' => (string) ($package['version'] ?? ''),
'pin' => self::pinnedVersion($package),
'require' => array_map('strtolower', array_keys((array) ($package['require'] ?? []))),
];
}
Expand All @@ -743,6 +746,27 @@ private function lockPackages(): array
return $packages;
}

/**
* The version to bring a locked package back at. "dev-main" alone is wherever the branch
* stands by then, so a branch keeps its commit: "dev-main#18f1a03...".
*
* Only for git and hg sources, the ones Composer prints a commit for: no other can be
* compared with what a dry run reports.
*
* @param array<string, mixed> $package Entry of composer.lock.
*/
private static function pinnedVersion(array $package): string
{
$version = (string) ($package['version'] ?? '');
$source = (array) ($package['source'] ?? []);
$reference = (string) ($source['reference'] ?? '');
$isBranch = str_starts_with($version, 'dev-') || str_ends_with($version, '-dev');

return $isBranch && $reference !== '' && in_array($source['type'] ?? '', ['git', 'hg'], true)
? $version . '#' . $reference
: $version;
}

/**
* Packages core/composer.json requires itself; they keep the core's version.
*
Expand All @@ -764,7 +788,7 @@ private function coreRequirements(): array
/**
* A package and what it requires in the lock, without the core's own requirements.
*
* @param array<string, array{version: string, require: list<string>}> $lock
* @param array<string, array{version: string, pin: string, require: list<string>}> $lock
* @param string $root Package name.
* @param array<string, true> $core
* @return array<string, string> Package name => version.
Expand All @@ -778,7 +802,7 @@ private function dependencyClosure(array $lock, string $root, array $core): arra
if (isset($versions[$name]) || !isset($lock[$name]) || ($name !== $root && isset($core[$name]))) {
continue;
}
$versions[$name] = $lock[$name]['version'];
$versions[$name] = $lock[$name]['pin'];
foreach ($lock[$name]['require'] as $dependency) {
if (self::isPackageName($dependency)) {
$queue[] = $dependency;
Expand Down
3 changes: 2 additions & 1 deletion core/src/Services/Store/StoreContextService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use EvolutionCMS\Models\RolePermissions;
use EvolutionCMS\Models\UserRole;
use EvolutionCMS\Support\ManagerLanguage;

class StoreContextService
{
Expand All @@ -16,7 +17,7 @@ public function loadLanguage(string $modulePath, string $managerLanguage): array
if (file_exists($fallbackFile)) {
include $fallbackFile;
}
if ($languageFile !== $fallbackFile && file_exists($languageFile)) {
if ($languageFile !== $fallbackFile && ManagerLanguage::isName($managerLanguage) && file_exists($languageFile)) {
include $languageFile;
}

Expand Down
71 changes: 66 additions & 5 deletions core/tests/Unit/Install/CliInstallCustomPackagesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -503,19 +503,80 @@ function removeCustomPackagesCore(string $core): void
]);
});

test('a branch counts as changed only when its reference moved', function (string $pinned, string $to, array $changes) {
test('a branch counts as unchanged only when both sides name one commit', function (string $pinned, string $to, array $changes) {
$operations = ['acme/widget' => ['op' => 'install', 'from' => '', 'to' => $to]];

expect(CustomComposerPackages::unexpectedChanges($operations, ['acme/widget' => $pinned], ['acme/widget']))->toBe($changes);
})->with([
'the lock knows the reference, Composer prints none' => ['dev-main 18f1a03', 'dev-main', []],
'Composer prints it, the lock does not' => ['dev-main', 'dev-main 18f1a03', []],
'the pin and what Composer prints' => ['dev-main#18f1a03c9d2e', 'dev-main 18f1a03', []],
'short and long spelling of one commit' => ['dev-main 18f1a03', 'dev-main 18f1a03c9d2e', []],
'the branch moved' => ['dev-main 18f1a03', 'dev-main 77aa012', ['acme/widget dev-main 18f1a03 => dev-main 77aa012']],
'another branch' => ['dev-main 18f1a03', 'dev-next 18f1a03', ['acme/widget dev-main 18f1a03 => dev-next 18f1a03']],
'no commit on either side, as a path repository' => ['dev-main', 'dev-main', []],
'the branch moved' => ['dev-main#18f1a03', 'dev-main 77aa012', ['acme/widget dev-main#18f1a03 => dev-main 77aa012']],
'the commit the site ran is not known' => ['dev-main', 'dev-main 18f1a03', ['acme/widget dev-main => dev-main 18f1a03']],
'Composer names no commit' => ['dev-main#18f1a03', 'dev-main', ['acme/widget dev-main#18f1a03 => dev-main']],
'another branch' => ['dev-main#18f1a03', 'dev-next 18f1a03', ['acme/widget dev-main#18f1a03 => dev-next 18f1a03']],
'another release' => ['1.2.0', '1.3.0', ['acme/widget 1.2.0 => 1.3.0']],
]);

test('a package on a branch comes back at the commit the site ran, or not without consent', function (bool $fromRecord) {
$commit = '18f1a03c9d2e4b7a6f0e1d2c3b4a59687f6e5d4c';
$moved = '77aa012b3c4d5e6f708192a3b4c5d6e7f8091a2b';
$locked = ['name' => 'acme/widget', 'version' => 'dev-main', 'source' => ['type' => 'git', 'url' => 'https://example.test/widget.git', 'reference' => $commit], 'dist' => ['type' => 'zip', 'reference' => $commit]];
$core = customPackagesCore(['acme/widget' => 'dev-main'], ['packages' => [$locked]]);
$packages = new CustomComposerPackages($core);
$packages->recordInstalled(['acme/widget']);
$previous = $fromRecord ? [] : $packages->previousVersions(['acme/widget']);

// The update lays the release's lock over the site, which knows nothing of the package.
file_put_contents($core . 'composer.lock', json_encode(['packages' => []]));
$previous = $fromRecord ? $packages->previousVersions(['acme/widget']) : $previous;
$pins = $packages->pins(['acme/widget'], $previous);
$resolves = fn (string $at) => fn (string $command): array => [0, "Lock file operations: 1 install, 0 updates, 0 removals\n - Locking acme/widget (dev-main " . substr($at, 0, 7) . ")\n"];

$same = $packages->plan($resolves($commit), 'composer', $pins, ['acme/widget'], true, false, false);
$branchMoved = $packages->plan($resolves($moved), 'composer', $pins, ['acme/widget'], true, false, false);
$withConsent = $packages->plan($resolves($moved), 'composer', $pins, ['acme/widget'], true, true, false);
removeCustomPackagesCore($core);

expect($pins)->toBe(['acme/widget' => 'dev-main#' . $commit])
->and($same['arguments'])->toContain('acme/widget:dev-main#' . $commit)
->and($same['ok'])->toBeTrue()
->and($branchMoved['ok'])->toBeFalse()
->and($branchMoved['reason'])->toBe('changes')
->and($withConsent['ok'])->toBeTrue();
})->with(['read from the lock' => false, 'read from the record of the last restore' => true]);

test('a record that does not know the commit of a branch asks for consent', function () {
$core = customPackagesCore(['acme/widget' => 'dev-main'], ['packages' => []], null, ['acme/widget' => ['version' => 'dev-main', 'dependencies' => []]]);
$packages = new CustomComposerPackages($core);
$pins = $packages->pins(['acme/widget'], $packages->previousVersions(['acme/widget']));
$plan = $packages->plan(fn (string $command): array => [0, "Lock file operations: 1 install, 0 updates, 0 removals\n - Locking acme/widget (dev-main 18f1a03)\n"], 'composer', $pins, ['acme/widget'], true, false, false);
removeCustomPackagesCore($core);

expect($plan['ok'])->toBeFalse()->and($plan['reason'])->toBe('changes');
});

test('only a branch Composer names by a commit is pinned to one', function () {
$commit = '18f1a03c9d2e4b7a6f0e1d2c3b4a59687f6e5d4c';
$core = customPackagesCore(['acme/widget' => '*'], ['packages' => [
['name' => 'acme/widget', 'version' => '1.2.0', 'source' => ['type' => 'git', 'reference' => $commit], 'require' => ['acme/branch' => '*', 'acme/numbered' => '*', 'acme/local' => '*', 'acme/mirror' => '*']],
['name' => 'acme/branch', 'version' => 'dev-main', 'source' => ['type' => 'git', 'reference' => $commit]],
['name' => 'acme/numbered', 'version' => '2.x-dev', 'source' => ['type' => 'hg', 'reference' => $commit]],
['name' => 'acme/local', 'version' => 'dev-main', 'dist' => ['type' => 'path', 'reference' => $commit]],
['name' => 'acme/mirror', 'version' => 'dev-main', 'source' => ['type' => 'svn', 'reference' => 'trunk@1']],
]]);
$previous = (new CustomComposerPackages($core))->previousVersions(['acme/widget']);
removeCustomPackagesCore($core);

expect($previous)->toBe([
'acme/widget' => '1.2.0',
'acme/branch' => 'dev-main#' . $commit,
'acme/numbered' => '2.x-dev#' . $commit,
'acme/local' => 'dev-main',
'acme/mirror' => 'dev-main',
]);
});

test('changes are judged on the lock, not on the vendor drift an update package ships', function () {
// Composer 2.10 against the unpacked 3.5.8 archive, whose vendor is newer than its own lock.
$output = "Loading composer repositories with package information\n"
Expand Down
Loading
Loading