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