crtx 0.1.0

CLI for the Cortex supervisory memory substrate.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! OS-managed exclusive active-store lock for production destructive restore.
//!
//! Doctrine: `DESIGN_production_active_store_restore.md` §"Lock model".
//! ADR 0028 §2 freeze protocol, ADR 0026 §4 break-glass-never-substitutes-for-attestation.
//!
//! ## Lock semantics
//!
//! - The lock is acquired by `OpenOptions::write(true).create_new(true)` on the
//!   marker path, then `fs2::FileExt::try_lock_exclusive` on the resulting
//!   handle. Marker creation + flock together ensure that a second restore
//!   cannot race in between.
//! - The kernel releases the OS lock on process death (POSIX `flock` and
//!   Windows `LockFileEx` both have this property), so a crashed restore does
//!   not strand the system. The marker file remains on disk so a follow-up
//!   restore can see that mutation may be in flight.
//! - `ActiveStoreLockGuard::Drop` releases the OS lock (handle close) and
//!   removes the marker file. Production callers MUST keep the guard alive
//!   across the entire mutation window: pre-anchor revalidation, atomic
//!   cutover, post-verify, and (on failure) auto-rollback.
//! - Stale-takeover requires an Ed25519-attested payload validated in
//!   `intent.rs`. This module never deletes a stale marker silently — it
//!   renames it to `.cortex-restore-active-store.lock.stale-<ts>` so the
//!   evidence survives.
//!
//! ## Cross-platform
//!
//! - Linux/macOS: `fs2::FileExt::try_lock_exclusive` -> `flock(LOCK_EX|LOCK_NB)`.
//! - Windows: `fs2::FileExt::try_lock_exclusive` -> `LockFileEx` with
//!   `LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY`. Kernel releases on
//!   handle close.

use std::fs::{self, File, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};

use chrono::{DateTime, Utc};
use fs2::FileExt;
use serde::Serialize;

/// Errors raised while acquiring or releasing the active-store lock.
#[derive(Debug)]
pub enum LockError {
    /// Marker file already exists. The caller MUST inspect it and decide
    /// whether to invoke the attested takeover path; this module never
    /// removes a stale marker silently.
    MarkerAlreadyExists {
        /// Marker path observed.
        path: PathBuf,
    },
    /// Marker created but the OS-level exclusive lock could not be acquired.
    /// This means a concurrent process is actively holding the lock (i.e.
    /// the marker is hot, not stale). Caller MUST refuse to proceed.
    Contended {
        /// Marker path that we could not lock.
        path: PathBuf,
        /// Operating-system reason.
        reason: String,
    },
    /// I/O failure during marker write or directory access. Caller MUST
    /// treat as `Reject` and emit no audit row.
    Io {
        /// What we were trying to do when the failure occurred.
        operation: &'static str,
        /// Path involved in the failure.
        path: PathBuf,
        /// Underlying I/O error message.
        message: String,
    },
}

impl std::fmt::Display for LockError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::MarkerAlreadyExists { path } => {
                write!(
                    f,
                    "active-store lock marker `{}` already exists; another restore or writer may hold the active store",
                    path.display()
                )
            }
            Self::Contended { path, reason } => write!(
                f,
                "active-store lock marker `{}` exists and the OS exclusive lock is held: {reason}",
                path.display()
            ),
            Self::Io {
                operation,
                path,
                message,
            } => write!(
                f,
                "active-store lock {operation} failed for `{}`: {message}",
                path.display()
            ),
        }
    }
}

impl std::error::Error for LockError {}

/// Marker file payload header. Bumped if the on-disk text format changes.
pub const MARKER_HEADER: &str = "cortex-restore-active-store-lock-v1";
/// Production scope label written into the marker.
pub const MARKER_SCOPE_PRODUCTION: &str = "production";

/// Subset of `RESTORE_INTENT` fields the lock marker needs to identify the
/// session. Mirrors `DESIGN_production_active_store_restore.md` §"Lock marker
/// file format".
#[derive(Debug, Clone, Serialize)]
pub struct LockMarkerPayload {
    /// Deployment identifier from the verified `RESTORE_INTENT`.
    pub deployment_id: String,
    /// Operator principal that signed the intent.
    pub operator_principal_id: String,
    /// BLAKE3 digest of the verified `RESTORE_INTENT` payload bytes (hex with
    /// `blake3:` prefix). Bounds the marker to one intent.
    pub restore_intent_blake3: String,
    /// Time the lock was acquired.
    pub acquired_at: DateTime<Utc>,
    /// Hostname captured for forensics. Empty if the host could not be
    /// resolved; absence is not fatal.
    pub host: String,
}

