From 954aa380152858999841512774a98a536c239875 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 27 Sep 2026 22:30:53 +0000 Subject: [PATCH 1/5] fix(code-metrics): explain the file counts after a total replication exclusion (#3843) When a sanctioned-replication registry excluded every clone group, the duplication report read Files with clones: 0 beside a scope header counting every scanned file, with nothing saying why. The counts measure different populations and stay apart: the exclusion line now names how many files hold nothing but excluded groups, and the report schema states which population summary.files and scope.files each describe. Co-authored-by: Kyle Sexton --- .../code-metrics/.claude-plugin/plugin.json | 2 +- plugins/code-metrics/CHANGELOG.md | 12 ++++++ .../code-metrics/reference/report-schema.md | 10 +++++ plugins/code-metrics/scripts/report.py | 24 ++++++++++- plugins/code-metrics/scripts/test_report.py | 42 +++++++++++++++++++ .../skills/audit-duplication/SKILL.md | 5 ++- .../scripts/audit-duplication.test.sh | 5 +++ 7 files changed, 96 insertions(+), 4 deletions(-) diff --git a/plugins/code-metrics/.claude-plugin/plugin.json b/plugins/code-metrics/.claude-plugin/plugin.json index d8c40fa717..567c6e74d2 100644 --- a/plugins/code-metrics/.claude-plugin/plugin.json +++ b/plugins/code-metrics/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "code-metrics", - "version": "0.3.17", + "version": "0.3.18", "description": "Read-only code measures for a change, with cited references and no verdict: lines per file (audit-size), cyclomatic, cognitive, and Halstead complexity (audit-complexity), duplication with sanctioned-replication exclusions (audit-duplication), coverage per function with CRAP from existing lcov, Cobertura, coverage.py, or Go artifacts (audit-coverage), type debt for TypeScript and Python (audit-type-debt), the literacy router for what each number can and cannot say (principles), and a setup skill for the consumer's .claude/code-metrics.yaml. Runs external collectors only when they already resolve, never installs, never runs tests, never emits a finding.", "author": { "name": "Melodic Software", diff --git a/plugins/code-metrics/CHANGELOG.md b/plugins/code-metrics/CHANGELOG.md index 1999d76e94..0705e7b1a9 100644 --- a/plugins/code-metrics/CHANGELOG.md +++ b/plugins/code-metrics/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to the `code-metrics` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.3.18] - 2026-09-27 + +### Fixed + +- **A total sanctioned-replication exclusion no longer leaves two unexplained file counts.** When a + registry excluded every clone group, the duplication report read `Files with clones: 0` beside a + scope header counting every scanned file, with nothing saying why. The two measure different + populations and stay apart: the exclusion line now adds how many files hold nothing but excluded + groups, and that `Files with clones` counts surviving groups only. `reference/report-schema.md` + states which population `summary.files` and `scope.files` each describe. A run with no exclusion, + or whose excluded groups touch only files a surviving group also touches, renders as before. + ## [0.3.17] - 2026-09-25 ### Changed diff --git a/plugins/code-metrics/reference/report-schema.md b/plugins/code-metrics/reference/report-schema.md index e95e8dca8e..0dda94cf24 100644 --- a/plugins/code-metrics/reference/report-schema.md +++ b/plugins/code-metrics/reference/report-schema.md @@ -43,6 +43,16 @@ methods in one file count as two, while a cyclomatic row and a Halstead row for count as one even though only the first reports where it begins. `summary.files` counts every file a row stands for, the copies behind a `replicas` row included. +`summary.files` and `scope.files` count different populations and are not expected to agree. +`scope.files` is every file the run scanned. `summary.files` is the files the surviving rows stand +for; in a duplication document that is every file holding an instance of a surviving clone group, +which the markdown labels `Files with clones`. A clone group a registry excluded adds nothing to +`summary.files`: its files stay in `scope.files` and in `excluded[].instances`. When at least one +file holds nothing but excluded groups, the markdown's exclusion line says how many, so a total +exclusion (`Files with clones: 0` beside a non-zero scope count) explains itself. The two counts +were deliberately kept apart rather than made equal: folding excluded files into the summary would +make it describe the scanned population a second time and stop saying where duplication remains. + The markdown rendering joins the rows the same way the count does: one line per function with every collector's values, a row with no start line joining the one function of its name in the file, and never two rows whose values disagree. The JSON keeps one row per collector, because each diff --git a/plugins/code-metrics/scripts/report.py b/plugins/code-metrics/scripts/report.py index 74e3e50a76..723314f76a 100755 --- a/plugins/code-metrics/scripts/report.py +++ b/plugins/code-metrics/scripts/report.py @@ -710,9 +710,31 @@ def render( f"{summary.get('clone_groups', 0)} clone group(s)." ) if doc.get("excluded"): - lines.append( + excluded_line = ( f"Excluded by a sanctioned-replication registry: {len(doc['excluded'])}." ) + # `Files with clones` counts surviving groups only, while the scope + # header counts every file scanned. A file whose every group was + # excluded is in the second and not the first; name how many, so the + # two counts read as the different populations they are. + surviving = { + instance.get("file") + for row in doc.get("measures", []) + for instance in row.get("instances") or [] + } + excluded_only = { + instance.get("file") + for group in doc["excluded"] + for instance in group.get("instances") or [] + if instance.get("file") + } - surviving + if excluded_only: + excluded_line += ( + f" Files with clones counts surviving groups only, so the " + f"{len(excluded_only)} file(s) holding nothing but excluded groups are " + "left out of it; the scope's file count is every file scanned." + ) + lines.append(excluded_line) elif duplication: lines.append( "Excluded by a sanctioned-replication registry: 0 (no registry configured, or " diff --git a/plugins/code-metrics/scripts/test_report.py b/plugins/code-metrics/scripts/test_report.py index 111f9f8e9a..5d9bf43f64 100755 --- a/plugins/code-metrics/scripts/test_report.py +++ b/plugins/code-metrics/scripts/test_report.py @@ -1386,6 +1386,48 @@ def test_an_empty_excluded_list_is_stated_with_its_reason(self) -> None: "Excluded by a sanctioned-replication registry: 1.", self.rendered(doc) ) + def test_a_total_exclusion_labels_the_two_file_counts(self) -> None: + # Every group excluded: the scope header still counts the scanned + # files while the summary counts none, and the report says why. + excluded = clone_row("bash", "a/shared/u.sh", "b/shared/u.sh", 20) + doc = resummarized(duplication_doc([])) + doc["excluded"] = [ + { + "registry": "r.txt", + "line": 3, + "path": "shared/u.sh", + "instances": excluded["instances"], + } + ] + self.assertEqual(doc["summary"]["files"], 0) + out = self.rendered(doc) + self.assertIn("Scope: all, 4 file(s).", out) + self.assertIn("\nFiles with clones: 0.\n", out) + self.assertIn( + "Excluded by a sanctioned-replication registry: 1. Files with clones counts " + "surviving groups only, so the 2 file(s) holding nothing but excluded groups " + "are left out of it; the scope's file count is every file scanned.", + out, + ) + + def test_a_file_in_a_surviving_group_is_not_named_as_excluded_only(self) -> None: + kept = clone_row("bash", "a/u.sh", "b/u.sh", 20) + doc = resummarized(duplication_doc([kept])) + doc["excluded"] = [ + { + "registry": "r.txt", + "line": 3, + "path": "u.sh", + "instances": [ + {"file": "a/u.sh", "start_line": 40, "end_line": 60}, + {"file": "c/u.sh", "start_line": 40, "end_line": 60}, + ], + } + ] + out = self.rendered(doc) + self.assertIn("\nFiles with clones: 2.\n", out) + self.assertIn("so the 1 file(s) holding nothing but excluded groups", out) + def test_no_detector_prints_one_headline_with_the_hint(self) -> None: hint = "jscpd: https://github.com/kucherenko/jscpd (npm install -g jscpd)" doc = duplication_doc( diff --git a/plugins/code-metrics/skills/audit-duplication/SKILL.md b/plugins/code-metrics/skills/audit-duplication/SKILL.md index 276b40569a..fb81d70dd5 100644 --- a/plugins/code-metrics/skills/audit-duplication/SKILL.md +++ b/plugins/code-metrics/skills/audit-duplication/SKILL.md @@ -46,8 +46,9 @@ continues. This plugin never installs, downloads, or `npx`-fetches a detector. Present the markdown report as printed. It opens with the scope and a "Coverage of this run" table (lane, collector, status, reason), then one row per clone group, largest first, listing every instance as `file:start-end`, then a rollup per lane and per directory, then the summary -lines: files with clones, the duplicated-line total, how many groups a registry excluded, and -which lanes were partial. When the report opens with `No clone detector ran in any lane`, offer +lines: files with clones, the duplicated-line total, how many groups a registry excluded (and how +many files hold nothing but excluded groups, which files with clones leaves out while the scope's +count keeps them), and which lanes were partial. When the report opens with `No clone detector ran in any lane`, offer the user the install command that headline carries (`npm install -g jscpd`, or a devDependency) and run it only when they confirm; never install silently and never `npx`-fetch it. Keep the `--json` document when the numbers feed a comparison: diff --git a/plugins/code-metrics/skills/audit-duplication/scripts/audit-duplication.test.sh b/plugins/code-metrics/skills/audit-duplication/scripts/audit-duplication.test.sh index f0e4097cea..ce1e476446 100755 --- a/plugins/code-metrics/skills/audit-duplication/scripts/audit-duplication.test.sh +++ b/plugins/code-metrics/skills/audit-duplication/scripts/audit-duplication.test.sh @@ -85,6 +85,8 @@ excluded_path="$(printf '%s' "$out" | "$PY" -c 'import json,sys; print(json.load assert_eq "the excluded entry names the registry line" "shared/shared-utils.sh" "$excluded_path" excluded_registry="$(printf '%s' "$out" | "$PY" -c 'import json,sys; print(json.load(sys.stdin)["excluded"][0]["registry"])' 2>/dev/null)" assert_contains "the excluded entry names the registry file" "$excluded_registry" "cluster.txt" +assert_doc "a total exclusion counts no file with clones while the scope counts the scanned files" "$out" \ + 'd["summary"]["files"] == 0 and d["scope"]["files"] >= 2' # 2. Without the registry the same clones are duplication debt. out="$(PATH="$STUBS:$EMPTY_PATH" bash "$SCRIPT" --json --all "$CLUSTER")" @@ -100,6 +102,9 @@ assert_eq "the markdown run exits 0" 0 "$?" assert_contains "markdown carries the run table" "$out" "## Coverage of this run" assert_contains "markdown states the duplicated-line count" "$out" "Duplicated lines" assert_contains "markdown states the exclusion" "$out" "Excluded by a sanctioned-replication registry" +assert_contains "markdown counts no file with clones after a total exclusion" "$out" "Files with clones: 0." +assert_contains "markdown says which files the summary leaves out, and why" "$out" \ + "so the 2 file(s) holding nothing but excluded groups are left out of it" # 4. Every collector absent: a report is still produced and says so. out="$(PATH="$EMPTY_PATH" bash "$SCRIPT" --json --all "$CLUSTER")" From 65f1303ceebce38ef95e650b426bc14ccf2d2004 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 27 Sep 2026 23:06:50 +0000 Subject: [PATCH 2/5] fix(gaming): report unlistable folders on an elevated Windows runner The DLSS selftest's user-only Set-Acl deny still lets an elevated runner list the folder, and Windows often leaves the error TargetObject empty, so the four unreadable-folder assertions never match. Co-authored-by: Kyle Sexton --- plugins/gaming/.claude-plugin/plugin.json | 2 +- plugins/gaming/CHANGELOG.md | 6 +++ .../skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 | 48 ++++++++++++++----- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/plugins/gaming/.claude-plugin/plugin.json b/plugins/gaming/.claude-plugin/plugin.json index be34e848c1..afc64dc377 100644 --- a/plugins/gaming/.claude-plugin/plugin.json +++ b/plugins/gaming/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "gaming", - "version": "0.8.0", + "version": "0.8.1", "description": "Apply, track, tune, and cleanly remove the community DLSS 5 Neural Rendering mod (OptiScaler forks) in PC games on Windows, from Steam, Epic, EA app, Battle.net, GOG, Ubisoft Connect and the Xbox app. Anti-cheat checks that refuse by default and install only on a typed at-your-own-risk acknowledgement, pre-install snapshots and manifests for byte-exact removal, a per-game ledger, and an upstream watch for fork and driver releases. Ships no NVIDIA binary: the runtime DLL comes from a path, an installed DLSS 5 title, or a source the user configures.", "author": { "name": "Melodic Software", diff --git a/plugins/gaming/CHANGELOG.md b/plugins/gaming/CHANGELOG.md index 82768e0f5d..b74fb272fc 100644 --- a/plugins/gaming/CHANGELOG.md +++ b/plugins/gaming/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `gaming` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.1] - 2026-09-27 + +### Fixed + +- A folder the discovery scan cannot list is reported with the path from the error, or `CategoryInfo.TargetName` when Windows leaves `TargetObject` empty. The selftest denies list access for the current user, Administrators, and Everyone and writes that ACL with `SetAccessControl`, because a user-only deny applied with `Set-Acl` does not stop an elevated runner. + ## [0.8.0] - 2026-09-27 ### Added diff --git a/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 b/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 index 7eea60ce41..ff487e67b1 100644 --- a/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 +++ b/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 @@ -232,7 +232,7 @@ function Find-AntiCheat($root) { Get-ChildItem -LiteralPath $p -Force -ErrorAction SilentlyContinue } } - $script:AcScanGaps = @($err | ForEach-Object { "on-disk scan: $($_.TargetObject) unreadable: $($_.Exception.Message)" }) + $script:AcScanGaps = @($err | ForEach-Object { "on-disk scan: $(ErrorPath $_ '') unreadable: $($_.Exception.Message)" }) @($items | Where-Object { IsAntiCheatName $_.Name } | ForEach-Object FullName | Sort-Object -Unique) } @@ -417,6 +417,12 @@ function Game($launcher, $name, $dir, $source) { } # One bad record is reported and skipped; the rest of that launcher's records still load. function Record($label, [scriptblock]$body) { try { & $body } catch { $script:Unchecked += "${label} unreadable: $($_.Exception.Message)" } } +# The path a listing error names. Windows often leaves TargetObject empty and puts the path on +# CategoryInfo.TargetName; without that fallback the report cannot be matched to the folder. +function ErrorPath($err, $fallback) { + foreach ($named in @($err.TargetObject, $err.CategoryInfo.TargetName, $fallback)) { if ("$named") { return "$named" } } + return "$fallback" +} # Lists a folder; an absent one is silent, one that cannot be listed is reported. # The name filter is applied afterwards: Get-ChildItem -Filter drops an access-denied folder, at # any depth, without recording an error. @@ -424,7 +430,7 @@ function ListDir($label, $path, [hashtable]$opts = @{}) { $pattern = $opts.Filter ?? '*'; $o = @{} + $opts; $o.Remove('Filter') $err = $null Get-ChildItem -LiteralPath $path @o -Force -ErrorAction SilentlyContinue -ErrorVariable err | Where-Object Name -like $pattern - foreach ($x in $err) { if ($x.Exception -isnot [Management.Automation.ItemNotFoundException]) { $script:Unchecked += "${label}: $($x.TargetObject) unreadable: $($x.Exception.Message)" } } + foreach ($x in $err) { if ($x.Exception -isnot [Management.Automation.ItemNotFoundException]) { $script:Unchecked += "${label}: $(ErrorPath $x $path) unreadable: $($x.Exception.Message)" } } } function Find-SteamGames { $steam = (RegProps 'HKCU:\Software\Valve\Steam').SteamPath @@ -1282,7 +1288,7 @@ function Find-RuntimeCandidates { @(Get-ScanRoots | Where-Object { Test-Path -LiteralPath $_ -PathType Container } | ForEach-Object { $err = $null Get-ChildItem -LiteralPath $_ -Recurse -File -Force -ErrorAction SilentlyContinue -ErrorVariable err | Where-Object Name -eq 'nvngx_dlssnr.dll' - foreach ($x in $err) { $script:ScanGaps += "runtime scan: $($x.TargetObject) unreadable: $($x.Exception.Message)" } + foreach ($x in $err) { $script:ScanGaps += "runtime scan: $(ErrorPath $x '') unreadable: $($x.Exception.Message)" } } | Sort-Object FullName -Unique) } # Read-only: installed games per launcher, what could not be read, and runtime DLL candidates with @@ -1875,22 +1881,40 @@ function Do-Selftest { try { $vf = Find-Games } finally { $vlock.Dispose() } Assert 'discover: a locked libraryfolders.vdf is reported and the main library still scans' (@($script:Unchecked | Where-Object { $_ -like 'Steam:*libraryfolders.vdf*unreadable*' }).Count -eq 1 -and @($vf | Where-Object name -eq 'Main Lib Game').Count -eq 1 -and -not @($vf | Where-Object name -eq 'Clean Game').Count) # A library folder that cannot be listed is reported, not read as empty - $deny = [Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.WindowsIdentity]::GetCurrent().User, 'ListDirectory', 'Deny') - $acl = Get-Acl -LiteralPath "$l2\steamapps"; $acl.AddAccessRule($deny); Set-Acl -LiteralPath "$l2\steamapps" -AclObject $acl + # A user-only Deny written with Set-Acl does not stop an elevated runner: Administrators + # still list the folder, so the unreadable-folder cases never record an error. Deny list + # access for the user, Administrators, and Everyone, and write the ACL with SetAccessControl. + $SetDeny = { + param([string]$LiteralPath, [switch]$Clear) + $acl = Get-Acl -LiteralPath $LiteralPath + foreach ($id in @( + [Security.Principal.WindowsIdentity]::GetCurrent().User + [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + [Security.Principal.SecurityIdentifier]::new('S-1-1-0'))) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new( + $id, [Security.AccessControl.FileSystemRights]::ListDirectory, + [Security.AccessControl.InheritanceFlags]::None, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Deny) + if ($Clear) { [void]$acl.RemoveAccessRule($rule) } else { [void]$acl.AddAccessRule($rule) } + } + (Get-Item -LiteralPath $LiteralPath -Force).SetAccessControl($acl) + } + & $SetDeny "$l2\steamapps" $od = "$tmp\pd\Origin\LocalContent\Denied"; Put "$od\x.mfst" '?id=x' - $acl2 = Get-Acl -LiteralPath $od; $acl2.AddAccessRule($deny); Set-Acl -LiteralPath $od -AclObject $acl2 + & $SetDeny $od try { $null = Find-Games } finally { - $acl = Get-Acl -LiteralPath "$l2\steamapps"; [void]$acl.RemoveAccessRule($deny); Set-Acl -LiteralPath "$l2\steamapps" -AclObject $acl - $acl2 = Get-Acl -LiteralPath $od; [void]$acl2.RemoveAccessRule($deny); Set-Acl -LiteralPath $od -AclObject $acl2 + & $SetDeny "$l2\steamapps" -Clear + & $SetDeny $od -Clear } Assert 'discover: a library that cannot be listed is reported' (@($script:Unchecked | Where-Object { $_ -like "Steam: *lib2\steamapps unreadable*" }).Count -eq 1) Assert 'discover: a nested folder that cannot be listed is reported, and the rest still loads' (@($script:Unchecked | Where-Object { $_ -like 'Origin: *LocalContent\Denied unreadable*' }).Count -eq 1) Put "$tmp\rc\a\nvngx_dlssnr.dll" 'held'; Put "$tmp\rc\b\nvngx_dlssnr.dll" 'fakemodel'; Put "$tmp\rc\c\x.txt" 'x' $dlock = [IO.File]::Open("$tmp\rc\a\nvngx_dlssnr.dll", 'Open', 'Read', 'None') - $acl = Get-Acl -LiteralPath "$tmp\rc\c"; $acl.AddAccessRule($deny); Set-Acl -LiteralPath "$tmp\rc\c" -AclObject $acl + & $SetDeny "$tmp\rc\c" try { $script:ScanRoots = @("$tmp\rc"); $rc = (Do-Discover) | ConvertFrom-Json } - finally { $dlock.Dispose(); $script:ScanRoots = $null; $acl = Get-Acl -LiteralPath "$tmp\rc\c"; [void]$acl.RemoveAccessRule($deny); Set-Acl -LiteralPath "$tmp\rc\c" -AclObject $acl } + finally { $dlock.Dispose(); $script:ScanRoots = $null; & $SetDeny "$tmp\rc\c" -Clear } Assert 'discover: a game subfolder the runtime scan cannot list is reported' (@($rc.unchecked | Where-Object { $_ -like 'runtime scan:*rc\c*unreadable*' }).Count -eq 1) Assert 'discover: an unreadable runtime candidate fails alone and the next is still reported' (@($rc.runtimeCandidates | Where-Object { -not $_.passes -and $_.reason -like 'unreadable*' }).Count -eq 1 -and @($rc.runtimeCandidates | Where-Object passes).Count -eq 1) $dj = (Do-Discover) | ConvertFrom-Json @@ -1944,8 +1968,8 @@ function Do-Selftest { $cga = (Do-Assess $cg) | ConvertFrom-Json Assert 'Steam with nothing disclosed anywhere: none-disclosed, no acknowledgement, caveat stated' ($cga.antiCheat.status -eq 'none-disclosed' -and -not $cga.acknowledgementRequired -and $cga.antiCheat.note -like '*not proof of no anti-cheat*') New-Item -ItemType Directory -Force -Path "$cg\locked" | Out-Null - $acl = Get-Acl -LiteralPath "$cg\locked"; $acl.AddAccessRule($deny); Set-Acl -LiteralPath "$cg\locked" -AclObject $acl - try { $cgl = (Do-Assess $cg) | ConvertFrom-Json } finally { $acl = Get-Acl -LiteralPath "$cg\locked"; [void]$acl.RemoveAccessRule($deny); Set-Acl -LiteralPath "$cg\locked" -AclObject $acl; Remove-Item -LiteralPath "$cg\locked" -Force } + & $SetDeny "$cg\locked" + try { $cgl = (Do-Assess $cg) | ConvertFrom-Json } finally { & $SetDeny "$cg\locked" -Clear; Remove-Item -LiteralPath "$cg\locked" -Force } Assert 'a game folder the on-disk scan cannot list makes the status unknown' ($cgl.antiCheat.status -eq 'unknown' -and @($cgl.antiCheat.unchecked | Where-Object { $_ -like 'on-disk scan:*locked*unreadable*' }).Count -eq 1) $script:SteamPages['444'] = '
Unlisted Game
' Put "$l2\steamapps\appmanifest_444.acf" (& $acf 444 'Unlisted Game' 'Unlisted') From 906a4905ce3b5104028e7fc762d74803697ea34c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 27 Sep 2026 23:17:54 +0000 Subject: [PATCH 3/5] fix(gaming): write the unlistable-folder ACL on pwsh 7 DirectoryInfo.SetAccessControl is absent in PowerShell 7, so the Windows selftest threw before the deny cases ran. Write the same user, Administrators, and Everyone list deny through FileSystemAclExtensions. Co-authored-by: Kyle Sexton --- plugins/gaming/CHANGELOG.md | 2 +- plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/gaming/CHANGELOG.md b/plugins/gaming/CHANGELOG.md index b74fb272fc..19f17f3aa1 100644 --- a/plugins/gaming/CHANGELOG.md +++ b/plugins/gaming/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to the `gaming` plugin are documented here. Format follows ### Fixed -- A folder the discovery scan cannot list is reported with the path from the error, or `CategoryInfo.TargetName` when Windows leaves `TargetObject` empty. The selftest denies list access for the current user, Administrators, and Everyone and writes that ACL with `SetAccessControl`, because a user-only deny applied with `Set-Acl` does not stop an elevated runner. +- A folder the discovery scan cannot list is reported with the path from the error, or `CategoryInfo.TargetName` when Windows leaves `TargetObject` empty. The selftest denies list access for the current user, Administrators, and Everyone and writes that ACL with `FileSystemAclExtensions.SetAccessControl`, because pwsh 7 has no `DirectoryInfo.SetAccessControl` and a user-only deny applied with `Set-Acl` does not stop an elevated runner. ## [0.8.0] - 2026-09-27 diff --git a/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 b/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 index ff487e67b1..12961a1cf8 100644 --- a/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 +++ b/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 @@ -1880,12 +1880,14 @@ function Do-Selftest { $vlock = [IO.File]::Open("$sp\steamapps\libraryfolders.vdf", 'Open', 'Read', 'None') try { $vf = Find-Games } finally { $vlock.Dispose() } Assert 'discover: a locked libraryfolders.vdf is reported and the main library still scans' (@($script:Unchecked | Where-Object { $_ -like 'Steam:*libraryfolders.vdf*unreadable*' }).Count -eq 1 -and @($vf | Where-Object name -eq 'Main Lib Game').Count -eq 1 -and -not @($vf | Where-Object name -eq 'Clean Game').Count) - # A library folder that cannot be listed is reported, not read as empty + # A library folder that cannot be listed is reported, not read as empty. # A user-only Deny written with Set-Acl does not stop an elevated runner: Administrators # still list the folder, so the unreadable-folder cases never record an error. Deny list - # access for the user, Administrators, and Everyone, and write the ACL with SetAccessControl. + # access for the user, Administrators, and Everyone. pwsh 7 has no + # DirectoryInfo.SetAccessControl; that method is FileSystemAclExtensions.SetAccessControl. $SetDeny = { param([string]$LiteralPath, [switch]$Clear) + $item = Get-Item -LiteralPath $LiteralPath -Force $acl = Get-Acl -LiteralPath $LiteralPath foreach ($id in @( [Security.Principal.WindowsIdentity]::GetCurrent().User @@ -1898,7 +1900,7 @@ function Do-Selftest { [Security.AccessControl.AccessControlType]::Deny) if ($Clear) { [void]$acl.RemoveAccessRule($rule) } else { [void]$acl.AddAccessRule($rule) } } - (Get-Item -LiteralPath $LiteralPath -Force).SetAccessControl($acl) + [System.IO.FileSystemAclExtensions]::SetAccessControl($item, $acl) } & $SetDeny "$l2\steamapps" $od = "$tmp\pd\Origin\LocalContent\Denied"; Put "$od\x.mfst" '?id=x' From 6b85b02cfa36da47749ca9cb519611b4d48fa728 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 27 Sep 2026 23:27:39 +0000 Subject: [PATCH 4/5] fix(gaming): make unlistable-folder cases fail the listing A Deny ACE, including one for Everyone, still lets the elevated Windows runner list the folder. Hold the directory open with no sharing so the next Get-ChildItem fails with a sharing violation, and recover a quoted path from the error message when TargetObject is empty. Co-authored-by: Kyle Sexton --- plugins/gaming/CHANGELOG.md | 2 +- .../skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 | 55 ++++++++----------- 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/plugins/gaming/CHANGELOG.md b/plugins/gaming/CHANGELOG.md index 19f17f3aa1..bd39f706e9 100644 --- a/plugins/gaming/CHANGELOG.md +++ b/plugins/gaming/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to the `gaming` plugin are documented here. Format follows ### Fixed -- A folder the discovery scan cannot list is reported with the path from the error, or `CategoryInfo.TargetName` when Windows leaves `TargetObject` empty. The selftest denies list access for the current user, Administrators, and Everyone and writes that ACL with `FileSystemAclExtensions.SetAccessControl`, because pwsh 7 has no `DirectoryInfo.SetAccessControl` and a user-only deny applied with `Set-Acl` does not stop an elevated runner. +- A folder the discovery scan cannot list is reported with the path from the error, from `CategoryInfo.TargetName`, or from a quoted path in the message when Windows leaves `TargetObject` empty. The selftest holds that folder open with no sharing so the listing fails with a sharing violation. A Deny ACE, including one for Everyone, does not stop an elevated runner from listing. ## [0.8.0] - 2026-09-27 diff --git a/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 b/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 index 12961a1cf8..d6c6789d81 100644 --- a/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 +++ b/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 @@ -418,9 +418,13 @@ function Game($launcher, $name, $dir, $source) { # One bad record is reported and skipped; the rest of that launcher's records still load. function Record($label, [scriptblock]$body) { try { & $body } catch { $script:Unchecked += "${label} unreadable: $($_.Exception.Message)" } } # The path a listing error names. Windows often leaves TargetObject empty and puts the path on -# CategoryInfo.TargetName; without that fallback the report cannot be matched to the folder. +# CategoryInfo.TargetName, or only inside a quoted path in the message. function ErrorPath($err, $fallback) { - foreach ($named in @($err.TargetObject, $err.CategoryInfo.TargetName, $fallback)) { if ("$named") { return "$named" } } + foreach ($named in @($err.TargetObject, $err.CategoryInfo.TargetName)) { if ("$named") { return "$named" } } + $msg = "$($err.Exception.Message)" + if ($msg -match "'([A-Za-z]:\\[^']+)'") { return $Matches[1] } + if ($msg -match '"([A-Za-z]:\\[^"]+)"') { return $Matches[1] } + if ("$fallback") { return "$fallback" } return "$fallback" } # Lists a folder; an absent one is silent, one that cannot be listed is reported. @@ -1881,42 +1885,29 @@ function Do-Selftest { try { $vf = Find-Games } finally { $vlock.Dispose() } Assert 'discover: a locked libraryfolders.vdf is reported and the main library still scans' (@($script:Unchecked | Where-Object { $_ -like 'Steam:*libraryfolders.vdf*unreadable*' }).Count -eq 1 -and @($vf | Where-Object name -eq 'Main Lib Game').Count -eq 1 -and -not @($vf | Where-Object name -eq 'Clean Game').Count) # A library folder that cannot be listed is reported, not read as empty. - # A user-only Deny written with Set-Acl does not stop an elevated runner: Administrators - # still list the folder, so the unreadable-folder cases never record an error. Deny list - # access for the user, Administrators, and Everyone. pwsh 7 has no - # DirectoryInfo.SetAccessControl; that method is FileSystemAclExtensions.SetAccessControl. - $SetDeny = { - param([string]$LiteralPath, [switch]$Clear) - $item = Get-Item -LiteralPath $LiteralPath -Force - $acl = Get-Acl -LiteralPath $LiteralPath - foreach ($id in @( - [Security.Principal.WindowsIdentity]::GetCurrent().User - [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') - [Security.Principal.SecurityIdentifier]::new('S-1-1-0'))) { - $rule = [Security.AccessControl.FileSystemAccessRule]::new( - $id, [Security.AccessControl.FileSystemRights]::ListDirectory, - [Security.AccessControl.InheritanceFlags]::None, - [Security.AccessControl.PropagationFlags]::None, - [Security.AccessControl.AccessControlType]::Deny) - if ($Clear) { [void]$acl.RemoveAccessRule($rule) } else { [void]$acl.AddAccessRule($rule) } - } - [System.IO.FileSystemAclExtensions]::SetAccessControl($item, $acl) + # A Deny ACE does not stop an elevated runner from listing, even a Deny for Everyone. + # Hold the directory open with no sharing so the next listing fails with a sharing violation. + $LockDir = { + param([string]$LiteralPath) + $opts = [System.IO.FileStreamOptions]::new() + $opts.Mode = [System.IO.FileMode]::Open + $opts.Access = [System.IO.FileAccess]::Read + $opts.Share = [System.IO.FileShare]::None + $opts.Options = [System.IO.FileOptions]::BackupSemantics + [System.IO.FileStream]::new($LiteralPath, $opts) } - & $SetDeny "$l2\steamapps" + $lockSteam = & $LockDir "$l2\steamapps" $od = "$tmp\pd\Origin\LocalContent\Denied"; Put "$od\x.mfst" '?id=x' - & $SetDeny $od + $lockOd = & $LockDir $od try { $null = Find-Games } - finally { - & $SetDeny "$l2\steamapps" -Clear - & $SetDeny $od -Clear - } + finally { $lockSteam.Dispose(); $lockOd.Dispose() } Assert 'discover: a library that cannot be listed is reported' (@($script:Unchecked | Where-Object { $_ -like "Steam: *lib2\steamapps unreadable*" }).Count -eq 1) Assert 'discover: a nested folder that cannot be listed is reported, and the rest still loads' (@($script:Unchecked | Where-Object { $_ -like 'Origin: *LocalContent\Denied unreadable*' }).Count -eq 1) Put "$tmp\rc\a\nvngx_dlssnr.dll" 'held'; Put "$tmp\rc\b\nvngx_dlssnr.dll" 'fakemodel'; Put "$tmp\rc\c\x.txt" 'x' $dlock = [IO.File]::Open("$tmp\rc\a\nvngx_dlssnr.dll", 'Open', 'Read', 'None') - & $SetDeny "$tmp\rc\c" + $lockRc = & $LockDir "$tmp\rc\c" try { $script:ScanRoots = @("$tmp\rc"); $rc = (Do-Discover) | ConvertFrom-Json } - finally { $dlock.Dispose(); $script:ScanRoots = $null; & $SetDeny "$tmp\rc\c" -Clear } + finally { $dlock.Dispose(); $lockRc.Dispose(); $script:ScanRoots = $null } Assert 'discover: a game subfolder the runtime scan cannot list is reported' (@($rc.unchecked | Where-Object { $_ -like 'runtime scan:*rc\c*unreadable*' }).Count -eq 1) Assert 'discover: an unreadable runtime candidate fails alone and the next is still reported' (@($rc.runtimeCandidates | Where-Object { -not $_.passes -and $_.reason -like 'unreadable*' }).Count -eq 1 -and @($rc.runtimeCandidates | Where-Object passes).Count -eq 1) $dj = (Do-Discover) | ConvertFrom-Json @@ -1970,8 +1961,8 @@ function Do-Selftest { $cga = (Do-Assess $cg) | ConvertFrom-Json Assert 'Steam with nothing disclosed anywhere: none-disclosed, no acknowledgement, caveat stated' ($cga.antiCheat.status -eq 'none-disclosed' -and -not $cga.acknowledgementRequired -and $cga.antiCheat.note -like '*not proof of no anti-cheat*') New-Item -ItemType Directory -Force -Path "$cg\locked" | Out-Null - & $SetDeny "$cg\locked" - try { $cgl = (Do-Assess $cg) | ConvertFrom-Json } finally { & $SetDeny "$cg\locked" -Clear; Remove-Item -LiteralPath "$cg\locked" -Force } + $lockCg = & $LockDir "$cg\locked" + try { $cgl = (Do-Assess $cg) | ConvertFrom-Json } finally { $lockCg.Dispose(); Remove-Item -LiteralPath "$cg\locked" -Force } Assert 'a game folder the on-disk scan cannot list makes the status unknown' ($cgl.antiCheat.status -eq 'unknown' -and @($cgl.antiCheat.unchecked | Where-Object { $_ -like 'on-disk scan:*locked*unreadable*' }).Count -eq 1) $script:SteamPages['444'] = '
Unlisted Game
' Put "$l2\steamapps\appmanifest_444.acf" (& $acf 444 'Unlisted Game' 'Unlisted') From c59adee63c1945f9a93ef0a014a975432848f81c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 27 Sep 2026 23:37:07 +0000 Subject: [PATCH 5/5] fix(gaming): open unlistable test folders with CreateFileW The runner's pwsh has no FileOptions.BackupSemantics, so the selftest threw before the sharing lock was taken. Open the directory with CreateFileW and FILE_FLAG_BACKUP_SEMANTICS instead. Co-authored-by: Kyle Sexton --- .../skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 b/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 index d6c6789d81..416e88a2ee 100644 --- a/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 +++ b/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 @@ -1887,14 +1887,31 @@ function Do-Selftest { # A library folder that cannot be listed is reported, not read as empty. # A Deny ACE does not stop an elevated runner from listing, even a Deny for Everyone. # Hold the directory open with no sharing so the next listing fails with a sharing violation. + # pwsh on the runner has no FileOptions.BackupSemantics, so open the directory with CreateFileW. $LockDir = { param([string]$LiteralPath) - $opts = [System.IO.FileStreamOptions]::new() - $opts.Mode = [System.IO.FileMode]::Open - $opts.Access = [System.IO.FileAccess]::Read - $opts.Share = [System.IO.FileShare]::None - $opts.Options = [System.IO.FileOptions]::BackupSemantics - [System.IO.FileStream]::new($LiteralPath, $opts) + if (-not ('Dlss5DirHandle' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +public sealed class Dlss5DirHandle : IDisposable { + IntPtr handle; + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + static extern IntPtr CreateFileW(string name, uint access, uint share, IntPtr sec, uint disp, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool CloseHandle(IntPtr handle); + public Dlss5DirHandle(string path) { + handle = CreateFileW(path, 0x80000000, 0, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero); + if (handle == new IntPtr(-1)) throw new Win32Exception(Marshal.GetLastWin32Error()); + } + public void Dispose() { + if (handle != IntPtr.Zero && handle != new IntPtr(-1)) { CloseHandle(handle); handle = IntPtr.Zero; } + } +} +'@ + } + [Dlss5DirHandle]::new($LiteralPath) } $lockSteam = & $LockDir "$l2\steamapps" $od = "$tmp\pd\Origin\LocalContent\Denied"; Put "$od\x.mfst" '?id=x'