ttsc 0.32.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/cmd/ttsc-cache/account.go +94 -0
  2. package/cmd/ttsc-cache/interruption_test.go +176 -0
  3. package/cmd/ttsc-cache/main.go +252 -0
  4. package/cmd/ttsc-cache/merge.go +324 -0
  5. package/cmd/ttsc-cache/merge_test.go +161 -0
  6. package/cmd/ttsc-cache/prepare.go +330 -0
  7. package/cmd/ttsc-cache/prepare_test.go +456 -0
  8. package/cmd/ttsc-cache/protocol_test.go +119 -0
  9. package/cmd/ttsc-cache/publisher_seal_test.go +135 -0
  10. package/cmd/ttsc-cache/seal.go +427 -0
  11. package/cmd/ttsc-cache/stock_go_failure_test.go +94 -0
  12. package/cmd/ttsc-cache/stock_go_test.go +163 -0
  13. package/cmd/ttsc-cache/store.go +249 -0
  14. package/cmd/ttsc-cache/store_test.go +104 -0
  15. package/cmd/ttsc-cache/tuple.go +107 -0
  16. package/cmd/ttsc-cache/tuple_test.go +62 -0
  17. package/cmd/ttsc-cache/usage.go +73 -0
  18. package/driver/configloaderscratch/canonical_directory_other.go +34 -0
  19. package/driver/configloaderscratch/canonical_directory_windows.go +60 -0
  20. package/driver/configloaderscratch/identity_temp_base.go +81 -0
  21. package/driver/configloaderscratch/identity_temp_base_posix_test.go +124 -0
  22. package/driver/configloaderscratch/identity_temp_base_windows_test.go +156 -0
  23. package/driver/emit_provenance.go +7 -5
  24. package/driver/resolutioninputs/recorder.cjs +16 -4
  25. package/driver/rewrite.go +25 -16
  26. package/driver/write_file_if_changed.go +69 -0
  27. package/driver/write_file_if_changed_test.go +148 -0
  28. package/internal/packageartifacts/manifest.go +288 -0
  29. package/internal/packageartifacts/manifest_test.go +127 -0
  30. package/lib/compiler/internal/build/BuildExecution.d.ts +11 -7
  31. package/lib/compiler/internal/build/BuildExecution.js +22 -8
  32. package/lib/compiler/internal/build/BuildExecution.js.map +1 -1
  33. package/lib/compiler/internal/build/ResidentCheckWatchSession.d.ts +10 -10
  34. package/lib/compiler/internal/build/ResidentCheckWatchSession.js +19 -14
  35. package/lib/compiler/internal/build/ResidentCheckWatchSession.js.map +1 -1
  36. package/lib/compiler/internal/build/RunBuildOptions.d.ts +8 -1
  37. package/lib/compiler/internal/build/TsgoArguments.d.ts +16 -0
  38. package/lib/compiler/internal/build/TsgoArguments.js +19 -0
  39. package/lib/compiler/internal/build/TsgoArguments.js.map +1 -1
  40. package/lib/compiler/internal/captureProcessOutput.d.ts +16 -13
  41. package/lib/compiler/internal/captureProcessOutput.js +22 -14
  42. package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
  43. package/lib/compiler/internal/startResidentTransform.js +3 -1
  44. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  45. package/lib/compiler/internal/transformProjectInMemory.js +5 -1
  46. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  47. package/lib/launcher/internal/runtime/PluginDescriptorInputReport.d.ts +43 -0
  48. package/lib/launcher/internal/runtime/PluginDescriptorInputReport.js +106 -0
  49. package/lib/launcher/internal/runtime/PluginDescriptorInputReport.js.map +1 -0
  50. package/lib/launcher/internal/runtime/installRuntimeHooks.js +15 -29
  51. package/lib/launcher/internal/runtime/installRuntimeHooks.js.map +1 -1
  52. package/lib/launcher/internal/ttscserver/runTtscserver.js +1 -0
  53. package/lib/launcher/internal/ttscserver/runTtscserver.js.map +1 -1
  54. package/lib/launcher/internal/watch/WatchTopology.js +5 -4
  55. package/lib/launcher/internal/watch/WatchTopology.js.map +1 -1
  56. package/lib/plugin/internal/CapabilityResolutionFormat.js +4 -3
  57. package/lib/plugin/internal/CapabilityResolutionFormat.js.map +1 -1
  58. package/lib/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.js +6 -0
  59. package/lib/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.js.map +1 -1
  60. package/lib/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.js +6 -0
  61. package/lib/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.js.map +1 -1
  62. package/lib/plugin/internal/load/PluginDescriptorEvaluationCache.js +3 -3
  63. package/lib/plugin/internal/load/PluginDescriptorEvaluationCache.js.map +1 -1
  64. package/lib/plugin/internal/load/descriptorRecordEnvironment.d.ts +15 -0
  65. package/lib/plugin/internal/load/descriptorRecordEnvironment.js +24 -0
  66. package/lib/plugin/internal/load/descriptorRecordEnvironment.js.map +1 -0
  67. package/lib/plugin/internal/load/loadProjectPlugins.js +28 -26
  68. package/lib/plugin/internal/load/loadProjectPlugins.js.map +1 -1
  69. package/lib/plugin/internal/source/SourceBuildCacheLayout.d.ts +2 -0
  70. package/lib/plugin/internal/source/SourceBuildCacheLayout.js +2 -0
  71. package/lib/plugin/internal/source/SourceBuildCacheLayout.js.map +1 -1
  72. package/lib/plugin/internal/source/SourcePackageArtifacts.d.ts +145 -0
  73. package/lib/plugin/internal/source/SourcePackageArtifacts.js +612 -0
  74. package/lib/plugin/internal/source/SourcePackageArtifacts.js.map +1 -0
  75. package/lib/plugin/internal/source/buildSourcePlugin.js +54 -4
  76. package/lib/plugin/internal/source/buildSourcePlugin.js.map +1 -1
  77. package/lib/plugin/internal/source/prunePackageArtifactRoot.d.ts +19 -0
  78. package/lib/plugin/internal/source/prunePackageArtifactRoot.js +95 -0
  79. package/lib/plugin/internal/source/prunePackageArtifactRoot.js.map +1 -0
  80. package/lib/plugin/internal/source/resolveCleanTargets.js +1 -1
  81. package/lib/plugin/internal/source/resolveCleanTargets.js.map +1 -1
  82. package/lib/plugin/internal/source/resolveSourceBuildCachePaths.js +9 -4
  83. package/lib/plugin/internal/source/resolveSourceBuildCachePaths.js.map +1 -1
  84. package/lib/plugin/internal/source/selectedRecordIdentityTempParent.d.ts +18 -0
  85. package/lib/plugin/internal/source/selectedRecordIdentityTempParent.js +46 -0
  86. package/lib/plugin/internal/source/selectedRecordIdentityTempParent.js.map +1 -0
  87. package/package.json +8 -8
  88. package/src/compiler/internal/build/BuildExecution.ts +30 -10
  89. package/src/compiler/internal/build/ResidentCheckWatchSession.ts +23 -22
  90. package/src/compiler/internal/build/RunBuildOptions.ts +9 -1
  91. package/src/compiler/internal/build/TsgoArguments.ts +22 -0
  92. package/src/compiler/internal/captureProcessOutput.ts +28 -14
  93. package/src/compiler/internal/startResidentTransform.ts +7 -1
  94. package/src/compiler/internal/transformProjectInMemory.ts +9 -1
  95. package/src/launcher/internal/runtime/PluginDescriptorInputReport.ts +100 -0
  96. package/src/launcher/internal/runtime/installRuntimeHooks.ts +57 -55
  97. package/src/launcher/internal/ttscserver/runTtscserver.ts +5 -0
  98. package/src/launcher/internal/watch/WatchTopology.ts +5 -4
  99. package/src/plugin/internal/CapabilityResolutionFormat.ts +4 -3
  100. package/src/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.ts +4 -0
  101. package/src/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.ts +4 -0
  102. package/src/plugin/internal/load/PluginDescriptorEvaluationCache.ts +3 -3
  103. package/src/plugin/internal/load/descriptorRecordEnvironment.ts +24 -0
  104. package/src/plugin/internal/load/loadProjectPlugins.ts +45 -29
  105. package/src/plugin/internal/source/SourceBuildCacheLayout.ts +3 -0
  106. package/src/plugin/internal/source/SourcePackageArtifacts.ts +787 -0
  107. package/src/plugin/internal/source/buildSourcePlugin.ts +82 -12
  108. package/src/plugin/internal/source/prunePackageArtifactRoot.ts +100 -0
  109. package/src/plugin/internal/source/resolveCleanTargets.ts +1 -0
  110. package/src/plugin/internal/source/resolveSourceBuildCachePaths.ts +11 -4
  111. package/src/plugin/internal/source/selectedRecordIdentityTempParent.ts +40 -0
