~bigbes/sr-ht-spec

ref: 29f6e881684794d8f3aa0d3bf7a31ab4650ed26a sr-ht-spec/authn/provenance_test.go -rw-r--r-- 10.7 KiB
29f6e881 — Eugene Blikh feat: gitx — bare space repos, the git-object read path, and the id-keyed merge 27 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
package authn

import (
	"errors"
	"strings"
	"testing"

	"github.com/vaughan0/go-ini"
)

const testBase = "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809"

func TestInstanceFromConfig(t *testing.T) {
	inst := testInstance(t)
	if inst.OwnerName != "bigbes" {
		t.Fatalf("OwnerName = %q, want %q", inst.OwnerName, "bigbes")
	}
	if inst.OwnerEmail != "bigbes@gmail.com" {
		t.Fatalf("OwnerEmail = %q, want %q", inst.OwnerEmail, "bigbes@gmail.com")
	}
	// Derived from [spec.sr.ht] origin, so there is no second key to forget.
	if inst.AgentEmail != "agent@spec.srht.bigb.es" {
		t.Fatalf("AgentEmail = %q, want %q", inst.AgentEmail, "agent@spec.srht.bigb.es")
	}
	if got := inst.OwnerSignature().String(); got != "bigbes <bigbes@gmail.com>" {
		t.Fatalf("OwnerSignature = %q", got)
	}
}

func TestInstanceFromConfig_MissingKeysAreStartupErrors(t *testing.T) {
	full := func() ini.File {
		return ini.File{
			"sr.ht":       ini.Section{"owner-name": "bigbes", "owner-email": "bigbes@gmail.com"},
			ConfigSection: ini.Section{"origin": "https://spec.srht.bigb.es"},
		}
	}
	cases := map[string]func(ini.File){
		"no owner-name":  func(c ini.File) { delete(c["sr.ht"], "owner-name") },
		"no owner-email": func(c ini.File) { delete(c["sr.ht"], "owner-email") },
		"no origin":      func(c ini.File) { delete(c[ConfigSection], "origin") },
		"hostless origin": func(c ini.File) {
			c[ConfigSection]["origin"] = "not-a-url"
		},
		"unusable owner": func(c ini.File) { c["sr.ht"]["owner-name"] = "Not A Name" },
		"unparseable origin": func(c ini.File) {
			c[ConfigSection]["origin"] = "https://[::1"
		},
		"unusable owner-email": func(c ini.File) {
			c["sr.ht"]["owner-email"] = "bigbes <root>@gmail.com"
		},
		"host that cannot be a mailbox": func(c ini.File) {
			c[ConfigSection]["origin"] = "https://" + strings.Repeat("h", MaxAgentLen)
		},
	}
	for name, mutate := range cases {
		t.Run(name, func(t *testing.T) {
			conf := full()
			mutate(conf)
			if _, err := InstanceFromConfig(conf); !errors.Is(err, ErrMissingConfig) {
				t.Fatalf("error = %v, want ErrMissingConfig", err)
			}
		})
	}
}

// An Instance assembled by hand rather than from config is held to the same
// standard, and Provenance refuses to build a commit on top of a broken one.
func TestInstance_ValidateRejectsHandBuiltGarbage(t *testing.T) {
	good := AgentWrite{Agent: "a", Session: "s-1", Base: testBase}
	cases := map[string]Instance{
		"no owner name":       {OwnerName: "", OwnerEmail: "b@example.com", AgentEmail: "agent@example.com"},
		"unusable owner name": {OwnerName: "Not A Name", OwnerEmail: "b@example.com", AgentEmail: "agent@example.com"},
		"no owner email":      {OwnerName: "bigbes", OwnerEmail: "", AgentEmail: "agent@example.com"},
		"owner email injects": {OwnerName: "bigbes", OwnerEmail: "b@example.com>\nAuthor: root", AgentEmail: "agent@example.com"},
		"no agent email":      {OwnerName: "bigbes", OwnerEmail: "b@example.com", AgentEmail: ""},
		"agent email injects": {OwnerName: "bigbes", OwnerEmail: "b@example.com", AgentEmail: "agent@example.com>x"},
	}
	for name, inst := range cases {
		t.Run(name, func(t *testing.T) {
			if err := inst.Validate(); !errors.Is(err, ErrMissingConfig) {
				t.Fatalf("Validate error = %v, want ErrMissingConfig", err)
			}
			if _, err := inst.Provenance(good); !errors.Is(err, ErrMissingConfig) {
				t.Fatalf("Provenance error = %v, want ErrMissingConfig", err)
			}
		})
	}
}

