ARCHITECTURE.md — Architecture and Layer Contracts

Current as of v1.4/v1.5 (last synced 2026-07-18). All signatures reflect actual implemented code, verified by reading it — see CLAUDE.md's Documentation Map for when to update this file.

Generated API reference (T-F172/T-F173, 2026-08-13): Archiver.Core's and Archiver.App.Core's public members are also documented at https://pakkoapp-oss.github.io/pakko/dev/api/, generated straight from their own XML /// doc comments — CS1591 (missing doc comment) is a real enforced build gate now, so this can't silently drift the way a hand-maintained signature list can. This file's own content is unchanged and remains the narrative/DI-wiring reference; the generated site is a complement, not a replacement.


Layer Diagram

┌─────────────────────────────────────┐  ┌──────────────────────────────────────┐
│           Archiver.App              │  │         Archiver.Shell               │
│           (WinUI 3, net10.0-win)    │  │  (net10.0-windows, WinExe, no WinUI) │
│                                     │  │                                      │
│  MainWindow.xaml / .cs              │  │  Program.cs (entry point)            │
│  ViewModels/MainViewModel.cs        │  │  ShellArgumentParser.cs              │
│  Services/ (Dialog, Log)            │  │  ShellCommands → IOperationUi        │
│                                     │  │  Win32OperationUi (native dialogs)   │
│  Strings/en-US/Resources.resw       │  │  AppLauncher: ActivateApplication    │
└──────────────┬──────────────────────┘  └───────────────┬──────────────────────┘
               │  project reference                       │  project reference
               └──────────────┬──────────────────────────┘
                               ▼
┌─────────────────────────────────────┐
│           Archiver.Core             │
│         (net10.0, no UI deps)       │
│                                     │
│  Interfaces/IArchiveService.cs      │
│  Services/ZipArchiveService.cs      │
│  Models/                            │
└──────────────┬──────────────────────┘
               │
               ▼
┌─────────────────────────────────────┐
│       Windows / .NET APIs           │
│  System.IO.Compression              │
│  System.Diagnostics.Process         │
└─────────────────────────────────────┘

Shell UI (T-F268) — every window an Explorer command shows goes through one internal interface, IOperationUi (Begin → an IOperationSession per operation: progress + cancel, the conflict/password prompts asked while it runs, and one Complete(OperationMessage?) result; ShowMessage only for a failed open-in-Pakko hand-off). ShellCommands holds the commands and never calls a dialog directly; OperationMessages builds the result text; ShellServices holds the Core factories so tests use a ZIP-only router. The implementation today is Win32OperationUi.

Progress UI — Win32OperationUi shows progress via the Windows Shell's built-in IProgressDialog COM object (NativeProgressDialog.cs), in-process — no satellite process, no IPC. An earlier design (Archiver.ProgressWindow, a second WinUI 3 .exe talking to Archiver.Shell over a named pipe) was removed in T-F65 after its WinUI/WindowsAppRuntime activation proved unreliable when spawned via Process.Start; see DECISIONS.md.

Archiver.Package — created and deleted during v1.2 development. The .wapproj approach was abandoned due to PRI resource conflicts when packaging multiple WinUI 3 apps in one package. Satellite EXE packaging is solved instead via Content Include items in Archiver.App.csproj conditioned on GenerateAppxPackageOnBuild=true.

Rule: Archiver.Core must have zero references to WinUI, Microsoft.UI, Windows.ApplicationModel.Resources, or any UI assembly.


Folder Structure

src/
├── Archiver.Core/              ← net10.0, zero UI deps, zero NuGet packages
│   ├── Interfaces/
│   │   ├── IArchiveService.cs
│   │   ├── IArchiveCreationRouter.cs   ← T-F105: routes ArchiveAsync by ArchiveContainerFormat
│   │   ├── IArchiveListingRouter.cs    ← T-F05: routes ListEntriesAsync by detected format
│   │   ├── IExtractionRouter.cs        ← T-F85: routes ExtractAsync/TestAsync by detected format
│   │   ├── IAntivirusScanService.cs    ← T-F146: AMSI-based threat scan, NOT an IArchiveService/
│   │   │                                  ITarService extension (see ARCHITECTURE.md's own section)
│   │   └── ITarService.cs
│   ├── Services/
│   │   ├── ZipArchiveService.cs
│   │   ├── TarSandboxedService.cs      ← T-F52: replaced TarProcessService outright, no fallback
│   │   ├── ArchiveCreationRouter.cs / ArchiveListingRouter.cs / ExtractionRouter.cs
│   │   ├── AntivirusScanService.cs     ← T-F146: dispatches per-archive to a ZIP in-memory scan or
│   │   │                                  a tar-family quarantine scan (see below)
│   │   ├── ArchiveFormatPolicy.cs      ← T-F146/T-F261: the one public classifier (zip/tar/refused +
│   │   │                                  Group Policy gate) for extract, test, list and scan
│   │   ├── PakkoServices.cs            ← T-F261: Core factory for Shell/CLI, one policy for everything
│   │   ├── ArchiveEntrySecurity.cs     ← ADS/reserved-name/reparse-point/bomb checks, shared
│   │   ├── ArchiveFormatDetector.cs    ← magic-byte sniffing, not extension-based
│   │   ├── ArchiveNaming.cs            ← compound-extension-aware naming (T-F103)
│   │   ├── SourcePathNormalizer.cs     ← a creation source as entries are named from it: no trailing separator, "."/".."/relative resolved (T-F153, T-F338)
│   │   ├── ConflictResolver.cs         ← T-F06: resolves ConflictBehavior.Ask
│   │   ├── EncryptionPasswordRule.cs   ← T-F193: public; which passwords a NEW encrypted ZIP accepts
│   │   │                                  (printable ASCII, <= 99) — shared by App/CLI prompts and
│   │   │                                  ZipArchiveService's own last-line check
│   │   ├── LaunchArguments.cs          ← T-F232: public; the one owner of the Shell -> App Launch-argument
│   │   │                                  format (--browse|--extract|--archive <base64 JSON>), both sides
│   │   ├── StickyCallback.cs           ← T-F160: public; widens an "apply to all/remaining" answer
│   │   │                                  across several Core calls for one user action (Shell's
│   │   │                                  per-archive loop, CLI's zip/tar router split) — replaced
│   │   │                                  Shell's StickyApplyToAllConflictResolver/StickyPasswordResolver
│   │   ├── ExtractionDestinationPlanner.cs ← T-F157: shared actualDest/StripRootPrefix decision,
│   │   │                                  was hand-kept-in-sync between ZipArchiveService and
│   │   │                                  TarSandboxedService (T-F118)
│   │   ├── ExtractionStaging.cs        ← T-F227/T-F263: fresh owned hidden .pakko-x-<owner>-<guid>
│   │   │                                  staging folder (dead runs' ones swept first) + the
│   │   │                                  T-F161/T-F170 commit (CommitInto); ZIP and tar
│   │   ├── ArchiveTempFile.cs          ← T-F312: the temp file a new archive is written to
│   │   │                                  (.pakko-a-<owner>-<guid>.tmp, not hidden) and its commit,
│   │   │                                  retried ~1.5 s while either file is held; ZIP and tar
│   │   ├── DestinationConflictResolver.cs ← T-F158: archive-creation-side analogue of T-F157 —
│   │   │                                  shared Skip/Overwrite/Rename decision for a candidate
│   │   │                                  archive destination path
│   │   ├── PreviewPolicy.cs            ← T-F97/T-F109: safe-preview allowlist
│   │   ├── TarVersionParser.cs
│   │   ├── FileHashService.cs          ← T-F128: single-file/multi-file/single-folder-recursive hashing
│   │   ├── Antivirus/                  ← T-F146: AMSI P/Invoke subsystem
│   │   │   ├── IAmsiScanner.cs / AmsiScanner.cs
│   │   │   └── AmsiProviderCheck.cs
│   │   ├── Sandbox/                    ← T-F52: AppContainer subsystem for tar.exe
│   │   │   ├── AppContainerProfile.cs / QuarantineAcl.cs / SandboxHandles.cs
│   │   │   ├── SandboxJobObject.cs / SandboxedProcessLauncher.cs / ProcessLaunchOptions.cs
│   │   │   ├── LaunchAttributeList.cs / TarCommandLineEncoding.cs / TarOutputEncoding.cs
│   │   │   └── TarSandboxScope.cs / TarSignatureVerifier.cs
│   │   └── Zip/                        ← T-F35: parallel SingleArchive compression pipeline
│   │       ├── WorkItemEnumerator.cs / FileWorkItem.cs / WorkResult.cs
│   │       ├── ParallelSingleArchiveWriter.cs
│   │       ├── ZipEntryWriter.cs / ZipEntryCompressor.cs / DosDateTime.cs
│   │       ├── ZipArchiveReader.cs      ← T-F234: the one ZIP read path (list/test/extract/scan) —
│   │       │                                  ZipArchive.Entries paired with decoded names (NamedZipEntry,
│   │       │                                  incl. the post-decoding collision flag)
│   │       ├── ZipEntryNameDecoder.cs   ← T-F234: 7-Zip's name rule (bit 11 / 0x7075 / host OS)
│   │       ├── ZipNameCodePages.cs      ← T-F234: system OEM/ANSI via in-box CodePagesEncodingProvider
│   │       └── Decryption/              ← T-F188/T-F189: RawZipEntryLocator (central directory +
│   │                                          local headers), EncryptedZipEntryReader, ZipCrypto, WinZip AES
│   ├── Recovery/                   ← T-F275: the PAR 2.0 engine, internal — GF(2^16), packets,
│   │                                  a bounded reader, create/verify/repair; RecoveryDataWriter
│   │                                  (the router's PAR2 step), RecoveryProgressSplit, and
│   │                                  RecoveryTestStep (the test's PAR2 check, T-F275 step 3)
│   ├── IO/
│   │   ├── TempOwner.cs                ← T-F263/T-F312: public; the one owner of temp names —
│   │   │                                  tag m<machine>-<pid>-<start ticks>, the sweep of entries
│   │   │                                  dead runs left (by process here, by age next to a
│   │   │                                  destination for anything not provably this machine's)
│   │   ├── FolderTotals.cs             ← T-F236: public; a folder's bytes and file count over
│   │   │                                  DirectoryWalker, cancellable — the App's pending list and
│   │   │                                  ZipArchiveService's totals both use it
│   │   ├── Crc32.cs                    ← public (T-F110); slice-by-8 (T-F128 follow-up, was
│   │   │                                  byte-at-a-time — real ~9x perf gap vs. 7-Zip found via
│   │   │                                  HashPerformanceTests), reused by pending-list CRC too.
│   │   │                                  Combine() (zlib crc32_combine reimplementation) lets
│   │   │                                  FileHashService hash one large file's chunks in
│   │   │                                  parallel then fold results back together in order
│   │   ├── VerifyingReadStream.cs      ← T-F246/T-F231: CRC-32 at end of stream + declared-size
│   │   │                                  cap for every extracted ZIP entry, plain or decrypted
│   │   ├── ProgressStream.cs           ← T-F16: byte-accurate IProgress<int> wrapper, per-stream
│   │   ├── AggregateProgressTracker.cs ← T-F128 follow-up: shared byte counter across many
│   │   │                                  concurrently-hashed files, folder-wide total (not
│   │   │                                  per-file) — fixes ComputeFolderAsync's progress bug
│   │   ├── AggregateProgressStream.cs  ← T-F128 follow-up: read-only wrapper reporting into the
│   │   │                                  tracker above, used only by ComputeFolderAsync
│   │   └── HashDigestAccumulator.cs    ← T-F128: NanaZip-compatible folder DataSum/NamesSum combine
│   └── Models/
│       ├── ArchiveOptions.cs / ExtractOptions.cs / ArchiveResult.cs
│       ├── ArchiveError.cs / SkippedFile.cs / ProgressReport.cs / ProgressPhase.cs
│       ├── MessageCode.cs / CoreText.cs   ← T-F209: every user-visible Core message as a code + arguments
│       ├── OperationOutcome.cs             ← T-F260: what a whole operation achieved
│       ├── ArchiveFormat.cs / ArchiveContainerFormat.cs   ← detection vs. creation enums
│       ├── ArchiveEntryInfo.cs / ArchiveListResult.cs     ← T-F05: browse-mode listing
│       ├── EntryEncryption.cs                             ← T-F199: per-entry encryption marker
│       ├── ConflictInfo.cs / ConflictDecision.cs          ← T-F06; incoming size/time T-F268
│       ├── CompressionBombWarning.cs                      ← T-F94
│       ├── HashAlgorithmKind.cs                           ← T-F128: Crc32 | Sha256
│       ├── ThreatScanResult.cs / AntivirusScanOptions.cs   ← T-F146
│       └── TarCapabilities.cs
│
├── Archiver.App/               ← WinUI 3 main app; packages all satellite EXEs via MSIX
│   ├── MainWindow.xaml / .cs
│   ├── App.xaml.cs             ← ConfigureServices (DI), OnLaunched/OnActivated (T-F83/T-F100)
│   ├── ViewModels/
│   │   └── MainViewModel.cs
│   ├── Services/
│   │   ├── IDialogService.cs / DialogService.cs
│   │   └── ILogService.cs / LogService.cs
│   ├── Converters/
│   │   └── BoolToVisibilityConverter.cs
│   └── Strings/                ← 37 locales (T-F91), en-US is the fallback
│       └── en-US/Resources.resw
│
├── Archiver.Messages/          ← net10.0 -> Core (T-F209): renders Core's MessageCode texts in 37
│                                  locales (MessageText, UiCulture, Resources/CoreMessages*.resx)
│
├── Archiver.App.Core/          ← net10.0, WinUI-free helpers for Archiver.App (T-F05), unit-testable
│   │                              without a WinUI test host
│   ├── ArchiveEntryViewModel.cs / ArchiveTreeIndex.cs   ← Archive Browser tree/breadcrumb building
│   ├── FileSystemBrowser.cs                             ← T-F107: real-filesystem climb past archive root
│   ├── FileActivationRouter.cs                          ← T-F100: file activation routing
│   ├── LaunchActivationRouter.cs                        ← T-F232: Launch-argument routing (browse vs. pending list)
│   ├── FileItem.cs                                      ← pending-list row; TryCreate skips unreadable paths (T-F232)
│   ├── NestedArchiveCache.cs / NestedArchivePolicy.cs   ← T-F98: nested-archive drill-down
│   ├── PreviewCache.cs                                  ← T-F97: preview extraction cache
│   ├── DeferredActionGate.cs                            ← T-F106: defers activation past first layout pass
│   ├── SourceRecycler.cs                                ← T-F207: "Delete after operation" — Recycle Bin / confirm / report;
│   │                                                       T-F302: DeleteAsync returns RecycleResult (Deleted, NotDeleted)
│   ├── Win32SourceDeleteOperations.cs                   ← T-F207: final-path + volume-type + SHFileOperationW P/Invoke
│   ├── PrimaryActionPolicy.cs                           ← T-F199: Compress/Extract availability + which one is primary
│   ├── CreateModeText.cs                                ← T-F199: create-mode resource keys (card summary, delete-after words)
│   ├── InlinePasswordState.cs                           ← T-F199: inline encryption password checks (message key, AllowsCompress)
│   ├── EncryptionSummary.cs                             ← T-F199: browse badge + info-bar notes, read without a password
│   ├── BrowseLocationState.cs / BrowseWork.cs           ← T-F199: what browse mode offers where; in-flight browse work
│   ├── OutcomeLine.cs                                   ← T-F211: footer result line; FooterLine.Pick chooses the footer text
│   ├── SessionPasswordMemory.cs                         ← T-F200: browse-session password, memory only
│   ├── BrowserEntryRouting.cs                           ← T-F242: what a row double-click does
│   ├── BrowseNavigation.cs                              ← T-F112: ArchiveBrowseScope + where Up goes (BrowseUpStep)
│   ├── DisplayText.cs                                   ← T-F198: list words/size units, set by the App at startup
│   ├── ProgressText.cs / ConflictText.cs                ← T-F303: footer speed + time left; T-F220: conflict dialog size/date lines
│   ├── SortIndicator.cs / NestedDisplayPath.cs          ← T-F220: header sort arrow; nested archive shown as "outer.zip > ... > l4.zip"
│   ├── BuildStamp.cs                                    ← T-F218: title-bar build stamp from assembly metadata
│   ├── WindowCascade.cs / Win32PakkoWindows.cs          ← T-F201: cascade a new window off other Pakko windows
│   └── ProcessTempRoot.cs                               ← T-F252: per-process %TEMP% subfolder, stale sweep
│
├── Archiver.Shell/             ← shell-triggered operation entry point; net10.0-windows; WinExe; no WinUI
│   ├── Program.cs                      ← T-F268: parse, then dispatch to ShellCommands
│   ├── ShellArgumentParser.cs          ← T-F235: `--paths-stdin` in place of the path list
│   ├── StdinPathList.cs                ← T-F235: reads Explorer's UTF-16LE NUL-separated list from stdin
│   ├── ShellCommands.cs                ← T-F268: every Explorer command; windows only via IOperationUi
│   ├── ShellServices.cs                ← T-F268: Core factories (tests swap in a ZIP-only router);
│   │                                      T-F261: built from Core's PakkoServices
│   ├── IOperationUi.cs                 ← T-F268: IOperationUi/IOperationSession/OperationMessage
│   ├── Win32OperationUi.cs             ← T-F268: IOperationUi on IProgressDialog/TaskDialog/MessageBoxW
│   ├── DeferredOperationSession.cs     ← T-F356: the window starts after 0.5 s, or at once for a prompt or a result
│   ├── HandleListProcess.cs            ← T-F356: a child that inherits only the handles named for it (the helper)
│   ├── OperationMessages.cs            ← T-F268: pure result-text builder (errors, skips, hash, scan)
│   ├── ProgressText.cs                 ← progress status, byte and speed text
│   ├── AppLauncher.cs                  ← T-F232: opens Archiver.App via IApplicationActivationManager::
│   │                                      ActivateApplication (no URI scheme); refuses > 32000 chars
│   ├── ShellResultPresenter.cs         ← T-F68: skipped-list text (classification is ArchiveResult.Outcome, T-F260)
│   ├── ShellConfirmDialog.cs           ← T-F217: Win32 yes/no fallback (suspected compression bomb)
│   ├── NativeProgressDialog.cs         ← IProgressDialog COM interop (in-process progress UI)
│   ├── HashResultLocalizer.cs          ← T-F128 follow-up: first localized text in Archiver.Shell —
│   │                                      plain .resx/ResourceManager (not App's WinRT/.resw — needs
│   │                                      no Windows-versioned TFM, see DECISIONS.md)
│   ├── ShellConflictDialog.cs          ← T-F155: TaskDialogIndirect-based Overwrite/Rename/Skip +
│   │                                      "apply to all" dialog (needs the comctl32 v6 dependency
│   │                                      in app.manifest — see DECISIONS.md's T-F155 entry for
│   │                                      the TASKDIALOG_BUTTON packing gotcha)
│   ├── ConflictDialogLocalizer.cs      ← T-F155: mirrors ScanResultLocalizer's own pattern; the 6
│   │                                      ConflictDialog* values are copied from Archiver.App's own
│   │                                      already-translated Strings/*/Resources.resw, not re-translated
│   ├── PasswordDialogTemplateBuilder.cs ← T-F192: pure byte[]-returning DLGTEMPLATEEX builder,
│   │                                      separate from the P/Invoke body so its byte-layout is
│   │                                      unit-testable (see DECISIONS.md for the design research)
│   ├── PasswordDialog.cs               ← T-F192: DialogBoxIndirectParamW-based password prompt,
│   │                                      same ShowAsync/MapResult split as ShellConflictDialog
│   ├── PasswordDialogLocalizer.cs      ← T-F192: mirrors ConflictDialogLocalizer's own pattern
│   └── Resources/
│       ├── HashMessages.resx / HashMessages.<locale>.resx      ← 36 locales
│       ├── ScanMessages.resx / ScanMessages.<locale>.resx      ← 36 locales
│       ├── ConflictMessages.resx / ConflictMessages.<locale>.resx  ← 36 locales, matches
│       │                                                             Archiver.App/Strings/'s own set
│       └── PasswordMessages.resx / PasswordMessages.<locale>.resx  ← 36 locales, same reuse pattern
│
├── Archiver.CLI/                ← standalone console frontend (T-F09); net10.0; Exe (real console,
│   │                                not WinExe); no WinUI; built as pakko.exe; ships as its own zip
│   │                                (scripts/Publish-Cli.ps1, winget) and, since T-F317, also inside
│   │                                the MSIX behind the "pakko" execution alias
│   ├── CliPackageIdentity.cs           ← T-F317: package full name for pakko -v (GetCurrentPackageFullName)
│   ├── Program.cs
│   ├── CliArgumentParser.cs
│   ├── CliStreamStaging.cs             ← T-F116: -si/-so buffer-then-proceed staging, zero Core changes
│   ├── CliStagingFolder.cs             ← T-F244: per-run <pid>-<guid> staging folder + dead-run sweep
│   ├── CliCancellation.cs              ← T-F244: Ctrl+C -> cancel (exit 255), second press ends the process
│   ├── CliConsoleCharset.cs            ← T-F238: -scc{UTF-8|WIN|DOS} stdout/stderr writers
│   ├── CliVersionText.cs               ← T-F222: `pakko -v` text (release vs 0.0.0-dev+sha)
│   ├── CliConflictPrompt.cs            ← T-F160: 7-Zip-style (Y/N/A/S/U/Q) overwrite prompt on stderr,
│   │                                      injected line source; Q/EOF/Ctrl+C -> exit 255
│   ├── CliCompressionLevelMapper.cs / CliEntryFormatter.cs / CliHelpText.cs
│
└── Archiver.ShellExtension/    ← IExplorerCommand COM DLL (T-F61); C++/WRL, x64+ARM64, static CRT
    ├── dllmain.cpp                     ← DllGetClassObject, DllCanUnloadNow
    ├── ExplorerCommands.cpp/.h         ← PakkoRootCommand, SubCommandEnum, and every leaf command
    │                                     (ExtractDialog/ExtractHereFlat/ExtractHere/ExtractFolder/
    │                                     CompressDialog/Archive/TarArchive/Test/Browse/Hash) —
    │                                     T-F128: HashCommand (ECF_HASSUBCOMMANDS) + its two leaves
    │                                     HashCrc32Command/HashSha256Command
    ├── ShellExtUtils.cpp/.h            ← COM-free logic, unit-tested via Archiver.ShellExtension.Tests
    └── Localization.cpp/.h             ← T-F115: 37-locale context-menu string table

Core Models — Current Signatures

// Models/ArchiveOptions.cs
public sealed record ArchiveOptions
{
    public IReadOnlyList<string> SourcePaths { get; init; } = [];
    public string DestinationFolder { get; init; } = string.Empty;
    public string? ArchiveName { get; init; }
    public ArchiveMode Mode { get; init; } = ArchiveMode.SingleArchive;
    public ConflictBehavior OnConflict { get; init; } = ConflictBehavior.Skip;
    public bool OpenDestinationFolder { get; init; } = false;
    public CompressionLevel CompressionLevel { get; init; } = CompressionLevel.Optimal;
    // T-F105 (v1.4): which container format to CREATE. Default Zip preserves all pre-T-F105
    // callers/tests unchanged. Deliberately separate from the detection-only ArchiveFormat enum
    // (Models/ArchiveFormat.cs) used by extraction routing — that one includes read-only formats
    // (Rar, SevenZip) that can never be a creation target.
    public ArchiveContainerFormat Format { get; init; } = ArchiveContainerFormat.Zip;

    // T-F06: invoked once per conflicting destination path when OnConflict == Ask. Null (e.g.
    // Archiver.Shell, or a test that doesn't wire it) falls back to Skip — see ConflictResolver.
    public Func<ConflictInfo, Task<ConflictDecision>>? ResolveConflictAsync { get; init; }

    // T-F193: non-null encrypts every ZIP file entry with WinZip AES-256 (AE-2); folder entries
    // stay unencrypted. Called once, before any destination-conflict step, Purpose = Encrypt,
    // maxAttempts = 1. A cancelled answer or one EncryptionPasswordRule refuses fails the call
    // with nothing created; a tar-family Format with a resolver is an error (TarSandboxedService).
    // Forces ParallelSingleArchiveWriter in both modes — ZipArchive cannot encrypt.
    public Func<PasswordPromptInfo, Task<PasswordDecision>>? ResolvePasswordAsync { get; init; }

    // T-F275: PAR2 recovery data next to each created archive, 1-100 % of its slices; 0 = none.
    // Handled by ArchiveCreationRouter, not the engines: outside 0-100 → RecoveryPercentInvalid,
    // over 0 under the DisableRecoveryData policy → RecoveryDataDisabled, both before any work.
    // Outside the policy, with or without a percent: every set left beside a rewritten archive
    // from its earlier bytes is removed first (RecoveryDataWriter.RemoveEarlierSets), so a test
    // does not call the new archive damaged, also when writing the new set is cut short.
    public int RecoveryPercent { get; init; }
}

public enum ArchiveMode { SingleArchive, SeparateArchives }

// Models/ArchiveContainerFormat.cs
public enum ArchiveContainerFormat { Zip, Tar, TarGz, TarBz2, TarXz, TarZst, TarLzma }

public enum ConflictBehavior { Overwrite, Skip, Rename, Ask }
// T-23 (v1.0): Ask was cut from scope, default Skip. T-F06 (2026-07-14) reintroduced it as a
// real interactive per-conflict dialog — see ConflictResolver and DECISIONS.md's T-F06 entry.
// Models/ExtractOptions.cs
public sealed record ExtractOptions
{
    public IReadOnlyList<string> ArchivePaths { get; init; } = [];
    public string DestinationFolder { get; init; } = string.Empty;
    public ExtractMode Mode { get; init; } = ExtractMode.SeparateFolders;

    // Overrides the per-archive subfolder name Mode.SeparateFolders would otherwise derive from
    // the archive's own file name. Only meaningful when ArchivePaths has exactly one entry — used
    // by Archiver.Shell for "always create a fresh named folder" behavior (see CLAUDE.md's hard
    // constraint on ConflictBehavior.Rename vs. this field).
    public string? SeparateFolderName { get; init; }

    // T-F205: SingleFolder mode keeps an archive's single root folder; true drops it only when it
    // is named like the archive (NanaZip's "Extract to name\" ElimDup). Set by Shell's
    // --extract-folder only.
    public bool EliminateDuplicateRootFolder { get; init; }

    public ConflictBehavior OnConflict { get; init; } = ConflictBehavior.Skip;
    public bool OpenDestinationFolder { get; init; } = false;

    // T-F94: invoked when an archive looks like a decompression bomb (declared uncompressed
    // size vs. compressed size exceeds the ratio threshold) AND the destination has enough free
    // space to hold it. True proceeds with extraction, false declines. Null (default) auto-
    // declines — the safe behavior for callers that don't wire a callback (Archiver.Shell).
    public Func<CompressionBombWarning, Task<bool>>? ConfirmCompressionBombExtraction { get; init; }

    // T-F05: non-null/non-empty restricts extraction to just these archive-internal entry paths
    // instead of every entry — "Extract selected" from the Archive Browser. A selected directory
    // path implies its full nested contents. Only meaningful with exactly one archive path.
    public IReadOnlyList<string>? SelectedEntryPaths { get; init; }

    // T-F06: invoked once per conflicting entry when OnConflict == Ask. Same null-safe-default
    // (Skip) and delegate shape as ArchiveOptions.ResolveConflictAsync above.
    public Func<ConflictInfo, Task<ConflictDecision>>? ResolveConflictAsync { get; init; }

    // T-F189: invoked once per encrypted ZIP archive, before its entry loop runs, when the archive
    // contains at least one encrypted entry. Null (Archiver.Shell until T-F192 ships, or a test
    // that doesn't wire it) preserves the pre-T-F189 "password-protected and cannot be extracted"
    // rejection exactly. Archiver.CLI wires this via -p{pwd}/an interactive masked prompt since
    // T-F191. See DECISIONS.md's T-F189/T-F191 entries.
    public Func<PasswordPromptInfo, Task<PasswordDecision>>? ResolvePasswordAsync { get; init; }
    // T-F360: false leaves the download mark off; EnforceMOTW overrides it either way.
    public bool ApplyDownloadMark { get; init; } = true;
}

public enum ExtractMode { SeparateFolders, SingleFolder }
// Models/PasswordPromptInfo.cs — T-F189
public enum PasswordPurpose { Decrypt, Encrypt }  // Encrypt: ArchiveAsync (T-F193)

public sealed record PasswordPromptInfo
{
    public required string ArchiveName { get; init; }
    public required PasswordPurpose Purpose { get; init; }
    public int AttemptNumber { get; init; } = 1;           // 1-based
    public bool PreviousAttemptWasWrong { get; init; }
}

public sealed record PasswordDecision
{
    public string? Password { get; init; }        // null = user cancelled
    public bool ApplyToRemaining { get; init; }    // mirrors ConflictDecision.ApplyToAll
    public bool Remembered { get; init; }          // T-F301: an earlier answer reused, not asked for this archive
}
// Services/PasswordResolver.cs — internal, Archiver.Core.Services (T-F189)
// Resolves an encrypted archive's password via the caller's ResolvePasswordAsync callback,
// retrying up to maxAttempts times and remembering an ApplyToRemaining choice for its own
// lifetime — same shape as ConflictResolver. One shared class for both Decrypt (ExtractAsync/
// TestAsync, maxAttempts=3) and the Encrypt direction (T-F193's ArchiveAsync, maxAttempts=1)
// — maxAttempts is a per-call-site parameter, not a constant, so there is no Purpose-branch inside
// the retry loop. verify is caller-supplied so this class stays format-agnostic (no ZIP knowledge).
internal sealed class PasswordResolver(
    Func<PasswordPromptInfo, Task<PasswordDecision>>? resolvePasswordAsync,
    int maxAttempts)
{
    public Task<string?> ResolveAsync(
        string archiveName, PasswordPurpose purpose, Func<string, bool> verify);
    // T-F301: a Remembered password that fails verify is not retried; the caller then reports
    // RememberedPasswordDoesNotFit instead of PasswordProtectedExtract/Test.
    public bool RememberedPasswordRejected { get; }
}
// Services/EncryptionPasswordRule.cs — public, Archiver.Core.Services (T-F193)
// 7-Zip's own creation rule (ZipHandlerOut.cpp IsSimpleAsciiString; WzAes kPasswordSizeMax).
// Frontends call Check inside their prompt to show a localized reason; ZipArchiveService
// enforces the same rule again. Decrypt (T-F189) accepts any password.
public enum EncryptionPasswordProblem { None, Empty, UnsupportedCharacters, TooLong }

public static class EncryptionPasswordRule
{
    public const int MaxLength = 99;
    public static EncryptionPasswordProblem Check(string password);  // chars checked before length
    public static bool IsAllowed(char c);  // printable ASCII; the App filters input with it (T-F199)
}
// Models/ConflictInfo.cs
public sealed record ConflictInfo
{
    public required string ExistingPath { get; init; }
    public long? IncomingSize { get; init; }               // null when not known yet (a new archive)
    public DateTimeOffset? IncomingModified { get; init; }
}

// Models/ConflictDecision.cs
public enum ConflictResolution { Skip, Overwrite, Rename }  // no Ask — a resolved decision, not a policy

public sealed record ConflictDecision
{
    public required ConflictResolution Resolution { get; init; }
    public bool ApplyToAll { get; init; }  // suppresses further ResolveConflictAsync calls this operation
}
// Services/StickyCallback.cs — public, Archiver.Core.Services (T-F160)
// Core's ConflictResolver/PasswordResolver remember "apply to all/remaining" for ONE
// ExtractAsync/TestAsync call. A frontend making several calls for one user action (Archiver.Shell's
// per-archive loop, Archiver.CLI's zip/tar split through ExtractionRouter) wraps its prompt in one
// instance, created per user action, and passes ResolveAsync as the Core callback.
// whenReused marks a remembered answer each time it is handed out again (Shell: Remembered = true,
// T-F301).
public sealed class StickyCallback<TInfo, TDecision>(
    Func<TInfo, Task<TDecision>> inner, Func<TDecision, bool> isSticky,
    Func<TDecision, TDecision>? whenReused = null) where TDecision : class
{
    public Task<TDecision> ResolveAsync(TInfo info);
}

// Services/ConflictResolver.cs — internal, Archiver.Core.Services
// Resolves ConflictBehavior.Ask into a concrete Skip/Overwrite/Rename by invoking the caller's
// ResolveConflictAsync callback, remembering an ApplyToAll choice for its own lifetime. One
// instance is constructed per ArchiveAsync/ExtractAsync call, before any loop, so "apply to all"
// spans every archive/entry in that call. Wired into all four existing conflict-resolution call
// sites (ZipArchiveService.ArchiveAsync's two modes, ZipArchiveService.ExtractAsync,
// TarSandboxedService.ExtractAsync) — see DECISIONS.md's T-F06 entry and DIAGRAMS.md's diagrams 3/5.
internal sealed class ConflictResolver(
    ConflictBehavior configured,
    Func<ConflictInfo, Task<ConflictDecision>>? resolveConflictAsync)
{
    public Task<ConflictBehavior> ResolveAsync(string existingPath);
}
// Services/ExtractionDestinationPlanner.cs — internal, Archiver.Core.Services
// T-F157: the actualDest/StripRootPrefix decision ZipArchiveService.
// ExtractWithSmartFolderingAsync and TarSandboxedService.ExtractSingleArchiveAsync used to
// hand-duplicate (T-F118's "kept algorithmically in sync" promise, broken twice in one day by
// T-F154/T-F156) — now one shared, pure, unit-testable function. RootShape.Classify and
// Resolve's 8 arms are spelled out explicitly (no discard) for auditability, not compiler
// exhaustiveness — Roslyn's enum-exhaustiveness check treats named members as an open set, so a
// discard-less switch still needs the `_ => throw` arm; the real "new RootShape must be handled"
// guard is ExtractionDestinationPlannerTests' enumeration theory. See DECISIONS.md's T-F157 entry.
internal enum RootShape { SingleFolder, SingleFile, MultiRoot, SelectedSubset }

internal static class ExtractionDestinationPlanner
{
    public static RootShape Classify(bool isSelectedSubset, bool isSingleRootFolder, bool isSingleRootFile);

    // T-F205: (notIsolated, SingleFolder) strips only when rootDuplicatesArchiveName.
    public static (string ActualDest, bool StripRootPrefix) Resolve(
        bool alreadyIsolated, RootShape shape, string destDir, string unisolatedDestDir,
        bool rootDuplicatesArchiveName = false);

    public static bool RootDuplicatesArchiveName(string rootName, string archivePath);
}
// Services/DestinationConflictResolver.cs — internal, Archiver.Core.Services
// T-F158: the archive-creation-side analogue of ExtractionDestinationPlanner (T-F157) — the
// Skip/Overwrite/Rename decision for a candidate destination archive path, previously
// hand-duplicated in ZipArchiveService.ArchiveSingleArchiveModeAsync,
// ZipArchiveService.ResolveSeparateArchivePlansAsync (with an extra same-run-collision wrinkle,
// see DECISIONS.md's T-F158 entry), and TarSandboxedService's own private
// ResolveDestinationConflictAsync (deleted). Deliberately pure aside from awaiting
// conflictResolver — no File.Exists/File.Delete inside; on ProceedReplacingExisting the commit's
// rename replaces the old archive (T-F312), so nothing is deleted before the new one exists.
internal enum DestinationConflictOutcome { Proceed, ProceedReplacingExisting, Skip }

internal static class DestinationConflictResolver
{
    public static Task<(DestinationConflictOutcome Outcome, string ResolvedDestPath)> ResolveAsync(
        string destPath, bool onDiskConflict, bool sameRunConflict,
        ConflictResolver conflictResolver, Func<string, string> renameCandidate);
}
// Archiver.Shell/ShellConflictDialog.cs — public, Archiver.Shell
// T-F155: brings Archiver.Shell's three extract commands to parity with the WinUI App's own T-F06
// ContentDialog, via TaskDialogIndirect (comctl32 v6 — see app.manifest's <dependency>). MapResult
// is the pure, unit-tested seam; ShowAsync/ShowCore do the P/Invoke and degrade to Skip on any
// failure (missing/broken activation context, etc.) — see DECISIONS.md's T-F155 entry for the
// TASKDIALOG_BUTTON packing bug this uncovered.
public static class ShellConflictDialog
{
    public static ConflictDecision MapResult(int buttonId, bool applyToAllChecked);
    public static Task<ConflictDecision> ShowAsync(ConflictInfo conflict);
}

// Archiver.Shell/ConflictDialogLocalizer.cs — public, Archiver.Shell
// T-F155: mirrors ScanResultLocalizer.cs exactly (ResourceManager over Resources/ConflictMessages).
public static class ConflictDialogLocalizer
{
    public static string Get(string key, params object[] args);
}

// Archiver.Shell/PasswordDialog.cs — public, Archiver.Shell
// T-F192: same ShowAsync/MapResult split and Skip-on-failure degradation as ShellConflictDialog,
// via an in-memory DLGTEMPLATEEX + DialogBoxIndirectParamW instead of TaskDialogIndirect (no
// text-input capability there) — see DECISIONS.md for the NanaZip research that settled this over
// CredUIPromptForCredentialsW, and the Phase 0 spike that found DialogBoxIndirectParamW's dialog
// needs an explicit SetWindowPos(HWND_TOPMOST, ...) in WM_INITDIALOG to actually become visible
// from this call site (a background thread, with Archiver.Shell's own IProgressDialog already
// showing) — plain SetForegroundWindow alone is not reliable there.
public static class PasswordDialog
{
    public static PasswordDecision MapResult(int buttonId, string editText, bool applyToRemainingChecked);
    public static Task<PasswordDecision> ShowAsync(PasswordPromptInfo info, bool canApplyToRemaining);
}

// Archiver.Shell/PasswordDialogTemplateBuilder.cs — internal, Archiver.Shell (InternalsVisibleTo
// Archiver.Shell.Tests, same convention Archiver.Core.csproj uses)
// T-F192: pure DLGTEMPLATEEX byte-buffer builder, kept separate from PasswordDialog's P/Invoke
// body so byte-layout mistakes are unit-testable instead of only surfacing on-device.
internal static class PasswordDialogTemplateBuilder
{
    public static byte[] Build(string title, string message, bool canApplyToRemaining,
        string applyToRemainingLabel, string showPasswordLabel, string okLabel, string cancelLabel);
}

// Archiver.Shell/PasswordDialogLocalizer.cs — public, Archiver.Shell
// T-F192: mirrors ConflictDialogLocalizer.cs exactly (ResourceManager over Resources/PasswordMessages).
public static class PasswordDialogLocalizer
{
    public static string Get(string key, params object[] args);
}
// Models/CompressionBombWarning.cs
public sealed record CompressionBombWarning
{
    public string ArchivePath { get; init; } = string.Empty;
    public long DeclaredUncompressedSize { get; init; }
    public long CompressedSize { get; init; }
    public long Ratio => CompressedSize > 0 ? DeclaredUncompressedSize / CompressedSize : 0;
}
// Models/ArchiveResult.cs
public sealed record ArchiveResult
{
    public bool Success { get; }                      // T-F260: derived — no errors
    public OperationOutcome Outcome { get; }          // Completed / CompletedWithWarnings / CompletedWithSkips / NothingDone / Failed
    public IReadOnlyList<string> CreatedFiles { get; init; } = [];
    // T-F275: the PAR2 index and volume written next to each archive in CreatedFiles — kept out
    // of CreatedFiles, so the outcome line, pakko's name check and "contains its own output" stay
    // about the archives.
    public IReadOnlyList<string> RecoveryFiles { get; init; } = [];
    // T-F275 step 3: the PAR2 check of each tested archive that has a set, filled only by
    // IExtractionRouter.TestAsync(verifyRecoveryData: true). RecoveryCheck: ArchivePath, State
    // (Intact / Repairable / NotRepairable / RepairTooLarge / DoesNotMatch / Unusable), SetFiles,
    // Blocks, DamagedBlocks, RecoveryBlocks, Text (step 3b: MessageCode.RecoveryDataIntact for
    // Intact, step 4a: RecoveryDataRepaired for Repaired, null otherwise). Damage is also an ArchiveError, DoesNotMatch and Unusable an
    // ArchiveWarning, so a frontend that ignores this list still reports every outcome but Intact.
    // Step 4a: IRecoveryService.RepairAsync fills it too, adding State Repaired with RepairedPath.
    public IReadOnlyList<RecoveryCheck> RecoveryChecks { get; init; } = [];
    public IReadOnlyList<ArchiveError> Errors { get; init; } = [];
    public IReadOnlyList<SkippedFile> SkippedFiles { get; init; } = [];
    // T-F280: what the user should know although everything asked was done. Never fails the
    // operation, never makes a source undeletable; built only by CoreMessages.Warning. Every
    // frontend shows them whatever the outcome (under the errors or skips when there are any).
    public IReadOnlyList<ArchiveWarning> Warnings { get; init; } = [];   // SourcePath, Message, Text
    // T-F313: entries the ZIP engine left alone because the file existed and the automatic
    // conflict choice was Skip (not a Skip the user answered, T-F216). Outside SkippedFiles, so
    // Outcome and the App/Explorer summaries do not change; pakko prints them and exits 1. The
    // tar engine lists the same case in SkippedFiles.
    public IReadOnlyList<SkippedFile> KeptExistingFiles { get; init; } = [];
    // T-F260: one entry per source the engine finished; no entry = not processed (fail-closed).
    public IReadOnlyList<SourceResult> Sources { get; init; } = [];
    // The only input to "Delete after operation": sources whose Outcome is Completed.
    public IEnumerable<string> FullyProcessedSources { get; }
}

// Models/SourceResult.cs (T-F260)
public enum SourceOutcome { Completed, Partial, NotProcessed }
public sealed record SourceResult
{
    public string Path { get; init; } = string.Empty;   // as the engine saw it (trailing separator trimmed)
    public SourceOutcome Outcome { get; init; }
}

Completed = output exists, nothing from the source skipped or failed, the whole source requested (a SelectedEntryPaths extraction is always Partial, T-F265), and — for creation — the source does not contain one of the created archives. SingleArchive mode gives every source the whole call's outcome. Rules live in Services/SourceOutcomeRules.cs. Cancellation throws OperationCanceledException from ArchiveAsync/ExtractAsync/CompressAsync and both routers — the one exception to "never throws" — including a cancel noticed between two sources.

// Models/ArchiveError.cs
public sealed record ArchiveError
{
    public string SourcePath { get; init; } = string.Empty;
    public string Message { get; init; } = string.Empty;   // English, for logs and the CLI
    public CoreText? Text { get; init; }                     // T-F209: code + arguments, rendered per UI language
    public Exception? Exception { get; init; }
}
// Models/SkippedFile.cs
public sealed record SkippedFile
{
    public string Path { get; init; } = string.Empty;
    public string Reason { get; init; } = string.Empty;   // English
    public CoreText? Text { get; init; }                    // T-F209
}

T-F209 — messages. Core builds every user-visible message through the internal Services/CoreMessages from a MessageCode (Models/MessageCode.cs); Services/MessageTemplates is the one English table; ArchiveListResult.ErrorText, ThreatFinding.ReasonText and HashEntry.ErrorText carry the same CoreText. Archiver.Messages (net10.0 -> Core) renders a CoreText in a UI language (MessageText.Render, UiCulture.Resolve/ResolveFirst) from Resources/CoreMessages.resx (37 locales); Shell and the App use it, the CLI prints English. T-F297: an exception's text enters a message only through CoreMessages.Detail, which keeps the English text and adds the Windows error code; eight codes of their own (System*, ContentCrcMismatch, T-F333's ContentLargerThanDeclared, ContentSmallerThanDeclared and EntryDataTruncated) are translated in the Ukrainian table only (MessageText.UkrainianOnlyCodes). ArchiveOptions.ExactFileName (T-F221) names a single archive exactly, no extension added.


Interface — Current Signature

// Interfaces/IArchiveService.cs
public interface IArchiveService
{
    Task<ArchiveResult> ArchiveAsync(
        ArchiveOptions options,
        IProgress<ProgressReport>? progress = null,
        CancellationToken cancellationToken = default);

    Task<ArchiveResult> ExtractAsync(
        ExtractOptions options,
        IProgress<ProgressReport>? progress = null,
        CancellationToken cancellationToken = default);

    // T-F62: verifies every entry's CRC-32 against its declared header value without
    // writing anything to disk. Never throws — mismatches surface as ArchiveResult.Errors —
    // except OperationCanceledException on cancellation, between archives too (T-F260, T-F268).
    // T-F189: resolvePasswordAsync mirrors ExtractOptions.ResolvePasswordAsync — TestAsync takes a
    // flat path list rather than an Options record, so it's a trailing parameter instead of a
    // field. Placed before cancellationToken per CA1068 (CancellationToken must be last), which is
    // why the two existing call sites (Archiver.CLI/Archiver.Shell) pass cancellationToken: named.
    Task<ArchiveResult> TestAsync(
        IReadOnlyList<string> archivePaths,
        IProgress<ProgressReport>? progress = null,
        Func<PasswordPromptInfo, Task<PasswordDecision>>? resolvePasswordAsync = null,
        CancellationToken cancellationToken = default);

    // T-F05: lists entries without extracting — flat, not hierarchical. Never throws — a
    // failure is reported via ArchiveListResult.Success/ErrorMessage.
    Task<ArchiveListResult> ListEntriesAsync(
        string archivePath,
        CancellationToken cancellationToken = default);
}

ProgressReport carries Percent, BytesTransferred, TotalBytes, and CurrentFile (T-F16), plus Phase (ProgressPhase, T-F307): CheckingArchive while tar-family extraction runs its whole-archive listing passes (one report before them, a Transferring report right after), else Transferring. Frontends show a localized "checking" status instead of a standing 0%. CreatingRecoveryData (T-F275) is the router's PAR2 step after the engine: the percent keeps rising (RecoveryProgressSplit gives the engine 0 to 100 · (1 - p/(p+20)) and PAR2 the rest, and sends 100 only after the last set), the byte counts are 0. The App shows its own status for it. VerifyingRecoveryData (T-F275 step 3) is the PAR2 check after a test with verifyRecoveryData: the test gets the part of the climb its ZIP bytes are, the check the rest, by the archives' sizes; the byte counts are 0.

T-F275 step 4a: repair. Built only by PakkoServices (RecoveryService property); the frontends call nothing else to repair.

public interface IRecoveryService
{
    // Rebuilds each damaged archive into <name>.repaired<extension>, next to it or in
    // OutputDirectory; a taken name gets the next number. The archive and its PAR2 files are only
    // read. An archive is rebuilt exactly when TestAsync(verifyRecoveryData: true) would call it
    // damaged and repairable (RecoveryTestStep.Describe decides for both), and the copy is kept
    // only after its hashes match the set. CreatedFiles = the copies; RecoveryChecks = each
    // archive's state (RecoveryState.Repaired with RepairedPath and Text, or what the test would
    // say). No usable set is an error. Under DisableRecoveryData every path is refused. Throws
    // only OperationCanceledException.
    Task<ArchiveResult> RepairAsync(
        RepairOptions options,                 // Paths (archives or .par2), OutputDirectory?, ApplyDownloadMark = true
        IProgress<ProgressReport>? progress = null,   // Phase: VerifyingRecoveryData, then RepairingArchive
        CancellationToken cancellationToken = default);
}

T-F275 steps 3c and 4c: what a frontend asks before any test runs, on the same interface. Both read the disk, so a UI calls them off its thread. The App registers IRecoveryService in DI with its policy and its IExtractionRouter.

public interface IRecoveryService   // continued
{
    // PAR2 files lie next to the archive under either name the test looks for (a.zip.par2,
    // a.par2, their volumes). Not whether they are usable. False under DisableRecoveryData and
    // for an unreadable path; never throws.
    bool HasFilesFor(string archivePath);
    // The file the set protects, chosen by the set's hashes as the test chooses it. Exactly one
    // of RecoveryTarget.ArchivePath / Error is set (Archiver.Core.Models); a file that is gone is
    // still named. Throws only OperationCanceledException.
    RecoveryTarget FindArchive(string par2Path, CancellationToken cancellationToken = default);
}

// The one check that reads nothing: named like a PAR2 file.
public static class RecoveryDataLookup { public static bool IsRecoveryFile(string path); }

App Services — Current Signatures

// Services/IDialogService.cs
public interface IDialogService
{
    Task ShowErrorAsync(string title, string message);
    Task<bool> ShowConfirmAsync(string title, string message);
    Task<string?> PickDestinationFolderAsync();
    Task<IReadOnlyList<string>> PickFilesAsync();
    Task<IReadOnlyList<string>> PickFoldersAsync();
    Task ShowOperationSummaryAsync(string operationName, ArchiveResult result);
    Task ShowAboutAsync();
    Task ShowFileHashAsync();

    // T-F94: called from a thread-pool thread by the extractors — implementation must marshal
    // onto the window's DispatcherQueue before showing a ContentDialog. See DECISIONS.md's
    // T-F94 entry.
    Task<bool> ShowCompressionBombConfirmAsync(CompressionBombWarning warning);

    // T-F06: same DispatcherQueue-marshaling requirement as ShowCompressionBombConfirmAsync above.
    Task<ConflictDecision> ShowConflictDialogAsync(ConflictInfo conflict, Action? cancelOperation = null);

    // T-F97: opens a previewed/extracted file with the OS default handler. Process.Start
    // (UseShellExecute:true), not StorageFile/Launcher — the latter silently fails for an
    // arbitrary %TEMP% path even from this app's full-trust packaged identity (see DECISIONS.md's
    // T-F97 entry).
    Task<bool> OpenFileWithDefaultAppAsync(string filePath);

    // T-F190: same DispatcherQueue-marshaling requirement as ShowCompressionBombConfirmAsync
    // above. canApplyToRemaining is a caller-supplied bool, not a PasswordPromptInfo field —
    // Archiver.Core has no notion of a frontend's batch shape (see DECISIONS.md's T-F190 entry).
    Task<PasswordDecision> ShowPasswordPromptAsync(PasswordPromptInfo info, bool canApplyToRemaining);

    // T-F207: owner window for SHFileOperationW, the confirmation before a permanent delete
    // (drives without a Recycle Bin; default button keeps the items), and the report of sources
    // "Delete after operation" could not remove (T-F242).
    IntPtr OwnerWindowHandle { get; }
    Task<bool> ShowPermanentDeleteConfirmAsync(IReadOnlyList<string> paths);
    Task ShowNotDeletedAsync(IReadOnlyList<string> paths);
}
// Services/ILogService.cs
public interface ILogService
{
    void Info(string message);
    void Warn(string message);
    void Error(string message, Exception? ex = null);
}
// Log path: %LocalAppData%\Pakko\logs\pakko.log
// Rotation: 1 MB → .log.1, max 3 rotated files

ZipArchiveService — Key Behaviors

  • ZIP detection: magic bytes 50 4B 03 04 — no extension check
  • Encryption detection: bit 0 of general purpose bit flag in local file header
  • Known non-ZIP formats: RAR, 7-Zip, GZip, BZip2, XZ, LZ4 detected by magic bytes → SkippedFiles
  • Smart extract (T-14): single root folder → strips prefix; multiple roots → creates subfolder; single root file → direct
  • ZIP slip protection: every entry path validated against destination before extraction
  • Progress: progress?.Report((i+1)*100/total) per file in both Archive and Extract
  • Threading: Task.Run wraps all IO — never blocks UI thread
  • Indeterminate: single source/archive >10 MB → progress?.Report(-1)
  • Lazy enumeration: Directory.EnumerateFiles — no upfront collection for large directories
  • Commit resilience (T-F161): internal static ZipArchiveService. CommitTempDestToActualDest(string tempDest, string actualDest, Action<string,string>? moveOverride = null) — the fast-path Directory.Move(tempDest, actualDest) (used when actualDest doesn't already exist) falls back to the existing per-file merge-and-delete path on IOException, since Windows fails the whole Directory.Move if any single file anywhere in the source tree is transiently locked by another process (confirmed empirically — see DECISIONS.md's T-F161 entry). ExtractWithSmartFolderingAsync's entry-extraction loop also now cleans up tempDest on any exception, not just OperationCanceledException.

Archiver.Core/Services/Zip/ — T-F35 Parallel SingleArchive Pipeline

Gated inside ZipArchiveService.ArchiveAsync's SingleArchive branch: below ParallelPipelineFileCountThreshold (64 files), the original always-sequential ZipFile.Open/AddDirectoryToArchiveAsync/AddEntryFromFileAsync code runs completely unchanged - unless the selection is a few large files (T-F352, ZipArchiveService.UsesParallelWriter: at least 8 MiB beside the largest file and at least a quarter of it, a level other than NoCompression, free space for twice the sources, and source and destination disks that report no seek penalty - IO/DiskSeekPenalty, which answers false for a spinning disk, a network share and a disk that does not answer). On a disk that reports a seek penalty the writer compresses its chunk-file entries one at a time (T-F359, CompressionSettings.OneLargeFileAtATime; same archive bytes). Above it, archiving routes into this subsystem instead — see DECISIONS.md's T-F35 entry (and its follow-up entries) for the full design rationale (why ZipArchive can't be reused for the write side once gated, the Option A/B trade-off, the temp-file-compression redesign that removed the original design's file-size ceiling, and the real bugs the test suite caught along the way).

Every non-placeholder file is compressed in parallel now, regardless of size — small files (≤1 MiB) fully in memory, everything else via a private per-worker temp file (bounded memory either way: a byte[] capped at 1 MiB, or a fixed copy-buffer streamed to disk, never O(file size) in RAM). There is no longer a "some files skip the parallel path" case.

// FileWorkItem.cs — one unit of work, in the exact final ZIP entry order
internal readonly record struct FileWorkItem(
    string SourcePath, string EntryName, FileWorkKind Kind, long FileSize, DateTime LastWriteTime);
internal enum FileWorkKind { File, DirectoryPlaceholder }

// WorkItemEnumerator.cs — deterministic, lazy; reshapes AddDirectoryToArchiveAsync's recursive
// walk into a flat IEnumerable<FileWorkItem>, preserving T-F31/T-F32 order, T-F30 collision
// renaming (reuses ZipArchiveService.GetUniqueEntryName, widened to internal), T-F66 empty-dir
// placeholders, T-F23 reparse-point skip, T-F75 fixed rootDir. Uses DirectoryInfo.EnumerateFiles()/
// EnumerateDirectories() (not the plain string-path overloads) so Length/LastWriteTime/Attributes
// come from the same FindNextFile data the enumeration already read — zero extra stat calls.
internal static class WorkItemEnumerator
{
    public static IEnumerable<FileWorkItem> Enumerate(
        IReadOnlyList<string> sortedSourcePaths,
        Action<SkippedFile> reportSkipped, Action<ArchiveError> reportError);
}

// WorkResult.cs — outcome of processing one FileWorkItem, consumed strictly in enqueue order.
// TempFileCompressed replaced the original "large files stream sequentially" design outright —
// both compressed cases know crc/compressed/uncompressed size fully upfront by the time the
// writer sees them (the temp file is already fully written by a background worker).
// SourceStored (T-F357): an unencrypted entry stored as it is, copied in the drain from the
// worker's own read handle (WorkResult.Source, sharing read only) instead of from a chunk.
internal enum WorkResultKind { Compressed, TempFileCompressed, SourceStored, DirectoryPlaceholder, Error }
internal sealed record WorkResult { /* Kind + payload; static WorkResult.For*() factories */ }

// ZipEntryCompressor.cs — compresses a file's bytes fully into memory via DeflateStream directly
// (no ZipArchiveEntry involved), for the in-memory (≤1 MiB) parallel path.
internal readonly record struct CompressedEntryData(
    byte[] CompressedBytes, uint Crc32, long UncompressedLength, ushort Method);
internal static class ZipEntryCompressor
{
    public static CompressedEntryData Compress(Stream sourceStream, CompressionLevel compressionLevel);
}

// ParallelSingleArchiveWriter.cs — dispatch/drain orchestration
internal static class ParallelSingleArchiveWriter
{
    public const long InMemoryCompressByteThreshold = 1L * 1024 * 1024; // 1 MiB — memory-shape
        // boundary (RAM buffer vs. disk-streamed buffer), not a parallelism-eligibility boundary.
    public static int ComputeWindowCapacity(); // Clamp(ProcessorCount, 2, 16)

    public static Task WriteAsync(
        string tempPath, IReadOnlyList<string> sortedSourcePaths, CompressionLevel compressionLevel,
        long totalBytes, Action<SkippedFile> reportSkipped, Action<ArchiveError> reportError,
        IProgress<ProgressReport>? progress, CancellationToken cancellationToken);

    // Decoupled from real compression so whitebox tests can inject controllable delegates for
    // both compress paths — see ParallelSingleArchiveWriterTests (including the whitebox test
    // that caught a real "bounded channel alone doesn't bound concurrency" bug).
    internal static Task RunPipelineAsync(
        string tempPath, IEnumerable<FileWorkItem> items,
        Func<FileWorkItem, CancellationToken, Task<WorkResult>> compressInMemory,
        Func<FileWorkItem, CancellationToken, Task<WorkResult>> compressToTempFile,
        int windowCapacity, long totalBytes, IProgress<ProgressReport>? progress,
        Action<ArchiveError> reportError, CancellationToken cancellationToken);
}

// ZipEntryWriter.cs — hand-rolled ZIP container writer (local file header/central directory/
// EOCD, conditional Zip64 — never "always on", see DECISIONS.md). Owns the whole output file
// once gated; a ZipArchive and this writer never share one output stream. Both write methods
// take crc/compressed/uncompressed size fully known upfront — no unknown-size placeholder-then-
// patch mechanism (removed once the "large files stream sequentially, sizes unknown until done"
// design was replaced by temp-file compression, which always knows sizes before the writer runs).
internal sealed class ZipEntryWriter : IAsyncDisposable
{
    internal const ushort StoredMethod = 0;
    internal const ushort DeflateMethod = 8;
    internal static ushort SelectMethod(CompressionLevel compressionLevel);

    public ZipEntryWriter(string path);
    public int EntryCount { get; }
    public Task WriteCompressedEntryAsync(string entryName, CompressedEntryData data, DateTime lastWriteTime, CancellationToken ct); // in-memory byte[]
    public Task WriteCompressedEntryFromStreamAsync(string entryName, Stream compressedSource,
        long compressedLength, long uncompressedLength, uint crc32, ushort method,
        DateTime lastWriteTime, CancellationToken ct); // temp-file-sourced, streamed copy, no size ceiling
    public Task WriteDirectoryPlaceholderAsync(string entryName, DateTime lastWriteTime, CancellationToken ct);
    public ValueTask DisposeAsync(); // writes central directory + EOCD (+ Zip64 if needed)

    // internal — reused by ParallelSingleArchiveWriter's temp-file compression worker so a file's
    // CRC is computed in the same single read pass as compression, not a second full file read.
    internal static Task<(long Total, uint Crc32)> CopyWithCrcAsync(
        FileStream source, Stream destination, byte[] buffer, IProgress<ProgressReport>? progress,
        long totalBytes, long startOffset, string entryName, CancellationToken ct);
}

// DosDateTime.cs — MS-DOS date/time packing, byte-identical to ZipArchiveEntry's own encoding
// (verified by a dedicated test that compares against a real ZipArchiveEntry-written header).
internal static class DosDateTime
{
    public static uint Encode(DateTime dateTime);
    public static DateTime Decode(uint packed);
}

Archiver.Core.IO.Crc32 gained an Accumulator struct (incremental CRC-32 — Update(ReadOnlySpan<byte>)/ Finish()) alongside its existing whole-stream Compute(Stream), so uncompressed bytes can be hashed in the same single read pass as compression instead of a second full file read.

Temp-file lifecycle (T-F35 follow-up, twice-revised): WriteAsync creates a per-operation, uniquely-named, hidden subfolder next to the destination archive — {destinationDir}\.pakko-tmp-{Guid}\ — not loose files scattered in that folder (on-device verification showed those visibly flickering in Explorer mid-operation) and not a shared %TEMP% location either (considered and rejected in turn: a different, possibly smaller/fuller volume than the destination, which matters now that there's no per-file size ceiling). Same- volume-as-destination plus FileAttributes.Hidden gets both disk-space locality and invisible-by-default in Explorer at once. CompressToTempFileAsync (in ParallelSingleArchiveWriter) creates one uniquely-named chunk file (chunk-{Guid}.tmp) inside that folder per file above the in-memory threshold, streams the compressed result into it, and hands the finished path to the writer via WorkResult.TempFileCompressed. The writer copies its bytes into the archive and deletes it immediately after. An unencrypted entry stored as it is gets no chunk of its own (T-F357, WorkResult.SourceStored): its source stays open until the writer copies it, and the pipeline's finally releases any source never copied. A tracked set (ConcurrentDictionary<string,byte>) plus an outer finally that awaits every dispatched compress task before sweeping guarantees no orphaned temp file survives cancellation or an unhandled exception — a real race (a straggler task finishing and creating its temp file after an earlier sweep attempt already ran) was caught by a test that failed intermittently under full-suite parallel load before this was fixed; see DECISIONS.md.

Before creating a chunk file, CompressToTempFileAsync also checks ArchiveEntrySecurity.GetAvailableFreeSpace(chunkDirectory) against the file's declared size — reusing the same T-F94 helper the extraction-side compression-bomb check already uses. Archive creation never had any disk-space check before this (only extraction did, and only as part of the bomb defense); the temp-file redesign introduced a real, if best-effort-only-mitigated, new disk-space risk that direct streaming never had. Insufficient space is reported as a per-file ArchiveError, no disk touched — see DECISIONS.md.


FileHashService — Current Signature (T-F128)

Static, stateless — mirrors ArchiveNaming/ArchiveFormatRegistryNames's "no DI needed" shape, not a constructor-injected service. Consumed directly by Archiver.Shell.ShellCommands.HashAsync (the Explorer context-menu's "Хеш-суми" submenu — see ExplorerCommands.cpp's HashCommand).

// Models/HashAlgorithmKind.cs
public enum HashAlgorithmKind { Crc32, Sha256 }

// Services/FileHashService.cs
public sealed record HashEntry(string SourcePath, string? Hash, string? Error);
public sealed record FolderHashSummary(string DataSum, string NamesSum, int FileCount, long TotalBytes); // TotalBytes: T-F128 follow-up

public sealed class HashResult
{
    public IReadOnlyList<HashEntry> Entries { get; init; }
    public FolderHashSummary? Folder { get; init; } // non-null only for a single-folder input
}

public static class FileHashService
{
    public static Task<HashResult> ComputeAsync(
        IReadOnlyList<string> paths,
        HashAlgorithmKind algorithm,
        IProgress<ProgressReport>? progress,
        CancellationToken ct);
}

// IO/HashDigestAccumulator.cs (internal — NanaZip-compatible combine algorithm, see
// DECISIONS.md's T-F128 entry for the full byte-level derivation against real NanaZip source)
internal sealed class HashDigestAccumulator
{
    public HashDigestAccumulator(int digestSize);
    public void Add(ReadOnlySpan<byte> itemDigest);
    public string ToDisplayString(); // e.g. "3B4FE1AC-00000001" once 2+ items are combined
}

Branching in ComputeAsync: exactly one folder path → recursive whole-tree hash with a combined Folder summary (DataSum always NanaZip-exact regardless of nesting; NamesSum exact per file, but omits each subfolder object's own contribution — see DECISIONS.md). Anything else (one or more files, or a folder mixed into a larger selection) → each path hashed independently into Entries, Folder stays null; a folder encountered in this branch is recorded as a skipped HashEntry, not summed.


Dependency Injection & Startup

Merged in from the former BOOTSTRAP.md (2026-07-05) — same content, one owner.

// App.xaml.cs — ConfigureServices()
GroupPolicyOptions policy = GroupPolicyService.Load();
services.AddSingleton(policy);
services.AddSingleton<ILogService, LogService>();
services.AddSingleton<IArchiveService, ZipArchiveService>();
services.AddSingleton<IDialogService, DialogService>();
var tarService = new TarSandboxedService(policy);
Task<TarCapabilities> tarProbe = Task.Run(tarService.DetectCapabilitiesAsync);
services.AddSingleton<ITarService>(tarService);
services.AddSingleton<TarCapabilities>(_ => tarProbe.GetAwaiter().GetResult());
services.AddSingleton<IExtractionRouter, ExtractionRouter>();
services.AddSingleton<IArchiveListingRouter, ArchiveListingRouter>();
services.AddSingleton<IArchiveCreationRouter, ArchiveCreationRouter>();
services.AddSingleton<IAntivirusScanService, AntivirusScanService>();
// T-F207: the owner window is read at delete time, after DialogService.SetWindow has run.
services.AddSingleton(sp => new SourceRecycler(new Win32SourceDeleteOperations(
    () => sp.GetRequiredService<IDialogService>().OwnerWindowHandle)));
services.AddTransient<MainViewModel>();
// T-F347: the tar.exe probe starts on the thread pool here and runs beside XAML loading; the
// first consumer (MainViewModel, built in MainWindow's constructor) takes its result and waits
// only if the probe is not done. Before, it ran on the UI thread (~100 ms) before any window.
// T-F51: GroupPolicyOptions is registered first so ActivatorUtilities can inject it into every
// consumer below. T-F261: the policy is a required ctor param on every engine/router — there is
// no "allow everything" default to fall back to if a registration is missing.
Type Lifetime Reason
GroupPolicyOptions Singleton, eager (GroupPolicyService.Load()) One synchronous registry read at startup (T-F51); never changes mid-session
LogService Singleton Holds file path, lock object
ZipArchiveService Singleton Stateless (besides the injected GroupPolicyOptions)
DialogService Singleton Holds window reference
TarSandboxedService Singleton, built by hand Stateless (per-call sandbox scope, not per-instance state); built before the container so the probe can start at once (T-F347)
TarCapabilities Singleton, factory-resolved Probed once at startup on the thread pool (T-F48, T-F347), never changes at runtime
ExtractionRouter / ArchiveListingRouter / ArchiveCreationRouter Singleton Stateless — route by detected/requested format only
MainViewModel Transient Fresh state per window

ViewModel Resolution

// MainWindow.xaml.cs
public MainWindow()
{
    // Tray commands (TrayOpenCommand/TrayAboutCommand/TrayExitCommand/TrayLeftClickCommand/
    // HashFilesCommand) are constructed here, before InitializeComponent() — omitted below.
    InitializeComponent();
    ViewModel = App.Services.GetRequiredService<MainViewModel>();

    // 1100x780, floor 900x780 via OverlappedPresenter.PreferredMinimumWidth/Height — re-tuned
    // twice by T-F106; see DECISIONS.md's T-F106 entry for the full before/after account. Not
    // 800x700 — that size predates T-F105/T-F106 and could clamp the file table to zero height.
    this.AppWindow.Resize(new Windows.Graphics.SizeInt32(1100, 780));
    if (this.AppWindow.Presenter is Microsoft.UI.Windowing.OverlappedPresenter presenter)
    {
        presenter.PreferredMinimumWidth = 900;
        presenter.PreferredMinimumHeight = 780;
    }

    // Title bar shows the running assembly's own file-write timestamp, not a static "Pakko" or a
    // manually-bumped version — makes every on-device screenshot self-certifying proof a fresh
    // Deploy.ps1 build is actually installed (see CLAUDE.md's "never trust build logs alone" note).
    var buildTime = System.IO.File.GetLastWriteTime(
        System.Reflection.Assembly.GetExecutingAssembly().Location);
    this.AppWindow.Title = $"Pakko — build {buildTime:yyyy-MM-dd HH:mm:ss}";

    this.AppWindow.SetIcon("Assets/Square44x44Logo.ico");
    this.Activated += OnFirstActivated;
    RootGrid.Loaded += RootGrid_Loaded;
    this.Closed += (_, _) =>
    {
        TrayIcon.Dispose();
        ActivationGate.Cancel();
        PreviewCache.DeleteAll();      // T-F97
        NestedArchiveCache.DeleteAll(); // T-F98
    };
}

ActivationGate (DeferredActionGate, Archiver.App.Core) defers File/Protocol-activation mutations (FileItems, IsBrowsingArchive) until after RootGrid's first Loaded/layout pass — mutating them synchronously right after Activate() realizes ListView containers against an incomplete layout, leaving rows permanently blank (T-F106).

Rules

  • Never call new ZipArchiveService() outside ConfigureServices()
  • Never access App.Services from inside Archiver.Core
  • Archiver.Core has zero references to any app service

Planned Layer Additions (v1.2+)

v1.2 — Shell Extension

Archiver.Shell (net10.0-windows, WinExe) is implemented and included in the MSIX package, showing progress via the in-process IProgressDialog COM object (NativeProgressDialog.cs).

T-F61 — Archiver.ShellExtension (in-process COM DLL, C++/WRL):

  • One registered CLSID: PakkoRootCommand (1EABC7CE-20A4-48EE-A99F-43D4E0F58D6A), ThreadingModel STA
  • Sub-commands (BrowseCommand, ExtractDialogCommand, ExtractHereCommand, ExtractFolderCommand, CompressDialogCommand, ArchiveCommand, TestCommand) returned at runtime via IExplorerCommand::EnumSubCommands — not separately registered in the manifest
  • Selection logic in EnumSubCommands (order mirrors NanaZip's real ContextMenu.cpp: BrowseCommand ("Open") first, mirroring NanaZip's own kOpen-before-kExtract order (T-F03) — a separate, coexisting command, not a replacement for ExtractDialogCommand; dialog command before its one-click sibling in each group; TestCommand always last per Pakko's own primary-actions-before-diagnostic rule, CLAUDE.md): BrowseCommand shown only for a single-item selection that's a supported archive (paths.size() == 1 && AllPathsAreSupportedArchive); ExtractDialogCommand/TestCommand shown whenever selection contains ≥1 .zip (AnyPathIsZip); ExtractHereCommand/ExtractFolderCommand shown only when all paths are .zip (AllPathsAreZip); CompressDialogCommand always shown; ArchiveCommand shown unless all paths are .zip
  • Invoke launches Archiver.Shell.exe via CreateProcess with the correct argument set — dialog commands (T-F63) use --open-ui --extract/--open-ui --archive to route through a Launch activation of Archiver.App (AppLauncher, T-F232) instead of running silently
  • Registered via com:SurrogateServer in Package.appxmanifest — com:Path must be a child element of the server, not a Path attribute on com:Class (see DECISIONS.md); requires MinVersion="10.0.18362.0" (Windows 10 1903) or higher in TargetDeviceFamily
  • Rejected alternative: out-of-process COM EXE server inside Archiver.Shell.exe — an in-process DLL has lower latency and needs no LocalServer32 infrastructure; see DECISIONS.md for the full rationale and the crash-isolation risk this accepts

v1.3 — ITarService Layer

New interface and implementation in Archiver.Core:

// Models/TarCapabilities.cs
public sealed record TarCapabilities
{
    public bool SupportsRar { get; init; }
    public bool Supports7z { get; init; }
    public bool SupportsZstd { get; init; }
    public bool SupportsXz { get; init; }
    public bool SupportsLzma { get; init; }
    public bool SupportsBz2 { get; init; }
    public string Version { get; init; } = string.Empty;
}
// Interfaces/ITarService.cs
public interface ITarService
{
    Task<TarCapabilities> DetectCapabilitiesAsync();
    Task<ArchiveResult> ExtractAsync(
        ExtractOptions options,
        IProgress<int>? progress = null,
        CancellationToken cancellationToken = default);

    // T-F05: built on the RunTarAsync primitive (-tf + -tvf) — deliberately does NOT reuse
    // ScanForUnsafeEntriesAsync's pre-scan; listing must never be gated on a policy that only
    // matters once bytes are about to be written to disk.
    Task<ArchiveListResult> ListEntriesAsync(
        string archivePath,
        CancellationToken cancellationToken = default);

    // T-F105 (v1.4): archive CREATION, not extraction — deliberately unsandboxed (trusted local
    // input, not an untrusted archive; see SECURITY.md's tar.exe Trust Model). IProgress<
    // ProgressReport>, not IProgress<int> like ExtractAsync above, to match
    // IArchiveService.ArchiveAsync's contract for IArchiveCreationRouter below.
    Task<ArchiveResult> CompressAsync(
        ArchiveOptions options,
        IProgress<ProgressReport>? progress = null,
        CancellationToken cancellationToken = default);
}

Correction (T-F142, 2026-08-04): ExtractAsync's progress parameter is now IProgress<ProgressReport>?, not IProgress<int>? as shown above — matching CompressAsync's and IArchiveService.ExtractAsync's contract exactly, so IExtractionRouter passes progress straight through to both sub-services with no adapter in between (the AdaptProgress method described below, under IExtractionRouter, no longer exists). TarSandboxedService.ExtractAsync reports real BytesTransferred/TotalBytes/CurrentFile — not just Percent — for a single-archive extraction, via a poll of the sandboxed quarantine output directory rather than a streamed subprocess channel (see DECISIONS.md's T-F142 entry for why: tar.exe runs inside the AppContainer for extraction, unlike CompressAsync's unsandboxed launch, so there is no per-entry stderr line to hook the way T-F140 did for archiving). T-F306 (2026-10-01): with ZIP and tar-family archives in one selection, ExtractionRouter wraps progress per engine: each gets a slice of the percent sized by the archives' sizes on disk, and tar's bytes continue after ZIP's total; a selection of one kind passes through untouched.

Implementation: TarProcessService in Archiver.Core/Services/.

  • Always invokes C:\Windows\System32\tar.exe (absolute path)
  • Quarantine/staging: same pattern as T-F26/T-F27 (temp dir on same disk, atomic move)
  • Whole-archive pre-scan (T-F49) runs before -xf: tar -tf/-tvf reject the archive outright if any entry name is unsafe or any entry is a symlink/hardlink/device — see DECISIONS.md's T-F49 entry for why post-extraction validation alone isn't sufficient
  • Post-extraction validation: ADS, reserved names, reparse points — via ArchiveEntrySecurity (Archiver.Core/Services/ArchiveEntrySecurity.cs), a shared internal static class also used by ZipArchiveService, so this checklist can't drift between the two extractors
  • MOTW propagation: copies Zone.Identifier ADS from archive to each extracted file (also via ArchiveEntrySecurity)

DetectCapabilitiesAsync (T-F48) runs tar.exe --version and delegates parsing to TarVersionParser.Parse(string) in Archiver.Core/Services/TarVersionParser.cs — pulled into its own class so format detection is unit-testable without launching a process, the same rationale as Archiver.Shell's ShellArgumentParser (T-F57). Supports7z/SupportsRar are gated on libarchive >= 3.7.0.

DI registration:

services.AddSingleton<ITarService>(tarService);   // TarSandboxedService, see "Dependency Injection & Startup"
services.AddSingleton<TarCapabilities>(_ => tarProbe.GetAwaiter().GetResult());
services.AddSingleton<IExtractionRouter, ExtractionRouter>();
services.AddSingleton<IArchiveListingRouter, ArchiveListingRouter>(); // T-F05
services.AddSingleton<IArchiveCreationRouter, ArchiveCreationRouter>(); // T-F105

v1.3 — IExtractionRouter (T-F85)

MainViewModel/Archiver.Shell don't call IArchiveService/ITarService directly for extraction — both go through IExtractionRouter, which splits a mixed selection by format and merges the results:

// Interfaces/IExtractionRouter.cs
public interface IExtractionRouter
{
    Task<ArchiveResult> ExtractAsync(
        ExtractOptions options,
        IProgress<ProgressReport>? progress = null,
        CancellationToken cancellationToken = default);

    // T-F261: same classifier as ExtractAsync. ZIP paths go to IArchiveService.TestAsync;
    // tar-family paths are skipped ("tar-family archives have no test capability") — tar.exe is
    // never started; policy/capability refusals are skipped with their own reason.
    // T-F275 step 3, verifyRecoveryData (opt-in; pakko t sets it): each archive is also checked
    // against a PAR2 set next to it (RecoveryTestStep), a .par2 path stands for the archive its
    // set protects, a tar-family archive with a usable set is checked by it instead of skipped,
    // and a ZIP that tests intact while its set disagrees gets a DoesNotMatch warning (a set left
    // from an earlier version), not damage. Under DisableRecoveryData no set is looked for and a
    // .par2 path is an error.
    Task<ArchiveResult> TestAsync(
        IReadOnlyList<string> archivePaths,
        IProgress<ProgressReport>? progress = null,
        Func<PasswordPromptInfo, Task<PasswordDecision>>? resolvePasswordAsync = null,
        bool verifyRecoveryData = false,
        CancellationToken cancellationToken = default);
}

Implementation: ExtractionRouter in Archiver.Core/Services/. Classifies every ArchivePaths entry via ArchiveFormatDetector.Detect (magic-byte only — ZIP/gzip/bzip2/ RAR/7z/xz/zstd via header bytes, plain .tar via the ustar string at offset 257), routes ZIP to IArchiveService and tar-family formats TarCapabilities reports supported to ITarService (both sub-calls get OpenDestinationFolder = false — the router opens it itself, once, after merging), and merges both ArchiveResults. T-F142 correction: no adapter step exists any more — ITarService.ExtractAsync takes IProgress<ProgressReport>? directly (see the correction note above), so progress passes straight through. The one exception: for a MIXED selection (both a zip and a tar-family path in the same call), progress is passed to tar only when zipPaths is empty — otherwise each sub-service would independently believe itself "the sole archive" (each only sees its own bucket's count) and both would run a real 0→100 climb, visibly dipping the dialog back down when tar's climb restarts after zip's already finished. ZIP's own pass-through stays unconditional (pre-existing, zip always runs first). A tar-family format the installed tar.exe doesn't support becomes a SkippedFiles entry with a specific reason, not a generic message.

ZipArchiveService.GetKnownArchiveReason is deliberately not refactored to share ArchiveFormatDetector — see DECISIONS.md-equivalent reasoning in TASKS_DONE.md's T-F85 entry (opposite polarity, not behavior-equivalent).

v1.4 — AppContainer Sandbox for tar.exe

Status: implemented (T-F52 Phase 1, steps 1–11 of 13 complete 2026-07-14) — TarSandboxedService is real, shipping code, not a design description. It implements ITarService, replacing TarProcessService (deleted outright, not kept as a fallback — fail-closed posture, see TASKS_DONE.md/DECISIONS.md's T-F52 entries). Mechanism is an AppContainer, not a Low-IL restricted token (superseded design, see DECISIONS.md's T-F52 tradeoff entry — network isolation falls out of AppContainer's empty capability list for free, avoiding a global firewall rule). DI swap touches three call sites, not one — Archiver.App/App.xaml.cs, Archiver.Shell/Program.cs (no DI container there, a direct new), and SkipIfFormatUnsupportedAttribute.cs (test infra):

services.AddSingleton<ITarService, TarSandboxedService>(); // was TarProcessService

src/Archiver.Core/Services/Sandbox/ — single-concern classes, no P/Invoke god-class: SandboxHandles.cs (SafeHandle types), AppContainerProfile.cs, QuarantineAcl.cs, SandboxJobObject.cs, SandboxedProcessLauncher.cs + ProcessLaunchOptions.cs, LaunchAttributeList.cs, TarCommandLineEncoding.cs, TarOutputEncoding.cs, TarSignatureVerifier.cs, and TarSandboxScope.cs — the disposable orchestration class every sandboxed tar.exe run goes through (ListAsync/ExtractAsync), tying profile + ACL + the open archive + Job Object + signature check together per archive operation. Fix phase 4 (2026-09-25) removed QuarantineStaging.cs (hardlink staging, T-F233) — see DECISIONS.md's fix-phase-4 entry.

P/Invoke surface:

  • CreateAppContainerProfile — created lazily once, reused for the lifetime of the install. An existing profile is detected through its AppContainer\Mappings\<SID> registry key first, so concurrent operations never call it (T-F244 item 1: concurrent calls on an existing profile failed)
  • PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES + PROC_THREAD_ATTRIBUTE_HANDLE_LIST (LaunchAttributeList, built per launch) — the AppContainer SID with an empty capability list (no network), and the exact handles the child may inherit (its stdin and its own two pipe write ends, T-F244 item 5). Every tar.exe launch — sandboxed runs, archive creation, the startup --version probe — goes through SandboxedProcessLauncher
  • WideCharToMultiByte(CP_ACP, WC_NO_BEST_FIT_CHARS) round trip (TarCommandLineEncoding) — every tar.exe argument must survive the ANSI command-line conversion unchanged (T-F266)
  • SetEntriesInAclW/SetNamedSecurityInfoW — grants the AppContainer SID access to Pakko's own quarantine folders only: out\ = Modify (0x1301BF), the quarantine root = traverse/list/read attributes, the shared parent = traverse-only. Never an ACE on the user's archive
  • ReOpenFile — each tar.exe run reads the archive from its own synchronous handle to the file the scope holds open, inherited as stdin (-f -)
  • CreateJobObject/SetInformationJobObject/AssignProcessToJobObject — ActiveProcessLimit = 1
    • memory/CPU limits + UI restrictions (absorbed from T-F13), plus a completion port the job reports an enforced limit on (T-F239)
  • WinVerifyTrust + CryptQueryObject/CryptMsgGetParam/CertGetNameStringW — Authenticode signature + Microsoft-Organization check before every scope creation (cheap, defense-in-depth only, not managed X509Certificate2 — that only extracts the embedded cert without verifying it against the file's actual bytes)

Flow (TarSandboxScope.CreateAsync + ListAsync/ExtractAsync):

  1. Verify tar.exe's Authenticode signature (Microsoft Organization) once per scope
  2. Open the archive read-only, sharing read only, for the whole scope — nobody can write, rename or delete it until the scope ends, so the pre-scan and the extraction read the same bytes; a file another program still has open for writing is refused as "in use" (T-F233)
  3. Ensure the AppContainer profile exists (lazy, once, never deleted)
  4. Create a quarantine directory under a fixed, Pakko-owned %TEMP%\PakkoTarSandbox\<guid>\ — not "same disk as the destination" (an AppContainer token has no bypass-traverse-checking privilege, so FILE_TRAVERSE is enforced on every ancestor directory — see DECISIONS.md's T-F52 entry) — with out\ if the scope extracts
  5. Per tar.exe run: a fresh Job Object (ActiveProcessLimit = 1; memory = half the machine's memory within 1–4 GiB; CPU time = at least 60 minutes, plus one per 10 MB of archive; JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE), a fresh ReOpenFile handle as stdin, the quarantine root as current directory, -f - and (once decided) --options tar:hdrcharset=UTF-8
  6. The first listing decides the tar header charset: UTF-8, or the OEM code page when tar.exe reports the names are not valid UTF-8 (T-F204); tar.exe's output is decoded with the user locale's ANSI code page (TarOutputEncoding)
  7. Run tar.exe inside the AppContainer for both the T-F49 whole-archive pre-scan (-t/-tv) and the extraction (-x -C out). Before extraction, Pakko itself pre-creates every directory the archive implies (libarchive's implicit parent-directory creation fails under the AppContainer — see DECISIONS.md's T-F52 entry)
  8. After the process exits, move each file from out\ into an ExtractionStaging folder on the destination's volume (conflicts, MOTW from the archive the user chose), then commit it with CommitInto — the same commit ZIP extraction uses (T-F263), so a cancel leaves no partial files
  9. Dispose the scope: close the archive, delete the quarantine directory, release the SID handle — the AppContainer profile itself is not deleted, it persists for reuse

v1.4 — T-F05 Archive Browser

New models in Archiver.Core/Models/:

// Models/ArchiveEntryInfo.cs
public sealed record ArchiveEntryInfo
{
    public required string Path { get; init; }  // '/'-separated, no leading slash
    public long Size { get; init; }
    public long? CompressedSize { get; init; }   // null for tar-routed formats: no per-entry packed size (T-F214)
    public uint? Crc32 { get; init; }            // null for tar-routed formats — no per-entry CRC
    public DateTime? Modified { get; init; }     // tar: parsed from "-tv" by TarListingDate (T-F214), minute or date only; null if unreadable
    public bool ModifiedHasTime { get; init; } = true; // T-F335: false when tar.exe listed the year in place of the time (shown as a date alone)
    public bool IsDirectory { get; init; }
    public EntryEncryption? Encryption { get; init; } // T-F199: ZIP only, read without a password; null = format can't say
    public int? AesVersion { get; init; }            // WinZip AE-1/AE-2, null unless AES
}

// Models/EntryEncryption.cs (T-F199)
public enum EntryEncryption { None, ZipCrypto, Aes128, Aes192, Aes256, Unknown } // Unknown: bit 0 set, method unreadable

// Models/ArchiveListResult.cs
public sealed record ArchiveListResult
{
    public bool Success { get; init; }
    public IReadOnlyList<ArchiveEntryInfo> Entries { get; init; } = [];
    public string? ErrorMessage { get; init; }
}

IArchiveService/ITarService each gain:

Task<ArchiveListResult> ListEntriesAsync(string archivePath, CancellationToken cancellationToken = default);

Routed by a new interface, mirroring IExtractionRouter's dispatch exactly (same ArchiveFormatDetector/TarCapabilities logic, copied rather than shared — see DECISIONS.md; T-F261: now shared through ArchiveFormatPolicy, and T-F250: the router takes a required GroupPolicyOptions — ArchiveListingRouter(IArchiveService, ITarService, TarCapabilities, GroupPolicyOptions)):

// Interfaces/IArchiveListingRouter.cs
public interface IArchiveListingRouter
{
    Task<ArchiveListResult> ListEntriesAsync(string archivePath, CancellationToken cancellationToken = default);
}

Implementation: ArchiveListingRouter in Archiver.Core/Services/. TarSandboxedService.ListEntriesAsync is built on the existing RunTarAsync primitive (-tf + -tvf), deliberately not reusing ScanForUnsafeEntriesAsync — listing must never be gated on the security pre-scan that only matters once bytes are about to be written to disk.

ExtractOptions gains one field for the archive browser's "Extract selected" command:

public IReadOnlyList<string>? SelectedEntryPaths { get; init; }

Non-null/non-empty restricts extraction to just those archive-internal entry paths (a selected folder implies its full nested contents); null (default) is unaffected — every existing caller extracts everything, as before. Rides through ExtractionRouter's existing options with { ArchivePaths = ... } pattern for free — ExtractionRouter.cs itself needed zero changes. Both ZipArchiveService.ExtractWithSmartFolderingAsync and TarSandboxedService.ExtractSingleArchiveAsync implement the filtering; the tar side's whole-archive pre-scan (T-F49) still runs unconditionally before the subset is ever computed — see DECISIONS.md's T-F05 entry for the tar.exe selective-extraction spike this was verified against. (TarSandboxedService replaced TarProcessService outright in T-F52 — fail-closed, no unsandboxed fallback; every reference to TarProcessService elsewhere in this file describes pre-T-F52 history and is dated accordingly.)

DI registration adds:

services.AddSingleton<IArchiveListingRouter, ArchiveListingRouter>();

New project Archiver.App.Core (plain net10.0, no WinUI — referenced by Archiver.App, tested by Archiver.App.Core.Tests) holds the App-layer model and the flat-to-tree helper:

// Archiver.App.Core/ArchiveEntryViewModel.cs
public sealed record ArchiveEntryViewModel
{
    public required string FullPath { get; init; }
    public required string Name { get; init; }
    public required bool IsFolder { get; init; }
    public long Size { get; init; }
    public long? CompressedSize { get; init; }
    public uint? Crc32 { get; init; }
    public DateTime? Modified { get; init; }
    public bool ModifiedHasTime { get; init; } = true; // T-F335: false -> ModifiedDisplay is the date alone

    // T-F98: true only for a nested-archive row where drilling in would exceed
    // NestedArchivePolicy.MaxDepth — the one case where double-clicking an archive entry does
    // NOT transparently drill in. Drives the Icon property's View-vs-Hide glyph choice (T-F110).
    public bool NestedDepthLimitReached { get; init; }

    // + ModifiedDisplay/SizeDisplay/CompressedSizeDisplay/CrcDisplay/Icon computed properties
}

// Archiver.App.Core/ArchiveTreeIndex.cs
public static class ArchiveTreeIndex
{
    public static ArchiveTree Build(IReadOnlyList<ArchiveEntryInfo> flatEntries);
}

public sealed class ArchiveTree
{
    public bool TryGetChildren(string folderPath, out IReadOnlyList<ArchiveEntryViewModel> children);
}

Build synthesizes implied folder nodes from /-split paths (ZIP archives commonly have no explicit directory entries) and runs once per archive open, linear in the total path length — folder navigation afterward walks the path's segments, never a re-scan of the flat list, which matters at the 65,000+-entry scale this app's archives can reach (T-F20). A folder's rows (and their full-path strings) are made on its first visit only (T-F237: one string per ancestor was O(depth^2)).

MainViewModel (Archiver.App) gains an IArchiveListingRouter constructor dependency plus browser state (IsBrowsingArchive, BrowsedArchivePath, CurrentFolderPath, CurrentFolderEntries, BreadcrumbSegments, SelectedBrowserEntries) and commands (EnterBrowseModeAsync, NavigateIntoFolder/NavigateToBreadcrumbSegment, NavigateUpOrExitBrowserCommand, ExtractSelectedFromBrowserCommand/ExtractAllFromBrowserCommand/ExtractSingleBrowserEntryAsync). The Extract-related commands all funnel through a shared private RunExtractAsync(archivePaths, selectedEntryPaths) that the pre-existing whole-archive ExtractCommand was refactored to call too — the IsBusy/progress/stopwatch/bomb-confirm/ summary-dialog/cleanup sequence is identical for both; only ArchivePaths and SelectedEntryPaths differ. NavigateUpOrExitBrowserCommand (added in a follow-up round, same day) replaced the standalone ExitBrowseModeCommand/Close button entirely: it steps up one archive folder level when not at the archive's own root, and exits browse mode (the same effect ExitBrowseMode — kept as a private method, no longer its own command — always had) when already there. A second, unrelated up-navigation command, NavigateDestinationUpCommand, was added the same round for the Destination Path row (Row 2, shared by both modes): it sets DestinationPath = Path.GetDirectoryName(DestinationPath), disabled via its own CanExecute when that returns null (a drive root or an unrooted path).

ArchiveEntryViewModel (Archiver.App.Core) exposes SizeDisplay/CompressedSizeDisplay/ CrcDisplay — all three render as the entry table's own columns (Row 1 browse in MainWindow.xaml) rather than a separate Info dialog; IDialogService.ShowEntryInfoAsync was removed the same day it shipped (design review 2026-07-13) once every field it showed had a table-column equivalent. Note CompressedSizeDisplay/CrcDisplay are both blank for every tar-routed format (RAR/7z/tar.*) — TarSandboxedService's listing path never populates CompressedSize/Crc32 (no per-entry concept for either in a tar-family archive) — so both columns only ever show a value for ZIP. Both guard on null: 0 is a real CRC-32 (an empty file) and a real packed size (an empty ZIP entry), so neither can double as a "not available" sentinel (T-F214 made CompressedSize nullable for this).

MainWindow.xaml's Row 1 (file table) and Row 3 (action buttons) each gain a sibling Grid toggled by IsPendingListVisibility/IsBrowsingArchiveVisibility (inline mode-swap, not a new window or NavigationView). The browser Grid holds a BreadcrumbBar (first use in this codebase) and a ListView (SelectionMode="Multiple", explicit VirtualizingStackPanel VirtualizationMode="Recycling" via ItemsPanelTemplate) bound to ArchiveEntryViewModel. Double-clicking a recognized archive in the existing pending-selection list (gated by ArchiveFormatDetector.Detect, not FileItem.Type) calls EnterBrowseModeAsync.

v1.4 — IArchiveCreationRouter (T-F105, TAR Archive Creation)

Archive creation gains the same one-interface-per-operation dispatch pattern IExtractionRouter/IArchiveListingRouter already established — but simpler, since the format is a single explicit ArchiveOptions.Format choice for the whole call, not something detected per-path from file content:

// Interfaces/IArchiveCreationRouter.cs
public interface IArchiveCreationRouter
{
    Task<ArchiveResult> ArchiveAsync(
        ArchiveOptions options,
        IProgress<ProgressReport>? progress = null,
        CancellationToken cancellationToken = default);
}

Implementation: ArchiveCreationRouter in Archiver.Core/Services/ — a single branch, no per-path splitting/merging like ExtractionRouter:

public sealed class ArchiveCreationRouter(IArchiveService archiveService, ITarService tarService)
    : IArchiveCreationRouter
{
    public Task<ArchiveResult> ArchiveAsync(ArchiveOptions options, IProgress<ProgressReport>? progress = null, CancellationToken cancellationToken = default) =>
        options.Format == ArchiveContainerFormat.Zip
            ? archiveService.ArchiveAsync(options, progress, cancellationToken)
            : tarService.CompressAsync(options, progress, cancellationToken);
}
// Later: the T-F51 policy checks (third ctor parameter) and, T-F275, RecoveryPercent — the engine
// runs with OpenDestinationFolder off, then RecoveryDataWriter.AddTo on the thread pool removes
// the sets of each archive's earlier bytes (RemoveEarlierSets) and writes a set per CreatedFiles
// entry, and the router opens the folder. With 0 and the policy off, the same shape runs
// RemoveEarlierSets alone.
// DIAGRAMS.md diagram 9.

TarSandboxedService.CompressAsync runs tar.exe unsandboxed — no TarSandboxScope/ AppContainer/Job Object — since creation reads trusted local files, not an untrusted archive; see SECURITY.md's "Why Archive Creation Is NOT Sandboxed" for the full reasoning. It builds one tar.exe -cf invocation per output archive (ArchiveMode.SingleArchive = one invocation for all sources via a repeated -C <parent> <name> pair per source, confirmed to preserve relative structure correctly; ArchiveMode.SeparateArchives = one invocation per source), using the temp-file-then-atomic-move pattern ZipArchiveService.ArchiveAsync already uses (no partial files on cancel/failure). Compression level (the existing ZIP System.IO.Compression.CompressionLevel enum, reused rather than adding a second one) maps to tar.exe's real --options <filter>:compression-level=N mechanism — confirmed empirically that a bare -9-style flag does not work, but --options does, for all five write filters (gzip/bzip2/xz/zstd/lzma); plain Tar gets no filter flag and no --options at all (passing --options without an active filter fails outright — confirmed). Progress is file-count-based (parsed from -v's per-entry stderr lines — confirmed empirically that verbose creation output goes to stderr, not stdout), not byte-accurate like ZIP's ProgressStream — an accepted v1 trade-off, same precedent as the tar-family listing path being coarser than ZIP's.

ArchiveNaming (Archiver.Core/Services/ArchiveNaming.cs) gains the inverse of its existing GetBaseName — GetExtension(ArchiveContainerFormat) maps the enum to .zip/.tar/.tar.gz/ etc.; ZipArchiveService.ArchiveAsync's two previously-hardcoded ".zip" literals now call it too, so both creation paths share one source of truth for extensions.

T-F264 (2026-09-28): ArchiveNaming is the only naming source. New public members:

// Archiver.Core/Services/ArchiveNaming.cs — public static
string GetDefaultArchiveName(IReadOnlyList<string> sourcePaths); // one source: its name (compound tar
                                  // extension stripped, dotfile kept); several: the first one's folder;
                                  // UNC share root: the share name; else "archive"
string GetUniqueName(string fileName, Func<string, bool> isTaken);  // "name (N).ext"
string GetUniqueFolderName(string parentDir, string name);          // "name (N)" for a folder

ResolveSingleArchiveName(null, ...), Shell's "Add to" and the C++ menu title (BuildAddToArchiveTitle) all follow GetDefaultArchiveName; GetUniqueFilePath and both engines' unique-entry-name helpers call GetUniqueName. FormatListConsistencyTests keeps the C++ extension arrays and the manifest in line with the C# lists.

DI registration adds:

services.AddSingleton<IArchiveCreationRouter, ArchiveCreationRouter>();

Phase B (2026-07-16): MainViewModel's constructor now takes IArchiveCreationRouter instead of IArchiveService — that was its only IArchiveService call site. ArchiveAsync calls _archiveCreationRouter.ArchiveAsync(options, progress, _cts.Token) with a new SelectedContainerFormat (ArchiveContainerFormat, default Zip) plumbed into ArchiveOptions.Format. MainWindow.xaml gained a "Формат" ComboBox (Row 5, right before the existing Compression combobox) bound to a new FormatIndex int property, following the same get/set switch-expression pattern as CompressionLevelIndex/OnConflictIndex. The Compression combobox's IsEnabled now binds to a new IsCompressionLevelEnabled property (IsNotBusy && !IsPlainTarFormatSelected) instead of plain IsNotBusy, so it greys out only when plain Tar is selected (the Phase A empirical finding — every other format, ZIP included, keeps a working compression-level control). All 37 locale Resources.resw files gained the FormatLabel/FormatZipItem/.../FormatTarLzmaItem keys; the 7 format-name item values are identical, untranslated Latin script in every locale (technical identifiers, not prose — same convention Windows Explorer itself follows), only the FormatLabel.Text word is translated per locale.

Phase C (2026-07-16): a new TarArchiveCommand leaf IExplorerCommand (Archiver.ShellExtension/ExplorerCommands.h/.cpp, CLSID 5F440071-6288-4446-AE25-3F4EDA490DDC) mirrors ArchiveCommand exactly but always passes L".tar"/L"tar" where ArchiveCommand passes the ZIP defaults — plain/uncompressed tar only, since one-click commands never prompt the user for a filter choice. Registered in PakkoRootCommand::EnumSubCommands right after ArchiveCommand ("Add to X.zip"); needs no Package.appxmanifest entry — only the root command's CLSID is ever registered there, every leaf command is instantiated internally via Make<T>(). Two shared ShellExtUtils functions gained parameters instead of new twin functions: BuildAddToArchiveTitle(paths, ext = L".zip") and BuildArchiveArgs(paths, format = L"zip") (the latter only emits --format <value> for a non-"zip" value, so the pre-existing zip command line is unchanged). On the .NET side, ShellArgumentParser.ParseArchive consumes an optional --format zip|tar pair right after --archive into a new ParsedCommand.Format (ArchiveContainerFormat, default Zip); Archiver.Shell's archive command (ShellCommands.ArchiveAsync since T-F268, via ShellServices) now constructs new ArchiveCreationRouter(new ZipArchiveService(), new TarSandboxedService()) directly (no DI container in this console entry point) instead of calling ZipArchiveService.ArchiveAsync, and sets ArchiveOptions.Format from the parsed switch. (Superseded by T-F261: the router now comes from Core's PakkoServices, built with the loaded policy.)


v1.4 — GroupPolicyOptions (T-F51, Group Policy Support)

Four registry-backed policies under HKLM\Software\Policies\Pakko\ — see POLICIES.md for the sysadmin-facing spec (value vocabulary, defaults, interaction rules) and deploy/ for the ADMX/ADML templates. This section documents the code shape only.

// Models/MotwMode.cs
public enum MotwMode { Disabled = 0, AllFiles = 1, UnsafeExtensionsOnly = 2 }

// Models/GroupPolicyOptions.cs
public sealed record GroupPolicyOptions
{
    public MotwMode MotwMode { get; init; } = MotwMode.AllFiles;
    public bool MotwModeSetByPolicy { get; init; }          // T-F360: EnforceMOTW was 0, 1 or 2
    public MotwMode EffectiveMotwMode(bool applyMark);      // policy's mode if set, else on/Disabled
    public IReadOnlyList<string>? AllowedFormats { get; init; }
    public IReadOnlyList<string>? BlockedFormats { get; init; }
    public bool DisableTarExtraction { get; init; }

    public bool IsFormatAllowed(string registryName); // BlockedFormats takes precedence over AllowedFormats
}

// Services/ArchiveDownloadMark.cs — T-F360, for the App's checkbox; never throws
public static class ArchiveDownloadMark { public static bool IsPresent(string archivePath); }

// Interfaces/IRegistryReader.cs — minimal seam, hand-rolled FakeRegistryReader in tests (no
// mocking library anywhere in this repo)
public interface IRegistryReader
{
    int? GetDword(string keyPath, string valueName);
    string[]? GetMultiString(string keyPath, string valueName);
}

// Services/Win32RegistryReader.cs — [SupportedOSPlatform("windows")], reads HKEY_LOCAL_MACHINE,
// swallows every failure (absent key, access denied, wrong type) and returns null
public sealed class Win32RegistryReader : IRegistryReader { /* ... */ }

// Services/GroupPolicyService.cs — static, never throws; absent/malformed values fall back to
// today's shipped (unrestricted) defaults
public static class GroupPolicyService
{
    [SupportedOSPlatform("windows")]
    public static GroupPolicyOptions Load();                 // real registry, used by App/Shell/CLI
    public static GroupPolicyOptions Load(IRegistryReader r); // testable overload
}

// Services/ArchiveFormatRegistryNames.cs — maps ArchiveFormat/ArchiveContainerFormat to the
// registry-string vocabulary (zip/tar/gzip/bz2/xz/zstd/lzma/rar/sevenzip) AllowedFormats/
// BlockedFormats use. ArchiveContainerFormat.TarGz maps to "gzip" — the same name
// ArchiveFormat.GZip detection uses — since the two enums don't line up 1:1.
public static class ArchiveFormatRegistryNames
{
    public static string ToRegistryName(ArchiveFormat format);
    public static string ToRegistryName(ArchiveContainerFormat format);
}

Consumer wiring — GroupPolicyOptions? policy = null was added as an optional constructor parameter (default = "everything allowed", so every pre-T-F51 new XService() call site keeps compiling) to the list below. T-F261 correction: the parameter is now required and non-null on every engine and router (ZipArchiveService, TarSandboxedService, ExtractionRouter, ArchiveCreationRouter, AntivirusScanService) — a forgotten policy is a compile error, not a fail-open default. TarSandboxedService also refuses on its own under DisableTarExtraction (Extract/List/Compress return a policy error; DetectCapabilitiesAsync returns all-false defaults without running the unsandboxed tar.exe --version probe), so the App's DI path — which does not use the factory below — is covered too. The original T-F51 consumers:

  • ZipArchiveService / TarSandboxedService — _policy.MotwMode threaded down into ArchiveEntrySecurity.TryPropagateMotw(archivePath, destFilePath, motwMode)'s new third parameter (Disabled no-ops, UnsafeExtensionsOnly checks destFilePath's extension against a fixed unsafe-extension list modeled on Windows Attachment Manager/SmartScreen). Both extractors' smart-foldering helper methods (ExtractWithSmartFolderingAsync / ExtractSingleArchiveAsync) are private static, so motwMode is threaded through as an explicit parameter rather than captured — a static local/private method cannot close over an instance field.
  • ExtractionRouter — gained a 4th ctor param; AllowedFormats/BlockedFormats are checked for every non-Unknown detected format before the existing Zip/tar-family switch, and DisableTarExtraction is checked only for the tar-family branch (both produce a SkippedFile, never a thrown exception).
  • ArchiveCreationRouter — gained a 3rd ctor param; this router had zero capability/whitelist check before T-F51, so both the format-block check and the DisableTarExtraction check (POLICIES.md documents DisableTarExtraction as blocking creation too, not just extraction) are wholly new code, returning an ArchiveResult with Success = false rather than throwing.
  • MainViewModel — gained a 6th ctor param; exposes TarFormatVisibility (Visibility.Collapsed when DisableTarExtraction), bound from MainWindow.xaml's 6 tar ComboBoxItems via Visibility="{x:Bind ViewModel.TarFormatVisibility}". SelectedContainerFormat is defensively reset to Zip in the constructor if policy disables tar — normally unreachable today since nothing else sets it away from the Zip default, kept in case a future caller (e.g. protocol activation) sets a tar format directly.

DI (Archiver.App) — services.AddSingleton(GroupPolicyService.Load());, registered before every consumer above so ActivatorUtilities injects the real instance instead of falling back to each optional parameter's null default. No DI (Archiver.Shell, Archiver.CLI) — T-F261: both call PakkoServices.Create(GroupPolicyService.Load()) once near the top of Program.cs and take every service from it (this supersedes the hand-built per-command construction shown in the T-F105/T-F09 sections above):

// Services/PakkoServices.cs
public sealed class PakkoServices
{
    public static PakkoServices Create(GroupPolicyOptions policy); // real engines, one policy
    public GroupPolicyOptions Policy { get; }
    public IArchiveService ArchiveService { get; }
    public ITarService TarService { get; }
    public IArchiveCreationRouter CreationRouter { get; }          // needs no tar.exe probe
    public Task<TarCapabilities> GetTarCapabilitiesAsync();        // probed once, cached (T-F85)
    public Task<IExtractionRouter> CreateExtractionRouterAsync();  // extract + test
    public Task<IArchiveListingRouter> CreateListingRouterAsync();
    [SupportedOSPlatform("windows")]
    public Task<IAntivirusScanService> CreateScanServiceAsync();
}

The tar.exe probe is lazy (T-F350): the three Create*Async methods no longer run it. They hand GetTarCapabilitiesAsync to an internal constructor of each router, and ArchiveFormatPolicy.ClassifyAsync awaits it only when a selection holds a tar-family archive that Group Policy allows — a ZIP-only Explorer command or pakko call never starts tar.exe. The public constructors with a ready TarCapabilities (the App's DI) are unchanged.

Correction (T-F250, decided 2026-09-25): listing is gated by Group Policy. The original T-F51 text here said ArchiveListingRouter and the CLI's i/l were deliberately left without a policy, citing ITarService.ListEntriesAsync's doc comment — but that comment is about the entry-safety pre-scan, not Group Policy, while POLICIES.md promises DisableTarExtraction never starts tar.exe and that blocked formats are not opened. Listing parses the archive with the same libarchive parser the policy exists to keep away, so ArchiveListingRouter now takes a required GroupPolicyOptions and refuses through ArchiveFormatPolicy like every other operation (App browse mode, file-association/Explorer "Open", nested drill-in, pakko l). ZipArchiveService also refuses a blocked zip on its own in TestAsync/ExtractAsync/ ListEntriesAsync — a ZIP the magic-byte detector calls Unknown (an entry-less archive, a self-extractor) reaches the ZIP engine through the routers' Unknown bucket, where no router check sees it.


v1.5 — Archiver.CLI (T-F09)

A fourth thin frontend over Archiver.Core, alongside App/Shell/ShellExtension — 7z-familiar single-letter commands (x/t/i/a/l), specified in full in CLI.md. Ships as a separate, standalone, self-contained downloadable artifact (see CLI.md's "Distribution" section and scripts/README.md) — it does not require the MSIX/GUI to be installed. Since T-F317 (v1.7.0) the MSIX also carries the same pakko.exe as a hidden <Application Id="Cli"> with the pakko.exe execution alias (Archiver.App.csproj's Content Include, built by Deploy.ps1/CI-Build-Msix.ps1). All four exes are Native AOT (T-F355): each is one native file with the runtime compiled in, and the package carries no .NET runtime of its own.

No DI container — mirrors Archiver.Shell/Program.cs's pattern exactly (T-F261: every command takes its services from Core's PakkoServices, built once with the loaded policy), not Archiver.App's ServiceCollection. GroupPolicyService.Load() is called once near the top of Program.cs and threaded through explicitly (T-F51) — see that section below for why this project no longer has "nothing to inject" for these two services.

CliArgumentParser.Parse(string[]) (in Archiver.CLI/CliArgumentParser.cs) never throws — mirrors ShellArgumentParser's shape:

public enum CliCommandType { Extract, Test, Info, Archive, List, Help, Invalid }

public sealed record ParsedCliCommand
{
    public CliCommandType Type { get; init; }
    public IReadOnlyList<string> ArchivePaths { get; init; } = [];   // x, t, l
    public IReadOnlyList<string> SourcePaths { get; init; } = [];    // a
    public string? ArchivePathArg { get; init; }                     // a: raw positional[0]
    public string? OutputDirectory { get; init; }                    // -o{dir}, x only
    public bool AssumeYes { get; init; }                              // -y
    public ConflictBehavior? OverwriteMode { get; init; }             // -ao{a|s|u}, x only
    public ArchiveContainerFormat ArchiveFormat { get; init; } = ArchiveContainerFormat.Zip; // a
    public CompressionLevel? CompressionLevel { get; init; }          // -mx=N, a only
    public string? ErrorMessage { get; init; }
}

Per-command allowed-switch enforcement lives inside the parser itself — any switch token not on a command's own allowed list is rejected with CLI.md's three-way rule (case 1: unparseable token; case 2: a real 7z command Pakko deliberately doesn't implement, e.g. u/d/rn/b/e; case 3: a real, supported command with an unsupported switch). Never a silent no-op.

Command → Core API mapping:

Command Core API Notes
x IExtractionRouter.ExtractAsync ExtractMode.SingleFolder; OnConflict = -ao ?? (-y ? Overwrite : Skip); ConfirmCompressionBombExtraction set only when -y
t IExtractionRouter.TestAsync (T-F261; ZIP-only testing — ITarService has no Test method) with verifyRecoveryData (T-F275), except for -si tar-family paths become SkippedFiles with a named reason, not silently dropped, unless a PAR2 set next to them checks them; Group Policy applies
i PakkoServices.GetTarCapabilitiesAsync + ArchiveFormatPolicy each line's status (supported / not supported / blocked by Group Policy) comes from the shared classifier; no probe under DisableTarExtraction
a IArchiveCreationRouter.ArchiveAsync always ArchiveMode.SingleArchive; ArchiveNaming.GetBaseName derives the name
l IArchiveListingRouter.ListEntriesAsync looped once per archive path (the router itself takes one path at a time)

-y wiring: ArchiveOptions/ExtractOptions already default to Skip/auto-decline when their callback delegates are null — the CLI needs to do nothing special in -y's absence. -y only overrides those defaults (ConfirmCompressionBombExtraction → always-confirm, OnConflict → Overwrite), and an explicit -ao always wins over -y when both are given.

-mx bucketing (CliCompressionLevelMapper.TryMap(int)), documented rather than a naive /9*4 approximation:

-mx CompressionLevel
0 NoCompression
1–2 Fastest
3–6 Optimal (7z's own default -mx5 lands here, matching ArchiveOptions' own default)
7–9 SmallestSize

Exit codes (7z-familiar, documented in --help/CLI.md):

Code Meaning
0 Success, nothing skipped
1 Success, but SkippedFiles or KeptExistingFiles were present (e.g. a conflict/bomb declined without -y, or t hit a tar-family archive), a landed under another name than asked (T-F325), or a warning was printed (pakko: warning: <archive>: <text> on stderr, T-F280 — 7-Zip's code for a warning)
2 Operation failed (ArchiveResult.Success == false, listing failed, or real Test corruption)
7 Command-line error — any of the three three-way-rule categories, distinguished by stderr text, not exit code

Test project Archiver.CLI.Tests has two layers: parser/mapper/help-text/formatter unit tests (no process spawn), and a new Subprocess/ layer that Process.Starts the real built pakko.exe (the Archiver.CLI project's AssemblyName) against real fixtures and asserts real exit codes/stdout/stderr — see TESTING.md. Distribution (scripts/Publish-Cli.ps1) is documented in scripts/README.md.

-si/-so stdin/stdout streaming (T-F116): ParsedCliCommand gained ReadFromStdin/ WriteToStdout bools. -si (valid on x/t/l) and -so (valid on x/a) are implemented entirely inside Archiver.CLI/CliStreamStaging.cs — zero Archiver.Core changes. -si copies Console.OpenStandardInput() into a private %TEMP%\Archiver.CLI.Stdin\<pid>-<guid>\stdin.bin file before the command runs, then proceeds exactly as if that path had been typed; -so runs the operation into a private %TEMP%\Archiver.CLI.Stdout\<pid>-<guid>\ folder instead of the real destination, then streams the single resulting file to Console.OpenStandardOutput() (CliStreamStaging.StreamSingleFileAsync takes the destination Stream as a parameter specifically so the broken-pipe path is unit-testable without a real OS pipe). Both staging locations are owned from creation by a disposable CliStagingFolder (T-F244: a failed or cancelled copy removes its folder), and every x/t/l/a run first sweeps folders left by a dead process (PID gone, or reused by a later process). Rejected out of scope: true zero-copy streaming through Core — ZipArchive needs a seekable stream to read its central directory, TarSandboxedService's T-F49 pre-scan needs a real file to scan before extraction runs, and SandboxedProcessLauncher has no stdin-redirection plumbing — see DECISIONS.md's T-F116 entry, which also records the empirical finding that native PowerShell 5.1 (not just old cmd.exe) silently corrupts binary data piped between two native executables, while cmd /c "..." does not, on any PowerShell version.

-p{pwd} password support (T-F191): ParsedCliCommand gained Password (valid on x/t, and on a since T-F193 — l needs no password) and, in T-F193, PromptForPassword (a bare -p). Program.cs's BuildPasswordResolver(ParsedCliCommand, bool assumeYes) picks one of three ExtractOptions.ResolvePasswordAsync/IArchiveService.TestAsync's resolvePasswordAsync shapes: -p given → try it once, printing a specific "incorrect password" line on a second call (Core's PasswordResolver collapses never-wired/cancelled/exhausted-attempts into the same null result, so the CLI has to add this distinction itself — see DECISIONS.md); no -p and Console.IsInputRedirected/-y → null (no resolver at all), preserving the exact pre-T-F191 rejection message, which also covers -si since stdin is already consumed by the piped archive bytes; no -p and a real interactive console → a masked prompt. The masked-input editing logic itself lives in a separate, directly unit-tested class, CliPasswordPrompt.Read(Func<ConsoleKeyInfo> readKey, Action<char>? echo = null) — Program.cs only supplies the real Console.ReadKey/ Console.Error.Write glue, since the Subprocess/ test layer always redirects the built exe's stdin and can never reach this path end-to-end.

a -p (T-F193): RunArchiveAsync checks a fixed -p<pwd> against EncryptionPasswordRule before any work (exit 7). A bare -p calls CliPasswordPrompt.ReadNewPassword (enter + re-enter, rule checked after the first entry, never re-asked); its NewPasswordResult — not Core's generic English error — decides the report: cancelled → 255, refused/mismatch → 2. A bare -p with redirected stdin exits 7 before dispatch (RejectBarePasswordWithoutConsole).


FileItem Model (UI layer)

// Archiver.App.Core/FileItem.cs (moved from Archiver.App/Models, T-F232)
// CommunityToolkit.Mvvm ObservableObject, not plain mutable auto-properties — Size/SizeBytes/
// Crc32Display/Crc32 are all [ObservableProperty] source-generated fields (real property names
// Size/SizeBytes/Crc32Display/Crc32, backing fields _size/_sizeBytes/_crc32Display/_crc32), so
// LoadFolderSizeAsync/LoadCrc32Async's writes to them raise INotifyPropertyChanged for free.
public sealed partial class FileItem : ObservableObject
{
    public string FullPath { get; }
    public string Name { get; }
    public string Type { get; }              // extension uppercase or "Folder"
    public DateTime Modified { get; }
    public string ModifiedDisplay { get; }   // "yyyy-MM-dd HH:mm"

    [ObservableProperty] private string _size = "...";       // "1.2 MB", "345 KB", "12 bytes"
    [ObservableProperty] private long _sizeBytes = -1;
    [ObservableProperty] private string _crc32Display = "";  // "..." while computing, "?" on read error, hex once done, empty for folders
    [ObservableProperty] private uint? _crc32;

    // Created only via TryCreate: null for a path that cannot be read (missing, no access,
    // invalid), so MainViewModel.AddPaths skips and logs it instead of losing the whole list.
    public static FileItem? TryCreate(string path);
    public static string FormatSize(long bytes);

    // The private constructor also starts LoadFolderSizeAsync (folders) or LoadCrc32Async
    // (files) — both async, fire-and-forget, throttled via a shared static SemaphoreSlim(4).
}

Crc32/Crc32Display (added alongside a pending-list "CRC-32" column, per user request) mirror the existing Size/SizeBytes async-load pattern (LoadFolderSizeAsync) but in reverse: size is async only for folders (walking the tree), CRC is async only for files (folders have no single meaningful CRC to aggregate, so LoadCrc32Async is never started for one). Computation reuses Archiver.Core.IO.Crc32.Compute(Stream) — made public (was internal, previously only used by ZipArchiveService's own integrity check) rather than adding a NuGet hashing package to Archiver.App or reimplementing the algorithm a second time. A static readonly SemaphoreSlim (capacity 4) throttles concurrent CRC reads across every FileItem instance — reading a whole file's bytes is real disk I/O, and queuing many/large files at once (loose files added individually, not one collapsed folder row) would otherwise spawn an unbounded number of concurrent Task.Run reads. No cancellation if an item is removed mid-read — same tradeoff LoadFolderSizeAsync already accepts.

v1.4 — IAntivirusScanService (T-F146, AMSI-based threat scanning)

A standalone service, deliberately not an IExtractionRouter-style extension of IArchiveService/ITarService — a scan never writes to a real destination and has no conflict/MOTW dimension, and adding a method to those two interfaces would ripple through every hand-rolled test fake in the repo (no mocking library is used anywhere) for a capability that isn't a variant of extraction. See docs/DECISIONS.md's T-F146 entry for the full option-space writeup (AMSI vs. MpCmdRun.exe/WMI) and the empirical Phase 0 findings this design rests on.

// Interfaces/IAntivirusScanService.cs
public interface IAntivirusScanService
{
    Task<ThreatScanResult> ScanAsync(
        AntivirusScanOptions options,
        IProgress<ProgressReport>? progress = null,
        CancellationToken cancellationToken = default);
}
// Models/ThreatScanResult.cs
public enum ThreatVerdict { Clean, ThreatDetected, Inconclusive }

public sealed record ThreatFinding
{
    public required string ArchivePath { get; init; }
    public string? EntryPath { get; init; }          // null = whole-archive-level finding
    public required ThreatVerdict Verdict { get; init; }
    public string? ThreatName { get; init; }          // AMSI never actually returns one
    public string? Reason { get; init; }              // set for Inconclusive
}

public sealed record ThreatScanResult
{
    public required ThreatVerdict OverallVerdict { get; init; }
    public IReadOnlyList<ThreatFinding> Findings { get; init; } = [];
}

Named ThreatVerdict/ThreatFinding/ThreatScanResult (not Scan*) deliberately — TarSandboxedService.ScanForUnsafeEntriesAsync already owns "Scan" for the unrelated T-F49 traversal/symlink pre-scan; keeping the two grep-separable avoids confusing them.

Implementation (Archiver.Core/Services/AntivirusScanService.cs) dispatches each archive path to one of two independent scan flows via ArchiveFormatPolicy.Classify (see below):

  • ZIP — in-process, no disk writes at all. Opens the archive via the same System.IO.Compression.ZipFile.OpenRead ZipArchiveService itself uses, reads each (optionally SelectedEntryPaths-filtered) entry's bytes into a rented buffer, and calls IAmsiScanner.ScanBuffer directly — no quarantine, no temp files. T-F194: AntivirusScanOptions.ResolvePasswordAsync (same type as ExtractOptions.ResolvePasswordAsync) lets an encrypted entry be decrypted in memory via EncryptedZipEntryReader and its plaintext scanned; the password is resolved once per archive through ZipArchiveService's own ResolveArchivePasswordAsync (now internal). No password → that entry is Inconclusive, never Clean; a decrypted entry is only reported Clean if its stream reaches a verified end (CRC).
  • tar-family — reuses TarSandboxScope/T-F49's whole-archive pre-scan and T-F52's AppContainer sandbox exactly as a real Extract would (TarSandboxedService. ScanForUnsafeEntriesAsync/ExpandSelection/EnumerateFilesGuarded were bumped from private to internal for this reuse, zero external API change), extracting into TarSandboxScope.OutputDirectory — but stops there. No move-to-destination phase ever runs; using (scope) guarantees the quarantine directory is deleted in every case (clean, threat, or a mid-scan failure).

Before scanning anything, Archiver.Core/Services/Antivirus/AmsiProviderCheck. IsAnyProviderRegistered() checks HKLM\SOFTWARE\Microsoft\AMSI\Providers (readable non-elevated, same Microsoft.Win32.Registry access GroupPolicyService/Win32RegistryReader already established) — AmsiScanBuffer alone can't distinguish "no AV is listening" from "AV says clean" (both return AMSI_RESULT_NOT_DETECTED), so an empty key forces every finding to Inconclusive without ever calling AmsiScanBuffer. A 64 MiB per-entry cap (AntivirusScanService. MaxScannableEntryBytes) also forces Inconclusive for anything too large to buffer — a deliberately conservative first-pass constant, not AMSI's own documented limit (none is published); see docs/DECISIONS.md's "Known limitation" note on the deferred MpCmdRun.exe/ chunked-scan fallback for oversized entries.

Archiver.Core/Services/Antivirus/AmsiScanner.cs is the real P/Invoke wrapper (amsi.dll's AmsiInitialize/AmsiOpenSession/AmsiScanBuffer/AmsiCloseSession/AmsiUninitialize), one instance = one AMSI session reused across every buffer in a single ScanAsync call (never called concurrently — AMSI session thread-safety is undocumented). IAmsiScanner is an internal seam (Archiver.Core.Tests/Archiver.Core.IntegrationTests both have InternalsVisibleTo) so AntivirusScanService's orchestration logic (subset filtering, size-cap skip, provider-empty gate, tar per-file-failure handling) is unit-testable via a hand-rolled FakeAmsiScanner without depending on the test machine's actual registered AV.

Archiver.Core/Services/ArchiveFormatPolicy.cs is a small shared helper extracted from ExtractionRouter's own per-path classify loop (zip/tar/unsupported split + BlockedFormats/ AllowedFormats/DisableTarExtraction Group Policy gating) — used by both ExtractionRouter and AntivirusScanService now, so a scan can never silently drift from what real extraction would allow or refuse (a tar-family scan spawns tar.exe in the same AppContainer a real extraction does, so it must be gated identically). Behavior-preserving refactor — ExtractionRouterTests stayed green unmodified. T-F261: now public and the single classifier for every operation — ExtractionRouter (extract and test), ArchiveListingRouter, AntivirusScanService and the CLI's i all use Classify/GetRefusalReason/IsBlockedByPolicy; the listing router's private copy of the capability table is gone. Policy is checked before tar.exe capability, so a refusal under DisableTarExtraction always names the policy. Scan opens TarSandboxScope directly (not through ITarService), so for scan this classifier is the only tar gate.

DI registration adds:

services.AddSingleton<IAntivirusScanService, AntivirusScanService>();

Frontends. Archiver.Shell/Program.cs gained a --scan CLI switch (ShellArgumentParser.CommandType.Scan) and a scan command with its own result text — a ThreatScanResult is a genuine three-state result, not a success/failure ArchiveResult. Since T-F268 that is ShellCommands.ScanAsync + OperationMessages.ForScan, sharing the one IOperationUi session (progress, cancel poll, password prompt) with every other command. Archiver.ShellExtension gained a ScanCommand leaf IExplorerCommand, gated on AnyPathIsSupportedArchive (not AnyPathIsZip like TestCommand — T-F86's ZIP-only reasoning for Test doesn't apply here, since the scan path genuinely supports tar-family via the quarantine flow), registered in PakkoRootCommand:: EnumSubCommands right after TestCommand. Archiver.App's MainViewModel gained ScanArchiveFromBrowserCommand — one combined button (scans the current SelectedBrowserEntries selection if any, else the whole BrowsedArchivePath archive — deliberately not two separate Selected/All buttons, to keep the Archive Browser's command row from getting crowded for a lower-frequency diagnostic action) — and IDialogService gained ShowThreatScanResultAsync, grouping ThreatDetected/Inconclusive findings into two sections mirroring ShowOperationSummaryAsync's existing Errors/SkippedFiles pattern. Clean-result copy is deliberately "No threats found in this archive" everywhere, never "safe" — Pakko doesn't recurse into nested archives and can't make that broader claim.