devrites 4.0.1 → 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 +9 -0
- package/README.md +23 -18
- 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 +30 -17
- package/docs/command-map.md +6 -4
- package/docs/engine/commands.md +14 -12
- package/docs/release.md +7 -7
- package/engine/internal/install/install.go +95 -6
- package/engine/internal/install/install_test.go +111 -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
|
@@ -2,6 +2,7 @@ package install
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"bytes"
|
|
5
|
+
"context"
|
|
5
6
|
"crypto/sha256"
|
|
6
7
|
"fmt"
|
|
7
8
|
"os"
|
|
@@ -14,6 +15,7 @@ import (
|
|
|
14
15
|
"time"
|
|
15
16
|
|
|
16
17
|
"github.com/devrites/devrites/internal/hostpack"
|
|
18
|
+
"github.com/devrites/devrites/internal/release"
|
|
17
19
|
"github.com/devrites/devrites/internal/testutil"
|
|
18
20
|
)
|
|
19
21
|
|
|
@@ -484,6 +486,36 @@ func buildVersionBinary(t *testing.T, version string) string {
|
|
|
484
486
|
return binary
|
|
485
487
|
}
|
|
486
488
|
|
|
489
|
+
func buildUpdateHandoffBinary(t *testing.T, version string) string {
|
|
490
|
+
t.Helper()
|
|
491
|
+
dir := t.TempDir()
|
|
492
|
+
source := filepath.Join(dir, "main.go")
|
|
493
|
+
testutil.WriteFile(t, source, fmt.Sprintf(`package main
|
|
494
|
+
import (
|
|
495
|
+
"fmt"
|
|
496
|
+
"os"
|
|
497
|
+
"strings"
|
|
498
|
+
)
|
|
499
|
+
func main() {
|
|
500
|
+
if len(os.Args) == 2 && os.Args[1] == "version" {
|
|
501
|
+
fmt.Println(%q)
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
output := strings.Join(os.Args[1:], "\n") + "\n" +
|
|
505
|
+
"DEVRITES_UPDATE_HANDOFF=" + os.Getenv("DEVRITES_UPDATE_HANDOFF") + "\n" +
|
|
506
|
+
"DEVRITES_ENGINE_CLI=" + os.Getenv("DEVRITES_ENGINE_CLI") + "\n"
|
|
507
|
+
if err := os.WriteFile(os.Getenv("DEVRITES_TEST_UPDATE_HANDOFF"), []byte(output), 0600); err != nil {
|
|
508
|
+
panic(err)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
`, version))
|
|
512
|
+
binary := filepath.Join(dir, engineBinaryName())
|
|
513
|
+
if out, err := exec.Command("go", "build", "-o", binary, source).CombinedOutput(); err != nil {
|
|
514
|
+
t.Fatalf("build update handoff engine: %v\n%s", err, out)
|
|
515
|
+
}
|
|
516
|
+
return binary
|
|
517
|
+
}
|
|
518
|
+
|
|
487
519
|
func TestBinaryCandidatesSkipsUnsetBinDir(t *testing.T) {
|
|
488
520
|
t.Setenv("DEVRITES_BIN_DIR", "")
|
|
489
521
|
|
|
@@ -531,6 +563,7 @@ func TestUpdateInstallsLocalPayload(t *testing.T) {
|
|
|
531
563
|
opts.Stderr = &bytes.Buffer{}
|
|
532
564
|
check := opts
|
|
533
565
|
check.UpdateCheck = true
|
|
566
|
+
check.PayloadDir = ""
|
|
534
567
|
if err := Apply(check); err == nil || !strings.Contains(err.Error(), "update available: 1.0.0 -> 2.0.0") {
|
|
535
568
|
t.Fatalf("update check error = %v, want local candidate report", err)
|
|
536
569
|
}
|
|
@@ -552,6 +585,84 @@ func TestUpdateInstallsLocalPayload(t *testing.T) {
|
|
|
552
585
|
}
|
|
553
586
|
}
|
|
554
587
|
|
|
588
|
+
func TestUpdateAcquiresLatestReleaseAndHandsOff(t *testing.T) {
|
|
589
|
+
t.Setenv("DEVRITES_NO_BINARY", "1")
|
|
590
|
+
target := t.TempDir()
|
|
591
|
+
runInstall(t, target, testPayload(t), func(o *Options) {
|
|
592
|
+
o.SourceDir = testSource(t, "1.0.0")
|
|
593
|
+
})
|
|
594
|
+
source := testSource(t, "2.0.0")
|
|
595
|
+
payload := testPayload(t)
|
|
596
|
+
engine := buildUpdateHandoffBinary(t, "2.0.0")
|
|
597
|
+
marker := filepath.Join(t.TempDir(), "handoff.txt")
|
|
598
|
+
t.Setenv("DEVRITES_TEST_UPDATE_HANDOFF", marker)
|
|
599
|
+
|
|
600
|
+
oldResolve, oldAcquire := resolveLatestRelease, acquireRelease
|
|
601
|
+
resolveLatestRelease = func(context.Context, string) (string, error) { return "v2.0.0", nil }
|
|
602
|
+
cleaned := false
|
|
603
|
+
acquireRelease = func(context.Context, string, string) (release.Candidate, func(), error) {
|
|
604
|
+
return release.Candidate{
|
|
605
|
+
SourceDir: source,
|
|
606
|
+
PayloadDir: payload,
|
|
607
|
+
EnginePath: engine,
|
|
608
|
+
BundleURL: "https://example.invalid/devrites-v2.0.0.tar.gz",
|
|
609
|
+
}, func() { cleaned = true }, nil
|
|
610
|
+
}
|
|
611
|
+
t.Cleanup(func() {
|
|
612
|
+
resolveLatestRelease, acquireRelease = oldResolve, oldAcquire
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
var stdout, stderr bytes.Buffer
|
|
616
|
+
opts := DefaultOptions(ModeUpdate)
|
|
617
|
+
opts.Target = target
|
|
618
|
+
opts.Stdout = &stdout
|
|
619
|
+
opts.Stderr = &stderr
|
|
620
|
+
if err := Apply(opts); err != nil {
|
|
621
|
+
t.Fatalf("remote update: %v\n%s", err, stderr.String())
|
|
622
|
+
}
|
|
623
|
+
if !cleaned {
|
|
624
|
+
t.Fatal("remote update did not clean acquired release")
|
|
625
|
+
}
|
|
626
|
+
handoff := testutil.ReadFile(t, marker)
|
|
627
|
+
for _, want := range []string{
|
|
628
|
+
"update\n",
|
|
629
|
+
"--target\n" + target + "\n",
|
|
630
|
+
"--source-dir\n" + source + "\n",
|
|
631
|
+
"--payload-dir\n" + payload + "\n",
|
|
632
|
+
"DEVRITES_UPDATE_HANDOFF=1\n",
|
|
633
|
+
"DEVRITES_ENGINE_CLI=" + engine + "\n",
|
|
634
|
+
} {
|
|
635
|
+
if !strings.Contains(handoff, want) {
|
|
636
|
+
t.Fatalf("handoff missing %q:\n%s", want, handoff)
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if !strings.Contains(stdout.String(), "latest: 2.0.0") || !strings.Contains(stdout.String(), "bundle: https://example.invalid/") {
|
|
640
|
+
t.Fatalf("remote update output:\n%s", stdout.String())
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
func TestRemoteUpdateCheckDoesNotDownload(t *testing.T) {
|
|
645
|
+
target := t.TempDir()
|
|
646
|
+
testutil.WriteFile(t, filepath.Join(target, ManifestName), "# devrites-version: 1.0.0\n")
|
|
647
|
+
oldResolve, oldAcquire := resolveLatestRelease, acquireRelease
|
|
648
|
+
resolveLatestRelease = func(context.Context, string) (string, error) { return "v2.0.0", nil }
|
|
649
|
+
acquireRelease = func(context.Context, string, string) (release.Candidate, func(), error) {
|
|
650
|
+
t.Fatal("--check downloaded release assets")
|
|
651
|
+
return release.Candidate{}, func() {}, nil
|
|
652
|
+
}
|
|
653
|
+
t.Cleanup(func() {
|
|
654
|
+
resolveLatestRelease, acquireRelease = oldResolve, oldAcquire
|
|
655
|
+
})
|
|
656
|
+
opts := DefaultOptions(ModeUpdate)
|
|
657
|
+
opts.Target = target
|
|
658
|
+
opts.UpdateCheck = true
|
|
659
|
+
opts.Stdout = &bytes.Buffer{}
|
|
660
|
+
opts.Stderr = &bytes.Buffer{}
|
|
661
|
+
if err := Apply(opts); err == nil || !strings.Contains(err.Error(), "update available: 1.0.0 -> 2.0.0") {
|
|
662
|
+
t.Fatalf("remote update check error = %v", err)
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
555
666
|
func TestUpdateCleansLegacyCodexHooks(t *testing.T) {
|
|
556
667
|
t.Setenv("DEVRITES_NO_BINARY", "1")
|
|
557
668
|
payload := testPayload(t)
|
|
@@ -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
|
+
}
|