2 Commits

Author SHA1 Message Date
0c19f94292 M7: add verbose mode for debugging
Add -v/--verbose persistent flag that prints debug info to stderr:
D-Bus connection status, token plugin directory discovery, unlock method
sequencing with per-method success/failure, and full cryptsetup command
lines including LD_LIBRARY_PATH.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:37:08 -07:00
e44dd382dd M6: shell completions with dynamic alias lookup
Add ValidArgsFunction to mount and unmount commands that reads config
aliases for tab completion. Install zsh, bash, and fish completion
scripts via flake postInstall. Update PLAN.md with post-1.0 roadmap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:35:19 -07:00
10 changed files with 211 additions and 144 deletions

264
PLAN.md
View File

@@ -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 Released. FIDO2/passphrase/keyfile/TPM2 unlock with method sequencing,
fallback, privileged mount/unmount, config aliases with method idempotent mount/unmount, config aliases, init command, status command,
sequencing, init and status commands. NixOS LD_LIBRARY_PATH workaround for cryptsetup token plugins, unit
tests, nix flake packaging.
## 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.
--- ---
## 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` #### Files changed
- `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
- `cmd/root.go` - `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` - `flake.nix`
### Work #### Work
1. Update `README.md`: 1. Cobra provides `completion` subcommand by default (`arca completion
- Replace placeholder UUIDs with realistic examples from actual zsh`, `arca completion bash`, etc.). Verify it works.
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.
2. Verify `flake.nix`: 2. Add a custom `ValidArgsFunction` to `mountCmd` and `unmountCmd`
- Run `nix build` and confirm it produces a working binary. that reads the config and returns alias names + device paths from
- Add `-ldflags` for version injection from `self.rev` or a `arca status`. This gives dynamic completion for:
`version` variable. ```
- Test the flake output: `./result/bin/arca --version`. arca mount <TAB> -> backup media /dev/sda1
arca unmount <TAB> -> 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 <device>`
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 <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 - `--dry-run` flag
- `--json` output for `status` - `--json` output for `status`
- udev auto-mount on plug - udev auto-mount on plug
- Keyfile creation/management - Keyfile creation/management
- Multiple config files or config includes - Multiple config files or config includes
- Systemd mount unit integration

25
cmd/completion.go Normal file
View File

@@ -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
}

View File

@@ -19,6 +19,7 @@ var mountCmd = &cobra.Command{
Short: "Unlock and mount a LUKS volume", Short: "Unlock and mount a LUKS volume",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runMount, RunE: runMount,
ValidArgsFunction: completeDeviceOrAlias,
} }
func init() { func init() {

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"git.wntrmute.dev/kyle/arca/internal/verbose"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -12,6 +13,10 @@ var rootCmd = &cobra.Command{
Short: "Mount and unmount LUKS-encrypted volumes", Short: "Mount and unmount LUKS-encrypted volumes",
} }
func init() {
rootCmd.PersistentFlags().BoolVarP(&verbose.Enabled, "verbose", "v", false, "print debug information to stderr")
}
func SetVersion(v string) { func SetVersion(v string) {
rootCmd.Version = v rootCmd.Version = v
} }

View File

@@ -15,6 +15,7 @@ var unmountCmd = &cobra.Command{
Short: "Unmount and lock a LUKS volume", Short: "Unmount and lock a LUKS volume",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runUnmount, RunE: runUnmount,
ValidArgsFunction: completeDeviceOrAlias,
} }
func init() { func init() {

View File

@@ -10,7 +10,7 @@
let let
system = "x86_64-linux"; system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
version = "1.0.0"; version = "1.1.0";
in in
{ {
packages.${system}.default = pkgs.buildGoModule { packages.${system}.default = pkgs.buildGoModule {
@@ -21,6 +21,14 @@
ldflags = [ ldflags = [
"-X main.version=${version}" "-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
'';
}; };
}; };
} }

View File

@@ -5,6 +5,9 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"git.wntrmute.dev/kyle/arca/internal/verbose"
) )
// Open opens a LUKS device using cryptsetup with token-based unlock. // Open opens a LUKS device using cryptsetup with token-based unlock.
@@ -12,6 +15,8 @@ func Open(devicePath, mapperName string) error {
args := withTokenPluginEnv([]string{"cryptsetup", "open", devicePath, mapperName, "--token-only"}) args := withTokenPluginEnv([]string{"cryptsetup", "open", devicePath, mapperName, "--token-only"})
args = withPrivilege(args) args = withPrivilege(args)
verbose.Printf("exec: %s", strings.Join(args, " "))
cmd := exec.Command(args[0], args[1:]...) cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
@@ -101,6 +106,7 @@ func findTokenPluginDir() string {
// NixOS stable symlink — survives rebuilds. // NixOS stable symlink — survives rebuilds.
const nixSystemPath = "/run/current-system/sw/lib/cryptsetup" const nixSystemPath = "/run/current-system/sw/lib/cryptsetup"
if hasTokenPlugins(nixSystemPath) { if hasTokenPlugins(nixSystemPath) {
verbose.Printf("token plugin dir: %s", nixSystemPath)
return nixSystemPath return nixSystemPath
} }
@@ -109,11 +115,13 @@ func findTokenPluginDir() string {
if resolved, err := filepath.EvalSymlinks(bin); err == nil { if resolved, err := filepath.EvalSymlinks(bin); err == nil {
dir := filepath.Join(filepath.Dir(filepath.Dir(resolved)), "lib", "cryptsetup") dir := filepath.Join(filepath.Dir(filepath.Dir(resolved)), "lib", "cryptsetup")
if hasTokenPlugins(dir) { if hasTokenPlugins(dir) {
verbose.Printf("token plugin dir (from systemd-cryptenroll): %s", dir)
return dir return dir
} }
} }
} }
verbose.Printf("no token plugin directory found")
return "" return ""
} }

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"git.wntrmute.dev/kyle/arca/internal/verbose"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -18,6 +19,7 @@ func NewClient() (*Client, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot connect to udisks2 — is the udisks2 service running? (%w)", err) return nil, fmt.Errorf("cannot connect to udisks2 — is the udisks2 service running? (%w)", err)
} }
verbose.Printf("connected to system D-Bus")
return &Client{conn: conn}, nil return &Client{conn: conn}, nil
} }

View File

@@ -8,6 +8,7 @@ import (
"git.wntrmute.dev/kyle/arca/internal/cryptsetup" "git.wntrmute.dev/kyle/arca/internal/cryptsetup"
"git.wntrmute.dev/kyle/arca/internal/udisks" "git.wntrmute.dev/kyle/arca/internal/udisks"
"git.wntrmute.dev/kyle/arca/internal/verbose"
) )
// Result holds the outcome of a successful unlock. // Result holds the outcome of a successful unlock.
@@ -35,12 +36,16 @@ func New(client *udisks.Client, opts Options) *Unlocker {
// Unlock tries each method in order and returns the result on first success. // Unlock tries each method in order and returns the result on first success.
func (u *Unlocker) Unlock(dev *udisks.BlockDevice, methods []string) (*Result, error) { func (u *Unlocker) Unlock(dev *udisks.BlockDevice, methods []string) (*Result, error) {
verbose.Printf("unlock %s: methods %v", dev.DevicePath, methods)
var errs []error var errs []error
for _, method := range methods { for _, method := range methods {
verbose.Printf("trying method: %s", method)
res, err := u.tryMethod(dev, method) res, err := u.tryMethod(dev, method)
if err == nil { if err == nil {
verbose.Printf("method %s succeeded", method)
return res, nil return res, nil
} }
verbose.Printf("method %s failed: %v", method, err)
errs = append(errs, fmt.Errorf("%s: %w", method, err)) errs = append(errs, fmt.Errorf("%s: %w", method, err))
} }
return nil, fmt.Errorf("all unlock methods failed for %s:\n%w", dev.DevicePath, errors.Join(errs...)) return nil, fmt.Errorf("all unlock methods failed for %s:\n%w", dev.DevicePath, errors.Join(errs...))

View File

@@ -0,0 +1,16 @@
package verbose
import (
"fmt"
"os"
)
// Enabled is set by the root command's --verbose flag.
var Enabled bool
// Printf prints to stderr if verbose mode is enabled.
func Printf(format string, args ...any) {
if Enabled {
fmt.Fprintf(os.Stderr, "arca: "+format+"\n", args...)
}
}