3 Commits

Author SHA1 Message Date
steve 3c5e0a26f3 chore(release): default installer to v0.5.2
release / release (push) Successful in 43s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:05:31 +01:00
steve 456d25d4f3 fix(cli): clearer whitelist usage errors
`whitelist <in|out> <add|remove|list>` has two positional slots; omitting
either let a --flag slide into the slot and produced a misleading
"--account is required". Validate the direction and the subcommand up
front, before flag parsing, so the real mistake is reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:04:40 +01:00
steve 3bea73f857 fix(store): expand a leading ~ in EMCLI_DB
A literal "~/..." in EMCLI_DB has no shell to expand it, so SQLite opened
it relative to the cwd and silently created a stray "~" directory tree.
Expand a leading "~" or "~/" to the user's home dir; "~user", mid-path
tildes, and absolute/relative paths are left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:04:40 +01:00
6 changed files with 120 additions and 8 deletions
+12 -3
View File
@@ -330,7 +330,19 @@ func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
return 2
}
dir := store.Direction(args[0])
if dir != store.DirIn && dir != store.DirOut {
fmt.Fprintf(errOut, "whitelist direction must be \"in\" or \"out\", got %q\n", args[0])
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
return 2
}
sub, rest := args[1], args[2:]
switch sub {
case "add", "remove", "list": // valid
default:
fmt.Fprintf(errOut, "unknown whitelist subcommand %q (want add|remove|list)\n", sub)
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
return 2
}
fs := flag.NewFlagSet("whitelist", flag.ContinueOnError)
fs.SetOutput(errOut)
account := fs.String("account", "", "account name")
@@ -371,9 +383,6 @@ func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
for _, a := range addrs {
fmt.Fprintln(out, a)
}
default:
fmt.Fprintf(errOut, "unknown whitelist subcommand %q\n", sub)
return 2
}
return 0
}
+45
View File
@@ -110,6 +110,51 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
}
}
// A missing direction (e.g. `whitelist list`) must report the real problem —
// the in|out direction — not the misleading "--account is required".
func TestWhitelistMissingDirectionReported(t *testing.T) {
adminEnv(t)
code, _, errOut := run(t, "whitelist", "list", "--account", "bobby")
if code == 0 {
t.Fatal("missing direction must be a usage error")
}
if strings.Contains(errOut, "--account is required") {
t.Fatalf("misleading error; want a direction complaint, got: %q", errOut)
}
if !strings.Contains(errOut, "in") || !strings.Contains(errOut, "out") {
t.Fatalf("error should name the in|out direction, got: %q", errOut)
}
}
// A missing subcommand (e.g. `whitelist out --account x`) must report the real
// problem — the add|remove|list subcommand — not "--account is required".
func TestWhitelistMissingSubcommandReported(t *testing.T) {
adminEnv(t)
code, _, errOut := run(t, "whitelist", "out", "--account", "bobby")
if code == 0 {
t.Fatal("missing subcommand must be a usage error")
}
if strings.Contains(errOut, "--account is required") {
t.Fatalf("misleading error; want a subcommand complaint, got: %q", errOut)
}
if !strings.Contains(errOut, "add") || !strings.Contains(errOut, "list") {
t.Fatalf("error should name the add|remove|list subcommand, got: %q", errOut)
}
}
// The happy path still works after the direction/subcommand validation.
func TestWhitelistListWorks(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "--name", "bobby", "--imap-host", "h", "--username", "u@x.com")
if code, _, e := run(t, "whitelist", "out", "add", "--account", "bobby", "--address", "@x.com"); code != 0 {
t.Fatalf("add failed: %s", e)
}
code, out, _ := run(t, "whitelist", "out", "list", "--account", "bobby")
if code != 0 || !strings.Contains(out, "@x.com") {
t.Fatalf("list: code=%d out=%q", code, out)
}
}
func TestAuditListCoreRenders(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil {
+16 -1
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"runtime"
"strconv"
"strings"
_ "modernc.org/sqlite"
)
@@ -77,10 +78,24 @@ func (s *Store) migrate() error {
func (s *Store) Close() error { return s.db.Close() }
// expandUserHome replaces a leading "~" or "~/" in p with the user's home
// directory. Only a leading tilde is expanded (the usual shell convention) —
// "~user" and a tilde elsewhere in the path are left untouched. This guards
// against an EMCLI_DB set to a literal "~/..." (no shell to expand it), which
// would otherwise be opened relative to the cwd and create a stray "~" dir.
func expandUserHome(p string) string {
if p == "~" || strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, strings.TrimPrefix(p[1:], "/"))
}
}
return p
}
// DefaultDBPath resolves EMCLI_DB or the per-OS default location.
func DefaultDBPath() (string, error) {
if p := os.Getenv("EMCLI_DB"); p != "" {
return p, nil
return expandUserHome(p), nil
}
if runtime.GOOS == "windows" {
if dir := os.Getenv("AppData"); dir != "" {
+43
View File
@@ -2,10 +2,53 @@ package store
import (
"database/sql"
"os"
"path/filepath"
"strings"
"testing"
)
// A leading "~" in EMCLI_DB must be expanded to the home dir, so a literal
// tilde (no shell to expand it) can't be opened relative to the cwd and
// silently create a stray "~" directory.
func TestDefaultDBPathExpandsLeadingTilde(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no home dir: %v", err)
}
cases := map[string]string{
"~/.config/emcli/emcli.db": filepath.Join(home, ".config", "emcli", "emcli.db"),
"~": home,
}
for in, want := range cases {
t.Setenv("EMCLI_DB", in)
got, err := DefaultDBPath()
if err != nil {
t.Fatalf("DefaultDBPath(%q): %v", in, err)
}
if got != want {
t.Fatalf("EMCLI_DB=%q -> %q, want %q", in, got, want)
}
if strings.Contains(got, "~") {
t.Fatalf("EMCLI_DB=%q left a literal tilde: %q", in, got)
}
}
}
// A non-leading tilde or "~user" is NOT a path we should rewrite — leave it be.
func TestDefaultDBPathLeavesOtherPathsUntouched(t *testing.T) {
for _, p := range []string{"/var/lib/emcli.db", "./rel/emcli.db", "~user/db"} {
t.Setenv("EMCLI_DB", p)
got, err := DefaultDBPath()
if err != nil {
t.Fatalf("DefaultDBPath(%q): %v", p, err)
}
if got != p {
t.Fatalf("EMCLI_DB=%q was rewritten to %q", p, got)
}
}
}
// openTemp opens a fresh store in a temp dir and initialises keys so that
// account tests (which do crypto) work without needing their own setup.
func openTemp(t *testing.T) *Store {
+1 -1
View File
@@ -54,7 +54,7 @@ checksum, makes it executable in `~/.local/bin` (ensure that's on your PATH), an
| Variable | Default | Purpose |
|---|---|---|
| `EMCLI_VERSION` | `v0.5.1` | Release tag to fetch |
| `EMCLI_VERSION` | `v0.5.2` | Release tag to fetch |
| `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL |
| `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
+3 -3
View File
@@ -7,17 +7,17 @@
# bash install.sh
#
# Environment overrides:
# EMCLI_VERSION release tag to fetch (default: v0.5.1)
# EMCLI_VERSION release tag to fetch (default: v0.5.2)
# EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli)
# EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin)
#
# Release assets follow this naming scheme:
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.5.1_linux_amd64
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.5.2_linux_amd64
# checksums.txt (sha256, one "<sum> <asset>" line per asset)
set -euo pipefail
VERSION="${EMCLI_VERSION:-v0.5.1}"
VERSION="${EMCLI_VERSION:-v0.5.2}"
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"