| 1 | package session |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | |
| 10 | "reasonix/internal/fileutil" |
| 11 | filelock "reasonix/internal/identitylock" |
| 12 | ) |
| 13 | |
| 14 | // PurgeWithTombstone keeps directory ownership across writer-lock release and |
| 15 | // rename (required on Windows). The callback durably withdraws the identity. |
| 16 | // The deterministic staging directory makes interrupted removal replayable. |
| 17 | func (p *FilesystemPersistence) PurgeWithTombstone(ctx context.Context, id string, prepare func() error) error { |
| 18 | return p.purgeWithTombstone(ctx, id, prepare, func(string) {}) |
| 19 | } |
| 20 | |
| 21 | // checkpoint belongs to one invocation; tests terminate a child process here |
| 22 | // to exercise real lock release and restart, without process-global hooks. |
| 23 | func (p *FilesystemPersistence) purgeWithTombstone(ctx context.Context, id string, prepare func() error, checkpoint func(string)) error { |
| 24 | source, err := p.sessionDir(id, false) |
| 25 | if err != nil { |
| 26 | return err |
| 27 | } |
| 28 | if err := ctx.Err(); err != nil { |
| 29 | return err |
| 30 | } |
| 31 | releaseDirectory, err := filelock.TryAcquire(directoryOwnershipPath(source)) |
| 32 | if err != nil { |
| 33 | return err |
| 34 | } |
| 35 | defer releaseDirectory() |
| 36 | staged := filepath.Join(p.Root, ".purging", id) |
| 37 | receipt := staged + ".receipt" |
| 38 | proof := "reasonix-session-purge-v1\n" + id + "\n" |
| 39 | if err := validatePurgeDirectories(p.Root, source, staged, receipt, proof); err != nil { |
| 40 | return err |
| 41 | } |
| 42 | if _, err := os.Lstat(source); err == nil { |
| 43 | if _, err := os.Lstat(staged); err == nil { |
| 44 | return errors.New("session purge staging collision") |
| 45 | } else if !os.IsNotExist(err) { |
| 46 | return err |
| 47 | } |
| 48 | release, err := filelock.TryAcquire(filepath.Join(source, "writer.lock")) |
| 49 | if err != nil { |
| 50 | return err |
| 51 | } |
| 52 | checkpoint("before-tombstone") |
| 53 | if err := prepare(); err != nil { |
| 54 | release() |
| 55 | return err |
| 56 | } |
| 57 | checkpoint("after-tombstone") |
| 58 | if err := os.MkdirAll(filepath.Dir(staged), 0700); err != nil { |
| 59 | release() |
| 60 | return err |
| 61 | } |
| 62 | if err := fileutil.AtomicCreateFile(receipt, []byte(proof), 0600); err != nil && !errors.Is(err, os.ErrExist) { |
| 63 | release() |
| 64 | return err |
| 65 | } |
| 66 | release() |
| 67 | checkpoint("before-rename") |
| 68 | if err := os.Rename(source, staged); err != nil { |
| 69 | return err |
| 70 | } |
| 71 | checkpoint("after-rename") |
| 72 | } else if !os.IsNotExist(err) { |
| 73 | return err |
| 74 | } else { |
| 75 | if _, err := os.Lstat(staged); err == nil { |
| 76 | if _, err := os.Lstat(receipt); err != nil { |
| 77 | return errors.New("unowned session purge staging directory") |
| 78 | } |
| 79 | } else if !os.IsNotExist(err) { |
| 80 | return err |
| 81 | } |
| 82 | if err := prepare(); err != nil { |
| 83 | return err |
| 84 | } |
| 85 | } |
| 86 | if err := os.RemoveAll(staged); err != nil { |
| 87 | return err |
| 88 | } |
| 89 | checkpoint("after-content-removal") |
| 90 | if err := os.RemoveAll(filepath.Join(p.Root, ".query-cache", id)); err != nil { |
| 91 | return err |
| 92 | } |
| 93 | if err := os.Remove(receipt); err != nil && !os.IsNotExist(err) { |
| 94 | return err |
| 95 | } |
| 96 | checkpoint("after-cleanup") |
| 97 | return nil |
| 98 | } |
| 99 | |
| 100 | // AcquireMaintenance excludes an external writer without opening, recovering, |
| 101 | // or changing a cold session. The caller must not already own its runtime. |
| 102 | func (p *FilesystemPersistence) AcquireMaintenance(sessionID string) (func(), error) { |
| 103 | dir, err := p.sessionDir(sessionID, true) |
| 104 | if err != nil { |
| 105 | return nil, err |
| 106 | } |
| 107 | release, err := acquireSessionWriter(dir) |
| 108 | if errors.Is(err, filelock.ErrHeld) { |
| 109 | return nil, fmt.Errorf("%w", ErrWriterOwned) |
| 110 | } |
| 111 | return release, err |
| 112 | } |
| 113 | |
| 114 | func validatePurgeDirectories(root, source, staged, receipt, proof string) error { |
| 115 | for _, path := range []string{root, filepath.Dir(staged), staged, source, filepath.Join(root, ".query-cache")} { |
| 116 | info, err := os.Lstat(path) |
| 117 | if err != nil && !os.IsNotExist(err) { |
| 118 | return err |
| 119 | } |
| 120 | if err == nil && (!info.IsDir() || info.Mode()&os.ModeSymlink != 0) { |
| 121 | return errors.New("unsafe session maintenance directory") |
| 122 | } |
| 123 | } |
| 124 | if info, err := os.Lstat(receipt); err == nil { |
| 125 | if !info.Mode().IsRegular() { |
| 126 | return errors.New("unsafe session purge receipt") |
| 127 | } |
| 128 | body, err := os.ReadFile(receipt) |
| 129 | if err != nil || string(body) != proof { |
| 130 | return errors.New("session purge receipt mismatch") |
| 131 | } |
| 132 | } else if !os.IsNotExist(err) { |
| 133 | return err |
| 134 | } |
| 135 | return nil |
| 136 | } |
| 137 |