diff --git a/PLAN.md b/PLAN.md index 43bb30e..8d6359b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,158 +1,154 @@ -# arca — v1.0.0 Plan +# arca — Post-1.0 Plan -## Current State (v0.1.0) +## Current State (v1.0.0) -Working proof of concept. FIDO2 unlock via cryptsetup with passphrase -fallback, privileged mount/unmount, config aliases with method -sequencing, init and status commands. - -## M1: Idempotent mount/unmount - -`mount` and `unmount` should be safe to run at any point in a device's -lifecycle without producing confusing errors. - -### Files changed - -- `cmd/mount.go` -- `cmd/unmount.go` -- `internal/udisks/client.go` - -### Work - -1. In `runMount`, after `FindDevice`, check if the device is already - unlocked (`CleartextDevice` succeeds). If the cleartext device is - already mounted, print the existing mount point and return success. - If unlocked but not mounted, skip unlock and go straight to mount. - -2. In `runUnmount`, handle each failure case: - - `CleartextDevice` fails (already locked): print "already locked" - and return success. - - `Unmount` fails because not mounted: proceed to lock anyway. - - `Lock` fails because already locked: return success. - -3. Add `client.IsMounted(dev)` helper that returns `(mountpoint, bool)` - to reduce duplicated mount-point checking logic. +Released. FIDO2/passphrase/keyfile/TPM2 unlock with method sequencing, +idempotent mount/unmount, config aliases, init command, status command, +NixOS LD_LIBRARY_PATH workaround for cryptsetup token plugins, unit +tests, nix flake packaging. --- -## M2: Error messages +## v1.1: Shell completions and verbose mode -Replace generic D-Bus errors with actionable messages. +### M6: Shell completions -### Files changed +Cobra generates completion scripts. Wire them up so alias names +complete in the shell. -- `internal/udisks/client.go` -- `cmd/mount.go`, `cmd/unmount.go`, `cmd/init.go`, `cmd/status.go` - -### Work - -1. Wrap the `dbus.SystemBus()` error in `NewClient` to detect - "connection refused" or "no such file" and print: - `"cannot connect to udisks2 — is the udisks2 service running?"` - -2. In `FindDevice`, when no device matches, include what was searched - and suggest `arca status` or `arca init`: - `"device /dev/sda1 not found (run 'arca status' to list devices)"` - -3. In the unlock sequencer, prefix each method error with context: - `"fido2: cryptsetup open --token-only: exit status 5 (is the FIDO2 key plugged in?)"` - ---- - -## M3: Unit tests - -Cover pure logic that doesn't need D-Bus or real devices. - -### Files changed - -- `internal/config/config_test.go` (new) -- `internal/cryptsetup/cryptsetup_test.go` (new) - -### Work - -1. **config_test.go** — test cases: - - `ResolveDevice` with exact alias match - - `ResolveDevice` with device path match (`/dev/sda1` -> `sda1`) - - `ResolveDevice` with unknown name returns default methods - - `ResolveDevice` with empty methods list defaults to `[passphrase]` - - `AliasFor` finds alias by UUID - - `AliasFor` returns "" for unknown UUID - - `Load` with nonexistent file returns empty config - - `Load` with valid YAML parses correctly - -2. **cryptsetup_test.go** — test cases: - - `MapperName("/dev/sda1")` == `"arca-sda1"` - - `MapperName("/dev/nvme0n1p2")` == `"arca-nvme0n1p2"` - - `hasTokenPlugins` with a temp dir containing a matching .so - - `hasTokenPlugins` with an empty temp dir - ---- - -## M4: CLI polish - -Small usability improvements. - -### Files changed +#### Files changed - `cmd/root.go` -- `cmd/mount.go` -- `cmd/init.go` -- `main.go` -- `.gitignore` (new) - -### Work - -1. Add `var version = "dev"` to `main.go`. Set `rootCmd.Version` in - `cmd/root.go`. Build with `-ldflags "-X main.version=..."` in - `flake.nix`. - -2. Add `--mountpoint` / `-m` flag to `mount` subcommand. When set, - pass it to `cryptsetup.Mount` (privileged path) or log a warning - that udisks2 doesn't support custom mount points. - -3. In `init`, use first 8 chars of UUID as alias instead of device - path basename. Example: `b8b2f8e3` instead of `sda1`. UUIDs are - stable across boots; device paths are not. - -4. Create `.gitignore` containing `/arca`. - ---- - -## M5: Documentation and packaging - -Make the project installable and the README trustworthy. - -### Files changed - -- `README.md` - `flake.nix` -### Work +#### Work -1. Update `README.md`: - - Replace placeholder UUIDs with realistic examples from actual - tested usage. - - Add "NixOS notes" section documenting the `LD_LIBRARY_PATH` - requirement for FIDO2/TPM2 and why `--external-tokens-path` - doesn't work. - - Add "Troubleshooting" section: no FIDO2 token enrolled, udisks2 - not running, permission denied on mount. - - Document `init` subcommand. +1. Cobra provides `completion` subcommand by default (`arca completion + zsh`, `arca completion bash`, etc.). Verify it works. -2. Verify `flake.nix`: - - Run `nix build` and confirm it produces a working binary. - - Add `-ldflags` for version injection from `self.rev` or a - `version` variable. - - Test the flake output: `./result/bin/arca --version`. +2. Add a custom `ValidArgsFunction` to `mountCmd` and `unmountCmd` + that reads the config and returns alias names + device paths from + `arca status`. This gives dynamic completion for: + ``` + arca mount -> backup media /dev/sda1 + arca unmount -> backup media + ``` -3. Tag `v1.0.0`. +3. In `flake.nix`, install the zsh completion file to + `$out/share/zsh/site-functions/_arca` so NixOS picks it up + automatically. + +### M7: Verbose mode + +Add `-v` / `--verbose` flag to aid debugging (e.g., the FIDO2 plugin +discovery issue we hit during development). + +#### Files changed + +- `cmd/root.go` +- `internal/cryptsetup/cryptsetup.go` +- `internal/unlock/unlock.go` +- `internal/udisks/client.go` + +#### Work + +1. Add a `--verbose` / `-v` persistent flag on the root command. Store + in a package-level `Verbose bool`. + +2. In `cryptsetup.Open`: print the full command being executed + (including `LD_LIBRARY_PATH` and privilege wrapper). + +3. In `findTokenPluginDir`: print which directory was found (or that + none was found). + +4. In `unlock.Unlock`: print which method is being attempted before + each try. + +5. In `udisks.NewClient`: print the D-Bus connection status. + +Use `fmt.Fprintf(os.Stderr, "arca: ...")` for verbose output — no +logging framework needed. --- -## Non-goals for v1.0.0 +## v1.2: Config management and cleanup + +### M8: `arca add ` + +Add a single device to an existing config without regenerating +everything. + +#### Files changed + +- `cmd/add.go` (new) +- `internal/config/config.go` + +#### Work + +1. New subcommand: `arca add /dev/sda1` or `arca add --uuid `. + +2. Resolve the device via udisks2 to get UUID. + +3. Generate a UUID-prefix alias (same as `init`). + +4. Prompt for alias name (default: UUID prefix), methods (default: + passphrase), and optional mountpoint. + +5. Append to existing config. If the UUID is already configured, print + the existing alias and exit. + +6. Add `config.Save(*Config)` to write the config file (currently + only `init` writes, with inline marshaling). + +### M9: `init --merge` + +Merge newly discovered devices into an existing config without +overwriting existing entries. + +#### Files changed + +- `cmd/init.go` +- `internal/config/config.go` + +#### Work + +1. Add `--merge` flag to `init`. When set, load existing config + first. + +2. For each discovered device, skip if its UUID already exists in + config (regardless of alias name). + +3. Add new devices with UUID-prefix aliases. + +4. Write the merged config. + +This replaces the current `--force` behavior for the common case of +"I plugged in a new drive." + +### M10: Mount cleanup + +Privileged unmount leaves empty directories under `/mnt/`. + +#### Files changed + +- `internal/cryptsetup/cryptsetup.go` +- `cmd/unmount.go` + +#### Work + +1. After `Unmount`, attempt `rmdir` on the mount point. Only remove + if empty (not `rm -rf`). + +2. Use privileged `rmdir` since the directory was created with + privilege. + +--- + +## Non-goals - `--dry-run` flag - `--json` output for `status` - udev auto-mount on plug - Keyfile creation/management - Multiple config files or config includes +- Systemd mount unit integration diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..9a4352f --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "sort" + + "git.wntrmute.dev/kyle/arca/internal/config" + "github.com/spf13/cobra" +) + +// completeDeviceOrAlias provides dynamic completion for device aliases +// and device paths from the config file. +func completeDeviceOrAlias(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + cfg := config.Load() + var completions []string + for alias := range cfg.Devices { + completions = append(completions, alias) + } + sort.Strings(completions) + + return completions, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/mount.go b/cmd/mount.go index 5de2e61..59a6dc0 100644 --- a/cmd/mount.go +++ b/cmd/mount.go @@ -15,10 +15,11 @@ import ( var mountpoint string var mountCmd = &cobra.Command{ - Use: "mount ", - Short: "Unlock and mount a LUKS volume", - Args: cobra.ExactArgs(1), - RunE: runMount, + Use: "mount ", + Short: "Unlock and mount a LUKS volume", + Args: cobra.ExactArgs(1), + RunE: runMount, + ValidArgsFunction: completeDeviceOrAlias, } func init() { diff --git a/cmd/unmount.go b/cmd/unmount.go index 81db308..e17eb91 100644 --- a/cmd/unmount.go +++ b/cmd/unmount.go @@ -10,11 +10,12 @@ import ( ) var unmountCmd = &cobra.Command{ - Use: "unmount ", - Aliases: []string{"umount"}, - Short: "Unmount and lock a LUKS volume", - Args: cobra.ExactArgs(1), - RunE: runUnmount, + Use: "unmount ", + Aliases: []string{"umount"}, + Short: "Unmount and lock a LUKS volume", + Args: cobra.ExactArgs(1), + RunE: runUnmount, + ValidArgsFunction: completeDeviceOrAlias, } func init() { diff --git a/flake.nix b/flake.nix index 623092c..ebe1e2a 100644 --- a/flake.nix +++ b/flake.nix @@ -10,7 +10,7 @@ let system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; - version = "1.0.0"; + version = "1.1.0"; in { packages.${system}.default = pkgs.buildGoModule { @@ -21,6 +21,14 @@ ldflags = [ "-X main.version=${version}" ]; + postInstall = '' + mkdir -p $out/share/zsh/site-functions + mkdir -p $out/share/bash-completion/completions + mkdir -p $out/share/fish/vendor_completions.d + $out/bin/arca completion zsh > $out/share/zsh/site-functions/_arca + $out/bin/arca completion bash > $out/share/bash-completion/completions/arca + $out/bin/arca completion fish > $out/share/fish/vendor_completions.d/arca.fish + ''; }; }; }