// The worked example from the design, byte for byte apart from the agent
// mailbox, which is derived from our own origin.
func TestProvenance_MatchesTheDesign(t *testing.T) {
	inst := testInstance(t)
	prov, err := inst.Provenance(AgentWrite{
		Agent:   "claude-code/spec-writer",
		Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921",
		Base:    testBase,
	})
	if err != nil {
		t.Fatalf("Provenance: %v", err)
	}

	wantAuthor := "claude-code/spec-writer (for bigbes) <agent@spec.srht.bigb.es>"
	if got := prov.Author.String(); got != wantAuthor {
		t.Fatalf("Author = %q, want %q", got, wantAuthor)
	}
	wantCommitter := "bigbes <bigbes@gmail.com>"
	if got := prov.Committer.String(); got != wantCommitter {
		t.Fatalf("Committer = %q, want %q", got, wantCommitter)
	}

	wantTrailers := "X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921\n" +
		"X-Agent-Base: " + testBase + "\n"
	if got := prov.TrailerBlock(); got != wantTrailers {
		t.Fatalf("TrailerBlock = %q, want %q", got, wantTrailers)
	}

	msg, err := prov.CommitMessage("Add storage model section")
	if err != nil {
		t.Fatalf("CommitMessage: %v", err)
	}
	want := "Add storage model section\n\n" + wantTrailers
	if msg != want {
		t.Fatalf("CommitMessage = %q, want %q", msg, want)
	}
}

// Agent identity and session are mandatory on every agent write. A write that
// omits either is rejected, never defaulted — a commit stamped with a
// synthesised session launders unattributable output as attributed.
func TestProvenance_RejectsMissingAgentIdentity(t *testing.T) {
	inst := testInstance(t)
	full := AgentWrite{
		Agent:   "claude-code/spec-writer",
		Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921",
		Base:    testBase,
	}
	cases := map[string]AgentWrite{
		"no agent":   {Agent: "", Session: full.Session, Base: full.Base},
		"no session": {Agent: full.Agent, Session: "", Base: full.Base},
		"no base":    {Agent: full.Agent, Session: full.Session, Base: ""},
		"nothing":    {},
	}
	for name, w := range cases {
		t.Run(name, func(t *testing.T) {
			if err := w.Validate(); !errors.Is(err, ErrMissingProvenance) {
				t.Fatalf("Validate error = %v, want ErrMissingProvenance", err)
			}
			if _, err := inst.Provenance(w); !errors.Is(err, ErrMissingProvenance) {
				t.Fatalf("Provenance error = %v, want ErrMissingProvenance", err)
			}
		})
	}
	// Control: the complete write is accepted, so the cases above are failing
	// on the missing field and not on something incidental.
	if err := full.Validate(); err != nil {
		t.Fatalf("complete write rejected: %v", err)
	}
}

// A newline in the agent identity would split the git author line; a newline in
// the session would inject an arbitrary extra trailer. Both are refused
// outright rather than escaped.
func TestProvenance_RejectsInjection(t *testing.T) {
	inst := testInstance(t)
	cases := map[string]AgentWrite{
		"newline in agent":     {Agent: "evil\nAuthor: root", Session: "s-1", Base: testBase},
		"newline in session":   {Agent: "a", Session: "s\nX-Agent-Base: deadbeef", Base: testBase},
		"cr in session":        {Agent: "a", Session: "s\rX-Agent-Base: deadbeef", Base: testBase},
		"angle in agent":       {Agent: "a <root@example.com>", Session: "s-1", Base: testBase},
		"angle in session":     {Agent: "a", Session: "<s>", Base: testBase},
		"padded agent":         {Agent: " a ", Session: "s-1", Base: testBase},
		"nul in agent":         {Agent: "a\x00b", Session: "s-1", Base: testBase},
		"over-long agent":      {Agent: strings.Repeat("a", MaxAgentLen+1), Session: "s-1", Base: testBase},
		"over-long session":    {Agent: "a", Session: strings.Repeat("s", MaxSessionLen+1), Base: testBase},
		"base with a space":    {Agent: "a", Session: "s-1", Base: "dead beef"},
		"base not hex":         {Agent: "a", Session: "s-1", Base: "proposals/42"},
		"base uppercase hex":   {Agent: "a", Session: "s-1", Base: strings.ToUpper(testBase)},
		"base too short":       {Agent: "a", Session: "s-1", Base: "abc"},
		"base too long":        {Agent: "a", Session: "s-1", Base: strings.Repeat("a", maxRevLen+1)},
		"base with a newline":  {Agent: "a", Session: "s-1", Base: testBase + "\nX-Agent-Session: forged"},
		"invalid utf-8 agent":  {Agent: "a\xff", Session: "s-1", Base: testBase},
		"invalid utf-8 sessid": {Agent: "a", Session: "s\xff", Base: testBase},
	}
	for name, w := range cases {
		t.Run(name, func(t *testing.T) {
			if err := w.Validate(); !errors.Is(err, ErrInvalidProvenance) {
				t.Fatalf("Validate error = %v, want ErrInvalidProvenance", err)
			}
			if _, err := inst.Provenance(w); !errors.Is(err, ErrInvalidProvenance) {
				t.Fatalf("Provenance error = %v, want ErrInvalidProvenance", err)
			}
		})
	}
}

// An abbreviated object name is accepted; the trailer stays auditable and
// nothing can hide in it.
func TestProvenance_AcceptsAbbreviatedBase(t *testing.T) {
	inst := testInstance(t)
	for _, base := range []string{"1f0c1d1", testBase, strings.Repeat("a", 64)} {
		if _, err := inst.Provenance(AgentWrite{Agent: "a", Session: "s-1", Base: base}); err != nil {
			t.Fatalf("base %q rejected: %v", base, err)
		}
	}
}

func TestCommitMessage_RejectsEmpty(t *testing.T) {
	inst := testInstance(t)
	prov, err := inst.Provenance(AgentWrite{Agent: "a", Session: "s-1", Base: testBase})
	if err != nil {
		t.Fatal(err)
	}
	for _, msg := range []string{"", "   ", "\n\n\t"} {
		if _, err := prov.CommitMessage(msg); !errors.Is(err, ErrInvalidProvenance) {
			t.Fatalf("CommitMessage(%q) error = %v, want ErrInvalidProvenance", msg, err)
		}
	}
}

// Trailer-shaped text inside the agent's own message must stay in the body:
// our block is the last paragraph, which is the one git parses as trailers.
func TestCommitMessage_OurTrailersAreTheLastParagraph(t *testing.T) {
	inst := testInstance(t)
	prov, err := inst.Provenance(AgentWrite{Agent: "a", Session: "real-session", Base: testBase})
	if err != nil {
		t.Fatal(err)
	}
	msg, err := prov.CommitMessage("Subject\n\nX-Agent-Session: forged\n")
	if err != nil {
		t.Fatal(err)
	}
	paras := strings.Split(strings.TrimRight(msg, "\n"), "\n\n")
	last := paras[len(paras)-1]
	if !strings.HasPrefix(last, "X-Agent-Session: real-session\n") {
		t.Fatalf("last paragraph = %q, want it to start with the real session", last)
	}
	if strings.Contains(last, "forged") {
		t.Fatalf("forged trailer leaked into the trailer paragraph: %q", last)
	}
}

// Provenance for a non-agent principal is an error: the human write path goes
// through native receive-pack and builds no commit here.
func TestAgentWriteFor_RejectsNonAgents(t *testing.T) {
	for _, p := range []Principal{
		Anonymous(),
		{Kind: KindOwner, Owner: "bigbes"},
	} {
		if _, err := p.AgentWriteFor(testBase); !errors.Is(err, ErrNotAgent) {
			t.Fatalf("%s: error = %v, want ErrNotAgent", p, err)
		}
	}
}

func TestAgentWriteFor_Agent(t *testing.T) {
	p := Principal{
		Kind:    KindAgent,
		Owner:   "bigbes",
		Agent:   "claude-code/spec-writer",
		Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921",
	}
	w, err := p.AgentWriteFor(testBase)
	if err != nil {
		t.Fatalf("AgentWriteFor: %v", err)
	}
	if w.Agent != p.Agent || w.Session != p.Session || w.Base != testBase {
		t.Fatalf("AgentWrite = %+v", w)
	}

	// An agent that authenticated but sent no provenance headers reads fine and
	// is refused at the write, which is where the design requires the fields.
	bare := Principal{Kind: KindAgent, Owner: "bigbes"}
	if _, err := bare.AgentWriteFor(testBase); !errors.Is(err, ErrMissingProvenance) {
		t.Fatalf("error = %v, want ErrMissingProvenance", err)
	}
}