Skip to content

Deep audit: data-safety, CLI, filter, and WPF UX hardening#2

Merged
SysAdminDoc merged 5 commits into
mainfrom
audit/hardening
Jun 15, 2026
Merged

Deep audit: data-safety, CLI, filter, and WPF UX hardening#2
SysAdminDoc merged 5 commits into
mainfrom
audit/hardening

Conversation

@SysAdminDoc

Copy link
Copy Markdown
Owner

A deep engineering + product-quality audit of RED++ (a Windows tool that recursively deletes empty directories). Five reviewable commits across data safety, CLI robustness, filtering correctness, and WPF UX/accessibility. Engine build clean, 58 tests (+8), headless safety smoke green, GUI verified by screenshot.

Correctness & data safety

  • [P0] Protected folders are no longer destroyed by a recursive ancestor delete. Protecting a folder only guarded its exact path; an empty-eligible ancestor was still deleted recursively (in both the direct and recycle-batch paths), taking the protected child with it. A directory is now treated as protected when it — or anything beneath it — is on the protected list (IsProtectedOrAncestorOfProtected), and the list is matched case-insensitively. 6 regression tests.
  • [P1] Cross-volume Move-to-folder fallback re-verifies file-free and deletes the source bottom-up by handle (refuses a non-empty dir) instead of a blind recursive Directory.Delete.
  • [P2] The empty-files recycle batch derives each outcome from !File.Exists rather than the shell sink's positional results (matches the directory-path fix; IFileOperation doesn't guarantee callback order).

CLI & config robustness (Task Scheduler safety)

  • [P2] -saveprofile rejects an unknown -mode, or -mode move without an absolute -moveto, instead of persisting a profile that fails every later run. Profile names are trimmed, length-capped, and control-char-rejected.
  • [P2] Config load restores any sub-object a crafted file nil'd out, so the dirty-state check (incl. ConfigLoad's finally) can't NRE and crash a headless run.
  • [P3] -eventlog summary now sanitizes folder names (bidi/zero-width/control) like the log lines.

Filtering correctness

  • [P0] A per-directory .gitignore's bare-name rule (e.g. dist) is now scoped to its own subtree (Git semantics) instead of applying tree-wide, so unrelated directories aren't wrongly reported ignored/skipped in the dry-run.
  • [P1] User filter regexes carry a 1-second match timeout, so a catastrophic-backtracking pattern ((a+)+$) can't hang the scan thread (ReDoS). 2 tests.

WPF UX, accessibility & polish (default shell)

  • [P1] The review list is color-coded (eligible = red, kept = muted, deleted = green, protected = blue, failed = amber) — the computed status color was previously never bound, so the destructive list had no coloring despite the legend promising it. Color reinforces the status word, never alone.
  • [P1] Visible keyboard-focus ring on the path box, deletion-mode dropdown, filter lists, and result list (were WPF's invisible dotted default) — WCAG 2.4.7.
  • [P1] A dry run no longer claims files were "changed" (now "…would be removed. Nothing was changed."); completion copy matches the chosen mode; canceled vs error-stopped read distinctly.
  • Legible status red, comfortable row height, green/amber status indicator with a screen-reader name, a positive "no empty directories found" state, and one-off colors/radii folded onto the palette.
  • [crash] Tree protect/unprotect now guards against non-DirectoryInfo nodes.

Verification

  • dotnet build -c Release clean; dotnet test 58/58.
  • Headless safety smoke: empty-dir/file deletion, junction/deny-ACL/AutoProtectRoot protection, dry-run exit codes, recycle→undo — all green.
  • GUI verified by screenshot: color-coded results, clean state, pre-scan state, classic WinForms (dark + light).

Deferred items (light/high-contrast theme for the WPF shell, undo-manifest path validation, filter separator normalization, tree protect/unprotect symmetry, minor hardening) are recorded in the local ROADMAP, not implemented here.

A user-protected folder was only guarded at its exact path. Because results
are sorted parents-first and deleted recursively, an empty-eligible ancestor
of a protected folder was deleted and took the protected child with it (the
child was then counted as already-deleted-by-parent, never re-checked). This
breached the per-folder protection invariant in both the direct and
recycle-batch delete paths (AutoProtectRoot survived only because the root is
the topmost item).

A directory is now treated as protected when it, or any directory beneath it,
is on the protected list (IsProtectedOrAncestorOfProtected), and the protected
list is built with an OrdinalIgnoreCase comparer to match Windows path casing.
Adds 6 regression tests covering self/ancestor/case-insensitive/prefix-sibling
matching.

Also: harden the cross-volume Move-to-folder fallback to re-verify file-free
and delete the source bottom-up by handle instead of a blind recursive
Directory.Delete; and derive the empty-files recycle batch outcome from
!File.Exists rather than the sink's positional results.
- -saveprofile now rejects an unknown -mode, or -mode move without an
  absolute -moveto, with exit 1 instead of persisting a profile that fails
  every later -profile run. Profile names are trimmed and capped at 128 chars
  with control characters rejected (they corrupt -listprofiles output).
- Config load restores any sub-object that a crafted file nil'd out, so the
  dirty-state check (incl. ConfigLoad's finally) can no longer NullReference
  and crash a headless run with a non-deterministic exit code.
- The -eventlog summary now sanitizes folder names (bidi/zero-width/control)
  like the console and log lines already do.
…ching

- A bare-name rule in a per-directory .gitignore (e.g. `dist`) was applied to
  every directory of that name tree-wide instead of only its own subtree, so
  unrelated directories were wrongly reported ignored/skipped in the dry-run.
  Name patterns now honor their scope like path patterns do (Git semantics).
- User filter regexes (RegExName/RegExPath) had no match timeout, so a
  catastrophic-backtracking pattern could hang the scan thread. They now carry
  a one-second timeout and a timed-out match is treated as no-match.

Adds a scoped-gitignore test and a ReDoS-timeout test.
Premium/accessibility pass on the default WPF shell:
- The review list's computed status color was never bound to anything, so the
  destructive list had no color coding despite the legend promising it. Status
  is now a colored word (eligible=red, kept=muted, deleted=green,
  protected=blue, failed=amber) — color reinforces the word, never alone.
- Text box, deletion-mode dropdown, filter lists, and result list now show a
  visible BlueLight keyboard-focus ring (were WPF's invisible dotted default).
- A dry run no longer claims files were "changed": it reports "would be
  removed. Nothing was changed.", and completion copy matches the chosen mode.
  Canceled vs error-stopped runs read distinctly.
- Legible red for eligible status text, comfortable row height, green/amber
  status indicator with a screen-reader-friendly name, a positive "no empty
  directories found" state, and one-off colors/radii folded onto the palette.
unprotectNode/ProtectNode cast node.Tag to DirectoryInfo unconditionally, so a
node with a null or placeholder Tag threw while walking the tree. Both now use
a pattern-matched guard and skip such nodes (the class's own TODO flagged this).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 21ca0881dd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread RED/Config/Config.cs
Comment on lines +69 to +70
if (Options == null) Options = new ConfigOptions();
if (Filters == null) Filters = new ConfigFilters();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Initialize replacement config children with real defaults

When a crafted/corrupt config nils one of these children, the replacement constructors do not apply RED++'s normal defaults: ConfigOptions.SetToDefaults() is what sets values like MaxDirectoryDepth = -1, AutoProtectRoot, and the system/hidden-directory defaults, and ConfigFilters.SetToDefaults() seeds the ignore/never-empty lists. As written, a headless run with <Options xsi:nil="true"/> or <Filters xsi:nil="true"/> no longer crashes but continues with CLR zeroes or empty safety filters, so it can behave very differently from a fresh default config; call the relevant SetToDefaults() when creating these replacements.

Useful? React with 👍 / 👎.

@SysAdminDoc SysAdminDoc merged commit 7db1f32 into main Jun 15, 2026
1 check passed
@SysAdminDoc SysAdminDoc deleted the audit/hardening branch June 15, 2026 14:06
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.

1 participant