diff --git a/docs/phase1-conformance.md b/docs/phase1-conformance.md index b9a9a1a3..474e7aae 100644 --- a/docs/phase1-conformance.md +++ b/docs/phase1-conformance.md @@ -2047,7 +2047,7 @@ revision authorities can therefore have different workflow hashes: | `scripts/phase1-windows-supervisor-install.ps1` | 1,743 | `2baab275f0bb6789884cded5f6185d00bfa5348b9e7c3ad1e5575353639101d5` | | `scripts/windows-job-supervisor.cs` | 397,084 | `ec56ad9daf9cfd2e92de4420cfc5ce328e09a76b627a8253ef35f2e9ef151669` | | `scripts/windows-job-supervisor.test.ps1` | 201,048 | `0b9828c2cd801799bc0055fe4047e1e914ee7fcc921345dee6b0caa3a389386a` | -| `scripts/windows-quota-diagnostics.test.ps1` | 39,744 | `f22177fd3b079a29333627f9a37c55a0a288010062685ffc9b08392f7b65b97d` | +| `scripts/windows-quota-diagnostics.test.ps1` | 45,628 | `2a2f59c95e14d7687cc258d52ed233062a793ba516a2b0d18d9a473a3ed79d96` | | `scripts/windows-owner-directory-quota.test.ps1` | 14,789 | `a6fccce4e1e41b06c655bc1a70bf870ba115f0848ee647777d2606294483cd1f` | | `scripts/windows-quota-isolated-reader.test.ps1` | 24,200 | `22f3baa7272f0bf7f03e44b3dec98398a96e64a8b507ce5b11925bd777d58a31` | | `scripts/windows-quota-lifetime.test.ps1` | 2,513 | `dd10741c19cd97cc1b9ee29ebe18b8381503d589680acd0eddaabda08b5e7aec` | diff --git a/docs/windows-quota-repeat-outcome.md b/docs/windows-quota-repeat-outcome.md index bc5d080f..97f860c9 100644 --- a/docs/windows-quota-repeat-outcome.md +++ b/docs/windows-quota-repeat-outcome.md @@ -12,6 +12,7 @@ follow-up read: - `readable`: the follow-up callback returned successfully. - `missing`: it threw `FileNotFoundException` or `DirectoryNotFoundException`. - `persistent`: it threw another exception; this need not match the first error. + This label has since been split by the second exception's category; see below. - `none`: no diagnostic follow-up was requested. Protected run @@ -28,25 +29,86 @@ attempt's partial list is discarded; only a fresh traversal that completes under the existing bounds can become the measurement. There is no wait, third read, identity switch, permission change, or resource-ceiling change. -`missing` and `persistent` repeats remain terminal, as do every initial failure -other than `UnauthorizedAccessException`. Attribute and file-length reads keep +`missing` and every `persistent` repeat remain terminal, as do every initial +failure other than `UnauthorizedAccessException`. Attribute and file-length reads keep their existing fail-closed diagnostic-only repeats. The terminal check performs the same bounded recovery and still rejects any path that cannot produce a complete readable snapshot. +Protected run +[35146928092](https://github.com/OpenCoven/chat/actions/runs/35146928092) +then failed with `access-denied`, `root=harness-execution-aggregate`, +`scope=checkouts`, `directory-enumeration-depth-3-plus`, and +`repeat=persistent`. That label was as uninformative as `transient` had been. +Both repeat sites classified every non-missing second exception with a bare +`catch`, so `persistent` covered at least three different outcomes: + +- a second `UnauthorizedAccessException`, the only case the label suggests; +- `QuotaEntryBoundException`, thrown when a snapshot reaches its entry limit. + `MaximumQuotaEntries` is one budget for the whole traversal of a root, not a + per-directory cap: `MeasureDirectoryBytes` counts entries once per root and + passes the remainder into each enumeration, so once the budget is spent the + bound fires on the first entry of every later directory, whatever its ACL; +- another `IOException`, for example from concurrent mutation of the checkout + while the traversal runs. + +The repeat label now carries the second exception's category, using only the +fixed categories the monitor already reports for a first failure: + +- `readable`, `missing`, and `none` are unchanged. +- `persistent-access-denied`: the follow-up threw a second + `UnauthorizedAccessException`. +- `persistent-entry-bound`: the follow-up threw `QuotaEntryBoundException`; the + traversal-wide entry budget was already spent when this directory was read. +- `persistent-io`, `persistent-io-file-not-found`, + `persistent-io-path-not-found`, `persistent-io-sharing-violation`, + `persistent-io-lock-violation`, `persistent-io-name-too-long`, + `persistent-io-invalid-directory`, `persistent-io-delete-pending`: the + follow-up threw an `IOException`, classified by the same reviewed Win32 + HRESULT table as an initial I/O failure. +- `persistent-arithmetic-overflow` and `persistent-unexpected`: the remaining + first-failure categories, applied to the follow-up. +- `persistent` is still accepted by the context normalizer for records + produced before the split; the supervisor no longer emits it. + +Both catch sites map through one helper, `ClassifyPersistentQuotaRepeat`, which +prefixes `ClassifyQuotaMonitorError` of the second exception. Any value outside +the fixed list normalizes to `none`, so no exception text or path can enter the +label. The first failure's category is preserved: a denial followed by an +entry-bound repeat still reports `access-denied` with +`repeat=persistent-entry-bound`. The change adds no read, wait, identity switch, +permission change, or change to `MaximumQuotaEntries` or any other ceiling. + Managed regression coverage exercises both missing exception types, a successful -metadata follow-up, repeated access denial, and a different second error. A +metadata follow-up, repeated access denial, and a different second error. Both +production seams are then driven through one injected second failure at a time: +every normalized repeat label: a second denial, an entry-bound exception, +generic I/O and each classified I/O HRESULT, arithmetic overflow, both missing +types, and an unexpected exception, each of which must keep its own label after +exactly two calls. A real directory read over a spent entry +budget must report `entry-bound` with `repeat=persistent-entry-bound` through +the production enumeration path without any injection. A separate snapshot regression requires exactly two calls and verifies that the accepted result contains the complete fresh directory contents. Native Windows coverage first holds a real owner-only ACL denial across both reads and requires -the bounded `persistent` failure. The isolated-reader fixture then denies the +the bounded `persistent-access-denied` failure. The isolated-reader fixture then denies the validated isolated identity, temporarily reverts only for the fixture ACL restoration, verifies impersonation is restored before the second enumeration, and requires two identity-checked reads to return the complete fresh snapshot. A subsequent 512-byte limit check proves the production accounting path still enforces the byte quota. -Status: local diagnostic tests pass after a demonstrated failing regression. -Native Windows validation, reviewed frozen-source binding, SDK rebinding and -fresh protected evidence are still required before claiming deployed coverage. -Tracks Chat #219 and `cave-k0aqq.2`. Preserve chat and active worktrees. +Status: the `readable`/`missing` split passed local diagnostic tests after a +demonstrated failing regression. The `persistent-` split is in the +frozen supervisor source. The managed diagnostic file, including the per-seam +repeat-outcome matrix and the spent-budget check, passed on macOS under +PowerShell 7.6.6 on .NET 10, and the `windows-supervisor-behavior` job passed on +`windows-2025` for PR #338 in run +[35561671518](https://github.com/OpenCoven/chat/actions/runs/35561671518). +Which cause fired in protected run 35146928092 remains unproven: that record +says only `persistent`, and this change does not repair the underlying denial. +A fresh protected run will instead report `persistent-entry-bound`, +`persistent-access-denied`, or a `persistent-io-*` label, which is the evidence +still required. Reviewed frozen-source binding and SDK rebinding are unchanged +by this diagnostic. Tracks Chat #219 and `cave-k0aqq.2`. Preserve chat and +active worktrees. diff --git a/scripts/windows-quota-diagnostics.test.ps1 b/scripts/windows-quota-diagnostics.test.ps1 index f9835bcb..8e5aa151 100644 --- a/scripts/windows-quota-diagnostics.test.ps1 +++ b/scripts/windows-quota-diagnostics.test.ps1 @@ -314,6 +314,25 @@ namespace OpenCoven.Tests throw new IOException("private-second-snapshot"); } + public static int RepeatFailureCalls; + public static Exception RepeatFailure; + public static Func RepeatFailureRead { get { return RepeatFailureMetadata; } } + public static Func> RepeatFailureSnapshotRead { get { return RepeatFailureSnapshot; } } + + public static string RepeatFailureMetadata() + { + RepeatFailureCalls++; + if (RepeatFailureCalls == 1) throw new UnauthorizedAccessException("private-first-denial"); + throw RepeatFailure; + } + + private static List RepeatFailureSnapshot() + { + RepeatFailureCalls++; + if (RepeatFailureCalls == 1) throw new UnauthorizedAccessException("private-first-denial"); + throw RepeatFailure; + } + public static Func> TransientSnapshotRead { get { return TransientSnapshot; } @@ -456,6 +475,84 @@ if ($null -eq $changedSnapshotError -or } Write-Host 'Snapshot retry distinguishes changed I/O failure from repeated access denial.' +# Chat #219: run 35146928092 reported only `repeat=persistent` after an access +# denial. That label could not tell a second denial from an exhausted +# traversal-wide entry budget or a changed I/O failure. Each second-attempt +# outcome must keep its own fixed label at both production seams, with exactly +# one repeat and no private text. +$repeatOutcomeCases = @( + @($bound, 'persistent-entry-bound'), + @([UnauthorizedAccessException]::new('private-second-denial'), 'persistent-access-denied'), + @([IO.IOException]::new('private-second-io'), 'persistent-io'), + # A plain IOException carrying the file- or path-not-found HRESULT is not a + # FileNotFoundException or DirectoryNotFoundException, so it must reach the + # classifier rather than the missing catch. + @([IO.IOException]::new('private-second-file-hresult', -2147024894), 'persistent-io-file-not-found'), + @([IO.IOException]::new('private-second-path-hresult', -2147024893), 'persistent-io-path-not-found'), + @([IO.IOException]::new('private-second-sharing', -2147024864), 'persistent-io-sharing-violation'), + @([IO.IOException]::new('private-second-lock', -2147024863), 'persistent-io-lock-violation'), + @([IO.IOException]::new('private-second-name', -2147024690), 'persistent-io-name-too-long'), + @([IO.IOException]::new('private-second-directory-hresult', -2147024629), 'persistent-io-invalid-directory'), + @([IO.IOException]::new('private-second-delete', -2147024593), 'persistent-io-delete-pending'), + @([OverflowException]::new('private-second-overflow'), 'persistent-arithmetic-overflow'), + @([IO.FileNotFoundException]::new('private-second-file'), 'missing'), + @([IO.DirectoryNotFoundException]::new('private-second-directory'), 'missing'), + @([InvalidOperationException]::new('private-second-unexpected'), 'persistent-unexpected') +) +foreach ($seam in @( + @('entry-attributes', $readQuota, [OpenCoven.Tests.QuotaRepeatProbe]::RepeatFailureRead), + @('directory-enumeration-depth-3-plus', $readSnapshot, [OpenCoven.Tests.QuotaRepeatProbe]::RepeatFailureSnapshotRead))) { + foreach ($outcome in $repeatOutcomeCases) { + [OpenCoven.Tests.QuotaRepeatProbe]::RepeatFailureCalls = 0 + [OpenCoven.Tests.QuotaRepeatProbe]::RepeatFailure = $outcome[0] + $repeatOutcomeError = $null + try { + $seam[1].Invoke($null, [object[]]@($seam[0], $seam[2], $true, [Type]::Missing)) | Out-Null + } catch { + $repeatOutcomeError = $_.Exception.GetBaseException() + } + if ($null -eq $repeatOutcomeError -or $repeatOutcomeError.GetType() -ne $contextType -or + [OpenCoven.Tests.QuotaRepeatProbe]::RepeatFailureCalls -ne 2 -or + $contextType.GetProperty('Category', $instanceFlags).GetValue($repeatOutcomeError) -cne 'access-denied' -or + $contextType.GetProperty('Operation', $instanceFlags).GetValue($repeatOutcomeError) -cne $seam[0] -or + $contextType.GetProperty('Repeat', $instanceFlags).GetValue($repeatOutcomeError) -cne $outcome[1] -or + $repeatOutcomeError.ToString().Contains('private-')) { + throw "Repeat outcome lost its label at $($seam[0]): expected $($outcome[1])." + } + } +} +[OpenCoven.Tests.QuotaRepeatProbe]::RepeatFailure = $null +Write-Host 'Second denial, exhausted entry budget, other I/O failures, and missing paths keep distinct repeat labels at both seams.' + +# The entry budget is spent across one whole traversal, not per directory, so a +# denied directory can be followed by an entry-bound repeat with no ACL change. +# Without injection, a real directory over a spent budget must report +# `entry-bound` on both attempts through the production enumeration path. +$readBoundedSnapshot = [OpenCoven.WindowsJobSupervisor].GetMethod('ReadBoundedDirectorySnapshot', $flags) +if ($null -eq $readBoundedSnapshot) { throw 'Missing bounded directory snapshot seam.' } +$budgetRoot = Join-Path $PSScriptRoot ('.quota-budget-' + [guid]::NewGuid().ToString('N')) +try { + [IO.Directory]::CreateDirectory($budgetRoot) | Out-Null + [IO.File]::WriteAllText((Join-Path $budgetRoot 'first'), 'quota') + [IO.File]::WriteAllText((Join-Path $budgetRoot 'second'), 'quota') + $budgetError = $null + try { + $readBoundedSnapshot.Invoke($null, [object[]]@([string]$budgetRoot, $null, $false, 1, 3, $true)) | Out-Null + } catch { + $budgetError = $_.Exception.GetBaseException() + } + if ($null -eq $budgetError -or $budgetError.GetType() -ne $contextType -or + $contextType.GetProperty('Category', $instanceFlags).GetValue($budgetError) -cne 'entry-bound' -or + $contextType.GetProperty('Operation', $instanceFlags).GetValue($budgetError) -cne 'directory-enumeration-depth-3-plus' -or + $contextType.GetProperty('Repeat', $instanceFlags).GetValue($budgetError) -cne 'persistent-entry-bound' -or + $budgetError.ToString().Contains($budgetRoot)) { + throw 'Spent entry budget was not reported as entry-bound on both attempts.' + } +} finally { + Remove-Item -LiteralPath $budgetRoot -Recurse -Force -ErrorAction SilentlyContinue +} +Write-Host 'A spent traversal entry budget reports entry-bound on the first attempt and its repeat.' + $snapshotRoot = Join-Path $PSScriptRoot ('.quota-readable-' + [guid]::NewGuid().ToString('N')) try { [IO.Directory]::CreateDirectory($snapshotRoot) | Out-Null