/// Guard holding the OS-level exclusive lock plus the marker file lifetime.
///
/// On disk the guard owns two files:
///
/// 1. The marker file at `path` (operator-visible, readable while the lock
///    is held — POSIX `O_CREAT|O_EXCL` racy-create plus fsync'd body).
/// 2. A sibling lock-companion file at `path` + [`LOCK_COMPANION_SUFFIX`]
///    whose handle carries the `fs2::FileExt::try_lock_exclusive` lock.
///    Using a separate handle is necessary on Windows: `LockFileEx` with
///    `LOCKFILE_EXCLUSIVE_LOCK` makes the locked bytes inaccessible to
///    other handles, including readonly reads, which would prevent
///    operator inspection of the marker. POSIX `flock` is advisory and does
///    not have this issue, but using a companion file keeps the semantics
///    uniform across platforms.
///
/// Together they preserve the design invariant: a second restore racing on
/// `create_new` cannot succeed, AND a concurrent process that managed to
/// race past create_new will fail the OS lock.
///
/// `Drop` removes both files and releases the OS lock. To deliberately
/// keep the marker after a fatal error (so the operator can inspect what
/// happened), call [`Self::leak_for_rollback_failure`] before drop.
#[derive(Debug)]
pub struct ActiveStoreLockGuard {
    lock_file: Option<File>,
    path: PathBuf,
    lock_companion_path: PathBuf,
    // Marker payload retained on the guard so callers can correlate the
    // active scope with the audit row appended after `acquire`. Kept on
    // the struct for future report emitters (e.g. doctor / preflight) even
    // when the current `run_apply` does not introspect it.
    #[allow(dead_code)]
    payload: LockMarkerPayload,
    leaked: bool,
}

/// Sibling suffix used for the OS-lock companion file.
pub const LOCK_COMPANION_SUFFIX: &str = ".flock";

impl ActiveStoreLockGuard {
    /// Acquire the exclusive active-store lock.
    ///
    /// 1. The marker file is created with `O_CREAT|O_EXCL` so a second
    ///    restore that races at the syscall level cannot succeed; the body
    ///    is written + fsync'd and the handle is dropped immediately.
    /// 2. A sibling companion file is created and locked with
    ///    `fs2::FileExt::try_lock_exclusive`. The kernel releases this lock
    ///    on process death.
    pub fn acquire(path: &Path, payload: LockMarkerPayload) -> Result<Self, LockError> {
        if let Some(parent) = path.parent() {
            if !parent.as_os_str().is_empty() && !parent.exists() {
                return Err(LockError::Io {
                    operation: "marker parent directory probe",
                    path: parent.to_path_buf(),
                    message: "parent directory does not exist".to_string(),
                });
            }
        }

        // Step 1: racy-exclusive marker create.
        let mut marker_file = match OpenOptions::new().write(true).create_new(true).open(path) {
            Ok(file) => file,
            Err(err) if err.kind() == ErrorKind::AlreadyExists => {
                return Err(LockError::MarkerAlreadyExists {
                    path: path.to_path_buf(),
                });
            }
            Err(err) => {
                return Err(LockError::Io {
                    operation: "marker create_new",
                    path: path.to_path_buf(),
                    message: err.to_string(),
                });
            }
        };
        let body = render_marker_body(&payload);
        if let Err(err) = marker_file
            .write_all(body.as_bytes())
            .and_then(|()| marker_file.sync_all())
        {
            drop(marker_file);
            let _ = fs::remove_file(path);
            return Err(LockError::Io {
                operation: "marker body write+sync",
                path: path.to_path_buf(),
                message: err.to_string(),
            });
        }
        drop(marker_file);

        // Step 2: companion OS-lock file.
        let lock_companion_path = make_companion_path(path);
        let lock_file = match OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .open(&lock_companion_path)
        {
            Ok(file) => file,
            Err(err) => {
                let _ = fs::remove_file(path);
                return Err(LockError::Io {
                    operation: "lock companion open",
                    path: lock_companion_path,
                    message: err.to_string(),
                });
            }
        };
        if let Err(err) = FileExt::try_lock_exclusive(&lock_file) {
            drop(lock_file);
            let _ = fs::remove_file(&lock_companion_path);
            let _ = fs::remove_file(path);
            return Err(LockError::Contended {
                path: lock_companion_path,
                reason: err.to_string(),
            });
        }

        Ok(Self {
            lock_file: Some(lock_file),
            path: path.to_path_buf(),
            lock_companion_path,
            payload,
            leaked: false,
        })
    }

    /// Path of the marker file backing this guard.
    #[must_use]
    pub fn marker_path(&self) -> &Path {
        &self.path
    }

    /// Path of the lock companion file backing the OS-level exclusive lock.
    /// Exposed for callers that emit JSON reports about the guard state;
    /// retained on the public API even though `run_apply` does not surface
    /// it today.
    #[must_use]
    #[allow(dead_code)]
    pub fn lock_companion_path(&self) -> &Path {
        &self.lock_companion_path
    }

    /// Borrow the marker payload for report emission. Public for the same
    /// reason as [`Self::lock_companion_path`].
    #[must_use]
    #[allow(dead_code)]
    pub fn payload(&self) -> &LockMarkerPayload {
        &self.payload
    }

    /// Preserve the marker file after drop. Use this only when an
    /// auto-rollback failed and the operator needs the marker on disk as
    /// evidence per `DESIGN_production_active_store_restore.md` §"Failure modes".
    pub fn leak_for_rollback_failure(&mut self) {
        self.leaked = true;
    }

    fn release_locked_handle(&mut self) {
        if let Some(file) = self.lock_file.take() {
            // fs2 unlock is best-effort; the kernel also releases on close.
            let _ = FileExt::unlock(&file);
            drop(file);
        }
    }
}

impl Drop for ActiveStoreLockGuard {
    fn drop(&mut self) {
        self.release_locked_handle();
        if !self.leaked {
            let _ = fs::remove_file(&self.lock_companion_path);
            let _ = fs::remove_file(&self.path);
        }
    }
}

fn render_marker_body(payload: &LockMarkerPayload) -> String {
    format!(
        "{header}\npid={pid}\nhost={host}\ndeployment_id={deployment_id}\noperator_principal_id={operator_principal_id}\nacquired_at={acquired_at}\nscope={scope}\nrestore_intent_blake3={intent_digest}\n",
        header = MARKER_HEADER,
        pid = std::process::id(),
        host = payload.host,
        deployment_id = payload.deployment_id,
        operator_principal_id = payload.operator_principal_id,
        acquired_at = payload.acquired_at.to_rfc3339(),
        scope = MARKER_SCOPE_PRODUCTION,
        intent_digest = payload.restore_intent_blake3,
    )
}

fn make_companion_path(marker: &Path) -> PathBuf {
    let mut companion = marker.as_os_str().to_os_string();
    companion.push(LOCK_COMPANION_SUFFIX);
    PathBuf::from(companion)
}

/// Quarantine a stale marker by renaming it to a timestamped sibling. The
/// caller invokes this only after Ed25519 attestation has validated the
/// takeover request (see `intent.rs`).
pub fn quarantine_stale_marker(path: &Path) -> Result<PathBuf, LockError> {
    let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ");
    let stale_name = match path.file_name().and_then(|name| name.to_str()) {
        Some(name) => format!("{name}.stale-{timestamp}"),
        None => format!(".cortex-restore-active-store.lock.stale-{timestamp}"),
    };
    let stale_path = path
        .parent()
        .map(|parent| parent.join(&stale_name))
        .unwrap_or_else(|| PathBuf::from(&stale_name));
    fs::rename(path, &stale_path).map_err(|err| LockError::Io {
        operation: "stale marker rename",
        path: path.to_path_buf(),
        message: err.to_string(),
    })?;
    Ok(stale_path)
}

/// Try to read a marker file. Returns `None` if the file is absent. Used by
/// the takeover path to extract `pid`/`acquired_at` for the attestation
/// payload (the operator must explicitly name the stale process).
pub fn read_marker_file(path: &Path) -> Result<Option<String>, LockError> {
    match fs::read_to_string(path) {
        Ok(text) => Ok(Some(text)),
        Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
        Err(err) => Err(LockError::Io {
            operation: "marker read",
            path: path.to_path_buf(),
            message: err.to_string(),
        }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn fixture_payload() -> LockMarkerPayload {
        LockMarkerPayload {
            deployment_id: "dep-test-0001".to_string(),
            operator_principal_id: "operator-test".to_string(),
            restore_intent_blake3:
                "blake3:0000000000000000000000000000000000000000000000000000000000000000"
                    .to_string(),
            acquired_at: Utc::now(),
            host: "test-host".to_string(),
        }
    }

    #[test]
    fn acquire_creates_marker_and_releases_on_drop() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("active-store.lock");
        {
            let guard = ActiveStoreLockGuard::acquire(&path, fixture_payload()).unwrap();
            assert!(guard.marker_path().exists());
            let body = fs::read_to_string(guard.marker_path()).unwrap();
            assert!(body.starts_with(MARKER_HEADER));
            assert!(body.contains("scope=production"));
            assert!(body.contains("deployment_id=dep-test-0001"));
        }
        assert!(!path.exists(), "drop must clean the marker");
    }

    #[test]
    fn second_acquire_fails_while_first_is_held() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("active-store.lock");
        let _first = ActiveStoreLockGuard::acquire(&path, fixture_payload()).unwrap();
        match ActiveStoreLockGuard::acquire(&path, fixture_payload()) {
            Err(LockError::MarkerAlreadyExists { .. }) => {}
            other => panic!("expected MarkerAlreadyExists, got {other:?}"),
        }
    }

    #[test]
    fn quarantine_renames_stale_marker() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("active-store.lock");
        fs::write(&path, "stale-body").unwrap();
        let stale = quarantine_stale_marker(&path).unwrap();
        assert!(!path.exists());
        assert!(stale.exists());
        let text = fs::read_to_string(&stale).unwrap();
        assert_eq!(text, "stale-body");
        assert!(stale
            .file_name()
            .unwrap()
            .to_string_lossy()
            .contains(".stale-"));
    }

    #[test]
    fn leak_keeps_marker_after_drop() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("active-store.lock");
        let path_clone = path.clone();
        {
            let mut guard = ActiveStoreLockGuard::acquire(&path, fixture_payload()).unwrap();
            guard.leak_for_rollback_failure();
        }
        assert!(
            path_clone.exists(),
            "leak_for_rollback_failure preserves marker"
        );
        // Cleanup so tempdir drop succeeds.
        let _ = fs::remove_file(path_clone);
    }
}