| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | |
| 11 | "reasonix/desktop/internal/workspacestate" |
| 12 | "reasonix/internal/session" |
| 13 | ) |
| 14 | |
| 15 | var errPurgeSourceRetained = errors.New("migrated source must be retained") |
| 16 | |
| 17 | // Cleanup is opt-in in the original tombstone transaction. Replaying an older |
| 18 | // purge must not grant new authority to remove upgrade evidence. Canonical |
| 19 | // directories use the same ownership lock and crash-safe staging as native |
| 20 | // deletion; shared content pools and legacy multi-head files remain untouched. |
| 21 | func (a *App) purgeMigratedSources(ctx context.Context, id string) error { |
| 22 | state, err := a.workspaceRegistry().Load(ctx) |
| 23 | if err != nil { |
| 24 | return err |
| 25 | } |
| 26 | op := state.PendingOperations["purge-"+id] |
| 27 | if len(op.Request) == 0 { |
| 28 | return nil |
| 29 | } |
| 30 | var plan workspacestate.PurgeSourceCleanup |
| 31 | if err := json.Unmarshal(op.Request, &plan); err != nil { |
| 32 | return err |
| 33 | } |
| 34 | if plan.Version != 1 { |
| 35 | return workspacestate.ErrUnsupportedVersion |
| 36 | } |
| 37 | for _, mapping := range plan.Sources { |
| 38 | if mapping.SessionID != id || mapping.Format != "canonical" || mapping.HeadID != "" || !filepath.IsAbs(mapping.Path) { |
| 39 | return workspacestate.ErrMutationConflict |
| 40 | } |
| 41 | // Never let a legacy receipt authorize deletion within the live store. |
| 42 | if pathsOverlapForPurge(mapping.Path, a.desktopSessions.root) || !purgeSourceExclusive(state, id, mapping) { |
| 43 | continue |
| 44 | } |
| 45 | // An offline or relocated historical root is not required to delete |
| 46 | // the canonical conversation. The adoption tombstone still hides it |
| 47 | // if it returns. Never follow a replaced directory symlink for cleanup. |
| 48 | rootInfo, rootErr := os.Lstat(filepath.Dir(mapping.Path)) |
| 49 | if os.IsNotExist(rootErr) || (rootErr == nil && !rootInfo.IsDir()) { |
| 50 | continue |
| 51 | } |
| 52 | if rootErr != nil { |
| 53 | return rootErr |
| 54 | } |
| 55 | if info, err := os.Lstat(mapping.Path); err == nil && !info.IsDir() { |
| 56 | continue |
| 57 | } |
| 58 | fs := session.NewFilesystemPersistence(filepath.Dir(mapping.Path)) |
| 59 | err := fs.PurgeWithTombstone(ctx, filepath.Base(mapping.Path), func() error { |
| 60 | // Both directory ownership and writer ownership are held here. |
| 61 | // Imports hold shared directory ownership through registry commit. |
| 62 | current, err := a.workspaceRegistry().Load(ctx) |
| 63 | if err != nil { |
| 64 | return err |
| 65 | } |
| 66 | if !purgeSourceExclusive(current, id, mapping) { |
| 67 | return errPurgeSourceRetained |
| 68 | } |
| 69 | fingerprint, err := desktopSourceFingerprint(mapping.Path) |
| 70 | if err == nil && fingerprint != mapping.Fingerprint { |
| 71 | return errPurgeSourceRetained |
| 72 | } |
| 73 | // An absent source can be a completed rename from a prior attempt. |
| 74 | // FilesystemPersistence verifies ownership of any staged directory. |
| 75 | if err != nil && !os.IsNotExist(err) { |
| 76 | return errPurgeSourceRetained |
| 77 | } |
| 78 | a.lifecycleCheckpoint("before-source-cleanup") |
| 79 | return nil |
| 80 | }) |
| 81 | if err != nil && !errors.Is(err, errPurgeSourceRetained) { |
| 82 | return err |
| 83 | } |
| 84 | } |
| 85 | return nil |
| 86 | } |
| 87 | |
| 88 | func purgeSourceExclusive(state workspacestate.State, id string, source workspacestate.SourceMapping) bool { |
| 89 | if state.SessionStates[id].Lifecycle != workspacestate.Deleted || workspacestate.ClassifyPurge(state, id) != workspacestate.PurgeTombstoned { |
| 90 | return false |
| 91 | } |
| 92 | registered, ok := state.SourceMappings[source.SourceKey] |
| 93 | if !ok || registered.Path != source.Path || registered.SessionID != id || registered.Fingerprint != source.Fingerprint { |
| 94 | return false |
| 95 | } |
| 96 | for _, mapping := range state.SourceMappings { |
| 97 | if state.SessionStates[mapping.SessionID].Lifecycle != workspacestate.Deleted && purgeMappingReferences(mapping, source.Path) { |
| 98 | return false |
| 99 | } |
| 100 | } |
| 101 | for _, op := range state.PendingOperations { |
| 102 | if op.Phase != "committed" && op.Mapping != nil && purgeMappingReferences(*op.Mapping, source.Path) { |
| 103 | return false |
| 104 | } |
| 105 | } |
| 106 | for _, recovery := range state.RecoveryEntries { |
| 107 | if recovery.Status != "restored" && pathsOverlapForPurge(recovery.Path, source.Path) { |
| 108 | return false |
| 109 | } |
| 110 | } |
| 111 | return true |
| 112 | } |
| 113 | |
| 114 | func purgeMappingReferences(mapping workspacestate.SourceMapping, source string) bool { |
| 115 | if pathsOverlapForPurge(mapping.Path, source) { |
| 116 | return true |
| 117 | } |
| 118 | for _, artifact := range mapping.RetainedArtifacts { |
| 119 | if pathsOverlapForPurge(artifact, source) { |
| 120 | return true |
| 121 | } |
| 122 | } |
| 123 | return false |
| 124 | } |
| 125 | |
| 126 | func pathsOverlapForPurge(left, right string) bool { |
| 127 | if left == "" || right == "" { |
| 128 | return false |
| 129 | } |
| 130 | l, errLeft := canonicalRuntimeRootErr(left) |
| 131 | r, errRight := canonicalRuntimeRootErr(right) |
| 132 | if errLeft != nil || errRight != nil { |
| 133 | return true // Uncertain ownership is never deletion authority. |
| 134 | } |
| 135 | return l == r || strings.HasPrefix(l, r+string(filepath.Separator)) || strings.HasPrefix(r, l+string(filepath.Separator)) |
| 136 | } |
| 137 |