| 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 |