diff --git a/CHANGELOG.md b/CHANGELOG.md index 26dbddf..e15ef8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,22 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- -## [Unreleased] +## [0.1.3] — 2026-05-03 -### Symbol packaging (planned 0.1.2) -- **`portable`** instead of `embedded`. The previous combination produced an empty `.snupkg` (no `.pdb` files because debug info was inside the `.dll`), which nuget.org rejects with HTTP 400. The published `.nupkg` is now slightly smaller, and the `.snupkg` actually contains symbols so consumers debugging into the library get sources via nuget.org's symbol server. +### Added + +- **`SelfUpdaterOptions.PreservePaths`.** Glob list (`appsettings.Development.json`, `appsettings.*.json`, `data/**`, `*.db`, …) telling the installer which top-level entries in the install directory belong to the user, not the package. Matched entries are skipped in Phase 1 (not moved into `.old/`) and don't get clobbered by a new release in Phase 2. Defaults to empty — current consumers get unchanged behaviour until they opt in. +- **Per-conflict resolver.** When a new release ships an entry whose path matches a `PreservePaths` pattern, `ISelfUpdater.InstallAsync` and `IUpdateInstaller.InstallAsync` accept an optional `Func>?` resolver. `null` (default) keeps the user's file. Headless callers can return a constant; interactive callers can prompt per file. New `UpdateConflict` record carries `RelativePath`, `ExistingSizeBytes`, `NewSizeBytes`. +- **`update --strategy ask|keep|new`.** New flag on `UpdateCommand`. With `--yes`, defaults to `keep` so updates never block on a prompt. Without `--yes`, defaults to `ask` and uses Spectre's `Confirm` per conflict. + +### Changed + +- Layered config support is documented in the README: end-user CLIs can read additional `PreservePaths` entries from `appsettings*.json` via `IConfiguration` and merge with the in-code list — no new package API needed. + +--- + +## [0.1.2] — 2026-05-03 + +### Fixed + +- **Symbol package now actually contains symbols.** The previous combo (`true` + `snupkg` + `embedded`) produced an empty `.snupkg` because debug info was embedded inside the `.dll`; nuget.org rejects empty symbol packages with HTTP 400. v0.1.1's `.nupkg` was published successfully but the symbol upload failed. Switching to `portable` produces a real `.pdb` next to the `.dll`; the `.snupkg` now ships it; nuget.org accepts the symbol upload; consumers debugging into the library get sources via the nuget.org symbol server. Same fix landed across all four sibling repos (Splash 0.1.2, Auth 0.6.2, Auth.Providers 0.2.2 / 0.2.2 / 0.3.2). + +--- + +## [0.1.1] — 2026-05-03 + +Coordinated patch driven by an external code review. + +### Security -### Security & correctness fixes (planned 0.1.1) - **Lock before staging mutation.** `UpdateInstaller.InstallAsync` now acquires `.update.lock` before any change to `.update//`. Previously a second installer could wipe a first installer's in-flight staging directory on its way to losing the lock race. -- **Rollback on swap failure.** A copy failure mid-swap now restores the install directory from `.old/` instead of leaving it half-populated. New `UpdateInstaller.RestoreFromOld` helper. - **Asset-name validation.** New `UpdateInstaller.ValidateAssetName` rejects path separators, parent references, rooted paths, and any name whose `Path.GetFileName` doesn't round-trip — closing a path-traversal vector for malicious or misconfigured sources. -- **HTTPS enforcement in `HttpManifestSource`.** Plain-HTTP manifest URLs and asset URLs are now rejected by default. Opt in via `SelfUpdaterOptions.AllowInsecureManifestSource = true` (tests, internal mirrors, local dev). Plain HTTP defeats SHA-256 verification because the SHA itself is MITM-able. +- **HTTPS enforcement in `HttpManifestSource`.** Plain-HTTP manifest URLs and asset URLs are now rejected by default. Opt in via `SelfUpdaterOptions.AllowInsecureManifestSource = true` for tests, internal mirrors on a trusted network, and local development. Plain HTTP defeats SHA-256 verification because the SHA itself is MITM-able. + +### Fixed + +- **Rollback on swap failure.** A copy failure mid-swap now restores the install directory from `.old/` instead of leaving it half-populated. New `UpdateInstaller.RestoreFromOld` helper. - **TOCTOU-safe install path.** `UpdateCommand` now fetches the release once and installs that exact instance — no second source query between display and install. New `ISelfUpdater.GetLatestReleaseAsync()` and `ISelfUpdater.InstallAsync(RemoteRelease, ...)` overloads. The parameterless `InstallAsync` is kept as a convenience for non-interactive consumers (TOCTOU window documented). -### Build hygiene +### Changed + - `` is now Release-only; ordinary `dotnet build` and `dotnet test` no longer produce `.nupkg` files. Output path moved from `C:\nuget-local\` to `$(MSBuildThisFileDirectory)..\..\artifacts\packages` — platform-neutral, repo-local, and gitignored. +- Test-suite count: 86 → 150. + +--- + +## [0.1.0] — 2026-05-03 + +Initial commit. Never published to nuget.org — superseded by 0.1.1 before the first tag was cut. + +### Added — initial public release -### Added — initial public release (planned 0.1.0) - **Pluggable update sources** — `IUpdateSource` contract with three built-in implementations: - `HttpGitHubReleaseSource` (default) — public GitHub Releases via HttpClient. - `GhCliReleaseSource` — private GitHub repos via the `gh` CLI. @@ -36,4 +70,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Configurable cache, timeouts, opt-out env var, dev-build skip predicate.** - DI wiring via `ServiceCollectionExtensions.AddSelfUpdater(...)`. - Full XML documentation on the public surface, `TreatWarningsAsErrors=true`, `AnalysisLevel=latest`. -- SourceLink, deterministic builds, embedded symbols, published symbol packages. +- SourceLink, deterministic builds, published symbol packages. + +[0.1.3]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.3 +[0.1.2]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.2 +[0.1.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.1 diff --git a/README.md b/README.md index 73fe553..78e51e4 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,98 @@ This avoids the "EXE is locked while running" problem on Windows without a separ --- +## Preserving user files across updates + +By default the installer treats every entry in the install directory as +package-owned: anything that's not in the new release ends up in `.old/` +and gets cleaned up on next startup. That's wrong for files the *user* +placed there — `appsettings.Development.json`, a local SQLite database, +a `data/` folder, scratch notes, plugins. + +`SelfUpdaterOptions.PreservePaths` is a list of glob patterns identifying +top-level entries the installer should leave alone: + +```csharp +services.AddSelfUpdater(opts => +{ + opts.AppName = "myapp"; + opts.UseGitHubReleases("acme/myapp"); + + opts.PreservePaths = new[] + { + "appsettings.Development.json", // exact filename + "appsettings.*.json", // glob over top-level files + "data/**", // whole top-level directory + "*.db", // sqlite or similar + }; +}); +``` + +Patterns match the **top-level entry name** (the part before the first +`/` in the pattern). `data/**` and `data/seed.json` both preserve the +whole `data/` directory; nested-only preservation isn't supported in +v0.1.x. + +### Letting end users extend the list via `appsettings.json` + +The `PreservePaths` property is a plain `IReadOnlyList`, so +consumers can populate it from `IConfiguration` and merge with their +in-code defaults: + +```csharp +var fromConfig = configuration + .GetSection("SelfUpdate:Preserve") + .Get() ?? Array.Empty(); + +services.AddSelfUpdater(opts => +{ + opts.AppName = "myapp"; + opts.UseGitHubReleases("acme/myapp"); + opts.PreservePaths = new[] + { + "appsettings.Development.json", + "data/**", + }.Concat(fromConfig).ToArray(); +}); +``` + +End users can then drop their own paths into `appsettings.json` (or +`appsettings.Development.json` next to the binary) without recompiling +the CLI: + +```json +{ + "SelfUpdate": { + "Preserve": [ "my-custom-plugins/**", "notes.md" ] + } +} +``` + +### Conflict resolution when a release ships a preserved path + +If a new release publishes an entry whose path matches one of the +preserve patterns, the installer asks the resolver passed to +`InstallAsync` what to do: + +```csharp +await selfUpdater.InstallAsync( + release, + progress: null, + onConflict: (conflict, ct) => + { + // conflict.RelativePath, conflict.ExistingSizeBytes, conflict.NewSizeBytes + return Task.FromResult(UpdateConflictResolution.KeepExisting); + }); +``` + +Default (no resolver passed) is `KeepExisting` — the user's file wins. +The drop-in `update` command exposes a `--strategy ask|keep|new` flag: +with `--yes` it defaults to `keep` (so unattended runs never block on a +prompt); without `--yes` it defaults to `ask` and prompts the user +per file via Spectre's `Confirm`. + +--- + ## Configuration reference | Property | Default | Description | @@ -214,6 +306,8 @@ This avoids the "EXE is locked while running" problem on Windows without a separ | `SkipVersionPredicate` | `v => v == "1.0.0"` | Returns true to suppress the check (for unstamped dev builds). | | `GitHubToken` | `null` (then `GITHUB_TOKEN`/`GH_TOKEN` env) | Optional bearer token for `HttpGitHubReleaseSource`. | | `UseDefaultSha256Verifier` | `true` | Whether to register the SHA-256 verifier automatically. | +| `AllowInsecureManifestSource` | `false` | When true, `HttpManifestSource` accepts `http://` URLs. Tests / internal mirrors only — plain HTTP defeats SHA-256 verification. | +| `PreservePaths` | `[]` | Glob patterns identifying top-level entries the installer must leave alone (`appsettings.Development.json`, `data/**`, `*.db`, …). See [Preserving user files](#preserving-user-files-across-updates). | --- diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs index e4f0cfa..ebafe23 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs @@ -31,8 +31,21 @@ public sealed class Settings : CommandSettings [CommandOption("--force")] [Description("Reinstall even if already on the latest version.")] public bool Force { get; init; } + + /// + /// How to resolve conflicts where a new release ships an entry whose + /// path matches one of SelfUpdaterOptions.PreservePaths: + /// ask prompts per file, keep always keeps the user's, + /// new always uses the new release's. Default ask when + /// running interactively (no --yes), keep with --yes. + /// + [CommandOption("--strategy")] + [Description("Conflict strategy when a release ships a preserved path: ask | keep | new.")] + public string? Strategy { get; init; } } + private enum ConflictStrategy { Ask, Keep, New } + private readonly ISelfUpdater _selfUpdater; private readonly IUpdateChecker _checker; private readonly IUpdateInstaller _installer; @@ -99,13 +112,16 @@ protected override async Task ExecuteAsync(CommandContext context, Settings return 2; } + var strategy = ResolveStrategy(settings); + var conflictResolver = BuildConflictResolver(strategy); + try { await _console.Status().StartAsync("Updating…", async statusContext => { var progress = new Progress(evt => statusContext.Status(StageLabel(evt.Stage))); - await _selfUpdater.InstallAsync(release, progress, cancellationToken).ConfigureAwait(false); + await _selfUpdater.InstallAsync(release, progress, conflictResolver, cancellationToken).ConfigureAwait(false); }).ConfigureAwait(false); } catch (UpdateException ex) @@ -126,6 +142,47 @@ private bool PromptToContinue(string tag) defaultValue: true); } + private static ConflictStrategy ResolveStrategy(Settings settings) + { + if (string.IsNullOrWhiteSpace(settings.Strategy)) + { + // Non-interactive runs (--yes) default to keep so updates + // never block on a prompt. Interactive runs default to ask + // so the user gets a real chance to make a decision. + return settings.Yes ? ConflictStrategy.Keep : ConflictStrategy.Ask; + } + return settings.Strategy.ToLowerInvariant() switch + { + "ask" => ConflictStrategy.Ask, + "keep" => ConflictStrategy.Keep, + "new" => ConflictStrategy.New, + _ => throw new InvalidOperationException( + $"Unknown --strategy value '{settings.Strategy}'. Expected one of: ask, keep, new."), + }; + } + + private Func>? BuildConflictResolver(ConflictStrategy strategy) => + strategy switch + { + ConflictStrategy.Keep => null, // null is the documented "KeepExisting" default + ConflictStrategy.New => (_, _) => Task.FromResult(UpdateConflictResolution.UseNew), + ConflictStrategy.Ask => PromptForConflictAsync, + _ => null, + }; + + private Task PromptForConflictAsync(UpdateConflict conflict, CancellationToken ct) + { + _console.MarkupLineInterpolated(CultureInfo.InvariantCulture, + $"[yellow]Preserved path conflict:[/] [bold]{Markup.Escape(conflict.RelativePath)}[/]"); + if (conflict.ExistingSizeBytes is { } ex && conflict.NewSizeBytes is { } nu) + { + _console.MarkupLineInterpolated(CultureInfo.InvariantCulture, + $" yours: {ex} bytes → new: {nu} bytes"); + } + var keep = _console.Confirm("Keep your existing file?", defaultValue: true); + return Task.FromResult(keep ? UpdateConflictResolution.KeepExisting : UpdateConflictResolution.UseNew); + } + private static bool IsUpdateAvailable(string current, string latestTag) { // Defer to the same comparator the checker uses so behaviour is diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs b/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs index 4a101cf..5140bb3 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs @@ -19,7 +19,7 @@ public interface ISelfUpdater /// latest release on the configured channel. Returns /// when nothing is available. Use this when /// you want to display "what would be installed?" to the user - /// before calling + /// before calling /// — the same release instance can be passed to install so the /// displayed and installed versions are guaranteed to match (no /// TOCTOU window between display and install). @@ -34,9 +34,18 @@ public interface ISelfUpdater /// user a specific release — passing it back avoids the second /// source query the parameterless overload performs. /// + /// The release to install. + /// Optional progress sink for stage-level events. + /// + /// Optional resolver invoked when a new release entry lands on a + /// path covered by . + /// (default) keeps the user's existing file. + /// + /// Cancellation token. Task InstallAsync( RemoteRelease release, IProgress? progress = null, + Func>? onConflict = null, CancellationToken ct = default); /// @@ -47,7 +56,7 @@ Task InstallAsync( /// release returned by the source here may differ from the one a /// prior reported. For interactive UIs, /// prefer the + - /// + /// /// pair so the user confirms exactly the release that gets /// installed. /// diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs index 382f079..490985c 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs @@ -21,10 +21,21 @@ public interface IUpdateInstaller /// /// The resolved release to install. /// Optional progress sink for stage-level events. + /// + /// Optional resolver invoked when a new release entry lands on a + /// path covered by . + /// (default) means + /// — the user's + /// file is left in place and the new release's copy is discarded + /// for that path. Resolvers are called once per conflicting entry, + /// in deterministic order, and may return different decisions per + /// entry. + /// /// Cancellation token. Task InstallAsync( RemoteRelease release, IProgress? progress = null, + Func>? onConflict = null, CancellationToken ct = default); /// diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj index a8fd752..60967fa 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj +++ b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj @@ -11,7 +11,7 @@ NextIteration.SpectreConsole.SelfUpdate - 0.1.2 + 0.1.3 Stuart Meeks Self-update for Spectre.Console CLIs: pluggable update sources (GitHub Releases over HTTP, GitHub Releases via gh CLI for private repos, generic HTTPS manifest, custom), SHA-256 verification, atomic file swap, and a drop-in `update` command. true diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs index c7a2d4a..f4aa2c3 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs @@ -36,10 +36,14 @@ public SelfUpdater( public Task GetLatestReleaseAsync(CancellationToken ct = default) => _source.GetLatestAsync(_options.Channel, ct); - public Task InstallAsync(RemoteRelease release, IProgress? progress = null, CancellationToken ct = default) + public Task InstallAsync( + RemoteRelease release, + IProgress? progress = null, + Func>? onConflict = null, + CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(release); - return _installer.InstallAsync(release, progress, ct); + return _installer.InstallAsync(release, progress, onConflict, ct); } public async Task InstallAsync(IProgress? progress = null, CancellationToken ct = default) @@ -50,7 +54,7 @@ public async Task InstallAsync(IProgress? progress = null, throw new UpdateException( "No release is available from the configured update source. The source either returned null or is currently unreachable."); } - await _installer.InstallAsync(release, progress, ct).ConfigureAwait(false); + await _installer.InstallAsync(release, progress, onConflict: null, ct).ConfigureAwait(false); } } } diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs index 0bf2a63..d6dc24e 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs @@ -58,7 +58,11 @@ internal UpdateInstaller( public string InstallDirectory => _installDirResolver(); - public async Task InstallAsync(RemoteRelease release, IProgress? progress = null, CancellationToken ct = default) + public async Task InstallAsync( + RemoteRelease release, + IProgress? progress = null, + Func>? onConflict = null, + CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(release); @@ -111,7 +115,7 @@ public async Task InstallAsync(RemoteRelease release, IProgress + SwapAsync(sourceDirectory, installDirectory, oldDirectory, Array.Empty(), onConflict: null, CancellationToken.None) + .GetAwaiter().GetResult(); + + internal static async Task SwapAsync( + string sourceDirectory, + string installDirectory, + string oldDirectory, + IReadOnlyList preservePaths, + Func>? onConflict, + CancellationToken ct) { // Reset .old/ — it should be empty going in. if (Directory.Exists(oldDirectory)) @@ -178,9 +195,10 @@ internal static void Swap(string sourceDirectory, string installDirectory, strin } Directory.CreateDirectory(oldDirectory); - // Phase 1: move install/ contents (except maintenance) → .old/. - // Track what we moved so a failure mid-loop can restore the - // partial move and leave the install dir as we found it. + // Phase 1: move install/ contents (except maintenance and + // preserved entries) → .old/. Track what we moved so a failure + // mid-loop can restore the partial move and leave the install + // dir as we found it. var movedNames = new List(); try { @@ -188,6 +206,7 @@ internal static void Swap(string sourceDirectory, string installDirectory, strin { var name = Path.GetFileName(entry); if (IsMaintenanceEntry(name)) continue; + if (IsPreserved(name, preservePaths)) continue; var dest = Path.Combine(oldDirectory, name); if (File.Exists(entry)) @@ -207,16 +226,48 @@ internal static void Swap(string sourceDirectory, string installDirectory, strin throw; } - // Phase 2: copy new entries in. Track each placed entry so a - // failure mid-copy can rip out the partial replacement and - // restore the previous install from .old/. + // Phase 2: copy new entries in. When a new entry lands on a + // preserved path, ask the resolver (or default to KeepExisting). + // Track each placed entry so a failure mid-copy can rip out + // the partial replacement and restore the previous install + // from .old/. var copiedNames = new List(); try { foreach (var entry in Directory.EnumerateFileSystemEntries(sourceDirectory)) { + ct.ThrowIfCancellationRequested(); var name = Path.GetFileName(entry); var dest = Path.Combine(installDirectory, name); + + // Conflict path: source ships an entry whose top-level + // name matches a preserved pattern. + if (IsPreserved(name, preservePaths)) + { + var existingExists = File.Exists(dest) || Directory.Exists(dest); + if (existingExists) + { + var resolution = await ResolveConflictAsync(name, dest, entry, onConflict, ct).ConfigureAwait(false); + if (resolution == UpdateConflictResolution.KeepExisting) + { + // User's file wins — drop the new entry on the floor. + continue; + } + // UseNew: the existing preserved entry wasn't + // moved in Phase 1; do it inline now so the + // copy below has somewhere clean to land and + // rollback restores the right thing on failure. + var oldDest = Path.Combine(oldDirectory, name); + TryDeleteEntry(oldDest); + if (File.Exists(dest)) File.Move(dest, oldDest); + else if (Directory.Exists(dest)) Directory.Move(dest, oldDest); + movedNames.Add(name); + } + // else: a new release introduces a path that the + // user marked as preservable but doesn't exist + // locally. Treat as a normal copy. + } + if (File.Exists(entry)) { File.Copy(entry, dest, overwrite: true); @@ -239,6 +290,55 @@ internal static void Swap(string sourceDirectory, string installDirectory, strin } } + private static async Task ResolveConflictAsync( + string name, + string existingPath, + string newPath, + Func>? onConflict, + CancellationToken ct) + { + if (onConflict is null) + { + return UpdateConflictResolution.KeepExisting; + } + + long? existingSize = File.Exists(existingPath) ? new FileInfo(existingPath).Length : null; + long? newSize = File.Exists(newPath) ? new FileInfo(newPath).Length : null; + var conflict = new UpdateConflict(name.Replace(Path.DirectorySeparatorChar, '/'), existingSize, newSize); + return await onConflict(conflict, ct).ConfigureAwait(false); + } + + internal static bool IsPreserved(string name, IReadOnlyList preservePaths) + { + if (preservePaths.Count == 0) return false; + foreach (var pattern in preservePaths) + { + if (string.IsNullOrWhiteSpace(pattern)) continue; + // Take the part of the pattern before the first slash — + // this lets `data/**`, `data/seed.json`, and bare `data` + // all match the top-level entry `data`. Nested-only + // preservation (e.g. preserve only `data/seed.json` but + // not the rest of `data/`) is out of scope for v0.1.x. + var head = TopLevelSegment(pattern); + if (head.Length == 0) continue; + if (System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(head, name, ignoreCase: true)) + { + return true; + } + } + return false; + } + + private static ReadOnlySpan TopLevelSegment(string pattern) + { + ReadOnlySpan span = pattern; + for (var i = 0; i < span.Length; i++) + { + if (span[i] == '/' || span[i] == '\\') return span[..i]; + } + return span; + } + // Move every named entry from .old/ back into install/, replacing // anything currently at the destination. Best-effort — any single // entry that can't be restored is logged silently; the original diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs b/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs index efbe224..d451adf 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs @@ -115,6 +115,31 @@ public sealed class SelfUpdaterOptions /// public bool AllowInsecureManifestSource { get; set; } + /// + /// Glob patterns identifying files and directories the installer + /// should leave alone during an update. Patterns are matched + /// against the path relative to the install directory using + /// forward-slash separators, e.g. + /// "appsettings.Development.json", "appsettings.*.json", + /// "data/**". ** matches any number of path segments; + /// * matches any run of non-separator characters. + /// + /// Matched entries are skipped in both phases of the swap: + /// they are not moved into .old/, and a new release entry + /// landing on a preserved path is resolved by the conflict + /// resolver passed to + /// (default: keep the existing user file). + /// + /// + /// Defaults to empty — the installer treats every entry in the + /// install directory as package-owned. Add patterns for any user- + /// editable config (appsettings.Development.json), + /// per-machine state (*.db, logs/**), or anything + /// the consumer wouldn't want to lose across an upgrade. + /// + /// + public IReadOnlyList PreservePaths { get; set; } = Array.Empty(); + // ---------- Source registration ---------- /// diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/UpdateConflict.cs b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateConflict.cs new file mode 100644 index 0000000..caa6f53 --- /dev/null +++ b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateConflict.cs @@ -0,0 +1,28 @@ +namespace NextIteration.SpectreConsole.SelfUpdate +{ + /// + /// Describes a single file conflict surfaced by the installer to a + /// caller-supplied resolver: a new release ships an entry whose path + /// matches one of , so + /// the installer needs to know whether to keep the user's local copy + /// or overwrite it with the new release's version. + /// + /// + /// Path of the conflicting entry, relative to the install directory + /// (e.g. "appsettings.Development.json" or "data/seed.json"). + /// Forward-slash separated regardless of OS so resolvers can match + /// against patterns portably. + /// + /// + /// Size of the user's existing file in bytes, or + /// when the existing entry is a directory. + /// + /// + /// Size of the new release's file in bytes, or + /// when the new entry is a directory. + /// + public sealed record UpdateConflict( + string RelativePath, + long? ExistingSizeBytes, + long? NewSizeBytes); +} diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/UpdateConflictResolution.cs b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateConflictResolution.cs new file mode 100644 index 0000000..d4da906 --- /dev/null +++ b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateConflictResolution.cs @@ -0,0 +1,24 @@ +namespace NextIteration.SpectreConsole.SelfUpdate +{ + /// + /// Per-file decision returned by a conflict resolver when a new release + /// ships an entry whose path matches one of + /// . + /// + public enum UpdateConflictResolution + { + /// + /// Keep the user's existing file untouched. The new release's copy + /// is silently discarded for this path. Safe default — chosen when + /// no resolver is supplied. + /// + KeepExisting, + + /// + /// Replace the user's existing file with the new release's copy. + /// The previous content is moved to .old/ as part of the + /// normal swap. + /// + UseNew, + } +} diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs index e29f109..db86a45 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs @@ -92,7 +92,7 @@ public async Task Execute_when_install_succeeds_returns_zero_and_calls_install() configUpdater: u => { u.GetLatestImpl = _ => Task.FromResult(ReleaseV142); - u.InstallReleaseImpl = (_, _, _) => + u.InstallReleaseImpl = (_, _, _, _) => { installCalls++; return Task.CompletedTask; @@ -118,7 +118,7 @@ public async Task Execute_passes_resolved_release_to_install() configUpdater: u => { u.GetLatestImpl = _ => Task.FromResult(ReleaseV142); - u.InstallReleaseImpl = (release, _, _) => + u.InstallReleaseImpl = (release, _, _, _) => { receivedRelease = release; return Task.CompletedTask; @@ -148,7 +148,7 @@ public async Task Execute_does_not_call_parameterless_install() parameterlessCalls++; return Task.CompletedTask; }; - u.InstallReleaseImpl = (_, _, _) => Task.CompletedTask; + u.InstallReleaseImpl = (_, _, _, _) => Task.CompletedTask; }); await run("--yes"); @@ -164,7 +164,7 @@ public async Task Execute_when_install_throws_UpdateException_returns_three() configUpdater: u => { u.GetLatestImpl = _ => Task.FromResult(ReleaseV142); - u.InstallReleaseImpl = (_, _, _) => throw new UpdateException("boom"); + u.InstallReleaseImpl = (_, _, _, _) => throw new UpdateException("boom"); }); var exit = await run("--yes"); @@ -183,7 +183,7 @@ public async Task Execute_when_force_and_up_to_date_proceeds_to_install() configUpdater: u => { u.GetLatestImpl = _ => Task.FromResult(ReleaseV142); - u.InstallReleaseImpl = (_, _, _) => + u.InstallReleaseImpl = (_, _, _, _) => { installCalls++; return Task.CompletedTask; diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs index fee142f..dbbe6db 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs @@ -6,7 +6,7 @@ internal sealed class StubSelfUpdater : ISelfUpdater public Func>? CheckImpl { get; set; } public Func>? GetLatestImpl { get; set; } public Func?, CancellationToken, Task>? InstallImpl { get; set; } - public Func?, CancellationToken, Task>? InstallReleaseImpl { get; set; } + public Func?, Func>?, CancellationToken, Task>? InstallReleaseImpl { get; set; } public Task CheckAsync(CancellationToken ct = default) => CheckImpl?.Invoke(ct) ?? Task.FromResult(null); @@ -14,8 +14,12 @@ internal sealed class StubSelfUpdater : ISelfUpdater public Task GetLatestReleaseAsync(CancellationToken ct = default) => GetLatestImpl?.Invoke(ct) ?? Task.FromResult(null); - public Task InstallAsync(RemoteRelease release, IProgress? progress = null, CancellationToken ct = default) => - InstallReleaseImpl?.Invoke(release, progress, ct) ?? Task.CompletedTask; + public Task InstallAsync( + RemoteRelease release, + IProgress? progress = null, + Func>? onConflict = null, + CancellationToken ct = default) => + InstallReleaseImpl?.Invoke(release, progress, onConflict, ct) ?? Task.CompletedTask; public Task InstallAsync(IProgress? progress = null, CancellationToken ct = default) => InstallImpl?.Invoke(progress, ct) ?? Task.CompletedTask; @@ -38,8 +42,11 @@ internal sealed class StubUpdateInstaller : IUpdateInstaller { public string InstallDirectory { get; set; } = "/tmp/install"; - public Task InstallAsync(RemoteRelease release, IProgress? progress = null, CancellationToken ct = default) => - Task.CompletedTask; + public Task InstallAsync( + RemoteRelease release, + IProgress? progress = null, + Func>? onConflict = null, + CancellationToken ct = default) => Task.CompletedTask; public void CleanupOldInstall() { } } diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs index f492c95..b8f2aca 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs @@ -12,6 +12,10 @@ public sealed class UpdateInstallerTests { private static readonly string[] TwoEntryNames = { "a.txt", "subdir" }; private static readonly string[] OneEntryName = { "a.txt" }; + private static readonly string[] PreserveAppsettingsDevelopment = { "appsettings.Development.json" }; + private static readonly string[] PreserveAppsettingsJson = { "appsettings.json" }; + private static readonly string[] PreserveDb = { "*.db" }; + private static readonly string[] PreserveAppsettingsGlob = { "appsettings.*.json" }; [Fact] public async Task InstallAsync_swaps_files_into_install_directory() @@ -36,7 +40,7 @@ public async Task InstallAsync_swaps_files_into_install_directory() }; var installer = NewInstaller(installDir, source, "linux-x64"); - await installer.InstallAsync(release, progress: null, CancellationToken.None); + await installer.InstallAsync(release, progress: null, onConflict: null, CancellationToken.None); // New files in place Assert.Equal("new binary", await File.ReadAllTextAsync(Path.Combine(installDir, "myapp.exe"))); @@ -70,7 +74,7 @@ public async Task InstallAsync_throws_when_no_asset_matches_rid() var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-arm64"); var ex = await Assert.ThrowsAsync(() => - installer.InstallAsync(release, progress: null, CancellationToken.None)); + installer.InstallAsync(release, progress: null, onConflict: null, CancellationToken.None)); Assert.Contains("No release asset matches RID 'linux-arm64'", ex.Message, StringComparison.Ordinal); } @@ -91,7 +95,7 @@ public async Task InstallAsync_reports_progress_for_each_stage() var stages = new List(); var progress = new Progress(e => stages.Add(e.Stage)); var installer = NewInstaller(installDir, source, "linux-x64"); - await installer.InstallAsync(release, progress, CancellationToken.None); + await installer.InstallAsync(release, progress, onConflict: null, CancellationToken.None); // Allow Progress to drain. await Task.Yield(); @@ -146,7 +150,7 @@ public async Task InstallAsync_when_lock_file_held_throws() var installer = NewInstaller(installDir, source, "linux-x64"); await Assert.ThrowsAsync(() => - installer.InstallAsync(release, progress: null, CancellationToken.None)); + installer.InstallAsync(release, progress: null, onConflict: null, CancellationToken.None)); } [Fact] @@ -177,7 +181,7 @@ public async Task InstallAsync_when_lock_held_leaves_existing_staging_intact() var installer = NewInstaller(installDir, source, "linux-x64"); await Assert.ThrowsAsync(() => - installer.InstallAsync(release, progress: null, CancellationToken.None)); + installer.InstallAsync(release, progress: null, onConflict: null, CancellationToken.None)); Assert.True(File.Exists(sentinel), "Lock acquisition must precede any staging mutation."); @@ -215,7 +219,7 @@ public async Task InstallAsync_when_resolver_returns_malicious_name_throws() installDirResolver: () => installDir); var ex = await Assert.ThrowsAsync(() => - installer.InstallAsync(release, progress: null, CancellationToken.None)); + installer.InstallAsync(release, progress: null, onConflict: null, CancellationToken.None)); Assert.Contains("Refusing to install asset", ex.Message, StringComparison.Ordinal); } @@ -334,6 +338,157 @@ public void Swap_when_phase2_copy_fails_restores_install_from_old() Assert.False(File.Exists(Path.Combine(installDir, "newfile.txt"))); } + [Theory] + [InlineData("appsettings.Development.json", "appsettings.Development.json", true)] + [InlineData("appsettings.*.json", "appsettings.Development.json", true)] + [InlineData("appsettings.*.json", "appsettings.json", false)] + [InlineData("data/**", "data", true)] + [InlineData("data/**", "other", false)] + [InlineData("data/seed.json", "data", true)] // top-level rule: pattern's first segment matches dir name + [InlineData("*.db", "myapp.db", true)] + [InlineData("*.db", "config.json", false)] + [InlineData("", "anything", false)] // empty pattern is ignored + public void IsPreserved_matches_top_level_entry_against_pattern(string pattern, string name, bool expected) + { + var patterns = new[] { pattern }; + Assert.Equal(expected, UpdateInstaller.IsPreserved(name, patterns)); + } + + [Fact] + public void IsPreserved_with_empty_list_returns_false() + { + Assert.False(UpdateInstaller.IsPreserved("anything.txt", Array.Empty())); + } + + [Fact] + public async Task SwapAsync_preserved_entry_is_not_moved_to_old() + { + using var work = new TempDir(); + var installDir = work.Combine("install"); + var sourceDir = work.Combine("src"); + var oldDir = Path.Combine(installDir, ".old"); + Directory.CreateDirectory(installDir); + Directory.CreateDirectory(sourceDir); + File.WriteAllText(Path.Combine(installDir, "appsettings.Development.json"), "user-config"); + File.WriteAllText(Path.Combine(installDir, "binary.exe"), "old-binary"); + File.WriteAllText(Path.Combine(sourceDir, "binary.exe"), "new-binary"); + + await UpdateInstaller.SwapAsync( + sourceDir, installDir, oldDir, + preservePaths: PreserveAppsettingsDevelopment, + onConflict: null, + CancellationToken.None); + + // Preserved file untouched. + Assert.Equal("user-config", File.ReadAllText(Path.Combine(installDir, "appsettings.Development.json"))); + Assert.False(File.Exists(Path.Combine(oldDir, "appsettings.Development.json"))); + // Non-preserved file replaced. + Assert.Equal("new-binary", File.ReadAllText(Path.Combine(installDir, "binary.exe"))); + Assert.Equal("old-binary", File.ReadAllText(Path.Combine(oldDir, "binary.exe"))); + } + + [Fact] + public async Task SwapAsync_when_release_conflicts_default_keeps_existing() + { + using var work = new TempDir(); + var installDir = work.Combine("install"); + var sourceDir = work.Combine("src"); + var oldDir = Path.Combine(installDir, ".old"); + Directory.CreateDirectory(installDir); + Directory.CreateDirectory(sourceDir); + File.WriteAllText(Path.Combine(installDir, "appsettings.json"), "user-edited"); + File.WriteAllText(Path.Combine(sourceDir, "appsettings.json"), "release-default"); + + await UpdateInstaller.SwapAsync( + sourceDir, installDir, oldDir, + preservePaths: PreserveAppsettingsJson, + onConflict: null, // null → keep existing + CancellationToken.None); + + Assert.Equal("user-edited", File.ReadAllText(Path.Combine(installDir, "appsettings.json"))); + Assert.False(File.Exists(Path.Combine(oldDir, "appsettings.json"))); + } + + [Fact] + public async Task SwapAsync_when_resolver_returns_use_new_replaces_existing() + { + using var work = new TempDir(); + var installDir = work.Combine("install"); + var sourceDir = work.Combine("src"); + var oldDir = Path.Combine(installDir, ".old"); + Directory.CreateDirectory(installDir); + Directory.CreateDirectory(sourceDir); + File.WriteAllText(Path.Combine(installDir, "appsettings.json"), "user-edited"); + File.WriteAllText(Path.Combine(sourceDir, "appsettings.json"), "release-default"); + + UpdateConflict? sawConflict = null; + Func> resolver = + (c, _) => { sawConflict = c; return Task.FromResult(UpdateConflictResolution.UseNew); }; + + await UpdateInstaller.SwapAsync( + sourceDir, installDir, oldDir, + preservePaths: PreserveAppsettingsJson, + onConflict: resolver, + CancellationToken.None); + + Assert.NotNull(sawConflict); + Assert.Equal("appsettings.json", sawConflict!.RelativePath); + Assert.Equal("release-default", File.ReadAllText(Path.Combine(installDir, "appsettings.json"))); + // Previous user copy moved to .old/ (so the next-startup cleanup sweep removes it). + Assert.Equal("user-edited", File.ReadAllText(Path.Combine(oldDir, "appsettings.json"))); + } + + [Fact] + public async Task SwapAsync_preserved_glob_does_not_block_unrelated_release_files() + { + using var work = new TempDir(); + var installDir = work.Combine("install"); + var sourceDir = work.Combine("src"); + var oldDir = Path.Combine(installDir, ".old"); + Directory.CreateDirectory(installDir); + Directory.CreateDirectory(sourceDir); + File.WriteAllText(Path.Combine(installDir, "myapp.db"), "user-db"); + File.WriteAllText(Path.Combine(installDir, "binary.exe"), "old-binary"); + File.WriteAllText(Path.Combine(sourceDir, "binary.exe"), "new-binary"); + // Note: source does NOT ship myapp.db. + + await UpdateInstaller.SwapAsync( + sourceDir, installDir, oldDir, + preservePaths: PreserveDb, + onConflict: null, + CancellationToken.None); + + Assert.Equal("user-db", File.ReadAllText(Path.Combine(installDir, "myapp.db"))); + Assert.Equal("new-binary", File.ReadAllText(Path.Combine(installDir, "binary.exe"))); + } + + [Fact] + public async Task SwapAsync_preserved_path_introduced_by_new_release_when_user_has_none_just_copies() + { + using var work = new TempDir(); + var installDir = work.Combine("install"); + var sourceDir = work.Combine("src"); + var oldDir = Path.Combine(installDir, ".old"); + Directory.CreateDirectory(installDir); + Directory.CreateDirectory(sourceDir); + File.WriteAllText(Path.Combine(sourceDir, "appsettings.Production.json"), "release-default"); + + // The path is preservable but the user doesn't have a copy yet — + // installer should just place the new file with no resolver call. + var resolverCalled = false; + Func> resolver = + (_, _) => { resolverCalled = true; return Task.FromResult(UpdateConflictResolution.KeepExisting); }; + + await UpdateInstaller.SwapAsync( + sourceDir, installDir, oldDir, + preservePaths: PreserveAppsettingsGlob, + onConflict: resolver, + CancellationToken.None); + + Assert.False(resolverCalled); + Assert.Equal("release-default", File.ReadAllText(Path.Combine(installDir, "appsettings.Production.json"))); + } + [Fact] public void Swap_preserves_maintenance_entries() {