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.
- package/cmd/ttsc-cache/account.go +94 -0
- package/cmd/ttsc-cache/interruption_test.go +176 -0
- package/cmd/ttsc-cache/main.go +252 -0
- package/cmd/ttsc-cache/merge.go +324 -0
- package/cmd/ttsc-cache/merge_test.go +161 -0
- package/cmd/ttsc-cache/prepare.go +330 -0
- package/cmd/ttsc-cache/prepare_test.go +456 -0
- package/cmd/ttsc-cache/protocol_test.go +119 -0
- package/cmd/ttsc-cache/publisher_seal_test.go +135 -0
- package/cmd/ttsc-cache/seal.go +427 -0
- package/cmd/ttsc-cache/stock_go_failure_test.go +94 -0
- package/cmd/ttsc-cache/stock_go_test.go +163 -0
- package/cmd/ttsc-cache/store.go +249 -0
- package/cmd/ttsc-cache/store_test.go +104 -0
- package/cmd/ttsc-cache/tuple.go +107 -0
- package/cmd/ttsc-cache/tuple_test.go +62 -0
- package/cmd/ttsc-cache/usage.go +73 -0
- package/driver/configloaderscratch/canonical_directory_other.go +34 -0
- package/driver/configloaderscratch/canonical_directory_windows.go +60 -0
- package/driver/configloaderscratch/identity_temp_base.go +81 -0
- package/driver/configloaderscratch/identity_temp_base_posix_test.go +124 -0
- package/driver/configloaderscratch/identity_temp_base_windows_test.go +156 -0
- package/driver/emit_provenance.go +7 -5
- package/driver/resolutioninputs/recorder.cjs +16 -4
- package/driver/rewrite.go +25 -16
- package/driver/write_file_if_changed.go +69 -0
- package/driver/write_file_if_changed_test.go +148 -0
- package/internal/packageartifacts/manifest.go +288 -0
- package/internal/packageartifacts/manifest_test.go +127 -0
- package/lib/compiler/internal/build/BuildExecution.d.ts +11 -7
- package/lib/compiler/internal/build/BuildExecution.js +22 -8
- package/lib/compiler/internal/build/BuildExecution.js.map +1 -1
- package/lib/compiler/internal/build/ResidentCheckWatchSession.d.ts +10 -10
- package/lib/compiler/internal/build/ResidentCheckWatchSession.js +19 -14
- package/lib/compiler/internal/build/ResidentCheckWatchSession.js.map +1 -1
- package/lib/compiler/internal/build/RunBuildOptions.d.ts +8 -1
- package/lib/compiler/internal/build/TsgoArguments.d.ts +16 -0
- package/lib/compiler/internal/build/TsgoArguments.js +19 -0
- package/lib/compiler/internal/build/TsgoArguments.js.map +1 -1
- package/lib/compiler/internal/captureProcessOutput.d.ts +16 -13
- package/lib/compiler/internal/captureProcessOutput.js +22 -14
- package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
- package/lib/compiler/internal/startResidentTransform.js +3 -1
- package/lib/compiler/internal/startResidentTransform.js.map +1 -1
- package/lib/compiler/internal/transformProjectInMemory.js +5 -1
- package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
- package/lib/launcher/internal/runtime/PluginDescriptorInputReport.d.ts +43 -0
- package/lib/launcher/internal/runtime/PluginDescriptorInputReport.js +106 -0
- package/lib/launcher/internal/runtime/PluginDescriptorInputReport.js.map +1 -0
- package/lib/launcher/internal/runtime/installRuntimeHooks.js +15 -29
- package/lib/launcher/internal/runtime/installRuntimeHooks.js.map +1 -1
- package/lib/launcher/internal/ttscserver/runTtscserver.js +1 -0
- package/lib/launcher/internal/ttscserver/runTtscserver.js.map +1 -1
- package/lib/launcher/internal/watch/WatchTopology.js +5 -4
- package/lib/launcher/internal/watch/WatchTopology.js.map +1 -1
- package/lib/plugin/internal/CapabilityResolutionFormat.js +4 -3
- package/lib/plugin/internal/CapabilityResolutionFormat.js.map +1 -1
- package/lib/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.js +6 -0
- package/lib/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.js.map +1 -1
- package/lib/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.js +6 -0
- package/lib/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.js.map +1 -1
- package/lib/plugin/internal/load/PluginDescriptorEvaluationCache.js +3 -3
- package/lib/plugin/internal/load/PluginDescriptorEvaluationCache.js.map +1 -1
- package/lib/plugin/internal/load/descriptorRecordEnvironment.d.ts +15 -0
- package/lib/plugin/internal/load/descriptorRecordEnvironment.js +24 -0
- package/lib/plugin/internal/load/descriptorRecordEnvironment.js.map +1 -0
- package/lib/plugin/internal/load/loadProjectPlugins.js +28 -26
- package/lib/plugin/internal/load/loadProjectPlugins.js.map +1 -1
- package/lib/plugin/internal/source/SourceBuildCacheLayout.d.ts +2 -0
- package/lib/plugin/internal/source/SourceBuildCacheLayout.js +2 -0
- package/lib/plugin/internal/source/SourceBuildCacheLayout.js.map +1 -1
- package/lib/plugin/internal/source/SourcePackageArtifacts.d.ts +145 -0
- package/lib/plugin/internal/source/SourcePackageArtifacts.js +612 -0
- package/lib/plugin/internal/source/SourcePackageArtifacts.js.map +1 -0
- package/lib/plugin/internal/source/buildSourcePlugin.js +54 -4
- package/lib/plugin/internal/source/buildSourcePlugin.js.map +1 -1
- package/lib/plugin/internal/source/prunePackageArtifactRoot.d.ts +19 -0
- package/lib/plugin/internal/source/prunePackageArtifactRoot.js +95 -0
- package/lib/plugin/internal/source/prunePackageArtifactRoot.js.map +1 -0
- package/lib/plugin/internal/source/resolveCleanTargets.js +1 -1
- package/lib/plugin/internal/source/resolveCleanTargets.js.map +1 -1
- package/lib/plugin/internal/source/resolveSourceBuildCachePaths.js +9 -4
- package/lib/plugin/internal/source/resolveSourceBuildCachePaths.js.map +1 -1
- package/lib/plugin/internal/source/selectedRecordIdentityTempParent.d.ts +18 -0
- package/lib/plugin/internal/source/selectedRecordIdentityTempParent.js +46 -0
- package/lib/plugin/internal/source/selectedRecordIdentityTempParent.js.map +1 -0
- package/package.json +8 -8
- package/src/compiler/internal/build/BuildExecution.ts +30 -10
- package/src/compiler/internal/build/ResidentCheckWatchSession.ts +23 -22
- package/src/compiler/internal/build/RunBuildOptions.ts +9 -1
- package/src/compiler/internal/build/TsgoArguments.ts +22 -0
- package/src/compiler/internal/captureProcessOutput.ts +28 -14
- package/src/compiler/internal/startResidentTransform.ts +7 -1
- package/src/compiler/internal/transformProjectInMemory.ts +9 -1
- package/src/launcher/internal/runtime/PluginDescriptorInputReport.ts +100 -0
- package/src/launcher/internal/runtime/installRuntimeHooks.ts +57 -55
- package/src/launcher/internal/ttscserver/runTtscserver.ts +5 -0
- package/src/launcher/internal/watch/WatchTopology.ts +5 -4
- package/src/plugin/internal/CapabilityResolutionFormat.ts +4 -3
- package/src/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.ts +4 -0
- package/src/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.ts +4 -0
- package/src/plugin/internal/load/PluginDescriptorEvaluationCache.ts +3 -3
- package/src/plugin/internal/load/descriptorRecordEnvironment.ts +24 -0
- package/src/plugin/internal/load/loadProjectPlugins.ts +45 -29
- package/src/plugin/internal/source/SourceBuildCacheLayout.ts +3 -0
- package/src/plugin/internal/source/SourcePackageArtifacts.ts +787 -0
- package/src/plugin/internal/source/buildSourcePlugin.ts +82 -12
- package/src/plugin/internal/source/prunePackageArtifactRoot.ts +100 -0
- package/src/plugin/internal/source/resolveCleanTargets.ts +1 -0
- package/src/plugin/internal/source/resolveSourceBuildCachePaths.ts +11 -4
- 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
|
+
}
|