ttsc 0.32.1 → 0.33.0-next.1

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 (44) 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/internal/packageartifacts/manifest.go +288 -0
  19. package/internal/packageartifacts/manifest_test.go +127 -0
  20. package/lib/launcher/internal/watch/WatchTopology.js +5 -4
  21. package/lib/launcher/internal/watch/WatchTopology.js.map +1 -1
  22. package/lib/plugin/internal/source/SourceBuildCacheLayout.d.ts +2 -0
  23. package/lib/plugin/internal/source/SourceBuildCacheLayout.js +2 -0
  24. package/lib/plugin/internal/source/SourceBuildCacheLayout.js.map +1 -1
  25. package/lib/plugin/internal/source/SourcePackageArtifacts.d.ts +145 -0
  26. package/lib/plugin/internal/source/SourcePackageArtifacts.js +612 -0
  27. package/lib/plugin/internal/source/SourcePackageArtifacts.js.map +1 -0
  28. package/lib/plugin/internal/source/buildSourcePlugin.js +54 -4
  29. package/lib/plugin/internal/source/buildSourcePlugin.js.map +1 -1
  30. package/lib/plugin/internal/source/prunePackageArtifactRoot.d.ts +19 -0
  31. package/lib/plugin/internal/source/prunePackageArtifactRoot.js +95 -0
  32. package/lib/plugin/internal/source/prunePackageArtifactRoot.js.map +1 -0
  33. package/lib/plugin/internal/source/resolveCleanTargets.js +1 -1
  34. package/lib/plugin/internal/source/resolveCleanTargets.js.map +1 -1
  35. package/lib/plugin/internal/source/resolveSourceBuildCachePaths.js +9 -4
  36. package/lib/plugin/internal/source/resolveSourceBuildCachePaths.js.map +1 -1
  37. package/package.json +8 -8
  38. package/src/launcher/internal/watch/WatchTopology.ts +5 -4
  39. package/src/plugin/internal/source/SourceBuildCacheLayout.ts +3 -0
  40. package/src/plugin/internal/source/SourcePackageArtifacts.ts +787 -0
  41. package/src/plugin/internal/source/buildSourcePlugin.ts +82 -12
  42. package/src/plugin/internal/source/prunePackageArtifactRoot.ts +100 -0
  43. package/src/plugin/internal/source/resolveCleanTargets.ts +1 -0
  44. package/src/plugin/internal/source/resolveSourceBuildCachePaths.ts +11 -4
