Skip to content

Follow a symlinked Herdr config in tsk setup herdr (#129) - #191

Open
smarzban wants to merge 2 commits into
mainfrom
tsk/t211-follow-a-symlinked-herdr
Open

smarzban wants to merge 2 commits into
mainfrom
tsk/t211-follow-a-symlinked-herdr

Conversation

@smarzban

@smarzban smarzban commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Fixes #129. tsk setup herdr (and --check) failed with Too many levels of symbolic links when ~/.config/herdr/config.toml is a symlink, as dotfiles managers create.

What changed

  • src/setup.rs: new follow_links / resolve_config resolve the config directory link, then the config file link (relative, absolute, chained), before the existing fd-pinned flow runs. Dir (dir_unix.rs / dir_windows.rs) is unchanged, so the state-dir and tsk-plugins hardening stays as it was.
  • Where things land:
    • staged candidate, herdr config check, atomic rename, backup → beside the real file (the link is never replaced)
    • tsk-plugins/ and .tsk-setup.lock → beside the configured path (so a file link doesn't put generated assets into the dotfiles repo)
    • Herdr host calls still get the configured path (HERDR_CONFIG_PATH); Herdr follows the link itself
  • Dangling and looping links (self, a→b→a, >40 hops) are refused before any read, write, or host call, with an error naming the link.
  • --check on a dangling or looping link exits non-zero with empty stdout. The installers read stdout = bound with stderr discarded, so they treat it as unbound. No change to the probe's shape.
  • Windows: resolution is plain std::fs, so it runs on both platforms. Known limit (F-4, deliberate): Windows still refuses a reparse-point/symlinked ancestor directory of the resolved paths through the hardened reparse check, so a linked file whose target sits under a linked directory is refused there. Follow-up if someone reports it.
  • Review fix (F-5/F-9/F-2/F-3/F-10): a loop in a directory along the link target (config.toml -> loopdir/config.toml, loopdir -> loopdir) used to return a raw Too many levels of symbolic links. It now returns Herdr config symlink … loops; fix the link. A regular file along the target (ENOTDIR) now returns … points at missing …. All three follow_links call sites name the configured path.

Judgment call to review: following a symlinked herdr/ directory (GNU stow folds directories this way) reverses two cases of the old symlink_config_and_parent_and_assets_are_refused test. Those two moved to the new tests; the asset, scripts, root, base, and lock symlink refusals still hold (symlinked_assets_and_lock_are_refused). With a directory link, tsk-plugins/ lands in the linked directory, because that is the config directory.

Tests

  • tests/setup_safety.rs:
    • symlinked_config_is_edited_through_the_link (absolute, relative ../outside/dotfiles/config.toml, chained): the link is kept, the target is edited, there is one backup beside the target and none beside the link, assets and lock stay beside the link, no staging files are left over, --check says bound, and a rerun makes no new backup
    • symlinked_config_directory_is_followed
    • config_link_into_a_linked_directory_edits_the_real_directory (F-7/F-8): config.toml -> ../outside/dotlink/<name>, dotlink -> dotfiles[/v2], for both config.toml and herdr.toml. Both links are kept, the real file is edited, and staging and the backup land in the real directory (not through the link). --check goes unbound → bound. It fails when follow_links(dir) is replaced with dir.to_path_buf() (cannot open directory …/dotlink (symlinks refused)). The backup is still named config.toml.tsk-backup-* for a herdr.toml target, matching the documented name.
    • dangling_or_looping_config_link_is_refused_without_writes (dangling, self, pair, loop dir along the target, regular file along the target); with the loop mapping removed, it fails with the raw ELOOP: setup and --check both refused, zero host calls, nothing written
  • src/setup/tests.rs: a cross-platform run_at test with a symlinked file (on Windows it skips if the symlink privilege is missing), plus a follow_links unit test
  • tests/cli_setup_agent.rs: the --check assertion now expects bound for a linked config and a refusal for a dangling one
  • Fail-without-fix: with resolve_config stubbed to identity, the three new integration tests fail, two of them with the tsk setup herdr fails when herdr config is a symlink #129 message (cannot open …/config.toml (symlinks/non-directories refused): Too many levels of symbolic links (os error 62)). Fix restored, all green.
  • Gate: cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test green; cargo clippy --all-targets --target x86_64-pc-windows-gnu -- -D warnings green; site/ npm test 0 failures.

Docs: cli.md (setup herdr), install.md (backup bullet), packaging/README.md (setup contract + review-scope note), CHANGELOG.md Unreleased → Fixed.

Before / after

Both binaries are release builds: before = origin/main 39e3a8e, after = this branch. They ran against the real herdr 0.9.3 with full isolation (HOME, XDG_CONFIG_HOME, XDG_STATE_HOME, HERDR_SOCKET_PATH=<nonexistent>, TSK_STATE_DIR, HERDR_CONFIG_PATH unset). The scenario is /tmp/tsk-t211-scenario.sh: ~/.config/herdr/config.toml is a symlink to dotfiles/herdr/config.toml. Herdr's plugin registry was written only inside the isolated roots.

Before (/tmp/tsk-t211-before):

$ ls -l ~/.config/herdr/config.toml
lrwxr-xr-x@ 1 saeed  wheel  53 Oct 10 01:41 $R/home/.config/herdr/config.toml -> $R/dotfiles/herdr/config.toml
$ tsk setup herdr --check
tsk setup: cannot open /tmp/tsk-t211-before-roots/home/.config/herdr/config.toml (symlinks/non-directories refused): Too many levels of symbolic links (os error 62)
(exit 1)
$ tsk setup herdr
tsk setup: cannot open /tmp/tsk-t211-before-roots/home/.config/herdr/config.toml (symlinks/non-directories refused): Too many levels of symbolic links (os error 62)
(exit 1)
$ tsk setup herdr --check
tsk setup: cannot open /tmp/tsk-t211-before-roots/home/.config/herdr/config.toml (symlinks/non-directories refused): Too many levels of symbolic links (os error 62)
(exit 1)
--- link still a link?
yes
--- dotfiles/herdr:
config.toml
--- dotfiles config.toml keys:
# from dotfiles

After (/tmp/tsk-t211-after):

$ ls -l ~/.config/herdr/config.toml
lrwxr-xr-x@ 1 saeed  wheel  52 Oct 10 01:55 $R/home/.config/herdr/config.toml -> $R/dotfiles/herdr/config.toml
$ tsk setup herdr --check
unbound
(exit 0)
$ tsk setup herdr
    Config backup:
        /tmp/tsk-t211-after-roots/dotfiles/herdr/config.toml.tsk-backup-20261009-235501
    Shortcuts:      prefix+t board, prefix+a quick capture

Reload Herdr (herdr server reload-config) or restart it to apply the shortcuts.
(exit 0)
$ tsk setup herdr --check
bound
(exit 0)
--- link still a link?
yes
--- dotfiles/herdr:
config.toml                             config.toml.tsk-backup-20261009-235501
--- dotfiles config.toml keys:
# from dotfiles
command = "herdr-tsk.open-board"
command = "herdr-tsk.quick-capture"

How to test

Herdr tabs (already running, workspace w9D): tab before and tab after each show the scenario output above, and each shell is idle. To rerun, press ↑ then Enter in either tab. That reruns sh /tmp/tsk-t211-scenario.sh before|after, which rebuilds fresh isolated roots each time.

What to look for:

  • before: all three commands print Too many levels of symbolic links and exit 1, and the dotfiles config has no herdr-tsk lines.
  • after: unbound, then setup succeeds with the backup under dotfiles/herdr/, then bound. The link is still a link, and the dotfiles config now has both herdr-tsk.* commands.

From any shell (throwaway roots under /tmp; never touches ~/.tsk or your Herdr):

sh /tmp/tsk-t211-scenario.sh before
sh /tmp/tsk-t211-scenario.sh after

Edge cases by hand (after binary):

R=/tmp/tsk-t211-edge; rm -rf $R; mkdir -p $R/home/.config/herdr $R/state
ln -s $R/missing.toml $R/home/.config/herdr/config.toml          # dangling
env -u HERDR_CONFIG_PATH HOME=$R/home XDG_CONFIG_HOME=$R/home/.config XDG_STATE_HOME=$R/state \
  HERDR_SOCKET_PATH=$R/none.sock TSK_STATE_DIR=$R/tsk /tmp/tsk-t211-after setup herdr </dev/null
# expect: "Herdr config symlink …/config.toml points at missing …/missing.toml; create the target or remove the link", exit 1
ln -sf config.toml $R/home/.config/herdr/config.toml             # self-loop, same env line again
# expect: "Herdr config symlink …/config.toml loops; fix the link", exit 1
ln -s loopdir $R/loopdir; ln -sf $R/loopdir/config.toml $R/home/.config/herdr/config.toml   # loop along the target, same env line
# expect: the same "loops; fix the link" message (before binary: raw "Too many levels of symbolic links")

The binaries stay at /tmp/tsk-t211-before and /tmp/tsk-t211-after for reruns.

A dotfiles-managed config.toml (or herdr directory) is a symlink, and
setup refused it with 'Too many levels of symbolic links'. Resolve the
config file and directory links first, then run the fd-pinned flow on
the real file: stage, rename and back up beside the target, keep the
link, and keep generated assets and the lock beside the configured
path. Dangling and looping links are refused before any write.
@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
herdr-tsk Ignored Ignored Preview Oct 9, 2026 11:54pm UTC

A loop or a non-directory in a directory along the link target returned
raw ELOOP/ENOTDIR. Map both to the existing 'loops' and 'points at
missing' refusals, naming the configured path. Cover a config link into
a linked directory and a target basename other than config.toml.
@jovric jovric Bot added the bug Something isn't working label Oct 10, 2026

This branch has not been deployed

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tsk setup herdr fails when herdr config is a symlink

1 participant