devrites 4.0.0 → 4.0.2
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/CHANGELOG.md +15 -0
- package/README.md +23 -13
- package/SECURITY.md +9 -8
- package/docs/adr/0024-native-policy-offline-installer-boundary.md +1 -1
- package/docs/adr/0028-self-contained-engine-update.md +66 -0
- package/docs/adr/README.md +2 -1
- package/docs/architecture.md +17 -12
- package/docs/cli.md +31 -12
- package/docs/command-map.md +6 -4
- package/docs/engine/commands.md +14 -12
- package/docs/release.md +7 -7
- package/engine/internal/hostpack/hostpack.go +7 -0
- package/engine/internal/install/install.go +131 -9
- package/engine/internal/install/install_test.go +239 -0
- package/engine/internal/release/release.go +411 -0
- package/engine/internal/release/release_test.go +167 -0
- package/engine/tests/meta_test.go +6 -6
- package/package.json +1 -1
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// Package release acquires verified DevRites release artifacts for self-update.
|
|
2
|
+
package release
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"archive/tar"
|
|
6
|
+
"compress/gzip"
|
|
7
|
+
"context"
|
|
8
|
+
"crypto/sha256"
|
|
9
|
+
"encoding/hex"
|
|
10
|
+
"encoding/json"
|
|
11
|
+
"errors"
|
|
12
|
+
"fmt"
|
|
13
|
+
"io"
|
|
14
|
+
"io/fs"
|
|
15
|
+
"net/http"
|
|
16
|
+
"net/netip"
|
|
17
|
+
"net/url"
|
|
18
|
+
"os"
|
|
19
|
+
"path"
|
|
20
|
+
"path/filepath"
|
|
21
|
+
"regexp"
|
|
22
|
+
"runtime"
|
|
23
|
+
"strings"
|
|
24
|
+
"time"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const (
|
|
28
|
+
DefaultRepository = "ViktorsBaikers/DevRites"
|
|
29
|
+
maxAssetBytes = int64(64) << 20
|
|
30
|
+
maxJSONBytes = int64(4) << 20
|
|
31
|
+
maxChecksumBytes = int64(4) << 10
|
|
32
|
+
maxArchiveEntries = 4096
|
|
33
|
+
maxEntryBytes = int64(64) << 20
|
|
34
|
+
maxExtractedBytes = int64(512) << 20
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
var (
|
|
38
|
+
apiBaseURL = "https://api.github.com"
|
|
39
|
+
webBaseURL = "https://github.com"
|
|
40
|
+
httpClient = &http.Client{
|
|
41
|
+
Timeout: 2 * time.Minute,
|
|
42
|
+
CheckRedirect: checkRedirect,
|
|
43
|
+
}
|
|
44
|
+
repositoryPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$`)
|
|
45
|
+
tagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
// Candidate is one verified, extracted release and its platform engine.
|
|
49
|
+
type Candidate struct {
|
|
50
|
+
SourceDir string
|
|
51
|
+
PayloadDir string
|
|
52
|
+
EnginePath string
|
|
53
|
+
BundleURL string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Latest returns the exact tag of the latest published release.
|
|
57
|
+
func Latest(ctx context.Context, repository string) (string, error) {
|
|
58
|
+
if !repositoryPattern.MatchString(repository) {
|
|
59
|
+
return "", fmt.Errorf("invalid DevRites repository %q", repository)
|
|
60
|
+
}
|
|
61
|
+
var metadata struct {
|
|
62
|
+
TagName string `json:"tag_name"`
|
|
63
|
+
}
|
|
64
|
+
endpoint := strings.TrimRight(apiBaseURL, "/") + "/repos/" + repository + "/releases/latest"
|
|
65
|
+
if err := fetchJSON(ctx, endpoint, &metadata); err != nil {
|
|
66
|
+
return "", fmt.Errorf("resolve latest release: %w", err)
|
|
67
|
+
}
|
|
68
|
+
if !validTag(metadata.TagName) {
|
|
69
|
+
return "", fmt.Errorf("resolve latest release: invalid tag %q", metadata.TagName)
|
|
70
|
+
}
|
|
71
|
+
return metadata.TagName, nil
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Acquire downloads, verifies, and extracts the bundle and platform engine.
|
|
75
|
+
func Acquire(ctx context.Context, repository, tag string) (Candidate, func(), error) {
|
|
76
|
+
if !repositoryPattern.MatchString(repository) {
|
|
77
|
+
return Candidate{}, func() {}, fmt.Errorf("invalid DevRites repository %q", repository)
|
|
78
|
+
}
|
|
79
|
+
if !validTag(tag) {
|
|
80
|
+
return Candidate{}, func() {}, fmt.Errorf("invalid DevRites release tag %q", tag)
|
|
81
|
+
}
|
|
82
|
+
binaryName, err := platformBinaryName()
|
|
83
|
+
if err != nil {
|
|
84
|
+
return Candidate{}, func() {}, err
|
|
85
|
+
}
|
|
86
|
+
tmp, err := os.MkdirTemp("", "devrites-update-*")
|
|
87
|
+
if err != nil {
|
|
88
|
+
return Candidate{}, func() {}, fmt.Errorf("create update directory: %w", err)
|
|
89
|
+
}
|
|
90
|
+
cleanup := func() { _ = os.RemoveAll(tmp) }
|
|
91
|
+
fail := func(err error) (Candidate, func(), error) {
|
|
92
|
+
cleanup()
|
|
93
|
+
return Candidate{}, func() {}, err
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
base := strings.TrimRight(webBaseURL, "/") + "/" + repository + "/releases/download/" + tag
|
|
97
|
+
bundleName := "devrites-" + tag + ".tar.gz"
|
|
98
|
+
bundleURL := base + "/" + bundleName
|
|
99
|
+
bundlePath := filepath.Join(tmp, bundleName)
|
|
100
|
+
if err := downloadVerified(ctx, bundleURL, bundlePath, bundleName, 0o644); err != nil {
|
|
101
|
+
return fail(fmt.Errorf("download release bundle: %w", err))
|
|
102
|
+
}
|
|
103
|
+
source, err := extractBundle(bundlePath, tmp, tag)
|
|
104
|
+
if err != nil {
|
|
105
|
+
return fail(err)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
engineURL := base + "/" + binaryName
|
|
109
|
+
enginePath := filepath.Join(tmp, binaryName)
|
|
110
|
+
if err := downloadVerified(ctx, engineURL, enginePath, binaryName, 0o755); err != nil {
|
|
111
|
+
return fail(fmt.Errorf("download engine binary: %w", err))
|
|
112
|
+
}
|
|
113
|
+
return Candidate{
|
|
114
|
+
SourceDir: source,
|
|
115
|
+
PayloadDir: filepath.Join(source, "pack", "generated"),
|
|
116
|
+
EnginePath: enginePath,
|
|
117
|
+
BundleURL: bundleURL,
|
|
118
|
+
}, cleanup, nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func validTag(tag string) bool {
|
|
122
|
+
if !tagPattern.MatchString(tag) {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
version := strings.SplitN(strings.TrimPrefix(tag, "v"), "+", 2)[0]
|
|
126
|
+
_, prerelease, found := strings.Cut(version, "-")
|
|
127
|
+
if !found {
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
for _, identifier := range strings.Split(prerelease, ".") {
|
|
131
|
+
if len(identifier) > 1 && identifier[0] == '0' {
|
|
132
|
+
numeric := true
|
|
133
|
+
for _, char := range identifier {
|
|
134
|
+
if char < '0' || char > '9' {
|
|
135
|
+
numeric = false
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if numeric {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func platformBinaryName() (string, error) {
|
|
148
|
+
supported := (runtime.GOOS == "darwin" || runtime.GOOS == "linux") && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64")
|
|
149
|
+
if runtime.GOOS == "windows" && runtime.GOARCH == "amd64" {
|
|
150
|
+
supported = true
|
|
151
|
+
}
|
|
152
|
+
if !supported {
|
|
153
|
+
return "", fmt.Errorf("no DevRites engine release for %s/%s", runtime.GOOS, runtime.GOARCH)
|
|
154
|
+
}
|
|
155
|
+
name := "devrites-" + runtime.GOOS + "-" + runtime.GOARCH
|
|
156
|
+
if runtime.GOOS == "windows" {
|
|
157
|
+
name += ".exe"
|
|
158
|
+
}
|
|
159
|
+
return name, nil
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func fetchJSON(ctx context.Context, rawURL string, out any) error {
|
|
163
|
+
resp, err := get(ctx, rawURL)
|
|
164
|
+
if err != nil {
|
|
165
|
+
return err
|
|
166
|
+
}
|
|
167
|
+
defer resp.Body.Close()
|
|
168
|
+
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
169
|
+
return fmt.Errorf("%s returned %s", resp.Request.URL.Redacted(), resp.Status)
|
|
170
|
+
}
|
|
171
|
+
if resp.ContentLength > maxJSONBytes {
|
|
172
|
+
return fmt.Errorf("JSON response exceeds %d bytes", maxJSONBytes)
|
|
173
|
+
}
|
|
174
|
+
body, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONBytes+1))
|
|
175
|
+
if err != nil {
|
|
176
|
+
return fmt.Errorf("read JSON response: %w", err)
|
|
177
|
+
}
|
|
178
|
+
if int64(len(body)) > maxJSONBytes {
|
|
179
|
+
return fmt.Errorf("JSON response exceeds %d bytes", maxJSONBytes)
|
|
180
|
+
}
|
|
181
|
+
if err := json.Unmarshal(body, out); err != nil {
|
|
182
|
+
return fmt.Errorf("decode JSON response: %w", err)
|
|
183
|
+
}
|
|
184
|
+
return nil
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func downloadVerified(ctx context.Context, rawURL, destination, filename string, mode fs.FileMode) error {
|
|
188
|
+
if err := download(ctx, rawURL, destination, maxAssetBytes, mode); err != nil {
|
|
189
|
+
return err
|
|
190
|
+
}
|
|
191
|
+
sumPath := destination + ".sha256"
|
|
192
|
+
if err := download(ctx, rawURL+".sha256", sumPath, maxChecksumBytes, 0o600); err != nil {
|
|
193
|
+
return fmt.Errorf("missing checksum for %s: %w", filename, err)
|
|
194
|
+
}
|
|
195
|
+
sum, err := os.ReadFile(sumPath)
|
|
196
|
+
if err != nil {
|
|
197
|
+
return fmt.Errorf("read checksum for %s: %w", filename, err)
|
|
198
|
+
}
|
|
199
|
+
fields := strings.Fields(string(sum))
|
|
200
|
+
if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != filename {
|
|
201
|
+
return fmt.Errorf("invalid checksum record for %s", filename)
|
|
202
|
+
}
|
|
203
|
+
want, err := hex.DecodeString(fields[0])
|
|
204
|
+
if err != nil || len(want) != sha256.Size {
|
|
205
|
+
return fmt.Errorf("invalid SHA-256 for %s", filename)
|
|
206
|
+
}
|
|
207
|
+
file, err := os.Open(destination)
|
|
208
|
+
if err != nil {
|
|
209
|
+
return fmt.Errorf("open %s for checksum: %w", filename, err)
|
|
210
|
+
}
|
|
211
|
+
hash := sha256.New()
|
|
212
|
+
_, copyErr := io.Copy(hash, file)
|
|
213
|
+
closeErr := file.Close()
|
|
214
|
+
if copyErr != nil {
|
|
215
|
+
return fmt.Errorf("hash %s: %w", filename, copyErr)
|
|
216
|
+
}
|
|
217
|
+
if closeErr != nil {
|
|
218
|
+
return fmt.Errorf("close %s: %w", filename, closeErr)
|
|
219
|
+
}
|
|
220
|
+
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), hex.EncodeToString(want)) {
|
|
221
|
+
return fmt.Errorf("checksum mismatch for %s", filename)
|
|
222
|
+
}
|
|
223
|
+
return nil
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func download(ctx context.Context, rawURL, destination string, maxBytes int64, mode fs.FileMode) error {
|
|
227
|
+
resp, err := get(ctx, rawURL)
|
|
228
|
+
if err != nil {
|
|
229
|
+
return err
|
|
230
|
+
}
|
|
231
|
+
defer resp.Body.Close()
|
|
232
|
+
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
233
|
+
return fmt.Errorf("%s returned %s", resp.Request.URL.Redacted(), resp.Status)
|
|
234
|
+
}
|
|
235
|
+
if resp.ContentLength > maxBytes {
|
|
236
|
+
return fmt.Errorf("download exceeds %d bytes", maxBytes)
|
|
237
|
+
}
|
|
238
|
+
out, err := os.CreateTemp(filepath.Dir(destination), ".download-*")
|
|
239
|
+
if err != nil {
|
|
240
|
+
return fmt.Errorf("create download: %w", err)
|
|
241
|
+
}
|
|
242
|
+
tmp := out.Name()
|
|
243
|
+
keep := false
|
|
244
|
+
defer func() {
|
|
245
|
+
_ = out.Close()
|
|
246
|
+
if !keep {
|
|
247
|
+
_ = os.Remove(tmp)
|
|
248
|
+
}
|
|
249
|
+
}()
|
|
250
|
+
written, err := io.Copy(out, io.LimitReader(resp.Body, maxBytes+1))
|
|
251
|
+
if err != nil {
|
|
252
|
+
return fmt.Errorf("write download: %w", err)
|
|
253
|
+
}
|
|
254
|
+
if written > maxBytes {
|
|
255
|
+
return fmt.Errorf("download exceeds %d bytes", maxBytes)
|
|
256
|
+
}
|
|
257
|
+
if err := out.Chmod(mode); err != nil {
|
|
258
|
+
return fmt.Errorf("set download mode: %w", err)
|
|
259
|
+
}
|
|
260
|
+
if err := out.Sync(); err != nil {
|
|
261
|
+
return fmt.Errorf("sync download: %w", err)
|
|
262
|
+
}
|
|
263
|
+
if err := out.Close(); err != nil {
|
|
264
|
+
return fmt.Errorf("close download: %w", err)
|
|
265
|
+
}
|
|
266
|
+
if err := os.Rename(tmp, destination); err != nil {
|
|
267
|
+
return fmt.Errorf("install download: %w", err)
|
|
268
|
+
}
|
|
269
|
+
keep = true
|
|
270
|
+
return nil
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
func get(ctx context.Context, rawURL string) (*http.Response, error) {
|
|
274
|
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
275
|
+
if err != nil {
|
|
276
|
+
return nil, fmt.Errorf("build release request: %w", err)
|
|
277
|
+
}
|
|
278
|
+
if err := validateURL(req.URL); err != nil {
|
|
279
|
+
return nil, err
|
|
280
|
+
}
|
|
281
|
+
req.Header.Set("Accept", "application/vnd.github+json")
|
|
282
|
+
req.Header.Set("User-Agent", "devrites-engine")
|
|
283
|
+
return httpClient.Do(req)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
func validateURL(u *url.URL) error {
|
|
287
|
+
if u == nil || u.Hostname() == "" {
|
|
288
|
+
return errors.New("release URL has no host")
|
|
289
|
+
}
|
|
290
|
+
if strings.EqualFold(u.Scheme, "https") {
|
|
291
|
+
return nil
|
|
292
|
+
}
|
|
293
|
+
if strings.EqualFold(u.Scheme, "http") {
|
|
294
|
+
if ip, err := netip.ParseAddr(u.Hostname()); err == nil && ip.Unmap().IsLoopback() {
|
|
295
|
+
return nil
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return fmt.Errorf("release URL must use HTTPS: %s", u.Redacted())
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
func checkRedirect(req *http.Request, via []*http.Request) error {
|
|
302
|
+
if len(via) >= 10 {
|
|
303
|
+
return errors.New("stopped after 10 redirects")
|
|
304
|
+
}
|
|
305
|
+
if err := validateURL(req.URL); err != nil {
|
|
306
|
+
return err
|
|
307
|
+
}
|
|
308
|
+
if len(via) > 0 && !isLoopback(via[0].URL) && isLoopback(req.URL) {
|
|
309
|
+
return fmt.Errorf("remote release URL cannot redirect to loopback: %s", req.URL.Redacted())
|
|
310
|
+
}
|
|
311
|
+
return nil
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
func isLoopback(u *url.URL) bool {
|
|
315
|
+
if u == nil {
|
|
316
|
+
return false
|
|
317
|
+
}
|
|
318
|
+
host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
|
|
319
|
+
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
|
320
|
+
return true
|
|
321
|
+
}
|
|
322
|
+
ip, err := netip.ParseAddr(host)
|
|
323
|
+
return err == nil && ip.Unmap().IsLoopback()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
func extractBundle(archive, destination, tag string) (string, error) {
|
|
327
|
+
if err := extractTarGz(archive, destination); err != nil {
|
|
328
|
+
return "", fmt.Errorf("extract release bundle: %w", err)
|
|
329
|
+
}
|
|
330
|
+
source := filepath.Join(destination, "devrites-"+tag)
|
|
331
|
+
if _, err := os.Stat(filepath.Join(source, "package.json")); err != nil {
|
|
332
|
+
return "", fmt.Errorf("release bundle is missing package.json: %w", err)
|
|
333
|
+
}
|
|
334
|
+
if info, err := os.Stat(filepath.Join(source, "pack", "generated")); err != nil || !info.IsDir() {
|
|
335
|
+
return "", fmt.Errorf("release bundle is missing pack/generated")
|
|
336
|
+
}
|
|
337
|
+
return source, nil
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
func extractTarGz(archive, destination string) error {
|
|
341
|
+
file, err := os.Open(archive)
|
|
342
|
+
if err != nil {
|
|
343
|
+
return fmt.Errorf("open archive: %w", err)
|
|
344
|
+
}
|
|
345
|
+
defer file.Close()
|
|
346
|
+
gz, err := gzip.NewReader(file)
|
|
347
|
+
if err != nil {
|
|
348
|
+
return fmt.Errorf("open gzip stream: %w", err)
|
|
349
|
+
}
|
|
350
|
+
defer gz.Close()
|
|
351
|
+
reader := tar.NewReader(gz)
|
|
352
|
+
root, err := filepath.Abs(destination)
|
|
353
|
+
if err != nil {
|
|
354
|
+
return fmt.Errorf("resolve extraction directory: %w", err)
|
|
355
|
+
}
|
|
356
|
+
entries := 0
|
|
357
|
+
var total int64
|
|
358
|
+
for {
|
|
359
|
+
header, err := reader.Next()
|
|
360
|
+
if errors.Is(err, io.EOF) {
|
|
361
|
+
return nil
|
|
362
|
+
}
|
|
363
|
+
if err != nil {
|
|
364
|
+
return fmt.Errorf("read archive: %w", err)
|
|
365
|
+
}
|
|
366
|
+
entries++
|
|
367
|
+
if entries > maxArchiveEntries {
|
|
368
|
+
return fmt.Errorf("release bundle has more than %d entries", maxArchiveEntries)
|
|
369
|
+
}
|
|
370
|
+
if header.Size < 0 || header.Size > maxEntryBytes || header.Size > maxExtractedBytes-total {
|
|
371
|
+
return fmt.Errorf("release bundle entry %q exceeds extraction limits", header.Name)
|
|
372
|
+
}
|
|
373
|
+
total += header.Size
|
|
374
|
+
name := path.Clean(header.Name)
|
|
375
|
+
if name != strings.TrimSuffix(header.Name, "/") || !fs.ValidPath(name) || name == "." {
|
|
376
|
+
return fmt.Errorf("unsafe path in release bundle: %s", header.Name)
|
|
377
|
+
}
|
|
378
|
+
target := filepath.Join(root, filepath.FromSlash(name))
|
|
379
|
+
if target != root && !strings.HasPrefix(target, root+string(os.PathSeparator)) {
|
|
380
|
+
return fmt.Errorf("unsafe path in release bundle: %s", header.Name)
|
|
381
|
+
}
|
|
382
|
+
mode := fs.FileMode(header.Mode & 0o777)
|
|
383
|
+
switch header.Typeflag {
|
|
384
|
+
case tar.TypeDir:
|
|
385
|
+
if err := os.MkdirAll(target, mode); err != nil {
|
|
386
|
+
return fmt.Errorf("create directory %s: %w", name, err)
|
|
387
|
+
}
|
|
388
|
+
case tar.TypeReg:
|
|
389
|
+
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
390
|
+
return fmt.Errorf("create directory for %s: %w", name, err)
|
|
391
|
+
}
|
|
392
|
+
out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
|
|
393
|
+
if err != nil {
|
|
394
|
+
return fmt.Errorf("create %s: %w", name, err)
|
|
395
|
+
}
|
|
396
|
+
written, copyErr := io.Copy(out, io.LimitReader(reader, maxEntryBytes+1))
|
|
397
|
+
closeErr := out.Close()
|
|
398
|
+
if copyErr != nil {
|
|
399
|
+
return fmt.Errorf("extract %s: %w", name, copyErr)
|
|
400
|
+
}
|
|
401
|
+
if written != header.Size || written > maxEntryBytes {
|
|
402
|
+
return fmt.Errorf("release bundle entry %q has inconsistent size", header.Name)
|
|
403
|
+
}
|
|
404
|
+
if closeErr != nil {
|
|
405
|
+
return fmt.Errorf("close %s: %w", name, closeErr)
|
|
406
|
+
}
|
|
407
|
+
default:
|
|
408
|
+
return fmt.Errorf("release bundle entry %q has unsupported type", header.Name)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
package release
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"archive/tar"
|
|
5
|
+
"bytes"
|
|
6
|
+
"compress/gzip"
|
|
7
|
+
"context"
|
|
8
|
+
"crypto/sha256"
|
|
9
|
+
"fmt"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/http/httptest"
|
|
12
|
+
"os"
|
|
13
|
+
"path/filepath"
|
|
14
|
+
"strings"
|
|
15
|
+
"testing"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
func TestLatestAndAcquireVerifiedRelease(t *testing.T) {
|
|
19
|
+
const tag = "v4.1.0"
|
|
20
|
+
bundleName := "devrites-" + tag + ".tar.gz"
|
|
21
|
+
bundle := testBundle(t, tag, map[string]string{
|
|
22
|
+
"package.json": `{"version":"4.1.0"}`,
|
|
23
|
+
"pack/generated/claude/skills/rite/SKILL.md": "rite\n",
|
|
24
|
+
})
|
|
25
|
+
binaryName, err := platformBinaryName()
|
|
26
|
+
if err != nil {
|
|
27
|
+
t.Skip(err)
|
|
28
|
+
}
|
|
29
|
+
assets := map[string][]byte{
|
|
30
|
+
bundleName: bundle,
|
|
31
|
+
binaryName: []byte("test engine"),
|
|
32
|
+
}
|
|
33
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
34
|
+
if r.URL.Path == "/repos/owner/repo/releases/latest" {
|
|
35
|
+
_, _ = fmt.Fprintf(w, `{"tag_name":%q}`, tag)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
name := filepath.Base(r.URL.Path)
|
|
39
|
+
if strings.HasSuffix(name, ".sha256") {
|
|
40
|
+
assetName := strings.TrimSuffix(name, ".sha256")
|
|
41
|
+
asset, ok := assets[assetName]
|
|
42
|
+
if !ok {
|
|
43
|
+
http.NotFound(w, r)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
_, _ = fmt.Fprintf(w, "%x %s\n", sha256.Sum256(asset), assetName)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
asset, ok := assets[name]
|
|
50
|
+
if !ok {
|
|
51
|
+
http.NotFound(w, r)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
_, _ = w.Write(asset)
|
|
55
|
+
}))
|
|
56
|
+
defer server.Close()
|
|
57
|
+
oldAPI, oldWeb := apiBaseURL, webBaseURL
|
|
58
|
+
apiBaseURL, webBaseURL = server.URL, server.URL
|
|
59
|
+
t.Cleanup(func() { apiBaseURL, webBaseURL = oldAPI, oldWeb })
|
|
60
|
+
|
|
61
|
+
gotTag, err := Latest(context.Background(), "owner/repo")
|
|
62
|
+
if err != nil {
|
|
63
|
+
t.Fatal(err)
|
|
64
|
+
}
|
|
65
|
+
if gotTag != tag {
|
|
66
|
+
t.Fatalf("latest tag = %q, want %q", gotTag, tag)
|
|
67
|
+
}
|
|
68
|
+
candidate, cleanup, err := Acquire(context.Background(), "owner/repo", tag)
|
|
69
|
+
if err != nil {
|
|
70
|
+
t.Fatal(err)
|
|
71
|
+
}
|
|
72
|
+
root := filepath.Dir(candidate.SourceDir)
|
|
73
|
+
if _, err := os.Stat(filepath.Join(candidate.PayloadDir, "claude", "skills", "rite", "SKILL.md")); err != nil {
|
|
74
|
+
t.Fatalf("extracted payload: %v", err)
|
|
75
|
+
}
|
|
76
|
+
if got, err := os.ReadFile(candidate.EnginePath); err != nil || string(got) != "test engine" {
|
|
77
|
+
t.Fatalf("engine = %q, %v", got, err)
|
|
78
|
+
}
|
|
79
|
+
cleanup()
|
|
80
|
+
if _, err := os.Stat(root); !os.IsNotExist(err) {
|
|
81
|
+
t.Fatalf("cleanup kept %s: %v", root, err)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func TestValidTag(t *testing.T) {
|
|
86
|
+
for _, tc := range []struct {
|
|
87
|
+
tag string
|
|
88
|
+
want bool
|
|
89
|
+
}{
|
|
90
|
+
{tag: "v4.1.0", want: true},
|
|
91
|
+
{tag: "v4.1.0-rc.1+build.2", want: true},
|
|
92
|
+
{tag: "4.1.0", want: false},
|
|
93
|
+
{tag: "v4.1.0-01", want: false},
|
|
94
|
+
{tag: "v4.1", want: false},
|
|
95
|
+
} {
|
|
96
|
+
if got := validTag(tc.tag); got != tc.want {
|
|
97
|
+
t.Errorf("validTag(%q) = %t, want %t", tc.tag, got, tc.want)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func TestAcquireRejectsChecksumMismatch(t *testing.T) {
|
|
103
|
+
const tag = "v4.1.0"
|
|
104
|
+
bundleName := "devrites-" + tag + ".tar.gz"
|
|
105
|
+
bundle := testBundle(t, tag, map[string]string{
|
|
106
|
+
"package.json": `{"version":"4.1.0"}`,
|
|
107
|
+
"pack/generated/claude/skills/rite/SKILL.md": "rite\n",
|
|
108
|
+
})
|
|
109
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
110
|
+
if strings.HasSuffix(r.URL.Path, ".sha256") {
|
|
111
|
+
_, _ = fmt.Fprintf(w, "%064d %s\n", 0, bundleName)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
_, _ = w.Write(bundle)
|
|
115
|
+
}))
|
|
116
|
+
defer server.Close()
|
|
117
|
+
oldWeb := webBaseURL
|
|
118
|
+
webBaseURL = server.URL
|
|
119
|
+
t.Cleanup(func() { webBaseURL = oldWeb })
|
|
120
|
+
|
|
121
|
+
_, _, err := Acquire(context.Background(), "owner/repo", tag)
|
|
122
|
+
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
|
|
123
|
+
t.Fatalf("Acquire error = %v, want checksum mismatch", err)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func TestExtractBundleRejectsPathTraversal(t *testing.T) {
|
|
128
|
+
const tag = "v4.1.0"
|
|
129
|
+
archive := filepath.Join(t.TempDir(), "unsafe.tar.gz")
|
|
130
|
+
data := testBundle(t, tag, map[string]string{"../escape": "bad"})
|
|
131
|
+
if err := os.WriteFile(archive, data, 0o600); err != nil {
|
|
132
|
+
t.Fatal(err)
|
|
133
|
+
}
|
|
134
|
+
_, err := extractBundle(archive, t.TempDir(), tag)
|
|
135
|
+
if err == nil || !strings.Contains(err.Error(), "unsafe path") {
|
|
136
|
+
t.Fatalf("extractBundle error = %v, want unsafe path", err)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func testBundle(t *testing.T, tag string, files map[string]string) []byte {
|
|
141
|
+
t.Helper()
|
|
142
|
+
var raw bytes.Buffer
|
|
143
|
+
gz := gzip.NewWriter(&raw)
|
|
144
|
+
tw := tar.NewWriter(gz)
|
|
145
|
+
for name, body := range files {
|
|
146
|
+
data := []byte(body)
|
|
147
|
+
header := &tar.Header{
|
|
148
|
+
Name: "devrites-" + tag + "/" + name,
|
|
149
|
+
Mode: 0o644,
|
|
150
|
+
Size: int64(len(data)),
|
|
151
|
+
Typeflag: tar.TypeReg,
|
|
152
|
+
}
|
|
153
|
+
if err := tw.WriteHeader(header); err != nil {
|
|
154
|
+
t.Fatal(err)
|
|
155
|
+
}
|
|
156
|
+
if _, err := tw.Write(data); err != nil {
|
|
157
|
+
t.Fatal(err)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if err := tw.Close(); err != nil {
|
|
161
|
+
t.Fatal(err)
|
|
162
|
+
}
|
|
163
|
+
if err := gz.Close(); err != nil {
|
|
164
|
+
t.Fatal(err)
|
|
165
|
+
}
|
|
166
|
+
return raw.Bytes()
|
|
167
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
package main_test
|
|
2
2
|
|
|
3
|
-
// Cross-cutting assertion:
|
|
3
|
+
// Cross-cutting assertion: release acquisition is the only engine network boundary.
|
|
4
4
|
|
|
5
5
|
import (
|
|
6
6
|
"os/exec"
|
|
@@ -8,10 +8,9 @@ import (
|
|
|
8
8
|
"testing"
|
|
9
9
|
)
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
func TestFirstPartyMakesNoNetworkCalls(t *testing.T) {
|
|
11
|
+
// TestNetworkImportsStayInReleaseBoundary keeps update networking isolated from
|
|
12
|
+
// workspace state, policy, proof, and installation logic.
|
|
13
|
+
func TestNetworkImportsStayInReleaseBoundary(t *testing.T) {
|
|
15
14
|
if _, err := exec.LookPath("go"); err != nil {
|
|
16
15
|
t.Skip("go toolchain unavailable")
|
|
17
16
|
}
|
|
@@ -22,6 +21,7 @@ func TestFirstPartyMakesNoNetworkCalls(t *testing.T) {
|
|
|
22
21
|
t.Fatalf("go list: %v", err)
|
|
23
22
|
}
|
|
24
23
|
forbidden := map[string]bool{"net": true, "net/http": true, "net/rpc": true, "net/smtp": true}
|
|
24
|
+
allowed := map[string]bool{"github.com/devrites/devrites/internal/release": true}
|
|
25
25
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
26
26
|
fields := strings.Fields(line)
|
|
27
27
|
if len(fields) == 0 {
|
|
@@ -29,7 +29,7 @@ func TestFirstPartyMakesNoNetworkCalls(t *testing.T) {
|
|
|
29
29
|
}
|
|
30
30
|
pkg, imports := fields[0], fields[1:]
|
|
31
31
|
for _, imp := range imports {
|
|
32
|
-
if forbidden[imp] {
|
|
32
|
+
if forbidden[imp] && !allowed[pkg] {
|
|
33
33
|
t.Errorf("first-party package %s imports forbidden network package %q", pkg, imp)
|
|
34
34
|
}
|
|
35
35
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devrites",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.2",
|
|
4
4
|
"description": "DevRites: a disciplined senior-engineer workflow pack for Claude Code and Codex",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://github.com/ViktorsBaikers/DevRites#readme",
|