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,163 @@
1
+ package main
2
+
3
+ import (
4
+ "archive/zip"
5
+ "bytes"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ "net/url"
9
+ "os"
10
+ "os/exec"
11
+ "path/filepath"
12
+ "regexp"
13
+ "runtime"
14
+ "strings"
15
+ "testing"
16
+ )
17
+
18
+ // TestStockGoCacheClient verifies the helper against the actual supported Go client.
19
+ //
20
+ // Verifies: a relocated module and dependency cache reuse exact actions, while a dependency update misses.
21
+ // 1. Build the helper and serve two versions of a dependency from a private file proxy.
22
+ // 2. Build identical modules under distinct paths and empty Go and module caches with one helper store.
23
+ // 3. Compare archive bytes and compile counts, then change the dependency version and require compilation.
24
+ //
25
+ // @evidence contracts/testing.md#behavioral-verification Runs stock go build with GOCACHEPROG through the production helper executable, then observes compiler commands and archive bytes.
26
+ // @evidence contracts/testing.md#independent-expectations Identical source and dependency bytes under -trimpath should request the same actions; a changed dependency version must change the archive and require compilation.
27
+ // @evidence contracts/testing.md#distinguishing-cases Exercises stock Go 1.26 protocol startup, put/get, path quoting, source and module-cache relocation, and a changed dependency version.
28
+ // @evidence contracts/testing.md#execution-ownership The test runs stock Go in temporary directories with a private file proxy and never contacts the network or publishes a release artifact.
29
+ func TestStockGoCacheClient(t *testing.T) {
30
+ if !supportedGoVersion.MatchString(runtime.Version()) {
31
+ t.Skip("the helper protocol requires a stock Go 1.26 patch release")
32
+ }
33
+ root := filepath.Join(t.TempDir(), "native artifact path with spaces")
34
+ if err := os.MkdirAll(root, 0700); err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ helper := filepath.Join(root, "cache helper"+exeSuffix())
38
+ buildHelper := exec.Command("go", "build", "-trimpath", "-o", helper, ".")
39
+ if output, err := buildHelper.CombinedOutput(); err != nil {
40
+ t.Fatalf("building cache helper: %v\n%s", err, output)
41
+ }
42
+ store := filepath.Join(root, "shared artifact store")
43
+ program := quoteGoCacheArgument(helper) + " -root " + quoteGoCacheArgument(store)
44
+ proxy := filepath.Join(root, "private module proxy")
45
+ for _, version := range []string{"v1.0.0", "v1.0.1"} {
46
+ directory := filepath.Join(proxy, "example.org", "cache-dep", "@v")
47
+ if err := os.MkdirAll(directory, 0700); err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ module := []byte("module example.org/cache-dep\n\ngo 1.26\n")
51
+ info := []byte(`{"Version":"` + version + `","Time":"2026-01-01T00:00:00Z"}`)
52
+ var archive bytes.Buffer
53
+ writer := zip.NewWriter(&archive)
54
+ for name, body := range map[string][]byte{
55
+ "go.mod": module,
56
+ "dep.go": []byte("package dep\nfunc Value() string { return \"" + version + "\" }\n"),
57
+ } {
58
+ entry, err := writer.Create("example.org/cache-dep@" + version + "/" + name)
59
+ if err != nil {
60
+ t.Fatal(err)
61
+ }
62
+ if _, err := entry.Write(body); err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ }
66
+ if err := writer.Close(); err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ for extension, body := range map[string][]byte{
70
+ ".mod": module, ".info": info, ".zip": archive.Bytes(),
71
+ } {
72
+ if err := os.WriteFile(filepath.Join(directory, version+extension), body, 0600); err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ }
76
+ }
77
+ proxyPath := filepath.ToSlash(proxy)
78
+ if !strings.HasPrefix(proxyPath, "/") {
79
+ proxyPath = "/" + proxyPath
80
+ }
81
+ proxyURL := (&url.URL{Scheme: "file", Path: proxyPath}).String()
82
+ compileCommand := regexp.MustCompile(`(?m)[/\\]compile(?:\.exe)?["']?\s`)
83
+ var archiveDigests [3]string
84
+ var compileCounts [3]int
85
+ for phase, name := range []string{"publisher", "relocated consumer", "updated dependency"} {
86
+ version := "v1.0.0"
87
+ if phase == 2 {
88
+ version = "v1.0.1"
89
+ }
90
+ directory := filepath.Join(root, name)
91
+ if err := os.MkdirAll(directory, 0700); err != nil {
92
+ t.Fatal(err)
93
+ }
94
+ for file, source := range map[string]string{
95
+ "go.mod": "module example.org/ttsc-cache-client\n\ngo 1.26\n\nrequire example.org/cache-dep " + version + "\n",
96
+ "tiny.go": "package tiny\nimport \"example.org/cache-dep\"\nfunc Value() string { return dep.Value() }\n",
97
+ } {
98
+ if err := os.WriteFile(filepath.Join(directory, file), []byte(source), 0600); err != nil {
99
+ t.Fatal(err)
100
+ }
101
+ }
102
+ goCache := filepath.Join(directory, "empty go cache")
103
+ moduleCache := filepath.Join(directory, "empty module cache")
104
+ if err := os.MkdirAll(goCache, 0700); err != nil {
105
+ t.Fatal(err)
106
+ }
107
+ if err := os.MkdirAll(moduleCache, 0700); err != nil {
108
+ t.Fatal(err)
109
+ }
110
+ t.Cleanup(func() {
111
+ clean := exec.Command("go", "clean", "-modcache")
112
+ clean.Env = append(os.Environ(),
113
+ "GOMODCACHE="+moduleCache, "GOCACHEPROG=", "GOFLAGS=", "GOTOOLCHAIN=local", "GOWORK=off",
114
+ )
115
+ if output, err := clean.CombinedOutput(); err != nil {
116
+ t.Errorf("cleaning %s private module cache: %v\n%s", name, err, output)
117
+ }
118
+ })
119
+ archive := filepath.Join(directory, "result.a")
120
+ build := exec.Command("go", "build", "-mod=mod", "-trimpath", "-x", "-o", archive, ".")
121
+ build.Dir = directory
122
+ build.Env = append(os.Environ(),
123
+ "CGO_ENABLED=0", "GOFLAGS=", "GOWORK=off", "GOCACHE="+goCache,
124
+ "GOMODCACHE="+moduleCache, "GOCACHEPROG="+program,
125
+ "GOPROXY="+proxyURL, "GOSUMDB=off", "GOTOOLCHAIN=local",
126
+ )
127
+ output, err := build.CombinedOutput()
128
+ if err != nil {
129
+ t.Fatalf("%s stock Go build: %v\n%s", name, err, output)
130
+ }
131
+ if _, err := os.Stat(filepath.Join(moduleCache, "example.org", "cache-dep@"+version, "dep.go")); err != nil {
132
+ t.Fatalf("%s did not populate its separate dependency cache: %v", name, err)
133
+ }
134
+ compileCounts[phase] = len(compileCommand.FindAll(output, -1))
135
+ raw, err := os.ReadFile(archive)
136
+ if err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ digest := sha256.Sum256(raw)
140
+ archiveDigests[phase] = hex.EncodeToString(digest[:])
141
+ }
142
+ if compileCounts[0] == 0 || compileCounts[1] != 0 || compileCounts[2] == 0 ||
143
+ archiveDigests[0] != archiveDigests[1] || archiveDigests[1] == archiveDigests[2] {
144
+ t.Fatalf("stock Go did not distinguish relocated and updated actions: compiles=%v archives=%v", compileCounts, archiveDigests)
145
+ }
146
+ }
147
+
148
+ func exeSuffix() string {
149
+ if runtime.GOOS == "windows" {
150
+ return ".exe"
151
+ }
152
+ return ""
153
+ }
154
+
155
+ func quoteGoCacheArgument(value string) string {
156
+ if !strings.Contains(value, "'") {
157
+ return "'" + value + "'"
158
+ }
159
+ if !strings.Contains(value, "\"") {
160
+ return "\"" + value + "\""
161
+ }
162
+ panic("test path cannot be represented by Go's command quoting")
163
+ }
@@ -0,0 +1,249 @@
1
+ package main
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "errors"
9
+ "fmt"
10
+ "io"
11
+ "os"
12
+ "path/filepath"
13
+ "strings"
14
+
15
+ "github.com/samchon/ttsc/packages/ttsc/internal/packageartifacts"
16
+ )
17
+
18
+ type mapping struct {
19
+ OutputID string `json:"outputID"`
20
+ Size int64 `json:"size"`
21
+ }
22
+
23
+ type artifactStore struct {
24
+ path string
25
+ root *os.Root
26
+ base map[string]packageartifacts.Action
27
+ packs map[string]packageartifacts.Pack
28
+ pins []*os.File
29
+ pinned map[string]bool
30
+ verified map[string]bool
31
+ receiptName string
32
+ }
33
+
34
+ func openArtifactStore(directory string, manifest *packageartifacts.Manifest) (*artifactStore, error) {
35
+ if err := os.MkdirAll(directory, 0700); err != nil {
36
+ return nil, err
37
+ }
38
+ info, err := os.Lstat(directory)
39
+ if err != nil {
40
+ return nil, err
41
+ }
42
+ if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
43
+ return nil, fmt.Errorf("artifact root is not an ordinary directory: %s", directory)
44
+ }
45
+ physical, err := filepath.EvalSymlinks(directory)
46
+ if err != nil {
47
+ return nil, err
48
+ }
49
+ root, err := os.OpenRoot(physical)
50
+ if err != nil {
51
+ return nil, err
52
+ }
53
+ store := &artifactStore{
54
+ path: physical, root: root,
55
+ base: make(map[string]packageartifacts.Action),
56
+ packs: make(map[string]packageartifacts.Pack),
57
+ pinned: make(map[string]bool),
58
+ verified: make(map[string]bool),
59
+ }
60
+ for _, name := range []string{"base", "base/packs", "base/staging", "overlay", "overlay/bodies", "overlay/actions", "overlay/staging", "receipts"} {
61
+ if err := root.Mkdir(name, 0700); err != nil && !errors.Is(err, os.ErrExist) {
62
+ root.Close()
63
+ return nil, err
64
+ }
65
+ info, err := root.Lstat(name)
66
+ if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
67
+ root.Close()
68
+ return nil, fmt.Errorf("artifact directory is aliased or unreadable: %s", name)
69
+ }
70
+ }
71
+ if manifest != nil {
72
+ for _, pack := range manifest.Packs {
73
+ store.packs[pack.Name] = pack
74
+ }
75
+ for _, action := range manifest.Actions {
76
+ store.base[action.ActionID] = action
77
+ }
78
+ }
79
+ return store, nil
80
+ }
81
+
82
+ func (store *artifactStore) close() error {
83
+ var first error
84
+ for _, file := range store.pins {
85
+ if err := file.Close(); err != nil && first == nil {
86
+ first = err
87
+ }
88
+ }
89
+ if err := store.root.Close(); err != nil && first == nil {
90
+ first = err
91
+ }
92
+ return first
93
+ }
94
+
95
+ func (store *artifactStore) get(actionID string) (mapping, string, bool, error) {
96
+ index := filepath.ToSlash(filepath.Join("overlay", "actions", actionID))
97
+ indexFile, err := store.root.Open(index)
98
+ if err == nil {
99
+ raw, readErr := io.ReadAll(io.LimitReader(indexFile, 257))
100
+ closeErr := indexFile.Close()
101
+ if readErr != nil || closeErr != nil {
102
+ return mapping{}, "", false, errors.Join(readErr, closeErr)
103
+ }
104
+ var value mapping
105
+ if len(raw) > 256 || json.Unmarshal(raw, &value) != nil || !validID(value.OutputID) || value.Size < 0 {
106
+ return mapping{}, "", false, fmt.Errorf("invalid overlay mapping for action %s", actionID)
107
+ }
108
+ location, err := store.pinBody("overlay", value)
109
+ if errors.Is(err, os.ErrNotExist) {
110
+ return mapping{}, "", false, nil
111
+ }
112
+ return value, location, err == nil, err
113
+ }
114
+ if !errors.Is(err, os.ErrNotExist) {
115
+ return mapping{}, "", false, err
116
+ }
117
+ base, exists := store.base[actionID]
118
+ if !exists {
119
+ return mapping{}, "", false, nil
120
+ }
121
+ value := mapping{OutputID: base.OutputID, Size: base.BodyLength}
122
+ pack := store.packs[base.Pack]
123
+ owner := filepath.ToSlash(filepath.Join("base", "packs", pack.Name+"-"+pack.SHA256))
124
+ info, err := store.root.Lstat(owner)
125
+ if errors.Is(err, os.ErrNotExist) {
126
+ return mapping{}, "", false, nil
127
+ }
128
+ if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
129
+ return mapping{}, "", false, fmt.Errorf("invalid admitted pack %s", pack.Name)
130
+ }
131
+ marker, err := store.root.ReadFile(filepath.ToSlash(filepath.Join(owner, ".complete")))
132
+ if errors.Is(err, os.ErrNotExist) {
133
+ return mapping{}, "", false, nil
134
+ }
135
+ if err != nil || string(marker) != pack.SHA256+"\n" {
136
+ return mapping{}, "", false, fmt.Errorf("invalid admission marker for pack %s", pack.Name)
137
+ }
138
+ location, err := store.pinBody(owner, value)
139
+ if errors.Is(err, os.ErrNotExist) {
140
+ return mapping{}, "", false, nil
141
+ }
142
+ return value, location, err == nil, err
143
+ }
144
+
145
+ func (store *artifactStore) put(actionID, outputID string, size int64, body io.Reader) (string, error) {
146
+ if !validID(actionID) || !validID(outputID) || size < 0 || size > 512<<20 {
147
+ return "", errors.New("invalid Go cache put identity or body size")
148
+ }
149
+ staging := filepath.ToSlash(filepath.Join("overlay", "staging", randomName()))
150
+ file, err := store.root.OpenFile(staging, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
151
+ if err != nil {
152
+ return "", err
153
+ }
154
+ defer store.root.Remove(staging)
155
+ hash := sha256.New()
156
+ written, copyErr := io.CopyN(io.MultiWriter(file, hash), body, size)
157
+ syncErr := file.Sync()
158
+ closeErr := file.Close()
159
+ if copyErr != nil || written != size {
160
+ return "", fmt.Errorf("incomplete Go cache body: %w", copyErr)
161
+ }
162
+ if syncErr != nil {
163
+ return "", syncErr
164
+ }
165
+ if closeErr != nil {
166
+ return "", closeErr
167
+ }
168
+ if hex.EncodeToString(hash.Sum(nil)) != outputID {
169
+ return "", errors.New("Go cache body differs from its OutputID")
170
+ }
171
+ destination := filepath.ToSlash(filepath.Join("overlay", "bodies", outputID))
172
+ if err := store.root.Link(staging, destination); err != nil && !errors.Is(err, os.ErrExist) {
173
+ return "", err
174
+ }
175
+ value := mapping{OutputID: outputID, Size: size}
176
+ location, err := store.pinBody("overlay", value)
177
+ if err != nil {
178
+ return "", err
179
+ }
180
+ data, err := json.Marshal(value)
181
+ if err != nil {
182
+ return "", err
183
+ }
184
+ indexStage := filepath.ToSlash(filepath.Join("overlay", "staging", randomName()))
185
+ indexFile, err := store.root.OpenFile(indexStage, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
186
+ if err != nil {
187
+ return "", err
188
+ }
189
+ defer store.root.Remove(indexStage)
190
+ _, writeErr := indexFile.Write(data)
191
+ syncErr = indexFile.Sync()
192
+ closeErr = indexFile.Close()
193
+ if writeErr != nil || syncErr != nil || closeErr != nil {
194
+ return "", errors.Join(writeErr, syncErr, closeErr)
195
+ }
196
+ index := filepath.ToSlash(filepath.Join("overlay", "actions", actionID))
197
+ if err := store.root.Rename(indexStage, index); err != nil {
198
+ return "", err
199
+ }
200
+ return location, nil
201
+ }
202
+
203
+ func (store *artifactStore) pinBody(owner string, value mapping) (string, error) {
204
+ relative := filepath.ToSlash(filepath.Join(owner, "bodies", value.OutputID))
205
+ info, err := store.root.Lstat(relative)
206
+ if err != nil {
207
+ return "", err
208
+ }
209
+ if !info.Mode().IsRegular() || info.Size() != value.Size {
210
+ return "", fmt.Errorf("invalid artifact body %s", relative)
211
+ }
212
+ file, err := store.root.Open(relative)
213
+ if err != nil {
214
+ return "", err
215
+ }
216
+ key := owner + "/" + value.OutputID
217
+ if !store.verified[key] {
218
+ hash := sha256.New()
219
+ count, err := io.Copy(hash, file)
220
+ if err != nil || count != value.Size || hex.EncodeToString(hash.Sum(nil)) != value.OutputID {
221
+ file.Close()
222
+ return "", fmt.Errorf("artifact body failed validation: %s", relative)
223
+ }
224
+ store.verified[key] = true
225
+ }
226
+ if store.pinned[key] {
227
+ file.Close()
228
+ } else {
229
+ store.pins = append(store.pins, file)
230
+ store.pinned[key] = true
231
+ }
232
+ return filepath.Join(store.path, filepath.FromSlash(relative)), nil
233
+ }
234
+
235
+ func validID(value string) bool {
236
+ if len(value) != 64 {
237
+ return false
238
+ }
239
+ _, err := hex.DecodeString(value)
240
+ return err == nil && value == strings.ToLower(value)
241
+ }
242
+
243
+ func randomName() string {
244
+ var value [16]byte
245
+ if _, err := rand.Read(value[:]); err != nil {
246
+ panic(err)
247
+ }
248
+ return hex.EncodeToString(value[:])
249
+ }
@@ -0,0 +1,104 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+ )
12
+
13
+ // TestArtifactStore verifies the overlay's complete-body and generation contract.
14
+ //
15
+ // Verifies: an action is published only after its OutputID body is valid, and a later put replaces its mapping without invalidating an earlier reader.
16
+ // 1. Put an archive, read it, and put a second body for the same action.
17
+ // 2. Reopen the store and require the new generation while the old body still exists.
18
+ // 3. Reject a bad digest, a truncated body and an aliased body, then treat a missing body after interrupted cleanup as a miss.
19
+ //
20
+ // @evidence contracts/testing.md#behavioral-verification Uses actual owned files and the store's put/get operations, asserting returned bytes and the surviving index after failed writes.
21
+ // @evidence contracts/testing.md#independent-expectations SHA-256 is computed directly from literal body bytes; a second mapping for one ActionID follows stock Go's last-valid-put policy, while invalid bytes cannot match their claimed OutputID.
22
+ // @evidence contracts/testing.md#distinguishing-cases Covers first put, same-action replacement, restart, zero body, wrong digest, truncated input, deduplicated output, body alias rejection and a missing body after partial cleanup; helper protocol and native clean races have separate owners.
23
+ // @evidence contracts/testing.md#execution-ownership This Go unit test owns a temporary directory and calls storage directly; it starts no Go build or external host.
24
+ func TestArtifactStore(t *testing.T) {
25
+ root := t.TempDir()
26
+ store, err := openArtifactStore(root, nil)
27
+ if err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ defer store.close()
31
+ action := strings.Repeat("a", 64)
32
+ first := []byte("first archive")
33
+ second := []byte("second archive")
34
+ digest := func(body []byte) string {
35
+ sum := sha256.Sum256(body)
36
+ return hex.EncodeToString(sum[:])
37
+ }
38
+ firstPath, err := store.put(action, digest(first), int64(len(first)), bytes.NewReader(first))
39
+ if err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ value, location, found, err := store.get(action)
43
+ if err != nil || !found || location != firstPath || value.OutputID != digest(first) {
44
+ t.Fatalf("first generation not readable: %+v %q %v %v", value, location, found, err)
45
+ }
46
+ secondPath, err := store.put(action, digest(second), int64(len(second)), bytes.NewReader(second))
47
+ if err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ value, location, found, err = store.get(action)
51
+ if err != nil || !found || location != secondPath || value.OutputID != digest(second) {
52
+ t.Fatalf("new generation not readable: %+v %q %v %v", value, location, found, err)
53
+ }
54
+ if raw, err := os.ReadFile(firstPath); err != nil || !bytes.Equal(raw, first) {
55
+ t.Fatalf("old pinned body changed: %q %v", raw, err)
56
+ }
57
+ if _, err := store.put(action, digest(first), int64(len(second)), bytes.NewReader(second)); err == nil {
58
+ t.Fatal("wrong OutputID accepted")
59
+ }
60
+ if _, err := store.put(action, digest(first), int64(len(first)), bytes.NewReader(first[:3])); err == nil {
61
+ t.Fatal("truncated body accepted")
62
+ }
63
+ if _, err := store.put(strings.Repeat("b", 64), digest(second), int64(len(second)), bytes.NewReader(second)); err != nil {
64
+ t.Fatal(err)
65
+ }
66
+ empty := []byte{}
67
+ if _, err := store.put(strings.Repeat("c", 64), digest(empty), 0, bytes.NewReader(empty)); err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ if err := store.close(); err != nil {
71
+ t.Fatal(err)
72
+ }
73
+ reopened, err := openArtifactStore(root, nil)
74
+ if err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ defer reopened.close()
78
+ value, location, found, err = reopened.get(action)
79
+ if err != nil || !found || value.OutputID != digest(second) {
80
+ t.Fatalf("restart lost last valid generation: %+v %q %v %v", value, location, found, err)
81
+ }
82
+ aliasAction := strings.Repeat("d", 64)
83
+ alias := filepath.Join(root, "overlay", "bodies", digest([]byte("alias")))
84
+ if err := os.Symlink(secondPath, alias); err == nil {
85
+ if _, err := reopened.put(aliasAction, digest([]byte("alias")), int64(len("alias")), bytes.NewReader([]byte("alias"))); err == nil {
86
+ t.Fatal("aliased body accepted")
87
+ }
88
+ }
89
+ if err := reopened.close(); err != nil {
90
+ t.Fatal(err)
91
+ }
92
+ if err := os.Remove(secondPath); err != nil {
93
+ t.Fatal(err)
94
+ }
95
+ recovering, err := openArtifactStore(root, nil)
96
+ if err != nil {
97
+ t.Fatal(err)
98
+ }
99
+ defer recovering.close()
100
+ _, _, found, err = recovering.get(action)
101
+ if err != nil || found {
102
+ t.Fatalf("an interrupted cleanup's missing body did not become a miss: %v %v", found, err)
103
+ }
104
+ }
@@ -0,0 +1,107 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ "encoding/json"
9
+ "errors"
10
+ "fmt"
11
+ "io"
12
+ "os"
13
+ "os/exec"
14
+ "path/filepath"
15
+ "regexp"
16
+ "strings"
17
+ "time"
18
+
19
+ "github.com/samchon/ttsc/packages/ttsc/internal/packageartifacts"
20
+ )
21
+
22
+ func readGoTuple(goBinary string) (packageartifacts.Tuple, error) {
23
+ var tuple packageartifacts.Tuple
24
+ if !filepath.IsAbs(goBinary) {
25
+ return tuple, errors.New("artifact Go executable must be absolute")
26
+ }
27
+ info, err := os.Stat(goBinary)
28
+ if err != nil || !info.Mode().IsRegular() || info.Size() > 256<<20 {
29
+ return tuple, errors.New("artifact Go executable is missing or too large")
30
+ }
31
+ file, err := os.Open(goBinary)
32
+ if err != nil {
33
+ return tuple, err
34
+ }
35
+ goHash := sha256.New()
36
+ _, copyErr := io.Copy(goHash, io.LimitReader(file, (256<<20)+1))
37
+ closeErr := file.Close()
38
+ if copyErr != nil || closeErr != nil {
39
+ return tuple, errors.Join(copyErr, closeErr)
40
+ }
41
+ output, err := runGoProbe(goBinary, "env", "-json", "GOVERSION", "GOOS", "GOARCH", "GOAMD64", "GOARM", "GOARM64", "GO386", "GOMIPS", "GOMIPS64", "GOPPC64", "GORISCV64", "GOWASM", "CGO_ENABLED", "GOEXPERIMENT", "GOFLAGS")
42
+ if err != nil {
43
+ return tuple, err
44
+ }
45
+ var values map[string]string
46
+ if err := json.Unmarshal(output, &values); err != nil {
47
+ return tuple, fmt.Errorf("decode Go tuple: %w", err)
48
+ }
49
+ if values["CGO_ENABLED"] != "0" || values["GOFLAGS"] != "" {
50
+ return tuple, errors.New("publisher artifacts require effective CGO_ENABLED=0 and no GOFLAGS")
51
+ }
52
+ if !supportedGoVersion.MatchString(values["GOVERSION"]) {
53
+ return tuple, errors.New("publisher cache protocol requires a stock Go 1.26 patch release")
54
+ }
55
+ if values["GOVERSION"] == "" || values["GOOS"] == "" || values["GOARCH"] == "" {
56
+ return tuple, errors.New("Go did not report a complete artifact tuple")
57
+ }
58
+ toolHash := sha256.New()
59
+ for _, name := range []string{"asm", "cgo", "compile", "cover", "link", "vet"} {
60
+ version, err := runGoProbe(goBinary, "tool", name, "-V=full")
61
+ if err != nil {
62
+ return tuple, fmt.Errorf("Go tool %s identity unavailable: %w", name, err)
63
+ }
64
+ _, _ = fmt.Fprintf(toolHash, "%s:%d:%s\n", name, len(version), version)
65
+ }
66
+ architecture := make(map[string]string)
67
+ for _, key := range []string{"GOAMD64", "GOARM", "GOARM64", "GO386", "GOMIPS", "GOMIPS64", "GOPPC64", "GORISCV64", "GOWASM"} {
68
+ if value := values[key]; value != "" {
69
+ architecture[key] = value
70
+ }
71
+ }
72
+ return packageartifacts.Tuple{
73
+ GoVersion: values["GOVERSION"], GoToolSHA256: hex.EncodeToString(goHash.Sum(nil)),
74
+ GOOS: values["GOOS"], GOARCH: values["GOARCH"], Architecture: architecture,
75
+ CGOEnabled: values["CGO_ENABLED"], GoExperiment: values["GOEXPERIMENT"],
76
+ BuildFlags: []string{"-trimpath"}, BuildTags: []string{},
77
+ ToolIDsSHA256: hex.EncodeToString(toolHash.Sum(nil)),
78
+ }, nil
79
+ }
80
+
81
+ var supportedGoVersion = regexp.MustCompile(`^go1\.26\.(0|[1-9][0-9]*)$`)
82
+
83
+ func runGoProbe(goBinary string, arguments ...string) ([]byte, error) {
84
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
85
+ defer cancel()
86
+ command := exec.CommandContext(ctx, goBinary, arguments...)
87
+ output := boundedBuffer{limit: 64 << 10}
88
+ command.Stdout = &output
89
+ diagnostic := boundedBuffer{limit: 16 << 10}
90
+ command.Stderr = &diagnostic
91
+ if err := command.Run(); err != nil {
92
+ return nil, fmt.Errorf("stock Go probe %s failed: %w (%s)", strings.Join(arguments, " "), err, strings.TrimSpace(diagnostic.String()))
93
+ }
94
+ return output.Bytes(), nil
95
+ }
96
+
97
+ type boundedBuffer struct {
98
+ bytes.Buffer
99
+ limit int
100
+ }
101
+
102
+ func (buffer *boundedBuffer) Write(value []byte) (int, error) {
103
+ if len(value) > buffer.limit-buffer.Len() {
104
+ return 0, errors.New("stock Go identity output is too large")
105
+ }
106
+ return buffer.Buffer.Write(value)
107
+ }
@@ -0,0 +1,62 @@
1
+ package main
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "runtime"
10
+ "testing"
11
+ )
12
+
13
+ // TestReadGoTuple verifies the selected stock-Go tool and effective build mode.
14
+ //
15
+ // Verifies: an eligible native tuple names the actual tool bytes and reported architecture, while unsupported flags and CGO retain source-build selection.
16
+ // 1. Probe the host Go executable with CGO disabled and no ambient GOFLAGS.
17
+ // 2. Compare its digest and version with independent Go and file readings.
18
+ // 3. Set GOFLAGS and CGO independently and require tuple rejection.
19
+ //
20
+ // @evidence contracts/testing.md#behavioral-verification Calls the production tuple probe against the actual installed Go command and observes both eligible fields and rejected environments.
21
+ // @evidence contracts/testing.md#independent-expectations Direct file SHA-256 and runtime's Go version provide independent tool expectations; Go's effective environment, not a caller's guessed default, decides CGO eligibility.
22
+ // @evidence contracts/testing.md#distinguishing-cases Covers a complete CGO=0 tuple, an effective flag override and CGO=1; release manifest authentication is covered by the parser test.
23
+ // @evidence contracts/testing.md#execution-ownership Runs bounded local Go identity probes without building source modules, downloading packages or writing repository files.
24
+ func TestReadGoTuple(t *testing.T) {
25
+ if !supportedGoVersion.MatchString(runtime.Version()) {
26
+ t.Skip("the artifact tuple requires a stock Go 1.26 patch release")
27
+ }
28
+ goBinary, err := exec.LookPath("go")
29
+ if err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ goBinary, err = filepath.Abs(goBinary)
33
+ if err != nil {
34
+ t.Fatal(err)
35
+ }
36
+ t.Setenv("CGO_ENABLED", "0")
37
+ t.Setenv("GOFLAGS", "")
38
+ t.Setenv("GOOS", runtime.GOOS)
39
+ t.Setenv("GOARCH", runtime.GOARCH)
40
+ t.Setenv("GOTOOLCHAIN", "local")
41
+ tuple, err := readGoTuple(goBinary)
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ raw, err := os.ReadFile(goBinary)
46
+ if err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ digest := sha256.Sum256(raw)
50
+ if tuple.GoToolSHA256 != hex.EncodeToString(digest[:]) || tuple.GoVersion != runtime.Version() || tuple.GOOS != runtime.GOOS || tuple.GOARCH != runtime.GOARCH || tuple.CGOEnabled != "0" || tuple.ToolIDsSHA256 == "" {
51
+ t.Fatalf("reported tuple differs from the selected Go tool: %+v", tuple)
52
+ }
53
+ t.Setenv("GOFLAGS", "-tags=foreign")
54
+ if _, err := readGoTuple(goBinary); err == nil {
55
+ t.Fatal("GOFLAGS override was admitted")
56
+ }
57
+ t.Setenv("GOFLAGS", "")
58
+ t.Setenv("CGO_ENABLED", "1")
59
+ if _, err := readGoTuple(goBinary); err == nil {
60
+ t.Fatal("effective CGO mode was admitted")
61
+ }
62
+ }