@@ -0,0 +1,94 @@
1
+ package main
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "io"
7
+ "os"
8
+ "path/filepath"
9
+ )
10
+
11
+ type artifactAccounting struct {
12
+ BaseBytes int64 `json:"baseBytes"`
13
+ OverlayBytes int64 `json:"overlayBytes"`
14
+ StagingBytes int64 `json:"stagingBytes"`
15
+ PinnedBytes int64 `json:"pinnedBytes"`
16
+ BaseFiles int64 `json:"baseFiles"`
17
+ OverlayFiles int64 `json:"overlayFiles"`
18
+ StagingFiles int64 `json:"stagingFiles"`
19
+ }
20
+
21
+ // accountArtifactStore reads bounded rooted metadata for every retained cache
22
+ // population. The byte totals are logical file lengths, not free-space or
23
+ // physical allocated-byte claims; concurrent writers can change them later.
24
+ func accountArtifactStore(store *artifactStore) (artifactAccounting, error) {
25
+ var result artifactAccounting
26
+ var entries int
27
+ var walk func(string, int, *int64, *int64) error
28
+ walk = func(directory string, depth int, bytes, files *int64) error {
29
+ if depth > 4 {
30
+ return errors.New("artifact storage exceeds directory depth policy")
31
+ }
32
+ opened, err := store.root.Open(directory)
33
+ if err != nil {
34
+ return err
35
+ }
36
+ defer opened.Close()
37
+ for {
38
+ batch, readErr := opened.ReadDir(256)
39
+ for _, entry := range batch {
40
+ entries++
41
+ if entries > 100000 {
42
+ return errors.New("artifact storage exceeds entry count policy")
43
+ }
44
+ child := filepath.ToSlash(filepath.Join(directory, entry.Name()))
45
+ info, err := store.root.Lstat(child)
46
+ if err != nil {
47
+ return err
48
+ }
49
+ if info.IsDir() && info.Mode()&os.ModeSymlink == 0 {
50
+ if err := walk(child, depth+1, bytes, files); err != nil {
51
+ return err
52
+ }
53
+ } else if info.Mode().IsRegular() {
54
+ if info.Size() < 0 || info.Size() > 1<<40 || *bytes > (1<<50)-info.Size() {
55
+ return fmt.Errorf("artifact storage has an invalid file size: %s", child)
56
+ }
57
+ *bytes += info.Size()
58
+ *files += 1
59
+ } else {
60
+ return fmt.Errorf("artifact storage contains an aliased or unknown entry: %s", child)
61
+ }
62
+ }
63
+ if errors.Is(readErr, io.EOF) {
64
+ return nil
65
+ }
66
+ if readErr != nil {
67
+ return readErr
68
+ }
69
+ }
70
+ }
71
+ for _, part := range []struct {
72
+ path string
73
+ bytes *int64
74
+ files *int64
75
+ }{
76
+ {"base/packs", &result.BaseBytes, &result.BaseFiles},
77
+ {"base/staging", &result.StagingBytes, &result.StagingFiles},
78
+ {"overlay/bodies", &result.OverlayBytes, &result.OverlayFiles},
79
+ {"overlay/actions", &result.OverlayBytes, &result.OverlayFiles},
80
+ {"overlay/staging", &result.StagingBytes, &result.StagingFiles},
81
+ } {
82
+ if err := walk(part.path, 0, part.bytes, part.files); err != nil {
83
+ return artifactAccounting{}, err
84
+ }
85
+ }
86
+ for _, pinned := range store.pins {
87
+ info, err := pinned.Stat()
88
+ if err != nil || !info.Mode().IsRegular() || info.Size() < 0 || result.PinnedBytes > (1<<50)-info.Size() {
89
+ return artifactAccounting{}, errors.New("artifact pinned body accounting failed")
90
+ }
91
+ result.PinnedBytes += info.Size()
92
+ }
93
+ return result, nil
94
+ }
@@ -0,0 +1,176 @@
1
+ package main
2
+
3
+ import (
4
+ "bufio"
5
+ "bytes"
6
+ "crypto/sha256"
7
+ "encoding/base64"
8
+ "encoding/hex"
9
+ "encoding/json"
10
+ "errors"
11
+ "fmt"
12
+ "io"
13
+ "os"
14
+ "os/exec"
15
+ "path/filepath"
16
+ "strings"
17
+ "testing"
18
+ "time"
19
+ )
20
+
21
+ // TestCacheHelperInterruption verifies that an interrupted protocol operation cannot publish an incomplete action.
22
+ //
23
+ // Verifies: killing a helper during a streamed put leaves the new action absent, and a broken get response leaves an older complete action readable.
24
+ // 1. Seed a complete action, start the production helper protocol, and send only the beginning of a larger put body.
25
+ // 2. Kill the helper after its staging file appears, then reopen the store and compare the old hit and interrupted miss.
26
+ // 3. Break the response pipe during a get and reopen the store to require the same complete hit.
27
+ //
28
+ // @evidence contracts/testing.md#behavioral-verification Runs the production helper as a child through its public protocol, kills it during a partial streamed put, and interrupts the direct server's response writer on a real get.
29
+ // @evidence contracts/testing.md#independent-expectations An action mapping requires a complete body matching its SHA-256 OutputID; a partly received body has no valid mapping, while an earlier completed mapping must survive both failures.
30
+ // @evidence contracts/testing.md#distinguishing-cases Covers process death after a partial body is sent and a get response failure with a prior valid hit and a new missing action; stock Go's reaction to a bad helper is owned by TestStockGoCacheFailures.
31
+ // @evidence contracts/testing.md#execution-ownership This Go test uses a temporary local helper process and store; it does not install a consumer or run a product E2E build.
32
+ func TestCacheHelperInterruption(t *testing.T) {
33
+ root := t.TempDir()
34
+ oldAction := bytes.Repeat([]byte{0x31}, 32)
35
+ newAction := bytes.Repeat([]byte{0x32}, 32)
36
+ oldBody := []byte("completed archive")
37
+ oldOutput := sha256.Sum256(oldBody)
38
+ initial, err := openArtifactStore(root, nil)
39
+ if err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ if _, err := initial.put(hex.EncodeToString(oldAction), hex.EncodeToString(oldOutput[:]), int64(len(oldBody)), bytes.NewReader(oldBody)); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if err := initial.close(); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+
49
+ helper := filepath.Join(t.TempDir(), "cache helper"+exeSuffix())
50
+ build := exec.Command("go", "build", "-trimpath", "-o", helper, ".")
51
+ build.Env = append(os.Environ(), "GOCACHEPROG=", "GOTOOLCHAIN=local")
52
+ if output, err := build.CombinedOutput(); err != nil {
53
+ t.Fatalf("building cache helper: %v\n%s", err, output)
54
+ }
55
+ command := exec.Command(helper, "-root", root)
56
+ stdin, err := command.StdinPipe()
57
+ if err != nil {
58
+ t.Fatal(err)
59
+ }
60
+ stdout, err := command.StdoutPipe()
61
+ if err != nil {
62
+ t.Fatal(err)
63
+ }
64
+ var stderr bytes.Buffer
65
+ command.Stderr = &stderr
66
+ if err := command.Start(); err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ defer func() {
70
+ if command.ProcessState == nil {
71
+ command.Process.Kill()
72
+ command.Wait()
73
+ }
74
+ }()
75
+ startup, err := bufio.NewReader(stdout).ReadBytes('\n')
76
+ if err != nil {
77
+ t.Fatalf("reading helper startup: %v; stderr: %s", err, &stderr)
78
+ }
79
+ var capability response
80
+ if err := json.Unmarshal(startup, &capability); err != nil || fmt.Sprint(capability.KnownCommands) != "[get put close]" {
81
+ t.Fatalf("helper did not advertise the Go cache protocol: %s; %v", startup, err)
82
+ }
83
+ body := bytes.Repeat([]byte("interrupted archive body"), 50000)
84
+ outputID := sha256.Sum256(body)
85
+ header, err := json.Marshal(request{ID: 1, Command: "put", ActionID: newAction, OutputID: outputID[:], BodySize: int64(len(body))})
86
+ if err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ if _, err := fmt.Fprintf(stdin, "%s\n\n\"%s", header, base64.StdEncoding.EncodeToString(body[:98304])); err != nil {
90
+ t.Fatalf("sending partial Go cache body: %v", err)
91
+ }
92
+ staging := filepath.Join(root, "overlay", "staging")
93
+ deadline := time.Now().Add(10 * time.Second)
94
+ staged := false
95
+ for time.Now().Before(deadline) {
96
+ entries, err := os.ReadDir(staging)
97
+ if err != nil {
98
+ t.Fatal(err)
99
+ }
100
+ staged = len(entries) > 0
101
+ if staged {
102
+ break
103
+ }
104
+ time.Sleep(10 * time.Millisecond)
105
+ }
106
+ if !staged {
107
+ entries, _ := os.ReadDir(staging)
108
+ command.Process.Kill()
109
+ command.Wait()
110
+ remaining, _ := io.ReadAll(stdout)
111
+ t.Fatalf("helper never opened a put staging file: entries=%d remaining=%q stderr=%s", len(entries), remaining, &stderr)
112
+ }
113
+ if err := command.Process.Kill(); err != nil {
114
+ t.Fatal(err)
115
+ }
116
+ command.Wait()
117
+ stdin.Close()
118
+ stdout.Close()
119
+
120
+ reopened, err := openArtifactStore(root, nil)
121
+ if err != nil {
122
+ t.Fatal(err)
123
+ }
124
+ _, _, found, err := reopened.get(hex.EncodeToString(newAction))
125
+ if err != nil || found {
126
+ t.Fatalf("interrupted put became a hit: found=%v error=%v", found, err)
127
+ }
128
+ oldValue, oldPath, found, err := reopened.get(hex.EncodeToString(oldAction))
129
+ if err != nil || !found || oldValue.OutputID != hex.EncodeToString(oldOutput[:]) {
130
+ t.Fatalf("interrupted put damaged the complete action: value=%+v found=%v error=%v", oldValue, found, err)
131
+ }
132
+ if raw, err := os.ReadFile(oldPath); err != nil || !bytes.Equal(raw, oldBody) {
133
+ t.Fatalf("complete body changed after interruption: %q %v", raw, err)
134
+ }
135
+ if err := reopened.close(); err != nil {
136
+ t.Fatal(err)
137
+ }
138
+
139
+ interruptedGet, err := openArtifactStore(root, nil)
140
+ if err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ getHeader, err := json.Marshal(request{ID: 2, Command: "get", ActionID: oldAction})
144
+ if err != nil {
145
+ t.Fatal(err)
146
+ }
147
+ broken := &failSecondResponse{}
148
+ err = serve(strings.NewReader(string(getHeader)+"\n"), broken, interruptedGet)
149
+ if !errors.Is(err, errBrokenResponse) {
150
+ t.Fatalf("interrupted get did not report its broken response: %v", err)
151
+ }
152
+ if err := interruptedGet.close(); err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ settled, err := openArtifactStore(root, nil)
156
+ if err != nil {
157
+ t.Fatal(err)
158
+ }
159
+ defer settled.close()
160
+ value, location, found, err := settled.get(hex.EncodeToString(oldAction))
161
+ if err != nil || !found || value.OutputID != hex.EncodeToString(oldOutput[:]) {
162
+ t.Fatalf("interrupted get damaged the complete action: value=%+v path=%q found=%v error=%v", value, location, found, err)
163
+ }
164
+ }
165
+
166
+ var errBrokenResponse = errors.New("client response pipe closed")
167
+
168
+ type failSecondResponse struct{ writes int }
169
+
170
+ func (writer *failSecondResponse) Write(value []byte) (int, error) {
171
+ writer.writes++
172
+ if writer.writes == 2 {
173
+ return 0, errBrokenResponse
174
+ }
175
+ return len(value), nil
176
+ }
@@ -0,0 +1,252 @@
1
+ package main
2
+
3
+ import (
4
+ "bufio"
5
+ "bytes"
6
+ "context"
7
+ "encoding/base64"
8
+ "encoding/hex"
9
+ "encoding/json"
10
+ "errors"
11
+ "flag"
12
+ "fmt"
13
+ "io"
14
+ "os"
15
+ "path/filepath"
16
+ "strings"
17
+
18
+ "github.com/samchon/ttsc/packages/ttsc/internal/packageartifacts"
19
+ )
20
+
21
+ type request struct {
22
+ ID int64
23
+ Command string
24
+ ActionID []byte
25
+ OutputID []byte
26
+ BodySize int64
27
+ }
28
+
29
+ type response struct {
30
+ ID int64
31
+ Err string `json:",omitempty"`
32
+ KnownCommands []string `json:",omitempty"`
33
+ Miss bool `json:",omitempty"`
34
+ OutputID []byte `json:",omitempty"`
35
+ Size int64 `json:",omitempty"`
36
+ DiskPath string `json:",omitempty"`
37
+ }
38
+
39
+ func main() {
40
+ if err := run(); err != nil {
41
+ fmt.Fprintln(os.Stderr, "ttsc cache helper:", err)
42
+ os.Exit(1)
43
+ }
44
+ }
45
+
46
+ func run() error {
47
+ rootPath := flag.String("root", "", "owned artifact cache root")
48
+ manifestPath := flag.String("manifest", "", "authenticated publisher manifest")
49
+ manifestDigest := flag.String("manifest-sha256", "", "trusted release metadata digest")
50
+ prepare := flag.Bool("prepare", false, "download and verify selected packs before a Go build")
51
+ packNames := flag.String("packs", "", "comma-separated selected pack names")
52
+ tupleMode := flag.Bool("tuple", false, "report the effective stock-Go artifact tuple")
53
+ goBinary := flag.String("go", "", "regular Go executable used by the source builder")
54
+ seal := flag.Bool("seal", false, "seal recorded publisher actions into an immutable pack")
55
+ merge := flag.Bool("merge", false, "merge independently sealed publisher packs")
56
+ plan := flag.String("plan", "", "absolute JSON release merge plan")
57
+ verify := flag.Bool("verify", false, "validate a trusted release manifest without using a cache")
58
+ actionGraph := flag.String("actiongraph", "", "Go -debug-actiongraph JSON from the recording build")
59
+ packageReceipts := flag.String("packages", "", "Go list -deps -json -compiled receipts from the same workspace")
60
+ release := flag.String("release", "", "ttsc release version")
61
+ sourceCommit := flag.String("source-commit", "", "full source commit")
62
+ packURLBase := flag.String("pack-url-base", "", "immutable pack URL directory")
63
+ packName := flag.String("pack-name", "", "safe transport pack name")
64
+ outputDir := flag.String("out", "", "publisher output directory")
65
+ receiptName := flag.String("receipt-name", "", "optional local per-build usage receipt ID")
66
+ flag.Parse()
67
+ if *merge {
68
+ if *plan == "" || *rootPath != "" || *manifestPath != "" || *manifestDigest != "" || *prepare || *packNames != "" || *tupleMode || *goBinary != "" || *seal || *verify || *actionGraph != "" || *packageReceipts != "" || *release != "" || *sourceCommit != "" || *packURLBase != "" || *packName != "" || *outputDir != "" || *receiptName != "" {
69
+ return errors.New("supply only -merge and -plan for release union")
70
+ }
71
+ return MergeRecordedPacks(*plan)
72
+ }
73
+ if *plan != "" {
74
+ return errors.New("-plan requires -merge")
75
+ }
76
+ if *verify {
77
+ if *manifestPath == "" || *manifestDigest == "" || *rootPath != "" || *prepare || *packNames != "" || *tupleMode || *goBinary != "" || *seal || *actionGraph != "" || *packageReceipts != "" || *release != "" || *sourceCommit != "" || *packURLBase != "" || *packName != "" || *outputDir != "" || *receiptName != "" {
78
+ return errors.New("supply only -verify and trusted manifest arguments")
79
+ }
80
+ _, err := loadTrustedManifest(*manifestPath, *manifestDigest)
81
+ return err
82
+ }
83
+ if *seal {
84
+ if *rootPath == "" || *goBinary == "" || *actionGraph == "" || *packageReceipts == "" || *release == "" || *sourceCommit == "" || *packURLBase == "" || *packName == "" || *outputDir == "" || *manifestPath != "" || *manifestDigest != "" || *prepare || *tupleMode || *packNames != "" || *receiptName != "" {
85
+ return errors.New("incomplete or conflicting publisher seal arguments")
86
+ }
87
+ return SealRecordedActions(sealOptions{root: *rootPath, goBinary: *goBinary, actionGraph: *actionGraph, packages: *packageReceipts, release: *release, sourceCommit: *sourceCommit, packURLBase: *packURLBase, packName: *packName, outputDir: *outputDir})
88
+ }
89
+ if *tupleMode {
90
+ if *goBinary == "" || *rootPath != "" || *manifestPath != "" || *manifestDigest != "" || *prepare || *packNames != "" || *receiptName != "" {
91
+ return errors.New("supply only -tuple and -go for tuple inspection")
92
+ }
93
+ value, err := readGoTuple(*goBinary)
94
+ if err != nil {
95
+ return err
96
+ }
97
+ return json.NewEncoder(os.Stdout).Encode(value)
98
+ }
99
+ if *rootPath == "" || (*manifestPath == "") != (*manifestDigest == "") || (*prepare && (*manifestPath == "" || *packNames == "")) || (!*prepare && *packNames != "") || (*prepare && *receiptName != "") || (*receiptName != "" && !validReceiptName(*receiptName)) {
100
+ return errors.New("supply -root and either both publisher manifest arguments or neither")
101
+ }
102
+ var manifest *packageartifacts.Manifest
103
+ if *manifestPath != "" {
104
+ decoded, err := loadTrustedManifest(*manifestPath, *manifestDigest)
105
+ if err != nil {
106
+ return err
107
+ }
108
+ manifest = &decoded
109
+ }
110
+ store, err := openArtifactStore(*rootPath, manifest)
111
+ if err != nil {
112
+ return err
113
+ }
114
+ defer store.close()
115
+ store.receiptName = *receiptName
116
+ if *prepare {
117
+ receipt, err := preparePacks(context.Background(), store, *manifest, strings.Split(*packNames, ","))
118
+ if err != nil {
119
+ return err
120
+ }
121
+ return json.NewEncoder(os.Stdout).Encode(receipt)
122
+ }
123
+ return serve(os.Stdin, os.Stdout, store)
124
+ }
125
+
126
+ func loadTrustedManifest(location, digest string) (packageartifacts.Manifest, error) {
127
+ raw, err := readBoundedFile(location, packageartifacts.MaxManifestBytes)
128
+ if err != nil {
129
+ return packageartifacts.Manifest{}, err
130
+ }
131
+ return packageartifacts.ParseTrustedManifest(raw, digest)
132
+ }
133
+
134
+ func serve(source io.Reader, destination io.Writer, store *artifactStore) error {
135
+ usage := usageReceipt{
136
+ BaseActionIDs: []string{}, OverlayActionIDs: []string{},
137
+ MissActionIDs: []string{}, PutActionIDs: []string{},
138
+ }
139
+ input := bufio.NewReaderSize(source, 64<<10)
140
+ output := bufio.NewWriter(destination)
141
+ if err := writeResponse(output, response{KnownCommands: []string{"get", "put", "close"}}); err != nil {
142
+ return err
143
+ }
144
+ for {
145
+ line, err := input.ReadSlice('\n')
146
+ if err != nil {
147
+ return fmt.Errorf("reading Go cache request: %w", err)
148
+ }
149
+ if len(bytes.TrimSpace(line)) == 0 {
150
+ continue
151
+ }
152
+ decoder := json.NewDecoder(bytes.NewReader(line))
153
+ decoder.DisallowUnknownFields()
154
+ var current request
155
+ if err := decoder.Decode(&current); err != nil {
156
+ return fmt.Errorf("decoding Go cache request: %w", err)
157
+ }
158
+ answer := response{ID: current.ID}
159
+ switch current.Command {
160
+ case "get":
161
+ usage.Gets++
162
+ if len(current.ActionID) != 32 {
163
+ answer.Err = "invalid full Go ActionID"
164
+ break
165
+ }
166
+ actionID := hex.EncodeToString(current.ActionID)
167
+ value, location, found, err := store.get(actionID)
168
+ if err != nil {
169
+ answer.Err = err.Error()
170
+ } else if !found {
171
+ answer.Miss = true
172
+ usage.Misses++
173
+ usage.record(&usage.MissActionIDs, actionID)
174
+ } else {
175
+ if strings.HasPrefix(location, filepath.Join(store.path, "base")+string(os.PathSeparator)) {
176
+ usage.BaseHits++
177
+ usage.record(&usage.BaseActionIDs, actionID)
178
+ } else {
179
+ usage.OverlayHits++
180
+ usage.record(&usage.OverlayActionIDs, actionID)
181
+ }
182
+ answer.OutputID, _ = hex.DecodeString(value.OutputID)
183
+ answer.Size = value.Size
184
+ answer.DiskPath = location
185
+ }
186
+ case "put":
187
+ if len(current.ActionID) != 32 || len(current.OutputID) != 32 || current.BodySize < 0 || current.BodySize > 512<<20 {
188
+ return errors.New("invalid full Go cache put identity or body size")
189
+ }
190
+ // Go 1.26 writes an extra newline after the encoded request.
191
+ separator, err := input.ReadByte()
192
+ if err != nil || separator != '\n' {
193
+ return errors.New("missing Go cache request separator")
194
+ }
195
+ var body io.Reader = bytes.NewReader(nil)
196
+ if current.BodySize > 0 {
197
+ quote, err := input.ReadByte()
198
+ if err != nil || quote != '"' {
199
+ return errors.New("missing Go cache body framing")
200
+ }
201
+ encodedLength := ((current.BodySize + 2) / 3) * 4
202
+ body = base64.NewDecoder(base64.StdEncoding, io.LimitReader(input, encodedLength))
203
+ }
204
+ location, putErr := store.put(hex.EncodeToString(current.ActionID), hex.EncodeToString(current.OutputID), current.BodySize, body)
205
+ if current.BodySize > 0 {
206
+ end, err := input.ReadByte()
207
+ if err != nil || end != '"' {
208
+ return errors.New("truncated Go cache body framing")
209
+ }
210
+ end, err = input.ReadByte()
211
+ if err != nil || end != '\n' {
212
+ return errors.New("invalid Go cache body terminator")
213
+ }
214
+ }
215
+ if putErr != nil {
216
+ answer.Err = putErr.Error()
217
+ } else {
218
+ answer.DiskPath = location
219
+ usage.Puts++
220
+ usage.PutBytes += current.BodySize
221
+ usage.record(&usage.PutActionIDs, hex.EncodeToString(current.ActionID))
222
+ }
223
+ case "close":
224
+ if store.receiptName != "" {
225
+ if accounting, err := accountArtifactStore(store); err == nil {
226
+ usage.Storage = &accounting
227
+ } else {
228
+ fmt.Fprintln(os.Stderr, "ttsc cache helper: storage accounting unavailable:", err)
229
+ }
230
+ if err := writeUsageReceipt(store, usage); err != nil {
231
+ fmt.Fprintln(os.Stderr, "ttsc cache helper: usage receipt unavailable:", err)
232
+ }
233
+ }
234
+ if err := writeResponse(output, answer); err != nil {
235
+ return err
236
+ }
237
+ return nil
238
+ default:
239
+ answer.Err = "unsupported Go cache command"
240
+ }
241
+ if err := writeResponse(output, answer); err != nil {
242
+ return err
243
+ }
244
+ }
245
+ }
246
+
247
+ func writeResponse(output *bufio.Writer, value response) error {
248
+ if err := json.NewEncoder(output).Encode(value); err != nil {
249
+ return err
250
+ }
251
+ return output.Flush()
252
+ }