Skip to content

removeFileArtifact treats dangling symlinks and stat errors as already absent #434

Open

Description

@bguidolim

Problem

SyncStrategy.removeFileArtifact checks FileManager.fileExists(atPath:) before deleting. When that returns false, the path counts as already absent: the method returns true, and every caller drops it from files / fileHashes. That covers stale reconcile, unconfigurePack, mcs pack remove and excluded components. fileExists also returns false in two cases where something is still on disk:

  • Dangling symlink. fileExists follows links, so a tracked symlink whose target is gone reads as absent. It is untracked and never unlinked. Nothing is logged, and because the path is no longer tracked, no later sync retries it. mcs can install such a link: ComponentExecutor.copyWithSubstitution falls back to FileManager.copyItem, which preserves symlinks. A pack that ships a relative link (e.g. ../shared/x.md) can end up with one that dangles at its destination.
  • Stat failure. Any error from stat also reads as absent. For example, EACCES when a parent directory has no search permission. The file is untracked with no message and left on disk.

A related case to decide together with these: a tracked symlink that sits inside the base but points outside it. PathContainment.safePath resolves the link, returns nil, and the escape branch warns "clearing from tracking" and returns true. The link itself is inside the managed directory, so unlinking it would be safe. Today it is left behind (at least with a warning).

The fileExists guard predates the single shared implementation from #433. Both per-scope copies had it.

Proposed fix

  • Check existence with lstat semantics: FileManager.attributesOfItem(atPath:) in a do/catch. Treat only "no such file" (ENOENT) as absent, and any other error as a failed removal (false, path stays tracked, retried).
  • Judge containment by where the link sits, not where it points. Resolve the parent directory, then append lastPathComponent. This removes an in-base link to an outside target without following it.

Behavior change: a stat error will keep the path tracked and warn on each sync until it resolves. Today it is dropped silently.

Tests

  • A tracked dangling symlink is removed from disk and untracked.
  • A tracked in-base symlink that points outside the base is unlinked, and its target is untouched.
  • A stat failure (e.g. a parent without search permission) keeps the path tracked and warns once.

Found during review of #433.

Activity

  1. added
    bugSomething isn't working
    silent-failureError handling / silent failure
    P3Medium priority
    on Sep 25, 2026
  2. bguidolim commented on Sep 26, 2026

    @bguidolim
    CollaboratorAuthor

    Note (2026-09-26): #365 (hash check before deletion) targets the same function, SyncStrategy.removeFileArtifact. Worth fixing both together.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Medium prioritybugSomething isn't workingsilent-failureError handling / silent failure

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions