| 1 | package session |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/fileutil" |
| 13 | ) |
| 14 | |
| 15 | const ( |
| 16 | sessionHeaderName = "header.json" |
| 17 | SessionHeaderSchemaVersion = 1 |
| 18 | ) |
| 19 | |
| 20 | type SessionOrigin string |
| 21 | |
| 22 | const ( |
| 23 | SessionOriginNew SessionOrigin = "new" |
| 24 | SessionOriginFork SessionOrigin = "fork" |
| 25 | SessionOriginCanonicalImport SessionOrigin = "canonical-v4-import" |
| 26 | SessionOriginLegacyImport SessionOrigin = "legacy-import" |
| 27 | ) |
| 28 | |
| 29 | // SessionHeader is immutable Desktop ownership metadata. It deliberately |
| 30 | // excludes display and model projections so Session remains their sole source. |
| 31 | type SessionHeader struct { |
| 32 | SchemaVersion int `json:"schemaVersion"` |
| 33 | SessionID string `json:"sessionId"` |
| 34 | CreatedAt time.Time `json:"createdAt"` |
| 35 | CWD string `json:"cwd"` |
| 36 | ParentSessionID string `json:"parentSessionId,omitempty"` |
| 37 | Origin SessionOrigin `json:"origin"` |
| 38 | } |
| 39 | |
| 40 | func headerForCreate(options CreateOptions) (*SessionHeader, error) { |
| 41 | cwd := strings.TrimSpace(options.CWD) |
| 42 | parentID := strings.TrimSpace(options.ParentSessionID) |
| 43 | origin := options.Origin |
| 44 | if cwd == "" && parentID == "" && origin == "" { |
| 45 | return nil, nil |
| 46 | } |
| 47 | if cwd != "" { |
| 48 | cwd = filepath.Clean(cwd) |
| 49 | } |
| 50 | if parentID != "" { |
| 51 | if err := validateSessionID(parentID); err != nil { |
| 52 | return nil, fmt.Errorf("session: invalid parent identity: %w", err) |
| 53 | } |
| 54 | } |
| 55 | if origin == "" { |
| 56 | origin = SessionOriginNew |
| 57 | } |
| 58 | if !validSessionOrigin(origin) { |
| 59 | return nil, fmt.Errorf("session: unsupported session origin %q", origin) |
| 60 | } |
| 61 | return &SessionHeader{ |
| 62 | SchemaVersion: SessionHeaderSchemaVersion, |
| 63 | SessionID: strings.TrimSpace(options.SessionID), |
| 64 | CWD: cwd, |
| 65 | ParentSessionID: parentID, |
| 66 | Origin: origin, |
| 67 | }, nil |
| 68 | } |
| 69 | |
| 70 | func validSessionOrigin(origin SessionOrigin) bool { |
| 71 | switch origin { |
| 72 | case SessionOriginNew, SessionOriginFork, SessionOriginCanonicalImport, SessionOriginLegacyImport: |
| 73 | return true |
| 74 | default: |
| 75 | return false |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func writeSessionHeader(dir string, header SessionHeader) error { |
| 80 | if header.SchemaVersion != SessionHeaderSchemaVersion || header.SessionID == "" || header.CreatedAt.IsZero() || !validSessionOrigin(header.Origin) { |
| 81 | return fmt.Errorf("session: invalid session header") |
| 82 | } |
| 83 | body, err := json.Marshal(header) |
| 84 | if err != nil { |
| 85 | return err |
| 86 | } |
| 87 | return fileutil.AtomicWriteFileStrict(filepath.Join(dir, sessionHeaderName), append(body, '\n'), 0o600) |
| 88 | } |
| 89 | |
| 90 | func writeSessionHeaderForCreate(dir, sessionID string, createdAt time.Time, options CreateOptions) error { |
| 91 | options.SessionID = sessionID |
| 92 | header, err := headerForCreate(options) |
| 93 | if err != nil || header == nil { |
| 94 | return err |
| 95 | } |
| 96 | header.CreatedAt = createdAt |
| 97 | return writeSessionHeader(dir, *header) |
| 98 | } |
| 99 | |
| 100 | func validateSessionHeaderForCreate(dir, sessionID string, options CreateOptions) error { |
| 101 | expected, err := headerForCreate(options) |
| 102 | if err != nil || expected == nil { |
| 103 | return err |
| 104 | } |
| 105 | header, found, err := readSessionHeader(dir, sessionID) |
| 106 | if err != nil { |
| 107 | return err |
| 108 | } |
| 109 | if !found { |
| 110 | return fmt.Errorf("%w: session header is missing", ErrDamagedStore) |
| 111 | } |
| 112 | if header.CWD != expected.CWD { |
| 113 | return fmt.Errorf("%w: session header workspace does not match requested ownership", ErrDamagedStore) |
| 114 | } |
| 115 | if header.ParentSessionID != expected.ParentSessionID { |
| 116 | return fmt.Errorf("%w: session header lineage does not match requested ownership", ErrDamagedStore) |
| 117 | } |
| 118 | if header.Origin != expected.Origin { |
| 119 | return fmt.Errorf("%w: session header origin does not match requested ownership", ErrDamagedStore) |
| 120 | } |
| 121 | return nil |
| 122 | } |
| 123 | |
| 124 | func readSessionHeader(dir, sessionID string) (SessionHeader, bool, error) { |
| 125 | body, err := os.ReadFile(filepath.Join(dir, sessionHeaderName)) |
| 126 | if errors.Is(err, os.ErrNotExist) { |
| 127 | return SessionHeader{}, false, nil |
| 128 | } |
| 129 | if err != nil { |
| 130 | return SessionHeader{}, false, err |
| 131 | } |
| 132 | var header SessionHeader |
| 133 | if err := json.Unmarshal(body, &header); err != nil { |
| 134 | return SessionHeader{}, true, fmt.Errorf("%w: decode session header: %w", ErrDamagedStore, err) |
| 135 | } |
| 136 | if header.SchemaVersion != SessionHeaderSchemaVersion { |
| 137 | return SessionHeader{}, true, fmt.Errorf("%w: session header version %d", ErrUnsupportedVersion, header.SchemaVersion) |
| 138 | } |
| 139 | if header.SessionID != sessionID || header.CreatedAt.IsZero() || !validSessionOrigin(header.Origin) { |
| 140 | return SessionHeader{}, true, fmt.Errorf("%w: invalid session header", ErrDamagedStore) |
| 141 | } |
| 142 | return header, true, nil |
| 143 | } |
| 144 |