ttsc 0.32.1 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cmd/ttsc-cache/account.go +94 -0
- package/cmd/ttsc-cache/interruption_test.go +176 -0
- package/cmd/ttsc-cache/main.go +252 -0
- package/cmd/ttsc-cache/merge.go +324 -0
- package/cmd/ttsc-cache/merge_test.go +161 -0
- package/cmd/ttsc-cache/prepare.go +330 -0
- package/cmd/ttsc-cache/prepare_test.go +456 -0
- package/cmd/ttsc-cache/protocol_test.go +119 -0
- package/cmd/ttsc-cache/publisher_seal_test.go +135 -0
- package/cmd/ttsc-cache/seal.go +427 -0
- package/cmd/ttsc-cache/stock_go_failure_test.go +94 -0
- package/cmd/ttsc-cache/stock_go_test.go +163 -0
- package/cmd/ttsc-cache/store.go +249 -0
- package/cmd/ttsc-cache/store_test.go +104 -0
- package/cmd/ttsc-cache/tuple.go +107 -0
- package/cmd/ttsc-cache/tuple_test.go +62 -0
- package/cmd/ttsc-cache/usage.go +73 -0
- package/driver/configloaderscratch/canonical_directory_other.go +34 -0
- package/driver/configloaderscratch/canonical_directory_windows.go +60 -0
- package/driver/configloaderscratch/identity_temp_base.go +81 -0
- package/driver/configloaderscratch/identity_temp_base_posix_test.go +124 -0
- package/driver/configloaderscratch/identity_temp_base_windows_test.go +156 -0
- package/driver/emit_provenance.go +7 -5
- package/driver/resolutioninputs/recorder.cjs +16 -4
- package/driver/rewrite.go +25 -16
- package/driver/write_file_if_changed.go +69 -0
- package/driver/write_file_if_changed_test.go +148 -0
- package/internal/packageartifacts/manifest.go +288 -0
- package/internal/packageartifacts/manifest_test.go +127 -0
- package/lib/compiler/internal/build/BuildExecution.d.ts +11 -7
- package/lib/compiler/internal/build/BuildExecution.js +22 -8
- package/lib/compiler/internal/build/BuildExecution.js.map +1 -1
- package/lib/compiler/internal/build/ResidentCheckWatchSession.d.ts +10 -10
- package/lib/compiler/internal/build/ResidentCheckWatchSession.js +19 -14
- package/lib/compiler/internal/build/ResidentCheckWatchSession.js.map +1 -1
- package/lib/compiler/internal/build/RunBuildOptions.d.ts +8 -1
- package/lib/compiler/internal/build/TsgoArguments.d.ts +16 -0
- package/lib/compiler/internal/build/TsgoArguments.js +19 -0
- package/lib/compiler/internal/build/TsgoArguments.js.map +1 -1
- package/lib/compiler/internal/captureProcessOutput.d.ts +16 -13
- package/lib/compiler/internal/captureProcessOutput.js +22 -14
- package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
- package/lib/compiler/internal/startResidentTransform.js +3 -1
- package/lib/compiler/internal/startResidentTransform.js.map +1 -1
- package/lib/compiler/internal/transformProjectInMemory.js +5 -1
- package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
- package/lib/launcher/internal/runtime/PluginDescriptorInputReport.d.ts +43 -0
- package/lib/launcher/internal/runtime/PluginDescriptorInputReport.js +106 -0
- package/lib/launcher/internal/runtime/PluginDescriptorInputReport.js.map +1 -0
- package/lib/launcher/internal/runtime/installRuntimeHooks.js +15 -29
- package/lib/launcher/internal/runtime/installRuntimeHooks.js.map +1 -1
- package/lib/launcher/internal/ttscserver/runTtscserver.js +1 -0
- package/lib/launcher/internal/ttscserver/runTtscserver.js.map +1 -1
- package/lib/launcher/internal/watch/WatchTopology.js +5 -4
- package/lib/launcher/internal/watch/WatchTopology.js.map +1 -1
- package/lib/plugin/internal/CapabilityResolutionFormat.js +4 -3
- package/lib/plugin/internal/CapabilityResolutionFormat.js.map +1 -1
- package/lib/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.js +6 -0
- package/lib/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.js.map +1 -1
- package/lib/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.js +6 -0
- package/lib/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.js.map +1 -1
- package/lib/plugin/internal/load/PluginDescriptorEvaluationCache.js +3 -3
- package/lib/plugin/internal/load/PluginDescriptorEvaluationCache.js.map +1 -1
- package/lib/plugin/internal/load/descriptorRecordEnvironment.d.ts +15 -0
- package/lib/plugin/internal/load/descriptorRecordEnvironment.js +24 -0
- package/lib/plugin/internal/load/descriptorRecordEnvironment.js.map +1 -0
- package/lib/plugin/internal/load/loadProjectPlugins.js +28 -26
- package/lib/plugin/internal/load/loadProjectPlugins.js.map +1 -1
- package/lib/plugin/internal/source/SourceBuildCacheLayout.d.ts +2 -0
- package/lib/plugin/internal/source/SourceBuildCacheLayout.js +2 -0
- package/lib/plugin/internal/source/SourceBuildCacheLayout.js.map +1 -1
- package/lib/plugin/internal/source/SourcePackageArtifacts.d.ts +145 -0
- package/lib/plugin/internal/source/SourcePackageArtifacts.js +612 -0
- package/lib/plugin/internal/source/SourcePackageArtifacts.js.map +1 -0
- package/lib/plugin/internal/source/buildSourcePlugin.js +54 -4
- package/lib/plugin/internal/source/buildSourcePlugin.js.map +1 -1
- package/lib/plugin/internal/source/prunePackageArtifactRoot.d.ts +19 -0
- package/lib/plugin/internal/source/prunePackageArtifactRoot.js +95 -0
- package/lib/plugin/internal/source/prunePackageArtifactRoot.js.map +1 -0
- package/lib/plugin/internal/source/resolveCleanTargets.js +1 -1
- package/lib/plugin/internal/source/resolveCleanTargets.js.map +1 -1
- package/lib/plugin/internal/source/resolveSourceBuildCachePaths.js +9 -4
- package/lib/plugin/internal/source/resolveSourceBuildCachePaths.js.map +1 -1
- package/lib/plugin/internal/source/selectedRecordIdentityTempParent.d.ts +18 -0
- package/lib/plugin/internal/source/selectedRecordIdentityTempParent.js +46 -0
- package/lib/plugin/internal/source/selectedRecordIdentityTempParent.js.map +1 -0
- package/package.json +8 -8
- package/src/compiler/internal/build/BuildExecution.ts +30 -10
- package/src/compiler/internal/build/ResidentCheckWatchSession.ts +23 -22
- package/src/compiler/internal/build/RunBuildOptions.ts +9 -1
- package/src/compiler/internal/build/TsgoArguments.ts +22 -0
- package/src/compiler/internal/captureProcessOutput.ts +28 -14
- package/src/compiler/internal/startResidentTransform.ts +7 -1
- package/src/compiler/internal/transformProjectInMemory.ts +9 -1
- package/src/launcher/internal/runtime/PluginDescriptorInputReport.ts +100 -0
- package/src/launcher/internal/runtime/installRuntimeHooks.ts +57 -55
- package/src/launcher/internal/ttscserver/runTtscserver.ts +5 -0
- package/src/launcher/internal/watch/WatchTopology.ts +5 -4
- package/src/plugin/internal/CapabilityResolutionFormat.ts +4 -3
- package/src/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.ts +4 -0
- package/src/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.ts +4 -0
- package/src/plugin/internal/load/PluginDescriptorEvaluationCache.ts +3 -3
- package/src/plugin/internal/load/descriptorRecordEnvironment.ts +24 -0
- package/src/plugin/internal/load/loadProjectPlugins.ts +45 -29
- package/src/plugin/internal/source/SourceBuildCacheLayout.ts +3 -0
- package/src/plugin/internal/source/SourcePackageArtifacts.ts +787 -0
- package/src/plugin/internal/source/buildSourcePlugin.ts +82 -12
- package/src/plugin/internal/source/prunePackageArtifactRoot.ts +100 -0
- package/src/plugin/internal/source/resolveCleanTargets.ts +1 -0
- package/src/plugin/internal/source/resolveSourceBuildCachePaths.ts +11 -4
- package/src/plugin/internal/source/selectedRecordIdentityTempParent.ts +40 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"archive/zip"
|
|
5
|
+
"compress/flate"
|
|
6
|
+
"crypto/sha256"
|
|
7
|
+
"encoding/hex"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"errors"
|
|
10
|
+
"fmt"
|
|
11
|
+
"io"
|
|
12
|
+
"os"
|
|
13
|
+
"path/filepath"
|
|
14
|
+
"reflect"
|
|
15
|
+
"sort"
|
|
16
|
+
"strings"
|
|
17
|
+
|
|
18
|
+
"github.com/samchon/ttsc/packages/ttsc/internal/packageartifacts"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
type mergePlan struct {
|
|
22
|
+
Schema int `json:"schema"`
|
|
23
|
+
Inputs []struct {
|
|
24
|
+
Name string `json:"name"`
|
|
25
|
+
Manifest string `json:"manifest"`
|
|
26
|
+
SHA256 string `json:"sha256"`
|
|
27
|
+
} `json:"inputs"`
|
|
28
|
+
PackURLBase string `json:"packURLBase"`
|
|
29
|
+
OutputDir string `json:"outputDir"`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type mergeAction struct {
|
|
33
|
+
action packageartifacts.Action
|
|
34
|
+
members uint8
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type mergeBody struct {
|
|
38
|
+
length int64
|
|
39
|
+
members uint8
|
|
40
|
+
source *zip.File
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// MergeRecordedPacks makes a deduplicated package-action union from separately
|
|
44
|
+
// sealed, digest-pinned publisher builds. Equal ActionIDs must retain their
|
|
45
|
+
// complete mapping and package evidence; each OutputID is written to one membership
|
|
46
|
+
// pack. The final manifest digest still needs a trusted release metadata pin.
|
|
47
|
+
//
|
|
48
|
+
// @evidence contracts/common.md#principled-implementation Every input manifest and transport ZIP is verified against the operator's separate digest before equal Go actions are joined; conflicting mappings or package evidence fail, while path-dependent command diagnostics use a deterministic representative.
|
|
49
|
+
// @evidence contracts/common.md#clear-and-simple-design One release plan names the approved publisher builds, output directory and immutable URL base; action membership determines reusable common and composition-specific packs.
|
|
50
|
+
// @evidence contracts/common.md#prohibited-implementation-shortcuts Go's full recorded ActionIDs and OutputIDs are copied, never recomputed from package names or an aggregate composition digest.
|
|
51
|
+
// @evidence contracts/common.md#meaningful-documentation The comment distinguishes a verified input union from the later release trust pin needed by consumers.
|
|
52
|
+
// @evidence contracts/portability.md#os-neutral-implementation Native absolute paths and standard ZIP APIs read publisher files; transport entry names and pack names are platform neutral.
|
|
53
|
+
// @evidence contracts/performance.md#efficient-algorithms Maps deduplicate actions and outputs once, and each selected body is streamed to exactly one sorted output ZIP without holding decoded archives in memory.
|
|
54
|
+
// @evidenceExclude contracts/performance.md#reuse-equivalent-work Release publication is a one-shot explicit operation; a changed plan or input is verified afresh.
|
|
55
|
+
// @evidence contracts/performance.md#bound-retention-and-release-resources The plan and manifest have byte/count caps, input archives close after merging, staging ZIPs are removed on failure and output packs retain only verified immutable bytes.
|
|
56
|
+
func MergeRecordedPacks(planPath string) error {
|
|
57
|
+
if !filepath.IsAbs(planPath) {
|
|
58
|
+
return errors.New("merge plan must be absolute")
|
|
59
|
+
}
|
|
60
|
+
raw, err := readBoundedFile(planPath, 64<<10)
|
|
61
|
+
if err != nil {
|
|
62
|
+
return err
|
|
63
|
+
}
|
|
64
|
+
var plan mergePlan
|
|
65
|
+
decoder := json.NewDecoder(strings.NewReader(string(raw)))
|
|
66
|
+
decoder.DisallowUnknownFields()
|
|
67
|
+
if err := decoder.Decode(&plan); err != nil {
|
|
68
|
+
return fmt.Errorf("decode release merge plan: %w", err)
|
|
69
|
+
}
|
|
70
|
+
var trailing any
|
|
71
|
+
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
72
|
+
return errors.New("release merge plan has trailing data")
|
|
73
|
+
}
|
|
74
|
+
if plan.Schema != 1 || len(plan.Inputs) < 2 || len(plan.Inputs) > 8 || !filepath.IsAbs(plan.OutputDir) || plan.PackURLBase == "" {
|
|
75
|
+
return errors.New("release merge plan has unsupported inputs or output")
|
|
76
|
+
}
|
|
77
|
+
var openFiles []*os.File
|
|
78
|
+
defer func() {
|
|
79
|
+
for _, file := range openFiles {
|
|
80
|
+
file.Close()
|
|
81
|
+
}
|
|
82
|
+
}()
|
|
83
|
+
seenNames := make(map[string]bool)
|
|
84
|
+
actions := make(map[string]mergeAction)
|
|
85
|
+
bodies := make(map[string]mergeBody)
|
|
86
|
+
var first packageartifacts.Manifest
|
|
87
|
+
var graphDigests []string
|
|
88
|
+
for index, input := range plan.Inputs {
|
|
89
|
+
if !validPackName(input.Name) || input.Name == "common" || seenNames[input.Name] || !filepath.IsAbs(input.Manifest) || !validID(input.SHA256) {
|
|
90
|
+
return errors.New("release merge input has an unsafe name, path or digest")
|
|
91
|
+
}
|
|
92
|
+
seenNames[input.Name] = true
|
|
93
|
+
manifestRaw, err := readBoundedFile(input.Manifest, packageartifacts.MaxManifestBytes)
|
|
94
|
+
if err != nil {
|
|
95
|
+
return err
|
|
96
|
+
}
|
|
97
|
+
manifest, err := packageartifacts.ParseTrustedManifest(manifestRaw, input.SHA256)
|
|
98
|
+
if err != nil {
|
|
99
|
+
return fmt.Errorf("merge input %s: %w", input.Name, err)
|
|
100
|
+
}
|
|
101
|
+
if index == 0 {
|
|
102
|
+
first = manifest
|
|
103
|
+
} else if manifest.Release != first.Release || manifest.SourceCommit != first.SourceCommit || !reflect.DeepEqual(manifest.Tuple, first.Tuple) {
|
|
104
|
+
return fmt.Errorf("merge input %s belongs to another release or Go tuple", input.Name)
|
|
105
|
+
}
|
|
106
|
+
graphDigests = append(graphDigests, manifest.GraphSHA256)
|
|
107
|
+
packBodies := make(map[string]map[string]int64)
|
|
108
|
+
for _, action := range manifest.Actions {
|
|
109
|
+
if packBodies[action.Pack] == nil {
|
|
110
|
+
packBodies[action.Pack] = make(map[string]int64)
|
|
111
|
+
}
|
|
112
|
+
packBodies[action.Pack][action.OutputID] = action.BodyLength
|
|
113
|
+
}
|
|
114
|
+
packEntries := make(map[string]map[string]*zip.File)
|
|
115
|
+
for _, pack := range manifest.Packs {
|
|
116
|
+
filename := filepath.Join(filepath.Dir(input.Manifest), pack.Name+"-"+pack.SHA256+".zip")
|
|
117
|
+
actual, err := hashFile(filename, packageartifacts.MaxPackBytes)
|
|
118
|
+
if err != nil || actual.digest != pack.SHA256 || actual.length != pack.Length {
|
|
119
|
+
return fmt.Errorf("merge input %s has a missing or corrupt pack %s", input.Name, pack.Name)
|
|
120
|
+
}
|
|
121
|
+
file, err := os.Open(filename)
|
|
122
|
+
if err != nil {
|
|
123
|
+
return err
|
|
124
|
+
}
|
|
125
|
+
openFiles = append(openFiles, file)
|
|
126
|
+
reader, err := zip.NewReader(file, pack.Length)
|
|
127
|
+
if err != nil || len(reader.File) != pack.FileCount {
|
|
128
|
+
return fmt.Errorf("merge input %s has an invalid ZIP catalog", input.Name)
|
|
129
|
+
}
|
|
130
|
+
entries := make(map[string]*zip.File, len(reader.File))
|
|
131
|
+
for _, entry := range reader.File {
|
|
132
|
+
outputID := strings.TrimPrefix(entry.Name, "bodies/")
|
|
133
|
+
length, exists := packBodies[pack.Name][outputID]
|
|
134
|
+
if !strings.HasPrefix(entry.Name, "bodies/") || !exists || entries[outputID] != nil || entry.UncompressedSize64 != uint64(length) {
|
|
135
|
+
return fmt.Errorf("merge input %s has an unexpected ZIP body", input.Name)
|
|
136
|
+
}
|
|
137
|
+
entries[outputID] = entry
|
|
138
|
+
}
|
|
139
|
+
packEntries[pack.Name] = entries
|
|
140
|
+
}
|
|
141
|
+
member := uint8(1 << index)
|
|
142
|
+
for _, action := range manifest.Actions {
|
|
143
|
+
sourcePack := action.Pack
|
|
144
|
+
previous, exists := actions[action.ActionID]
|
|
145
|
+
if exists {
|
|
146
|
+
action.Pack = previous.action.Pack
|
|
147
|
+
commandDigest := action.CommandSHA256
|
|
148
|
+
action.CommandSHA256 = previous.action.CommandSHA256
|
|
149
|
+
if !reflect.DeepEqual(previous.action, action) {
|
|
150
|
+
return fmt.Errorf("conflicting publisher action %s", action.ActionID)
|
|
151
|
+
}
|
|
152
|
+
if commandDigest < previous.action.CommandSHA256 {
|
|
153
|
+
previous.action.CommandSHA256 = commandDigest
|
|
154
|
+
}
|
|
155
|
+
previous.members |= member
|
|
156
|
+
actions[action.ActionID] = previous
|
|
157
|
+
} else {
|
|
158
|
+
actions[action.ActionID] = mergeAction{action: action, members: member}
|
|
159
|
+
}
|
|
160
|
+
body := bodies[action.OutputID]
|
|
161
|
+
if body.source != nil && body.length != action.BodyLength {
|
|
162
|
+
return fmt.Errorf("conflicting publisher body %s", action.OutputID)
|
|
163
|
+
}
|
|
164
|
+
body.length = action.BodyLength
|
|
165
|
+
body.members |= member
|
|
166
|
+
if body.source == nil {
|
|
167
|
+
body.source = packEntries[sourcePack][action.OutputID]
|
|
168
|
+
}
|
|
169
|
+
if body.source == nil {
|
|
170
|
+
return fmt.Errorf("publisher body %s has no ZIP entry", action.OutputID)
|
|
171
|
+
}
|
|
172
|
+
bodies[action.OutputID] = body
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if len(actions) > 100000 || len(bodies) > 100000 {
|
|
176
|
+
return errors.New("merged release exceeds action policy")
|
|
177
|
+
}
|
|
178
|
+
if err := os.MkdirAll(plan.OutputDir, 0700); err != nil {
|
|
179
|
+
return err
|
|
180
|
+
}
|
|
181
|
+
groups := make(map[uint8][]string)
|
|
182
|
+
for outputID, body := range bodies {
|
|
183
|
+
groups[body.members] = append(groups[body.members], outputID)
|
|
184
|
+
}
|
|
185
|
+
if len(groups) > 64 {
|
|
186
|
+
return errors.New("merged release exceeds pack policy")
|
|
187
|
+
}
|
|
188
|
+
manifest := packageartifacts.Manifest{
|
|
189
|
+
Schema: 1, Release: first.Release, SourceCommit: first.SourceCommit, Tuple: first.Tuple,
|
|
190
|
+
GraphSHA256: digestBytes([]byte(strings.Join(graphDigests, "\n"))),
|
|
191
|
+
}
|
|
192
|
+
groupNames := make(map[uint8]string)
|
|
193
|
+
usedNames := make(map[string]bool)
|
|
194
|
+
for members, ids := range groups {
|
|
195
|
+
sort.Strings(ids)
|
|
196
|
+
var names []string
|
|
197
|
+
for index, input := range plan.Inputs {
|
|
198
|
+
if members&(1<<index) != 0 {
|
|
199
|
+
names = append(names, input.Name)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
name := "common"
|
|
203
|
+
if len(names) == 1 {
|
|
204
|
+
name = names[0]
|
|
205
|
+
} else if len(names) != len(plan.Inputs) {
|
|
206
|
+
name = "shared-" + digestBytes([]byte(strings.Join(names, ",")))[:16]
|
|
207
|
+
}
|
|
208
|
+
if usedNames[name] {
|
|
209
|
+
return fmt.Errorf("merged pack name %s collides", name)
|
|
210
|
+
}
|
|
211
|
+
usedNames[name] = true
|
|
212
|
+
groupNames[members] = name
|
|
213
|
+
pack, err := writeMergedPack(plan.OutputDir, plan.PackURLBase, name, ids, bodies)
|
|
214
|
+
if err != nil {
|
|
215
|
+
return err
|
|
216
|
+
}
|
|
217
|
+
manifest.Packs = append(manifest.Packs, pack)
|
|
218
|
+
}
|
|
219
|
+
for _, value := range actions {
|
|
220
|
+
action := value.action
|
|
221
|
+
action.Pack = groupNames[bodies[action.OutputID].members]
|
|
222
|
+
manifest.Actions = append(manifest.Actions, action)
|
|
223
|
+
}
|
|
224
|
+
sort.Slice(manifest.Packs, func(i, j int) bool { return manifest.Packs[i].Name < manifest.Packs[j].Name })
|
|
225
|
+
sort.Slice(manifest.Actions, func(i, j int) bool { return manifest.Actions[i].ActionID < manifest.Actions[j].ActionID })
|
|
226
|
+
manifestRaw, err := json.MarshalIndent(manifest, "", " ")
|
|
227
|
+
if err != nil {
|
|
228
|
+
return err
|
|
229
|
+
}
|
|
230
|
+
manifestRaw = append(manifestRaw, '\n')
|
|
231
|
+
digest := digestBytes(manifestRaw)
|
|
232
|
+
if _, err := packageartifacts.ParseTrustedManifest(manifestRaw, digest); err != nil {
|
|
233
|
+
return err
|
|
234
|
+
}
|
|
235
|
+
manifestFile, err := os.OpenFile(filepath.Join(plan.OutputDir, "manifest.json"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
|
236
|
+
if err != nil {
|
|
237
|
+
return err
|
|
238
|
+
}
|
|
239
|
+
_, writeErr := manifestFile.Write(manifestRaw)
|
|
240
|
+
syncErr := manifestFile.Sync()
|
|
241
|
+
closeErr := manifestFile.Close()
|
|
242
|
+
if writeErr != nil || syncErr != nil || closeErr != nil {
|
|
243
|
+
return errors.Join(writeErr, syncErr, closeErr)
|
|
244
|
+
}
|
|
245
|
+
return json.NewEncoder(os.Stdout).Encode(struct {
|
|
246
|
+
ManifestSHA256 string `json:"manifestSHA256"`
|
|
247
|
+
Actions int `json:"actions"`
|
|
248
|
+
Packs int `json:"packs"`
|
|
249
|
+
}{digest, len(manifest.Actions), len(manifest.Packs)})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func writeMergedPack(directory, urlBase, name string, ids []string, bodies map[string]mergeBody) (packageartifacts.Pack, error) {
|
|
253
|
+
var pack packageartifacts.Pack
|
|
254
|
+
stage, err := os.CreateTemp(directory, ".ttsc-merge-*.zip")
|
|
255
|
+
if err != nil {
|
|
256
|
+
return pack, err
|
|
257
|
+
}
|
|
258
|
+
defer os.Remove(stage.Name())
|
|
259
|
+
writer := zip.NewWriter(stage)
|
|
260
|
+
writer.RegisterCompressor(zip.Deflate, func(destination io.Writer) (io.WriteCloser, error) {
|
|
261
|
+
return flate.NewWriter(destination, flate.BestSpeed)
|
|
262
|
+
})
|
|
263
|
+
var decoded int64
|
|
264
|
+
for _, outputID := range ids {
|
|
265
|
+
body := bodies[outputID]
|
|
266
|
+
if body.length > packageartifacts.MaxPackDecodedBytes-decoded {
|
|
267
|
+
writer.Close()
|
|
268
|
+
stage.Close()
|
|
269
|
+
return pack, errors.New("merged pack exceeds decoded byte policy")
|
|
270
|
+
}
|
|
271
|
+
input, err := body.source.Open()
|
|
272
|
+
if err != nil {
|
|
273
|
+
writer.Close()
|
|
274
|
+
stage.Close()
|
|
275
|
+
return pack, err
|
|
276
|
+
}
|
|
277
|
+
entry, err := writer.Create("bodies/" + outputID)
|
|
278
|
+
if err != nil {
|
|
279
|
+
input.Close()
|
|
280
|
+
writer.Close()
|
|
281
|
+
stage.Close()
|
|
282
|
+
return pack, err
|
|
283
|
+
}
|
|
284
|
+
hash := sha256.New()
|
|
285
|
+
count, copyErr := io.Copy(io.MultiWriter(entry, hash), io.LimitReader(input, body.length+1))
|
|
286
|
+
closeErr := input.Close()
|
|
287
|
+
if copyErr != nil || closeErr != nil || count != body.length || hex.EncodeToString(hash.Sum(nil)) != outputID {
|
|
288
|
+
writer.Close()
|
|
289
|
+
stage.Close()
|
|
290
|
+
return pack, fmt.Errorf("merged body %s failed verification: %w", outputID, errors.Join(copyErr, closeErr))
|
|
291
|
+
}
|
|
292
|
+
decoded += count
|
|
293
|
+
}
|
|
294
|
+
if err := writer.Close(); err != nil {
|
|
295
|
+
stage.Close()
|
|
296
|
+
return pack, err
|
|
297
|
+
}
|
|
298
|
+
if err := stage.Sync(); err != nil {
|
|
299
|
+
stage.Close()
|
|
300
|
+
return pack, err
|
|
301
|
+
}
|
|
302
|
+
if err := stage.Close(); err != nil {
|
|
303
|
+
return pack, err
|
|
304
|
+
}
|
|
305
|
+
digest, err := hashFile(stage.Name(), packageartifacts.MaxPackBytes)
|
|
306
|
+
if err != nil {
|
|
307
|
+
return pack, err
|
|
308
|
+
}
|
|
309
|
+
filename := name + "-" + digest.digest + ".zip"
|
|
310
|
+
final := filepath.Join(directory, filename)
|
|
311
|
+
if err := os.Link(stage.Name(), final); err != nil {
|
|
312
|
+
if !errors.Is(err, os.ErrExist) {
|
|
313
|
+
return pack, err
|
|
314
|
+
}
|
|
315
|
+
previous, hashErr := hashFile(final, packageartifacts.MaxPackBytes)
|
|
316
|
+
if hashErr != nil || previous != digest {
|
|
317
|
+
return pack, errors.New("existing merged pack differs from verified bytes")
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return packageartifacts.Pack{
|
|
321
|
+
Name: name, URL: strings.TrimRight(urlBase, "/") + "/" + filename,
|
|
322
|
+
SHA256: digest.digest, Length: digest.length, DecodedBytes: decoded, FileCount: len(ids),
|
|
323
|
+
}, nil
|
|
324
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"archive/zip"
|
|
5
|
+
"bytes"
|
|
6
|
+
"crypto/sha256"
|
|
7
|
+
"encoding/hex"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strings"
|
|
12
|
+
"testing"
|
|
13
|
+
|
|
14
|
+
"github.com/samchon/ttsc/packages/ttsc/internal/packageartifacts"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// TestMergeRecordedPacks checks that a release union retains complete action
|
|
18
|
+
// mappings while placing shared bytes in one pack and rejecting a conflict.
|
|
19
|
+
//
|
|
20
|
+
// Verifies: two authenticated publisher catalogs with one common action yield
|
|
21
|
+
// three packs and one common body, despite workspace-specific command hashes.
|
|
22
|
+
// A conflicting OutputID for the same ActionID stops the union.
|
|
23
|
+
// 1. Write two publisher manifests and packs with one shared action.
|
|
24
|
+
// 2. Merge twice and check pack membership, action mapping and identical output.
|
|
25
|
+
// 3. Change the shared action body and require the merge to reject the conflict.
|
|
26
|
+
//
|
|
27
|
+
// @evidence contracts/testing.md#behavioral-verification Calls the production merger on separately serialized manifests and ZIPs, then parses the produced catalog with its reported digest.
|
|
28
|
+
// @evidence contracts/testing.md#independent-expectations Literal body bytes define SHA-256 output identities independently of merge output; the expected three pack memberships follow from the input overlap.
|
|
29
|
+
// @evidence contracts/testing.md#distinguishing-cases A shared action with differing command diagnostics succeeds, whereas an equal ActionID with a different body fails.
|
|
30
|
+
// @evidence contracts/testing.md#execution-ownership All publisher inputs and merged output reside under t.TempDir and need no external server, installation, or sibling repository.
|
|
31
|
+
func TestMergeRecordedPacks(t *testing.T) {
|
|
32
|
+
root := t.TempDir()
|
|
33
|
+
hash := func(value []byte) string {
|
|
34
|
+
sum := sha256.Sum256(value)
|
|
35
|
+
return hex.EncodeToString(sum[:])
|
|
36
|
+
}
|
|
37
|
+
common := []byte("common archive")
|
|
38
|
+
alpha := []byte("alpha archive")
|
|
39
|
+
beta := []byte("beta archive")
|
|
40
|
+
makeAction := func(key, body, command string) packageartifacts.Action {
|
|
41
|
+
return packageartifacts.Action{
|
|
42
|
+
ActionID: strings.Repeat(key, 64), OutputID: hash([]byte(body)),
|
|
43
|
+
BodySHA256: hash([]byte(body)), BodyLength: int64(len(body)),
|
|
44
|
+
Kind: "archive", PackagePath: "example.org/" + key,
|
|
45
|
+
ModulePath: "example.org/fixture", ModuleRole: "main",
|
|
46
|
+
SelectedSourceSHA: strings.Repeat("d", 64), CommandSHA256: strings.Repeat(command, 64),
|
|
47
|
+
DirectInputs: []string{},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
writeInput := func(name string, sharedCommand string, own []byte, conflict bool) (string, string) {
|
|
51
|
+
t.Helper()
|
|
52
|
+
directory := filepath.Join(root, name)
|
|
53
|
+
if err := os.Mkdir(directory, 0700); err != nil {
|
|
54
|
+
t.Fatal(err)
|
|
55
|
+
}
|
|
56
|
+
shared := makeAction("a", string(common), sharedCommand)
|
|
57
|
+
if conflict {
|
|
58
|
+
shared = makeAction("a", "conflicting archive", sharedCommand)
|
|
59
|
+
}
|
|
60
|
+
uniqueKey := "b"
|
|
61
|
+
if strings.HasPrefix(name, "beta") {
|
|
62
|
+
uniqueKey = "c"
|
|
63
|
+
}
|
|
64
|
+
unique := makeAction(uniqueKey, string(own), "e")
|
|
65
|
+
shared.Pack, unique.Pack = name, name
|
|
66
|
+
var archive bytes.Buffer
|
|
67
|
+
writer := zip.NewWriter(&archive)
|
|
68
|
+
sharedBody := common
|
|
69
|
+
if conflict {
|
|
70
|
+
sharedBody = []byte("conflicting archive")
|
|
71
|
+
}
|
|
72
|
+
for _, body := range [][]byte{sharedBody, own} {
|
|
73
|
+
entry, err := writer.Create("bodies/" + hash(body))
|
|
74
|
+
if err != nil {
|
|
75
|
+
t.Fatal(err)
|
|
76
|
+
}
|
|
77
|
+
if _, err := entry.Write(body); err != nil {
|
|
78
|
+
t.Fatal(err)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if err := writer.Close(); err != nil {
|
|
82
|
+
t.Fatal(err)
|
|
83
|
+
}
|
|
84
|
+
packHash := hash(archive.Bytes())
|
|
85
|
+
if err := os.WriteFile(filepath.Join(directory, name+"-"+packHash+".zip"), archive.Bytes(), 0600); err != nil {
|
|
86
|
+
t.Fatal(err)
|
|
87
|
+
}
|
|
88
|
+
manifest := packageartifacts.Manifest{
|
|
89
|
+
Schema: 1, Release: "1.0.0", SourceCommit: strings.Repeat("f", 40),
|
|
90
|
+
GraphSHA256: strings.Repeat("0", 64),
|
|
91
|
+
Tuple: packageartifacts.Tuple{
|
|
92
|
+
GoVersion: "go1.26.0", GoToolSHA256: strings.Repeat("1", 64),
|
|
93
|
+
GOOS: "windows", GOARCH: "amd64", CGOEnabled: "0",
|
|
94
|
+
BuildFlags: []string{"-trimpath"}, ToolIDsSHA256: strings.Repeat("2", 64),
|
|
95
|
+
},
|
|
96
|
+
Packs: []packageartifacts.Pack{{
|
|
97
|
+
Name: name, URL: "http://127.0.0.1:1/" + name, SHA256: packHash,
|
|
98
|
+
Length: int64(archive.Len()), DecodedBytes: int64(len(sharedBody) + len(own)), FileCount: 2,
|
|
99
|
+
}},
|
|
100
|
+
Actions: []packageartifacts.Action{shared, unique},
|
|
101
|
+
}
|
|
102
|
+
raw, err := json.Marshal(manifest)
|
|
103
|
+
if err != nil {
|
|
104
|
+
t.Fatal(err)
|
|
105
|
+
}
|
|
106
|
+
path := filepath.Join(directory, "manifest.json")
|
|
107
|
+
if err := os.WriteFile(path, raw, 0600); err != nil {
|
|
108
|
+
t.Fatal(err)
|
|
109
|
+
}
|
|
110
|
+
return path, hash(raw)
|
|
111
|
+
}
|
|
112
|
+
firstPath, firstHash := writeInput("alpha", "3", alpha, false)
|
|
113
|
+
secondPath, secondHash := writeInput("beta", "4", beta, false)
|
|
114
|
+
writePlan := func(output, otherPath, otherHash string) string {
|
|
115
|
+
t.Helper()
|
|
116
|
+
plan := mergePlan{Schema: 1, PackURLBase: "http://127.0.0.1:1/release", OutputDir: filepath.Join(root, output)}
|
|
117
|
+
plan.Inputs = make([]struct {
|
|
118
|
+
Name string `json:"name"`
|
|
119
|
+
Manifest string `json:"manifest"`
|
|
120
|
+
SHA256 string `json:"sha256"`
|
|
121
|
+
}, 2)
|
|
122
|
+
plan.Inputs[0].Name, plan.Inputs[0].Manifest, plan.Inputs[0].SHA256 = "alpha", firstPath, firstHash
|
|
123
|
+
plan.Inputs[1].Name, plan.Inputs[1].Manifest, plan.Inputs[1].SHA256 = "beta", otherPath, otherHash
|
|
124
|
+
raw, err := json.Marshal(plan)
|
|
125
|
+
if err != nil {
|
|
126
|
+
t.Fatal(err)
|
|
127
|
+
}
|
|
128
|
+
path := filepath.Join(root, output+"-plan.json")
|
|
129
|
+
if err := os.WriteFile(path, raw, 0600); err != nil {
|
|
130
|
+
t.Fatal(err)
|
|
131
|
+
}
|
|
132
|
+
return path
|
|
133
|
+
}
|
|
134
|
+
if err := MergeRecordedPacks(writePlan("merged", secondPath, secondHash)); err != nil {
|
|
135
|
+
t.Fatal(err)
|
|
136
|
+
}
|
|
137
|
+
raw, err := os.ReadFile(filepath.Join(root, "merged", "manifest.json"))
|
|
138
|
+
if err != nil {
|
|
139
|
+
t.Fatal(err)
|
|
140
|
+
}
|
|
141
|
+
merged, err := packageartifacts.ParseTrustedManifest(raw, hash(raw))
|
|
142
|
+
if err != nil || len(merged.Packs) != 3 || len(merged.Actions) != 3 {
|
|
143
|
+
t.Fatalf("invalid union: packs=%d actions=%d err=%v", len(merged.Packs), len(merged.Actions), err)
|
|
144
|
+
}
|
|
145
|
+
if err := MergeRecordedPacks(writePlan("repeated", secondPath, secondHash)); err != nil {
|
|
146
|
+
t.Fatal(err)
|
|
147
|
+
}
|
|
148
|
+
repeated, err := os.ReadFile(filepath.Join(root, "repeated", "manifest.json"))
|
|
149
|
+
if err != nil || !bytes.Equal(raw, repeated) {
|
|
150
|
+
t.Fatalf("equivalent release plans differed: %v", err)
|
|
151
|
+
}
|
|
152
|
+
for _, action := range merged.Actions {
|
|
153
|
+
if action.ActionID == strings.Repeat("a", 64) && (action.Pack != "common" || action.CommandSHA256 != strings.Repeat("3", 64)) {
|
|
154
|
+
t.Fatalf("common action lost deterministic evidence or membership: %+v", action)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
conflictPath, conflictHash := writeInput("beta-conflict", "4", beta, true)
|
|
158
|
+
if err := MergeRecordedPacks(writePlan("rejected", conflictPath, conflictHash)); err == nil || !strings.Contains(err.Error(), "conflicting publisher action") {
|
|
159
|
+
t.Fatalf("conflicting full action was accepted: %v", err)
|
|
160
|
+
}
|
|
161
|
+
}
|