From da7619c3fb544664a1ba42546b9134b657a36186 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 16 May 2026 12:46:48 -0400 Subject: [PATCH 1/3] feat(credstore): migration helpers (stderr line, _migration JSON, conflict error) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the migration surface from the Secret-Handling Standard §2.1 (line 412) + §1.8 — the final shared-library unit before per-CLI migration: - EmitMigrationStderr(field, ref) — the one-time human signal "migrated to keyring at ; this is a one-time operation"; unexported formatMigrationLine seam - MigrationJSONEntry / MigrationBlock / MigrationObject (+ New*) — marshal byte-for-byte to the §1.8 shape {"_migration":{"version":1,"changes":[...]}}, empty -> "changes":[] not null; from/to are opaque locations, never the value - MigrationConflictError + ErrMigrationConflict sentinel — §1.8 line 254 conflict: names both locations, states they differ, offers the three remediation options; leak-proof by construction (no value argument) credstore owns formats + the conflict error only; migration mechanics and conflict detection remain per-CLI. No new dependencies. [INT-435] Closes #13 --- credstore/migration.go | 128 +++++++++++++++++++++++++++++++++++ credstore/migration_test.go | 130 ++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 credstore/migration.go create mode 100644 credstore/migration_test.go diff --git a/credstore/migration.go b/credstore/migration.go new file mode 100644 index 0000000..6e1ec65 --- /dev/null +++ b/credstore/migration.go @@ -0,0 +1,128 @@ +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" + "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) +} + +// 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) { + fmt.Fprintln(os.Stderr, formatMigrationLine(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..497613a --- /dev/null +++ b/credstore/migration_test.go @@ -0,0 +1,130 @@ +package credstore + +import ( + "encoding/json" + "errors" + "io" + "os" + "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) + } +} + +func TestEmitMigrationStderr(t *testing.T) { + // Capture os.Stderr and os.Stdout via pipes to prove the line goes to + // stderr (with a newline) and stdout is untouched. + origErr, origOut := os.Stderr, os.Stdout + er, ew, _ := os.Pipe() + or, ow, _ := os.Pipe() + os.Stderr, os.Stdout = ew, ow + t.Cleanup(func() { os.Stderr, os.Stdout = origErr, origOut }) + + EmitMigrationStderr("api_token", "atlassian-cli/default") + + _ = ew.Close() + _ = ow.Close() + gotErr, _ := io.ReadAll(er) + gotOut, _ := io.ReadAll(or) + + want := "migrated api_token to keyring at atlassian-cli/default; this is a one-time operation\n" + if string(gotErr) != want { + t.Fatalf("stderr = %q, want %q", gotErr, want) + } + if len(gotOut) != 0 { + t.Fatalf("stdout must be untouched, got %q", gotOut) + } +} + +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 preserved in order. + bm, _ := json.Marshal(NewMigrationObject( + MigrationJSONEntry("a", "config:legacy_plaintext", "keyring:svc/default/a"), + MigrationJSONEntry("b", "config:legacy_plaintext", "keyring:svc/default/b"), + )) + if !strings.Contains(string(bm), `"field":"a"`) || + strings.Index(string(bm), `"field":"a"`) > strings.Index(string(bm), `"field":"b"`) { + t.Fatalf("multi-change order wrong: %s", bm) + } + + // Version constant is what the standard pins. + if MigrationSchemaVersion != 1 || MigrationFieldName != "_migration" { + t.Fatalf("schema constants drifted: v=%d field=%q", MigrationSchemaVersion, MigrationFieldName) + } +} + +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) + } + msg := err.Error() + + // Names both locations, states they differ. + for _, want := range []string{cli, fld, loc, ref, "differs", "credstore:"} { + if !strings.Contains(msg, want) { + t.Fatalf("message missing %q: %q", want, msg) + } + } + // All three remediation options present (§1.8 lines 256-258). + for _, opt := range []string{"config clear", "manually delete", "--overwrite"} { + if !strings.Contains(msg, opt) { + t.Fatalf("message missing remediation %q: %q", opt, msg) + } + } + // Leak-proof by construction: there is no value parameter, so no + // secret value can appear. Sanity-check that the constructor never + // echoes anything resembling a credential (only the identifiers we + // passed are present). + if strings.Contains(msg, "BEGIN") || strings.Contains(msg, "xoxb-") { + t.Fatalf("unexpected value-like content: %q", msg) + } +} From 73adb5c9329d3d1749e175acc937cf157ef762b2 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 16 May 2026 12:51:46 -0400 Subject: [PATCH 2/3] test(credstore): tighten migration tests per TDD assessment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent TDD review (adequate-with-gaps), all addressed in tests only (no production change): - conflict error: replace the no-op "BEGIN/xoxb-" sanity check and substring probes with a single exact golden-message assertion — subsumes both-locations + three-options and proves leak-proofness (message fully determined by the four non-value identifiers) - multi-change JSON: exact bytes instead of strings.Index ordering - direct NewMigrationBlock coverage (version + changes==nil guard + "changes":[]) and the documented embedded-field mode (MigrationBlock behind a caller's json:"_migration" field) - MigrationJSONEntry tested in isolation - JSON-escaping adversarial case (quote/backslash/unicode in field and to-location) with round-trip - formatMigrationLine fmt-verb-inertness (inputs are args, not the format string) Declined (with rationale): the os.Stderr pipe-swap is not goroutine-safe — acceptable, no test here uses t.Parallel(). [INT-435] --- credstore/migration_test.go | 97 ++++++++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 24 deletions(-) diff --git a/credstore/migration_test.go b/credstore/migration_test.go index 497613a..805baff 100644 --- a/credstore/migration_test.go +++ b/credstore/migration_test.go @@ -19,6 +19,11 @@ func TestFormatMigrationLine(t *testing.T) { 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 TestEmitMigrationStderr(t *testing.T) { @@ -65,14 +70,17 @@ func TestMigrationJSONShapeByteForByte(t *testing.T) { t.Fatalf("empty object = %s", be) } - // Multiple changes preserved in order. + // 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"), )) - if !strings.Contains(string(bm), `"field":"a"`) || - strings.Index(string(bm), `"field":"a"`) > strings.Index(string(bm), `"field":"b"`) { - t.Fatalf("multi-change order wrong: %s", bm) + 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. @@ -81,6 +89,54 @@ func TestMigrationJSONShapeByteForByte(t *testing.T) { } } +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) @@ -106,25 +162,18 @@ func TestMigrationConflictError(t *testing.T) { if !errors.Is(err, ErrMigrationConflict) { t.Fatalf("must match ErrMigrationConflict sentinel, got %v", err) } - msg := err.Error() - - // Names both locations, states they differ. - for _, want := range []string{cli, fld, loc, ref, "differs", "credstore:"} { - if !strings.Contains(msg, want) { - t.Fatalf("message missing %q: %q", want, msg) - } - } - // All three remediation options present (§1.8 lines 256-258). - for _, opt := range []string{"config clear", "manually delete", "--overwrite"} { - if !strings.Contains(msg, opt) { - t.Fatalf("message missing remediation %q: %q", opt, msg) - } - } - // Leak-proof by construction: there is no value parameter, so no - // secret value can appear. Sanity-check that the constructor never - // echoes anything resembling a credential (only the identifiers we - // passed are present). - if strings.Contains(msg, "BEGIN") || strings.Contains(msg, "xoxb-") { - t.Fatalf("unexpected value-like content: %q", msg) + + // 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) } } From 8718bcdad7d39d852e0618afbf3d749c2001510b Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 16 May 2026 12:55:40 -0400 Subject: [PATCH 3/3] refactor(credstore): writer seam for EmitMigrationStderr (daemon review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon REQUEST_CHANGES — TestEmitMigrationStderr swapped global os.Stderr/os.Stdout (fragile under future t.Parallel) and swallowed os.Pipe / io.ReadAll errors. Fix via the package's own pure/impure seam convention: add unexported emitMigration(w io.Writer, field, ref); EmitMigrationStderr keeps its exact standard signature and is now a trivial os.Stderr delegate. The test drives emitMigration with a bytes.Buffer — no global fd mutation, no pipe, no swallowed I/O errors, parallelism-safe. errcheck: the best-effort Fprintln return is explicitly discarded with rationale (stdlib fmt.Println family parity; no actionable recovery, signature fixed by the standard). [INT-435] --- credstore/migration.go | 14 +++++++++++++- credstore/migration_test.go | 32 +++++++++----------------------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/credstore/migration.go b/credstore/migration.go index 6e1ec65..11164fd 100644 --- a/credstore/migration.go +++ b/credstore/migration.go @@ -14,6 +14,7 @@ package credstore import ( "errors" "fmt" + "io" "os" ) @@ -33,13 +34,24 @@ 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) { - fmt.Fprintln(os.Stderr, formatMigrationLine(field, ref)) + emitMigration(os.Stderr, field, ref) } // MigrationChange is one entry in the _migration signal: which legacy diff --git a/credstore/migration_test.go b/credstore/migration_test.go index 805baff..b802f7d 100644 --- a/credstore/migration_test.go +++ b/credstore/migration_test.go @@ -1,10 +1,9 @@ package credstore import ( + "bytes" "encoding/json" "errors" - "io" - "os" "strings" "testing" ) @@ -26,28 +25,15 @@ func TestFormatMigrationLine(t *testing.T) { } } -func TestEmitMigrationStderr(t *testing.T) { - // Capture os.Stderr and os.Stdout via pipes to prove the line goes to - // stderr (with a newline) and stdout is untouched. - origErr, origOut := os.Stderr, os.Stdout - er, ew, _ := os.Pipe() - or, ow, _ := os.Pipe() - os.Stderr, os.Stdout = ew, ow - t.Cleanup(func() { os.Stderr, os.Stdout = origErr, origOut }) - - EmitMigrationStderr("api_token", "atlassian-cli/default") - - _ = ew.Close() - _ = ow.Close() - gotErr, _ := io.ReadAll(er) - gotOut, _ := io.ReadAll(or) - +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 string(gotErr) != want { - t.Fatalf("stderr = %q, want %q", gotErr, want) - } - if len(gotOut) != 0 { - t.Fatalf("stdout must be untouched, got %q", gotOut) + if buf.String() != want { + t.Fatalf("emitMigration wrote %q, want %q", buf.String(), want) } }