Skip to content

Fix five aspire ls bugs from #17620 (L1–L5)#17631

Merged
joperezr merged 8 commits into
microsoft:mainfrom
adamint:fix/aspire-ls-l1-l5
May 29, 2026
Merged

Fix five aspire ls bugs from #17620 (L1–L5)#17631
joperezr merged 8 commits into
microsoft:mainfrom
adamint:fix/aspire-ls-l1-l5

Conversation

@adamint
Copy link
Copy Markdown
Member

@adamint adamint commented May 28, 2026

Description

This PR fixes the five aspire ls bugs catalogued in #17620 (L1–L5). They are all consequences of how the new settings/discovery pipeline interacts with the legacy .aspire/settings.json, the modern aspire.config.json, parallel discovery, and macOS-style symlinks.

Fixes #17615
Fixes #17620
Fixes #17621
Fixes #17624
Fixes #17626

What changes for users

# Issue User-visible behavior change
L1 #17615 aspire ls (and any other read command) no longer silently creates an aspire.config.json next to an existing .aspire/settings.json. Read commands no longer mutate the workspace; migration is performed only by the explicit write paths.
L2 #17620 The migration warning text for legacy .aspire/settings.json now references the file the user actually authored, instead of the auto-created aspire.config.json they have never seen.
L3 #17621 aspire ls --format json --stream now correctly documents and emits candidates in arrival order from the parallel discovery walk (the previous dead post-emission Sort() had no effect and only confused readers).
L4 #17624 A NUL byte (or any other character forbidden by Path.GetInvalidPathChars()) in appHost.path/appHostPath now surfaces a clear, localized error instead of crashing with the generic “An unexpected error occurred: Null character in path.”
L5 #17626 On macOS (and any platform with directory symlinks on the apphost path, e.g. /tmp -> /private/tmp), aspire ls no longer lists the same apphost twice — once from the discovery walk and once from the settings file pointing at it via the symlink.

User-facing usage

The CLI surface is unchanged; only behavior under existing scenarios is corrected.

aspire ls --stream is now documented as arrival-ordered:

--stream    Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json

A bad appHost.path now produces a useful, localized error rather than a stack-trace–style message:

The configured AppHost path in '/path/to/aspire.config.json' ('appHost.path') contains characters that are not allowed in a file path.

Implementation notes

  • L1: ConfigurationHelper.RegisterSettingsFiles no longer eagerly migrates .aspire/settings.jsonaspire.config.json. Migration is still available through the normal write paths.
  • L2: ProjectLocator.GetAppHostProjectFileFromSettingsAsync drops the silent parameter; the legacy branch unconditionally surfaces the warning, and the message now uses the user-authored settings.json path.
  • L3: Removed dead appHosts.Sort() in LsCommand.FindAppHostsWithJsonStreamAsync; updated the resx (LsStreamOptionDescription) and docs/specs/cli-output-formats.md. xlf set refreshed via UpdateXlf.
  • L4: New IsValidConfiguredAppHostPath helper in ProjectLocator validates against \0 and Path.GetInvalidPathChars() before Path.Combine / Path.IsPathRooted. Called in both the modern (appHost.path from aspire.config.json) and legacy (appHostPath from .aspire/settings.json) branches. Validation is intentionally at the consumption point rather than in AspireConfigFile.Load, which has 12+ unrelated callers that should not be impacted. New ConfiguredAppHostPathHasInvalidCharacters resource string.
  • L5: New PathNormalizer.ResolveSymlinks(string path) in src/Shared. It walks each path segment with Directory.ResolveLinkTarget/File.ResolveLinkTarget(returnFinalTarget: true). The critical subtlety is that ResolveLinkTarget returns the link target as stored on disk — so a link whose target is /var/.../app retains the un-canonical /var prefix, even when the rest of the path has already been canonicalized through /var -> /private/var. To produce a canonical form regardless of which side of the comparison reached the file first, the helper recursively canonicalizes each resolved target, with a hard depth limit of 40 to defend against pathological chains, and falls back to the input on broken or circular links. AddSettingsAppHostCandidateAsync uses the resolved paths as a comparison key only — the surfaced AppHostProjectCandidate keeps its original FileInfo so the displayed path matches what the user authored.

Tests

  • New unit tests for PathNormalizer.ResolveSymlinks (idempotence, empty input, final-file symlink, intermediate-directory symlink, broken-link fallback).
  • L1: ConfigurationHelperTests rewritten to verify no aspire.config.json is written when only legacy settings are present.
  • L2: ProjectLocatorTests adds a regression that the legacy warning references settings.json, not aspire.config.json.
  • L3: LsCommandTests adds an arrival-order test using non-alphabetical names (Z/A/M) so any incidental sort would fail.
  • L4: ProjectLocatorTests adds two NUL-byte tests covering both the modern and the legacy branch.
  • L5: ProjectLocatorTests adds an integration test that places the symlink in node_modules (excluded from DefaultFiltered discovery) so the walk surfaces only the canonical path while the settings file references the symbolic path. The test verifies dedupe collapses them to a single entry.

Tests on a clean checkout: 158/160 targeted tests pass; 2 Windows-only tests are skipped on macOS as expected.

dotnet test --project tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj --no-build --no-launch-profile \
  -- --filter-class "*.ProjectLocatorTests" --filter-class "*.LsCommandTests" \
     --filter-class "*.AspireConfigFileTests" --filter-class "*.ConfigurationHelperTests" \
     --filter-class "*.PathNormalizerTests" \
     --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true"

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
    • No. Follow-up changes expected.
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
    • No
  • Did you add public API?
    • Yes
      • If yes, did you have an API Review for it?
        • Yes
        • No
      • Did you add <remarks /> and <code /> elements on your triple slash comments?
        • Yes
        • No
    • No
  • Does the change make any security assumptions or guarantees?
    • Yes
      • If yes, have you done a threat model and had a security review?
        • Yes
        • No
    • No

Copilot AI review requested due to automatic review settings May 28, 2026 22:10
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 28, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 17631

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 17631"

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes several aspire ls settings/discovery regressions around legacy settings migration, missing-path warnings, streamed ordering, invalid configured paths, and symlink-aware duplicate detection.

Changes:

  • Stops startup settings registration from eagerly migrating legacy .aspire/settings.json into aspire.config.json.
  • Updates AppHost settings lookup to validate configured paths and dedupe symlink-equivalent candidates.
  • Documents aspire ls --stream as arrival-ordered and adds regression tests for the fixed cases.

Reviewed changes

Copilot reviewed 39 out of 40 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/Aspire.Cli/Utils/ConfigurationHelper.cs Removes eager migration during settings registration.
src/Aspire.Cli/Program.cs Updates call site for the new RegisterSettingsFiles signature.
src/Aspire.Cli/Projects/ProjectLocator.cs Adds invalid-path validation, warning behavior updates, and symlink-aware dedupe.
src/Shared/PathNormalizer.cs Adds symlink-resolution helper.
src/Aspire.Cli/Commands/LsCommand.cs Removes dead stream-mode sort and documents arrival-order behavior.
src/Aspire.Cli/Resources/ErrorStrings.resx Adds invalid AppHost path error string.
src/Aspire.Cli/Resources/ErrorStrings.Designer.cs Adds generated accessor for the new error string.
src/Aspire.Cli/Resources/SharedCommandStrings.resx Updates --stream option description.
src/Aspire.Cli/Resources/xlf/ErrorStrings.*.xlf Adds localized placeholders for the new error string.
src/Aspire.Cli/Resources/xlf/SharedCommandStrings.*.xlf Updates localized placeholders for the stream option description.
docs/specs/cli-output-formats.md Documents streamed output ordering.
tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs Updates tests for non-mutating startup registration.
tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs Adds stream arrival-order regression coverage.
tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs Adds warning, invalid-path, and symlink-dedupe regression tests.
tests/Aspire.Cli.Tests/Utils/PathNormalizerTests.cs Adds symlink-resolution unit tests.
tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs Updates test helper for the settings registration signature change.
Files not reviewed (1)
  • src/Aspire.Cli/Resources/ErrorStrings.Designer.cs: Language not supported

Comment thread src/Aspire.Cli/Projects/ProjectLocator.cs Outdated
Comment thread src/Aspire.Cli/Utils/ConfigurationHelper.cs
Fixes microsoft#17615, microsoft#17620, microsoft#17621, microsoft#17624, microsoft#17626.