@@ -0,0 +1,456 @@
1
+ package main
2
+
3
+ import (
4
+ "archive/zip"
5
+ "bytes"
6
+ "context"
7
+ "crypto/sha256"
8
+ "encoding/hex"
9
+ "errors"
10
+ "net/http"
11
+ "net/http/httptest"
12
+ "os"
13
+ "strings"
14
+ "sync"
15
+ "sync/atomic"
16
+ "testing"
17
+ "time"
18
+
19
+ "github.com/samchon/ttsc/packages/ttsc/internal/packageartifacts"
20
+ )
21
+
22
+ // TestPreparePacks verifies authenticated local admission from bounded bulk transport.
23
+ //
24
+ // Verifies: a complete pack is locally reusable, while transfer corruption, body corruption, traversal, count mismatch and cancellation leave no admitted base.
25
+ // 1. Serve a ZIP with one Go body from a loopback server and admit it.
26
+ // 2. Prepare the same pack again without a second request and get its full action locally.
27
+ // 3. Break transport or ZIP contents and require a miss after rejection.
28
+ //
29
+ // @evidence contracts/testing.md#behavioral-verification Uses the production HTTP, ZIP and rooted storage operations, asserting request counts, decoded bytes, a local get and absence of an admitted base after each failure.
30
+ // @evidence contracts/testing.md#independent-expectations SHA-256 of literal payload and ZIP bytes supplies body and transport expectations independently of preparation; a complete pack has one named body and no other entry.
31
+ // @evidence contracts/testing.md#distinguishing-cases Covers success, redirect request accounting, repeated and concurrent preparation, a cancelled waiter, corrupt transfer, corrupt decoded body, traversal, wrong file count, in-flight deadline and prior cancellation; helper lifetime and real remote transfer costs are separate boundaries.
32
+ // @evidence contracts/testing.md#execution-ownership This Go unit test uses an in-process loopback server and temporary owned store, without building a native helper or installing a consumer.
33
+ func TestPreparePacks(t *testing.T) {
34
+ body := []byte("package archive bytes")
35
+ bodyHash := sha256.Sum256(body)
36
+ outputID := hex.EncodeToString(bodyHash[:])
37
+ actionID := strings.Repeat("a", 64)
38
+ makeZIP := func(name string, value []byte) []byte {
39
+ var buffer bytes.Buffer
40
+ writer := zip.NewWriter(&buffer)
41
+ entry, err := writer.Create(name)
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if _, err := entry.Write(value); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ if err := writer.Close(); err != nil {
49
+ t.Fatal(err)
50
+ }
51
+ return buffer.Bytes()
52
+ }
53
+ goodZIP := makeZIP("bodies/"+outputID, body)
54
+ for _, scenario := range []struct {
55
+ name string
56
+ zip []byte
57
+ served []byte
58
+ valid bool
59
+ cancel bool
60
+ fileCount int
61
+ }{
62
+ {name: "complete", zip: goodZIP, served: goodZIP, valid: true},
63
+ {name: "corrupt transfer", zip: goodZIP, served: append([]byte(nil), goodZIP[:len(goodZIP)-1]...)},
64
+ {name: "corrupt body", zip: makeZIP("bodies/"+outputID, append([]byte("X"), body[1:]...))},
65
+ {name: "traversal", zip: makeZIP("../outside", body)},
66
+ {name: "wrong file count", zip: goodZIP, fileCount: 2},
67
+ {name: "cancelled", zip: goodZIP, served: goodZIP, cancel: true},
68
+ } {
69
+ t.Run(scenario.name, func(t *testing.T) {
70
+ served := scenario.served
71
+ if served == nil {
72
+ served = scenario.zip
73
+ }
74
+ requests := 0
75
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
76
+ requests++
77
+ _, _ = w.Write(served)
78
+ }))
79
+ defer server.Close()
80
+ zipHash := sha256.Sum256(scenario.zip)
81
+ fileCount := scenario.fileCount
82
+ if fileCount == 0 {
83
+ fileCount = 1
84
+ }
85
+ manifest := packageartifacts.Manifest{
86
+ Packs: []packageartifacts.Pack{{
87
+ Name: "base", URL: server.URL, SHA256: hex.EncodeToString(zipHash[:]),
88
+ Length: int64(len(scenario.zip)), DecodedBytes: int64(len(body)), FileCount: fileCount,
89
+ }},
90
+ Actions: []packageartifacts.Action{{
91
+ ActionID: actionID, OutputID: outputID, BodySHA256: outputID,
92
+ BodyLength: int64(len(body)), Pack: "base", Kind: "archive",
93
+ }},
94
+ }
95
+ store, err := openArtifactStore(t.TempDir(), &manifest)
96
+ if err != nil {
97
+ t.Fatal(err)
98
+ }
99
+ defer store.close()
100
+ ctx := context.Background()
101
+ if scenario.cancel {
102
+ cancelled, cancel := context.WithCancel(ctx)
103
+ cancel()
104
+ ctx = cancelled
105
+ }
106
+ receipt, err := preparePacks(ctx, store, manifest, []string{"base"})
107
+ if scenario.valid {
108
+ if err != nil || receipt.Requests != 1 || receipt.DownloadedPacks != 1 || receipt.DecodedBytes != int64(len(body)) || receipt.Storage.BaseBytes < int64(len(body)) || receipt.Storage.StagingBytes != 0 {
109
+ t.Fatalf("valid pack not prepared: %+v %v", receipt, err)
110
+ }
111
+ value, location, found, err := store.get(actionID)
112
+ if err != nil || !found || value.OutputID != outputID {
113
+ t.Fatalf("admitted action unavailable: %+v %q %v %v", value, location, found, err)
114
+ }
115
+ read, err := os.ReadFile(location)
116
+ if err != nil || !bytes.Equal(read, body) {
117
+ t.Fatalf("admitted bytes differ: %q %v", read, err)
118
+ }
119
+ second, err := preparePacks(ctx, store, manifest, []string{"base"})
120
+ if err != nil || second.Requests != 0 || requests != 1 {
121
+ t.Fatalf("repeat fetched a complete pack: %+v %v requests=%d", second, err, requests)
122
+ }
123
+ } else {
124
+ if err == nil {
125
+ t.Fatal("invalid pack was admitted")
126
+ }
127
+ _, _, found, getErr := store.get(actionID)
128
+ if getErr != nil || found {
129
+ t.Fatalf("failed pack became a hit: %v %v", found, getErr)
130
+ }
131
+ if scenario.name == "corrupt transfer" {
132
+ final := "base/packs/base-" + manifest.Packs[0].SHA256
133
+ if err := store.root.Mkdir(final, 0700); err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ _, _, found, getErr = store.get(actionID)
137
+ if getErr != nil || found {
138
+ t.Fatalf("incomplete final pack became a hit or error: %v %v", found, getErr)
139
+ }
140
+ }
141
+ }
142
+ })
143
+ }
144
+ t.Run("redirect requests are counted", func(t *testing.T) {
145
+ var requests atomic.Int64
146
+ target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
147
+ requests.Add(1)
148
+ _, _ = w.Write(goodZIP)
149
+ }))
150
+ defer target.Close()
151
+ redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
152
+ requests.Add(1)
153
+ http.Redirect(w, request, target.URL, http.StatusFound)
154
+ }))
155
+ defer redirect.Close()
156
+ zipHash := sha256.Sum256(goodZIP)
157
+ manifest := packageartifacts.Manifest{
158
+ Packs: []packageartifacts.Pack{{
159
+ Name: "base", URL: redirect.URL, SHA256: hex.EncodeToString(zipHash[:]),
160
+ Length: int64(len(goodZIP)), DecodedBytes: int64(len(body)), FileCount: 1,
161
+ }},
162
+ Actions: []packageartifacts.Action{{
163
+ ActionID: actionID, OutputID: outputID, BodySHA256: outputID,
164
+ BodyLength: int64(len(body)), Pack: "base", Kind: "archive",
165
+ }},
166
+ }
167
+ store, err := openArtifactStore(t.TempDir(), &manifest)
168
+ if err != nil {
169
+ t.Fatal(err)
170
+ }
171
+ defer store.close()
172
+ receipt, err := preparePacks(context.Background(), store, manifest, []string{"base"})
173
+ if err != nil || receipt.Requests != 2 || requests.Load() != 2 {
174
+ t.Fatalf("redirect transfer request count differs: %+v %v requests=%d", receipt, err, requests.Load())
175
+ }
176
+ })
177
+ t.Run("concurrent equivalent preparations", func(t *testing.T) {
178
+ var requests atomic.Int64
179
+ started := make(chan struct{})
180
+ release := make(chan struct{})
181
+ var once sync.Once
182
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
183
+ requests.Add(1)
184
+ once.Do(func() { close(started) })
185
+ <-release
186
+ _, _ = w.Write(goodZIP)
187
+ }))
188
+ defer server.Close()
189
+ zipHash := sha256.Sum256(goodZIP)
190
+ manifest := packageartifacts.Manifest{
191
+ Packs: []packageartifacts.Pack{{
192
+ Name: "base", URL: server.URL, SHA256: hex.EncodeToString(zipHash[:]),
193
+ Length: int64(len(goodZIP)), DecodedBytes: int64(len(body)), FileCount: 1,
194
+ }},
195
+ Actions: []packageartifacts.Action{{
196
+ ActionID: actionID, OutputID: outputID, BodySHA256: outputID,
197
+ BodyLength: int64(len(body)), Pack: "base", Kind: "archive",
198
+ }},
199
+ }
200
+ root := t.TempDir()
201
+ stores := make([]*artifactStore, 2)
202
+ for index := range stores {
203
+ var err error
204
+ stores[index], err = openArtifactStore(root, &manifest)
205
+ if err != nil {
206
+ t.Fatal(err)
207
+ }
208
+ defer stores[index].close()
209
+ }
210
+ type result struct {
211
+ receipt preparationReceipt
212
+ err error
213
+ }
214
+ results := make(chan result, 2)
215
+ for _, store := range stores {
216
+ go func(store *artifactStore) {
217
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
218
+ defer cancel()
219
+ receipt, err := preparePacks(ctx, store, manifest, []string{"base"})
220
+ results <- result{receipt, err}
221
+ }(store)
222
+ }
223
+ select {
224
+ case <-started:
225
+ case <-time.After(5 * time.Second):
226
+ t.Fatal("first transfer did not start")
227
+ }
228
+ close(release)
229
+ var downloads int
230
+ for range stores {
231
+ select {
232
+ case outcome := <-results:
233
+ if outcome.err != nil {
234
+ t.Fatal(outcome.err)
235
+ }
236
+ downloads += outcome.receipt.DownloadedPacks
237
+ case <-time.After(10 * time.Second):
238
+ t.Fatal("concurrent preparation did not settle")
239
+ }
240
+ }
241
+ if requests.Load() != 1 || downloads != 1 {
242
+ t.Fatalf("equivalent preparations did not share one transfer: requests=%d downloads=%d", requests.Load(), downloads)
243
+ }
244
+ })
245
+ t.Run("cancelled waiter preserves owner", func(t *testing.T) {
246
+ var requests atomic.Int64
247
+ started := make(chan struct{})
248
+ release := make(chan struct{})
249
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
250
+ requests.Add(1)
251
+ close(started)
252
+ <-release
253
+ _, _ = w.Write(goodZIP)
254
+ }))
255
+ defer server.Close()
256
+ zipHash := sha256.Sum256(goodZIP)
257
+ manifest := packageartifacts.Manifest{
258
+ Packs: []packageartifacts.Pack{{
259
+ Name: "base", URL: server.URL, SHA256: hex.EncodeToString(zipHash[:]),
260
+ Length: int64(len(goodZIP)), DecodedBytes: int64(len(body)), FileCount: 1,
261
+ }},
262
+ Actions: []packageartifacts.Action{{
263
+ ActionID: actionID, OutputID: outputID, BodySHA256: outputID,
264
+ BodyLength: int64(len(body)), Pack: "base", Kind: "archive",
265
+ }},
266
+ }
267
+ root := t.TempDir()
268
+ owner, err := openArtifactStore(root, &manifest)
269
+ if err != nil {
270
+ t.Fatal(err)
271
+ }
272
+ defer owner.close()
273
+ waiter, err := openArtifactStore(root, &manifest)
274
+ if err != nil {
275
+ t.Fatal(err)
276
+ }
277
+ defer waiter.close()
278
+ settled := make(chan error, 1)
279
+ go func() {
280
+ _, err := preparePacks(context.Background(), owner, manifest, []string{"base"})
281
+ settled <- err
282
+ }()
283
+ select {
284
+ case <-started:
285
+ case <-time.After(5 * time.Second):
286
+ t.Fatal("preparation owner did not start")
287
+ }
288
+ ctx, cancel := context.WithCancel(context.Background())
289
+ cancel()
290
+ if _, err := preparePacks(ctx, waiter, manifest, []string{"base"}); !errors.Is(err, context.Canceled) {
291
+ t.Fatalf("cancelled waiter did not leave independently: %v", err)
292
+ }
293
+ close(release)
294
+ select {
295
+ case err := <-settled:
296
+ if err != nil || requests.Load() != 1 {
297
+ t.Fatalf("owner failed after waiter left: %v requests=%d", err, requests.Load())
298
+ }
299
+ case <-time.After(10 * time.Second):
300
+ t.Fatal("owner did not finish after waiter left")
301
+ }
302
+ })
303
+ t.Run("transfer deadline", func(t *testing.T) {
304
+ started := make(chan struct{})
305
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
306
+ w.WriteHeader(http.StatusOK)
307
+ w.(http.Flusher).Flush()
308
+ close(started)
309
+ <-r.Context().Done()
310
+ }))
311
+ defer server.Close()
312
+ zipHash := sha256.Sum256(goodZIP)
313
+ manifest := packageartifacts.Manifest{
314
+ Packs: []packageartifacts.Pack{{
315
+ Name: "base", URL: server.URL, SHA256: hex.EncodeToString(zipHash[:]),
316
+ Length: int64(len(goodZIP)), DecodedBytes: int64(len(body)), FileCount: 1,
317
+ }},
318
+ Actions: []packageartifacts.Action{{
319
+ ActionID: actionID, OutputID: outputID, BodySHA256: outputID,
320
+ BodyLength: int64(len(body)), Pack: "base", Kind: "archive",
321
+ }},
322
+ }
323
+ store, err := openArtifactStore(t.TempDir(), &manifest)
324
+ if err != nil {
325
+ t.Fatal(err)
326
+ }
327
+ defer store.close()
328
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
329
+ defer cancel()
330
+ if _, err := preparePacks(ctx, store, manifest, []string{"base"}); err == nil {
331
+ t.Fatal("timed-out transfer was accepted")
332
+ }
333
+ select {
334
+ case <-started:
335
+ default:
336
+ t.Fatal("transfer deadline test did not reach the HTTP server")
337
+ }
338
+ if admittedPack(store, manifest.Packs[0], manifest.Actions) {
339
+ t.Fatal("timed-out transfer admitted an incomplete pack")
340
+ }
341
+ })
342
+ store, err := openArtifactStore(t.TempDir(), nil)
343
+ if err != nil {
344
+ t.Fatal(err)
345
+ }
346
+ defer store.close()
347
+ oversized := packageartifacts.Manifest{Packs: []packageartifacts.Pack{
348
+ {Name: "first", Length: 300 << 20, DecodedBytes: 2 << 30},
349
+ {Name: "second", Length: 300 << 20, DecodedBytes: 2 << 30},
350
+ }}
351
+ receipt, err := preparePacks(context.Background(), store, oversized, []string{"first", "second"})
352
+ if err == nil || receipt.Requests != 0 {
353
+ t.Fatalf("oversized selection began transfer: %+v %v", receipt, err)
354
+ }
355
+ }
356
+
357
+ // TestPrepareFailurePreservesBase verifies that a rejected newer pack does not
358
+ // remove an admitted older generation or turn its mapping into a partial hit.
359
+ //
360
+ // Verifies: a successful pack remains readable after a later corrupt transfer,
361
+ // offline endpoint, or disallowed redirect at the same artifact root.
362
+ // 1. Admit one authenticated pack into a temporary artifact root.
363
+ // 2. Attempt a newer generation through each failing endpoint.
364
+ // 3. Reopen the original generation and compare its stored body after each failure.
365
+ //
366
+ // @evidence contracts/testing.md#behavioral-verification Reopens the same owned root with its earlier authenticated catalog after each failed real HTTP preparation and reads the original body.
367
+ // @evidence contracts/testing.md#independent-expectations Literal body SHA-256 and ZIP bytes define the first generation; a distinct transport digest defines the rejected generation.
368
+ // @evidence contracts/testing.md#distinguishing-cases Transfer corruption, an offline endpoint and a redirect outside the allowed loopback/HTTPS policy all leave the previous generation intact.
369
+ // @evidence contracts/testing.md#execution-ownership The test uses private temporary storage and local HTTP servers only; each server and store settles before the root is deleted.
370
+ func TestPrepareFailurePreservesBase(t *testing.T) {
371
+ root := t.TempDir()
372
+ item := []byte("admitted body")
373
+ outputSum := sha256.Sum256(item)
374
+ outputID := hex.EncodeToString(outputSum[:])
375
+ var archive bytes.Buffer
376
+ writer := zip.NewWriter(&archive)
377
+ entry, err := writer.Create("bodies/" + outputID)
378
+ if err != nil {
379
+ t.Fatal(err)
380
+ }
381
+ if _, err := entry.Write(item); err != nil {
382
+ t.Fatal(err)
383
+ }
384
+ if err := writer.Close(); err != nil {
385
+ t.Fatal(err)
386
+ }
387
+ packSum := sha256.Sum256(archive.Bytes())
388
+ packHash := hex.EncodeToString(packSum[:])
389
+ actionID := strings.Repeat("a", 64)
390
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
391
+ _, _ = w.Write(archive.Bytes())
392
+ }))
393
+ defer server.Close()
394
+ old := packageartifacts.Manifest{
395
+ Packs: []packageartifacts.Pack{{
396
+ Name: "generation", URL: server.URL, SHA256: packHash,
397
+ Length: int64(archive.Len()), DecodedBytes: int64(len(item)), FileCount: 1,
398
+ }},
399
+ Actions: []packageartifacts.Action{{
400
+ ActionID: actionID, OutputID: outputID, BodySHA256: outputID,
401
+ BodyLength: int64(len(item)), Pack: "generation", Kind: "archive",
402
+ }},
403
+ }
404
+ initial, err := openArtifactStore(root, &old)
405
+ if err != nil {
406
+ t.Fatal(err)
407
+ }
408
+ if _, err := preparePacks(context.Background(), initial, old, []string{"generation"}); err != nil {
409
+ t.Fatal(err)
410
+ }
411
+ if err := initial.close(); err != nil {
412
+ t.Fatal(err)
413
+ }
414
+ redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
415
+ http.Redirect(w, r, "http://example.com/disallowed", http.StatusFound)
416
+ }))
417
+ defer redirect.Close()
418
+ offline := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
419
+ offlineURL := offline.URL
420
+ offline.Close()
421
+ for name, endpoint := range map[string]string{
422
+ "corrupt": server.URL,
423
+ "offline": offlineURL,
424
+ "redirect": redirect.URL,
425
+ } {
426
+ t.Run(name, func(t *testing.T) {
427
+ bad := old
428
+ bad.Packs = append([]packageartifacts.Pack(nil), old.Packs...)
429
+ bad.Packs[0].SHA256 = strings.Repeat("b", 64)
430
+ bad.Packs[0].URL = endpoint
431
+ store, err := openArtifactStore(root, &bad)
432
+ if err != nil {
433
+ t.Fatal(err)
434
+ }
435
+ if _, err := preparePacks(context.Background(), store, bad, []string{"generation"}); err == nil {
436
+ t.Fatal("rejected generation was admitted")
437
+ }
438
+ if err := store.close(); err != nil {
439
+ t.Fatal(err)
440
+ }
441
+ previous, err := openArtifactStore(root, &old)
442
+ if err != nil {
443
+ t.Fatal(err)
444
+ }
445
+ defer previous.close()
446
+ _, location, found, err := previous.get(actionID)
447
+ if err != nil || !found {
448
+ t.Fatalf("previous generation disappeared: %v %v", found, err)
449
+ }
450
+ read, err := os.ReadFile(location)
451
+ if err != nil || !bytes.Equal(read, item) {
452
+ t.Fatalf("previous body changed: %q %v", read, err)
453
+ }
454
+ })
455
+ }
456
+ }
@@ -0,0 +1,119 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "crypto/sha256"
6
+ "encoding/base64"
7
+ "encoding/json"
8
+ "fmt"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "testing"
13
+ )
14
+
15
+ // TestCacheProtocol verifies the Go 1.26 request framing and response contract.
16
+ //
17
+ // Verifies: the helper advertises capabilities, echoes IDs, returns local hits and misses, writes a bounded usage receipt, and refuses malformed body transport.
18
+ // 1. Send real Go-style request headers and separate base64 body values through the in-process server.
19
+ // 2. Read every response and compare IDs, capabilities, paths and body metadata.
20
+ // 3. Break JSON, base64 and body framing independently and require terminal failure.
21
+ //
22
+ // @evidence contracts/testing.md#behavioral-verification Calls the production protocol loop against an actual temporary store, parses its public JSON responses and reads the post-close local usage receipt.
23
+ // @evidence contracts/testing.md#independent-expectations The Go 1.26 cacheprog Request and Response fields, the extra request separator, and SHA-256 of literal bytes define expected framing and identities.
24
+ // @evidence contracts/testing.md#distinguishing-cases Covers startup, two misses, a large put/hit, empty body, unknown command, close, malformed JSON, bad base64 and truncated body; stock Go subprocess interoperability is a separate installed-boundary check.
25
+ // @evidence contracts/testing.md#execution-ownership This Go unit test calls serve directly without compiling a helper or launching a Go client; each case owns a temporary store.
26
+ func TestCacheProtocol(t *testing.T) {
27
+ action := bytes.Repeat([]byte{0x1a}, 32)
28
+ missing := bytes.Repeat([]byte{0x2b}, 32)
29
+ body := bytes.Repeat([]byte("archive-data-"), 350000)
30
+ outputID := sha256.Sum256(body)
31
+ emptyID := sha256.Sum256(nil)
32
+ requestLine := func(id int64, command string, key, output []byte, size int64) string {
33
+ encoded, err := json.Marshal(request{ID: id, Command: command, ActionID: key, OutputID: output, BodySize: size})
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ return string(encoded) + "\n\n"
38
+ }
39
+ input := requestLine(1, "get", missing, nil, 0) +
40
+ requestLine(2, "put", action, outputID[:], int64(len(body))) +
41
+ "\"" + base64.StdEncoding.EncodeToString(body) + "\"\n" +
42
+ requestLine(3, "get", action, nil, 0) +
43
+ requestLine(4, "get", missing, nil, 0) +
44
+ requestLine(5, "put", missing, emptyID[:], 0) +
45
+ requestLine(6, "get", missing, nil, 0) +
46
+ requestLine(7, "future", nil, nil, 0) +
47
+ requestLine(8, "close", nil, nil, 0)
48
+ store, err := openArtifactStore(t.TempDir(), nil)
49
+ if err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ defer store.close()
53
+ store.receiptName = strings.Repeat("a", 32)
54
+ var output bytes.Buffer
55
+ if err := serve(strings.NewReader(input), &output, store); err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ lines := strings.Split(strings.TrimSpace(output.String()), "\n")
59
+ if len(lines) != 9 {
60
+ t.Fatalf("expected startup and eight responses, got %d", len(lines))
61
+ }
62
+ responses := make([]response, len(lines))
63
+ for i, line := range lines {
64
+ if err := json.Unmarshal([]byte(line), &responses[i]); err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ }
68
+ if responses[0].ID != 0 || fmt.Sprint(responses[0].KnownCommands) != "[get put close]" {
69
+ t.Fatalf("incorrect startup capabilities: %+v", responses[0])
70
+ }
71
+ for i := 1; i < len(responses); i++ {
72
+ if responses[i].ID != int64(i) {
73
+ t.Fatalf("response %d lost request ID: %+v", i, responses[i])
74
+ }
75
+ }
76
+ if !responses[1].Miss || !responses[4].Miss || responses[2].DiskPath == "" || responses[3].DiskPath != responses[2].DiskPath || responses[3].Size != int64(len(body)) || !bytes.Equal(responses[3].OutputID, outputID[:]) {
77
+ t.Fatalf("large body get/put contract failed: %+v", responses)
78
+ }
79
+ if responses[5].DiskPath == "" || responses[6].DiskPath != responses[5].DiskPath || !bytes.Equal(responses[6].OutputID, emptyID[:]) || responses[7].Err == "" || responses[8].Err != "" {
80
+ t.Fatalf("empty body, unknown command or close contract failed: %+v", responses)
81
+ }
82
+ receiptRaw, err := os.ReadFile(filepath.Join(store.path, "receipts", store.receiptName))
83
+ if err != nil {
84
+ t.Fatal(err)
85
+ }
86
+ if !bytes.Contains(receiptRaw, []byte(`"baseActionIDs":[]`)) {
87
+ t.Fatalf("unused action identity list was not an empty JSON array: %s", receiptRaw)
88
+ }
89
+ var usage usageReceipt
90
+ if err := json.Unmarshal(receiptRaw, &usage); err != nil {
91
+ t.Fatal(err)
92
+ }
93
+ if usage.Gets != 4 || usage.OverlayHits != 2 || usage.BaseHits != 0 || usage.Misses != 2 || usage.Puts != 2 || usage.PutBytes != int64(len(body)) {
94
+ t.Fatalf("usage receipt did not distinguish exact hits and misses: %+v", usage)
95
+ }
96
+ if usage.Storage == nil || usage.Storage.OverlayBytes < int64(len(body)) || usage.Storage.PinnedBytes < int64(len(body)) || usage.Storage.StagingBytes != 0 {
97
+ t.Fatalf("usage receipt omitted settled storage populations: %+v", usage.Storage)
98
+ }
99
+ if usage.Truncated || len(usage.MissActionIDs) != 2 || len(usage.OverlayActionIDs) != 2 || len(usage.PutActionIDs) != 2 || usage.MissActionIDs[0] != fmt.Sprintf("%x", missing) || usage.OverlayActionIDs[0] != fmt.Sprintf("%x", action) || usage.PutActionIDs[0] != fmt.Sprintf("%x", action) {
100
+ t.Fatalf("usage receipt lost full requested action identities: %+v", usage)
101
+ }
102
+ for name, malformed := range map[string]string{
103
+ "json": "{not json}\n",
104
+ "base64": requestLine(1, "put", action, outputID[:], 3) + "\"!!!\"\n",
105
+ "truncated": requestLine(1, "put", action, outputID[:], 3) + "\"YQ==",
106
+ } {
107
+ t.Run(name, func(t *testing.T) {
108
+ caseStore, err := openArtifactStore(t.TempDir(), nil)
109
+ if err != nil {
110
+ t.Fatal(err)
111
+ }
112
+ defer caseStore.close()
113
+ var sink bytes.Buffer
114
+ if err := serve(strings.NewReader(malformed), &sink, caseStore); err == nil {
115
+ t.Fatal("malformed request was accepted")
116
+ }
117
+ })
118
+ }
119
+ }