Skip to content

feat: scan and restore the SSH client config (plan 16 Task 2) - #109

Merged
junjuny0227 merged 4 commits into
mainfrom
feat/ssh-config-core
Nov 5, 2026
Merged

junjuny0227 merged 4 commits into
mainfrom
feat/ssh-config-core

Conversation

@junjuny0227

Copy link
Copy Markdown
Owner

Summary

Plan 16 Task 2 (docs/plans/2026-11-05-16-ssh-config.md): a new ssh provider scans ~/.ssh/config and the files it Includes from inside ~/.ssh, and the export restores them with OS-specific lines commented out, private permissions on macOS/Linux, and a "Copy these SSH keys yourself" list. Keys are never read or exported. Spec §2/§6/§8/§10/§15 sections touched by plan 16 were already merged with the plan. This PR adds the details the implementation settled (see Deviations).

Type

  • feat
  • fix
  • refactor / chore
  • docs
  • ci / build

Changes

  • Model: Category::Ssh ("ssh"), FindingKind::KeyFile { path }, FindingKind::ExternalInclude { path }, TargetPath::is_private() (true when a Home target's first part is .ssh).
  • Provider ssh (providers/ssh.rs, registered after shell): reads ~/.ssh/config and follows Include only inside ~/.ssh (~/.ssh/…, relative, or absolute under home/.ssh). It does not follow .., paths outside ~/.ssh, wildcards before the last part, or %/$ tokens. Each of those lines gets an ExternalInclude finding. Wildcards match */? in the last part only, in name order, and skip dot files like glob(3). Limits: depth 16, 32 files, one item per file. Forbidden names (known_hosts*, authorized_keys*, id_*, *.pub, environment, rc, compared case-insensitively) and files holding a private key block never become items. IdentityFile targets are never opened or checked. Findings: secrets, then by line ExternalInclude / OsSpecificLine (UseKeychain, absolute or ~/Library/ paths) / KeyFile.
  • ssh::is_config_target is shared by the scan and repot.json opening (snapshot::is_scanned_config_target gets an ssh arm). Other providers' allowlists already reject .ssh/, so an ssh item can only write SSH config files under .ssh/.
  • Catalog file:ssh-config + catalog keys in all four desktop locales (en/ko/ja/zh-Hans).
  • Secrets: SetEnv NAME=value / SetEnv=NAME=value (any case) is masked through the name rule, to the end of the line.
  • prepare_config: when the OS kind differs, it comments out UseKeychain (destination not macOS), ControlMaster/ControlPath/ControlPersist (destination Windows), and IdentityFile/CertificateFile/IdentityAgent/UserKnownHostsFile/ControlPath with an absolute or ~/Library/ value. Line numbers stay the same, and the comment format is shared with git through comment_out. It always emits ExportNote::SshKey for IdentityFile/CertificateFile values, including lines it commented out.
  • bash: copy_private_file (umask 077 subshell + chmod 600) is emitted only when a target is under ~/.ssh. New dirs are 0700 and files 0600. An existing ~/.ssh keeps its mode, and the backup keeps the old file's mode (cp -p).
  • Summary and README (bash, PowerShell, manifest README, script-format README): Copy these SSH keys yourself (Repot does not export keys): lists each path once (first line wins). Without key lines it names the default keys. SSH keys are kept out of Notes. The manifest README adds a chmod 700/chmod 600 line for macOS/Linux. The script-format README is now also written when SSH keys are listed.
  • CLI: scan lists an SSH category after Shell. crates/cli/README*.md do not list categories, so they are unchanged.
  • render_samples: has .ssh/config and .ssh/config.d/work copies and an SshKey note, so CI's shellcheck and PSScriptAnalyzer lint the new code.
  • Tests: ssh_test.rs (13 tests, FakeEnv plus a recording Env that lists dirs in reverse and logs reads), SetEnv table, payload destination table (macOS→macOS/Linux/Windows, Linux→macOS), bash run test (modes, backup mode, idempotence, summary text), PowerShell and manifest key lists, repot.json .ssh/ rules + SSH round trip (direct == via file for 3 targets), compare (same / differs / Linux file that differs only by the commented UseKeychain is Present), CLI category order.
  • Docs: the plan adds ExternalInclude to Global Constraints, the example JSON and the model section. includes returns line numbers. The plan also records the finding order and how the snapshot rule is implemented, and Task 2 is ticked. The spec adds ExternalInclude to the model and ssh to the §10 allowlist sentence.

Checks

  • cargo fmt --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace
  • pnpm --filter @repot/desktop typecheck / test / build
  • Ran the real flow if behavior changed (--example scan / --example export, or tauri dev)
  • Not checked locally, CI only (say which, e.g. Windows/Linux providers, shellcheck): PSScriptAnalyzer on restore.ps1 (no local pwsh), PowerShell runtime behaviour, and the Windows/Linux runners.

Also ran: pnpm exec biome ci ., cargo test -p repot-core --example e2e_export --example smoke_export --example cross_os_e2e --example render_samples, shellcheck --severity=warning on the render_samples bash output (clean). Mutation pass: each rule in ssh.rs, prepare_config, copy_private_file, the key list, the README condition, the snapshot arm and SetEnv was broken once. 34 of 34 mutations fail a test. Three early survivors got tests (# comments in Include, Linux→macOS keeps UseKeychain, UNC paths). A fourth was an extra ".ssh/ only for ssh" guard that no input could reach, because every other provider's allowlist already rejects .ssh/. It was removed, and the plan now says so.

Real data (throwaway examples/zz_ssh.rs, deleted, scanning read-only):

  • This Mac: one item ssh:file:.ssh/config with ExternalInclude ~/.orbstack/ssh/config (line 4). Only ~/.ssh/config was read, and nothing under .orbstack. The macOS/Linux/Windows exports read back with 0 skipped items. The macOS restore.sh in a temp HOME (XDG pinned) gave ~/.ssh 700 and config 600. The restored file is identical to the source, and the second run reports already done. With an existing 755 ~/.ssh and a 644 config, the dir stays 755, the config becomes 600 and the backup stays 644. ssh -G -F $tmp/.ssh/config github.com exits 0. The real file has no host alias, so github-work was checked as below.
  • The plan's example config in a temp HOME: the findings, the Linux/Windows payload diffs and the summary match the plan's 예시 exactly. After restoring, ssh -G -F $tmp/.ssh/config github-work exits 0 with hostname/user/both identity files, and modes are 700/700/600/600.
  • shellcheck on a real copy-only restore.sh reports SC2034 (PREFLIGHT_MISSING unused). This is pre-existing: the helper is always written but only used when a step needs a tool, and this PR does not change it. CI lints render_samples, which has tools, so CI is clean.

Spec and generated files

  • Spec updated in this PR if a product decision changed, or no decision changed
  • Generated files regenerated from their source, not edited by hand (scan.json, snapshots/, icons/)
  • Snapshot diffs read and intended

scan.json is unchanged: ui_fixture.rs gets its SSH item in Task 3, and the desktop suite passes on the current fixture. The four snapshot diffs each add only the .ssh/config copy, copy_private_file, the SSH key list and the README chmod line.

Boundaries

  • repot-core does not depend on Tauri and does no network calls
  • Commands and file reads go through Env; tests use FakeEnv and fixtures, not real tools
  • No new crate or npm dependency, or the plan/spec was updated first
  • The app still only reads the device; it writes only to the export folder

@github-actions github-actions Bot added the enhancement New feature or request label Nov 5, 2026
@junjuny0227
junjuny0227 merged commit e187339 into main Nov 5, 2026
16 checks passed
@junjuny0227
junjuny0227 deleted the feat/ssh-config-core branch November 5, 2026 04:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant