Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,24 @@ jobs:
# Analyse source only. obj/ and bin/ hold generated and compiled
# output — e.g. the xUnit auto-generated entry point — so findings
# there are noise against code no human maintains.
#
# query-filters excludes the two audit queries that fire on every
# P/Invoke declaration and call site (cs/unmanaged-code,
# cs/call-to-unmanaged-code). Native-backend packages (Keychain,
# libsecret, DPAPI) exist to call unmanaged code, so these are pure
# noise there and non-native repos have no P/Invoke for them to hit.
# This excludes ONLY those two queries — every other
# security-and-quality query still runs on the interop files, so no
# real finding is lost (STANDARD.md 4.4).
config: |
paths-ignore:
- "**/obj/**"
- "**/bin/**"
query-filters:
- exclude:
id: cs/unmanaged-code
- exclude:
id: cs/call-to-unmanaged-code

# Explicit build rather than autobuild: these repos multi-target, and
# autobuild has picked a single TFM in the past, silently analysing half
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- **Adopted the revised canonical `.editorconfig` and enabled `EnforceCodeStyleInBuild`** (NextIteration.Standards §5.2, §1.2.1 — the latter now a `MUST`). The canonical file is a deliberate allow-list of gated style rules rather than a blanket `dotnet_analyzer_diagnostic.severity`, so a style rule a future SDK ships never auto-gates the build. With the flag on, the gated rules fail the build under `TreatWarningsAsErrors` instead of merely showing in the IDE. Bringing the code green was a mechanical, behaviour-preserving reformat of 92 sites — braces on all single-statement `if`s (IDE0011, 64 of them), collection expressions (IDE0300/IDE0301/IDE0028), `var` usage, two expression-bodied members, one simplified null check, and five unnecessary usings — applied with `dotnet format` plus the collection-expression sites it cannot fix automatically. All 392 tests (196 × `net8.0`/`net10.0`) pass unchanged, and the build stays at zero warnings.
- **`Path.Combine` → `Path.Join` repo-wide** (134 call sites, `src` and `tests`). `Path.Combine` returns its *last rooted argument* and silently discards everything before it, so a rooted second segment escapes the directory the first argument names. `Path.Join` always concatenates. Nothing here was reachable with a rooted segment — `ValidateAssetName` already rejects rooted and separator-bearing asset names before any path is built — so this is defence in depth on the install-directory path construction rather than a fix for a live defect. Verified behaviour-preserving: all 392 tests pass unchanged.
- **Idiomatic LINQ in place of filter-style loops.** `DefaultAssetResolver`'s five matcher loops become `FirstOrDefault`/`Any` predicates (the RID candidate walk stays lazy — `Select` plus `FirstOrDefault` still resolves a later candidate only when the earlier ones miss), `Sha256SumsManifest.Parse` maps its lines with `Select`, and `UpdateInstaller.IsPreserved` filters with `Where`/`Any` behind a named `MatchesTopLevelSegment` helper.
- **Test consoles are disposed.** Ten `TestConsole` instances that were test-locals now sit behind `using`. Three others are deliberately left undisposed and now say so in a comment: they escape their harness method via its return value, and the calling test reads `console.Output` after that method has returned, so disposing at the creation site closes the writer before the command under test writes to it.

- **CodeQL now excludes the two P/Invoke audit queries at the config level** (NextIteration.Standards §4.4, §3.0.1). A `query-filters` block in `codeql.yml` excludes exactly `cs/unmanaged-code` and `cs/call-to-unmanaged-code`. This package contains no P/Invoke, so the block matches nothing here — it is carried because §3.0.1 requires the workflow to equal the canonical template in non-comment content, and a repo that omits it has forked the template rather than customised it. Closes the `3.0.1 workflow content` audit row.

- **`IDE0005` suppressed in the non-shipping projects.** It only runs in-build when `GenerateDocumentationFile` is `true`, which both the test and demo projects set to `false` — sample and fixture code carries no XML docs, and `TreatWarningsAsErrors` would fail the build over every missing one. With `EnforceCodeStyleInBuild` on it hard-errors demanding the doc file be enabled instead. NextIteration.Standards §2.7 mandates this for the test project; the demo is the same shape (not shipped, so not public surface) and needs the same opt-out. `IDE0005` still gates the shipping project, where the doc file is on — and it caught five real unnecessary usings there.

- **`release.yml` folded into `ci.yml`.** Publishing now happens in the same workflow run as the build, so the `publish` job pushes the artifact this run's `build` job produced — the exact bytes the gate tested. The old tag-triggered `release.yml` rebuilt from the tag and published an artifact no gate had ever seen. It also globbed `*.nupkg` when uploading, so the `.snupkg` was built and then silently never published; the glob is now `*nupkg` and symbols ship. Repointing the nuget.org Trusted Publishing policy from `release.yml` to `ci.yml` was part of the same change, because the policy is bound to a workflow filename.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ internal string ResolveCacheFilePath()
var dir = !string.IsNullOrWhiteSpace(_options.CacheDirectory)
? _options.CacheDirectory!
: DefaultCacheDirectory(_options.AppName);
return Path.Combine(dir, ".update-check.json");
return Path.Join(dir, ".update-check.json");
}

