返回 DeepSeek-Reasonix
release_unit.go
根目录 / desktop / cmd / update-helper / release_unit.go
1 package main
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12
13 "reasonix/desktop/internal/update"
14 "reasonix/internal/installlayout"
15 "reasonix/internal/repair"
16 )
17
18 // maxWindowsPayloadMetadataSize bounds the signed manifest read; a schema 2
19 // manifest lists every shell tree file, so it is well above the flat size.
20 const maxWindowsPayloadMetadataSize = 1 << 20
21
22 var verifyWindowsPayloadManifestFn = update.Verify
23
24 type stagedFileUpdateMember struct {
25 targetPath string
26 content []byte
27 mode os.FileMode
28 }
29
30 // loadWindowsStagedReleaseUnit validates and reads the complete NSIS payload
31 // before any live release-unit member is moved. An existing Reasonix.exe is the
32 // portable alias of reasonix-launcher.exe and reuses those staged bytes; an
33 // installed package that did not have the alias remains unchanged.
34 func loadWindowsStagedReleaseUnit(claimed *repair.UpdateTransaction, stagingDir string) ([]stagedFileUpdateMember, error) {
35 if claimed == nil || claimed.TargetKind != "file" || len(claimed.Files) == 0 {
36 return nil, fmt.Errorf("load staged release unit: transaction identity is incomplete")
37 }
38 if err := validateWindowsClaimedReleaseUnit(claimed); err != nil {
39 return nil, fmt.Errorf("load staged release unit: %w", err)
40 }
41 stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
42 if stagingDir == "" || stagingDir == "." || !filepath.IsAbs(stagingDir) {
43 return nil, fmt.Errorf("load staged release unit: staging directory is invalid")
44 }
45 info, err := os.Lstat(stagingDir)
46 if err != nil {
47 return nil, fmt.Errorf("load staged release unit: inspect staging directory: %w", err)
48 }
49 if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
50 return nil, fmt.Errorf("load staged release unit: staging path is not a directory")
51 }
52 hashes, err := loadWindowsPayloadManifest(stagingDir, claimed.ToVersion)
53 if err != nil {
54 return nil, fmt.Errorf("load staged release unit: %w", err)
55 }
56
57 contents := make(map[string][]byte)
58 members := make([]stagedFileUpdateMember, 0, len(claimed.Files))
59 seenTargets := make(map[string]struct{}, len(claimed.Files))
60 for _, file := range claimed.Files {
61 targetPath := filepath.Clean(strings.TrimSpace(file.TargetPath))
62 targetKey := strings.ToLower(targetPath)
63 if targetPath == "" || targetPath == "." {
64 return nil, fmt.Errorf("load staged release unit: target path is invalid")
65 }
66 if _, ok := seenTargets[targetKey]; ok {
67 return nil, fmt.Errorf("load staged release unit: duplicate target %s", filepath.Base(targetPath))
68 }
69 seenTargets[targetKey] = struct{}{}
70 if strings.EqualFold(filepath.Base(targetPath), "Reasonix.exe") && file.MissingBefore {
71 continue
72 }
73
74 sourceName, err := windowsStagedSourceName(filepath.Base(targetPath))
75 if err != nil {
76 return nil, err
77 }
78 sourcePath := filepath.Join(stagingDir, sourceName)
79 content, ok := contents[sourceName]
80 if !ok {
81 sourceInfo, statErr := os.Lstat(sourcePath)
82 if statErr != nil {
83 return nil, fmt.Errorf("load staged release unit: inspect %s: %w", sourceName, statErr)
84 }
85 if !sourceInfo.Mode().IsRegular() {
86 return nil, fmt.Errorf("load staged release unit: %s is not a regular file", sourceName)
87 }
88 content, err = readVerifiedWindowsStagedPayloadFn(sourcePath)
89 if err != nil {
90 return nil, fmt.Errorf("load staged release unit: read %s: %w", sourceName, err)
91 }
92 if !strings.EqualFold(update.WindowsPayloadSHA256(content), hashes[sourceName]) {
93 return nil, fmt.Errorf("load staged release unit: %s does not match the signed release manifest", sourceName)
94 }
95 contents[sourceName] = content
96 }
97 members = append(members, stagedFileUpdateMember{
98 targetPath: targetPath,
99 content: content,
100 mode: 0o700,
101 })
102 }
103
104 // Publish the running desktop last. If an earlier member fails, the old
105 // desktop remains the executable entry point that can report/retry recovery.
106 sort.SliceStable(members, func(i, j int) bool {
107 iPrimary := strings.EqualFold(members[i].targetPath, claimed.TargetPath)
108 jPrimary := strings.EqualFold(members[j].targetPath, claimed.TargetPath)
109 return !iPrimary && jPrimary
110 })
111 return members, nil
112 }
113
114 func validateWindowsClaimedReleaseUnit(claimed *repair.UpdateTransaction) error {
115 if claimed == nil ||
116 !strings.EqualFold(filepath.Base(claimed.TargetPath), "reasonix-desktop.exe") {
117 return fmt.Errorf("claimed release unit primary executable is invalid")
118 }
119 required := map[string]bool{
120 "reasonix-desktop.exe": false,
121 "reasonix-guard.exe": false,
122 "reasonix-launcher.exe": false,
123 "reasonix-update-helper.exe": false,
124 "reasonix-cli.exe": false,
125 "reasonix.exe": false,
126 }
127 installDir := filepath.Clean(filepath.Dir(claimed.TargetPath))
128 for _, file := range claimed.Files {
129 target := filepath.Clean(strings.TrimSpace(file.TargetPath))
130 if target == "" || target == "." ||
131 !strings.EqualFold(filepath.Dir(target), installDir) {
132 return fmt.Errorf("claimed release unit target is outside the installation directory")
133 }
134 name := strings.ToLower(filepath.Base(target))
135 seen, ok := required[name]
136 if !ok {
137 return fmt.Errorf("claimed release unit contains an unexpected target")
138 }
139 if seen {
140 return fmt.Errorf("claimed release unit contains a duplicate target")
141 }
142 required[name] = true
143 }
144 for name, seen := range required {
145 if !seen {
146 return fmt.Errorf("claimed release unit omits %s", name)
147 }
148 }
149 if len(claimed.Files) != len(required) {
150 return fmt.Errorf("claimed release unit contains an unexpected target")
151 }
152 return nil
153 }
154
155 func loadWindowsPayloadManifest(stagingDir, expectedVersion string) (map[string]string, error) {
156 manifest, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadManifestName))
157 if err != nil {
158 return nil, fmt.Errorf("read signed release manifest: %w", err)
159 }
160 signature, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadSignatureName))
161 if err != nil {
162 return nil, fmt.Errorf("read signed release manifest signature: %w", err)
163 }
164 if err := verifyWindowsPayloadManifestFn(manifest, signature); err != nil {
165 return nil, fmt.Errorf("verify signed release manifest: %w", err)
166 }
167 hashes, err := update.DecodeWindowsPayloadManifest(manifest, expectedVersion)
168 if err != nil {
169 return nil, err
170 }
171 return hashes, nil
172 }
173
174 func readWindowsPayloadMetadata(path string) ([]byte, error) {
175 pathInfo, err := os.Lstat(path)
176 if err != nil {
177 return nil, err
178 }
179 if !pathInfo.Mode().IsRegular() {
180 return nil, fmt.Errorf("%s is not a regular file", filepath.Base(path))
181 }
182 file, err := os.Open(path)
183 if err != nil {
184 return nil, err
185 }
186 defer file.Close()
187 info, err := file.Stat()
188 if err != nil {
189 return nil, err
190 }
191 if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWindowsPayloadMetadataSize {
192 return nil, fmt.Errorf("%s is not a bounded regular file", filepath.Base(path))
193 }
194 if !os.SameFile(pathInfo, info) {
195 return nil, fmt.Errorf("%s changed before it was opened", filepath.Base(path))
196 }
197 data, err := io.ReadAll(io.LimitReader(file, maxWindowsPayloadMetadataSize+1))
198 if err != nil {
199 return nil, err
200 }
201 if len(data) == 0 || len(data) > maxWindowsPayloadMetadataSize {
202 return nil, fmt.Errorf("%s changed size while it was read", filepath.Base(path))
203 }
204 return data, nil
205 }
206
207 func windowsStagedSourceName(targetBase string) (string, error) {
208 switch strings.ToLower(strings.TrimSpace(targetBase)) {
209 case "reasonix-desktop.exe":
210 return "reasonix-desktop.exe", nil
211 case "reasonix-guard.exe":
212 return "reasonix-guard.exe", nil
213 case "reasonix-launcher.exe":
214 return "reasonix-launcher.exe", nil
215 case "reasonix-update-helper.exe":
216 return "reasonix-update-helper.exe", nil
217 case "reasonix-cli.exe":
218 return "reasonix-cli.exe", nil
219 case "reasonix.exe":
220 return "reasonix-launcher.exe", nil
221 default:
222 return "", fmt.Errorf("load staged release unit: unsupported target %q", targetBase)
223 }
224 }
225
226 func publishLoadedFileUpdateReleaseUnit(
227 claimed *repair.UpdateTransaction,
228 members []stagedFileUpdateMember,
229 publish func(*repair.UpdateTransaction, string, []byte, os.FileMode) (repair.FileUpdateInstallReceipt, error),
230 ) ([]repair.FileUpdateInstallReceipt, error) {
231 if publish == nil || len(members) == 0 {
232 return nil, fmt.Errorf("publish staged release unit: payload is incomplete")
233 }
234 receipts := make([]repair.FileUpdateInstallReceipt, 0, len(members))
235 for _, member := range members {
236 receipt, err := publish(claimed, member.targetPath, member.content, member.mode)
237 if err != nil {
238 return receipts, fmt.Errorf("publish staged release unit %s: %w", filepath.Base(member.targetPath), err)
239 }
240 receipts = append(receipts, receipt)
241 }
242 return receipts, nil
243 }
244
245 // stagedWindowsPayloadMembers binds each named staged file to its signed
246 // digest before the activator copies it into the version tree.
247 func stagedWindowsPayloadMembers(stagingDir string, hashes map[string]string, names []string) ([]installlayout.Member, error) {
248 members := make([]installlayout.Member, 0, len(names))
249 for _, name := range names {
250 src := filepath.Join(stagingDir, filepath.FromSlash(name))
251 if err := verifyStagedWindowsPayloadFile(src, hashes[name]); err != nil {
252 return nil, fmt.Errorf("staged %s: %w", name, err)
253 }
254 members = append(members, installlayout.Member{Name: name, Path: src, Mode: 0o700})
255 }
256 return members, nil
257 }
258
259 func verifyStagedWindowsPayloadFile(path, wantSHA256 string) error {
260 info, err := os.Lstat(path)
261 if err != nil {
262 return err
263 }
264 if !info.Mode().IsRegular() {
265 return fmt.Errorf("is not a regular file")
266 }
267 file, err := os.Open(path)
268 if err != nil {
269 return err
270 }
271 defer file.Close()
272 digest := sha256.New()
273 if _, err := io.Copy(digest, file); err != nil {
274 return err
275 }
276 if !strings.EqualFold(hex.EncodeToString(digest.Sum(nil)), strings.TrimSpace(wantSHA256)) {
277 return fmt.Errorf("does not match the signed release manifest")
278 }
279 return nil
280 }
281
281 lines GO