Skip to main content

lean_ctx/core/
workspace_authority.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Launcher-bound Workspace authority (#2120).
3//!
4//! Without it, the roots a session may reach come from process-wide knobs
5//! (`extra_roots`, `allow_paths`, `LEAN_CTX_ALLOW_PATH`, the `home` jail scope)
6//! and from what the client reports (`roots/list`, Cursor's workspace env,
7//! `ctx_multi_repo add_root`). None of that can express "this MCP server may
8//! reach exactly these roots, and this one has just been detached".
9//!
10//! A trusted launcher opts in by starting the MCP server with
11//! `LEAN_CTX_WORKSPACE_FILE=<absolute path>`. The file names one Workspace: a
12//! primary root, additional roots (read-write or read-only) and a revision.
13//! While it is set the file is the **only** root authority of the process:
14//!
15//! - every tool path is checked against the request's Workspace snapshot
16//!   (`pathjail`), never against client roots, config/env widening, the
17//!   `home`/`projects` jail scope or auto-admitted cache folders;
18//! - each tool call takes one immutable snapshot, so a call sees one revision
19//!   from start to end, and the next call sees a root removed in between;
20//! - a file that is missing, unreadable, malformed or insecurely stored denies
21//!   every path (fail closed) — it never falls back to the legacy roots.
22//!
23//! The client never chooses the Workspace: the environment of the MCP server
24//! process is set by its launcher, and nothing a client sends (MCP roots,
25//! `_meta`, thread or workspace ids) is consulted. Shell commands run by
26//! `ctx_shell` are processes of their own; the Workspace bounds their working
27//! directory, not what a command reads.
28//!
29//! Mutations (`lean-ctx workspace create|add|remove`) are compare-and-swap on
30//! the revision under an exclusive lock, written atomically with mode 0600.
31//! Records hold paths and the revision only — no timestamps — so every text
32//! built from them is a deterministic function of the stored state (#498).
33
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36
37use serde::{Deserialize, Serialize};
38
39/// Environment variable through which a launcher binds the process.
40pub const ENV_FILE: &str = "LEAN_CTX_WORKSPACE_FILE";
41/// Schema identifier of a Workspace file.
42pub const SCHEMA: &str = "lean-ctx.workspace/v1";
43/// Largest Workspace file read.
44const MAX_FILE_BYTES: u64 = 64 * 1024;
45/// Most additional roots one Workspace holds.
46pub const MAX_ROOTS: usize = 64;
47/// Longest Workspace id.
48const MAX_ID_LEN: usize = 64;
49
50/// Access an attached root grants.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum Access {
54    ReadWrite,
55    ReadOnly,
56}
57
58impl Access {
59    pub fn as_str(self) -> &'static str {
60        match self {
61            Self::ReadWrite => "read_write",
62            Self::ReadOnly => "read_only",
63        }
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69struct RootRecord {
70    path: String,
71    access: Access,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76struct WorkspaceRecord {
77    schema: String,
78    id: String,
79    revision: u64,
80    primary: String,
81    #[serde(default)]
82    roots: Vec<RootRecord>,
83}
84
85/// One attached additional root.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct AttachedRoot {
88    pub path: PathBuf,
89    pub access: Access,
90}
91
92/// An immutable view of one Workspace revision.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Snapshot {
95    pub file: PathBuf,
96    pub id: String,
97    pub revision: u64,
98    /// Canonical primary root (read-write).
99    pub primary: PathBuf,
100    /// Canonical additional roots that exist; see [`Snapshot::unavailable`].
101    pub roots: Vec<AttachedRoot>,
102    /// Attached roots that no longer resolve to the recorded directory
103    /// (deleted, or replaced by a symlink). They grant nothing.
104    pub unavailable: Vec<String>,
105}
106
107impl Snapshot {
108    /// Every root that grants access, primary first.
109    pub fn granting_roots(&self) -> impl Iterator<Item = (&Path, Access)> {
110        std::iter::once((self.primary.as_path(), Access::ReadWrite)).chain(
111            self.roots
112                .iter()
113                .map(|root| (root.path.as_path(), root.access)),
114        )
115    }
116
117    /// The additional roots as strings, for the request's tool context.
118    pub fn additional_root_strings(&self) -> Vec<String> {
119        self.roots
120            .iter()
121            .map(|root| root.path.to_string_lossy().into_owned())
122            .collect()
123    }
124
125    /// The granting root containing the canonical `path`, if any. The most
126    /// specific root wins, so a read-only root nested in a read-write one
127    /// stays read-only.
128    pub fn root_for(&self, canonical: &Path) -> Option<(&Path, Access)> {
129        self.granting_roots()
130            .filter(|(root, _)| canonical.starts_with(root))
131            .max_by_key(|(root, _)| root.components().count())
132    }
133
134    /// True when the canonical `path` lies in a granting root.
135    pub fn admits(&self, canonical: &Path) -> bool {
136        self.root_for(canonical).is_some()
137    }
138
139    /// True when the canonical `path` lies in a read-write root.
140    pub fn admits_write(&self, canonical: &Path) -> bool {
141        self.root_for(canonical)
142            .is_some_and(|(_, access)| access == Access::ReadWrite)
143    }
144
145    /// The request's project root: `wanted` when a granting root contains
146    /// it, else the primary root.
147    pub fn project_root_for(&self, wanted: &str) -> String {
148        // Unresolvable (or timed out) means the primary, never a lexical path.
149        if let Ok(canonical) = crate::core::pathutil::canonicalize_secure(Path::new(wanted))
150            && self.admits(&canonical)
151        {
152            return canonical.to_string_lossy().into_owned();
153        }
154        self.primary.to_string_lossy().into_owned()
155    }
156
157    /// One line naming the binding, for MCP status output.
158    pub fn status_line(&self) -> String {
159        let mut line = format!(
160            "Workspace: {} rev {} (launcher-bound) — primary {}",
161            self.id,
162            self.revision,
163            self.primary.display()
164        );
165        for root in &self.roots {
166            line.push_str(&format!(
167                "; {} ({})",
168                root.path.display(),
169                root.access.as_str()
170            ));
171        }
172        if !self.unavailable.is_empty() {
173            line.push_str(&format!("; unavailable: {}", self.unavailable.join(", ")));
174        }
175        line
176    }
177
178    /// Structured form for `lean-ctx workspace show --json` and `/api/scope`.
179    pub fn to_json(&self) -> serde_json::Value {
180        serde_json::json!({
181            "schema": SCHEMA,
182            "file": self.file,
183            "id": self.id,
184            "revision": self.revision,
185            "primary": self.primary,
186            "roots": self.roots.iter().map(|root| serde_json::json!({
187                "path": root.path,
188                "access": root.access.as_str(),
189            })).collect::<Vec<_>>(),
190            "unavailable": self.unavailable,
191        })
192    }
193}
194
195/// The root authority in force.
196#[derive(Debug, Clone)]
197pub enum Authority {
198    /// No Workspace configured: the legacy root rules apply unchanged.
199    Legacy,
200    /// A valid Workspace bounds every path.
201    Workspace(Arc<Snapshot>),
202    /// A Workspace is configured but cannot be used: every path is denied.
203    Unavailable(String),
204}
205
206impl Authority {
207    pub fn is_active(&self) -> bool {
208        !matches!(self, Self::Legacy)
209    }
210
211    pub fn snapshot(&self) -> Option<&Arc<Snapshot>> {
212        match self {
213            Self::Workspace(snapshot) => Some(snapshot),
214            _ => None,
215        }
216    }
217}
218
219tokio::task_local! {
220    /// The Workspace snapshot one tool call was admitted under.
221    static REQUEST: Authority;
222}
223
224/// The configured Workspace file, if the launcher set one. An empty value is
225/// treated as set-but-invalid, never as "off": a launcher that meant to bind
226/// the process must not silently get the legacy roots.
227pub fn configured_file() -> Option<Result<PathBuf, String>> {
228    let raw = std::env::var_os(ENV_FILE)?;
229    let path = PathBuf::from(&raw);
230    if raw.is_empty() {
231        return Some(Err(format!("{ENV_FILE} is set but empty")));
232    }
233    if !path.is_absolute() {
234        return Some(Err(format!(
235            "{ENV_FILE} must be an absolute path (got {})",
236            path.display()
237        )));
238    }
239    Some(Ok(path))
240}
241
242/// True when a Workspace (valid or not) bounds this call: the process is
243/// bound by its launcher, or the running call was admitted under one.
244pub fn is_configured() -> bool {
245    REQUEST.try_with(Authority::is_active).unwrap_or(false) || std::env::var_os(ENV_FILE).is_some()
246}
247
248/// The authority for the running tool call: its admitted snapshot, else the
249/// current file state.
250pub fn current() -> Authority {
251    REQUEST
252        .try_with(Clone::clone)
253        .unwrap_or_else(|_| load_current())
254}
255
256/// Load the current Workspace state from the launcher-set file.
257pub fn load_current() -> Authority {
258    match configured_file() {
259        None => Authority::Legacy,
260        Some(Err(reason)) => Authority::Unavailable(reason),
261        Some(Ok(path)) => match load_cached(&path) {
262            Ok(snapshot) => Authority::Workspace(snapshot),
263            Err(reason) => Authority::Unavailable(reason),
264        },
265    }
266}
267
268/// Run one async tool call under `authority`.
269pub async fn scope<F: std::future::Future>(authority: Authority, request: F) -> F::Output {
270    REQUEST.scope(authority, request).await
271}
272
273/// Run one synchronous handler under `authority` (task locals do not reach
274/// the blocking pool).
275pub fn sync_scope<R>(authority: Authority, operation: impl FnOnce() -> R) -> R {
276    REQUEST.sync_scope(authority, operation)
277}
278
279/// Whether a root-scoped store (an index, a repo root) may serve `root` now:
280/// always without a Workspace; under one only when the request's snapshot
281/// contains it; never while the Workspace is unusable.
282pub fn root_authorized(root: &Path) -> bool {
283    match current() {
284        Authority::Legacy => true,
285        Authority::Workspace(snapshot) => crate::core::pathutil::canonicalize_secure(root)
286            .is_ok_and(|canonical| snapshot.admits(&canonical)),
287        Authority::Unavailable(_) => false,
288    }
289}
290
291/// Confine a model-supplied path that a handler takes straight from its
292/// arguments. Under a Workspace it must resolve inside the snapshot —
293/// relative paths against `project_root` — and, for `write`, inside a
294/// read-write root. Without a Workspace a read keeps the handler's own rules
295/// (the path comes back unchanged), but a write destination is still put
296/// through the project's path jail: those handlers write exactly where the
297/// path points (GHSA-483j-9vcj-2gxx).
298pub fn confine(raw: &str, project_root: &str, write: bool) -> Result<String, String> {
299    let workspace = current().is_active();
300    if !workspace && !write {
301        return Ok(raw.to_string());
302    }
303    let root = if project_root.is_empty() {
304        "."
305    } else {
306        project_root
307    };
308    let jailed = crate::core::pathjail::jail_path(Path::new(raw), Path::new(root))
309        .map_err(|error| error.to_string())?;
310    if write {
311        crate::core::pathjail::enforce_writable(&jailed)?;
312    }
313    Ok(jailed.to_string_lossy().into_owned())
314}
315
316/// What `""`/`"."` (the project itself) means for a tool path: unchanged
317/// without a Workspace; under one the call's root in the snapshot, never the
318/// server's own working directory.
319pub fn project_dot(normalized: &str, project_root: Option<&str>) -> Result<String, String> {
320    match current() {
321        Authority::Legacy => Ok(normalized.to_string()),
322        Authority::Workspace(snapshot) => Ok(snapshot.project_root_for(project_root.unwrap_or(""))),
323        Authority::Unavailable(reason) => Err(unavailable_reason(&reason)),
324    }
325}
326
327/// The denial text for a configured but unusable Workspace.
328pub fn unavailable_reason(reason: &str) -> String {
329    format!(
330        "workspace authority unavailable ({ENV_FILE}): {reason} — every path is denied until the \
331         Workspace file is valid; inspect it with `lean-ctx workspace diagnose`"
332    )
333}
334
335// ── Loading ─────────────────────────────────────────────────────────────────
336
337/// Load the Workspace for a call. The file is at most 64 KiB and re-read on
338/// every call: no cache can serve a revision the file no longer holds, and
339/// every root is re-resolved each time.
340fn load_cached(path: &Path) -> Result<Arc<Snapshot>, String> {
341    load(path).map(Arc::new)
342}
343
344/// Load and validate the Workspace at `path`.
345pub fn load(path: &Path) -> Result<Snapshot, String> {
346    let (file, meta) = open_checked(path)?;
347    parse_open(path, file, &meta)
348}
349
350fn parse_open(
351    path: &Path,
352    file: std::fs::File,
353    meta: &std::fs::Metadata,
354) -> Result<Snapshot, String> {
355    use std::io::Read as _;
356    if meta.len() > MAX_FILE_BYTES {
357        return Err(format!(
358            "{} is larger than {MAX_FILE_BYTES} bytes",
359            path.display()
360        ));
361    }
362    let mut text = String::new();
363    file.take(MAX_FILE_BYTES + 1)
364        .read_to_string(&mut text)
365        .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
366    if text.len() as u64 > MAX_FILE_BYTES {
367        return Err(format!(
368            "{} is larger than {MAX_FILE_BYTES} bytes",
369            path.display()
370        ));
371    }
372    let record: WorkspaceRecord = serde_json::from_str(&text)
373        .map_err(|error| format!("{} is not a valid Workspace file: {error}", path.display()))?;
374    snapshot_from(path, &record)
375}
376
377fn snapshot_from(path: &Path, record: &WorkspaceRecord) -> Result<Snapshot, String> {
378    if record.schema != SCHEMA {
379        return Err(format!(
380            "unsupported Workspace schema '{}' (expected {SCHEMA})",
381            record.schema
382        ));
383    }
384    validate_id(&record.id)?;
385    if record.revision == 0 {
386        return Err("Workspace revision must be at least 1".to_string());
387    }
388    if record.roots.len() > MAX_ROOTS {
389        return Err(format!(
390            "a Workspace holds at most {MAX_ROOTS} additional roots"
391        ));
392    }
393    let primary = resolve_recorded_root(&record.primary)
394        .map_err(|reason| format!("primary root {}: {reason}", record.primary))?;
395    let mut roots: Vec<AttachedRoot> = Vec::new();
396    let mut unavailable = Vec::new();
397    let mut seen = vec![record.primary.clone()];
398    for entry in &record.roots {
399        if seen.contains(&entry.path) {
400            return Err(format!("root {} is listed twice", entry.path));
401        }
402        seen.push(entry.path.clone());
403        match resolve_recorded_root(&entry.path) {
404            Ok(path) => roots.push(AttachedRoot {
405                path,
406                access: entry.access,
407            }),
408            // A recorded root that is gone (or no longer the directory it
409            // was) grants nothing; it does not take the whole Workspace down.
410            Err(RootError::Unavailable(_)) => unavailable.push(entry.path.clone()),
411            Err(RootError::Invalid(reason)) => {
412                return Err(format!("root {}: {reason}", entry.path));
413            }
414        }
415    }
416    let snapshot = Snapshot {
417        file: path.to_path_buf(),
418        id: record.id.clone(),
419        revision: record.revision,
420        primary,
421        roots,
422        unavailable,
423    };
424    // The file is the agent's authority: inside one of its own roots, the
425    // agent could edit it with the very tools it bounds.
426    let location = canonical_location(path)?;
427    if snapshot.admits(&location) {
428        return Err(format!(
429            "the Workspace file {} lies inside one of its own roots; keep it outside every root",
430            location.display()
431        ));
432    }
433    // lean-ctx's own directories are written by the engine for every
434    // project; the file must not be one more thing in them.
435    if own_dirs().iter().any(|dir| location.starts_with(dir)) {
436        return Err(format!(
437            "the Workspace file {} lies inside lean-ctx's own data, state, cache or config \
438             directory; keep it in a directory of the launcher",
439            location.display()
440        ));
441    }
442    Ok(snapshot)
443}
444
445enum RootError {
446    /// Malformed or forbidden — the record itself is wrong.
447    Invalid(String),
448    /// Well-formed but not currently a directory at that canonical path.
449    Unavailable(String),
450}
451
452impl std::fmt::Display for RootError {
453    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454        match self {
455            Self::Invalid(reason) | Self::Unavailable(reason) => f.write_str(reason),
456        }
457    }
458}
459
460/// A recorded root must be the canonical path of an existing directory: a
461/// directory later replaced by a symlink (pointing anywhere) stops granting.
462fn resolve_recorded_root(raw: &str) -> Result<PathBuf, RootError> {
463    let path = Path::new(raw);
464    check_root_shape(path).map_err(RootError::Invalid)?;
465    match crate::core::pathutil::canonicalize_secure(path) {
466        Ok(canonical) if canonical == path && canonical.is_dir() => Ok(canonical),
467        Ok(canonical) if canonical != path => Err(RootError::Unavailable(format!(
468            "now resolves to {} (not recorded)",
469            canonical.display()
470        ))),
471        Ok(_) => Err(RootError::Unavailable("is not a directory".to_string())),
472        Err(error) => Err(RootError::Unavailable(error.to_string())),
473    }
474}
475
476/// Shape rules for any root: absolute, no `..`, not a broad directory (`/`,
477/// home or one of its ancestors, agent config dirs), not lean-ctx's own data.
478fn check_root_shape(path: &Path) -> Result<(), String> {
479    if !path.is_absolute() {
480        return Err("must be an absolute path".to_string());
481    }
482    if path.components().any(|c| {
483        matches!(
484            c,
485            std::path::Component::ParentDir | std::path::Component::CurDir
486        )
487    }) {
488        return Err("must not contain '.' or '..' components".to_string());
489    }
490    if path.to_string_lossy().as_bytes().contains(&0) {
491        return Err("contains a null byte".to_string());
492    }
493    if crate::core::pathutil::is_broad_or_unsafe_root(path) {
494        return Err("is a broad or unsafe directory (/, home, agent config)".to_string());
495    }
496    if let Some(home) = dirs::home_dir() {
497        let canonical_home =
498            crate::core::pathutil::canonicalize_secure(&home).unwrap_or(home.clone());
499        if home.starts_with(path) || canonical_home.starts_with(path) {
500            return Err("contains the home directory".to_string());
501        }
502    }
503    for own in own_dirs() {
504        if path.starts_with(&own) || own.starts_with(path) {
505            return Err("overlaps lean-ctx's own data directory".to_string());
506        }
507    }
508    Ok(())
509}
510
511/// lean-ctx's own directories (canonical where they exist).
512fn own_dirs() -> Vec<PathBuf> {
513    [
514        crate::core::data_dir::lean_ctx_data_dir().ok(),
515        crate::core::paths::state_dir_read_only().ok(),
516        crate::core::paths::cache_dir_read_only().ok(),
517        crate::core::paths::config_dir_read_only().ok(),
518    ]
519    .into_iter()
520    .flatten()
521    .map(|dir| crate::core::pathutil::canonicalize_secure(&dir).unwrap_or(dir))
522    .collect()
523}
524
525fn validate_id(id: &str) -> Result<(), String> {
526    let valid = !id.is_empty()
527        && id.len() <= MAX_ID_LEN
528        && id
529            .chars()
530            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
531        && !id.starts_with('.');
532    if valid {
533        Ok(())
534    } else {
535        Err(format!(
536            "Workspace id must be 1-{MAX_ID_LEN} characters of A-Z a-z 0-9 . _ - (not starting with '.')"
537        ))
538    }
539}
540
541// ── Storage security ────────────────────────────────────────────────────────
542
543/// Ownership and permission rule for the Workspace file: owned by the
544/// process's effective user and not accessible to group or others.
545#[cfg_attr(not(unix), allow(dead_code))]
546fn check_file_owner_mode(uid: u32, mode: u32, euid: u32) -> Result<(), String> {
547    if uid != euid {
548        return Err(format!(
549            "is owned by uid {uid}, not by this process's user (uid {euid})"
550        ));
551    }
552    if mode & 0o077 != 0 {
553        return Err(format!(
554            "has mode {:o}; it must not be accessible to group or others (chmod 600)",
555            mode & 0o7777
556        ));
557    }
558    Ok(())
559}
560
561/// Rule for each directory above the Workspace file: owned by this user or
562/// root, and not writable by group or others unless sticky (so no other user
563/// can rename the file or a directory on its path).
564#[cfg_attr(not(unix), allow(dead_code))]
565fn check_dir_owner_mode(uid: u32, mode: u32, euid: u32) -> Result<(), String> {
566    if uid != euid && uid != 0 {
567        return Err(format!(
568            "is owned by uid {uid}, neither this user (uid {euid}) nor root"
569        ));
570    }
571    if mode & 0o022 != 0 && mode & 0o1000 == 0 {
572        return Err(format!(
573            "has mode {:o}: writable by group or others without the sticky bit",
574            mode & 0o7777
575        ));
576    }
577    Ok(())
578}
579
580#[cfg(unix)]
581fn euid() -> u32 {
582    // SAFETY: geteuid has no arguments or failure state.
583    unsafe { libc::geteuid() }
584}
585
586/// Canonical location of the Workspace file: its directory resolved, the file
587/// name kept (the file itself must not be a symlink).
588fn canonical_location(path: &Path) -> Result<PathBuf, String> {
589    let name = path
590        .file_name()
591        .ok_or_else(|| format!("{} has no file name", path.display()))?;
592    let parent = path
593        .parent()
594        .ok_or_else(|| format!("{} has no parent directory", path.display()))?;
595    let parent = crate::core::pathutil::canonicalize_secure(parent)
596        .map_err(|error| format!("directory of {}: {error}", path.display()))?;
597    Ok(parent.join(name))
598}
599
600#[cfg(unix)]
601fn check_ancestors(file: &Path) -> Result<(), String> {
602    use std::os::unix::fs::MetadataExt as _;
603    let euid = euid();
604    let mut dir = file.parent();
605    while let Some(current) = dir {
606        let meta = std::fs::symlink_metadata(current)
607            .map_err(|error| format!("directory {}: {error}", current.display()))?;
608        check_dir_owner_mode(meta.uid(), meta.mode(), euid)
609            .map_err(|reason| format!("directory {} {reason}", current.display()))?;
610        dir = current.parent();
611    }
612    Ok(())
613}
614
615/// Ownership and ACLs of the file and its directories are not verified on
616/// this platform yet, so a Workspace cannot be trusted here: fail closed.
617#[cfg(not(unix))]
618fn check_ancestors(_file: &Path) -> Result<(), String> {
619    Err(
620        "Workspace authority needs a Unix host: file ownership cannot be verified on this \
621         platform yet"
622            .to_string(),
623    )
624}
625
626/// Open the Workspace file without following a symlink and check what was
627/// opened (not a path re-resolved later).
628fn open_checked(path: &Path) -> Result<(std::fs::File, std::fs::Metadata), String> {
629    let location = canonical_location(path)?;
630    check_ancestors(&location)?;
631    let mut options = std::fs::OpenOptions::new();
632    options.read(true);
633    #[cfg(unix)]
634    {
635        use std::os::unix::fs::OpenOptionsExt as _;
636        options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
637    }
638    let link = std::fs::symlink_metadata(&location)
639        .map_err(|error| format!("cannot open {}: {error}", location.display()))?;
640    if link.file_type().is_symlink() {
641        return Err(format!(
642            "{} is a symlink; a Workspace file must not be",
643            location.display()
644        ));
645    }
646    let file = options
647        .open(&location)
648        .map_err(|error| format!("cannot open {}: {error}", location.display()))?;
649    let meta = file
650        .metadata()
651        .map_err(|error| format!("cannot inspect {}: {error}", location.display()))?;
652    if !meta.is_file() {
653        return Err(format!("{} is not a regular file", location.display()));
654    }
655    #[cfg(unix)]
656    {
657        use std::os::unix::fs::MetadataExt as _;
658        check_file_owner_mode(meta.uid(), meta.mode(), euid())
659            .map_err(|reason| format!("{} {reason}", location.display()))?;
660    }
661    Ok((file, meta))
662}
663
664// ── Mutation (CLI) ──────────────────────────────────────────────────────────
665
666/// Exclusive lock beside the Workspace file, held for one mutation.
667struct MutationLock {
668    _file: std::fs::File,
669}
670
671fn lock_path(location: &Path) -> PathBuf {
672    let mut name = location.file_name().unwrap_or_default().to_os_string();
673    name.push(".lock");
674    location.with_file_name(name)
675}
676
677fn acquire_lock(location: &Path) -> Result<MutationLock, String> {
678    use fs2::FileExt as _;
679    let path = lock_path(location);
680    let mut options = std::fs::OpenOptions::new();
681    options.read(true).write(true).create(true).truncate(false);
682    #[cfg(unix)]
683    {
684        use std::os::unix::fs::OpenOptionsExt as _;
685        options.mode(0o600);
686        options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
687    }
688    let file = options
689        .open(&path)
690        .map_err(|error| format!("cannot open lock {}: {error}", path.display()))?;
691    #[cfg(unix)]
692    {
693        use std::os::unix::fs::MetadataExt as _;
694        let meta = file
695            .metadata()
696            .map_err(|error| format!("cannot inspect lock {}: {error}", path.display()))?;
697        check_file_owner_mode(meta.uid(), meta.mode(), euid())
698            .map_err(|reason| format!("lock {} {reason}", path.display()))?;
699    }
700    file.lock_exclusive()
701        .map_err(|error| format!("cannot lock {}: {error}", path.display()))?;
702    Ok(MutationLock { _file: file })
703}
704
705fn write_record(location: &Path, record: &WorkspaceRecord) -> Result<(), String> {
706    let mut bytes = serde_json::to_vec_pretty(record).map_err(|error| error.to_string())?;
707    bytes.push(b'\n');
708    #[cfg(unix)]
709    let permissions = {
710        use std::os::unix::fs::PermissionsExt as _;
711        Some(std::fs::Permissions::from_mode(0o600))
712    };
713    #[cfg(not(unix))]
714    let permissions: Option<std::fs::Permissions> = None;
715    crate::core::atomic_fs::try_atomic_write(location, &bytes, permissions.as_ref())
716        .map_err(|error| format!("cannot write {}: {error}", location.display()))?;
717    if let Some(parent) = location.parent() {
718        crate::core::atomic_fs::sync_directory(parent)
719            .map_err(|error| format!("cannot sync {}: {error}", parent.display()))?;
720    }
721    Ok(())
722}
723
724fn read_record(location: &Path) -> Result<WorkspaceRecord, String> {
725    use std::io::Read as _;
726    let (file, meta) = open_checked(location)?;
727    if meta.len() > MAX_FILE_BYTES {
728        return Err(format!(
729            "{} is larger than {MAX_FILE_BYTES} bytes",
730            location.display()
731        ));
732    }
733    let mut text = String::new();
734    file.take(MAX_FILE_BYTES + 1)
735        .read_to_string(&mut text)
736        .map_err(|error| format!("cannot read {}: {error}", location.display()))?;
737    let record: WorkspaceRecord = serde_json::from_str(&text).map_err(|error| {
738        format!(
739            "{} is not a valid Workspace file: {error}",
740            location.display()
741        )
742    })?;
743    // A mutation starts only from a valid Workspace.
744    snapshot_from(location, &record)?;
745    Ok(record)
746}
747
748/// Canonical form of a root given on the command line: it must exist now.
749fn canonical_new_root(raw: &Path) -> Result<String, String> {
750    let absolute = if raw.is_absolute() {
751        raw.to_path_buf()
752    } else {
753        std::env::current_dir()
754            .map_err(|error| error.to_string())?
755            .join(raw)
756    };
757    let canonical = crate::core::pathutil::canonicalize_secure(&absolute)
758        .map_err(|error| format!("{}: {error}", absolute.display()))?;
759    if !canonical.is_dir() {
760        return Err(format!("{} is not a directory", canonical.display()));
761    }
762    check_root_shape(&canonical).map_err(|reason| format!("{}: {reason}", canonical.display()))?;
763    Ok(canonical.to_string_lossy().into_owned())
764}
765
766fn ensure_absolute_file(path: &Path) -> Result<(), String> {
767    if path.is_absolute() {
768        Ok(())
769    } else {
770        Err(format!(
771            "the Workspace file must be an absolute path (got {})",
772            path.display()
773        ))
774    }
775}
776
777/// Create a new Workspace file (fails if one exists). Returns revision 1.
778pub fn create(file: &Path, id: &str, primary: &Path) -> Result<Snapshot, String> {
779    ensure_absolute_file(file)?;
780    validate_id(id)?;
781    let primary = canonical_new_root(primary)?;
782    let location = canonical_location(file)?;
783    check_ancestors(&location)?;
784    let _lock = acquire_lock(&location)?;
785    if std::fs::symlink_metadata(&location).is_ok() {
786        return Err(format!("{} already exists", location.display()));
787    }
788    let record = WorkspaceRecord {
789        schema: SCHEMA.to_string(),
790        id: id.to_string(),
791        revision: 1,
792        primary,
793        roots: Vec::new(),
794    };
795    snapshot_from(&location, &record)?;
796    write_record(&location, &record)?;
797    load(&location)
798}
799
800/// Attach `root` if the stored revision is still `expect_revision`.
801pub fn add_root(
802    file: &Path,
803    root: &Path,
804    access: Access,
805    expect_revision: u64,
806) -> Result<Snapshot, String> {
807    mutate(file, expect_revision, |record| {
808        let root = canonical_new_root(root)?;
809        if root == record.primary || record.roots.iter().any(|entry| entry.path == root) {
810            return Err(format!("{root} is already attached"));
811        }
812        if record.roots.len() >= MAX_ROOTS {
813            return Err(format!(
814                "a Workspace holds at most {MAX_ROOTS} additional roots"
815            ));
816        }
817        record.roots.push(RootRecord { path: root, access });
818        Ok(())
819    })
820}
821
822/// Detach `root` if the stored revision is still `expect_revision`. The
823/// primary root cannot be detached. `root` is matched as recorded, so a root
824/// that no longer exists can still be removed.
825pub fn remove_root(file: &Path, root: &Path, expect_revision: u64) -> Result<Snapshot, String> {
826    mutate(file, expect_revision, |record| {
827        let wanted = crate::core::pathutil::canonicalize_secure(root)
828            .map(|p| p.to_string_lossy().into_owned())
829            .unwrap_or_else(|_| root.to_string_lossy().into_owned());
830        let raw = root.to_string_lossy().into_owned();
831        if wanted == record.primary || raw == record.primary {
832            return Err("the primary root cannot be detached".to_string());
833        }
834        let before = record.roots.len();
835        record
836            .roots
837            .retain(|entry| entry.path != wanted && entry.path != raw);
838        if record.roots.len() == before {
839            return Err(format!("{raw} is not attached"));
840        }
841        Ok(())
842    })
843}
844
845fn mutate(
846    file: &Path,
847    expect_revision: u64,
848    change: impl FnOnce(&mut WorkspaceRecord) -> Result<(), String>,
849) -> Result<Snapshot, String> {
850    ensure_absolute_file(file)?;
851    let location = canonical_location(file)?;
852    let _lock = acquire_lock(&location)?;
853    let mut record = read_record(&location)?;
854    if record.revision != expect_revision {
855        return Err(format!(
856            "revision conflict: the Workspace is at revision {}, not {expect_revision} — \
857             re-read it with `lean-ctx workspace show` and retry",
858            record.revision
859        ));
860    }
861    change(&mut record)?;
862    record.revision = record
863        .revision
864        .checked_add(1)
865        .ok_or_else(|| "revision overflow".to_string())?;
866    snapshot_from(&location, &record)?;
867    write_record(&location, &record)?;
868    load(&location)
869}
870
871// ── Diagnostics ─────────────────────────────────────────────────────────────
872
873/// What `lean-ctx workspace diagnose` and `doctor` report.
874pub fn diagnose_lines() -> Vec<String> {
875    match configured_file() {
876        None => vec![format!(
877            "no Workspace bound ({ENV_FILE} unset): legacy root rules apply"
878        )],
879        Some(Err(reason)) => vec![unavailable_reason(&reason)],
880        Some(Ok(path)) => match load(&path) {
881            Ok(snapshot) => {
882                let mut lines = vec![snapshot.status_line()];
883                lines.push(
884                    "client roots, extra_roots/allow_paths, the home jail scope and \
885                     ctx_multi_repo roots grant nothing while bound"
886                        .to_string(),
887                );
888                lines
889            }
890            Err(reason) => vec![unavailable_reason(&reason)],
891        },
892    }
893}
894
895// A Workspace is refused on non-Unix hosts (ownership is not verifiable
896// there), so the store and enforcement tests run on Unix.
897#[cfg(all(test, unix))]
898#[path = "workspace_authority_tests.rs"]
899mod enforcement_tests;
900
901#[cfg(all(test, unix))]
902mod tests {
903    use super::*;
904
905    fn private_dir() -> tempfile::TempDir {
906        let dir = tempfile::tempdir().unwrap();
907        #[cfg(unix)]
908        {
909            use std::os::unix::fs::PermissionsExt as _;
910            std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
911        }
912        dir
913    }
914
915    fn project(base: &Path, name: &str) -> PathBuf {
916        let path = base.join(name);
917        std::fs::create_dir_all(&path).unwrap();
918        crate::core::pathutil::canonicalize_secure(&path).unwrap()
919    }
920
921    #[test]
922    fn create_add_remove_with_cas() {
923        let store = private_dir();
924        let code = tempfile::tempdir().unwrap();
925        let primary = project(code.path(), "app");
926        let lib = project(code.path(), "lib");
927        let file = store.path().join("ws.json");
928
929        let created = create(&file, "team-a", &primary).unwrap();
930        assert_eq!(created.revision, 1);
931        assert_eq!(created.primary, primary);
932        assert!(created.roots.is_empty());
933
934        let added = add_root(&file, &lib, Access::ReadOnly, 1).unwrap();
935        assert_eq!(added.revision, 2);
936        assert_eq!(added.roots[0].path, lib);
937        assert!(added.admits(&lib.join("src/x.rs")));
938        assert!(!added.admits_write(&lib.join("src/x.rs")));
939        assert!(added.admits_write(&primary.join("main.rs")));
940
941        // A stale writer loses.
942        let stale = add_root(&file, &lib, Access::ReadWrite, 1).unwrap_err();
943        assert!(stale.contains("revision conflict"), "{stale}");
944
945        let removed = remove_root(&file, &lib, 2).unwrap();
946        assert_eq!(removed.revision, 3);
947        assert!(!removed.admits(&lib.join("src/x.rs")));
948        assert!(
949            remove_root(&file, &primary, 3)
950                .unwrap_err()
951                .contains("primary")
952        );
953        assert!(
954            create(&file, "team-a", &primary)
955                .unwrap_err()
956                .contains("exists")
957        );
958    }
959
960    #[test]
961    fn nested_read_only_root_stays_read_only() {
962        let store = private_dir();
963        let code = tempfile::tempdir().unwrap();
964        let primary = project(code.path(), "app");
965        let vendored = project(&primary, "vendor");
966        let file = store.path().join("ws.json");
967        create(&file, "w", &primary).unwrap();
968        let snapshot = add_root(&file, &vendored, Access::ReadOnly, 1).unwrap();
969        assert!(snapshot.admits_write(&primary.join("a.rs")));
970        assert!(!snapshot.admits_write(&vendored.join("b.rs")));
971    }
972
973    #[test]
974    fn the_file_must_lie_outside_its_roots() {
975        let code = private_dir();
976        let primary = project(code.path(), "app");
977        let inside = primary.join("ws.json");
978        assert!(
979            create(&inside, "w", &primary)
980                .unwrap_err()
981                .contains("own roots")
982        );
983        assert!(!inside.exists());
984
985        let store = private_dir();
986        let file = store.path().join("ws.json");
987        create(&file, "w", &primary).unwrap();
988        let store_root = crate::core::pathutil::canonicalize_secure(store.path()).unwrap();
989        assert!(
990            add_root(&file, &store_root, Access::ReadOnly, 1)
991                .unwrap_err()
992                .contains("own roots")
993        );
994        assert_eq!(load(&file).unwrap().revision, 1);
995    }
996
997    #[test]
998    fn broad_and_own_roots_are_refused() {
999        let _home = crate::core::data_dir::test_env_lock();
1000        let store = private_dir();
1001        let file = store.path().join("ws.json");
1002        assert!(create(&file, "w", Path::new("/")).is_err());
1003        if let Some(home) = dirs::home_dir() {
1004            assert!(create(&file, "w", &home).is_err());
1005        }
1006        assert!(create(&file, "../x", store.path()).is_err(), "bad id");
1007        assert!(!file.exists());
1008    }
1009
1010    #[test]
1011    fn malformed_and_unknown_fields_fail_closed() {
1012        let store = private_dir();
1013        let code = tempfile::tempdir().unwrap();
1014        let primary = project(code.path(), "app");
1015        let file = store.path().join("ws.json");
1016        let write = |text: String| {
1017            std::fs::write(&file, text).unwrap();
1018            #[cfg(unix)]
1019            {
1020                use std::os::unix::fs::PermissionsExt as _;
1021                std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
1022            }
1023        };
1024        write("{".to_string());
1025        assert!(load(&file).is_err());
1026        write(format!(
1027            r#"{{"schema":"{SCHEMA}","id":"w","revision":1,"primary":{primary:?},"roots":[],"grant":"all"}}"#
1028        ));
1029        assert!(load(&file).unwrap_err().contains("unknown field"));
1030        write(format!(
1031            r#"{{"schema":"{SCHEMA}","id":"w","revision":0,"primary":{primary:?}}}"#
1032        ));
1033        assert!(load(&file).unwrap_err().contains("revision"));
1034        // A relative or non-canonical primary is refused.
1035        write(format!(
1036            r#"{{"schema":"{SCHEMA}","id":"w","revision":1,"primary":"{}/../app"}}"#,
1037            primary.display()
1038        ));
1039        assert!(load(&file).is_err());
1040    }
1041
1042    #[cfg(unix)]
1043    #[test]
1044    fn insecure_storage_is_refused() {
1045        use std::os::unix::fs::PermissionsExt as _;
1046        let store = private_dir();
1047        let code = tempfile::tempdir().unwrap();
1048        let primary = project(code.path(), "app");
1049        let file = store.path().join("ws.json");
1050        create(&file, "w", &primary).unwrap();
1051        assert!(load(&file).is_ok());
1052
1053        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
1054        assert!(load(&file).unwrap_err().contains("group or others"));
1055        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
1056
1057        // A symlink to a valid file is not a Workspace file.
1058        let link = store.path().join("link.json");
1059        std::os::unix::fs::symlink(&file, &link).unwrap();
1060        assert!(load(&link).unwrap_err().contains("symlink"));
1061
1062        // A world-writable directory on the path (without sticky bit).
1063        std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o777)).unwrap();
1064        assert!(load(&file).unwrap_err().contains("sticky"));
1065        std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
1066        assert!(load(&file).is_ok());
1067    }
1068
1069    /// Multi-UID rules, checked as pure functions (changing owners needs
1070    /// root): a file owned by another user is refused even when private, a
1071    /// directory owned by root or this user is fine, a foreign directory is not.
1072    #[test]
1073    fn foreign_owners_are_refused() {
1074        assert!(check_file_owner_mode(1000, 0o100600, 1000).is_ok());
1075        assert!(
1076            check_file_owner_mode(1001, 0o100600, 1000)
1077                .unwrap_err()
1078                .contains("uid 1001")
1079        );
1080        assert!(
1081            check_file_owner_mode(0, 0o100600, 1000).is_err(),
1082            "root-owned file is foreign"
1083        );
1084        assert!(check_file_owner_mode(1000, 0o100640, 1000).is_err());
1085        assert!(
1086            check_dir_owner_mode(0, 0o41777, 1000).is_ok(),
1087            "sticky /tmp"
1088        );
1089        assert!(check_dir_owner_mode(0, 0o40755, 1000).is_ok());
1090        assert!(check_dir_owner_mode(1000, 0o40700, 1000).is_ok());
1091        assert!(check_dir_owner_mode(1001, 0o40700, 1000).is_err());
1092        assert!(check_dir_owner_mode(1000, 0o40775, 1000).is_err());
1093    }
1094
1095    #[cfg(unix)]
1096    #[test]
1097    fn a_root_replaced_by_a_symlink_stops_granting() {
1098        let store = private_dir();
1099        let code = tempfile::tempdir().unwrap();
1100        let primary = project(code.path(), "app");
1101        let lib = project(code.path(), "lib");
1102        let elsewhere = project(code.path(), "secret");
1103        let file = store.path().join("ws.json");
1104        create(&file, "w", &primary).unwrap();
1105        add_root(&file, &lib, Access::ReadWrite, 1).unwrap();
1106
1107        std::fs::remove_dir(&lib).unwrap();
1108        std::os::unix::fs::symlink(&elsewhere, &lib).unwrap();
1109        let snapshot = load(&file).unwrap();
1110        assert!(snapshot.roots.is_empty());
1111        assert_eq!(
1112            snapshot.unavailable,
1113            vec![lib.to_string_lossy().into_owned()]
1114        );
1115        assert!(!snapshot.admits(&elsewhere.join("key")));
1116        // The stale entry can still be detached.
1117        assert_eq!(remove_root(&file, &lib, 2).unwrap().revision, 3);
1118    }
1119
1120    #[test]
1121    fn concurrent_writers_serialize_and_one_wins() {
1122        let store = private_dir();
1123        let code = tempfile::tempdir().unwrap();
1124        let primary = project(code.path(), "app");
1125        let roots: Vec<PathBuf> = (0..8)
1126            .map(|i| project(code.path(), &format!("r{i}")))
1127            .collect();
1128        let file = store.path().join("ws.json");
1129        create(&file, "w", &primary).unwrap();
1130        let wins: Vec<bool> = std::thread::scope(|scope| {
1131            let handles: Vec<_> = roots
1132                .iter()
1133                .map(|root| {
1134                    let file = file.clone();
1135                    scope.spawn(move || add_root(&file, root, Access::ReadWrite, 1).is_ok())
1136                })
1137                .collect();
1138            handles.into_iter().map(|h| h.join().unwrap()).collect()
1139        });
1140        assert_eq!(
1141            wins.iter().filter(|w| **w).count(),
1142            1,
1143            "exactly one CAS succeeds"
1144        );
1145        let snapshot = load(&file).unwrap();
1146        assert_eq!(snapshot.revision, 2);
1147        assert_eq!(snapshot.roots.len(), 1);
1148    }
1149
1150    #[test]
1151    fn request_scope_pins_one_revision() {
1152        let snapshot = Arc::new(Snapshot {
1153            file: PathBuf::from("/w.json"),
1154            id: "w".into(),
1155            revision: 7,
1156            primary: PathBuf::from("/work/app"),
1157            roots: Vec::new(),
1158            unavailable: Vec::new(),
1159        });
1160        let seen = sync_scope(Authority::Workspace(Arc::clone(&snapshot)), || {
1161            current().snapshot().map(|s| s.revision)
1162        });
1163        assert_eq!(seen, Some(7));
1164        assert_eq!(snapshot.project_root_for("/elsewhere"), "/work/app");
1165    }
1166}