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,135 @@
1
+ package main
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
+ "encoding/json"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "os"
10
+ "os/exec"
11
+ "path/filepath"
12
+ "regexp"
13
+ "runtime"
14
+ "strings"
15
+ "testing"
16
+ )
17
+
18
+ // TestPublisherSeal verifies an actual recorded Go action through released-pack admission.
19
+ //
20
+ // Verifies: a publisher's full action and body can be sealed, transferred in bulk, and used by a relocated stock-Go consumer without compiling the package.
21
+ // 1. Compile one package with the production helper recording into an empty overlay and retain Go's action graph and package receipts.
22
+ // 2. Seal a release-pinned ZIP, serve it from loopback, and admit it into an independent consumer base.
23
+ // 3. Build the same package from a different source root with a fresh Go cache, then compare archive bytes and compiler commands.
24
+ //
25
+ // @evidence contracts/testing.md#behavioral-verification The test executes the shipped helper's recording, sealing, preparation and protocol modes with stock Go, asserting a complete manifest digest, a real base hit and byte-identical archives.
26
+ // @evidence contracts/testing.md#independent-expectations The literal package source and Go's own cold first-build compiler command establish a compile that the independent second cache must avoid; SHA-256 of each emitted archive establishes output equality.
27
+ // @evidence contracts/testing.md#distinguishing-cases Distinct source, overlay, base and GOCACHE roots distinguish publisher reuse from an ordinary warm build; direct helper tests own malformed transport and protocol cases.
28
+ // @evidence contracts/testing.md#execution-ownership This discoverable Go integration test builds a local helper and two private modules, serves one loopback pack, and removes all files through t.TempDir; no consumer install or external network is involved.
29
+ func TestPublisherSeal(t *testing.T) {
30
+ if !supportedGoVersion.MatchString(runtime.Version()) {
31
+ t.Skip("publisher protocol requires a stock Go 1.26 patch release")
32
+ }
33
+ goBinary, err := exec.LookPath("go")
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ goBinary, err = filepath.Abs(goBinary)
38
+ if err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ root := filepath.Join(t.TempDir(), "publisher path with spaces")
42
+ if err := os.MkdirAll(root, 0700); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ helper := filepath.Join(root, "cache helper"+exeSuffix())
46
+ buildHelper := exec.Command(goBinary, "build", "-trimpath", "-o", helper, ".")
47
+ if output, err := buildHelper.CombinedOutput(); err != nil {
48
+ t.Fatalf("helper build: %v\n%s", err, output)
49
+ }
50
+ source := "package tiny\nfunc Value() string { return \"sealed action\" }\n"
51
+ makeModule := func(name string) string {
52
+ directory := filepath.Join(root, name)
53
+ if err := os.MkdirAll(directory, 0700); err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ if err := os.WriteFile(filepath.Join(directory, "go.mod"), []byte("module example.org/ttsc-cache-seal\n\ngo 1.26\n"), 0600); err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ if err := os.WriteFile(filepath.Join(directory, "tiny.go"), []byte(source), 0600); err != nil {
60
+ t.Fatal(err)
61
+ }
62
+ return directory
63
+ }
64
+ publisher := makeModule("publisher")
65
+ consumer := makeModule("consumer")
66
+ graph := filepath.Join(root, "actiongraph.json")
67
+ packages := filepath.Join(root, "packages.json")
68
+ list := exec.Command(goBinary, "list", "-deps", "-json", ".")
69
+ list.Dir = publisher
70
+ list.Env = append(os.Environ(), "CGO_ENABLED=0", "GOFLAGS=", "GOWORK=off", "GOPROXY=off", "GOSUMDB=off", "GOTOOLCHAIN=local", "GOCACHEPROG=")
71
+ listOutput, err := list.CombinedOutput()
72
+ if err != nil {
73
+ t.Fatalf("publisher package receipts: %v\n%s", err, listOutput)
74
+ }
75
+ if err := os.WriteFile(packages, listOutput, 0600); err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ serverRoot := filepath.Join(root, "sealed")
79
+ server := httptest.NewServer(http.FileServer(http.Dir(serverRoot)))
80
+ defer server.Close()
81
+ compile := regexp.MustCompile(`(?m)[/\\]compile(?:\.exe)?["']?\s`)
82
+ build := func(directory, cache, program, archive, graphPath string) (string, int) {
83
+ if err := os.MkdirAll(cache, 0700); err != nil {
84
+ t.Fatal(err)
85
+ }
86
+ args := []string{"build", "-trimpath", "-x", "-o", archive}
87
+ if graphPath != "" {
88
+ args = append(args, "-debug-actiongraph="+graphPath)
89
+ }
90
+ args = append(args, ".")
91
+ command := exec.Command(goBinary, args...)
92
+ command.Dir = directory
93
+ command.Env = append(os.Environ(), "CGO_ENABLED=0", "GOFLAGS=", "GOWORK=off", "GOPROXY=off", "GOSUMDB=off", "GOTOOLCHAIN=local", "GOCACHE="+cache, "GOCACHEPROG="+program)
94
+ output, err := command.CombinedOutput()
95
+ if err != nil {
96
+ t.Fatalf("stock Go build: %v\n%s", err, output)
97
+ }
98
+ body, err := os.ReadFile(archive)
99
+ if err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ sum := sha256.Sum256(body)
103
+ return hex.EncodeToString(sum[:]), len(compile.FindAll(output, -1))
104
+ }
105
+ overlay := filepath.Join(root, "publisher overlay")
106
+ firstProgram := quoteGoCacheArgument(helper) + " -root " + quoteGoCacheArgument(overlay)
107
+ firstHash, firstCompiles := build(publisher, filepath.Join(root, "publisher go cache"), firstProgram, filepath.Join(root, "first.a"), graph)
108
+ if firstCompiles == 0 {
109
+ t.Fatal("publisher did not compile the package")
110
+ }
111
+ seal := exec.Command(helper, "-seal", "-root", overlay, "-go", goBinary, "-actiongraph", graph, "-packages", packages, "-release", "0.32.1", "-source-commit", strings.Repeat("a", 40), "-pack-url-base", server.URL, "-pack-name", "tiny", "-out", serverRoot)
112
+ seal.Env = append(os.Environ(), "CGO_ENABLED=0", "GOFLAGS=", "GOWORK=off", "GOPROXY=off", "GOSUMDB=off", "GOTOOLCHAIN=local")
113
+ output, err := seal.CombinedOutput()
114
+ if err != nil {
115
+ t.Fatalf("seal recorded actions: %v\n%s", err, output)
116
+ }
117
+ var sealed struct {
118
+ ManifestSHA256 string `json:"manifestSHA256"`
119
+ Actions int `json:"actions"`
120
+ }
121
+ if err := json.Unmarshal(output, &sealed); err != nil || sealed.Actions == 0 || len(sealed.ManifestSHA256) != 64 {
122
+ t.Fatalf("invalid publisher seal receipt: %s %v", output, err)
123
+ }
124
+ manifest := filepath.Join(serverRoot, "manifest.json")
125
+ base := filepath.Join(root, "independent consumer base")
126
+ prepare := exec.Command(helper, "-root", base, "-manifest", manifest, "-manifest-sha256", sealed.ManifestSHA256, "-prepare", "-packs", "tiny")
127
+ if output, err := prepare.CombinedOutput(); err != nil {
128
+ t.Fatalf("prepare sealed pack: %v\n%s", err, output)
129
+ }
130
+ secondProgram := quoteGoCacheArgument(helper) + " -root " + quoteGoCacheArgument(base) + " -manifest " + quoteGoCacheArgument(manifest) + " -manifest-sha256 " + sealed.ManifestSHA256
131
+ secondHash, secondCompiles := build(consumer, filepath.Join(root, "consumer go cache"), secondProgram, filepath.Join(root, "second.a"), "")
132
+ if secondCompiles != 0 || firstHash != secondHash {
133
+ t.Fatalf("sealed publisher package was not reused: compiles=%d/%d hashes=%s/%s", firstCompiles, secondCompiles, firstHash, secondHash)
134
+ }
135
+ }
@@ -0,0 +1,427 @@
1
+ package main
2
+
3
+ import (
4
+ "archive/zip"
5
+ "compress/flate"
6
+ "crypto/sha256"
7
+ "encoding/base64"
8
+ "encoding/hex"
9
+ "encoding/json"
10
+ "errors"
11
+ "fmt"
12
+ "io"
13
+ "os"
14
+ "path/filepath"
15
+ "sort"
16
+ "strings"
17
+
18
+ "github.com/samchon/ttsc/packages/ttsc/internal/packageartifacts"
19
+ )
20
+
21
+ type sealOptions struct {
22
+ root, goBinary, actionGraph, packages, release, sourceCommit string
23
+ packURLBase, packName, outputDir string
24
+ }
25
+
26
+ type graphAction struct {
27
+ ID int `json:"ID"`
28
+ Mode string `json:"Mode"`
29
+ Package string `json:"Package"`
30
+ Deps []int `json:"Deps"`
31
+ ActionID string `json:"ActionID"`
32
+ Cmd []string `json:"Cmd"`
33
+ }
34
+
35
+ type packageReceipt struct {
36
+ ImportPath string
37
+ Dir string
38
+ GoFiles []string
39
+ CgoFiles []string
40
+ SFiles []string
41
+ EmbedFiles []string
42
+ Module *struct {
43
+ Path string
44
+ Version string
45
+ Main bool
46
+ Replace *struct {
47
+ Path string
48
+ Version string
49
+ }
50
+ }
51
+ }
52
+
53
+ // SealRecordedActions joins Go's displayed build actions to the full action and
54
+ // output IDs recorded by its cache client. Package receipts annotate those IDs;
55
+ // they never generate or replace a Go cache key. A release package must pin the
56
+ // printed manifest digest before a consumer may trust the result.
57
+ //
58
+ // @evidence contracts/common.md#principled-implementation Only full recorded actions with verified local bodies are sealed; a same-workspace Go action graph and package receipts add diagnostics, while the release pins the resulting manifest digest.
59
+ // @evidence contracts/common.md#clear-and-simple-design One publisher command reads bounded Go receipts, verifies body associations, writes one deterministic pack and authenticates its complete manifest before reporting the digest.
60
+ // @evidence contracts/common.md#prohibited-implementation-shortcuts A displayed action prefix is joined uniquely to a full recorded key; package names and source hashes cannot manufacture identities or resolve a conflict.
61
+ // @evidence contracts/common.md#meaningful-documentation The contract names the release trust step and separates diagnostic receipts from Go's authoritative keys.
62
+ // @evidence contracts/portability.md#os-neutral-implementation Go path and archive APIs process native publisher paths, while transport names use slash-separated ZIP entries independent of the publisher OS.
63
+ // @evidence contracts/performance.md#efficient-algorithms Each selected package body is streamed once into a level-one ZIP and hashed; action joins use maps rather than comparing every graph node with every recorded action.
64
+ // @evidenceExclude contracts/performance.md#reuse-equivalent-work Publisher sealing is an explicit one-shot release operation and does not memoize mutable source or tool observations.
65
+ // @evidence contracts/performance.md#bound-retention-and-release-resources Receipt/action files have byte caps, package source reads are streamed, body handles close after each entry, and failed staging files are removed; the finished pack remains with the publisher output owner.
66
+ func SealRecordedActions(opts sealOptions) error {
67
+ if !filepath.IsAbs(opts.root) || !filepath.IsAbs(opts.outputDir) || !filepath.IsAbs(opts.actionGraph) || !filepath.IsAbs(opts.packages) || !validPackName(opts.packName) {
68
+ return errors.New("publisher paths must be absolute and pack name must be safe")
69
+ }
70
+ tuple, err := readGoTuple(opts.goBinary)
71
+ if err != nil {
72
+ return err
73
+ }
74
+ graphRaw, err := readBoundedFile(opts.actionGraph, 16<<20)
75
+ if err != nil {
76
+ return err
77
+ }
78
+ var graph []graphAction
79
+ if err := json.Unmarshal(graphRaw, &graph); err != nil {
80
+ return fmt.Errorf("decode Go action graph: %w", err)
81
+ }
82
+ if len(graph) == 0 || len(graph) > 100000 {
83
+ return errors.New("Go action graph exceeds publisher policy")
84
+ }
85
+ receipts, err := readPackageReceipts(opts.packages)
86
+ if err != nil {
87
+ return err
88
+ }
89
+ store, err := openArtifactStore(opts.root, nil)
90
+ if err != nil {
91
+ return err
92
+ }
93
+ defer store.close()
94
+ recorded, err := readRecordedMappings(store)
95
+ if err != nil {
96
+ return err
97
+ }
98
+ byShort := make(map[string]string, len(recorded))
99
+ for actionID := range recorded {
100
+ bytes, _ := hex.DecodeString(actionID)
101
+ short := base64.RawURLEncoding.EncodeToString(bytes[:15])
102
+ if previous := byShort[short]; previous != "" && previous != actionID {
103
+ return fmt.Errorf("ambiguous Go action prefix %s", short)
104
+ }
105
+ byShort[short] = actionID
106
+ }
107
+ selected := make(map[int]packageartifacts.Action)
108
+ outputs := make(map[string]mapping)
109
+ for _, node := range graph {
110
+ if node.Mode != "build" || node.ActionID == "" {
111
+ continue
112
+ }
113
+ fullID := byShort[node.ActionID]
114
+ value, exists := recorded[fullID]
115
+ if !exists || node.Package == "" {
116
+ return fmt.Errorf("Go build action %s has no recorded full body", node.Package)
117
+ }
118
+ receipt, exists := receipts[node.Package]
119
+ if !exists {
120
+ return fmt.Errorf("Go build action %s has no package receipt", node.Package)
121
+ }
122
+ sourceHash, err := hashSelectedSources(receipt)
123
+ if err != nil {
124
+ return fmt.Errorf("package %s sources: %w", node.Package, err)
125
+ }
126
+ command, _ := json.Marshal(node.Cmd)
127
+ commandHash := sha256.Sum256(command)
128
+ role, modulePath, moduleVersion := receiptRole(receipt, tuple.GoVersion)
129
+ selected[node.ID] = packageartifacts.Action{
130
+ ActionID: fullID, OutputID: value.OutputID, BodySHA256: value.OutputID,
131
+ BodyLength: value.Size, Pack: opts.packName, Kind: "archive",
132
+ PackagePath: node.Package, ModulePath: modulePath, ModuleVersion: moduleVersion,
133
+ ModuleRole: role, SelectedSourceSHA: sourceHash,
134
+ CommandSHA256: hex.EncodeToString(commandHash[:]), DirectInputs: []string{},
135
+ }
136
+ if previous, exists := outputs[value.OutputID]; exists && previous.Size != value.Size {
137
+ return errors.New("recorded output has conflicting lengths")
138
+ }
139
+ outputs[value.OutputID] = value
140
+ }
141
+ if len(selected) == 0 {
142
+ return errors.New("no recorded package actions were selected")
143
+ }
144
+ manifest := packageartifacts.Manifest{
145
+ Schema: 1, Release: opts.release, SourceCommit: opts.sourceCommit,
146
+ Tuple: tuple, GraphSHA256: digestBytes(graphRaw), Actions: make([]packageartifacts.Action, 0, len(selected)),
147
+ }
148
+ for _, node := range graph {
149
+ action, exists := selected[node.ID]
150
+ if !exists {
151
+ continue
152
+ }
153
+ for _, dependency := range node.Deps {
154
+ if input, exists := selected[dependency]; exists {
155
+ action.DirectInputs = append(action.DirectInputs, input.OutputID)
156
+ }
157
+ }
158
+ sort.Strings(action.DirectInputs)
159
+ manifest.Actions = append(manifest.Actions, action)
160
+ }
161
+ sort.Slice(manifest.Actions, func(i, j int) bool { return manifest.Actions[i].ActionID < manifest.Actions[j].ActionID })
162
+ if err := os.MkdirAll(opts.outputDir, 0700); err != nil {
163
+ return err
164
+ }
165
+ stage, err := os.CreateTemp(opts.outputDir, ".ttsc-pack-*.zip")
166
+ if err != nil {
167
+ return err
168
+ }
169
+ defer os.Remove(stage.Name())
170
+ writer := zip.NewWriter(stage)
171
+ writer.RegisterCompressor(zip.Deflate, func(destination io.Writer) (io.WriteCloser, error) {
172
+ return flate.NewWriter(destination, flate.BestSpeed)
173
+ })
174
+ ids := make([]string, 0, len(outputs))
175
+ for outputID := range outputs {
176
+ ids = append(ids, outputID)
177
+ }
178
+ sort.Strings(ids)
179
+ var decodedBytes int64
180
+ for _, outputID := range ids {
181
+ value := outputs[outputID]
182
+ location := filepath.ToSlash(filepath.Join("overlay", "bodies", outputID))
183
+ body, err := store.root.Open(location)
184
+ if err != nil {
185
+ writer.Close()
186
+ stage.Close()
187
+ return err
188
+ }
189
+ info, err := body.Stat()
190
+ if err != nil || !info.Mode().IsRegular() || info.Size() != value.Size {
191
+ body.Close()
192
+ writer.Close()
193
+ stage.Close()
194
+ return fmt.Errorf("recorded body %s changed", outputID)
195
+ }
196
+ entry, err := writer.Create("bodies/" + outputID)
197
+ if err != nil {
198
+ body.Close()
199
+ writer.Close()
200
+ stage.Close()
201
+ return err
202
+ }
203
+ hash := sha256.New()
204
+ count, copyErr := io.CopyN(io.MultiWriter(entry, hash), body, value.Size)
205
+ closeErr := body.Close()
206
+ if copyErr != nil || closeErr != nil || count != value.Size || hex.EncodeToString(hash.Sum(nil)) != outputID {
207
+ writer.Close()
208
+ stage.Close()
209
+ return fmt.Errorf("recorded body %s failed verification: %w", outputID, errors.Join(copyErr, closeErr))
210
+ }
211
+ decodedBytes += count
212
+ if decodedBytes > packageartifacts.MaxPackDecodedBytes {
213
+ writer.Close()
214
+ stage.Close()
215
+ return errors.New("pack exceeds decoded byte policy")
216
+ }
217
+ }
218
+ if err := writer.Close(); err != nil {
219
+ stage.Close()
220
+ return err
221
+ }
222
+ if err := stage.Sync(); err != nil {
223
+ stage.Close()
224
+ return err
225
+ }
226
+ if err := stage.Close(); err != nil {
227
+ return err
228
+ }
229
+ packRaw, err := hashFile(stage.Name(), packageartifacts.MaxPackBytes)
230
+ if err != nil {
231
+ return err
232
+ }
233
+ packFile := opts.packName + "-" + packRaw.digest + ".zip"
234
+ manifest.Packs = []packageartifacts.Pack{{
235
+ Name: opts.packName, URL: strings.TrimRight(opts.packURLBase, "/") + "/" + packFile,
236
+ SHA256: packRaw.digest, Length: packRaw.length, DecodedBytes: decodedBytes, FileCount: len(ids),
237
+ }}
238
+ manifestRaw, err := json.MarshalIndent(manifest, "", " ")
239
+ if err != nil {
240
+ return err
241
+ }
242
+ manifestRaw = append(manifestRaw, '\n')
243
+ manifestDigest := digestBytes(manifestRaw)
244
+ if _, err := packageartifacts.ParseTrustedManifest(manifestRaw, manifestDigest); err != nil {
245
+ return fmt.Errorf("publisher manifest rejected: %w", err)
246
+ }
247
+ finalPack := filepath.Join(opts.outputDir, packFile)
248
+ if err := os.Link(stage.Name(), finalPack); err != nil {
249
+ if !errors.Is(err, os.ErrExist) {
250
+ return err
251
+ }
252
+ existing, hashErr := hashFile(finalPack, packageartifacts.MaxPackBytes)
253
+ if hashErr != nil || existing != packRaw {
254
+ return errors.New("existing publisher pack differs from sealed bytes")
255
+ }
256
+ }
257
+ manifestFile, err := os.OpenFile(filepath.Join(opts.outputDir, "manifest.json"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
258
+ if err != nil {
259
+ return err
260
+ }
261
+ _, writeErr := manifestFile.Write(manifestRaw)
262
+ syncErr := manifestFile.Sync()
263
+ closeErr := manifestFile.Close()
264
+ if writeErr != nil || syncErr != nil || closeErr != nil {
265
+ return errors.Join(writeErr, syncErr, closeErr)
266
+ }
267
+ return json.NewEncoder(os.Stdout).Encode(struct {
268
+ ManifestSHA256 string `json:"manifestSHA256"`
269
+ Pack string `json:"pack"`
270
+ Actions int `json:"actions"`
271
+ DecodedBytes int64 `json:"decodedBytes"`
272
+ CompressedBytes int64 `json:"compressedBytes"`
273
+ }{manifestDigest, packFile, len(manifest.Actions), decodedBytes, packRaw.length})
274
+ }
275
+
276
+ func readRecordedMappings(store *artifactStore) (map[string]mapping, error) {
277
+ directory, err := store.root.Open("overlay/actions")
278
+ if err != nil {
279
+ return nil, err
280
+ }
281
+ defer directory.Close()
282
+ entries, err := directory.ReadDir(-1)
283
+ if err != nil {
284
+ return nil, err
285
+ }
286
+ if len(entries) > 100000 {
287
+ return nil, errors.New("too many publisher actions")
288
+ }
289
+ result := make(map[string]mapping, len(entries))
290
+ for _, entry := range entries {
291
+ if !validID(entry.Name()) || !entry.Type().IsRegular() {
292
+ return nil, errors.New("publisher action index contains an unsafe entry")
293
+ }
294
+ index, err := store.root.Open(filepath.ToSlash(filepath.Join("overlay", "actions", entry.Name())))
295
+ if err != nil {
296
+ return nil, err
297
+ }
298
+ raw, readErr := io.ReadAll(io.LimitReader(index, 257))
299
+ closeErr := index.Close()
300
+ if readErr != nil || closeErr != nil || len(raw) > 256 {
301
+ return nil, errors.New("publisher action index is unreadable or too large")
302
+ }
303
+ var value mapping
304
+ if err := json.Unmarshal(raw, &value); err != nil || !validID(value.OutputID) || value.Size < 0 || value.Size > 512<<20 {
305
+ return nil, errors.New("publisher action index is invalid")
306
+ }
307
+ result[entry.Name()] = value
308
+ }
309
+ return result, nil
310
+ }
311
+
312
+ func readPackageReceipts(filename string) (map[string]packageReceipt, error) {
313
+ file, err := os.Open(filename)
314
+ if err != nil {
315
+ return nil, err
316
+ }
317
+ defer file.Close()
318
+ info, err := file.Stat()
319
+ if err != nil || !info.Mode().IsRegular() || info.Size() > 256<<20 {
320
+ return nil, errors.New("package receipts exceed publisher policy")
321
+ }
322
+ decoder := json.NewDecoder(io.LimitReader(file, (256<<20)+1))
323
+ result := make(map[string]packageReceipt)
324
+ for {
325
+ var receipt packageReceipt
326
+ if err := decoder.Decode(&receipt); err == io.EOF {
327
+ break
328
+ } else if err != nil {
329
+ return nil, fmt.Errorf("decode package receipt: %w", err)
330
+ }
331
+ if receipt.ImportPath == "" || receipt.Dir == "" {
332
+ return nil, errors.New("incomplete package receipt")
333
+ }
334
+ if _, exists := result[receipt.ImportPath]; exists {
335
+ return nil, errors.New("duplicate package receipt")
336
+ }
337
+ result[receipt.ImportPath] = receipt
338
+ if len(result) > 100000 {
339
+ return nil, errors.New("too many package receipts")
340
+ }
341
+ }
342
+ return result, nil
343
+ }
344
+
345
+ func hashSelectedSources(receipt packageReceipt) (string, error) {
346
+ names := append(append(append([]string{}, receipt.GoFiles...), receipt.CgoFiles...), receipt.SFiles...)
347
+ names = append(names, receipt.EmbedFiles...)
348
+ sort.Strings(names)
349
+ hash := sha256.New()
350
+ for _, name := range names {
351
+ if filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, ".."+string(filepath.Separator)) {
352
+ return "", errors.New("selected source escaped package directory")
353
+ }
354
+ file, err := os.Open(filepath.Join(receipt.Dir, name))
355
+ if err != nil {
356
+ return "", err
357
+ }
358
+ _, _ = fmt.Fprintf(hash, "%d:%s\n", len(name), filepath.ToSlash(name))
359
+ count, copyErr := io.Copy(hash, io.LimitReader(file, (128<<20)+1))
360
+ closeErr := file.Close()
361
+ if copyErr != nil || closeErr != nil || count > 128<<20 {
362
+ return "", errors.New("selected source is unreadable or too large")
363
+ }
364
+ _, _ = fmt.Fprintf(hash, "\n%d\n", count)
365
+ }
366
+ return hex.EncodeToString(hash.Sum(nil)), nil
367
+ }
368
+
369
+ func receiptRole(receipt packageReceipt, goVersion string) (string, string, string) {
370
+ if receipt.Module == nil {
371
+ return "standard", "std", goVersion
372
+ }
373
+ version := receipt.Module.Version
374
+ if receipt.Module.Replace != nil {
375
+ version = receipt.Module.Replace.Version
376
+ }
377
+ if receipt.Module.Main {
378
+ return "main", receipt.Module.Path, version
379
+ }
380
+ return "dependency", receipt.Module.Path, version
381
+ }
382
+
383
+ func readBoundedFile(filename string, limit int64) ([]byte, error) {
384
+ file, err := os.Open(filename)
385
+ if err != nil {
386
+ return nil, err
387
+ }
388
+ defer file.Close()
389
+ info, err := file.Stat()
390
+ if err != nil || !info.Mode().IsRegular() || info.Size() > limit {
391
+ return nil, errors.New("publisher input exceeds byte policy")
392
+ }
393
+ return io.ReadAll(io.LimitReader(file, limit+1))
394
+ }
395
+
396
+ type fileDigest struct {
397
+ digest string
398
+ length int64
399
+ }
400
+
401
+ func hashFile(filename string, limit int64) (fileDigest, error) {
402
+ file, err := os.Open(filename)
403
+ if err != nil {
404
+ return fileDigest{}, err
405
+ }
406
+ defer file.Close()
407
+ hash := sha256.New()
408
+ count, err := io.Copy(hash, io.LimitReader(file, limit+1))
409
+ if err != nil || count > limit {
410
+ return fileDigest{}, errors.New("publisher pack exceeds compressed byte policy")
411
+ }
412
+ return fileDigest{hex.EncodeToString(hash.Sum(nil)), count}, nil
413
+ }
414
+
415
+ func digestBytes(value []byte) string { sum := sha256.Sum256(value); return hex.EncodeToString(sum[:]) }
416
+
417
+ func validPackName(value string) bool {
418
+ if value == "" || len(value) > 80 {
419
+ return false
420
+ }
421
+ for _, char := range value {
422
+ if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyz0123456789-", char) {
423
+ return false
424
+ }
425
+ }
426
+ return true
427
+ }
@@ -0,0 +1,94 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "runtime"
10
+ "strings"
11
+ "testing"
12
+ "time"
13
+ )
14
+
15
+ // TestStockGoCacheFailures verifies the real Go client's response to an already-started bad helper.
16
+ //
17
+ // Verifies: a malformed response and premature helper exit fail the Go build promptly and do not silently replay it against a local cache.
18
+ // 1. Build a small helper that advertises the protocol, then emits malformed JSON or exits.
19
+ // 2. Run stock Go against each mode with a fresh local cache and a fixed failure deadline.
20
+ // 3. Require an identified cache-helper failure, no output archive, and no deadline expiry.
21
+ //
22
+ // @evidence contracts/testing.md#behavioral-verification Runs stock go build with GOCACHEPROG pointing to a deliberately broken executable and checks its process result and missing output.
23
+ // @evidence contracts/testing.md#independent-expectations The stock Go client owns the helper failure contract: invalid JSON or EOF after valid startup cannot represent an action miss, so the build must fail without producing an archive.
24
+ // @evidence contracts/testing.md#distinguishing-cases Covers malformed response and premature exit after valid capabilities; the production helper's valid protocol, hit and miss cases belong to TestStockGoCacheClient and TestCacheProtocol.
25
+ // @evidence contracts/testing.md#execution-ownership This narrow Go subprocess test uses a temporary helper and tiny local module; it never installs a consumer or runs the product E2E suite.
26
+ func TestStockGoCacheFailures(t *testing.T) {
27
+ if !supportedGoVersion.MatchString(runtime.Version()) {
28
+ t.Skip("the helper protocol requires a stock Go 1.26 patch release")
29
+ }
30
+ root := t.TempDir()
31
+ helperSource := filepath.Join(root, "broken-helper.go")
32
+ source := `package main
33
+ import (
34
+ "bufio"
35
+ "fmt"
36
+ "os"
37
+ )
38
+ func main() {
39
+ fmt.Println("{\"ID\":0,\"KnownCommands\":[\"get\",\"put\",\"close\"]}")
40
+ if _, err := bufio.NewReader(os.Stdin).ReadBytes('\n'); err != nil {
41
+ return
42
+ }
43
+ if os.Getenv("TTSC_BAD_HELPER_MODE") == "malformed" {
44
+ fmt.Println("{broken")
45
+ }
46
+ }
47
+ `
48
+ if err := os.WriteFile(helperSource, []byte(source), 0600); err != nil {
49
+ t.Fatal(err)
50
+ }
51
+ helper := filepath.Join(root, "broken helper"+exeSuffix())
52
+ compile := exec.Command("go", "build", "-o", helper, helperSource)
53
+ compile.Env = append(os.Environ(), "GOCACHEPROG=", "GOTOOLCHAIN=local")
54
+ if output, err := compile.CombinedOutput(); err != nil {
55
+ t.Fatalf("building failing cache helper: %v\n%s", err, output)
56
+ }
57
+ module := filepath.Join(root, "client")
58
+ if err := os.MkdirAll(module, 0700); err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ for name, body := range map[string]string{
62
+ "go.mod": "module example.org/broken-cache-client\n\ngo 1.26\n",
63
+ "tiny.go": "package tiny\nfunc Value() string { return \"stock Go\" }\n",
64
+ } {
65
+ if err := os.WriteFile(filepath.Join(module, name), []byte(body), 0600); err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ }
69
+ for _, mode := range []string{"malformed", "premature"} {
70
+ t.Run(mode, func(t *testing.T) {
71
+ cache := filepath.Join(root, "cache-"+mode)
72
+ if err := os.MkdirAll(cache, 0700); err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ archive := filepath.Join(root, "archive-"+mode+".a")
76
+ context, cancel := context.WithTimeout(context.Background(), 30*time.Second)
77
+ defer cancel()
78
+ build := exec.CommandContext(context, "go", "build", "-trimpath", "-o", archive, ".")
79
+ build.Dir = module
80
+ build.Env = append(os.Environ(),
81
+ "CGO_ENABLED=0", "GOFLAGS=", "GOWORK=off", "GOPROXY=off", "GOSUMDB=off", "GOTOOLCHAIN=local",
82
+ "GOCACHE="+cache, "GOCACHEPROG="+quoteGoCacheArgument(helper), "TTSC_BAD_HELPER_MODE="+mode,
83
+ )
84
+ output, err := build.CombinedOutput()
85
+ expected := map[string]string{"malformed": "invalid character", "premature": "exited pre-Close"}[mode]
86
+ if err == nil || context.Err() != nil || !strings.Contains(string(output), "GOCACHEPROG") || !strings.Contains(string(output), expected) {
87
+ t.Fatalf("stock Go did not fail promptly at the helper boundary: error=%v deadline=%v output=%s", err, context.Err(), output)
88
+ }
89
+ if _, err := os.Stat(archive); !errors.Is(err, os.ErrNotExist) {
90
+ t.Fatalf("stock Go produced an archive after a helper failure: %v", err)
91
+ }
92
+ })
93
+ }
94
+ }