返回 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/filepath"
12 "runtime"
13 "strings"
14 "time"
15
16 "reasonix/internal/filelock"
17 )
18
19 const activationLockName = ".reasonix-activate.lock"
20
21 // Member is one file to publish into a version directory.
22 type Member struct {
23 // Name is a base name only (no directories). Must be on the allow-list.
24 Name string
25 // Path is a regular file on the same volume as the install root (preferred)
26 // or any readable regular file that will be copied.
27 Path string
28 // Mode is the destination file mode. Zero defaults to 0o755.
29 Mode os.FileMode
30 }
31
32 // ActivationRequest describes a one-shot version publish + pointer swap.
33 type ActivationRequest struct {
34 InstallRoot string
35 Version string
36 // RequestID isolates staging directories so concurrent/failed attempts never
37 // share a global pending file.
38 RequestID string
39 Members []Member
40 // RequiredNames, when non-empty, is the exact member whitelist. Defaults to
41 // the platform desktop release unit.
42 RequiredNames []string
43 // RootMembers are stable entry points published at InstallRoot before the
44 // current.json commit. They are rolled back if any later step fails.
45 RootMembers []Member
46 // RequiredRootNames is the exact root-entry whitelist when RootMembers is
47 // non-empty. Callers must provide it explicitly.
48 RequiredRootNames []string
49 }
50
51 // AllowedVersionMembers returns the default files inside versions/<version>/.
52 func AllowedVersionMembers() []string {
53 names := []string{
54 DesktopBinaryName(),
55 CLIBinaryName(),
56 UpdateHelperBinaryName(),
57 }
58 return names
59 }
60
61 // StagingDirName builds versions/.staging-<version>-<nonce> for one request.
62 func StagingDirName(version, nonce string) string {
63 version = strings.TrimSpace(version)
64 nonce = strings.TrimSpace(nonce)
65 return fmt.Sprintf(".staging-%s-%s", version, nonce)
66 }
67
68 // ActivateVersion copies members into a unique staging directory on the same
69 // volume, validates the whitelist, renames staging to versions/<version>, and
70 // finally swaps current.json. Any failure before the pointer swap leaves the
71 // previous active version unchanged.
72 func ActivateVersion(req ActivationRequest) error {
73 installRoot, err := cleanInstallRoot(req.InstallRoot)
74 if err != nil {
75 return err
76 }
77 if err := ValidateVersionName(req.Version); err != nil {
78 return err
79 }
80 required := req.RequiredNames
81 if len(required) == 0 {
82 required = AllowedVersionMembers()
83 }
84 if err := validateMembers(req.Members, required); err != nil {
85 return err
86 }
87 if len(req.RootMembers) > 0 {
88 if len(req.RequiredRootNames) == 0 {
89 return fmt.Errorf("installlayout: root member whitelist is required")
90 }
91 if err := validateMembers(req.RootMembers, req.RequiredRootNames); err != nil {
92 return fmt.Errorf("installlayout: root entries: %w", err)
93 }
94 } else if len(req.RequiredRootNames) > 0 {
95 return fmt.Errorf("installlayout: root member whitelist provided without root members")
96 }
97
98 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
99 defer cancel()
100 unlock, err := filelock.Acquire(ctx, filepath.Join(installRoot, activationLockName))
101 if err != nil {
102 return fmt.Errorf("installlayout: acquire activation lock: %w", err)
103 }
104 defer unlock()
105
106 versionsRoot := filepath.Join(installRoot, VersionsDirName)
107 if err := os.MkdirAll(versionsRoot, 0o755); err != nil {
108 return fmt.Errorf("installlayout: create versions dir: %w", err)
109 }
110 if err := rejectSymlinkPathComponents(installRoot, VersionsDirName); err != nil {
111 return err
112 }
113
114 nonce, err := stagingNonce(req.RequestID)
115 if err != nil {
116 return err
117 }
118 stagingName := StagingDirName(req.Version, nonce)
119 stagingPath := filepath.Join(versionsRoot, stagingName)
120 rootStagingPath := filepath.Join(versionsRoot, ".root-"+stagingName)
121 // Always start clean for this request id/nonce.
122 _ = os.RemoveAll(stagingPath)
123 _ = os.RemoveAll(rootStagingPath)
124 if err := os.Mkdir(stagingPath, 0o755); err != nil {
125 return fmt.Errorf("installlayout: create staging dir: %w", err)
126 }
127 committed := false
128 defer func() {
129 if !committed {
130 _ = os.RemoveAll(stagingPath)
131 }
132 _ = os.RemoveAll(rootStagingPath)
133 }()
134
135 for _, m := range req.Members {
136 if err := publishMember(stagingPath, m); err != nil {
137 return err
138 }
139 }
140 // Ensure every required name exists as a regular file (no symlinks).
141 for _, name := range required {
142 path := filepath.Join(stagingPath, name)
143 info, err := os.Lstat(path)
144 if err != nil {
145 return fmt.Errorf("installlayout: staged member %s: %w", name, err)
146 }
147 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
148 return fmt.Errorf("installlayout: staged member %s is not a regular file", name)
149 }
150 }
151 if len(req.RootMembers) > 0 {
152 if err := os.Mkdir(rootStagingPath, 0o755); err != nil {
153 return fmt.Errorf("installlayout: create root staging dir: %w", err)
154 }
155 for _, m := range req.RootMembers {
156 if err := publishMember(rootStagingPath, m); err != nil {
157 return fmt.Errorf("installlayout: stage root entry: %w", err)
158 }
159 }
160 }
161
162 finalRel := VersionDirRelative(req.Version)
163 finalPath := filepath.Join(installRoot, filepath.FromSlash(finalRel))
164 var versionBackup string
165 if _, err := os.Lstat(finalPath); err == nil {
166 // A previous partial publish of the same version is replaced only from
167 // staging after validation. Never swap current.json first.
168 versionBackup = finalPath + ".replaced-" + nonce
169 _ = os.RemoveAll(versionBackup)
170 if err := os.Rename(finalPath, versionBackup); err != nil {
171 return fmt.Errorf("installlayout: displace existing version dir: %w", err)
172 }
173 } else if !os.IsNotExist(err) {
174 return fmt.Errorf("installlayout: inspect version dir: %w", err)
175 }
176
177 if err := os.Rename(stagingPath, finalPath); err != nil {
178 if versionBackup != "" {
179 _ = os.Rename(versionBackup, finalPath)
180 }
181 return fmt.Errorf("installlayout: publish version directory: %w", err)
182 }
183 rollbackVersion := func() error {
184 var rollbackErr error
185 if err := os.RemoveAll(finalPath); err != nil {
186 rollbackErr = errors.Join(rollbackErr, err)
187 }
188 if versionBackup != "" {
189 if err := os.Rename(versionBackup, finalPath); err != nil {
190 rollbackErr = errors.Join(rollbackErr, err)
191 }
192 }
193 return rollbackErr
194 }
195
196 rollbackRoots, commitRoots, err := publishRootEntries(installRoot, rootStagingPath, req.RootMembers)
197 if err != nil {
198 if rollbackErr := rollbackVersion(); rollbackErr != nil {
199 return errors.Join(err, fmt.Errorf("installlayout: restore version after root publish failure: %w", rollbackErr))
200 }
201 return err
202 }
203
204 ptr := CurrentPointer{
205 SchemaVersion: CurrentSchemaVersion,
206 ActiveVersion: req.Version,
207 ActiveDir: finalRel,
208 }
209 if err := WriteCurrent(installRoot, ptr); err != nil {
210 rootErr := rollbackRoots()
211 versionErr := rollbackVersion()
212 return errors.Join(
213 fmt.Errorf("installlayout: write current.json: %w", err),
214 wrapRollbackError("restore root entries", rootErr),
215 wrapRollbackError("restore version directory", versionErr),
216 )
217 }
218 committed = true
219 commitRoots()
220 if versionBackup != "" {
221 _ = os.RemoveAll(versionBackup)
222 }
223 return nil
224 }
225
226 func publishRootEntries(installRoot, stagingRoot string, members []Member) (rollback func() error, commit func(), err error) {
227 if len(members) == 0 {
228 return func() error { return nil }, func() {}, nil
229 }
230 backupRoot := filepath.Join(stagingRoot, ".backups")
231 if err := os.MkdirAll(backupRoot, 0o700); err != nil {
232 return nil, nil, fmt.Errorf("installlayout: create root backup staging: %w", err)
233 }
234 type replacement struct {
235 destination string
236 backup string
237 hadOriginal bool
238 }
239 replacements := make([]replacement, 0, len(members))
240 rollbackFn := func() error {
241 var rollbackErr error
242 for i := len(replacements) - 1; i >= 0; i-- {
243 r := replacements[i]
244 if err := os.Remove(r.destination); err != nil && !os.IsNotExist(err) {
245 rollbackErr = errors.Join(rollbackErr, err)
246 }
247 if r.hadOriginal {
248 if err := os.Rename(r.backup, r.destination); err != nil {
249 rollbackErr = errors.Join(rollbackErr, err)
250 }
251 }
252 }
253 return rollbackErr
254 }
255 for _, m := range members {
256 name := filepath.Base(m.Name)
257 source := filepath.Join(stagingRoot, name)
258 destination := filepath.Join(installRoot, name)
259 r := replacement{destination: destination, backup: filepath.Join(backupRoot, name)}
260 if info, statErr := os.Lstat(destination); statErr == nil {
261 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
262 _ = rollbackFn()
263 return nil, nil, fmt.Errorf("installlayout: root entry %s is not a regular file", name)
264 }
265 if err := os.Rename(destination, r.backup); err != nil {
266 _ = rollbackFn()
267 return nil, nil, fmt.Errorf("installlayout: back up root entry %s: %w", name, err)
268 }
269 r.hadOriginal = true
270 } else if !os.IsNotExist(statErr) {
271 _ = rollbackFn()
272 return nil, nil, fmt.Errorf("installlayout: inspect root entry %s: %w", name, statErr)
273 }
274 replacements = append(replacements, r)
275 if err := os.Rename(source, destination); err != nil {
276 rollbackErr := rollbackFn()
277 return nil, nil, errors.Join(
278 fmt.Errorf("installlayout: publish root entry %s: %w", name, err),
279 wrapRollbackError("restore root entries", rollbackErr),
280 )
281 }
282 }
283 return rollbackFn, func() { _ = os.RemoveAll(backupRoot) }, nil
284 }
285
286 func wrapRollbackError(label string, err error) error {
287 if err == nil {
288 return nil
289 }
290 return fmt.Errorf("installlayout: %s: %w", label, err)
291 }
292
293 func validateMembers(members []Member, required []string) error {
294 if len(members) == 0 {
295 return fmt.Errorf("installlayout: no members to activate")
296 }
297 allowed := make(map[string]struct{}, len(required))
298 for _, name := range required {
299 allowed[normalizeMemberName(name)] = struct{}{}
300 }
301 seen := make(map[string]struct{}, len(members))
302 for _, m := range members {
303 name := normalizeMemberName(m.Name)
304 if name == "" || name != filepath.Base(name) || strings.Contains(name, `\`) {
305 return fmt.Errorf("installlayout: member name %q is invalid", m.Name)
306 }
307 if _, ok := allowed[name]; !ok {
308 return fmt.Errorf("installlayout: member %q is not allowed", m.Name)
309 }
310 if _, dup := seen[name]; dup {
311 return fmt.Errorf("installlayout: duplicate member %q", m.Name)
312 }
313 seen[name] = struct{}{}
314 if strings.TrimSpace(m.Path) == "" {
315 return fmt.Errorf("installlayout: member %q path is empty", m.Name)
316 }
317 }
318 for _, name := range required {
319 if _, ok := seen[normalizeMemberName(name)]; !ok {
320 return fmt.Errorf("installlayout: required member %q is missing", name)
321 }
322 }
323 if len(seen) != len(required) {
324 return fmt.Errorf("installlayout: member set does not match required whitelist")
325 }
326 return nil
327 }
328
329 func normalizeMemberName(name string) string {
330 name = strings.TrimSpace(name)
331 if runtime.GOOS == "windows" {
332 return strings.ToLower(name)
333 }
334 return name
335 }
336
337 func publishMember(stagingDir string, m Member) error {
338 src := filepath.Clean(strings.TrimSpace(m.Path))
339 info, err := os.Lstat(src)
340 if err != nil {
341 return fmt.Errorf("installlayout: source %s: %w", m.Name, err)
342 }
343 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
344 return fmt.Errorf("installlayout: source %s is not a regular file", m.Name)
345 }
346 mode := m.Mode
347 if mode == 0 {
348 mode = 0o755
349 }
350 dst := filepath.Join(stagingDir, filepath.Base(m.Name))
351 if err := copyFileRegular(src, dst, mode); err != nil {
352 return fmt.Errorf("installlayout: copy %s: %w", m.Name, err)
353 }
354 return nil
355 }
356
357 func copyFileRegular(src, dst string, mode os.FileMode) error {
358 in, err := os.Open(src)
359 if err != nil {
360 return err
361 }
362 defer in.Close()
363 out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
364 if err != nil {
365 return err
366 }
367 ok := false
368 defer func() {
369 _ = out.Close()
370 if !ok {
371 _ = os.Remove(dst)
372 }
373 }()
374 if _, err := io.Copy(out, in); err != nil {
375 return err
376 }
377 if err := out.Sync(); err != nil {
378 return err
379 }
380 if err := out.Close(); err != nil {
381 return err
382 }
383 ok = true
384 return nil
385 }
386
387 func stagingNonce(requestID string) (string, error) {
388 requestID = strings.TrimSpace(requestID)
389 if requestID != "" {
390 // Sanitize request id into a short filesystem-safe token.
391 var b strings.Builder
392 for _, r := range requestID {
393 if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
394 b.WriteRune(r)
395 }
396 if b.Len() >= 24 {
397 break
398 }
399 }
400 if b.Len() >= 6 {
401 return b.String(), nil
402 }
403 }
404 var raw [8]byte
405 if _, err := rand.Read(raw[:]); err != nil {
406 // Fall back to time-based uniqueness if the platform CSPRNG fails.
407 return fmt.Sprintf("%d", time.Now().UnixNano()), nil
408 }
409 return hex.EncodeToString(raw[:]), nil
410 }
411
412 // CleanupStaleStaging removes versions/.staging-* directories older than maxAge.
413 // Safe to call anytime; never touches published version directories or current.json.
414 func CleanupStaleStaging(installRoot string, maxAge time.Duration) error {
415 installRoot, err := cleanInstallRoot(installRoot)
416 if err != nil {
417 return err
418 }
419 if maxAge <= 0 {
420 maxAge = 24 * time.Hour
421 }
422 versionsRoot := filepath.Join(installRoot, VersionsDirName)
423 entries, err := os.ReadDir(versionsRoot)
424 if err != nil {
425 if os.IsNotExist(err) {
426 return nil
427 }
428 return err
429 }
430 cutoff := time.Now().Add(-maxAge)
431 for _, e := range entries {
432 name := e.Name()
433 if !strings.HasPrefix(name, ".staging-") {
434 continue
435 }
436 path := filepath.Join(versionsRoot, name)
437 info, err := os.Lstat(path)
438 if err != nil {
439 continue
440 }
441 if info.Mode()&os.ModeSymlink != 0 {
442 continue
443 }
444 if info.ModTime().After(cutoff) {
445 continue
446 }
447 _ = os.RemoveAll(path)
448 }
449 return nil
450 }
451
452 // RetainPreviousVersions keeps the active version plus at most one previous
453 // version directory for signed recovery installers. Older trees are removed.
454 // The launcher never auto-selects a previous version; retention is for manual
455 // recovery packages only.
456 func RetainPreviousVersions(installRoot string, keep time.Duration) error {
457 installRoot, err := cleanInstallRoot(installRoot)
458 if err != nil {
459 return err
460 }
461 ptr, err := ReadCurrent(installRoot)
462 if err != nil {
463 return err
464 }
465 if keep <= 0 {
466 keep = 7 * 24 * time.Hour
467 }
468 versionsRoot := filepath.Join(installRoot, VersionsDirName)
469 entries, err := os.ReadDir(versionsRoot)
470 if err != nil {
471 return err
472 }
473 type verDir struct {
474 name string
475 mod time.Time
476 }
477 var previous []verDir
478 for _, e := range entries {
479 name := e.Name()
480 if strings.HasPrefix(name, ".") {
481 continue
482 }
483 if name == ptr.ActiveVersion {
484 continue
485 }
486 if err := ValidateVersionName(name); err != nil {
487 continue
488 }
489 info, err := e.Info()
490 if err != nil {
491 continue
492 }
493 if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
494 continue
495 }
496 previous = append(previous, verDir{name: name, mod: info.ModTime()})
497 }
498 // Keep the newest previous version if it is within the retention window;
499 // delete everything else.
500 var newest *verDir
501 for i := range previous {
502 p := &previous[i]
503 if newest == nil || p.mod.After(newest.mod) {
504 newest = p
505 }
506 }
507 cutoff := time.Now().Add(-keep)
508 for _, p := range previous {
509 if newest != nil && p.name == newest.name && !p.mod.Before(cutoff) {
510 continue
511 }
512 _ = os.RemoveAll(filepath.Join(versionsRoot, p.name))
513 }
514 return nil
515 }
516
516 lines GO