返回 DeepSeek-Reasonix
cli.go
1 // Package releaseasset downloads and verifies immutable Reasonix CLI release
2 // artifacts for a requested platform. It is used when a local Desktop or CLI
3 // needs to provision the remote `reasonix serve` binary without requiring
4 // Node/npm on the remote machine.
5 package releaseasset
6
7 import (
8 "archive/tar"
9 "bytes"
10 "compress/gzip"
11 "context"
12 "crypto/sha256"
13 "encoding/hex"
14 "errors"
15 "fmt"
16 "io"
17 "net/http"
18 "net/url"
19 "path"
20 "regexp"
21 "strings"
22 )
23
24 const (
25 cliReleaseBase = "https://github.com/esengine/DeepSeek-Reasonix/releases/download"
26 maxCLIArchiveBytes = int64(256 << 20)
27 maxCLIChecksumBytes = int64(1 << 20)
28 maxExtractedCLIBytes = int64(128 << 20)
29 )
30
31 var cliReleaseVersionPattern = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:preview|rc)\.(?:0|[1-9][0-9]*))?$`)
32
33 // DownloadCLI downloads the exact official CLI release for version and target,
34 // verifies it against SHA256SUMS from the same immutable release, and returns
35 // the extracted executable bytes. Remote Serve provisioning supports Linux and
36 // macOS hosts.
37 func DownloadCLI(ctx context.Context, client *http.Client, version, goos, goarch string) ([]byte, error) {
38 if !cliReleaseVersionPattern.MatchString(strings.TrimSpace(version)) {
39 return nil, fmt.Errorf("remote CLI download requires a released version, got %q", version)
40 }
41 if goos != "linux" && goos != "darwin" {
42 return nil, fmt.Errorf("remote CLI download does not support OS %q", goos)
43 }
44 if goarch != "amd64" && goarch != "arm64" {
45 return nil, fmt.Errorf("remote CLI download does not support architecture %q", goarch)
46 }
47 return downloadCLIFromBase(ctx, client, cliReleaseBase, version, goos, goarch, true)
48 }
49
50 func downloadCLIFromBase(ctx context.Context, client *http.Client, base, version, goos, goarch string, official bool) ([]byte, error) {
51 if client == nil {
52 return nil, errors.New("remote CLI download requires an HTTP client")
53 }
54 assetName := fmt.Sprintf("reasonix-%s-%s.tar.gz", goos, goarch)
55 releaseBase := strings.TrimRight(base, "/") + "/" + url.PathEscape(version) + "/"
56 archiveURL := releaseBase + assetName
57 checksumURL := releaseBase + "SHA256SUMS"
58
59 copyOfClient := *client
60 if official {
61 copyOfClient.CheckRedirect = validateOfficialRedirect
62 }
63 archive, err := fetchBounded(ctx, &copyOfClient, archiveURL, maxCLIArchiveBytes)
64 if err != nil {
65 return nil, fmt.Errorf("download %s: %w", assetName, err)
66 }
67 checksums, err := fetchBounded(ctx, &copyOfClient, checksumURL, maxCLIChecksumBytes)
68 if err != nil {
69 return nil, fmt.Errorf("download SHA256SUMS: %w", err)
70 }
71 if err := verifyChecksum(archive, assetName, checksums); err != nil {
72 return nil, err
73 }
74 binary, err := extractCLI(archive)
75 if err != nil {
76 return nil, fmt.Errorf("extract %s: %w", assetName, err)
77 }
78 return binary, nil
79 }
80
81 func fetchBounded(ctx context.Context, client *http.Client, rawURL string, limit int64) ([]byte, error) {
82 req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
83 if err != nil {
84 return nil, err
85 }
86 req.Header.Set("Accept", "application/octet-stream")
87 req.Header.Set("User-Agent", "reasonix-remote-bootstrap")
88 resp, err := client.Do(req)
89 if err != nil {
90 return nil, err
91 }
92 defer resp.Body.Close()
93 if resp.StatusCode != http.StatusOK {
94 return nil, fmt.Errorf("GET %s: %s", rawURL, resp.Status)
95 }
96 if resp.ContentLength > limit {
97 return nil, fmt.Errorf("asset exceeds %d-byte limit", limit)
98 }
99 data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
100 if err != nil {
101 return nil, err
102 }
103 if int64(len(data)) > limit {
104 return nil, fmt.Errorf("asset exceeds %d-byte limit", limit)
105 }
106 return data, nil
107 }
108
109 func verifyChecksum(data []byte, assetName string, checksums []byte) error {
110 want := ""
111 for _, line := range strings.Split(string(checksums), "\n") {
112 fields := strings.Fields(line)
113 if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != assetName {
114 continue
115 }
116 if want != "" {
117 return fmt.Errorf("SHA256SUMS contains duplicate entries for %s", assetName)
118 }
119 want = strings.ToLower(fields[0])
120 }
121 if len(want) != sha256.Size*2 {
122 return fmt.Errorf("SHA256SUMS has no valid entry for %s", assetName)
123 }
124 if _, err := hex.DecodeString(want); err != nil {
125 return fmt.Errorf("SHA256SUMS has an invalid digest for %s", assetName)
126 }
127 got := sha256.Sum256(data)
128 if hex.EncodeToString(got[:]) != want {
129 return fmt.Errorf("SHA-256 mismatch for %s", assetName)
130 }
131 return nil
132 }
133
134 func extractCLI(archive []byte) ([]byte, error) {
135 gz, err := gzip.NewReader(bytes.NewReader(archive))
136 if err != nil {
137 return nil, err
138 }
139 defer gz.Close()
140 tr := tar.NewReader(gz)
141 var binary []byte
142 for {
143 header, err := tr.Next()
144 if errors.Is(err, io.EOF) {
145 break
146 }
147 if err != nil {
148 return nil, err
149 }
150 if path.Base(path.Clean(header.Name)) != "reasonix" {
151 continue
152 }
153 if header.Typeflag != tar.TypeReg || header.Size <= 0 || header.Size > maxExtractedCLIBytes {
154 return nil, errors.New("reasonix archive entry is not a bounded regular file")
155 }
156 if binary != nil {
157 return nil, errors.New("reasonix archive contains duplicate binaries")
158 }
159 binary, err = io.ReadAll(io.LimitReader(tr, maxExtractedCLIBytes+1))
160 if err != nil {
161 return nil, err
162 }
163 if int64(len(binary)) != header.Size {
164 return nil, errors.New("reasonix archive entry size mismatch")
165 }
166 }
167 if len(binary) == 0 {
168 return nil, errors.New("reasonix binary not found in archive")
169 }
170 return binary, nil
171 }
172
173 func validateOfficialRedirect(req *http.Request, via []*http.Request) error {
174 if len(via) >= 10 {
175 return errors.New("remote CLI download stopped after 10 redirects")
176 }
177 if req == nil || req.URL == nil || !strings.EqualFold(req.URL.Scheme, "https") || req.URL.User != nil || req.URL.Port() != "" {
178 return errors.New("remote CLI download refused an unsafe redirect")
179 }
180 host := strings.ToLower(strings.TrimSuffix(req.URL.Hostname(), "."))
181 if host != "github.com" && !strings.HasSuffix(host, ".githubusercontent.com") {
182 return fmt.Errorf("remote CLI download refused redirect host %q", req.URL.Host)
183 }
184 return nil
185 }
186
186 lines GO