- L1 (microsoft#17615): Remove the eager-migration block in
  ConfigurationHelper.RegisterSettingsFiles. Read commands like
  `aspire ls` no longer silently materialize an aspire.config.json
  next to a user's legacy .aspire/settings.json. Migration now happens
  lazily/explicitly via the existing write paths.

- L2 (microsoft#17620): Drop the `silent` parameter from
  ProjectLocator.GetAppHostProjectFileFromSettingsAsync so the legacy
  branch unconditionally surfaces the migration warning, and surface
  the actual user-authored `.aspire/settings.json` path in the warning
  text rather than the auto-created `aspire.config.json` path.

- L3 (microsoft#17621): Remove the dead post-emission `appHosts.Sort()` in
  LsCommand.FindAppHostsWithJsonStreamAsync (--stream emits candidates
  as they are discovered, so the sort had no effect on already-emitted
  output). Update the --stream option description and
  docs/specs/cli-output-formats.md to declare the arrival-ordered
  contract.

- L4 (microsoft#17624): Add an IsValidConfiguredAppHostPath helper in
  ProjectLocator that rejects `\0` and Path.GetInvalidPathChars()
  before the path is passed to Path.IsPathRooted / Path.Combine.
  Wired into both the modern `aspire.config.json` (`appHost.path`)
  branch and the legacy `.aspire/settings.json` (`appHostPath`)
  branch. Validation is intentionally at the consumption point rather
  than in AspireConfigFile.Load, which has 12+ unrelated callers.
  Adds a new ConfiguredAppHostPathHasInvalidCharacters resource string
  and refreshes the xlf set via UpdateXlf.

- L5 (microsoft#17626): Add PathNormalizer.ResolveSymlinks in src/Shared, a
  recursive segment-walker that canonicalizes intermediate symlinks
  (Directory.ResolveLinkTarget only reads exactly the path it is given,
  and returns the link target as stored on disk — so a single call on
  /tmp/x/y.cs does not unwrap /tmp -> /private/tmp, and following a
  link whose stored target is /var/.../app keeps the un-canonical
  /var prefix). The recursion has a hard depth limit of 40 and falls
  back to the un-resolved input on broken or circular links. Use it in
  AddSettingsAppHostCandidateAsync as a comparison key only — the
  surfaced AppHostProjectCandidate keeps its original FileInfo so the
  displayed path matches what the user authored in settings.

Tests: 158 of 160 targeted tests pass (2 Windows-only skipped on
macOS). New tests cover L1 (no migration on read), L2 (legacy warning
references settings.json), L3 (arrival-order under --stream), L4
(NUL byte in modern and legacy branches), L5 (symlink dedupe via a
node_modules-hosted link the discovery walk excludes), plus 5 unit
tests on PathNormalizer.ResolveSymlinks itself.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@adamint adamint force-pushed the fix/aspire-ls-l1-l5 branch from 3869490 to ee4ff12 Compare May 28, 2026 23:00
@adamint
Copy link
Copy Markdown
Member Author

adamint commented May 28, 2026

🧪 Local validation results

Built and tested the CLI from this branch (ee4ff1236c) on macOS arm64. The PR build artifact is still in-flight in CI, so these results are from the locally-built binary at artifacts/bin/Aspire.Cli/Debug/net10.0/aspire against the same source.

Targeted regression: L1–L5

# Bug Manual check Result
L1 aspire ls silently writes aspire.config.json Run in a workspace with neither aspire.config.json nor .aspire/settings.json; assert no file is written ✅ no file written
L2 Warning claims path was specified in aspire.config.json when the file is actually legacy .aspire/settings.json Set legacy .aspire/settings.json with bogus path; run aspire ls; check warning text ✅ warning correctly names .aspire/settings.json
L3 --format json --stream ignores --stream aspire ls --format json --stream; assert NDJSON (one object per line) ✅ valid NDJSON; one JSON object per line
L4 NUL byte in appHost.path crashes with generic "unexpected error" Write {"appHost":{"path":"a\u0000b.csproj"}}; run aspire ls ✅ clean validation error naming the config file; exit 0
L5 Settings path via /tmp symlink produces a duplicate row alongside the canonical /private/tmp walk path Author settings with /tmp/... path on macOS; run aspire ls ✅ single row, no duplicate

Adversarial probe (15 attacks)

After addressing review feedback, I ran a focused adversarial pass against the same code paths. One additional in-scope bug was found and fixed in this PR.

Full attack table (click to expand)
Attack Scenario Result
A --format json with broken settings ✅ warning on stderr, clean [] on stdout
B --format json --stream with NUL byte ✅ clean NDJSON, error on stderr
C Empty appHostPath string ❌ → ✅ FIXED in this PR (was misleading "contains invalid characters" message)
D Whitespace-only path ✅ falls through as "not found" (matches pre-PR)
E Path with embedded newline ✅ legal on Unix, treated as filename
F Root / as appHost path ✅ rejected as directory not file
G Directory not file ✅ same as F
H Self-referential symlink ResolveSymlinks depth limit holds
I Nested symlink chain (3 deep) ⚠️ walk-vs-walk dedupe out-of-scope — pre-existing #17618
J Relative path ./AppHost.csproj in settings ✅ L5 dedupe canonicalizes correctly
K Invalid JSON in aspire.config.json ✅ clean error, exit 20
L Empty file aspire.config.json ✅ clean error, exit 20
M appHost section with no path ✅ falls through, walk works
N appHost.path as a number ✅ JSON-typing error + walk fallback
O appHost.path as an array ✅ same as N
P BOTH legacy appHostPath + modern appHost.path keys in legacy file ✅ legacy appHostPath wins (existing semantics)
Q Read-permission denied on settings ✅ clean access-denied error
R Case mismatch on macOS APFS (APPHOST vs AppHost) ⚠️ pre-existing case-sensitive comparison — filed #17635
S Very long path (1500 chars) ✅ clean "not found" warning
T Empty workspace --format json ✅ valid empty JSON []
U Empty workspace --format json --stream ✅ valid empty NDJSON (0 lines)

Bug found and fixed mid-review (ATTACK C): Empty appHost.path ("") was rejected by the L4 validator but emitted the misleading error string "... contains characters that are not allowed in a file path" — confusing for a string with no characters. Widened ConfiguredAppHostPathHasInvalidCharacters to "... is empty or contains characters that are not allowed in a file path" and added two regression tests (modern + legacy paths).

Test results

  • ProjectLocatorTests: 80/80 pass (78 existing + 2 new empty-path regression tests).
    Test run summary: Passed!
      total: 81, failed: 0, succeeded: 80, skipped: 1, duration: 5s
    
    (Skipped test is UseOrFindAppHostProjectFileResultUsesOnDiskCasingForExplicitPath — Windows-only.)

Filed follow-ups (out-of-scope for this PR)

Notes

  • The CLI binary above was built with MSBUILDTERMINALLOGGER=false ./.dotnet/dotnet build src/Aspire.Cli/Aspire.Cli.csproj. The dogfood comment at the top of this PR has the canonical command for the eventual PR-built binary.
  • Full PR CI run for this commit is pending: https://github.com/microsoft/aspire/actions/runs/26607333127

Comment thread src/Aspire.Cli/Projects/ProjectLocator.cs Outdated
Comment thread src/Aspire.Cli/Resources/SharedCommandStrings.resx Outdated
@davidfowl davidfowl added this to the 13.4 milestone May 29, 2026
Comment thread src/Aspire.Cli/Projects/ProjectLocator.cs Outdated
Copy link
Copy Markdown
Member

@JamesNK JamesNK left a comment

Choose a reason for hiding this comment

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

Solid set of fixes. One minor nit about a missing diagnostic log in a silent catch path — otherwise LGTM.

Comment thread src/Aspire.Cli/Projects/ProjectLocator.cs
adamint and others added 4 commits May 29, 2026 11:56
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@adamint adamint requested a review from sebastienros as a code owner May 29, 2026 17:19
capabilities.Add(capability.CapabilityId, capability);
}

private static bool CapabilitiesAreEquivalent(AtsCapability left, AtsCapability right) =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I actually think we should fail in every case the same id is found, even if the types are the same. We want to fix it, not keep these collisions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I understand it's not your PR that introduced it (a recent Foundry one). Other PRs are blocked by the same issue. We can just wait for #17671 to be merged

adamint added a commit to adamint/aspire that referenced this pull request May 29, 2026
Match the duplicate ATS capability parser/test/baseline shape from PR microsoft#17631 to avoid merge conflicts between the branches.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PR microsoft#17671 owns the Foundry ATS baseline update; this branch should only contain the AppHost and CLI fixes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@joperezr joperezr merged commit 2d65ec5 into microsoft:main May 29, 2026
616 of 619 checks passed
@joperezr
Copy link
Copy Markdown
Member

/backport to release/13.4

@microsoft-github-policy-service microsoft-github-policy-service Bot modified the milestones: 13.4, 13.5 May 29, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Started backporting to release/13.4 (link to workflow run)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment