返回 DeepSeek-Reasonix
activate.go
根目录 / internal / installlayout / activate.go
1 package installlayout
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "path"
12 "path/filepath"
13 "runtime"
14 "slices"
15 "strings"
16 "time"
17
18 filelock "reasonix/internal/identitylock"
19 )
20
21 const activationLockName = ".reasonix-activate.lock"
22
23 // Member is one file to publish into a version directory.
24 type Member struct {
25 // Name is a forward-slash path relative to the version directory: a
26 // whitelisted base name, or app/... for the shell tree.
27 Name string
28 // Path is a regular file on the same volume as the install root (preferred)
29 // or any readable regular file that will be copied.
30 Path string
31 // Mode is the destination file mode. Zero defaults to 0o755.
32 Mode os.FileMode
33 }
34
35 // WindowsRootEntrySources maps verified payload files to installed entry points.
36 // The payload keeps its historical names for already installed update helpers.
37 type WindowsRootEntrySources struct {
38 LauncherPath string
39 CLIEntryPath string
40 }
41
42 // ActivationRequest describes a one-shot version publish + pointer swap.
43 type ActivationRequest struct {
44 InstallRoot string
45 Version string
46 // RequestID isolates staging directories so concurrent/failed attempts never
47 // share a global pending file.
48 RequestID string
49 Members []Member
50 // RequiredNames, when non-empty, is the exact member whitelist and may list
51 // app/ tree members. Defaults to the platform desktop release unit.
52 RequiredNames []string
53 // RootMembers are stable entry points published at InstallRoot before the
54 // current.json commit. They are rolled back if any later step fails.
55 RootMembers []Member
56 // RequiredRootNames is the exact root-entry whitelist when RootMembers is
57 // non-empty. Callers must provide it explicitly.
58 RequiredRootNames []string
59 // WindowsRootEntries selects canonical entries and preserves an existing
60 // legacy launcher under the activation lock. Mutually exclusive with the
61 // explicit RootMembers/RequiredRootNames fields.
62 WindowsRootEntries *WindowsRootEntrySources
63 // CheckProcesses runs under the activation lock before file replacement and
64 // immediately before pointer publication. The caller owns its coordination
65 // lock first. A late failure rolls back the staged version and root entries.
66 CheckProcesses func() error
67 }
68
69 // AllowedVersionMembers returns the default files inside versions/<version>/.
70 func AllowedVersionMembers() []string {
71 names := []string{
72 DesktopBinaryName(),
73 CLIBinaryName(),
74 UpdateHelperBinaryName(),
75 }
76 return names
77 }
78
79 // StagingDirName builds versions/.staging-<version>-<nonce> for one request.
80 func StagingDirName(version, nonce string) string {
81 version = strings.TrimSpace(version)
82 nonce = strings.TrimSpace(nonce)
83 return fmt.Sprintf(".staging-%s-%s", version, nonce)
84 }
85
86 // ActivateVersion copies members into a unique staging directory on the same
87 // volume, validates the whitelist, renames staging to versions/<version>, and
88 // finally swaps current.json. Any failure before the pointer swap leaves the
89 // previous active version unchanged.
90 func ActivateVersion(req ActivationRequest) error {
91 return activateVersion(req, filelock.Acquire)
92 }
93
94 func activateVersion(req ActivationRequest, acquireLock func(context.Context, string) (func(), error)) error {
95 installRoot, required, err := validateActivationRequest(req)
96 if err != nil {
97 return err
98 }
99
100 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
101 defer cancel()
102 unlock, err := acquireLock(ctx, filepath.Join(installRoot, activationLockName))
103 if err != nil {
104 return fmt.Errorf("installlayout: acquire activation lock: %w", err)
105 }
106 defer unlock()
107
108 if err := resolveWindowsRootEntries(&req, installRoot); err != nil {
109 return err
110 }
111
112 versionsRoot := filepath.Join(installRoot, VersionsDirName)
113 if err := os.MkdirAll(versionsRoot, 0o755); err != nil {
114 return fmt.Errorf("installlayout: create versions dir: %w", err)
115 }
116 if err := rejectSymlinkPathComponents(installRoot, VersionsDirName); err != nil {
117 return err
118 }
119
120 nonce, err := stagingNonce(req.RequestID)
121 if err != nil {
122 return err
123 }
124 stagingName := StagingDirName(req.Version, nonce)
125 stagingPath := filepath.Join(versionsRoot, stagingName)
126 rootStagingPath := filepath.Join(versionsRoot, ".root-"+stagingName)
127 // Always start clean for this request id/nonce: a retried installer reuses
128 // both names, so a lingering scanner lock here would fail every attempt.
129 _ = removeAllRetry(stagingPath)
130 _ = removeAllRetry(rootStagingPath)
131 if err := os.Mkdir(stagingPath, 0o755); err != nil {
132 return fmt.Errorf("installlayout: create staging dir: %w", err)
133 }
134 committed := false
135 defer func() {
136 if !committed {
137 _ = os.RemoveAll(stagingPath)
138 }
139 _ = os.RemoveAll(rootStagingPath)
140 }()
141
142 for _, m := range req.Members {
143 if err := publishMember(stagingPath, m); err != nil {
144 return err
145 }
146 }
147 if err := verifyStagedMembers(stagingPath, required); err != nil {
148 return err
149 }
150 if len(req.RootMembers) > 0 {
151 if err := os.Mkdir(rootStagingPath, 0o755); err != nil {
152 return fmt.Errorf("installlayout: create root staging dir: %w", err)
153 }
154 for _, m := range req.RootMembers {
155 if err := publishMember(rootStagingPath, m); err != nil {
156 return fmt.Errorf("installlayout: stage root entry: %w", err)
157 }
158 }
159 }
160
161 if req.CheckProcesses != nil {
162 if err := req.CheckProcesses(); err != nil {
163 return err
164 }
165 }
166 finalRel := VersionDirRelative(req.Version)
167 finalPath := filepath.Join(installRoot, filepath.FromSlash(finalRel))
168 var versionBackup string
169 if _, err := os.Lstat(finalPath); err == nil {
170 // A previous partial publish of the same version is replaced only from
171 // staging after validation. Never swap current.json first.
172 versionBackup = finalPath + ".replaced-" + nonce
173 _ = removeAllRetry(versionBackup)
174 if err := renameRetry(finalPath, versionBackup); err != nil {
175 return fmt.Errorf("installlayout: displace existing version dir: %w", err)
176 }
177 } else if !os.IsNotExist(err) {
178 return fmt.Errorf("installlayout: inspect version dir: %w", err)
179 }
180
181 if err := renameRetry(stagingPath, finalPath); err != nil {
182 if versionBackup != "" {
183 _ = renameRetry(versionBackup, finalPath)
184 }
185 return fmt.Errorf("installlayout: publish version directory: %w", err)
186 }
187 rollbackVersion := versionRollback(finalPath, versionBackup)
188
189 rollbackRoots, commitRoots, err := publishRootEntries(installRoot, rootStagingPath, req.RootMembers)
190 if err != nil {
191 if rollbackErr := rollbackVersion(); rollbackErr != nil {
192 return errors.Join(err, fmt.Errorf("installlayout: restore version after root publish failure: %w", rollbackErr))
193 }
194 return err
195 }
196
197 ptr := CurrentPointer{
198 SchemaVersion: CurrentSchemaVersion,
199 ActiveVersion: req.Version,
200 ActiveDir: finalRel,
201 }
202 if req.CheckProcesses != nil {
203 if err := req.CheckProcesses(); err != nil {
204 return errors.Join(err, rollbackRoots(), rollbackVersion())
205 }
206 }
207 if err := WriteCurrent(installRoot, ptr); err != nil {
208 rootErr := rollbackRoots()
209 versionErr := rollbackVersion()
210 return errors.Join(
211 fmt.Errorf("installlayout: write current.json: %w", err),
212 wrapRollbackError("restore root entries", rootErr),
213 wrapRollbackError("restore version directory", versionErr),
214 )
215 }
216 committed = true
217 commitRoots()
218 if versionBackup != "" {
219 _ = removeAllRetry(versionBackup)
220 }
221 return nil
222 }
223
224 func validateActivationRequest(req ActivationRequest) (string, []string, error) {
225 if req.WindowsRootEntries != nil && (len(req.RootMembers) != 0 || len(req.RequiredRootNames) != 0) {
226 return "", nil, fmt.Errorf("installlayout: Windows root entries and explicit root members are mutually exclusive")
227 }
228 installRoot, err := cleanInstallRoot(req.InstallRoot)
229 if err != nil {
230 return "", nil, err
231 }
232 if err := ValidateVersionName(req.Version); err != nil {
233 return "", nil, err
234 }
235 required := req.RequiredNames
236 if len(required) == 0 {
237 required = AllowedVersionMembers()
238 }
239 if err := validateMembers(req.Members, required, true); err != nil {
240 return "", nil, err
241 }
242 if len(req.RootMembers) > 0 {
243 if len(req.RequiredRootNames) == 0 {
244 return "", nil, fmt.Errorf("installlayout: root member whitelist is required")
245 }
246 if err := validateMembers(req.RootMembers, req.RequiredRootNames, false); err != nil {
247 return "", nil, fmt.Errorf("installlayout: root entries: %w", err)
248 }
249 } else if len(req.RequiredRootNames) > 0 {
250 return "", nil, fmt.Errorf("installlayout: root member whitelist provided without root members")
251 }
252 return installRoot, required, nil
253 }
254
255 func resolveWindowsRootEntries(req *ActivationRequest, installRoot string) error {
256 if req.WindowsRootEntries == nil {
257 return nil
258 }
259 // Inspect only after acquiring the same lock that protects publication.
260 // A concurrent activation must not make the preserve decision stale.
261 rootMembers, requiredRootNames, err := windowsRootMembers(installRoot, *req.WindowsRootEntries)
262 if err != nil {
263 return err
264 }
265 if err := validateMembers(rootMembers, requiredRootNames, false); err != nil {
266 return fmt.Errorf("installlayout: Windows root entries: %w", err)
267 }
268 req.RootMembers = rootMembers
269 req.RequiredRootNames = requiredRootNames
270 return nil
271 }
272
273 func windowsRootMembers(root string, sources WindowsRootEntrySources) ([]Member, []string, error) {
274 members := []Member{
275 {Name: "Reasonix.exe", Path: sources.LauncherPath},
276 {Name: "reasonix-cli.exe", Path: sources.CLIEntryPath},
277 }
278 names := []string{"Reasonix.exe", "reasonix-cli.exe"}
279 const legacy = "reasonix-launcher.exe"
280 info, err := os.Lstat(filepath.Join(root, legacy))
281 if err == nil {
282 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
283 return nil, nil, fmt.Errorf("installlayout: legacy launcher is not a regular file")
284 }
285 members = append(members, Member{Name: legacy, Path: sources.LauncherPath})
286 names = append(names, legacy)
287 } else if !os.IsNotExist(err) {
288 return nil, nil, fmt.Errorf("installlayout: inspect legacy launcher: %w", err)
289 }
290 return members, names, nil
291 }
292
293 func versionRollback(finalPath, versionBackup string) func() error {
294 return func() error {
295 var rollbackErr error
296 if err := removeAllRetry(finalPath); err != nil {
297 rollbackErr = errors.Join(rollbackErr, err)
298 }
299 if versionBackup != "" {
300 if err := renameRetry(versionBackup, finalPath); err != nil {
301 rollbackErr = errors.Join(rollbackErr, err)
302 }
303 }
304 return rollbackErr
305 }
306 }
307
308 func publishRootEntries(installRoot, stagingRoot string, members []Member) (rollback func() error, commit func(), err error) {
309 if len(members) == 0 {
310 return func() error { return nil }, func() {}, nil
311 }
312 backupRoot := filepath.Join(stagingRoot, ".backups")
313 if err := os.MkdirAll(backupRoot, 0o700); err != nil {
314 return nil, nil, fmt.Errorf("installlayout: create root backup staging: %w", err)
315 }
316 type replacement struct {
317 destination string
318 backup string
319 hadOriginal bool
320 }
321 replacements := make([]replacement, 0, len(members))
322 rollbackFn := func() error {
323 var rollbackErr error
324 for _, v := range slices.Backward(replacements) {
325 r := v
326 if err := removeRetry(r.destination); err != nil && !os.IsNotExist(err) {
327 rollbackErr = errors.Join(rollbackErr, err)
328 }
329 if r.hadOriginal {
330 if err := renameRetry(r.backup, r.destination); err != nil {
331 rollbackErr = errors.Join(rollbackErr, err)
332 }
333 }
334 }
335 return rollbackErr
336 }
337 for _, m := range members {
338 name := filepath.Base(m.Name)
339 source := filepath.Join(stagingRoot, name)
340 destination := filepath.Join(installRoot, name)
341 r := replacement{destination: destination, backup: filepath.Join(backupRoot, name)}
342 if info, statErr := os.Lstat(destination); statErr == nil {
343 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
344 _ = rollbackFn()
345 return nil, nil, fmt.Errorf("installlayout: root entry %s is not a regular file", name)
346 }
347 if err := renameRetry(destination, r.backup); err != nil {
348 _ = rollbackFn()
349 return nil, nil, fmt.Errorf("installlayout: back up root entry %s: %w", name, err)
350 }
351 r.hadOriginal = true
352 } else if !os.IsNotExist(statErr) {
353 _ = rollbackFn()
354 return nil, nil, fmt.Errorf("installlayout: inspect root entry %s: %w", name, statErr)
355 }
356 replacements = append(replacements, r)
357 if err := renameRetry(source, destination); err != nil {
358 rollbackErr := rollbackFn()
359 return nil, nil, errors.Join(
360 fmt.Errorf("installlayout: publish root entry %s: %w", name, err),
361 wrapRollbackError("restore root entries", rollbackErr),
362 )
363 }
364 }
365 return rollbackFn, func() { _ = removeAllRetry(backupRoot) }, nil
366 }
367
368 func wrapRollbackError(label string, err error) error {
369 if err == nil {
370 return nil
371 }
372 return fmt.Errorf("installlayout: %s: %w", label, err)
373 }
374
375 // ValidateMemberName accepts a base name or a forward-slash path under app/
376 // whose segments are never empty, ".", "..", or contain a backslash or colon.
377 func ValidateMemberName(name string) error {
378 invalid := fmt.Errorf("installlayout: member name %q is invalid", name)
379 if name == "" || name != strings.TrimSpace(name) || strings.ContainsAny(name, "\\:\x00") {
380 return invalid
381 }
382 if path.IsAbs(name) || filepath.IsAbs(name) || path.Clean(name) != name {
383 return invalid
384 }
385 for part := range strings.SplitSeq(name, "/") {
386 if part == "" || part == "." || part == ".." {
387 return invalid
388 }
389 }
390 if strings.Contains(name, "/") && !strings.HasPrefix(name, AppShellDirName+"/") {
391 return fmt.Errorf("installlayout: member %q must be under %s/", name, AppShellDirName)
392 }
393 return nil
394 }
395
396 func validateMembers(members []Member, required []string, allowTree bool) error {
397 if len(members) == 0 {
398 return fmt.Errorf("installlayout: no members to activate")
399 }
400 allowed := make(map[string]struct{}, len(required))
401 for _, name := range required {
402 allowed[normalizeMemberName(name)] = struct{}{}
403 }
404 seen := make(map[string]struct{}, len(members))
405 for _, m := range members {
406 if err := ValidateMemberName(m.Name); err != nil {
407 return err
408 }
409 name := normalizeMemberName(m.Name)
410 if !allowTree && strings.Contains(name, "/") {
411 return fmt.Errorf("installlayout: member %q must be a base name", m.Name)
412 }
413 if _, ok := allowed[name]; !ok {
414 return fmt.Errorf("installlayout: member %q is not allowed", m.Name)
415 }
416 if _, dup := seen[name]; dup {
417 return fmt.Errorf("installlayout: duplicate member %q", m.Name)
418 }
419 seen[name] = struct{}{}
420 if strings.TrimSpace(m.Path) == "" {
421 return fmt.Errorf("installlayout: member %q path is empty", m.Name)
422 }
423 }
424 for _, name := range required {
425 if _, ok := seen[normalizeMemberName(name)]; !ok {
426 return fmt.Errorf("installlayout: required member %q is missing", name)
427 }
428 }
429 if len(seen) != len(required) {
430 return fmt.Errorf("installlayout: member set does not match required whitelist")
431 }
432 return nil
433 }
434
435 func normalizeMemberName(name string) string {
436 name = strings.TrimSpace(name)
437 if runtime.GOOS == "windows" {
438 return strings.ToLower(name)
439 }
440 return name
441 }
442
443 func verifyStagedMembers(stagingPath string, required []string) error {
444 for _, name := range required {
445 rel := filepath.FromSlash(name)
446 if err := rejectSymlinkPathComponents(stagingPath, rel); err != nil {
447 return fmt.Errorf("installlayout: staged member %s: %w", name, err)
448 }
449 info, err := os.Lstat(filepath.Join(stagingPath, rel))
450 if err != nil {
451 return fmt.Errorf("installlayout: staged member %s: %w", name, err)
452 }
453 if !info.Mode().IsRegular() {
454 return fmt.Errorf("installlayout: staged member %s is not a regular file", name)
455 }
456 }
457 return nil
458 }
459
460 func publishMember(stagingDir string, m Member) error {
461 src := filepath.Clean(strings.TrimSpace(m.Path))
462 info, err := os.Lstat(src)
463 if err != nil {
464 return fmt.Errorf("installlayout: source %s: %w", m.Name, err)
465 }
466 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
467 return fmt.Errorf("installlayout: source %s is not a regular file", m.Name)
468 }
469 mode := m.Mode
470 if mode == 0 {
471 mode = 0o755
472 }
473 dst := filepath.Join(stagingDir, filepath.FromSlash(m.Name))
474 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
475 return fmt.Errorf("installlayout: create %s parent: %w", m.Name, err)
476 }
477 if err := copyFileRegular(src, dst, mode); err != nil {
478 return fmt.Errorf("installlayout: copy %s: %w", m.Name, err)
479 }
480 return nil
481 }
482
483 func copyFileRegular(src, dst string, mode os.FileMode) error {
484 in, err := os.Open(src)
485 if err != nil {
486 return err
487 }
488 defer in.Close()
489 var out *os.File
490 if err := retryTransient(func() (openErr error) {
491 out, openErr = os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
492 return openErr
493 }); err != nil {
494 return err
495 }
496 closed := false
497 closeOut := func() error {
498 if closed {
499 return nil
500 }
501 closed = true
502 return out.Close()
503 }
504 ok := false
505 defer func() {
506 _ = closeOut()
507 if !ok {
508 _ = os.Remove(dst)
509 }
510 }()
511 if _, err := io.Copy(out, in); err != nil {
512 return err
513 }
514 if err := out.Sync(); err != nil {
515 return err
516 }
517 if err := closeOut(); err != nil {
518 return err
519 }
520 ok = true
521 return nil
522 }
523
524 func stagingNonce(requestID string) (string, error) {
525 requestID = strings.TrimSpace(requestID)
526 if requestID != "" {
527 // Sanitize request id into a short filesystem-safe token.
528 var b strings.Builder
529 for _, r := range requestID {
530 if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
531 b.WriteRune(r)
532 }
533 if b.Len() >= 24 {
534 break
535 }
536 }
537 if b.Len() >= 6 {
538 return b.String(), nil
539 }
540 }
541 var raw [8]byte
542 if _, err := rand.Read(raw[:]); err != nil {
543 // Fall back to time-based uniqueness if the platform CSPRNG fails.
544 return fmt.Sprintf("%d", time.Now().UnixNano()), nil
545 }
546 return hex.EncodeToString(raw[:]), nil
547 }
548
549 // CleanupStaleStaging removes versions/.staging-* and *.replaced-* directories
550 // older than maxAge. Safe to call anytime; never touches published version
551 // directories or current.json.
552 func CleanupStaleStaging(installRoot string, maxAge time.Duration) error {
553 installRoot, err := cleanInstallRoot(installRoot)
554 if err != nil {
555 return err
556 }
557 if maxAge <= 0 {
558 maxAge = 24 * time.Hour
559 }
560 versionsRoot := filepath.Join(installRoot, VersionsDirName)
561 entries, err := os.ReadDir(versionsRoot)
562 if err != nil {
563 if os.IsNotExist(err) {
564 return nil
565 }
566 return err
567 }
568 cutoff := time.Now().Add(-maxAge)
569 for _, e := range entries {
570 name := e.Name()
571 if !strings.HasPrefix(name, ".staging-") && !strings.Contains(name, ".replaced-") {
572 continue
573 }
574 path := filepath.Join(versionsRoot, name)
575 info, err := os.Lstat(path)
576 if err != nil {
577 continue
578 }
579 if info.Mode()&os.ModeSymlink != 0 {
580 continue
581 }
582 if info.ModTime().After(cutoff) {
583 continue
584 }
585 _ = os.RemoveAll(path)
586 }
587 return nil
588 }
589
590 // RetainPreviousVersions keeps the active version plus at most one previous
591 // version directory for signed recovery installers. Older trees are removed.
592 // The launcher never auto-selects a previous version; retention is for manual
593 // recovery packages only.
594 func RetainPreviousVersions(installRoot string, keep time.Duration) error {
595 installRoot, err := cleanInstallRoot(installRoot)
596 if err != nil {
597 return err
598 }
599 ptr, err := ReadCurrent(installRoot)
600 if err != nil {
601 return err
602 }
603 if keep <= 0 {
604 keep = 7 * 24 * time.Hour
605 }
606 versionsRoot := filepath.Join(installRoot, VersionsDirName)
607 entries, err := os.ReadDir(versionsRoot)
608 if err != nil {
609 return err
610 }
611 type verDir struct {
612 name string
613 mod time.Time
614 }
615 var previous []verDir
616 for _, e := range entries {
617 name := e.Name()
618 if strings.HasPrefix(name, ".") {
619 continue
620 }
621 if name == ptr.ActiveVersion {
622 continue
623 }
624 if err := ValidateVersionName(name); err != nil {
625 continue
626 }
627 info, err := e.Info()
628 if err != nil {
629 continue
630 }
631 if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
632 continue
633 }
634 previous = append(previous, verDir{name: name, mod: info.ModTime()})
635 }
636 // Keep the newest previous version if it is within the retention window;
637 // delete everything else.
638 var newest *verDir
639 for i := range previous {
640 p := &previous[i]
641 if newest == nil || p.mod.After(newest.mod) {
642 newest = p
643 }
644 }
645 cutoff := time.Now().Add(-keep)
646 for _, p := range previous {
647 if newest != nil && p.name == newest.name && !p.mod.Before(cutoff) {
648 continue
649 }
650 _ = os.RemoveAll(filepath.Join(versionsRoot, p.name))
651 }
652 return nil
653 }
654
654 lines GO