Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions credstore/migration.go
Original file line number Diff line number Diff line change
@@ -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 `<cli> 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}
}
165 changes: 165 additions & 0 deletions credstore/migration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading