Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
16663ba
The upgrade and installer accept a hand-named darling.json.bak-* back…
erikdarlingdata Oct 11, 2026
acafd47
CHANGELOG: the upgrade and installer accept a hand-named darling.json…
erikdarlingdata Oct 11, 2026
155cc09
Darling scripts: backups are matched by ordinal prefix, hard-linked b…
erikdarlingdata Oct 11, 2026
7a40dba
CHANGELOG: describe the prefix match, the hard-link finding and the b…
erikdarlingdata Oct 11, 2026
45aa7c0
Merge branch 'dev' into fix/5649-backup-trust
erikdarlingdata Oct 11, 2026
c022ad8
Count a file's names through an attribute-only handle, refuse a linke…
erikdarlingdata Oct 11, 2026
4fbb8a8
Merge remote-tracking branch 'origin/dev' into HEAD
erikdarlingdata Oct 11, 2026
a029b0e
Upgrade shapes: name the hand-named backup file apart from New-Base's…
erikdarlingdata Oct 11, 2026
468afed
Install scripts: compare the install folder and every path in one lon…
erikdarlingdata Oct 11, 2026
c6eb6dc
Install scripts and service: a name Windows folds is not a backup, an…
erikdarlingdata Oct 11, 2026
80f9f70
Merge remote-tracking branch 'origin/dev' into HEAD
erikdarlingdata Oct 11, 2026
75d192f
Backup trust: writer builds the name from DarlingConfigBackup, lock l…
erikdarlingdata Oct 11, 2026
677cebe
CHANGELOG: the lock leaves any file with more than one name alone (#5…
erikdarlingdata Oct 11, 2026
3bf715a
Install scripts: the lock sets the owner one object at a time and lea…
erikdarlingdata Oct 11, 2026
838c7a9
CHANGELOG and upgrade comment: the lock leaves a file with more than …
erikdarlingdata Oct 11, 2026
7b8fb0b
Merge remote-tracking branch 'origin/dev' into HEAD
erikdarlingdata Oct 11, 2026
094a571
The lock's owner step stays inside the install folder and reports eac…
erikdarlingdata Oct 11, 2026
7f78864
The lock tests match what an elevated session reports and print every…
erikdarlingdata Oct 11, 2026
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
34 changes: 29 additions & 5 deletions .github/scripts/darling-upgrade-shapes.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@

Shapes (the leg name is <shape>-<script>):
U1 current release in C:\Program Files\PerformanceMonitorDarling, service stopped. must PASS
U2 as U1, service logon changed to a non-default local account and started once on it. must PASS
U3 as U1, service deleted. install-darling.ps1 must PASS; upgrade-darling.ps1 has no
The folder also holds a hand-named darling.json.bak-before-edit, service-owned after the
base install and its service start (#5649); it must not refuse either script.
U2 as U1 without the backup file, service logon changed to a non-default local account and started once on it. must PASS
U3 as U1 without the backup file, service deleted. install-darling.ps1 must PASS; upgrade-darling.ps1 has no
service to upgrade and must say so, without listing the product's own files.
U4 pre-3.9 release in C:\PerformanceMonitorDarling. install-darling.ps1 must REFUSE, list only the
folder's own write entry, and print the move steps.
Expand All @@ -28,6 +30,7 @@ param(
$ErrorActionPreference = 'Continue'
$shape, $which = $Leg.Split('-')
$svc = 'PerformanceMonitor Darling'
$HandNamedBackupName = 'darling.json.bak-before-edit'
$work = 'C:\darling-shapes-work'
$repo = if ($env:GITHUB_REPOSITORY) { $env:GITHUB_REPOSITORY } else { 'erikdarlingdata/PerformanceMonitor' }
$tools = Join-Path $Workspace 'Darling\tools'
Expand Down Expand Up @@ -107,13 +110,17 @@ function Stop-Darling {

# Installs <version> into <root> the way an operator does (extract, write darling.json, run its install script) and
# lets the SERVICE extract pg-runtime. A base without pg-runtime.stamp is not the shape under test, so it fails the leg.
function New-Base([string]$version, [string]$root, [bool]$acceptWritable) {
function New-Base([string]$version, [string]$root, [bool]$acceptWritable, [bool]$handNamedBackup = $false) {
Say "BASE: install $version into $root"
$zip = Get-Zip $version
New-Item -ItemType Directory -Force -Path $root | Out-Null
tar -xf $zip -C $root
$cfg = '{ "postgres": { "managed": true, "port": 5641, "dataDirectory": null, "connectAs": "admin" }, "servers": [ { "name": "nosuch", "host": "nosuch.invalid", "auth": "integrated" } ] }'
Set-Content -LiteralPath (Join-Path $root 'darling.json') -Value $cfg
# #5649: a backup the operator named by hand, put there before the base install so the release's own install
# script and the service (both harden every darling.json.bak-* file) make it service-owned, the way a real one is
# after a service start. The script under test must then accept it.
if ($handNamedBackup) { Set-Content -LiteralPath (Join-Path $root $HandNamedBackupName) -Value $cfg }
$script = Join-Path $root 'install-darling.ps1'
$declared = @(Select-String -LiteralPath $script -Pattern '^\s*\[switch\]\$(\w+)' | ForEach-Object { $_.Matches[0].Groups[1].Value })
$a = @(); foreach ($w in 'SkipPreflight', 'NoShortcuts') { if ($declared -contains $w) { $a += "-$w" } }
Expand All @@ -130,6 +137,16 @@ function New-Base([string]$version, [string]$root, [bool]$acceptWritable) {
}
Start-Sleep -Seconds 10
Stop-Darling
if ($handNamedBackup) {
# The shape only means something if the file really is the service's now. If the hardening did not make it so,
# the leg is not testing the refusal it exists to prevent, so it fails here instead of passing for nothing.
$backup = Join-Path $root $HandNamedBackupName
if (-not (Test-Path -LiteralPath $backup)) { Fail-Leg "the hand-named backup $HandNamedBackupName is gone from $root after the base install" }
$owner = (Get-Acl -LiteralPath $backup).GetOwner([System.Security.Principal.SecurityIdentifier])
$ownerName = try { $owner.Translate([System.Security.Principal.NTAccount]).Value } catch { '(untranslatable)' }
Write-Host "owner of ${backup}: $ownerName"
if ($ownerName -notlike "NT SERVICE\$svc") { Fail-Leg "the hand-named backup is owned by $ownerName after the base install, not by the service, so the leg would not exercise the check" }
}
Write-Host "BASE: $version installed in $root, pg-runtime extracted by its service, service stopped"
}

Expand Down Expand Up @@ -178,7 +195,8 @@ Say "LEG $Leg on $([Environment]::OSVersion.VersionString) (current $CurrentVers

# -- the base for the shape ----------------------------------------------------------------------------------------
$root = if ($shape -eq 'U4') { 'C:\PerformanceMonitorDarling' } else { 'C:\Program Files\PerformanceMonitorDarling' }
New-Base $(if ($shape -eq 'U4') { $OldVersion } else { $CurrentVersion }) $root $false
# U1 (both its install and its upgrade leg) carries a hand-named backup in the root (#5649).
New-Base $(if ($shape -eq 'U4') { $OldVersion } else { $CurrentVersion }) $root $false ($shape -eq 'U1')

switch ($shape) {
'U2' {
Expand Down Expand Up @@ -233,7 +251,13 @@ else {

# -- the expectation of the leg ------------------------------------------------------------------------------------
switch ($Leg) {
{ $_ -in 'U1-install', 'U1-upgrade', 'U2-install', 'U2-upgrade' } { Expect-Success $r "$which-darling.ps1" }
{ $_ -in 'U1-install', 'U1-upgrade', 'U2-install', 'U2-upgrade' } {
Expect-Success $r "$which-darling.ps1"
if ($shape -eq 'U1') {
# #5649: the service-owned hand-named backup does not refuse the run (Expect-Success above) and stays where it was.
if (-not (Test-Path -LiteralPath (Join-Path $root $HandNamedBackupName))) { Fail-Leg "$which-darling.ps1 removed the hand-named backup $HandNamedBackupName" }
}
}
'U3-install' {
Expect-Success $r 'install-darling.ps1'
if (-not (Get-Service -Name $svc -ErrorAction SilentlyContinue)) { Fail-Leg 'install-darling.ps1 exited 0 but left no service registered' }
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/darling-upgrade-shapes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ name: Darling upgrade shapes
# build with its own install script, lets its service extract pg-runtime, stops it, and then runs the script
# under test:
# U1 the current release in C:\Program Files\PerformanceMonitorDarling must pass
# U2 as U1, the service logon changed to a non-default local account and run once on it must pass
# U3 as U1, the service deleted: install passes, upgrade says there is no service see the leg's expectation
# (with a hand-named darling.json.bak-before-edit in the folder, service-owned, #5649)
# U2 as U1 without the backup file, the service logon changed to a non-default local account and run once on it must pass
# U3 as U1 without the backup file, the service deleted: install passes, upgrade says there is no service see the leg's expectation
# U4 a release before 3.9 in C:\PerformanceMonitorDarling: install refuses, names only
# the folder's own write entry, and prints the move steps
# U1, U2 and U3 run both install-darling.ps1 and upgrade-darling.ps1 as separate legs; U4 runs install-darling.ps1.
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Releases before 3.0.0 are not archived: those entries carry no prose to move.

- **The Darling viewer's Manage Servers window now shows the service's version, once, instead of its own version on every server row** ([#5668]) - the grid's Installed Version column filled every row with the viewer's own version, so an old viewer talking to a newer service read as up to date. The service now writes its product version and process start time to the store when it starts (a new table, `config.service_version`, one row per service host, store schema V174, created by the first start after the upgrade), and Manage Servers shows it once above the grid beside the viewer's own version: one line per service host when more than one service writes to the store. A store with no row yet, or without the table, reads `unknown (older than 3.11)`. A write that fails at start logs a warning, and the service tries it again on every sweep until it succeeds. The Installed Version column is removed. The line is display only and shows no warning.
- **A collector that recovered no longer stays at WARNING** ([#5665]) - the 7-day failure rate gave WARNING above 20% errors and a later success never cleared it, so a new target with 2 errors in its first 8 runs stayed WARNING until its tenth run. The rate now counts only once the window holds 10 runs, or fewer when a collector runs so rarely that a full 7-day window holds fewer (7 for a daily collector, so a daily collector that failed 6 days of 7 and succeeded today stays WARNING), and a collector whose newest 3 runs in a row succeeded is HEALTHY with a note "recovered: the last 3 runs succeeded; last failure <time> UTC" in the Note column of Lite and the Darling viewer, in a Recovered column of the Darling web dashboard, and as note_summary (recovered_note on the shorter rows) in the MCP get_collection_health read. The note appears only on a row the recovery rule moved to HEALTHY, never on one that another rule bands WARNING or STOPPED. A collector that alternates between failing and succeeding stays WARNING, a collector with few runs whose latest run failed stays WARNING, and the staleness, abandon-rate and lost-databases rules are unchanged. The fleet overview card and the Darling viewer's Overview cards agree: when a collector's failure rate alone would band it WARNING, the roll-up reads that collector's newest 3 runs in one follow-up statement (none when no collector needs it), so a recovered collector reads HEALTHY on the fleet card as on its own tab and an alternating one stays WARNING.
- **The upgrade and installer accept a hand-named `darling.json.bak-*` backup in the install folder** ([#5659]) - In 3.10.0 the check that runs before the lock refused an install folder holding a backup such as `darling.json.bak-before-edit`, because the service owns every `darling.json.bak-*` file after a start and the check trusted it only on the stamped names the CLI writes. The refusal said the folder was writable by ordinary users and told the operator to move the whole install. The check now trusts the service account on every file in the install root whose name starts with `darling.json.bak-`, the same prefix the hardening uses, and nowhere else. Any other grantee on such a file, the service on another file, a folder with that name, a link, a file with more than one name (a hard link) and a file below the root still refuse. A backup-named hard link is now reported as a finding of its own, and the lock and the install pass leave it alone, so it never gets the service as owner. The lock also leaves any other file with more than one name alone, wherever it is in the install tree, and lists it as still open instead of changing its owner or resetting its permissions, because either would change the file's other name too. That owner step opens the install folder once and changes an owner only on an object whose real path it confirms is inside that folder, lists a name that ends in a dot or a space instead of acting on it, and turns on SeTakeOwnershipPrivilege and SeBackupPrivilege for itself and puts them back as it found them. When a refusal lists only backup files, it now starts with a note that names them, says they can hold the secrets that were in darling.json and can be moved to a folder only Administrators can read, and then gives the usual advice unchanged. The check, the install-time hardening, the upgrade's manifest guard, the service sweep and the CLI check now match one prefix per language by ordinal, case-insensitive name, so a Turkish-locale host reads the names the way the service does. The CLI permission check now lists the backups of the config it was given (`prod.json.bak-*` for `prod.json`) instead of always `darling.json.bak-*`. A name that ends in a dot or a space is not treated as a backup by the check, the install-time hardening, the service sweep or the CLI check, because Windows resolves such a name to a different file.

## [3.10.0] - 2026-10-09

Expand Down Expand Up @@ -5209,3 +5210,4 @@ Full entries: [docs/changelog/3.0.md](docs/changelog/3.0.md)
[#5663]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5663
[#5668]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5668
[#5665]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5665
[#5659]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5659
4 changes: 3 additions & 1 deletion Darling/Darling.Tests/DarlingDeployStaleFileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public sealed class DarlingDeployStaleFileTests
private static readonly string[] s_functions =
[
"Test-DarlingRollbackBackupName",
"Get-DarlingServiceWrittenNames",
"Format-DarlingBytes",
"ConvertTo-DarlingManifestPath",
"Join-DarlingInstallPath",
Expand Down Expand Up @@ -618,7 +619,8 @@ public void TheInstallScript_FindsOlderConfigBackupsByLiteralPath()
{
var script = InstallScript;

Assert.Contains("Get-ChildItem -LiteralPath $root -Filter 'darling.json.bak-*' -File -ErrorAction SilentlyContinue", script, StringComparison.Ordinal);
Assert.Contains("Get-ChildItem -LiteralPath $root -Force -File -ErrorAction SilentlyContinue", script, StringComparison.Ordinal);
Assert.Contains("Get-DarlingServiceWrittenRootFiles $root -BackupsOnly", script, StringComparison.Ordinal);
Assert.DoesNotContain("Get-ChildItem -Path $root", script, StringComparison.Ordinal);
}

Expand Down
101 changes: 101 additions & 0 deletions Darling/Darling.Tests/DarlingHardenFilesVerbTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,64 @@ public void TheResolver_HandlesTheUnqualifiedLocalSystemAlias()
Assert.Contains("LocalSystem", body, StringComparison.Ordinal);
Assert.Contains("WellKnownSidType.LocalSystemSid", body, StringComparison.Ordinal);
}

/// <summary>
/// #5649: the verb hardens the backups of the config it was given, named the way the config verbs and the service's
/// start-up sweep name them (<c>prod.json.bak-x</c> for <c>prod.json</c>), not the default config's
/// <c>darling.json.bak-*</c> that may sit in the same folder for another install. The name test is ordinal and
/// ignores case whatever the culture (Turkish dotted and dotless I), and the service's own sweep lists the same files.
/// </summary>
[Fact]
public void TheVerb_HardensTheBackupsOfTheConfigItWasGiven_ByAnOrdinalNameTest()
{
var directory = Path.Combine(Path.GetTempPath(), "pm5649-cli-" + Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(directory);
var previous = System.Globalization.CultureInfo.CurrentCulture;
try
{
foreach (var name in new[]
{
"prod.json", "prod.json.bak-20260101-120000", "PROD.JSON.BAK-Upper", "prod.json.bak-before-edit",
"prod.json.bak", "prod.jsonx.bak-x", "darling.json", "darling.json.bak-other-install", "DARLING.JSON.BAK-Upper",
})
{
File.WriteAllText(Path.Combine(directory, name), "{}");
}

var expectedProd = new[] { "PROD.JSON.BAK-Upper", "prod.json.bak-20260101-120000", "prod.json.bak-before-edit" };
var expectedDefault = new[] { "DARLING.JSON.BAK-Upper", "darling.json.bak-other-install" };

foreach (var culture in new[] { "en-US", "tr-TR" })
{
System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo(culture);

var prod = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.OrderBy(
System.Linq.Enumerable.Select(DarlingCliCommands.ConfigBackupFiles(directory, "prod.json"), Path.GetFileName), n => n, StringComparer.Ordinal));
Assert.Equal(expectedProd, prod);

var defaults = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.OrderBy(
System.Linq.Enumerable.Select(DarlingCliCommands.ConfigBackupFiles(directory, "darling.json"), Path.GetFileName), n => n, StringComparer.Ordinal));
Assert.Equal(expectedDefault, defaults);

var swept = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.OrderBy(
System.Linq.Enumerable.Select(DarlingConfigBackup.ListBackups(directory, "prod.json"), Path.GetFileName), n => n, StringComparer.Ordinal));
Assert.Equal(expectedProd, swept);

Assert.True(DarlingConfigBackup.IsBackupName("darling.json", "DARLING.JSON.BAK-X"), culture);
Assert.True(DarlingConfigBackup.IsBackupName("darling.json", "darling.json.bak-x"), culture);
Assert.False(DarlingConfigBackup.IsBackupName("darling.json", "DARLİNG.JSON.BAK-X"), culture);
Assert.False(DarlingConfigBackup.IsBackupName("darling.json", "darling.json.bak"), culture);
Assert.False(DarlingConfigBackup.IsBackupName("prod.json", "darling.json.bak-x"), culture);
}

Assert.Equal("darling.json.bak-", DarlingConfigBackup.Prefix);
}
finally
{
System.Globalization.CultureInfo.CurrentCulture = previous;
Directory.Delete(directory, recursive: true);
}
}
}

/// <summary>
Expand Down Expand Up @@ -491,4 +549,47 @@ private static void MakeJunction(string link, string target)
process.StandardError.ReadToEnd();
process.WaitForExit();
}

/// <summary>
/// #5649: a name Windows folds into another (a trailing dot or a trailing space) is never a backup, the same answer the
/// install and upgrade scripts give (<c>Test-DarlingServiceWrittenFileName</c>): it resolves to a DIFFERENT file, so the
/// service must not harden it or the CLI list it as the config's backup. The files are made through a <c>\\?\</c> path,
/// the only way to create such a name, and both listings (the CLI's and the service's start-up sweep) skip them while the
/// plain backup name still counts.
/// </summary>
[Fact]
public void TheBackupNameTest_RefusesANameWindowsFolds_InTheNameTestAndBothListings()
{
Assert.True(DarlingConfigBackup.IsBackupName("darling.json", "darling.json.bak-x"));
Assert.False(DarlingConfigBackup.IsBackupName("darling.json", "darling.json.bak-x."));
Assert.False(DarlingConfigBackup.IsBackupName("darling.json", "darling.json.bak-x "));
Assert.False(DarlingConfigBackup.IsBackupName("darling.json", "darling.json.bak-x.. "));
Assert.False(DarlingConfigBackup.IsBackupName("darling.json", "darling.json.bak-."));
Assert.False(DarlingConfigBackup.IsBackupName("prod.json", "prod.json.bak-x."));

var directory = Path.Combine(Path.GetTempPath(), "pm5649-fold-" + Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(directory);
try
{
File.WriteAllText(Path.Combine(directory, "darling.json.bak-x"), "{}");
foreach (var folded in new[] { "darling.json.bak-y.", "darling.json.bak-y " })
{
File.WriteAllText(@"\\?\" + Path.Combine(directory, folded), "{}");
}

var cli = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(DarlingCliCommands.ConfigBackupFiles(directory, "darling.json"), Path.GetFileName));
var sweep = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(DarlingConfigBackup.ListBackups(directory, "darling.json"), Path.GetFileName));
Assert.Equal(new[] { "darling.json.bak-x" }, cli);
Assert.Equal(new[] { "darling.json.bak-x" }, sweep);
}
finally
{
foreach (var folded in new[] { "darling.json.bak-y.", "darling.json.bak-y " })
{
try { File.Delete(@"\\?\" + Path.Combine(directory, folded)); } catch (IOException) { }
}

Directory.Delete(directory, recursive: true);
}
}
}
Loading
Loading