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
package/driver/rewrite.go CHANGED
@@ -157,10 +157,13 @@ func (p *Program) EmitAll(rs *RewriteSet, writeFile shimcompiler.WriteFile) (*sh
157
157
  // is the standard place to carry per-file cursors or an output map), so ttsc
158
158
  // owns the serialization rather than pushing goroutine-safety onto every
159
159
  // plugin author. See the emit-concurrency contract in the plugin docs.
160
+ // A watch-owned writer may leave an already identical file in place; that
161
+ // successful output remains in EmittedFiles. Rejected outDir paths are marked
162
+ // skipped and do not appear there.
160
163
  //
161
164
  // @evidence contracts/common.md#principled-implementation Native emission honors linked program hooks, compiler incremental-output policy, and output containment while omitting only ttsc text rewrites.
162
165
  // @evidence contracts/common.md#clear-and-simple-design One serialized writer callback wraps the compiler emit owner and shared diagnostic conversion.
163
- // @evidence contracts/common.md#prohibited-implementation-shortcuts Compiler emission is not patched or replaced with expected-output text; skipped writes are marked for accurate emitted-file reporting.
166
+ // @evidence contracts/common.md#prohibited-implementation-shortcuts Compiler emission is not patched or replaced with expected-output text; rejected outDir writes are marked skipped, while identical existing watch outputs remain successful emitted files.
164
167
  // @evidence contracts/common.md#meaningful-documentation Native prose distinguishes raw output and guaranteed callback serialization following the documentation skill.
165
168
  // @evidence contracts/portability.md#os-neutral-implementation Native containment resolves compiler paths with its case policy, and DefaultWriteFile uses native filesystem APIs.
166
169
  // @evidence contracts/performance.md#efficient-algorithms Native generation processes the current program and emitted text; serialized callbacks add lexical path-containment and default filesystem or arbitrary caller-writer costs. Result diagnostics require conversion. Native threading policy remains selected by the loaded program, with no measured claim that callback serialization is inexpensive.
@@ -185,8 +188,8 @@ func (p *Program) EmitAllRaw(writeFile shimcompiler.WriteFile) (*shimcompiler.Em
185
188
  wfMu.Lock()
186
189
  defer wfMu.Unlock()
187
190
  if p.outputEscapesOutDir(fileName) {
188
- // Marking the write skipped keeps the file out of EmitResult.EmittedFiles,
189
- // so callers reporting emitted counts don't include phantom outputs.
191
+ // Marking a rejected path skipped keeps it out of EmittedFiles. An
192
+ // existing identical output is still an emitted result and stays listed.
190
193
  if data != nil {
191
194
  data.SkippedDtsWrite = true
192
195
  }
@@ -243,8 +246,8 @@ func (p *Program) emit(rs *RewriteSet, target *ast.SourceFile, writeFile shimcom
243
246
  wfMu.Lock()
244
247
  defer wfMu.Unlock()
245
248
  if p.outputEscapesOutDir(fileName) {
246
- // See EmitAllRaw: mark the write skipped so EmitResult.EmittedFiles
247
- // reflects only files actually written.
249
+ // See EmitAllRaw: rejected paths are absent from EmittedFiles, while an
250
+ // existing identical watch output remains a successful result.
248
251
  if data != nil {
249
252
  data.SkippedDtsWrite = true
250
253
  }
@@ -316,19 +319,25 @@ func (p *Program) emitsBuildInfo() bool {
316
319
  }
317
320
 
318
321
  // DefaultWriteFile is the default disk writer used when EmitAll's caller does not
319
- // supply a custom WriteFile callback. Existing files are truncated; a failed
320
- // write can leave partial output. Requested creation modes are subject to the
321
- // native platform and umask, and do not replace existing file permissions.
322
+ // supply a custom WriteFile callback. Existing files are normally truncated; a
323
+ // watch child setting TTSC_WATCH_WRITE_IF_CHANGED=1 preserves byte-identical
324
+ // regular output files instead. A failed write can leave partial output.
325
+ // Requested creation modes are subject to the native platform and umask, and
326
+ // do not replace existing file permissions.
322
327
  //
323
- // @evidence contracts/common.md#principled-implementation Parent directories are created before the output is written, and native errors remain visible to emit callers.
324
- // @evidence contracts/common.md#clear-and-simple-design One directory-creation step precedes one file write.
325
- // @evidence contracts/common.md#prohibited-implementation-shortcuts The writer uses actual requested filenames without suppressing filesystem failures or relying on shell commands.
326
- // @evidence contracts/common.md#meaningful-documentation Native prose identifies the default callback role following the documentation skill.
327
- // @evidence contracts/portability.md#os-neutral-implementation filepath.Dir, os.MkdirAll, and os.WriteFile supply OS-neutral native operations without separator or command assumptions.
328
- // @evidence contracts/performance.md#efficient-algorithms Native parent creation can walk missing ancestors and query existing entries; path length/depth and output bytes drive delegated work. The text-to-byte conversion and file write process the supplied output without a directory enumeration or intermediate output collection.
329
- // @evidenceExclude contracts/performance.md#reuse-equivalent-work Each emit write is requested independently; this writer owns no reuse coordinator.
330
- // @evidence contracts/performance.md#bound-retention-and-release-resources os.WriteFile opens one output handle and attempts its closure after the write, including write failure; open failure acquires no handle. The adapter retains no handle or historical output buffer after returning, but created directories and partial or completed files remain on disk without rollback.
328
+ // @evidence contracts/common.md#principled-implementation The watch-only opt-in compares an existing writable regular output before deciding whether native writing is needed; all other writes use the original directory and file operations.
329
+ // @evidence contracts/common.md#clear-and-simple-design One opt-in decision selects the unchanged-output check or the original writer.
330
+ // @evidence contracts/common.md#prohibited-implementation-shortcuts Native file identity and bytes decide whether to skip; unknown state falls back to the original writer and its errors.
331
+ // @evidence contracts/common.md#meaningful-documentation Native prose states normal truncation, watch opt-in, partial failure and permission behavior.
332
+ // @evidence contracts/portability.md#os-neutral-implementation filepath.Dir, os.MkdirAll and os.WriteFile preserve native path and permission behavior; the optional check uses native file identity rather than path spelling.
333
+ // @evidence contracts/performance.md#efficient-algorithms Normal writes retain one directory creation and file write. Watch writes add metadata and streamed comparison of up to the existing output size, then write only when content differs or safe comparison fails; no prior-output cache is retained.
334
+ // @evidenceExclude contracts/performance.md#reuse-equivalent-work Each emit write is requested independently; this writer owns no cross-emit reuse coordinator.
335
+ // @evidence contracts/performance.md#bound-retention-and-release-resources The optional comparison closes its one read/write handle before return or fallback; os.WriteFile owns and closes the later write handle. No output buffer or handle survives this call, while created directories and partial or completed files remain on disk.
331
336
  func DefaultWriteFile(fileName, text string) error {
337
+ return writeFileWithWatchMode(fileName, text, os.Getenv("TTSC_WATCH_WRITE_IF_CHANGED") == "1")
338
+ }
339
+
340
+ func defaultWriteFile(fileName, text string) error {
332
341
  if dir := filepath.Dir(fileName); dir != "" {
333
342
  if err := os.MkdirAll(dir, 0o755); err != nil {
334
343
  return err
@@ -0,0 +1,69 @@
1
+ package driver
2
+
3
+ import (
4
+ "bytes"
5
+ "io"
6
+ "os"
7
+ )
8
+
9
+ // writeFileWithWatchMode preserves an unchanged regular output only for a
10
+ // watch-owned write. A failed comparison does not replace the default writer's
11
+ // errors or its behavior for symlinks, special files and absent parents.
12
+ func writeFileWithWatchMode(fileName, text string, watch bool) error {
13
+ if watch && unchangedRegularOutput(fileName, text) {
14
+ return nil
15
+ }
16
+ return defaultWriteFile(fileName, text)
17
+ }
18
+
19
+ // unchangedRegularOutput compares through a read/write descriptor so a file
20
+ // that the normal writer cannot open for writing is never treated as success.
21
+ // Identity and metadata checks reject a file replaced during the comparison;
22
+ // a later independent filesystem mutation remains a caller-visible race.
23
+ func unchangedRegularOutput(fileName, text string) bool {
24
+ before, err := os.Lstat(fileName)
25
+ if err != nil || !before.Mode().IsRegular() || before.Size() != int64(len(text)) {
26
+ return false
27
+ }
28
+ file, err := os.OpenFile(fileName, os.O_RDWR, 0)
29
+ if err != nil {
30
+ return false
31
+ }
32
+ same := matchesOpenRegularOutput(file, fileName, before, text)
33
+ if err := file.Close(); err != nil {
34
+ return false
35
+ }
36
+ return same
37
+ }
38
+
39
+ func matchesOpenRegularOutput(file *os.File, fileName string, before os.FileInfo, text string) bool {
40
+ opened, err := file.Stat()
41
+ if err != nil || !opened.Mode().IsRegular() || !os.SameFile(before, opened) || opened.Size() != int64(len(text)) {
42
+ return false
43
+ }
44
+ var buffer [32 * 1024]byte
45
+ for offset := 0; offset < len(text); {
46
+ count := min(len(buffer), len(text)-offset)
47
+ read, err := io.ReadFull(file, buffer[:count])
48
+ if err != nil || !bytes.Equal(buffer[:read], []byte(text[offset:offset+read])) {
49
+ return false
50
+ }
51
+ offset += read
52
+ }
53
+ if _, err := file.Read(buffer[:1]); err != io.EOF {
54
+ return false
55
+ }
56
+ afterOpen, err := file.Stat()
57
+ if err != nil {
58
+ return false
59
+ }
60
+ afterPath, err := os.Lstat(fileName)
61
+ if err != nil {
62
+ return false
63
+ }
64
+ return afterPath.Mode().IsRegular() &&
65
+ os.SameFile(opened, afterOpen) && os.SameFile(opened, afterPath) &&
66
+ afterOpen.Mode() == opened.Mode() && afterPath.Mode() == opened.Mode() &&
67
+ afterOpen.Size() == opened.Size() && afterPath.Size() == opened.Size() &&
68
+ afterOpen.ModTime().Equal(opened.ModTime()) && afterPath.ModTime().Equal(opened.ModTime())
69
+ }
@@ -0,0 +1,148 @@
1
+ package driver
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ "time"
8
+ )
9
+
10
+ // TestWriteFileWithWatchMode verifies the watch writer's disk effects and
11
+ // fallback against the ordinary writer.
12
+ //
13
+ // Verifies that only an existing, byte-identical regular file is preserved.
14
+ // 1. Write missing and changed outputs in watch mode and read their bytes.
15
+ // 2. Mark identical outputs with an old timestamp and compare watch and normal writes.
16
+ // 3. Exercise blocked parents, read-only outputs and a symlink against normal writes.
17
+ //
18
+ // @evidence contracts/testing.md#behavioral-verification The direct disk writer creates and changes real files, preserves an unchanged watch output's timestamp and mode, and still reports native write failures.
19
+ // @evidence contracts/testing.md#independent-expectations Authored literal bytes and a fixed old timestamp establish the expected output independently of the writer; normal mode on an equivalent file is the permission/error reference.
20
+ // @evidence contracts/testing.md#distinguishing-cases Missing, empty, unchanged, changed same-size, blocked-parent, read-only and symlink outputs separate the skip predicate from writes and error fallback. Compiler emitted-file accounting belongs to its owning host test.
21
+ // @evidence contracts/testing.md#execution-ownership This Go unit calls the driver writer with explicit watch mode on temporary native files; no compiler, child host or process environment mutation is required.
22
+ func TestWriteFileWithWatchMode(t *testing.T) {
23
+ old := time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC)
24
+
25
+ t.Run("missing and changed", func(t *testing.T) {
26
+ file := filepath.Join(t.TempDir(), "nested", "out.js")
27
+ if err := writeFileWithWatchMode(file, "first", true); err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ if err := writeFileWithWatchMode(file, "other", true); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ data, err := os.ReadFile(file)
34
+ if err != nil || string(data) != "other" {
35
+ t.Fatalf("changed output = %q, %v", data, err)
36
+ }
37
+ if err := writeFileWithWatchMode(file, "", true); err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ data, err = os.ReadFile(file)
41
+ if err != nil || len(data) != 0 {
42
+ t.Fatalf("empty output = %q, %v", data, err)
43
+ }
44
+ if err := os.Chtimes(file, old, old); err != nil {
45
+ t.Fatal(err)
46
+ }
47
+ if err := writeFileWithWatchMode(file, "", true); err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ info, err := os.Stat(file)
51
+ if err != nil || !info.ModTime().Equal(old) {
52
+ t.Fatalf("empty output was rewritten: %v, %v", info, err)
53
+ }
54
+ })
55
+
56
+ t.Run("identical watch and ordinary output", func(t *testing.T) {
57
+ root := t.TempDir()
58
+ watch := filepath.Join(root, "watch.js")
59
+ normal := filepath.Join(root, "normal.js")
60
+ for _, file := range []string{watch, normal} {
61
+ if err := os.WriteFile(file, []byte("same"), 0o600); err != nil {
62
+ t.Fatal(err)
63
+ }
64
+ if err := os.Chtimes(file, old, old); err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ }
68
+ before, err := os.Stat(watch)
69
+ if err != nil {
70
+ t.Fatal(err)
71
+ }
72
+ if err := writeFileWithWatchMode(watch, "same", true); err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ after, err := os.Stat(watch)
76
+ if err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ if !after.ModTime().Equal(before.ModTime()) || after.Mode() != before.Mode() {
80
+ t.Fatalf("unchanged watch metadata changed: before=%v after=%v", before, after)
81
+ }
82
+ if err := writeFileWithWatchMode(normal, "same", false); err != nil {
83
+ t.Fatal(err)
84
+ }
85
+ normalAfter, err := os.Stat(normal)
86
+ if err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ if normalAfter.ModTime().Equal(old) {
90
+ t.Fatal("ordinary identical output was not rewritten")
91
+ }
92
+ })
93
+
94
+ t.Run("blocked parent", func(t *testing.T) {
95
+ parent := filepath.Join(t.TempDir(), "parent")
96
+ if err := os.WriteFile(parent, []byte("file"), 0o600); err != nil {
97
+ t.Fatal(err)
98
+ }
99
+ if err := writeFileWithWatchMode(filepath.Join(parent, "out.js"), "x", true); err == nil {
100
+ t.Fatal("watch writer accepted a blocked parent")
101
+ }
102
+ })
103
+
104
+ t.Run("read-only output", func(t *testing.T) {
105
+ root := t.TempDir()
106
+ watch := filepath.Join(root, "watch.js")
107
+ normal := filepath.Join(root, "normal.js")
108
+ for _, file := range []string{watch, normal} {
109
+ if err := os.WriteFile(file, []byte("same"), 0o600); err != nil {
110
+ t.Fatal(err)
111
+ }
112
+ if err := os.Chmod(file, 0o400); err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ }
116
+ watchErr := writeFileWithWatchMode(watch, "same", true)
117
+ normalErr := writeFileWithWatchMode(normal, "same", false)
118
+ if (watchErr == nil) != (normalErr == nil) {
119
+ t.Fatalf("read-only write error differs: watch=%v normal=%v", watchErr, normalErr)
120
+ }
121
+ })
122
+
123
+ t.Run("symlink output", func(t *testing.T) {
124
+ root := t.TempDir()
125
+ target := filepath.Join(root, "target.js")
126
+ link := filepath.Join(root, "link.js")
127
+ if err := os.WriteFile(target, []byte("same"), 0o600); err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ if err := os.Symlink(target, link); err != nil {
131
+ t.Skipf("symlink unavailable: %v", err)
132
+ }
133
+ if err := os.Chtimes(target, old, old); err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ if err := writeFileWithWatchMode(link, "same", true); err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ info, err := os.Lstat(link)
140
+ if err != nil || info.Mode()&os.ModeSymlink == 0 {
141
+ t.Fatalf("output symlink was replaced: %v, %v", info, err)
142
+ }
143
+ targetInfo, err := os.Stat(target)
144
+ if err != nil || targetInfo.ModTime().Equal(old) {
145
+ t.Fatalf("symlink target was not rewritten: %v, %v", targetInfo, err)
146
+ }
147
+ })
148
+ }
@@ -0,0 +1,288 @@
1
+ // Package packageartifacts validates published Go cache actions before they enter a local base.
2
+ package packageartifacts
3
+
4
+ import (
5
+ "bytes"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ "encoding/json"
9
+ "errors"
10
+ "fmt"
11
+ "io"
12
+ "net"
13
+ "net/url"
14
+ "strings"
15
+ )
16
+
17
+ const MaxManifestBytes = 16 << 20
18
+
19
+ const MaxPackBytes = 512 << 20
20
+
21
+ const MaxPackDecodedBytes = 4 << 30
22
+
23
+ // Manifest is a candidate catalog. Only Go's full ActionID decides a cache hit.
24
+ // Its exact serialized bytes must match a digest carried by the trusted release package.
25
+ //
26
+ // @evidence contracts/common.md#principled-implementation Release provenance, Go selection, transport packs and complete action associations are kept together so admission cannot use an unauthenticated partial catalog.
27
+ // @evidence contracts/common.md#clear-and-simple-design One versioned value is decoded and validated before any pack or action is exposed.
28
+ // @evidence contracts/common.md#prohibited-implementation-shortcuts The graph digest diagnoses a build; it never substitutes for Go's action key or a release-pinned digest.
29
+ // @evidence contracts/common.md#meaningful-documentation The comment identifies candidate-only selection and the external trust prerequisite.
30
+ //
31
+ // @evidenceExclude contracts/portability.md#os-neutral-implementation This JSON value has no native path or process operation.
32
+ // @evidenceExclude contracts/performance.md#efficient-algorithms The type chooses no algorithm; parsing and traversal belong to ParseTrustedManifest.
33
+ // @evidenceExclude contracts/performance.md#reuse-equivalent-work The value owns no retained computation.
34
+ // @evidenceExclude contracts/performance.md#bound-retention-and-release-resources The value owns no external resource.
35
+ type Manifest struct {
36
+ Schema int `json:"schema"`
37
+ Release string `json:"release"`
38
+ SourceCommit string `json:"sourceCommit"`
39
+ Tuple Tuple `json:"tuple"`
40
+ GraphSHA256 string `json:"graphSHA256"`
41
+ Packs []Pack `json:"packs"`
42
+ Actions []Action `json:"actions"`
43
+ }
44
+
45
+ // Tuple selects candidate packs, while Go validates actual package actions.
46
+ // Initial publisher coverage is limited to an observed effective CGO_ENABLED=0.
47
+ //
48
+ // @evidence contracts/common.md#principled-implementation The recorded Go tool, target, features and build policy choose a possible pack without claiming that equal tuple fields imply equal ActionIDs.
49
+ // @evidence contracts/common.md#clear-and-simple-design Selection fields form one value and leave actual cache matching to Go.
50
+ // @evidence contracts/common.md#prohibited-implementation-shortcuts The CGO field records the observed setting and cannot be rewritten to manufacture coverage.
51
+ // @evidence contracts/common.md#meaningful-documentation The comment states the tuple's limited authority and initial native eligibility.
52
+ //
53
+ // @evidenceExclude contracts/portability.md#os-neutral-implementation These are Go-reported target strings, not native filesystem paths or process calls.
54
+ // @evidenceExclude contracts/performance.md#efficient-algorithms The type chooses no algorithm.
55
+ // @evidenceExclude contracts/performance.md#reuse-equivalent-work The type retains no result across requests.
56
+ // @evidenceExclude contracts/performance.md#bound-retention-and-release-resources The type owns no handle or task.
57
+ type Tuple struct {
58
+ GoVersion string `json:"goVersion"`
59
+ GoToolSHA256 string `json:"goToolSHA256"`
60
+ GOOS string `json:"goos"`
61
+ GOARCH string `json:"goarch"`
62
+ Architecture map[string]string `json:"architecture"`
63
+ CGOEnabled string `json:"cgoEnabled"`
64
+ GoExperiment string `json:"goExperiment"`
65
+ BuildFlags []string `json:"buildFlags"`
66
+ BuildTags []string `json:"buildTags"`
67
+ ToolIDsSHA256 string `json:"toolIDsSHA256"`
68
+ }
69
+
70
+ // Pack describes one immutable compressed transport object and its decoded limit.
71
+ //
72
+ // @evidence contracts/common.md#principled-implementation A transport digest and compressed/decoded counts are separate so admission can bound both transfer and extracted bodies.
73
+ // @evidence contracts/common.md#clear-and-simple-design One pack record carries the bytes and count limits needed by its reader.
74
+ // @evidence contracts/common.md#prohibited-implementation-shortcuts A pack hash from the pack itself is not producer authority; the enclosing Manifest requires a separately trusted digest.
75
+ // @evidence contracts/common.md#meaningful-documentation The comment names the immutable object and decoded limit; fields carry their JSON units in names.
76
+ //
77
+ // @evidenceExclude contracts/portability.md#os-neutral-implementation The name is a transport identifier, not an interpreted native path.
78
+ // @evidenceExclude contracts/performance.md#efficient-algorithms The record chooses no computation strategy.
79
+ // @evidenceExclude contracts/performance.md#reuse-equivalent-work The record retains no cross-request computation.
80
+ // @evidenceExclude contracts/performance.md#bound-retention-and-release-resources The record owns no downloaded bytes or handle.
81
+ type Pack struct {
82
+ Name string `json:"name"`
83
+ URL string `json:"url"`
84
+ SHA256 string `json:"sha256"`
85
+ Length int64 `json:"length"`
86
+ DecodedBytes int64 `json:"decodedBytes"`
87
+ FileCount int `json:"fileCount"`
88
+ }
89
+
90
+ // Action associates a complete Go cache key with the exact body Go produced.
91
+ // Evidence is diagnostic; no package path, graph digest or displayed build ID replaces ActionID.
92
+ //
93
+ // @evidence contracts/common.md#principled-implementation The full Go action and output identities own lookup; package/module/source/command/dependency fields explain a captured association without changing it.
94
+ // @evidence contracts/common.md#clear-and-simple-design Archive and opaque bodies share one association because both use the same Go cache protocol.
95
+ // @evidence contracts/common.md#prohibited-implementation-shortcuts The package annotation never synthesizes a key, and an opaque record cannot be silently interpreted as an archive.
96
+ // @evidence contracts/common.md#meaningful-documentation The comment distinguishes cache authority from diagnostic evidence.
97
+ //
98
+ // @evidenceExclude contracts/portability.md#os-neutral-implementation Paths are Go package/module identifiers and full digests, not native filesystem paths.
99
+ // @evidenceExclude contracts/performance.md#efficient-algorithms The record selects no algorithm.
100
+ // @evidenceExclude contracts/performance.md#reuse-equivalent-work The record retains no computation across calls.
101
+ // @evidenceExclude contracts/performance.md#bound-retention-and-release-resources The record owns no body or handle.
102
+ type Action struct {
103
+ ActionID string `json:"actionID"`
104
+ OutputID string `json:"outputID"`
105
+ BodySHA256 string `json:"bodySHA256"`
106
+ BodyLength int64 `json:"bodyLength"`
107
+ Pack string `json:"pack"`
108
+ Kind string `json:"kind"`
109
+ PackagePath string `json:"packagePath"`
110
+ ModulePath string `json:"modulePath"`
111
+ ModuleVersion string `json:"moduleVersion"`
112
+ ModuleRole string `json:"moduleRole"`
113
+ SelectedSourceSHA string `json:"selectedSourceSHA256"`
114
+ CommandSHA256 string `json:"commandSHA256"`
115
+ DirectInputs []string `json:"directInputs"`
116
+ }
117
+
118
+ // ParseTrustedManifest checks the raw release-pinned digest before decoding any mapping.
119
+ // The caller must obtain expectedSHA256 from authenticated release metadata, never from raw.
120
+ //
121
+ // @evidence contracts/common.md#principled-implementation SHA-256 of exact bounded bytes is compared with an external trusted digest before strict schema and full 32-byte action/output validation; Go alone computes the keys.
122
+ // @evidence contracts/common.md#clear-and-simple-design One decoder owns producer authentication, schema and association checks before returning a usable manifest.
123
+ // @evidence contracts/common.md#prohibited-implementation-shortcuts A digest supplied by the same untrusted manifest is never accepted as authority; duplicate conflicting actions fail rather than choosing a convenient output.
124
+ // @evidence contracts/common.md#meaningful-documentation The comment states the trust source and that the raw bytes, not a reserialized form, are authenticated.
125
+ //
126
+ // @evidenceExclude contracts/portability.md#os-neutral-implementation This byte decoder does no native path or process operation.
127
+ // @evidence contracts/performance.md#efficient-algorithms It hashes at most 16 MiB of input once, then decodes and traverses at most 64 packs and 100000 actions; maps give expected constant-time identity lookup with memory proportional to those populations.
128
+ // @evidenceExclude contracts/performance.md#reuse-equivalent-work Release bytes are checked for each call; this function retains no result or in-flight work.
129
+ // @evidenceExclude contracts/performance.md#bound-retention-and-release-resources Only bounded call-local bytes and maps are retained; the caller owns raw input and returned manifest lifetime.
130
+ func ParseTrustedManifest(raw []byte, expectedSHA256 string) (Manifest, error) {
131
+ var manifest Manifest
132
+ if len(raw) == 0 || len(raw) > MaxManifestBytes {
133
+ return manifest, fmt.Errorf("artifact manifest length %d is outside (0,%d]", len(raw), MaxManifestBytes)
134
+ }
135
+ if !validSHA256(expectedSHA256) {
136
+ return manifest, errors.New("artifact manifest has no valid trusted digest")
137
+ }
138
+ digest := sha256.Sum256(raw)
139
+ if hex.EncodeToString(digest[:]) != expectedSHA256 {
140
+ return manifest, errors.New("artifact manifest differs from trusted release digest")
141
+ }
142
+ decoder := json.NewDecoder(bytes.NewReader(raw))
143
+ decoder.DisallowUnknownFields()
144
+ if err := decoder.Decode(&manifest); err != nil {
145
+ return Manifest{}, fmt.Errorf("decode artifact manifest: %w", err)
146
+ }
147
+ var trailing any
148
+ if err := decoder.Decode(&trailing); err != io.EOF {
149
+ return Manifest{}, errors.New("artifact manifest contains trailing data")
150
+ }
151
+ if err := manifest.validate(); err != nil {
152
+ return Manifest{}, err
153
+ }
154
+ return manifest, nil
155
+ }
156
+
157
+ func (manifest Manifest) validate() error {
158
+ if manifest.Schema != 1 {
159
+ return fmt.Errorf("unsupported artifact manifest schema %d", manifest.Schema)
160
+ }
161
+ if manifest.Release == "" || !validCommit(manifest.SourceCommit) || !validSHA256(manifest.GraphSHA256) {
162
+ return errors.New("artifact manifest has incomplete release provenance")
163
+ }
164
+ tuple := manifest.Tuple
165
+ if tuple.GoVersion == "" || tuple.GOOS == "" || tuple.GOARCH == "" || tuple.CGOEnabled != "0" || !validSHA256(tuple.GoToolSHA256) || !validSHA256(tuple.ToolIDsSHA256) || len(tuple.BuildFlags) == 0 {
166
+ return errors.New("artifact manifest has an unsupported or incomplete Go tuple")
167
+ }
168
+ if len(manifest.Packs) == 0 || len(manifest.Packs) > 64 || len(manifest.Actions) == 0 || len(manifest.Actions) > 100000 {
169
+ return errors.New("artifact manifest exceeds pack or action count policy")
170
+ }
171
+ packs := make(map[string]Pack, len(manifest.Packs))
172
+ for _, pack := range manifest.Packs {
173
+ if !validName(pack.Name) || !validPackURL(pack.URL) || !validSHA256(pack.SHA256) || pack.Length <= 0 || pack.Length > MaxPackBytes || pack.DecodedBytes < 0 || pack.DecodedBytes > MaxPackDecodedBytes || pack.FileCount <= 0 {
174
+ return fmt.Errorf("invalid artifact pack %q", pack.Name)
175
+ }
176
+ if _, exists := packs[pack.Name]; exists {
177
+ return fmt.Errorf("duplicate artifact pack %q", pack.Name)
178
+ }
179
+ packs[pack.Name] = pack
180
+ }
181
+ actions := make(map[string]Action, len(manifest.Actions))
182
+ outputs := make(map[string]int64, len(manifest.Actions))
183
+ outputPacks := make(map[string]string, len(manifest.Actions))
184
+ packOutputs := make(map[string]map[string]int64, len(manifest.Packs))
185
+ for _, action := range manifest.Actions {
186
+ if !validSHA256(action.ActionID) || !validSHA256(action.OutputID) || action.BodySHA256 != action.OutputID || action.BodyLength < 0 {
187
+ return fmt.Errorf("invalid full action/output identity %q", action.ActionID)
188
+ }
189
+ if action.BodyLength == 0 && action.OutputID != emptyBodySHA256 {
190
+ return fmt.Errorf("invalid empty body identity for action %s", action.ActionID)
191
+ }
192
+ if action.Kind != "archive" && action.Kind != "opaque" {
193
+ return fmt.Errorf("unsupported cache body kind %q", action.Kind)
194
+ }
195
+ if action.Kind == "archive" && (action.PackagePath == "" || action.ModuleRole == "" || !validSHA256(action.SelectedSourceSHA) || !validSHA256(action.CommandSHA256)) {
196
+ return fmt.Errorf("incomplete package evidence for action %s", action.ActionID)
197
+ }
198
+ if _, exists := packs[action.Pack]; !exists {
199
+ return fmt.Errorf("action %s names unknown pack %q", action.ActionID, action.Pack)
200
+ }
201
+ if previous, exists := actions[action.ActionID]; exists {
202
+ if previous.OutputID != action.OutputID || previous.BodyLength != action.BodyLength {
203
+ return fmt.Errorf("conflicting publisher mapping for action %s", action.ActionID)
204
+ }
205
+ return fmt.Errorf("duplicate publisher action %s", action.ActionID)
206
+ }
207
+ if length, exists := outputs[action.OutputID]; exists && length != action.BodyLength {
208
+ return fmt.Errorf("conflicting body length for output %s", action.OutputID)
209
+ }
210
+ if owner, exists := outputPacks[action.OutputID]; exists && owner != action.Pack {
211
+ return fmt.Errorf("output %s is duplicated across packs", action.OutputID)
212
+ }
213
+ for _, input := range action.DirectInputs {
214
+ if !validSHA256(input) {
215
+ return fmt.Errorf("action %s has a malformed direct input", action.ActionID)
216
+ }
217
+ }
218
+ actions[action.ActionID] = action
219
+ outputs[action.OutputID] = action.BodyLength
220
+ outputPacks[action.OutputID] = action.Pack
221
+ if packOutputs[action.Pack] == nil {
222
+ packOutputs[action.Pack] = make(map[string]int64)
223
+ }
224
+ packOutputs[action.Pack][action.OutputID] = action.BodyLength
225
+ }
226
+ for _, pack := range manifest.Packs {
227
+ var total int64
228
+ for _, length := range packOutputs[pack.Name] {
229
+ if length > pack.DecodedBytes-total {
230
+ return fmt.Errorf("pack %q exceeds its decoded byte count", pack.Name)
231
+ }
232
+ total += length
233
+ }
234
+ if len(packOutputs[pack.Name]) != pack.FileCount || total != pack.DecodedBytes {
235
+ return fmt.Errorf("pack %q has inconsistent decoded counts", pack.Name)
236
+ }
237
+ }
238
+ return nil
239
+ }
240
+
241
+ const emptyBodySHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
242
+
243
+ func validSHA256(value string) bool {
244
+ if len(value) != 64 {
245
+ return false
246
+ }
247
+ for _, char := range value {
248
+ if !strings.ContainsRune("0123456789abcdef", char) {
249
+ return false
250
+ }
251
+ }
252
+ return true
253
+ }
254
+
255
+ func validCommit(value string) bool {
256
+ if len(value) != 40 && len(value) != 64 {
257
+ return false
258
+ }
259
+ for _, char := range value {
260
+ if !strings.ContainsRune("0123456789abcdef", char) {
261
+ return false
262
+ }
263
+ }
264
+ return true
265
+ }
266
+
267
+ func validName(value string) bool {
268
+ if value == "" || len(value) > 80 {
269
+ return false
270
+ }
271
+ for _, char := range value {
272
+ if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyz0123456789-", char) {
273
+ return false
274
+ }
275
+ }
276
+ return true
277
+ }
278
+
279
+ func validPackURL(value string) bool {
280
+ parsed, err := url.Parse(value)
281
+ if err != nil || parsed.User != nil || parsed.Host == "" || parsed.Fragment != "" {
282
+ return false
283
+ }
284
+ if parsed.Scheme == "https" {
285
+ return true
286
+ }
287
+ return parsed.Scheme == "http" && net.ParseIP(parsed.Hostname()) != nil && net.ParseIP(parsed.Hostname()).IsLoopback()
288
+ }