carlos / platform Public

fix: trim the account name once, before validating and minting it

runAccountsCreate validated a TrimSpace'd copy of -name but handed the
untrimmed original to createAccountInBucket/createAccountViaConsole,
so "carlos accounts create -name ' kass '" passed ValidAccountName on
the trimmed value yet minted " kass " through the one edge that writes
straight to the bucket — a name the validator itself would reject,
landing in storage anyway.

Fix: trim *name once, immediately after fs.Parse, and use that trimmed
value for the empty check, ValidAccountName, both mint paths, and the
fatal message.

Adds TestAccountsCreateTrimsTheNameBeforeMinting (regression for this
bug) and TestAccountsCreateRefusesANonSlugName (reviewer's requested
sibling to TestAccountsCreateNeedsAnOwner, covering the third edge's
shape refusal) to auth_e2e_test.go.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGdTfzwT492ysLPDaWfpvJ
Paul Campbell 15209ae724adac2e7a878b669b659136605c9528 parent 3ca3764
2 files changed, +69 −2
  • accounts.go +3 −2
  • auth_e2e_test.go +66 −0
diff --git a/accounts.go b/accounts.go
index 43fd3f125..3b3d9911f 100644
--- a/accounts.go
+++ b/accounts.go
@@ -84,10 +84,11 @@ func runAccountsCreate(args []string) {
"and what to use when this terminal is not logged in")
console := fs.String("console", "", "which logged-in console to create it through (only needed with several)")
fs.Parse(args)
- if strings.TrimSpace(*name) == "" {
+ *name = strings.TrimSpace(*name)
+ if *name == "" {
log.Fatal("usage: carlos accounts create -name <name> [-owner <address>]")
}
- if !domains.ValidAccountName(strings.TrimSpace(*name)) {
+ if !domains.ValidAccountName(*name) {
log.Fatalf("accounts create: %q is not a usable account name — 1-63 characters of a-z, 0-9\n"+
"and hyphen, no leading or trailing hyphen. The name appears in console URLs and\n"+
"resolves -account.", *name)
diff --git a/auth_e2e_test.go b/auth_e2e_test.go
index 936a409ac..c1d0bb82e 100644
--- a/auth_e2e_test.go
+++ b/auth_e2e_test.go
@@ -134,6 +134,35 @@ func TestAccountsCreateNeedsAnOwner(t *testing.T) {
}
}
+// A non-slug name is refused before anything is minted — this is the third
+// (and only bucket-writing) door, so a shape mistake here must not reach
+// domains.MintAccount at all. -owner is supplied so the refusal is
+// unambiguously about the NAME, not the ownerless-account path above.
+func TestAccountsCreateRefusesANonSlugName(t *testing.T) {
+ bin := buildCarlos(t)
+ tmp := t.TempDir()
+ storeDir := filepath.Join(tmp, "store")
+ env := append(os.Environ(),
+ "CARLOS_DEPLOYMENT_DIR="+storeDir,
+ "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"),
+ "CARLOS_DATA_DIR="+tmp,
+ )
+ cmd := exec.Command(bin, "accounts", "create", "-name", "Paul's Stuff!", "-owner", "paul@keymail.dev")
+ cmd.Env = env
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatalf("non-slug accounts create exited 0:\n%s", out)
+ }
+ if !strings.Contains(string(out), "not a usable account name") {
+ t.Fatalf("refusal does not name the shape rule:\n%s", out)
+ }
+ // Nothing was minted: a rejected name must not burn an id or leave a
+ // half-made account in the bucket.
+ if keys, err := release.NewDirStore(storeDir).List(t.Context(), "accounts/"); err != nil || len(keys) != 0 {
+ t.Fatalf("a refused create left %v behind (%v)", keys, err)
+ }
+}
+
// The headless path: -owner names the owner outright, no login, no browser,
// and the address lands in the manifest every box materializes identity from.
func TestAccountsCreateWithOwnerFlag(t *testing.T) {
@@ -171,6 +200,43 @@ func TestAccountsCreateWithOwnerFlag(t *testing.T) {
}
}
+// A padded name is trimmed BEFORE it is validated or minted — the gate and
+// the write must agree on what "the name" is. Before this fix, -name "
+// kass " passed ValidAccountName on the trimmed copy but handed the
+// untrimmed original to MintAccount, landing padding in the manifest
+// through the one edge that writes straight to the bucket.
+func TestAccountsCreateTrimsTheNameBeforeMinting(t *testing.T) {
+ bin := buildCarlos(t)
+ tmp := t.TempDir()
+ storeDir := filepath.Join(tmp, "store")
+ cmd := exec.Command(bin, "accounts", "create", "-name", " kass ", "-owner", "paul@keymail.dev")
+ cmd.Env = append(os.Environ(),
+ "CARLOS_DEPLOYMENT_DIR="+storeDir,
+ "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"),
+ "CARLOS_DATA_DIR="+tmp,
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("carlos accounts create -name ' kass ': %v\n%s", err, out)
+ }
+ store := release.NewDirStore(storeDir)
+ keys, err := store.List(t.Context(), "accounts/")
+ if err != nil || len(keys) != 1 {
+ t.Fatalf("account manifests = %v (%v), want exactly one", keys, err)
+ }
+ body, _, err := store.Get(t.Context(), keys[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m domains.AccountManifest
+ if err := json.Unmarshal(body, &m); err != nil {
+ t.Fatal(err)
+ }
+ if m.Name != "kass" {
+ t.Fatalf("manifest name = %q, want the trimmed %q", m.Name, "kass")
+ }
+}
+
// Logged in, no -owner: the account is created THROUGH the console, so the
// person at the terminal is its owner from the first instant.
func TestAccountsCreateUsesTheLoggedInIdentity(t *testing.T) {