package shellstream import ( "encoding/base64" "encoding/json" "os" "path/filepath" "strings" "testing" ) // Helper to create a minimal valid extensions set. func minimalExtensions() *ShellstreamExtensions { return &ShellstreamExtensions{ TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", Roles: []string{"analyst"}, } } // Helper to create a fully-populated extensions set. func fullExtensions() *ShellstreamExtensions { ext := &ShellstreamExtensions{ SatScopes: []*SatScope{{ RegistryType: "oci", Verbs: []string{"push", "pull"}, ResourcePattern: "tenant-a/*", }}, SatHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", Roles: []string{"administrator", "engineer"}, CeremonyID: "11223344-5566-7788-99aa-bbccddeeff00", CeremonyType: "single_approval", MerkleRoot: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", MerkleProof: []byte{0x01, 0x02, 0x03, 0x04, 0x05}, GovernanceIntent: "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f", } ext.WithGovernanceEpoch(42) return ext } func TestEncodeDecodeRoundTrip(t *testing.T) { ext := fullExtensions() encoded, err := Encode(ext) if err != nil { t.Fatalf("Encode: %v", err) } decoded, err := Decode(encoded) if err != nil { t.Fatalf("Decode: %v", err) } // Verify all fields round-trip. if decoded.TenantID != ext.TenantID { t.Errorf("TenantID: got %q, want %q", decoded.TenantID, ext.TenantID) } if len(decoded.Roles) != len(ext.Roles) { t.Fatalf("Roles length: got %d, want %d", len(decoded.Roles), len(ext.Roles)) } for i, r := range decoded.Roles { if r != ext.Roles[i] { t.Errorf("Roles[%d]: got %q, want %q", i, r, ext.Roles[i]) } } if decoded.SatHash != ext.SatHash { t.Errorf("SatHash: got %q, want %q", decoded.SatHash, ext.SatHash) } if len(decoded.SatScopes) != 1 { t.Fatalf("SatScopes length: got %d, want 1", len(decoded.SatScopes)) } if decoded.SatScopes[0].RegistryType != ext.SatScopes[0].RegistryType { t.Errorf("SatScopes[0].RegistryType: got %q, want %q", decoded.SatScopes[0].RegistryType, ext.SatScopes[0].RegistryType) } if len(decoded.SatScopes[0].Verbs) != len(ext.SatScopes[0].Verbs) { t.Fatalf("SatScopes[0].Verbs length: got %d, want %d", len(decoded.SatScopes[0].Verbs), len(ext.SatScopes[0].Verbs)) } if decoded.SatScopes[0].ResourcePattern != ext.SatScopes[0].ResourcePattern { t.Errorf("SatScopes[0].ResourcePattern: got %q, want %q", decoded.SatScopes[0].ResourcePattern, ext.SatScopes[0].ResourcePattern) } if decoded.CeremonyID != ext.CeremonyID { t.Errorf("CeremonyID: got %q, want %q", decoded.CeremonyID, ext.CeremonyID) } if decoded.CeremonyType != ext.CeremonyType { t.Errorf("CeremonyType: got %q, want %q", decoded.CeremonyType, ext.CeremonyType) } if decoded.MerkleRoot != ext.MerkleRoot { t.Errorf("MerkleRoot: got %q, want %q", decoded.MerkleRoot, ext.MerkleRoot) } if len(decoded.MerkleProof) != len(ext.MerkleProof) { t.Fatalf("MerkleProof length: got %d, want %d", len(decoded.MerkleProof), len(ext.MerkleProof)) } for i, b := range decoded.MerkleProof { if b != ext.MerkleProof[i] { t.Errorf("MerkleProof[%d]: got %x, want %x", i, b, ext.MerkleProof[i]) } } if decoded.GovernanceEpoch != ext.GovernanceEpoch { t.Errorf("GovernanceEpoch: got %d, want %d", decoded.GovernanceEpoch, ext.GovernanceEpoch) } if !decoded.HasGovernanceEpoch() { t.Error("HasGovernanceEpoch: got false, want true") } if decoded.GovernanceIntent != ext.GovernanceIntent { t.Errorf("GovernanceIntent: got %q, want %q", decoded.GovernanceIntent, ext.GovernanceIntent) } } func TestEncodeDecodeMinimal(t *testing.T) { ext := minimalExtensions() encoded, err := Encode(ext) if err != nil { t.Fatalf("Encode: %v", err) } // Only tenant-id and roles should be present. if len(encoded) != 2 { t.Errorf("encoded map length: got %d, want 2 (keys: %v)", len(encoded), mapKeys(encoded)) } if _, ok := encoded[ExtTenantID]; !ok { t.Error("missing tenant-id") } if _, ok := encoded[ExtRoles]; !ok { t.Error("missing roles") } decoded, err := Decode(encoded) if err != nil { t.Fatalf("Decode: %v", err) } if decoded.TenantID != ext.TenantID { t.Errorf("TenantID: got %q, want %q", decoded.TenantID, ext.TenantID) } if len(decoded.SatScopes) != 0 { t.Error("SatScopes should be empty for minimal extensions") } if decoded.CeremonyID != "" { t.Error("CeremonyID should be empty for minimal extensions") } if decoded.HasGovernanceEpoch() { t.Error("HasGovernanceEpoch should be false for minimal extensions") } } func TestDecodeUnknownExtensionsIgnored(t *testing.T) { m := map[string]string{ ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ExtRoles: "analyst", "unknown-ext@guildhouse.dev": "some-value", "permit-pty": "", "completely-unrelated": "ignored", } decoded, err := Decode(m) if err != nil { t.Fatalf("Decode: %v", err) } if decoded.TenantID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" { t.Errorf("TenantID: got %q", decoded.TenantID) } } func TestValidateRequiredTenantID(t *testing.T) { ext := &ShellstreamExtensions{ Roles: []string{"analyst"}, } err := Validate(ext) if err == nil { t.Fatal("expected error for missing tenant-id") } if !strings.Contains(err.Error(), "tenant-id is required") { t.Errorf("unexpected error: %v", err) } } func TestValidateRequiredRoles(t *testing.T) { ext := &ShellstreamExtensions{ TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", } err := Validate(ext) if err == nil { t.Fatal("expected error for missing roles") } if !strings.Contains(err.Error(), "roles is required") { t.Errorf("unexpected error: %v", err) } } func TestValidateInvalidTenantIDFormat(t *testing.T) { ext := &ShellstreamExtensions{ TenantID: "not-a-uuid", Roles: []string{"analyst"}, } err := Validate(ext) if err == nil { t.Fatal("expected error for invalid UUID") } if !strings.Contains(err.Error(), "not a valid UUID") { t.Errorf("unexpected error: %v", err) } } func TestValidateSatScopeRequiresSatHash(t *testing.T) { ext := minimalExtensions() ext.SatScopes = []*SatScope{{ RegistryType: "oci", Verbs: []string{"pull"}, ResourcePattern: "*", }} // Missing SatHash. err := Validate(ext) if err == nil { t.Fatal("expected error for sat-scope without sat-hash") } if !strings.Contains(err.Error(), "sat-scope requires sat-hash") { t.Errorf("unexpected error: %v", err) } } func TestValidateSatHashRequiresSatScope(t *testing.T) { ext := minimalExtensions() ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" // Missing SatScope. err := Validate(ext) if err == nil { t.Fatal("expected error for sat-hash without sat-scope") } if !strings.Contains(err.Error(), "sat-hash requires sat-scope") { t.Errorf("unexpected error: %v", err) } } func TestValidateSatHashFormat(t *testing.T) { ext := minimalExtensions() ext.SatScopes = []*SatScope{{ RegistryType: "oci", Verbs: []string{"pull"}, ResourcePattern: "*", }} // Too short. ext.SatHash = "abcdef" err := Validate(ext) if err == nil || !strings.Contains(err.Error(), "64 hex characters") { t.Errorf("expected 64-char error, got: %v", err) } // Uppercase. ext.SatHash = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789" err = Validate(ext) if err == nil || !strings.Contains(err.Error(), "lowercase") { t.Errorf("expected lowercase error, got: %v", err) } // Valid. ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" err = Validate(ext) if err != nil { t.Errorf("unexpected error for valid sat-hash: %v", err) } } func TestValidateCeremonyCooccurrence(t *testing.T) { ext := minimalExtensions() // ceremony-id without ceremony-type. ext.CeremonyID = "11223344-5566-7788-99aa-bbccddeeff00" err := Validate(ext) if err == nil || !strings.Contains(err.Error(), "ceremony-id requires ceremony-type") { t.Errorf("expected co-occurrence error, got: %v", err) } // ceremony-type without ceremony-id. ext.CeremonyID = "" ext.CeremonyType = "single_approval" err = Validate(ext) if err == nil || !strings.Contains(err.Error(), "ceremony-type requires ceremony-id") { t.Errorf("expected co-occurrence error, got: %v", err) } } func TestValidateUnknownCeremonyType(t *testing.T) { ext := minimalExtensions() ext.CeremonyID = "11223344-5566-7788-99aa-bbccddeeff00" ext.CeremonyType = "unknown_type" err := Validate(ext) if err == nil || !strings.Contains(err.Error(), "unknown ceremony-type") { t.Errorf("expected unknown ceremony-type error, got: %v", err) } } func TestValidateMerkleProofRequiresRoot(t *testing.T) { ext := minimalExtensions() ext.MerkleProof = []byte{0x01, 0x02} // Missing MerkleRoot. err := Validate(ext) if err == nil || !strings.Contains(err.Error(), "merkle-proof requires merkle-root") { t.Errorf("expected co-occurrence error, got: %v", err) } } func TestValidateMerkleRootFormat(t *testing.T) { ext := minimalExtensions() // Too short. ext.MerkleRoot = "abcdef" err := Validate(ext) if err == nil || !strings.Contains(err.Error(), "64 hex characters") { t.Errorf("expected 64-char error, got: %v", err) } // Uppercase. ext.MerkleRoot = "FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210" err = Validate(ext) if err == nil || !strings.Contains(err.Error(), "lowercase") { t.Errorf("expected lowercase error, got: %v", err) } } func TestValidateEmptyRoleName(t *testing.T) { ext := &ShellstreamExtensions{ TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", Roles: []string{"analyst", ""}, } err := Validate(ext) if err == nil || !strings.Contains(err.Error(), "empty role name") { t.Errorf("expected empty role error, got: %v", err) } } func TestValidateRoleWithComma(t *testing.T) { ext := &ShellstreamExtensions{ TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", Roles: []string{"analyst,viewer"}, } err := Validate(ext) if err == nil || !strings.Contains(err.Error(), "commas or spaces") { t.Errorf("expected comma error, got: %v", err) } } func TestDecodeSatScopeJSON(t *testing.T) { m := map[string]string{ ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ExtRoles: "analyst", ExtSatScope: `{"registry_type":"helm","verbs":["install","upgrade"],"resource_pattern":"ns/*"}`, ExtSatHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", } decoded, err := Decode(m) if err != nil { t.Fatalf("Decode: %v", err) } if len(decoded.SatScopes) != 1 { t.Fatalf("SatScopes length: got %d, want 1", len(decoded.SatScopes)) } if decoded.SatScopes[0].RegistryType != "helm" { t.Errorf("RegistryType: got %q, want %q", decoded.SatScopes[0].RegistryType, "helm") } if len(decoded.SatScopes[0].Verbs) != 2 || decoded.SatScopes[0].Verbs[0] != "install" { t.Errorf("Verbs: got %v", decoded.SatScopes[0].Verbs) } if decoded.SatScopes[0].ResourcePattern != "ns/*" { t.Errorf("ResourcePattern: got %q", decoded.SatScopes[0].ResourcePattern) } } func TestDecodeInvalidSatScopeJSON(t *testing.T) { m := map[string]string{ ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ExtRoles: "analyst", ExtSatScope: "not-valid-json", } _, err := Decode(m) if err == nil { t.Fatal("expected error for invalid JSON") } if !strings.Contains(err.Error(), "unmarshal sat-scope") { t.Errorf("unexpected error: %v", err) } } func TestDecodeRolesCommaParsing(t *testing.T) { m := map[string]string{ ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ExtRoles: "administrator,engineer,analyst", } decoded, err := Decode(m) if err != nil { t.Fatalf("Decode: %v", err) } if len(decoded.Roles) != 3 { t.Fatalf("Roles length: got %d, want 3", len(decoded.Roles)) } expected := []string{"administrator", "engineer", "analyst"} for i, r := range decoded.Roles { if r != expected[i] { t.Errorf("Roles[%d]: got %q, want %q", i, r, expected[i]) } } } func TestDecodeMerkleProofBase64(t *testing.T) { proof := []byte{0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04} m := map[string]string{ ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ExtRoles: "analyst", ExtMerkleRoot: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", ExtMerkleProof: base64.StdEncoding.EncodeToString(proof), } decoded, err := Decode(m) if err != nil { t.Fatalf("Decode: %v", err) } if len(decoded.MerkleProof) != len(proof) { t.Fatalf("MerkleProof length: got %d, want %d", len(decoded.MerkleProof), len(proof)) } for i, b := range decoded.MerkleProof { if b != proof[i] { t.Errorf("MerkleProof[%d]: got %x, want %x", i, b, proof[i]) } } } func TestDecodeInvalidMerkleProofBase64(t *testing.T) { m := map[string]string{ ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ExtRoles: "analyst", ExtMerkleProof: "not-valid-base64!!!", } _, err := Decode(m) if err == nil { t.Fatal("expected error for invalid base64") } if !strings.Contains(err.Error(), "decode merkle-proof") { t.Errorf("unexpected error: %v", err) } } func TestDecodeGovernanceEpoch(t *testing.T) { m := map[string]string{ ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ExtRoles: "analyst", ExtGovernanceEpoch: "12345", } decoded, err := Decode(m) if err != nil { t.Fatalf("Decode: %v", err) } if decoded.GovernanceEpoch != 12345 { t.Errorf("GovernanceEpoch: got %d, want 12345", decoded.GovernanceEpoch) } if !decoded.HasGovernanceEpoch() { t.Error("HasGovernanceEpoch: got false, want true") } } func TestDecodeGovernanceEpochZero(t *testing.T) { m := map[string]string{ ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ExtRoles: "analyst", ExtGovernanceEpoch: "0", } decoded, err := Decode(m) if err != nil { t.Fatalf("Decode: %v", err) } if decoded.GovernanceEpoch != 0 { t.Errorf("GovernanceEpoch: got %d, want 0", decoded.GovernanceEpoch) } if !decoded.HasGovernanceEpoch() { t.Error("HasGovernanceEpoch: got false, want true (epoch 0 is valid)") } } func TestDecodeInvalidGovernanceEpoch(t *testing.T) { m := map[string]string{ ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ExtRoles: "analyst", ExtGovernanceEpoch: "not-a-number", } _, err := Decode(m) if err == nil { t.Fatal("expected error for invalid epoch") } if !strings.Contains(err.Error(), "parse governance-epoch") { t.Errorf("unexpected error: %v", err) } } func TestEncodeNilReturnsError(t *testing.T) { _, err := Encode(nil) if err == nil { t.Fatal("expected error for nil extensions") } } func TestValidateNilReturnsError(t *testing.T) { err := Validate(nil) if err == nil { t.Fatal("expected error for nil extensions") } } func TestValidateFullExtensions(t *testing.T) { ext := fullExtensions() err := Validate(ext) if err != nil { t.Fatalf("unexpected validation error: %v", err) } } func TestValidateMinimalExtensions(t *testing.T) { ext := minimalExtensions() err := Validate(ext) if err != nil { t.Fatalf("unexpected validation error: %v", err) } } func TestIsValidUUID(t *testing.T) { tests := []struct { input string want bool }{ {"a1b2c3d4-e5f6-7890-abcd-ef1234567890", true}, {"00000000-0000-0000-0000-000000000000", true}, {"A1B2C3D4-E5F6-7890-ABCD-EF1234567890", false}, // uppercase {"a1b2c3d4e5f6-7890-abcd-ef1234567890", false}, // missing hyphen {"too-short", false}, {"", false}, {"a1b2c3d4-e5f6-7890-abcd-ef12345678901", false}, // too long } for _, tt := range tests { got := isValidUUID(tt.input) if got != tt.want { t.Errorf("isValidUUID(%q) = %v, want %v", tt.input, got, tt.want) } } } func TestDecodeSatScopeArrayForm(t *testing.T) { m := map[string]string{ ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ExtRoles: "analyst", ExtSatScope: `[{"registry_type":"oci","verbs":["pull"],"resource_pattern":"acme/*"},{"registry_type":"helm","verbs":["read"],"resource_pattern":"charts/*"}]`, ExtSatHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", } decoded, err := Decode(m) if err != nil { t.Fatalf("Decode: %v", err) } if len(decoded.SatScopes) != 2 { t.Fatalf("SatScopes length: got %d, want 2", len(decoded.SatScopes)) } if decoded.SatScopes[0].RegistryType != "oci" { t.Errorf("SatScopes[0].RegistryType: got %q, want %q", decoded.SatScopes[0].RegistryType, "oci") } if decoded.SatScopes[1].RegistryType != "helm" { t.Errorf("SatScopes[1].RegistryType: got %q, want %q", decoded.SatScopes[1].RegistryType, "helm") } if decoded.SatScopes[0].ResourcePattern != "acme/*" { t.Errorf("SatScopes[0].ResourcePattern: got %q, want %q", decoded.SatScopes[0].ResourcePattern, "acme/*") } if decoded.SatScopes[1].ResourcePattern != "charts/*" { t.Errorf("SatScopes[1].ResourcePattern: got %q, want %q", decoded.SatScopes[1].ResourcePattern, "charts/*") } } func TestEncodeDecodeArrayFormRoundTrip(t *testing.T) { ext := minimalExtensions() ext.SatScopes = []*SatScope{ {RegistryType: "oci", Verbs: []string{"pull"}, ResourcePattern: "acme/*"}, {RegistryType: "helm", Verbs: []string{"read"}, ResourcePattern: "charts/*"}, } ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" encoded, err := Encode(ext) if err != nil { t.Fatalf("Encode: %v", err) } // Array form should start with [. scopeVal := encoded[ExtSatScope] if scopeVal[0] != '[' { t.Errorf("expected array JSON, got: %s", scopeVal) } decoded, err := Decode(encoded) if err != nil { t.Fatalf("Decode: %v", err) } if len(decoded.SatScopes) != 2 { t.Fatalf("SatScopes length: got %d, want 2", len(decoded.SatScopes)) } if decoded.SatScopes[0].RegistryType != "oci" { t.Errorf("SatScopes[0].RegistryType: got %q", decoded.SatScopes[0].RegistryType) } if decoded.SatScopes[1].RegistryType != "helm" { t.Errorf("SatScopes[1].RegistryType: got %q", decoded.SatScopes[1].RegistryType) } } func TestEncodeSingleScopeProducesObject(t *testing.T) { ext := minimalExtensions() ext.SatScopes = []*SatScope{{ RegistryType: "oci", Verbs: []string{"pull"}, ResourcePattern: "*", }} ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" encoded, err := Encode(ext) if err != nil { t.Fatalf("Encode: %v", err) } scopeVal := encoded[ExtSatScope] if scopeVal[0] != '{' { t.Errorf("single scope should encode as object, got: %s", scopeVal) } } func TestValidateInvalidGovernanceIntent(t *testing.T) { ext := minimalExtensions() ext.GovernanceIntent = "not-a-uuid" err := Validate(ext) if err == nil || !strings.Contains(err.Error(), "governance-intent is not a valid UUID") { t.Errorf("expected UUID validation error, got: %v", err) } } func TestDecodeGovernanceIntent(t *testing.T) { m := map[string]string{ ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ExtRoles: "analyst", ExtGovernanceIntent: "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f", } decoded, err := Decode(m) if err != nil { t.Fatalf("Decode: %v", err) } if decoded.GovernanceIntent != "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f" { t.Errorf("GovernanceIntent: got %q", decoded.GovernanceIntent) } } func TestValidateEmptyResourcePattern(t *testing.T) { ext := minimalExtensions() ext.SatScopes = []*SatScope{{ RegistryType: "oci", Verbs: []string{"pull"}, ResourcePattern: "", }} ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" err := Validate(ext) if err == nil || !strings.Contains(err.Error(), "resource_pattern is required") { t.Errorf("expected resource_pattern error, got: %v", err) } } // loadFixture reads a test fixture from the test/fixtures directory. func loadFixture(t *testing.T, name string) []byte { t.Helper() data, err := os.ReadFile(filepath.Join("..", "..", "test", "fixtures", name)) if err != nil { t.Fatalf("load fixture %s: %v", name, err) } return data } func TestDecodeFixtureFile(t *testing.T) { data := loadFixture(t, "sample-ssh-cert-extensions.json") var extensions map[string]string if err := json.Unmarshal(data, &extensions); err != nil { t.Fatalf("unmarshal fixture: %v", err) } ext, err := Decode(extensions) if err != nil { t.Fatalf("Decode fixture: %v", err) } if err := Validate(ext); err != nil { t.Fatalf("Validate fixture: %v", err) } // Verify key fields parsed correctly from fixture. if ext.TenantID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" { t.Errorf("TenantID: got %q", ext.TenantID) } if len(ext.Roles) != 2 || ext.Roles[0] != "administrator" || ext.Roles[1] != "engineer" { t.Errorf("Roles: got %v", ext.Roles) } if len(ext.SatScopes) != 1 || ext.SatScopes[0].RegistryType != "oci" { t.Errorf("SatScopes: got %v", ext.SatScopes) } if ext.CeremonyType != "single_approval" { t.Errorf("CeremonyType: got %q", ext.CeremonyType) } if !ext.HasGovernanceEpoch() || ext.GovernanceEpoch != 42 { t.Errorf("GovernanceEpoch: got %d (has=%v)", ext.GovernanceEpoch, ext.HasGovernanceEpoch()) } if ext.GovernanceIntent != "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f" { t.Errorf("GovernanceIntent: got %q", ext.GovernanceIntent) } } // mapKeys returns the keys of a map for debug output. func mapKeys(m map[string]string) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys }