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 andArchiver.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.Runwraps 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-pathDirectory.Move(tempDest, actualDest)(used whenactualDestdoesn't already exist) falls back to the existing per-file merge-and-delete path onIOException, since Windows fails the wholeDirectory.Moveif any single file anywhere in the source tree is transiently locked by another process (confirmed empirically — seeDECISIONS.md's T-F161 entry).ExtractWithSmartFolderingAsync's entry-extraction loop also now cleans uptempDeston any exception, not justOperationCanceledException.
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()outsideConfigureServices() - Never access
App.Servicesfrom insideArchiver.Core Archiver.Corehas 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 viaIExplorerCommand::EnumSubCommands— not separately registered in the manifest - Selection logic in
EnumSubCommands(order mirrors NanaZip's realContextMenu.cpp:BrowseCommand("Open") first, mirroring NanaZip's ownkOpen-before-kExtractorder (T-F03) — a separate, coexisting command, not a replacement forExtractDialogCommand; dialog command before its one-click sibling in each group;TestCommandalways last per Pakko's own primary-actions-before-diagnostic rule,CLAUDE.md):BrowseCommandshown only for a single-item selection that's a supported archive (paths.size() == 1 && AllPathsAreSupportedArchive);ExtractDialogCommand/TestCommandshown whenever selection contains ≥1.zip(AnyPathIsZip);ExtractHereCommand/ExtractFolderCommandshown only when all paths are.zip(AllPathsAreZip);CompressDialogCommandalways shown;ArchiveCommandshown unless all paths are.zip InvokelaunchesArchiver.Shell.exeviaCreateProcesswith the correct argument set — dialog commands (T-F63) use--open-ui --extract/--open-ui --archiveto route through a Launch activation ofArchiver.App(AppLauncher, T-F232) instead of running silently- Registered via
com:SurrogateServerinPackage.appxmanifest—com:Pathmust be a child element of the server, not aPathattribute oncom:Class(seeDECISIONS.md); requiresMinVersion="10.0.18362.0"(Windows 10 1903) or higher inTargetDeviceFamily - Rejected alternative: out-of-process COM EXE server inside
Archiver.Shell.exe— an in-process DLL has lower latency and needs noLocalServer32infrastructure; seeDECISIONS.mdfor 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/-tvfreject 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 byZipArchiveService, so this checklist can't drift between the two extractors - MOTW propagation: copies
Zone.IdentifierADS from archive to each extracted file (also viaArchiveEntrySecurity)
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 itsAppContainer\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--versionprobe — goes throughSandboxedProcessLauncherWideCharToMultiByte(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 archiveReOpenFile— 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 managedX509Certificate2— that only extracts the embedded cert without verifying it against the file's actual bytes)
Flow (TarSandboxScope.CreateAsync + ListAsync/ExtractAsync):
- Verify
tar.exe's Authenticode signature (Microsoft Organization) once per scope - 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)
- Ensure the AppContainer profile exists (lazy, once, never deleted)
- 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, soFILE_TRAVERSEis enforced on every ancestor directory — seeDECISIONS.md's T-F52 entry) — without\if the scope extracts - 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 freshReOpenFilehandle as stdin, the quarantine root as current directory,-f -and (once decided)--options tar:hdrcharset=UTF-8 - 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) - 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 — seeDECISIONS.md's T-F52 entry) - After the process exits, move each file from
out\into anExtractionStagingfolder on the destination's volume (conflicts, MOTW from the archive the user chose), then commit it withCommitInto— the same commit ZIP extraction uses (T-F263), so a cancel leaves no partial files - 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.MotwModethreaded down intoArchiveEntrySecurity.TryPropagateMotw(archivePath, destFilePath, motwMode)'s new third parameter (Disabledno-ops,UnsafeExtensionsOnlychecksdestFilePath's extension against a fixed unsafe-extension list modeled on Windows Attachment Manager/SmartScreen). Both extractors' smart-foldering helper methods (ExtractWithSmartFolderingAsync/ExtractSingleArchiveAsync) areprivate static, somotwModeis threaded through as an explicit parameter rather than captured — astaticlocal/private method cannot close over an instance field.ExtractionRouter— gained a 4th ctor param;AllowedFormats/BlockedFormatsare checked for every non-Unknowndetected format before the existing Zip/tar-family switch, andDisableTarExtractionis checked only for the tar-family branch (both produce aSkippedFile, 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 theDisableTarExtractioncheck (POLICIES.md documentsDisableTarExtractionas blocking creation too, not just extraction) are wholly new code, returning anArchiveResultwithSuccess = falserather than throwing.MainViewModel— gained a 6th ctor param; exposesTarFormatVisibility(Visibility.CollapsedwhenDisableTarExtraction), bound fromMainWindow.xaml's 6 tarComboBoxItems viaVisibility="{x:Bind ViewModel.TarFormatVisibility}".SelectedContainerFormatis defensively reset toZipin the constructor if policy disables tar — normally unreachable today since nothing else sets it away from theZipdefault, 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.OpenReadZipArchiveServiceitself uses, reads each (optionallySelectedEntryPaths-filtered) entry's bytes into a rented buffer, and callsIAmsiScanner.ScanBufferdirectly — no quarantine, no temp files. T-F194:AntivirusScanOptions.ResolvePasswordAsync(same type asExtractOptions.ResolvePasswordAsync) lets an encrypted entry be decrypted in memory viaEncryptedZipEntryReaderand its plaintext scanned; the password is resolved once per archive throughZipArchiveService's ownResolveArchivePasswordAsync(nowinternal). No password → that entry isInconclusive, neverClean; a decrypted entry is only reportedCleanif 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/EnumerateFilesGuardedwere bumped fromprivatetointernalfor this reuse, zero external API change), extracting intoTarSandboxScope.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.