diff --git a/credstore/migration.go b/credstore/migration.go new file mode 100644 index 0000000..11164fd --- /dev/null +++ b/credstore/migration.go @@ -0,0 +1,140 @@ +package credstore + +// This file implements the package-level migration helpers per the Open +// CLI Collective Secret-Handling Standard §2.1 (line 412) and §1.8 +// (migration from legacy plaintext config). credstore owns only the +// *formats* and the conflict *error*: the one-line stderr signal, the +// machine-readable _migration JSON object, and the legacy-vs-keyring +// conflict error. The migration mechanics (reading legacy config, moving +// the secret into the keyring via a Store, rewriting the file) and +// conflict *detection* (comparing the two values) stay per-CLI — this +// package never sees a secret value, which is also why the conflict +// error is leak-proof by construction (§1.8 line 254 / §1.12). + +import ( + "errors" + "fmt" + "io" + "os" +) + +// MigrationSchemaVersion is the _migration object's schema version +// (§1.8). Bumped only on a breaking change to the JSON shape. +const MigrationSchemaVersion = 1 + +// MigrationFieldName is the top-level JSON key under which the migration +// signal is emitted (§1.8). +const MigrationFieldName = "_migration" + +// formatMigrationLine renders the one-time human stderr signal exactly as +// §1.8 (line 251) specifies, without a trailing newline so it composes. +// Unexported: not public API beyond §2.1; EmitMigrationStderr is the +// supported entry point. White-box tests call this directly. +func formatMigrationLine(field, ref string) string { + return fmt.Sprintf("migrated %s to keyring at %s; this is a one-time operation", field, ref) +} + +// emitMigration writes the one-time migration line to w. Unexported +// writer seam so tests inject a buffer instead of mutating the global +// os.Stderr file descriptor (consistent with the package's pure/impure +// seam convention, e.g. formatMigrationLine vs the public entry point). +func emitMigration(w io.Writer, field, ref string) { + // Best-effort diagnostic line (like the stdlib fmt.Println family): a + // failed stderr write has no actionable recovery and must not change + // the standard-mandated EmitMigrationStderr(field, ref) signature. + _, _ = fmt.Fprintln(w, formatMigrationLine(field, ref)) +} + +// EmitMigrationStderr prints the one-time migration signal to stderr +// (§1.8). A CLI calls this on the run where it moved a legacy plaintext +// field into the keyring. field is the legacy config field name; ref is +// the credential ref it now lives under. Never include a secret value in +// either argument — by contract these are descriptive identifiers only. +func EmitMigrationStderr(field, ref string) { + emitMigration(os.Stderr, field, ref) +} + +// MigrationChange is one entry in the _migration signal: which legacy +// field moved, and the descriptive opaque from/to locations. from and to +// are location descriptors (e.g. "config:legacy_plaintext", +// "keyring:atlassian-cli/default/api_token") — never the value (§1.8). +type MigrationChange struct { + Field string `json:"field"` + From string `json:"from"` + To string `json:"to"` +} + +// MigrationBlock is the value of the _migration field: a schema version +// plus the list of changes that occurred on this run. A CLI that embeds +// the signal as a field of its own response type tags that field +// `json:"_migration"` with this as the value. +type MigrationBlock struct { + Version int `json:"version"` + Changes []MigrationChange `json:"changes"` +} + +// MigrationObject is the standalone §1.8 object — it marshals to exactly +// {"_migration":{"version":1,"changes":[...]}}. For CLIs that merge +// objects into their JSON response rather than embedding a struct field. +type MigrationObject struct { + Migration MigrationBlock `json:"_migration"` +} + +// MigrationJSONEntry constructs one MigrationChange (the §2.1-named +// helper). field is the legacy config field; from/to are opaque location +// descriptors — never the secret value. +func MigrationJSONEntry(field, from, to string) MigrationChange { + return MigrationChange{Field: field, From: from, To: to} +} + +// NewMigrationBlock builds the _migration value with the current schema +// version and a non-nil Changes slice (so it marshals "changes":[], +// never null, on the degenerate empty call). +func NewMigrationBlock(changes ...MigrationChange) MigrationBlock { + if changes == nil { + changes = []MigrationChange{} + } + return MigrationBlock{Version: MigrationSchemaVersion, Changes: changes} +} + +// NewMigrationObject builds the standalone {"_migration":{...}} object. +func NewMigrationObject(changes ...MigrationChange) MigrationObject { + return MigrationObject{Migration: NewMigrationBlock(changes...)} +} + +// ErrMigrationConflict is the stable identity of the error +// MigrationConflictError returns. errors.Is(err, ErrMigrationConflict) +// holds regardless of the message text, mirroring the PR6 +// ErrSecretLeaked/leakError pattern. +var ErrMigrationConflict = errors.New("credstore: legacy plaintext value conflicts with existing keyring value") + +// migrationConflictError carries the actionable message while reporting +// the stable ErrMigrationConflict identity to errors.Is. +type migrationConflictError struct{ msg string } + +func (e *migrationConflictError) Error() string { return e.msg } + +func (e *migrationConflictError) Is(target error) bool { return target == ErrMigrationConflict } + +// MigrationConflictError builds the §1.8 (line 254) conflict error: the +// legacy plaintext value differs from the value already in the keyring, +// so the CLI must not silently pick a winner. The message names both +// locations, states that the values differ, and offers the three +// remediation options. It is leak-proof by construction: it takes no +// value argument, so it cannot print either value, masked or unmasked +// (§1.8 line 254 / §1.12). +// +// cli is the tool name (for the ` config clear` remediation); field +// is the conflicting legacy field; legacyLocation is a human description +// of where the plaintext lives (e.g. the config file path); ref is the +// keyring ref holding the existing value. +func MigrationConflictError(cli, field, legacyLocation, ref string) error { + msg := fmt.Sprintf( + "credstore: %s: the legacy plaintext value at %s differs from the existing keyring value at %s; "+ + "refusing to silently pick a winner. Resolve with one of:\n"+ + " - run `%s config clear` then re-run (keeps the legacy plaintext value, removes the keyring entry)\n"+ + " - manually delete the `%s` field from %s (keeps the keyring value)\n"+ + " - re-run with --overwrite (forces the legacy plaintext into the keyring, replacing the existing entry)", + field, legacyLocation, ref, cli, field, legacyLocation) + return &migrationConflictError{msg: msg} +} diff --git a/credstore/migration_test.go b/credstore/migration_test.go new file mode 100644 index 0000000..b802f7d --- /dev/null +++ b/credstore/migration_test.go @@ -0,0 +1,165 @@ +package credstore + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestFormatMigrationLine(t *testing.T) { + got := formatMigrationLine("api_token", "atlassian-cli/default") + want := "migrated api_token to keyring at atlassian-cli/default; this is a one-time operation" + if got != want { + t.Fatalf("formatMigrationLine = %q, want %q", got, want) + } + // No trailing newline (composable). + if strings.HasSuffix(got, "\n") { + t.Fatalf("must not carry a trailing newline: %q", got) + } + // Inputs are Sprintf *arguments*, not the format string: a fmt verb + // in field/ref must appear literally, never be interpreted. + if g := formatMigrationLine("%s%d", "r%n"); g != "migrated %s%d to keyring at r%n; this is a one-time operation" { + t.Fatalf("fmt verb in input not literal: %q", g) + } +} + +func TestEmitMigration(t *testing.T) { + // Drive the writer seam with an injected buffer — no global os.Stderr + // mutation, so this is parallelism-safe and surfaces no swallowed I/O + // errors. EmitMigrationStderr is the trivial os.Stderr delegate. + var buf bytes.Buffer + emitMigration(&buf, "api_token", "atlassian-cli/default") + want := "migrated api_token to keyring at atlassian-cli/default; this is a one-time operation\n" + if buf.String() != want { + t.Fatalf("emitMigration wrote %q, want %q", buf.String(), want) + } +} + +func TestMigrationJSONShapeByteForByte(t *testing.T) { + // The §1.8 example, byte-for-byte (struct field order fixes key order). + obj := NewMigrationObject(MigrationJSONEntry( + "api_token", "config:legacy_plaintext", "keyring:atlassian-cli/default/api_token")) + b, err := json.Marshal(obj) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"_migration":{"version":1,"changes":[{"field":"api_token","from":"config:legacy_plaintext","to":"keyring:atlassian-cli/default/api_token"}]}}` + if string(b) != want { + t.Fatalf("marshal =\n %s\nwant\n %s", b, want) + } + + // Empty changes → "changes":[] (never null), version still 1. + be, _ := json.Marshal(NewMigrationObject()) + if string(be) != `{"_migration":{"version":1,"changes":[]}}` { + t.Fatalf("empty object = %s", be) + } + + // Multiple changes — exact bytes (order + every from/to field pinned, + // not just a positional substring probe). + bm, _ := json.Marshal(NewMigrationObject( + MigrationJSONEntry("a", "config:legacy_plaintext", "keyring:svc/default/a"), + MigrationJSONEntry("b", "config:legacy_plaintext", "keyring:svc/default/b"), + )) + wantMulti := `{"_migration":{"version":1,"changes":[` + + `{"field":"a","from":"config:legacy_plaintext","to":"keyring:svc/default/a"},` + + `{"field":"b","from":"config:legacy_plaintext","to":"keyring:svc/default/b"}]}}` + if string(bm) != wantMulti { + t.Fatalf("multi-change =\n %s\nwant\n %s", bm, wantMulti) + } + + // Version constant is what the standard pins. + if MigrationSchemaVersion != 1 || MigrationFieldName != "_migration" { + t.Fatalf("schema constants drifted: v=%d field=%q", MigrationSchemaVersion, MigrationFieldName) + } +} + +func TestMigrationJSONEntryAndBlockDirect(t *testing.T) { + // MigrationJSONEntry in isolation. + if e := MigrationJSONEntry("f", "from-loc", "to-loc"); e != (MigrationChange{Field: "f", From: "from-loc", To: "to-loc"}) { + t.Fatalf("MigrationJSONEntry = %+v", e) + } + + // NewMigrationBlock called directly: version pinned, Changes non-nil + // even on the empty call (the changes==nil guard) so it marshals []. + empty := NewMigrationBlock() + if empty.Version != 1 || empty.Changes == nil || len(empty.Changes) != 0 { + t.Fatalf("NewMigrationBlock() = %+v, want version 1 + non-nil empty Changes", empty) + } + if b, _ := json.Marshal(empty); string(b) != `{"version":1,"changes":[]}` { + t.Fatalf("empty block = %s", b) + } + + // Documented primary embedding mode: MigrationBlock as a caller's own + // json:"_migration" struct field marshals to the exact §1.8 shape. + type cliResponse struct { + Migration MigrationBlock `json:"_migration"` + Result string `json:"result"` + } + resp := cliResponse{ + Migration: NewMigrationBlock(MigrationJSONEntry("api_token", "config:legacy_plaintext", "keyring:svc/default/api_token")), + Result: "ok", + } + b, _ := json.Marshal(resp) + want := `{"_migration":{"version":1,"changes":[{"field":"api_token","from":"config:legacy_plaintext","to":"keyring:svc/default/api_token"}]},"result":"ok"}` + if string(b) != want { + t.Fatalf("embedded field =\n %s\nwant\n %s", b, want) + } +} + +func TestMigrationJSONEscaping(t *testing.T) { + // "byte-for-byte" must survive JSON-special characters in inputs: + // encoding/json escapes them; the contract still holds. + b, _ := json.Marshal(NewMigrationObject( + MigrationJSONEntry(`we"ird\field`, "config:legacy_plaintext", "keyring:svc/默认/tok"))) + want := `{"_migration":{"version":1,"changes":[{"field":"we\"ird\\field","from":"config:legacy_plaintext","to":"keyring:svc/默认/tok"}]}}` + if string(b) != want { + t.Fatalf("escaping =\n %s\nwant\n %s", b, want) + } + var rt MigrationObject + if err := json.Unmarshal(b, &rt); err != nil || rt.Migration.Changes[0].Field != `we"ird\field` { + t.Fatalf("escaping round-trip: err=%v got=%q", err, rt.Migration.Changes[0].Field) + } +} + +func TestMigrationObjectRoundTrip(t *testing.T) { + in := NewMigrationObject(MigrationJSONEntry("tok", "config:legacy_plaintext", "keyring:svc/default/tok")) + b, _ := json.Marshal(in) + var out MigrationObject + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.Migration.Version != 1 || len(out.Migration.Changes) != 1 || + out.Migration.Changes[0] != (MigrationChange{Field: "tok", From: "config:legacy_plaintext", To: "keyring:svc/default/tok"}) { + t.Fatalf("round-trip mismatch: %+v", out) + } +} + +func TestMigrationConflictError(t *testing.T) { + const ( + cli = "jtk" + fld = "api_token" + loc = "/home/u/.config/jira-ticket-cli/config.yml" + ref = "atlassian-cli/default" + ) + err := MigrationConflictError(cli, fld, loc, ref) + + if !errors.Is(err, ErrMigrationConflict) { + t.Fatalf("must match ErrMigrationConflict sentinel, got %v", err) + } + + // Golden message: exact, spec-format-pinned. This single assertion + // subsumes "names both locations" + "all three options" AND proves + // leak-proofness — the message is fully determined by the four + // non-value identifiers, so nothing else (no value) can appear. + want := "credstore: api_token: the legacy plaintext value at " + + "/home/u/.config/jira-ticket-cli/config.yml differs from the existing keyring " + + "value at atlassian-cli/default; refusing to silently pick a winner. Resolve with one of:\n" + + " - run `jtk config clear` then re-run (keeps the legacy plaintext value, removes the keyring entry)\n" + + " - manually delete the `api_token` field from /home/u/.config/jira-ticket-cli/config.yml (keeps the keyring value)\n" + + " - re-run with --overwrite (forces the legacy plaintext into the keyring, replacing the existing entry)" + if got := err.Error(); got != want { + t.Fatalf("conflict message =\n%q\nwant\n%q", got, want) + } +}