internal bool IsCacheFresh(UpdateCacheEntry? entry) =>
Expand Down Expand Up @@ -322,19 +322,19 @@ internal static string DefaultCacheDirectory(string appName)
else if (OperatingSystem.IsMacOS())
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
root = Path.Combine(home, "Library", "Caches");
root = Path.Join(home, "Library", "Caches");
}
else
{
var xdg = Environment.GetEnvironmentVariable("XDG_CACHE_HOME");
if (string.IsNullOrWhiteSpace(xdg))
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
xdg = Path.Combine(home, ".cache");
xdg = Path.Join(home, ".cache");
}
root = xdg;
}
return Path.Combine(root, appName);
return Path.Join(root, appName);
}

// ---------- Helpers ----------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ public bool HasPendingCleanup
get
{
var installDir = InstallDirectory;
return Directory.Exists(Path.Combine(installDir, OldDirName))
|| Directory.Exists(Path.Combine(installDir, StagingDirName));
return Directory.Exists(Path.Join(installDir, OldDirName))
|| Directory.Exists(Path.Join(installDir, StagingDirName));
}
}

Expand All @@ -84,9 +84,9 @@ public async Task InstallAsync(
ValidateAssetName(asset.Name);

var installDir = InstallDirectory;
var stagingDir = Path.Combine(installDir, StagingDirName, SanitizeTag(release.Tag));
var oldDir = Path.Combine(installDir, OldDirName);
var lockFile = Path.Combine(installDir, LockFileName);
var stagingDir = Path.Join(installDir, StagingDirName, SanitizeTag(release.Tag));
var oldDir = Path.Join(installDir, OldDirName);
var lockFile = Path.Join(installDir, LockFileName);

// Acquire the lock BEFORE touching the staging tree. Otherwise a
// second installer can wipe a first installer's in-flight staging
Expand All @@ -99,7 +99,7 @@ public async Task InstallAsync(

try
{
var archivePath = Path.Combine(stagingDir, asset.Name);
var archivePath = Path.Join(stagingDir, asset.Name);

using (var dlCts = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
Expand All @@ -117,7 +117,7 @@ public async Task InstallAsync(
progress?.Report(new UpdateProgressEvent(UpdateStage.Verifying, 1));

progress?.Report(new UpdateProgressEvent(UpdateStage.Extracting, 0));
var extractedDir = Path.Combine(stagingDir, "extracted");
var extractedDir = Path.Join(stagingDir, "extracted");
await ArchiveExtractor.ExtractAsync(archivePath, extractedDir, ct).ConfigureAwait(false);
var sourceDir = ResolveSourceDirectory(extractedDir);
progress?.Report(new UpdateProgressEvent(UpdateStage.Extracting, 1));
Expand All @@ -130,7 +130,7 @@ public async Task InstallAsync(
{
// Drop the whole .update/ tree, not just the leaf <tag>/, so
// we don't leave an empty parent behind on the install dir.
TryDeleteDirectory(Path.Combine(installDir, StagingDirName));
TryDeleteDirectory(Path.Join(installDir, StagingDirName));
}
}

Expand All @@ -143,8 +143,8 @@ public void CleanupOldInstall()
// for the OneDrive / antivirus / Windows Search case where
// handles held at install time defeated the immediate cleanup;
// by next startup those handles have had time to release.
TrySwallow(() => DeleteDirectoryRobustly(Path.Combine(installDir, OldDirName)));
TrySwallow(() => DeleteDirectoryRobustly(Path.Combine(installDir, StagingDirName)));
TrySwallow(() => DeleteDirectoryRobustly(Path.Join(installDir, OldDirName)));
TrySwallow(() => DeleteDirectoryRobustly(Path.Join(installDir, StagingDirName)));
}

private static void TrySwallow(Action action)
Expand Down Expand Up @@ -232,7 +232,7 @@ internal static async Task SwapAsync(
continue;
}

var dest = Path.Combine(oldDirectory, name);
var dest = Path.Join(oldDirectory, name);
if (File.Exists(entry))
{
File.Move(entry, dest);
Expand Down Expand Up @@ -262,7 +262,7 @@ internal static async Task SwapAsync(
{
ct.ThrowIfCancellationRequested();
var name = Path.GetFileName(entry);
var dest = Path.Combine(installDirectory, name);
var dest = Path.Join(installDirectory, name);

// Conflict path: source ships an entry whose top-level
// name matches a preserved pattern.
Expand All @@ -281,7 +281,7 @@ internal static async Task SwapAsync(
// 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);
var oldDest = Path.Join(oldDirectory, name);
TryDeleteEntry(oldDest);
if (File.Exists(dest))
{
Expand Down Expand Up @@ -314,7 +314,7 @@ internal static async Task SwapAsync(
{
foreach (var name in copiedNames)
{
TryDeleteEntry(Path.Combine(installDirectory, name));
TryDeleteEntry(Path.Join(installDirectory, name));
}
RestoreFromOld(oldDirectory, installDirectory, movedNames);
throw;
Expand Down Expand Up @@ -346,29 +346,20 @@ internal static bool IsPreserved(string name, IReadOnlyList<string> preservePath
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;
}
return preservePaths
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
.Any(pattern => MatchesTopLevelSegment(pattern, name));
}

if (System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(head, name, ignoreCase: true))
{
return true;
}
}
return false;
// 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.
private static bool MatchesTopLevelSegment(string pattern, string name)
{
var head = TopLevelSegment(pattern);
return head.Length != 0
&& System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(head, name, ignoreCase: true);
}

private static ReadOnlySpan<char> TopLevelSegment(string pattern)
Expand All @@ -393,8 +384,8 @@ internal static void RestoreFromOld(string oldDirectory, string installDirectory
{
foreach (var name in names)
{
var src = Path.Combine(oldDirectory, name);
var dest = Path.Combine(installDirectory, name);
var src = Path.Join(oldDirectory, name);
var dest = Path.Join(installDirectory, name);
try
{
TryDeleteEntry(dest);
Expand Down Expand Up @@ -516,7 +507,7 @@ internal static void CopyDirectory(string source, string destination)
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(source, file);
var target = Path.Combine(destination, relative);
var target = Path.Join(destination, relative);
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
File.Copy(file, target, overwrite: true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,11 @@ public DefaultAssetResolver(string appName)
ArgumentNullException.ThrowIfNull(release);
ArgumentException.ThrowIfNullOrWhiteSpace(runtimeIdentifier);

foreach (var rid in CandidateRids(runtimeIdentifier))
{
var match = ResolveForRid(release, rid);
if (match is not null)
{
return match;
}
}
return null;
// Select is lazy and FirstOrDefault short-circuits, so a later
// candidate RID is only resolved when the earlier ones miss.
return CandidateRids(runtimeIdentifier)
.Select(rid => ResolveForRid(release, rid))
.FirstOrDefault(match => match is not null);
}

private ReleaseAsset? ResolveForRid(RemoteRelease release, string rid)
Expand Down Expand Up @@ -108,60 +104,27 @@ private static IEnumerable<string> CandidateRids(string runtimeIdentifier)

private static ReleaseAsset? MatchExact(RemoteRelease release, string stem)
{
foreach (var asset in release.Assets)
{
foreach (var ext in ArchiveExtensions)
{
if (NameEquals(asset.Name, stem + ext))
{
return asset;
}
}
}
return null;
return release.Assets.FirstOrDefault(
asset => ArchiveExtensions.Any(ext => NameEquals(asset.Name, stem + ext)));
}

private static ReleaseAsset? MatchPrefixSuffix(RemoteRelease release, string prefix, string ridSuffix)
{
foreach (var asset in release.Assets)
{
if (!asset.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}

if (!EndsWithRidAndArchive(asset.Name, ridSuffix))
{
continue;
}

return asset;
}
return null;
return release.Assets.FirstOrDefault(
asset => asset.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
&& EndsWithRidAndArchive(asset.Name, ridSuffix));
}

private static ReleaseAsset? MatchSuffixOnly(RemoteRelease release, string ridSuffix)
{
foreach (var asset in release.Assets)
{
if (EndsWithRidAndArchive(asset.Name, ridSuffix))
{
return asset;
}
}
return null;
return release.Assets.FirstOrDefault(
asset => EndsWithRidAndArchive(asset.Name, ridSuffix));
}

private static bool EndsWithRidAndArchive(string name, string ridSuffix)
{
foreach (var ext in ArchiveExtensions)
{
if (name.EndsWith(ridSuffix + ext, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
return ArchiveExtensions.Any(
ext => name.EndsWith(ridSuffix + ext, StringComparison.OrdinalIgnoreCase));
}

private static bool NameEquals(string actual, string expected) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ public async Task DownloadAssetAsync(ReleaseAsset asset, Stream destination, IPr
$"GhCliReleaseSource cannot download '{asset.Name}': release tag is not present in asset metadata.");
}

var tempFile = Path.Combine(Path.GetTempPath(), $"selfupdate-gh-{Guid.NewGuid():N}-{asset.Name}");
var tempFile = Path.Join(Path.GetTempPath(), $"selfupdate-gh-{Guid.NewGuid():N}-{asset.Name}");
try
{
progress?.Report(new DownloadProgress(0, asset.SizeBytes));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ public static IReadOnlyDictionary<string, string> Parse(string content)
ArgumentNullException.ThrowIfNull(content);

var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var rawLine in content.Split('\n'))
foreach (var line in content.Split('\n').Select(rawLine => rawLine.Trim()))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith('#'))
{
continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ public void AddUpdateBranch_rejects_blank_name()

private static (CommandApp App, TestConsole Console) BuildApp(Action<IConfigurator> configure)
{
// Deliberately not `using`: this console escapes via the return
// value and its lifetime belongs to the caller, which reads
// console.Output after the harness method has returned.
var console = new TestConsole();
var registrar = new TestRegistrar(s =>
{
Expand Down
Loading